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