feat: enhance frontend tag operations with UI notifications.

This commit is contained in:
2025-12-10 00:01:33 +01:00
parent 1d8d55144f
commit 82dcd6d33c
4 changed files with 89 additions and 90 deletions
@@ -6,8 +6,6 @@ import {
createTag, createTag,
deleteDocumentTag, deleteDocumentTag,
} from '../../lib/api/apiClient'; } from '../../lib/api/apiClient';
import useNotifyApiError from '../../hooks/useNotifyApiError';
import { useStatusToast } from '../../lib/context/StatusToastContext';
import type { TagsState, DocumentsState } from '../types/workspaceTypes'; import type { TagsState, DocumentsState } from '../types/workspaceTypes';
interface DocumentTagExtras { interface DocumentTagExtras {
@@ -24,51 +22,36 @@ export const useDocumentTagMutations = ({
tagsState, tagsState,
documentsState, documentsState,
}: UseDocumentTagMutationsArgs) => { }: UseDocumentTagMutationsArgs) => {
const { showToast } = useStatusToast(); // Note: Toasts are handled by the caller, e.g. useDetailWorkspace or ResultQueue.
const notifyApiError = useNotifyApiError();
const attachTagToDocument = useCallback( const attachTagToDocument = useCallback(
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;
} }
try { await addDocumentTags(documentId, [tag.id]);
await addDocumentTags(documentId, [tag.id]); documentsState.documentsManager.map((doc) => {
documentsState.documentsManager.map((doc) => { if (doc.id !== documentId) {
if (doc.id !== documentId) { return undefined;
return undefined;
}
const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
if (currentTags.includes(tag.id)) {
return doc;
}
return { ...doc, tags: [...currentTags, tag.id] };
});
if (!silent) {
showToast('Tag assigned.', 'success');
} }
return true; const currentTags = Array.isArray(doc.tags) ? doc.tags : [];
} catch (error) {
if (!silent) { if (currentTags.includes(tag.id)) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to assign tag.'; return doc;
notifyApiError(error, message);
} }
return false; return { ...doc, tags: [...currentTags, tag.id] };
} });
return true;
}, },
[documentsState],
[notifyApiError, showToast, documentsState],
); );
const handleDocumentTagAdd = useCallback( const handleDocumentTagAdd = useCallback(
@@ -87,31 +70,29 @@ export const useDocumentTagMutations = ({
const knownTags = Array.from(tagsState.tagLookupById.values()); const knownTags = Array.from(tagsState.tagLookupById.values());
tag = knownTags.find((item) => item.label?.toLowerCase() === normalizedLabel.toLowerCase()) || null; tag = knownTags.find((item) => item.label?.toLowerCase() === normalizedLabel.toLowerCase()) || null;
} }
try {
if (!tag) { // Create tag if needed. Errors bubble up.
const payload = tagsState.tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null }; if (!tag) {
const data = await createTag(payload); const payload = tagsState.tagManager.buildPayload({ label: normalizedLabel }) as { label: string; color?: string | null };
tag = data as Tag; const data = await createTag(payload);
// Ingest new tag into manager to ensure it's available tag = data as Tag;
tagsState.tagManager.ingest([tag]); // Ingest new tag into manager to ensure it's available
await tagsState.refreshTags(); tagsState.tagManager.ingest([tag]);
} await tagsState.refreshTags();
await attachTagToDocument({ }
documentId: document.id as DocumentId, await attachTagToDocument({
tag, documentId: document.id as DocumentId,
}); tag,
if (input && typeof input === 'object' && 'value' in input) { });
(input as { value?: string }).value = ''; if (input && typeof input === 'object' && 'value' in input) {
} (input as { value?: string }).value = '';
} catch (error) {
notifyApiError(error, 'Failed to assign tag.');
} }
}, },
[tagsState, attachTagToDocument, notifyApiError], [tagsState, attachTagToDocument],
); );
const handleDocumentTagAttach = useCallback( const handleDocumentTagAttach = useCallback(
async (documentId: DocumentId, tagId: DocumentId, options: { silent?: boolean } = {}) => { async (documentId: DocumentId, tagId: DocumentId) => {
if (!documentId || !tagId) { if (!documentId || !tagId) {
return false; return false;
} }
@@ -129,7 +110,6 @@ export const useDocumentTagMutations = ({
return attachTagToDocument({ return attachTagToDocument({
documentId, documentId,
tag: resolvedTag, tag: resolvedTag,
silent: options.silent,
}); });
}, },
[ [
@@ -139,41 +119,30 @@ export const useDocumentTagMutations = ({
); );
const handleDocumentTagDetach = useCallback( const handleDocumentTagDetach = useCallback(
async (documentId?: DocumentId, tagId?: DocumentId, options: { silent?: boolean } = {}) => { async (documentId?: DocumentId, tagId?: DocumentId) => {
if (!documentId || !tagId) { if (!documentId || !tagId) {
return false; return false;
} }
try { await deleteDocumentTag(documentId, tagId);
await deleteDocumentTag(documentId, tagId); // Inlined applyTagRemovalToCaches logic
// Inlined applyTagRemovalToCaches logic documentsState.documentsManager.map((doc) => {
documentsState.documentsManager.map((doc) => { if (doc.id !== documentId) {
if (doc.id !== documentId) { return undefined;
return undefined;
}
if (!doc || !Array.isArray(doc.tags)) {
return doc;
}
// Filter IDs
const nextTags = doc.tags.filter((id) => id !== tagId);
if (nextTags.length === doc.tags.length) {
return doc;
}
return { ...doc, tags: nextTags };
});
if (!options.silent) {
showToast('Tag removed.', 'success');
} }
return true; if (!doc || !Array.isArray(doc.tags)) {
} catch (error) { return doc;
if (!options.silent) {
const message = (error as Record<string, any>)?.response?.data?.error || 'Failed to remove tag.';
notifyApiError(error, message);
} }
return false; // Filter IDs
} const nextTags = doc.tags.filter((id) => id !== tagId);
if (nextTags.length === doc.tags.length) {
return doc;
}
return { ...doc, tags: nextTags };
});
return true;
}, },
[documentsState, notifyApiError, showToast], [documentsState],
); );
return { return {
@@ -47,8 +47,8 @@ const cleanupPreview = (previewNode: HTMLElement | null) => {
}; };
interface UseTagInteractionsArgs { interface UseTagInteractionsArgs {
onAssignTagToDocument?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => Promise<boolean> | void; onAssignTagToDocument?: (docId: Identifier, tagId: Identifier) => Promise<boolean> | void;
onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => Promise<boolean> | void; onRemoveTagFromDocument?: (docId: Identifier, tagId: Identifier) => Promise<boolean> | void;
onTagClick?: (tagId: Identifier) => void; onTagClick?: (tagId: Identifier) => void;
} }
@@ -164,7 +164,7 @@ export const useTagInteractions = ({
// Queue Result Logic // Queue Result Logic
beginAction(); beginAction();
try { try {
await onAssignTagToDocument(doc.id, payload.id, { silent: true }); await onAssignTagToDocument(doc.id, payload.id);
finishAction({ type: 'attach', success: true }); finishAction({ type: 'attach', success: true });
} catch { } catch {
finishAction({ type: 'attach', success: false }); finishAction({ type: 'attach', success: false });
@@ -226,7 +226,7 @@ export const useTagInteractions = ({
if (onRemoveTagFromDocument && sourceDocId && tagId) { if (onRemoveTagFromDocument && sourceDocId && tagId) {
beginAction(); beginAction();
try { try {
await onRemoveTagFromDocument(sourceDocId, tagId, { silent: true }); await onRemoveTagFromDocument(sourceDocId, tagId);
finishAction({ type: 'detach', success: true }); finishAction({ type: 'detach', success: true });
} catch { } catch {
finishAction({ type: 'detach', success: false }); 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, options?: { silent?: boolean }) => void; handleDocumentTagAttach?: (docId: Identifier, tagId: Identifier) => void;
handleDocumentTagDetach?: (docId: Identifier, tagId: Identifier, options?: { silent?: boolean }) => void; handleDocumentTagDetach?: (docId: Identifier, tagId: Identifier) => void;
documentsViewMode?: string; documentsViewMode?: string;
documentsSortField?: string; documentsSortField?: string;
documentsSortDirection?: string; documentsSortDirection?: string;
@@ -8,6 +8,9 @@ import type { Identifier } from '../../types/identifiers';
import type { Document } from '../../types/documents'; import type { Document } from '../../types/documents';
import { resolveBreadcrumbs } from '../../documents/logic/breadcrumbs'; import { resolveBreadcrumbs } from '../../documents/logic/breadcrumbs';
import type { Tag, Correspondent } from '../../types/documents'; import type { Tag, Correspondent } from '../../types/documents';
import { listFolderContents } from '../../lib/api/apiClient';
import { useStatusToast } from '../../lib/context/StatusToastContext';
import useNotifyApiError from '../../hooks/useNotifyApiError';
interface FolderNode { interface FolderNode {
id: Identifier | 'root'; id: Identifier | 'root';
@@ -52,8 +55,6 @@ interface UseDetailWorkspaceResult {
resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>; resolveFolderPath: (folderId?: Identifier | 'root') => Array<{ id: Identifier | 'root'; name: string }>;
} }
import { listFolderContents } from '../../lib/api/apiClient';
const useDetailWorkspace = ({ const useDetailWorkspace = ({
documents, documents,
documentLookup, documentLookup,
@@ -77,6 +78,9 @@ const useDetailWorkspace = ({
tagLookupById, tagLookupById,
correspondentLookupById, correspondentLookupById,
}: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => { }: UseDetailWorkspaceArgs): UseDetailWorkspaceResult => {
const { showToast } = useStatusToast();
const notifyApiError = useNotifyApiError();
const { const {
detailPanelOpen, detailPanelOpen,
detailPanelDocument, detailPanelDocument,
@@ -181,13 +185,39 @@ const useDetailWorkspace = ({
closeDetailPanel(); closeDetailPanel();
}, [closeDetailPanel]); }, [closeDetailPanel]);
const handleDetailTagAdd = useCallback(
async (doc: Document, value: string, context?: { option?: unknown }) => {
if (!handleDocumentTagAdd) return;
try {
await handleDocumentTagAdd(doc, value, context);
showToast('Tag assigned.', 'success');
} catch (error) {
notifyApiError(error, 'Failed to assign tag.');
}
},
[handleDocumentTagAdd, showToast, notifyApiError],
);
const handleDetailTagRemove = useCallback(
async (docId: any, tagId: any) => {
if (!handleDocumentTagDetach) return;
try {
await handleDocumentTagDetach(docId, tagId);
showToast('Tag removed.', 'success');
} catch (error) {
notifyApiError(error, 'Failed to remove tag.');
}
},
[handleDocumentTagDetach, showToast, notifyApiError],
);
const detailPanelProps = { const detailPanelProps = {
document: detailPanelDocument, document: detailPanelDocument,
tags, tags,
tagLookupById, tagLookupById,
correspondentLookupById, correspondentLookupById,
onTagAdd: handleDocumentTagAdd, onTagAdd: handleDetailTagAdd,
onTagRemove: handleDocumentTagDetach, onTagRemove: handleDetailTagRemove,
onOpenPreview: openDocumentPreview, onOpenPreview: openDocumentPreview,
activePreviewId, activePreviewId,
onUpdateTitle: handleDocumentTitleUpdate, onUpdateTitle: handleDocumentTitleUpdate,