feat: Implement asynchronous tag operations with user feedback

This commit is contained in:
2025-12-09 23:13:18 +01:00
parent 3ccc1f6698
commit 1d8d55144f
5 changed files with 123 additions and 31 deletions
@@ -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] };
}); });
if (!silent) {
showToast('Tag assigned.', 'success'); showToast('Tag assigned.', 'success');
}
return true; return true;
} catch (error) { } catch (error) {
if (!silent) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.'; const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.';
notifyApiError(error, message); 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 };
}); });
if (!options.silent) {
showToast('Tag removed.', 'success'); showToast('Tag removed.', 'success');
}
return true; return true;
} catch (error) { } catch (error) {
if (!options.silent) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.'; const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
notifyApiError(error, message); 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)) {
return;
}
preventAll(event);
event.currentTarget.classList.remove('is-tag-target');
const payload = parseTagTransferPayload(event); event.preventDefault();
if (payload && payload.sourceDocId === doc.id) { event.stopPropagation();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'none';
const isTagTransfer = isTagTransferEvent(event);
if (!isTagTransfer) {
return; return;
} }
setTimeout(() => { const payload = parseTagTransferPayload(event);
const element = event.currentTarget as HTMLElement;
element.classList.remove('is-tag-target');
if (!payload || !payload.id) { if (!payload || !payload.id) {
return; return;
} }
setTimeout(async () => {
// 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,