feat: enhance frontend tag operations with UI notifications.
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user