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