feat: Implement asynchronous tag operations with user feedback
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user