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 ({
documentId,
tag,
silent = false,
}: {
documentId?: DocumentId;
tag?: Tag | null;
silent?: boolean;
}) => {
if (!documentId || !tag?.id) {
return false;
@@ -52,14 +54,20 @@ export const useDocumentTagMutations = ({
}
return { ...doc, tags: [...currentTags, tag.id] };
});
showToast('Tag assigned.', 'success');
if (!silent) {
showToast('Tag assigned.', 'success');
}
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.';
notifyApiError(error, message);
if (!silent) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.';
notifyApiError(error, message);
}
return false;
}
},
[notifyApiError, showToast, documentsState],
);
@@ -103,7 +111,7 @@ export const useDocumentTagMutations = ({
);
const handleDocumentTagAttach = useCallback(
async (documentId: DocumentId, tagId: DocumentId) => {
async (documentId: DocumentId, tagId: DocumentId, options: { silent?: boolean } = {}) => {
if (!documentId || !tagId) {
return false;
}
@@ -121,6 +129,7 @@ export const useDocumentTagMutations = ({
return attachTagToDocument({
documentId,
tag: resolvedTag,
silent: options.silent,
});
},
[
@@ -130,7 +139,7 @@ export const useDocumentTagMutations = ({
);
const handleDocumentTagDetach = useCallback(
async (documentId?: DocumentId, tagId?: DocumentId) => {
async (documentId?: DocumentId, tagId?: DocumentId, options: { silent?: boolean } = {}) => {
if (!documentId || !tagId) {
return false;
}
@@ -152,11 +161,15 @@ export const useDocumentTagMutations = ({
}
return { ...doc, tags: nextTags };
});
showToast('Tag removed.', 'success');
if (!options.silent) {
showToast('Tag removed.', 'success');
}
return true;
} catch (error) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
notifyApiError(error, message);
if (!options.silent) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
notifyApiError(error, message);
}
return false;
}
},
@@ -44,10 +44,27 @@ interface ActiveDragState {
}
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>();
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 => {
listeners.add(callback);
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 = () => {
listeners.forEach((cb) => cb(activeDragState));
};
@@ -10,6 +10,8 @@ import {
writeTagTransferData,
getActiveDragState,
clearTagTransferData,
beginAction,
finishAction,
} from '../../documents/features/tagging/tagTransfer';
import type { Identifier } from '../../types/identifiers';
import type { Document, Tag } from '../../types/documents';
@@ -45,8 +47,8 @@ const cleanupPreview = (previewNode: HTMLElement | null) => {
};
interface UseTagInteractionsArgs {
onAssignTagToDocument?: (docId: Identifier, tagId: Identifier) => void;
onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier) => void;
onAssignTagToDocument?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => Promise<boolean> | void;
onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => Promise<boolean> | void;
onTagClick?: (tagId: Identifier) => void;
}
@@ -132,37 +134,45 @@ export const useTagInteractions = ({
);
const onTagDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>, doc: Document) => {
if (!doc || !doc.id) {
(event: React.DragEvent<HTMLElement>, doc: Document) => {
if (!event?.dataTransfer || !doc?.id) {
return;
}
if (!isTagTransfer(event)) {
event.preventDefault();
event.stopPropagation();
const isTagTransfer = isTagTransferEvent(event);
if (!isTagTransfer) {
return;
}
preventAll(event);
event.currentTarget.classList.remove('is-tag-target');
const payload = parseTagTransferPayload(event);
if (payload && payload.sourceDocId === doc.id) {
if (event.dataTransfer) event.dataTransfer.dropEffect = 'none';
const element = event.currentTarget as HTMLElement;
element.classList.remove('is-tag-target');
if (!payload || !payload.id) {
return;
}
setTimeout(() => {
if (!payload || !payload.id) {
return;
}
setTimeout(async () => {
// Double-check assignment (even though cursor logic tries to prevent it)
const isAssigned = doc.tags?.some((t) => t === payload.id);
if (isAssigned) return;
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);
},
[isTagTransfer, onAssignTagToDocument],
[onAssignTagToDocument],
);
const onTagDragStart = useCallback(
@@ -198,11 +208,11 @@ export const useTagInteractions = ({
(event: React.DragEvent<HTMLElement>) => {
event.stopPropagation();
const { sourceDocId, tagId } = getActiveDragState();
clearTagTransferData();
const dropEffect = event?.dataTransfer?.dropEffect;
setTimeout(() => {
clearTagTransferData();
setTimeout(async () => {
const state = draggingTagRef.current;
if (state) {
const element = state.element;
@@ -214,7 +224,13 @@ export const useTagInteractions = ({
// Remove if move operation completed
if (dropEffect === 'move') {
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[];
ensureAssetUrl?: (...args: unknown[]) => void;
getDocumentAsset?: (...args: unknown[]) => unknown;
handleDocumentTagAttach?: (docId: Identifier, tagId: Identifier) => void;
handleDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
handleDocumentTagAttach?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => void;
handleDocumentTagDetach?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => void;
documentsViewMode?: string;
documentsSortField?: 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 { isPointerModifierEvent, isPrimaryPointerEvent } from '../features/selection/useEntryPointer';
import { useDocumentsFilter } from '../context/DocumentsFilterContext';
import { useWorkspaceSelectionContext } from '../../app/WorkspaceSelectionContext';
import type { Identifier } from '../../types/identifiers';
import { useStatusToast } from '../../lib/context/StatusToastContext';
import { useTagInteractions } from '../interactions/useTagInteractions';
import { subscribeToToast } from '../features/tagging/tagTransfer';
const EntryType = {
folder: 'folder',
@@ -40,6 +42,15 @@ export const useDocumentsContextValues = (props: DocumentsPanelInnerProps) => {
const scrollRef = useRef<HTMLElement | null>(null);
const suppressDocumentClickRef = useRef(false);
const { showToast } = useStatusToast();
// Subscribe to tag operation results
useEffect(() => {
return subscribeToToast((message, type) => {
showToast(message, type);
});
}, [showToast]);
// Handlers
const tagHandlers = useTagInteractions({
onAssignTagToDocument: props.onDocumentTagAttach,