feat: Implement asynchronous tag operations with user feedback
This commit is contained in:
@@ -31,9 +31,11 @@ export const useDocumentTagMutations = ({
|
|||||||
async ({
|
async ({
|
||||||
documentId,
|
documentId,
|
||||||
tag,
|
tag,
|
||||||
|
silent = false,
|
||||||
}: {
|
}: {
|
||||||
documentId?: DocumentId;
|
documentId?: DocumentId;
|
||||||
tag?: Tag | null;
|
tag?: Tag | null;
|
||||||
|
silent?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
if (!documentId || !tag?.id) {
|
if (!documentId || !tag?.id) {
|
||||||
return false;
|
return false;
|
||||||
@@ -52,14 +54,20 @@ export const useDocumentTagMutations = ({
|
|||||||
}
|
}
|
||||||
return { ...doc, tags: [...currentTags, tag.id] };
|
return { ...doc, tags: [...currentTags, tag.id] };
|
||||||
});
|
});
|
||||||
showToast('Tag assigned.', 'success');
|
|
||||||
|
if (!silent) {
|
||||||
|
showToast('Tag assigned.', 'success');
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.';
|
if (!silent) {
|
||||||
notifyApiError(error, message);
|
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
[notifyApiError, showToast, documentsState],
|
[notifyApiError, showToast, documentsState],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -103,7 +111,7 @@ export const useDocumentTagMutations = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentTagAttach = useCallback(
|
const handleDocumentTagAttach = useCallback(
|
||||||
async (documentId: DocumentId, tagId: DocumentId) => {
|
async (documentId: DocumentId, tagId: DocumentId, options: { silent?: boolean } = {}) => {
|
||||||
if (!documentId || !tagId) {
|
if (!documentId || !tagId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -121,6 +129,7 @@ export const useDocumentTagMutations = ({
|
|||||||
return attachTagToDocument({
|
return attachTagToDocument({
|
||||||
documentId,
|
documentId,
|
||||||
tag: resolvedTag,
|
tag: resolvedTag,
|
||||||
|
silent: options.silent,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -130,7 +139,7 @@ export const useDocumentTagMutations = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleDocumentTagDetach = useCallback(
|
const handleDocumentTagDetach = useCallback(
|
||||||
async (documentId?: DocumentId, tagId?: DocumentId) => {
|
async (documentId?: DocumentId, tagId?: DocumentId, options: { silent?: boolean } = {}) => {
|
||||||
if (!documentId || !tagId) {
|
if (!documentId || !tagId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -152,11 +161,15 @@ export const useDocumentTagMutations = ({
|
|||||||
}
|
}
|
||||||
return { ...doc, tags: nextTags };
|
return { ...doc, tags: nextTags };
|
||||||
});
|
});
|
||||||
showToast('Tag removed.', 'success');
|
if (!options.silent) {
|
||||||
|
showToast('Tag removed.', 'success');
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
|
if (!options.silent) {
|
||||||
notifyApiError(error, message);
|
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
|
||||||
|
notifyApiError(error, message);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,10 +44,27 @@ interface ActiveDragState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let activeDragState: ActiveDragState = { tagId: null, sourceDocId: null };
|
let activeDragState: ActiveDragState = { tagId: null, sourceDocId: null };
|
||||||
|
interface ActionResult {
|
||||||
|
type: 'attach' | 'detach';
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let pendingActions = 0;
|
||||||
|
let actionResults: ActionResult[] = [];
|
||||||
|
let toastListener: ((message: string, type: 'success' | 'error' | 'info') => void) | null = null;
|
||||||
|
|
||||||
const listeners = new Set<(state: ActiveDragState) => void>();
|
const listeners = new Set<(state: ActiveDragState) => void>();
|
||||||
|
|
||||||
export const getActiveDragState = (): ActiveDragState => activeDragState;
|
export const getActiveDragState = (): ActiveDragState => activeDragState;
|
||||||
|
|
||||||
|
export const subscribeToToast = (callback: (message: string, type: 'success' | 'error' | 'info') => void): () => void => {
|
||||||
|
toastListener = callback;
|
||||||
|
return () => {
|
||||||
|
toastListener = null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const subscribeToTagDrag = (callback: (state: ActiveDragState) => void): () => void => {
|
export const subscribeToTagDrag = (callback: (state: ActiveDragState) => void): () => void => {
|
||||||
listeners.add(callback);
|
listeners.add(callback);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -55,6 +72,41 @@ export const subscribeToTagDrag = (callback: (state: ActiveDragState) => void):
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const processResults = () => {
|
||||||
|
if (pendingActions > 0) return;
|
||||||
|
if (actionResults.length === 0) return;
|
||||||
|
|
||||||
|
const successes = actionResults.filter(r => r.success);
|
||||||
|
const attached = successes.find(r => r.type === 'attach');
|
||||||
|
const detached = successes.find(r => r.type === 'detach');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (attached && detached) {
|
||||||
|
toastListener?.('Tag moved.', 'success');
|
||||||
|
} else if (attached) {
|
||||||
|
toastListener?.('Tag assigned.', 'success');
|
||||||
|
} else if (detached) {
|
||||||
|
toastListener?.('Tag removed.', 'success');
|
||||||
|
} else if (actionResults.some(r => !r.success)) {
|
||||||
|
// If we only had failures, or partial failures
|
||||||
|
toastListener?.('Action failed.', 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
actionResults = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const beginAction = (): void => {
|
||||||
|
pendingActions++;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const finishAction = (result: ActionResult): void => {
|
||||||
|
actionResults.push(result);
|
||||||
|
pendingActions--;
|
||||||
|
// Use timeout to allow batching if multiple actions finish closely or sequence gaps
|
||||||
|
setTimeout(processResults, 50);
|
||||||
|
};
|
||||||
|
|
||||||
const notifyListeners = () => {
|
const notifyListeners = () => {
|
||||||
listeners.forEach((cb) => cb(activeDragState));
|
listeners.forEach((cb) => cb(activeDragState));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
writeTagTransferData,
|
writeTagTransferData,
|
||||||
getActiveDragState,
|
getActiveDragState,
|
||||||
clearTagTransferData,
|
clearTagTransferData,
|
||||||
|
beginAction,
|
||||||
|
finishAction,
|
||||||
} from '../../documents/features/tagging/tagTransfer';
|
} from '../../documents/features/tagging/tagTransfer';
|
||||||
import type { Identifier } from '../../types/identifiers';
|
import type { Identifier } from '../../types/identifiers';
|
||||||
import type { Document, Tag } from '../../types/documents';
|
import type { Document, Tag } from '../../types/documents';
|
||||||
@@ -45,8 +47,8 @@ const cleanupPreview = (previewNode: HTMLElement | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface UseTagInteractionsArgs {
|
interface UseTagInteractionsArgs {
|
||||||
onAssignTagToDocument?: (docId: Identifier, tagId: Identifier) => void;
|
onAssignTagToDocument?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => Promise<boolean> | void;
|
||||||
onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier) => void;
|
onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => Promise<boolean> | void;
|
||||||
onTagClick?: (tagId: Identifier) => void;
|
onTagClick?: (tagId: Identifier) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,37 +134,45 @@ export const useTagInteractions = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onTagDrop = useCallback(
|
const onTagDrop = useCallback(
|
||||||
(event: React.DragEvent<HTMLDivElement>, doc: Document) => {
|
(event: React.DragEvent<HTMLElement>, doc: Document) => {
|
||||||
if (!doc || !doc.id) {
|
if (!event?.dataTransfer || !doc?.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isTagTransfer(event)) {
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const isTagTransfer = isTagTransferEvent(event);
|
||||||
|
if (!isTagTransfer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
preventAll(event);
|
|
||||||
event.currentTarget.classList.remove('is-tag-target');
|
|
||||||
|
|
||||||
const payload = parseTagTransferPayload(event);
|
const payload = parseTagTransferPayload(event);
|
||||||
if (payload && payload.sourceDocId === doc.id) {
|
const element = event.currentTarget as HTMLElement;
|
||||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'none';
|
element.classList.remove('is-tag-target');
|
||||||
|
|
||||||
|
if (!payload || !payload.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
if (!payload || !payload.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Double-check assignment (even though cursor logic tries to prevent it)
|
// Double-check assignment (even though cursor logic tries to prevent it)
|
||||||
const isAssigned = doc.tags?.some((t) => t === payload.id);
|
const isAssigned = doc.tags?.some((t) => t === payload.id);
|
||||||
if (isAssigned) return;
|
if (isAssigned) return;
|
||||||
|
|
||||||
if (onAssignTagToDocument && doc.id) {
|
if (onAssignTagToDocument && doc.id) {
|
||||||
onAssignTagToDocument(doc.id, payload.id);
|
// Queue Result Logic
|
||||||
|
beginAction();
|
||||||
|
try {
|
||||||
|
await onAssignTagToDocument(doc.id, payload.id, { silent: true });
|
||||||
|
finishAction({ type: 'attach', success: true });
|
||||||
|
} catch {
|
||||||
|
finishAction({ type: 'attach', success: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
},
|
},
|
||||||
[isTagTransfer, onAssignTagToDocument],
|
[onAssignTagToDocument],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onTagDragStart = useCallback(
|
const onTagDragStart = useCallback(
|
||||||
@@ -198,11 +208,11 @@ export const useTagInteractions = ({
|
|||||||
(event: React.DragEvent<HTMLElement>) => {
|
(event: React.DragEvent<HTMLElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const { sourceDocId, tagId } = getActiveDragState();
|
const { sourceDocId, tagId } = getActiveDragState();
|
||||||
clearTagTransferData();
|
|
||||||
|
|
||||||
const dropEffect = event?.dataTransfer?.dropEffect;
|
const dropEffect = event?.dataTransfer?.dropEffect;
|
||||||
|
|
||||||
setTimeout(() => {
|
clearTagTransferData();
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
const state = draggingTagRef.current;
|
const state = draggingTagRef.current;
|
||||||
if (state) {
|
if (state) {
|
||||||
const element = state.element;
|
const element = state.element;
|
||||||
@@ -214,7 +224,13 @@ export const useTagInteractions = ({
|
|||||||
// Remove if move operation completed
|
// Remove if move operation completed
|
||||||
if (dropEffect === 'move') {
|
if (dropEffect === 'move') {
|
||||||
if (onRemoveTagFromDocument && sourceDocId && tagId) {
|
if (onRemoveTagFromDocument && sourceDocId && tagId) {
|
||||||
onRemoveTagFromDocument(sourceDocId, tagId);
|
beginAction();
|
||||||
|
try {
|
||||||
|
await onRemoveTagFromDocument(sourceDocId, tagId, { silent: true });
|
||||||
|
finishAction({ type: 'detach', success: true });
|
||||||
|
} catch {
|
||||||
|
finishAction({ type: 'detach', success: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ interface UseDocumentsPanelPropsArgs {
|
|||||||
activeCorrespondentFilters?: Identifier[];
|
activeCorrespondentFilters?: Identifier[];
|
||||||
ensureAssetUrl?: (...args: unknown[]) => void;
|
ensureAssetUrl?: (...args: unknown[]) => void;
|
||||||
getDocumentAsset?: (...args: unknown[]) => unknown;
|
getDocumentAsset?: (...args: unknown[]) => unknown;
|
||||||
handleDocumentTagAttach?: (docId: Identifier, tagId: Identifier) => void;
|
handleDocumentTagAttach?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => void;
|
||||||
handleDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
|
handleDocumentTagDetach?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => void;
|
||||||
documentsViewMode?: string;
|
documentsViewMode?: string;
|
||||||
documentsSortField?: string;
|
documentsSortField?: string;
|
||||||
documentsSortDirection?: string;
|
documentsSortDirection?: string;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useMemo, useCallback, useRef } from 'react';
|
import { useMemo, useCallback, useRef, useEffect } from 'react';
|
||||||
import type { DocumentsPanelInnerProps } from './DocumentsPanel';
|
import type { DocumentsPanelInnerProps } from './DocumentsPanel';
|
||||||
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../features/selection/useEntryPointer';
|
import { isPointerModifierEvent, isPrimaryPointerEvent } from '../features/selection/useEntryPointer';
|
||||||
import { useDocumentsFilter } from '../context/DocumentsFilterContext';
|
import { useDocumentsFilter } from '../context/DocumentsFilterContext';
|
||||||
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
|
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
|
||||||
import type { Identifier } from '../../types/identifiers';
|
import type { Identifier } from '../../types/identifiers';
|
||||||
|
import { useStatusToast } from '../../lib/context/StatusToastContext';
|
||||||
import { useTagInteractions } from '../interactions/useTagInteractions';
|
import { useTagInteractions } from '../interactions/useTagInteractions';
|
||||||
|
import { subscribeToToast } from '../features/tagging/tagTransfer';
|
||||||
|
|
||||||
const EntryType = {
|
const EntryType = {
|
||||||
folder: 'folder',
|
folder: 'folder',
|
||||||
@@ -40,6 +42,15 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
|
|||||||
const scrollRef = useRef<HTMLElement | null>(null);
|
const scrollRef = useRef<HTMLElement | null>(null);
|
||||||
const suppressDocumentClickRef = useRef(false);
|
const suppressDocumentClickRef = useRef(false);
|
||||||
|
|
||||||
|
const { showToast } = useStatusToast();
|
||||||
|
|
||||||
|
// Subscribe to tag operation results
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeToToast((message, type) => {
|
||||||
|
showToast(message, type);
|
||||||
|
});
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const tagHandlers = useTagInteractions({
|
const tagHandlers = useTagInteractions({
|
||||||
onAssignTagToDocument: props.onDocumentTagAttach,
|
onAssignTagToDocument: props.onDocumentTagAttach,
|
||||||
|
|||||||
Reference in New Issue
Block a user