Remove asset/document hydration pipeline and introduce client-side cache updates for correspondents, tags, and search results

This commit is contained in:
2025-11-20 02:18:33 +01:00
parent b76f1df27f
commit 0eb4c0a294
14 changed files with 316 additions and 329 deletions
+2 -31
View File
@@ -5,37 +5,21 @@ type DocumentId = string | number;
export type ManagedDocument = { id?: DocumentId | null } & Record<string, unknown>;
type FetchDocument = (id: DocumentId) => Promise<unknown>;
type HydrateDocument<T extends ManagedDocument> = (payload: unknown) => T | null;
type HydrateDocuments<T extends ManagedDocument> = (payload: unknown[]) => T[];
class DocumentsManager<T extends ManagedDocument = ManagedDocument> {
private byId: Map<DocumentId, T>;
private fetcher?: FetchDocument;
private hydrateDocument?: HydrateDocument<T>;
private hydrateDocuments?: HydrateDocuments<T>;
private extractDocument?: HydrateDocument<T>;
private inflight: Map<DocumentId, Promise<T | null>>;
private listeners: Set<() => void>;
constructor(
fetchDocument?: FetchDocument,
options?: {
hydrateDocument?: HydrateDocument<T>;
hydrateDocuments?: HydrateDocuments<T>;
extractDocument?: HydrateDocument<T>;
},
) {
this.byId = new Map();
this.fetcher = fetchDocument;
this.hydrateDocument = options?.hydrateDocument;
this.hydrateDocuments = options?.hydrateDocuments;
this.extractDocument = options?.extractDocument;
this.inflight = new Map();
this.listeners = new Set();
}
@@ -54,19 +38,7 @@ class DocumentsManager<T extends ManagedDocument = ManagedDocument> {
}
ingest(rawDocs: unknown[] = []): { canonical: T[]; changed: boolean } {
const normalize = (docs: unknown[]) => {
if (this.hydrateDocuments) {
return this.hydrateDocuments(docs).filter(Boolean) as T[];
}
if (this.hydrateDocument) {
return docs
.map((doc) => this.hydrateDocument ? this.hydrateDocument(doc) : (doc as T | null))
.filter(Boolean) as T[];
}
return docs.filter(Boolean) as T[];
};
const docs = normalize(rawDocs);
const docs = rawDocs.map((doc) => doc as T).filter(Boolean);
let changed = false;
let nextById = this.byId;
const canonical: T[] = [];
@@ -124,8 +96,7 @@ class DocumentsManager<T extends ManagedDocument = ManagedDocument> {
const request = (async () => {
try {
const fetched = await fetcher(id);
const extracted = this.extractDocument ? this.extractDocument(fetched) : fetched;
const { canonical } = this.ingest([extracted as unknown]);
const { canonical } = this.ingest([fetched as unknown]);
return canonical[0] ?? null;
} finally {
this.inflight.delete(id);
@@ -21,7 +21,6 @@ interface UseBulkDocumentActionsArgs {
resolveTargetDocumentIds: (ids?: Identifier[] | null) => Identifier[];
correspondentLookupByName: Map<string, { id?: Identifier }>;
handleCorrespondentCreate: (payload: { name: string }) => Promise<{ id?: Identifier } | null>;
refreshCurrentFolder: () => Promise<void> | void;
setStatusMessage: (message: string, variant?: string) => void;
selectedDocumentIds?: Identifier[];
selectedFolderIds?: Identifier[];
@@ -29,6 +28,7 @@ interface UseBulkDocumentActionsArgs {
handleFolderDelete: (id: Identifier, options?: { showMessage?: boolean; manageLoading?: boolean }) => Promise<boolean>;
clearDocumentSelection: () => void;
setLoading: (value: boolean) => void;
updateDocumentCaches?: (id: Identifier, updater: (doc: any) => any) => void;
}
const useBulkDocumentActions = ({
@@ -36,7 +36,6 @@ const useBulkDocumentActions = ({
resolveTargetDocumentIds,
correspondentLookupByName,
handleCorrespondentCreate,
refreshCurrentFolder,
setStatusMessage,
selectedDocumentIds,
selectedFolderIds,
@@ -44,6 +43,7 @@ const useBulkDocumentActions = ({
handleFolderDelete,
clearDocumentSelection,
setLoading,
updateDocumentCaches,
}: UseBulkDocumentActionsArgs) => {
const handleBulkCorrespondentAdd = useCallback(
async ({ name, input, documentIds }: { name?: string; input?: HTMLInputElement | null; documentIds?: Identifier[] }) => {
@@ -70,21 +70,35 @@ const useBulkDocumentActions = ({
if (!target?.id) {
setStatusMessage('Unable to resolve correspondent.', 'error');
return;
}
}
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
document_ids: targets,
assignments: [
{
correspondent_id: target.id,
},
],
action: 'add',
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
document_ids: targets,
assignments: [
{
correspondent_id: target.id,
},
],
action: 'add',
});
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
if (updateDocumentCaches && target.id) {
targets.forEach((docId) => {
updateDocumentCaches(docId, (doc) => {
if (!doc) return doc;
const current = Array.isArray((doc as any).correspondents) ? (doc as any).correspondents : [];
if (current.some((entry: any) => entry?.id === target.id)) {
return doc;
}
return {
...(doc as any),
correspondents: [...current, { id: target.id, name: (target as any).name }],
};
});
});
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
await refreshCurrentFolder();
}
const assignedSuffix = assigned === 1 ? '' : 's';
if (removed > 0) {
const removedSuffix = removed === 1 ? '' : 's';
@@ -99,19 +113,19 @@ const useBulkDocumentActions = ({
);
}
if (input) {
input.value = '';
}
},
[
api,
correspondentLookupByName,
handleCorrespondentCreate,
refreshCurrentFolder,
resolveTargetDocumentIds,
setStatusMessage,
],
);
if (input) {
input.value = '';
}
},
[
api,
correspondentLookupByName,
handleCorrespondentCreate,
resolveTargetDocumentIds,
setStatusMessage,
updateDocumentCaches,
],
);
const handleBulkCorrespondentRemove = useCallback(
async ({ assignments = [], documentIds }: { assignments?: CorrespondentAssignment[]; documentIds?: Identifier[] }) => {
@@ -131,14 +145,29 @@ const useBulkDocumentActions = ({
correspondent_id: entry.correspondent_id,
}));
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
document_ids: targets,
assignments: normalizedAssignments,
action: 'remove',
});
const response = await api.post<BulkAssignmentResponse>('/documents/bulk/correspondents', {
document_ids: targets,
assignments: normalizedAssignments,
action: 'remove',
});
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
await refreshCurrentFolder();
const { assigned = 0, removed = 0 } = 'data' in response ? response.data : response;
if (updateDocumentCaches) {
targets.forEach((docId) => {
updateDocumentCaches(docId, (doc) => {
if (!doc || !Array.isArray((doc as any).correspondents)) {
return doc;
}
const filtered = (doc as any).correspondents.filter(
(entry: any) =>
entry && !normalizedAssignments.some((assignment) => assignment.correspondent_id === entry.id),
);
return filtered.length === (doc as any).correspondents.length
? doc
: { ...(doc as any), correspondents: filtered };
});
});
}
if (removed > 0) {
const removedSuffix = removed === 1 ? '' : 's';
@@ -153,7 +182,7 @@ const useBulkDocumentActions = ({
setStatusMessage('No correspondents changed.', 'info');
}
},
[api, refreshCurrentFolder, resolveTargetDocumentIds, setStatusMessage],
[api, resolveTargetDocumentIds, setStatusMessage, updateDocumentCaches],
);
const handleDeleteSelection = useCallback(async () => {
@@ -518,11 +518,33 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
);
const gridIconSize = DEFAULT_GRID_ICON_SIZE;
useEffect(() => {
const scrollToTop = useCallback(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = 0;
}
}, [viewMode]);
}, []);
const searchKey = useMemo(
() => (Array.isArray(searchResultIds) ? searchResultIds.join(':') : 'none'),
[searchResultIds],
);
const breadcrumbKey = useMemo(
() => (Array.isArray(breadcrumbs) ? breadcrumbs.map((crumb) => crumb?.id ?? '').join(':') : 'none'),
[breadcrumbs],
);
useEffect(() => {
scrollToTop();
}, [
scrollToTop,
viewMode,
showingSearchResults,
searchKey,
breadcrumbKey,
]);
const isTagDragEvent = useCallback((event) => isTagTransferEvent(event), []);
const ensureFocusedRowVisible = useCallback(() => {
if (!focusedRowKey) return;