documentsmanager

This commit is contained in:
2025-11-19 23:36:04 +01:00
parent e1b47ed153
commit b76f1df27f
17 changed files with 564 additions and 300 deletions
+39
View File
@@ -14,6 +14,7 @@
"pdfjs-dist": "^5.4.394",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.5"
},
"devDependencies": {
@@ -3094,6 +3095,12 @@
"@types/node": "*"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -8732,6 +8739,29 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.9.5",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz",
@@ -10489,6 +10519,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+1
View File
@@ -16,6 +16,7 @@
"pdfjs-dist": "^5.4.394",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.9.5"
},
"devDependencies": {
+18 -99
View File
@@ -36,8 +36,13 @@ type NavigateHandler = (path: string, options?: { replace?: boolean }) => void;
interface UseDocumentPreviewArgs {
routeDocumentId?: DocumentId | null;
documents: DocumentLike[];
searchResults: DocumentLike[] | null;
documentsManager: {
getById: (id: DocumentId) => DocumentLike | null;
ensure: (id: DocumentId) => Promise<DocumentLike | null>;
getMany: (ids: DocumentId[]) => DocumentLike[];
subscribe: (listener: () => void) => () => void;
ingest: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean };
};
selectedFolder?: FolderId | null;
assetManager: AssetManagerLike;
api: ApiClient;
@@ -55,7 +60,6 @@ interface UseDocumentPreviewArgs {
interface UseDocumentPreviewResult {
documentLinks: Map<DocumentId, DocumentLink>;
previewDocuments: Map<DocumentId, DocumentLike>;
ensureDownloadUrl: (documentId: DocumentId, options?: { force?: boolean }) => Promise<DocumentLink | null>;
ensurePreviewData: (documentId: DocumentId) => Promise<DocumentLike | null>;
openDocumentPreview: (documentId: DocumentId, options?: { replace?: boolean }) => void;
@@ -66,10 +70,9 @@ interface UseDocumentPreviewResult {
const useDocumentPreview = ({
routeDocumentId,
documents,
searchResults,
documentsManager,
selectedFolder,
assetManager,
assetManager: _assetManager,
api,
resolveApiPath,
notifyApiError,
@@ -80,7 +83,6 @@ const useDocumentPreview = ({
setActivePreviewId,
}: UseDocumentPreviewArgs): UseDocumentPreviewResult => {
const [documentLinks, setDocumentLinks] = useState<Map<DocumentId, DocumentLink>>(() => new Map());
const [previewDocuments, setPreviewDocuments] = useState<Map<DocumentId, DocumentLike>>(() => new Map());
const previewInflightRef = useRef<Map<DocumentId, Promise<DocumentLink | null>>>(new Map());
const previewReturnPathRef = useRef<string | null>(null);
@@ -110,35 +112,6 @@ const useDocumentPreview = ({
});
}, []);
const cachePreviewDocument = useCallback((doc: DocumentLike) => {
if (!doc?.id) {
return;
}
setPreviewDocuments((prev) => {
const existing = prev.get(doc.id);
if (existing === doc) {
return prev;
}
const next = new Map(prev);
next.set(doc.id, doc);
return next;
});
}, []);
const removeCachedPreviewDocument = useCallback((documentId: DocumentId) => {
if (!documentId) {
return;
}
setPreviewDocuments((prev) => {
if (!prev.has(documentId)) {
return prev;
}
const next = new Map(prev);
next.delete(documentId);
return next;
});
}, []);
const ensureDownloadUrl = useCallback(
async (documentId: DocumentId, { force = false }: { force?: boolean } = {}): Promise<DocumentLink | null> => {
if (!documentId) return null;
@@ -193,32 +166,22 @@ const useDocumentPreview = ({
async (documentId: DocumentId): Promise<DocumentLike | null> => {
if (!documentId) return null;
const findInCache = () => {
const pool = searchResults ?? documents;
return pool.find((item) => item.id === documentId) || null;
};
const findInCache = () => documentsManager.getById(documentId);
let doc = findInCache();
if (!doc) {
doc = await documentsManager.ensure(documentId);
}
if (!doc) {
const { data } = await api.get(`/documents/${documentId}`);
const hydratedDetail = assetManager.hydrateDetail(data);
const fetched = hydratedDetail?.document || (data as { document?: DocumentLike })?.document || data;
doc = fetched ? assetManager.hydrateDocument(fetched) || null : null;
const fetched = (data as { document?: DocumentLike })?.document || data;
const { canonical } = documentsManager.ingest([fetched as unknown]);
doc = (canonical[0] as DocumentLike | undefined) || null;
if (!doc) {
throw new Error('Document metadata unavailable.');
}
const existsInDocuments = documents.some((item) => item.id === doc?.id);
const existsInSearch = Array.isArray(searchResults)
? searchResults.some((item) => item.id === doc?.id)
: false;
if (doc?.id && (existsInDocuments || existsInSearch)) {
removeCachedPreviewDocument(doc.id);
} else {
cachePreviewDocument(doc);
}
}
if (!previewReturnPathRef.current) {
@@ -232,14 +195,10 @@ const useDocumentPreview = ({
return doc;
},
[
searchResults,
documents,
assetManager,
documentsManager,
ensureDownloadUrl,
setActivePreviewId,
api,
cachePreviewDocument,
removeCachedPreviewDocument,
],
);
@@ -270,21 +229,6 @@ const useDocumentPreview = ({
[navigate, selectedFolder],
);
useEffect(() => {
if (!routeDocumentId) {
return undefined;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeDocumentPreview();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [routeDocumentId, closeDocumentPreview]);
useEffect(() => {
if (!routeDocumentId) {
return undefined;
@@ -305,33 +249,8 @@ const useDocumentPreview = ({
};
}, [routeDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]);
useEffect(() => {
setPreviewDocuments((prev) => {
if (!prev.size) {
return prev;
}
const next = new Map(prev);
let changed = false;
const prune = (list?: DocumentLike[] | null) => {
if (!Array.isArray(list)) {
return;
}
list.forEach((doc) => {
if (doc?.id && next.has(doc.id)) {
next.delete(doc.id);
changed = true;
}
});
};
prune(documents);
prune(searchResults);
return changed ? next : prev;
});
}, [documents, searchResults]);
return {
documentLinks,
previewDocuments,
ensureDownloadUrl,
ensurePreviewData,
openDocumentPreview,
+23 -20
View File
@@ -10,13 +10,8 @@ type ApiClient = {
get: <T = unknown>(url: string, config?: { params?: Record<string, unknown> }) => Promise<{ data: T }>;
};
type AssetManagerLike = {
hydrateDocuments: (payload: unknown[]) => DocumentLike[];
};
interface UseDocumentsSearchArgs {
api: ApiClient;
assetManager: AssetManagerLike;
token?: string | null;
selectedFolder?: Identifier | 'root' | null;
navigate?: (path: string, options?: { replace?: boolean }) => void;
@@ -28,13 +23,16 @@ interface UseDocumentsSearchArgs {
notifyApiError: (error: unknown, message: string) => void;
setLoading: (state: boolean) => void;
setSearchIncludeDescendants: (value: boolean) => void;
documentsManager: {
ingest: (docs: unknown[]) => { canonical: DocumentLike[]; changed: boolean };
};
}
interface UseDocumentsSearchResult {
searchQuery: string;
setSearchQuery: Dispatch<SetStateAction<string>>;
searchResults: DocumentLike[] | null;
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | null>>;
searchResultIds: Identifier[] | null;
setSearchResultIds: Dispatch<SetStateAction<Identifier[] | null>>;
searchLoading: boolean;
setSearchLoading: Dispatch<SetStateAction<boolean>>;
activeTagFilters: Identifier[];
@@ -49,7 +47,7 @@ interface UseDocumentsSearchResult {
handleSearchSubmit: () => void;
documentsFilterValue: {
query: string;
searchResults: DocumentLike[] | null;
searchResultIds: Identifier[] | null;
searchLoading: boolean;
includeDescendants: boolean;
activeTagIds: Identifier[];
@@ -66,7 +64,6 @@ interface UseDocumentsSearchResult {
const useDocumentsSearch = ({
api,
assetManager,
token,
selectedFolder,
navigate,
@@ -78,11 +75,12 @@ const useDocumentsSearch = ({
notifyApiError,
setLoading,
setSearchIncludeDescendants,
documentsManager,
}: UseDocumentsSearchArgs): UseDocumentsSearchResult => {
const [searchQuery, setSearchQuery] = useState<string>('');
const [activeTagFilters, setActiveTagFilters] = useState<Identifier[]>([]);
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState<Identifier[]>([]);
const [searchResults, setSearchResults] = useState<DocumentLike[] | null>(null);
const [searchResultIds, setSearchResultIds] = useState<Identifier[] | null>(null);
const [searchLoading, setSearchLoading] = useState<boolean>(false);
const toggleTagFilter = useCallback((tagId: Identifier) => {
@@ -122,6 +120,7 @@ const useDocumentsSearch = ({
setActiveCorrespondentFilters([]);
setSearchLoading(false);
setSearchIncludeDescendants(true);
setSearchResultIds(null);
}, [
setSearchIncludeDescendants,
]);
@@ -142,7 +141,7 @@ const useDocumentsSearch = ({
const documentsFilterValue = useMemo(
() => ({
query: searchQuery,
searchResults,
searchResultIds,
searchLoading,
includeDescendants: Boolean(searchIncludeDescendants),
activeTagIds: activeTagFilters,
@@ -157,7 +156,7 @@ const useDocumentsSearch = ({
}),
[
searchQuery,
searchResults,
searchResultIds,
searchLoading,
searchIncludeDescendants,
activeTagFilters,
@@ -176,7 +175,7 @@ const useDocumentsSearch = ({
if (!token) return undefined;
if (!isFilterActive) {
setSearchResults(null);
setSearchResultIds(null);
setSearchLoading(false);
return undefined;
}
@@ -225,17 +224,21 @@ const useDocumentsSearch = ({
const { data } = await api.get<unknown[]>('/documents', { params });
if (cancelled) return;
const results = assetManager.hydrateDocuments(data || []);
setSearchResults(results);
const results = Array.isArray(data) ? data : [];
const { canonical } = documentsManager.ingest(results);
const ids = canonical
.map((doc) => (doc?.id ?? null) as Identifier | null)
.filter((id): id is Identifier => id != null);
setSearchResultIds(ids);
if (!results.length) {
if (!ids.length) {
setSearchLoading(false);
return;
}
} catch (error) {
if (cancelled) return;
notifyApiError(error, 'Search failed. Please try again.');
setSearchResults(null);
setSearchResultIds(null);
} finally {
if (!cancelled && started) {
setLoading(false);
@@ -264,15 +267,15 @@ const useDocumentsSearch = ({
documentsSortDirection,
selectedFolder,
notifyApiError,
assetManager,
setLoading,
documentsManager,
]);
return {
searchQuery,
setSearchQuery,
searchResults,
setSearchResults,
searchResultIds,
setSearchResultIds,
searchLoading,
setSearchLoading,
activeTagFilters,
+2 -2
View File
@@ -123,7 +123,7 @@ export const useWorkspaceSurface = ({
const showPreviewWorkspace = Boolean(previewDocumentId);
const previewSurface = useMemo<WorkspaceSurface>(() => {
if (!showPreviewWorkspace || !previewWorkspaceDocument) {
if (!showPreviewWorkspace) {
return null;
}
@@ -145,7 +145,7 @@ export const useWorkspaceSurface = ({
return {
content: (
<DocumentViewerPanel
document={previewWorkspaceDocument}
document={previewWorkspaceDocument || null}
documentLink={documentLink}
hydrateDocument={ensurePreviewData}
tagLookupById={tagLookupById}
+3 -9
View File
@@ -119,7 +119,6 @@ type WorkspaceSnapshotState = {
interface DesktopWorkspaceProps {
documents?: DeskDocument[];
searchResults?: DeskDocument[] | null;
onInspectDocument?: (...args: unknown[]) => void;
onEntryPointer?: (...args: unknown[]) => void;
onDocumentStackSelect?: (docIds: Identifier[]) => void;
@@ -195,7 +194,6 @@ const defaultGetDocumentAsset: GetAsset = () => null;
const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
documents = [],
searchResults = null,
onInspectDocument = null,
onEntryPointer = null,
onDocumentStackSelect = null,
@@ -211,10 +209,7 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
documentLinks,
ensureDownloadUrl,
}) => {
const items = useMemo<DeskDocument[]>(
() => (searchResults ? searchResults : documents),
[documents, searchResults],
);
const items = useMemo<DeskDocument[]>(() => documents, [documents]);
const allowLayoutPersistence = Boolean(tenantId && viewId && viewId.startsWith('folder:'));
const documentLinkMap = documentLinks instanceof Map ? documentLinks : null;
@@ -264,9 +259,8 @@ const DesktopWorkspace: React.FC<DesktopWorkspaceProps> = ({
}, [engine, allowLayoutPersistence, tenantId, viewId]);
useEffect(() => {
const nextItems = searchResults ? searchResults : documents;
engine.setItems(nextItems || []);
}, [engine, documents, searchResults]);
engine.setItems(items || []);
}, [engine, items]);
useEffect(() => {
const map = new Map();
+8 -10
View File
@@ -32,8 +32,6 @@ type DocumentLink = {
interface UseDetailWorkspaceArgs {
documents: DocumentLike[];
searchResults?: DocumentLike[] | null;
previewDocuments?: Map<Identifier, DocumentLike> | null;
selectionOrder: string[];
selectedDocumentIds: Identifier[];
documentLookup: Map<Identifier, DocumentLike>;
@@ -77,8 +75,6 @@ interface UseDetailWorkspaceResult {
const useDetailWorkspace = ({
documents,
searchResults,
previewDocuments,
selectionOrder,
selectedDocumentIds,
documentLookup,
@@ -245,17 +241,19 @@ const useDetailWorkspace = ({
[folderNodes],
);
const documentLink = useMemo(() => (detailPanelDocument ? documentLinks.get(detailPanelDocument.id) || null : null), [detailPanelDocument, documentLinks]);
const documentLink = useMemo(
() => (detailPanelDocument ? documentLinks.get(detailPanelDocument.id) || null : null),
[detailPanelDocument, documentLinks],
);
const previewWorkspaceDocument = useMemo(() => {
const previewWorkspaceDocument = useMemo(() => {
if (!previewDocumentId) {
return null;
}
const pool = searchResults ?? documents;
return pool.find((doc) => doc.id === previewDocumentId)
|| previewDocuments?.get?.(previewDocumentId)
return documentLookup.get(previewDocumentId)
|| documents.find((doc) => doc.id === previewDocumentId)
|| null;
}, [previewDocumentId, searchResults, documents, previewDocuments]);
}, [previewDocumentId, documentLookup, documents]);
const previewActive = Boolean(previewDocumentId && previewWorkspaceDocument);
+200
View File
@@ -0,0 +1,200 @@
import { shallowEqual } from 'react-redux';
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();
}
private emit() {
this.listeners.forEach((fn) => fn());
}
subscribe(listener: () => void) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
setFetcher(fetchDocument?: FetchDocument) {
this.fetcher = fetchDocument;
}
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);
let changed = false;
let nextById = this.byId;
const canonical: T[] = [];
docs.forEach((doc) => {
const id = doc?.id;
if (id == null) {
canonical.push(doc);
return;
}
const existing = nextById.get(id as DocumentId);
const merged = existing ? ({ ...existing, ...doc } as T) : ({ ...(doc as T) } as T);
const useExisting = existing && shallowEqual(existing, merged);
const nextDoc = useExisting ? (existing as T) : merged;
if (!useExisting) {
if (!changed) {
nextById = new Map(this.byId);
}
nextById.set(id as DocumentId, nextDoc);
changed = true;
}
canonical.push(nextDoc);
});
if (changed) {
this.byId = nextById;
this.emit();
}
return { canonical, changed };
}
async ensure(id: DocumentId, fetcherOverride?: FetchDocument): Promise<T | null> {
if (id == null) {
return null;
}
const cached = this.byId.get(id);
if (cached) {
return cached;
}
const fetcher = fetcherOverride || this.fetcher;
if (!fetcher) {
return null;
}
const inflight = this.inflight.get(id);
if (inflight) {
return inflight;
}
const request = (async () => {
try {
const fetched = await fetcher(id);
const extracted = this.extractDocument ? this.extractDocument(fetched) : fetched;
const { canonical } = this.ingest([extracted as unknown]);
return canonical[0] ?? null;
} finally {
this.inflight.delete(id);
}
})();
this.inflight.set(id, request);
return request;
}
map(mapper: (doc: T) => T | undefined): boolean {
if (!this.byId.size) {
return false;
}
let changed = false;
const next = new Map<DocumentId, T>();
this.byId.forEach((doc, key) => {
const updated = mapper(doc);
const nextDoc = updated === undefined ? doc : updated;
if (nextDoc !== doc) {
changed = true;
}
next.set(key, nextDoc ?? doc);
});
if (changed) {
this.byId = next;
this.emit();
}
return changed;
}
remove(ids: Array<DocumentId>): boolean {
if (!Array.isArray(ids) || ids.length === 0) {
return false;
}
let changed = false;
let next = this.byId;
ids.forEach((id) => {
if (next.has(id)) {
if (!changed) {
next = new Map(this.byId);
}
next.delete(id);
changed = true;
}
});
if (changed) {
this.byId = next;
this.emit();
}
return changed;
}
getById(id: DocumentId): T | null {
return this.byId.get(id) ?? null;
}
getMany(ids: Array<DocumentId> = []): T[] {
return ids
.map((id) => this.byId.get(id) || null)
.filter((doc): doc is T => Boolean(doc));
}
getSnapshot(): Map<DocumentId, T> {
return this.byId;
}
}
export default DocumentsManager;
@@ -4,7 +4,7 @@ type Identifier = string | number;
export interface DocumentsFilterValue {
query: string;
searchResults: Array<Record<string, unknown>> | null;
searchResultIds: Array<string | number> | null;
searchLoading: boolean;
includeDescendants: boolean;
activeTagIds: Identifier[];
@@ -27,7 +27,7 @@ export interface UseDocumentsPanelPropsArgs {
refreshCurrentFolder?: () => void | Promise<void>;
currentSubfolders?: unknown[];
documents?: unknown[];
searchResults?: unknown[] | null;
searchResultIds?: Identifier[] | null;
folderClickHandlers: FolderClickHandlers;
selectFolder?: (...args: unknown[]) => void;
handleFolderDragStart?: (...args: unknown[]) => void;
@@ -80,7 +80,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
refreshCurrentFolder,
currentSubfolders,
documents,
searchResults,
searchResultIds,
folderClickHandlers,
selectFolder,
handleFolderDragStart,
@@ -130,7 +130,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
onRefresh: refreshCurrentFolder,
subfolders: currentSubfolders,
documents,
searchResults,
searchResultIds,
onFolderSelect: selectFolder,
onFolderDrop: folderClickHandlers.onDrop,
onFolderDragOver: folderClickHandlers.onDragOver,
@@ -211,7 +211,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
openDocumentPreview,
refreshCurrentFolder,
searchLoading,
searchResults,
searchResultIds,
selectFolder,
tagLookupById,
tags,
@@ -47,7 +47,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
breadcrumbs,
subfolders,
documents,
searchResults,
searchResultIds,
onFolderSelect,
onFolderDrop,
onFolderDragOver,
@@ -104,10 +104,19 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
toggleTag: toggleTagFilter,
toggleCorrespondent: toggleCorrespondentFilter,
} = useDocumentsFilter();
const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents;
const searchDocuments = useMemo(
() =>
Array.isArray(searchResultIds)
? searchResultIds
.map((id) => documentLookup?.get?.(id) || null)
.filter((doc): doc is Record<string, unknown> => Boolean(doc))
: null,
[searchResultIds, documentLookup],
);
const showingSearchResults = Array.isArray(searchResultIds);
const rows = showingSearchResults && searchDocuments ? searchDocuments : documents;
const documentLinkMap = documentLinks instanceof Map ? documentLinks : null;
const searchResultCount = Array.isArray(searchResults) ? searchResults.length : 0;
const searchResultCount = Array.isArray(searchResultIds) ? searchResultIds.length : 0;
const headerTitle = showingSearchResults
? 'Search results'
: currentFolderName || 'Documents';
@@ -201,7 +210,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
const selectionContextRef = useRef(null);
useEffect(() => {
const nextContext = showingSearchResults
? { type: 'search', marker: searchResults }
? { type: 'search', marker: searchResultIds }
: { type: 'folder', marker: currentFolderId || 'root' };
const previous = selectionContextRef.current;
selectionContextRef.current = nextContext;
@@ -213,7 +222,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
if (changed) {
clearSelection();
}
}, [showingSearchResults, currentFolderId, searchResults, clearSelection]);
}, [showingSearchResults, currentFolderId, searchResultIds, clearSelection]);
const entries = useMemo(() => {
const list = [];
@@ -114,7 +114,7 @@ interface UseDocumentMutationsArgs {
setSelectedFolder: Dispatch<SetStateAction<FolderId>>;
setDocuments: Dispatch<SetStateAction<DocumentLike[]>>;
setFolderContents: Dispatch<SetStateAction<Map<FolderId, FolderContents>>>;
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | null>>;
setSearchResultIds: Dispatch<SetStateAction<DocumentId[] | null>>;
setSelectedEntries: Dispatch<SetStateAction<string[]>>;
setSelectionOrder: Dispatch<SetStateAction<string[]>>;
selectionOrderRef: MutableRefObject<string[] | null>;
@@ -190,7 +190,7 @@ const useDocumentMutations = ({
setSelectedFolder,
setDocuments,
setFolderContents,
setSearchResults,
setSearchResultIds,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
@@ -319,11 +319,11 @@ const useDocumentMutations = ({
}
if (uniqueIdSet.size) {
setSearchResults((prev) => {
setSearchResultIds((prev) => {
if (!Array.isArray(prev) || !prev.length) {
return prev;
}
const filtered = prev.filter((doc) => doc && !uniqueIdSet.has(doc.id as DocumentId));
const filtered = prev.filter((id) => !uniqueIdSet.has(id as DocumentId));
return filtered.length === prev.length ? prev : filtered;
});
setDocuments((prev) => prev.filter((doc) => !uniqueIdSet.has(doc.id as DocumentId)));
@@ -387,7 +387,7 @@ const useDocumentMutations = ({
folderLabelMap,
ensureFolderData,
selectedFolder,
setSearchResults,
setSearchResultIds,
setDocuments,
setFolderContents,
setSelectedEntries,
+97 -29
View File
@@ -1,7 +1,17 @@
import { Dispatch, SetStateAction, useCallback, useState } from 'react';
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import DocumentsManager from '../../documents/DocumentsManager';
type DocumentId = string | number;
interface DocumentLike {
id?: string | number;
id?: DocumentId;
[key: string]: unknown;
}
@@ -11,36 +21,74 @@ interface FolderContentsEntry {
}
interface UseDocumentsOptions {
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | null>>;
setFolderContents: Dispatch<SetStateAction<Map<string, FolderContentsEntry>>>;
fetchDocumentById?: (id: DocumentId) => Promise<DocumentLike | null>;
hydrateDocument?: (payload: unknown) => DocumentLike | null;
hydrateDocuments?: (payload: unknown[]) => DocumentLike[];
extractDocument?: (payload: unknown) => DocumentLike | null;
}
const useDocuments = ({ setSearchResults, setFolderContents }: UseDocumentsOptions) => {
const [documents, setDocuments] = useState<DocumentLike[]>([]);
const useDocuments = ({
setFolderContents,
fetchDocumentById,
hydrateDocument,
hydrateDocuments,
extractDocument,
}: UseDocumentsOptions) => {
const managerRef = useRef(
new DocumentsManager<DocumentLike>(fetchDocumentById, {
hydrateDocument,
hydrateDocuments,
extractDocument,
}),
);
const [documents, setDocumentsState] = useState<DocumentLike[]>([]);
useEffect(() => {
managerRef.current.setFetcher(fetchDocumentById);
}, [fetchDocumentById]);
const setDocuments = useCallback(
(value: DocumentLike[] | ((prev: DocumentLike[]) => DocumentLike[])) => {
setDocumentsState((prev) => {
const resolved = typeof value === 'function' ? value(prev) : value;
if (!Array.isArray(resolved)) {
return resolved;
}
const { canonical } = managerRef.current.ingest(resolved);
return canonical;
});
},
[],
);
const mapDocumentCaches = useCallback(
(mapper: (doc: DocumentLike) => DocumentLike | undefined) => {
managerRef.current.map(mapper);
const lookupSnapshot = managerRef.current.getSnapshot();
const applyToList = (list?: DocumentLike[] | null) => {
let changed = false;
const safeList = Array.isArray(list) ? list : [];
const next = safeList.map((doc) => {
const updated = mapper(doc);
if (updated === undefined || updated === doc) {
return doc;
}
changed = true;
return updated;
});
return changed ? next : safeList;
};
setDocuments((prev) => applyToList(prev));
setSearchResults((prev) => {
if (!Array.isArray(prev)) {
setDocumentsState((prev) => {
if (!Array.isArray(prev) || prev.length === 0) {
return prev;
}
return applyToList(prev);
let changed = false;
const next = prev.map((doc) => {
const id = doc?.id;
if (id != null && lookupSnapshot.has(id as DocumentId)) {
const canonical = lookupSnapshot.get(id as DocumentId) as DocumentLike;
if (canonical !== doc) {
changed = true;
}
return canonical;
}
const updated = mapper(doc);
const nextDoc = updated === undefined ? doc : updated;
if (nextDoc !== doc) {
changed = true;
}
return nextDoc;
});
return changed ? next : prev;
});
setFolderContents((prev) => {
if (!prev.size) {
@@ -56,12 +104,20 @@ const useDocuments = ({ setSearchResults, setFolderContents }: UseDocumentsOptio
}
let docsChanged = false;
const updatedDocs = docs.map((doc) => {
const updated = mapper(doc);
if (updated === undefined || updated === doc) {
return doc;
const id = doc?.id;
if (id != null && lookupSnapshot.has(id as DocumentId)) {
const canonical = lookupSnapshot.get(id as DocumentId) as DocumentLike;
if (canonical !== doc) {
docsChanged = true;
}
return canonical;
}
docsChanged = true;
return updated;
const updated = mapper(doc);
const nextDoc = updated === undefined ? doc : updated;
if (nextDoc !== doc) {
docsChanged = true;
}
return nextDoc;
});
if (docsChanged) {
changed = true;
@@ -73,7 +129,7 @@ const useDocuments = ({ setSearchResults, setFolderContents }: UseDocumentsOptio
return changed ? next : prev;
});
},
[setFolderContents, setSearchResults],
[setFolderContents],
);
const updateDocumentCaches = useCallback(
@@ -93,11 +149,23 @@ const useDocuments = ({ setSearchResults, setFolderContents }: UseDocumentsOptio
[mapDocumentCaches],
);
const removeDocumentsFromLookup = useCallback(
(documentIds: Array<DocumentId>) => {
if (!Array.isArray(documentIds) || !documentIds.length) {
return;
}
managerRef.current.remove(documentIds);
},
[],
);
return {
documents,
setDocuments,
removeDocumentsFromLookup,
mapDocumentCaches,
updateDocumentCaches,
documentsManager: managerRef.current,
};
};
@@ -1,4 +1,12 @@
import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react';
import {
matchPath,
useLocation,
@@ -220,10 +228,20 @@ const useDocumentsWorkspace = ({
if (!payload) {
return null;
}
const hydratedDetail = assetManager.hydrateDetail(payload);
return hydratedDetail?.document || payload.document || payload;
return payload.document || payload;
},
[assetManager],
[],
);
const fetchDocumentById = useCallback(
async (documentId: DocumentId) => {
if (!documentId) {
return null;
}
const { data } = await api.get(`/documents/${documentId}`);
return extractDocumentFromResponse(data);
},
[extractDocumentFromResponse],
);
const tagManagerRef = useRef(null);
@@ -290,21 +308,27 @@ const useDocumentsWorkspace = ({
folderContentsRef.current = folderContents;
}, [folderContents]);
const setSearchResultsRef = useRef<(value: unknown) => void>(() => {});
const setSearchResultsProxy = useCallback((value) => {
setSearchResultsRef.current(value);
}, []);
const {
documents,
setDocuments,
removeDocumentsFromLookup,
mapDocumentCaches,
updateDocumentCaches,
documentsManager,
} = useDocuments({
setSearchResults: setSearchResultsProxy,
setFolderContents,
fetchDocumentById,
hydrateDocument: (payload) => assetManager.hydrateDocument(payload),
hydrateDocuments: (payload) => assetManager.hydrateDocuments(payload),
extractDocument: extractDocumentFromResponse,
});
const documentLookup = useSyncExternalStore(
(onStoreChange) => documentsManager.subscribe(onStoreChange),
() => documentsManager.getSnapshot(),
() => documentsManager.getSnapshot(),
);
const {
folderNodes,
setFolderNodes,
@@ -338,8 +362,8 @@ const useDocumentsWorkspace = ({
const {
searchQuery,
setSearchQuery,
searchResults,
setSearchResults,
searchResultIds,
setSearchResultIds,
searchLoading,
activeTagFilters,
setActiveTagFilters,
@@ -349,7 +373,6 @@ const useDocumentsWorkspace = ({
documentsFilterValue,
} = useDocumentsSearch({
api,
assetManager,
token,
selectedFolder,
navigate,
@@ -361,17 +384,66 @@ const useDocumentsWorkspace = ({
notifyApiError,
setLoading,
setSearchIncludeDescendants,
documentsManager,
});
const documentsFilter = documentsFilterValue;
const [visibleDocumentIds, setVisibleDocumentIds] = useState<DocumentId[]>([]);
const showingSearchResults = searchResultIds !== null;
useEffect(() => {
setSearchResultsRef.current = setSearchResults;
}, [setSearchResults]);
const arraysEqual = (a: DocumentId[], b: DocumentId[]) =>
a.length === b.length && a.every((value, index) => value === b[index]);
if (showingSearchResults && Array.isArray(searchResultIds)) {
const ids = searchResultIds.filter((id): id is DocumentId => id != null);
setVisibleDocumentIds((prev) => (arraysEqual(prev, ids) ? prev : ids));
return;
}
const folderIds = documents
.map((doc) => (doc?.id ?? null) as DocumentId | null)
.filter((id): id is DocumentId => id != null);
setVisibleDocumentIds((prev) => (arraysEqual(prev, folderIds) ? prev : folderIds));
}, [showingSearchResults, searchResultIds, documents]);
const viewDocuments = useMemo(
() =>
visibleDocumentIds
.map((id) => documentLookup.get(id) || null)
.filter((doc): doc is DocumentLike => Boolean(doc)),
[visibleDocumentIds, documentLookup],
);
const visibleDocumentKeys = useMemo(
() => visibleDocumentIds.map((id) => resolveDocumentRowKey(id)).filter(Boolean),
[visibleDocumentIds],
);
const visibleFolderKeys = useMemo(
() =>
showingSearchResults
? []
: currentSubfolders
.map((folder) => resolveFolderRowKey(folder.id))
.filter(Boolean),
[showingSearchResults, currentSubfolders],
);
const visibleRowKeys = useMemo(
() => [...visibleFolderKeys, ...visibleDocumentKeys],
[visibleFolderKeys, visibleDocumentKeys],
);
const visibleRowKeySet = useMemo(
() => new Set(visibleRowKeys),
[visibleRowKeys],
);
const {
documentLinks,
previewDocuments,
ensurePreviewData,
ensureDownloadUrl,
openDocumentPreview,
@@ -380,8 +452,7 @@ const useDocumentsWorkspace = ({
removeDocumentLinks,
} = useDocumentPreview({
routeDocumentId: previewDocumentId,
documents,
searchResults,
documentsManager,
selectedFolder,
assetManager,
api,
@@ -414,65 +485,6 @@ const useDocumentsWorkspace = ({
const detailFolderFetchRef = useRef(new Set());
const showingSearchResults = searchResults !== null;
const visibleDocuments = useMemo(
() => (showingSearchResults ? searchResults : documents),
[showingSearchResults, searchResults, documents],
);
const visibleDocumentIds = useMemo(
() => visibleDocuments.map((doc) => doc.id),
[visibleDocuments],
);
const visibleDocumentKeys = useMemo(
() => visibleDocumentIds.map((id) => resolveDocumentRowKey(id)).filter(Boolean),
[visibleDocumentIds],
);
const visibleFolderKeys = useMemo(
() =>
showingSearchResults
? []
: currentSubfolders
.map((folder) => resolveFolderRowKey(folder.id))
.filter(Boolean),
[showingSearchResults, currentSubfolders],
);
const visibleRowKeys = useMemo(
() => [...visibleFolderKeys, ...visibleDocumentKeys],
[visibleFolderKeys, visibleDocumentKeys],
);
const visibleRowKeySet = useMemo(
() => new Set(visibleRowKeys),
[visibleRowKeys],
);
const documentLookup = useMemo(() => {
const map = new Map();
const push = (items) => {
(items || []).forEach((doc) => {
if (doc?.id) {
map.set(doc.id, doc);
}
});
};
push(documents);
if (Array.isArray(searchResults)) {
push(searchResults);
}
previewDocuments.forEach((doc, id) => {
if (doc && id && !map.has(id)) {
map.set(id, doc);
}
});
return map;
}, [documents, searchResults, previewDocuments]);
useEffect(() => {
if (!showingSearchResults) {
return;
@@ -696,7 +708,7 @@ const useDocumentsWorkspace = ({
selectionAnchorRef.current = null;
setDraggedDocumentIds([]);
setDraggedFolderId(null);
setSearchResults(null);
setSearchResultIds(null);
setTags([]);
setCorrespondents([]);
setSearchQuery('');
@@ -729,7 +741,7 @@ const useDocumentsWorkspace = ({
setDocuments,
setDraggedDocumentIds,
setDraggedFolderId,
setSearchResults,
setSearchResultIds,
setTags,
setCorrespondents,
setSearchQuery,
@@ -757,11 +769,11 @@ const useDocumentsWorkspace = ({
const idSet = new Set<DocumentId>(documentIds);
setDocuments((prev) => prev.filter((doc) => !idSet.has(doc.id)));
setSearchResults((prev) => {
setSearchResultIds((prev) => {
if (!Array.isArray(prev)) {
return prev;
}
const filtered = prev.filter((doc) => !idSet.has(doc.id));
const filtered = prev.filter((id) => !idSet.has(id as DocumentId));
return filtered.length === prev.length ? prev : filtered;
});
@@ -788,9 +800,16 @@ const useDocumentsWorkspace = ({
return changed ? next : prev;
});
removeDocumentsFromLookup(Array.from(idSet));
removeDocumentLinks(Array.from(idSet));
},
[setDocuments, setSearchResults, setFolderContents, removeDocumentLinks],
[
setDocuments,
setSearchResultIds,
setFolderContents,
removeDocumentsFromLookup,
removeDocumentLinks,
],
);
const {
@@ -812,7 +831,7 @@ const useDocumentsWorkspace = ({
setSelectedFolder,
setDocuments,
setFolderContents,
setSearchResults,
setSearchResultIds,
setSelectedEntries,
setSelectionOrder,
selectionOrderRef,
@@ -864,7 +883,7 @@ const useDocumentsWorkspace = ({
setLoading,
setFolderContents,
setCurrentFolder,
setSearchResults,
setSearchResultIds,
isFilterActive,
navigate,
handleFileDrop,
@@ -883,7 +902,7 @@ const useDocumentsWorkspace = ({
} = useDocumentsSelection({
showingSearchResults,
currentSubfolders,
visibleDocuments,
visibleDocuments: viewDocuments,
resolveFolderRowKey,
resolveDocumentRowKey,
configureSelectionEnvironment,
@@ -1021,19 +1040,13 @@ const useDocumentsWorkspace = ({
prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc)),
);
setSearchResults((prev) =>
Array.isArray(prev)
? prev.map((doc) => (doc.id === documentId ? mergeAssetIntoDocument(doc, entry) : doc))
: prev,
);
return entry;
} catch (error) {
notifyApiError(error, 'Unable to refresh document asset.');
throw error;
}
},
[assetManager, setDocuments, setSearchResults, notifyApiError],
[assetManager, setDocuments, notifyApiError],
);
@@ -1233,9 +1246,7 @@ const useDocumentsWorkspace = ({
documentLink,
resolveFolderPath,
} = useDetailWorkspace({
documents,
searchResults,
previewDocuments,
documents: viewDocuments,
selectionOrder,
selectedDocumentIds,
documentLookup,
@@ -1446,8 +1457,7 @@ const useDocumentsWorkspace = ({
const deskWorkspaceProps = useMemo(
() => ({
documents,
searchResults,
documents: viewDocuments,
onInspectDocument: inspectDocumentForDesk,
onEntryPointer: handleEntryPointerCore,
onDocumentStackSelect: handleDeskDocumentStackSelect,
@@ -1464,8 +1474,7 @@ const useDocumentsWorkspace = ({
ensureDownloadUrl,
}),
[
documents,
searchResults,
viewDocuments,
inspectDocumentForDesk,
handleEntryPointerCore,
handleDeskDocumentStackSelect,
@@ -1488,8 +1497,8 @@ const useDocumentsWorkspace = ({
breadcrumbs,
refreshCurrentFolder,
currentSubfolders,
documents,
searchResults,
documents: viewDocuments,
searchResultIds,
folderClickHandlers,
handleFolderDragStart,
handleFolderDragEnd,
@@ -70,7 +70,7 @@ interface UseFolderTreeActionsOptions {
updater: (prev: Map<FolderKey, FolderContentsState>) => Map<FolderKey, FolderContentsState>,
) => void;
setCurrentFolder: (updater: (prev: any) => any) => void;
setSearchResults: (value: any) => void;
setSearchResultIds: (value: any) => void;
isFilterActive: boolean;
navigate?: (path: string, options?: { replace?: boolean }) => void;
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise<void> | void;
@@ -99,7 +99,7 @@ const useFolderTreeActions = ({
setLoading,
setFolderContents,
setCurrentFolder,
setSearchResults,
setSearchResultIds,
isFilterActive,
navigate,
handleFileDrop,
@@ -235,7 +235,7 @@ const useFolderTreeActions = ({
}
applySelectedFolder(targetId, contents);
if (!preserveSearch) {
setSearchResults(null);
setSearchResultIds(null);
}
} catch (error) {
notifyApiError(error, 'Failed to load folder contents.');
@@ -250,7 +250,7 @@ const useFolderTreeActions = ({
expandFolderAncestors,
notifyApiError,
setLoading,
setSearchResults,
setSearchResultIds,
setSelectedFolder,
],
);
+32 -9
View File
@@ -253,12 +253,35 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
const [zoomOverlayOpen, setZoomOverlayOpen] = useState(false);
const fallbackDocumentLink = useMemo(() => {
if (!document) {
return null;
}
const downloadPath = document.current_version?.download_path;
if (!downloadPath) {
return null;
}
const href = resolveApiPath ? resolveApiPath(downloadPath) : downloadPath;
if (!href) {
return null;
}
const contentType = document.current_version?.version?.content_type || document.content_type || null;
const filename = document.current_version?.filename || document.filename || document.title || null;
return {
url: href,
contentType,
filename,
};
}, [document, resolveApiPath]);
const effectiveDocumentLink = documentLink?.url ? documentLink : fallbackDocumentLink;
const handleZoomOpen = useCallback(() => {
if (!documentLink?.url) {
if (!effectiveDocumentLink?.url) {
return;
}
setZoomOverlayOpen(true);
}, [documentLink?.url]);
}, [effectiveDocumentLink?.url]);
const handleZoomClose = useCallback(() => {
setZoomOverlayOpen(false);
@@ -266,7 +289,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
useEffect(() => {
setZoomOverlayOpen(false);
}, [documentLink?.url, document?.id]);
}, [effectiveDocumentLink?.url, document?.id]);
useEffect(() => {
if (hydrateDocument && document?.id) {
@@ -355,23 +378,23 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
}, [breadcrumbs, handleBreadcrumbNavigate]);
const zoomDisplay = useMemo(() => {
if (!documentLink?.url || !document) {
if (!effectiveDocumentLink?.url || !document) {
return null;
}
const docContentType = document.content_type;
const versionContentType = document.current_version?.version?.content_type;
const normalizedContentType = documentLink.contentType || docContentType || versionContentType || null;
const normalizedContentType = effectiveDocumentLink.contentType || docContentType || versionContentType || null;
return {
url: documentLink.url,
url: effectiveDocumentLink.url,
alt: document.title,
contentType: normalizedContentType || undefined,
};
}, [documentLink?.url, documentLink?.contentType, document]);
}, [effectiveDocumentLink?.url, effectiveDocumentLink?.contentType, document]);
const headerActions = createDocumentViewerHeaderActions({
document,
actionState,
documentLink,
documentLink: effectiveDocumentLink,
onZoom: zoomDisplay ? handleZoomOpen : null,
canZoom: Boolean(zoomDisplay),
});
@@ -471,7 +494,7 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
<section className={viewerClassName}>
<DocumentViewerLayout
document={document}
documentLink={documentLink}
documentLink={effectiveDocumentLink}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentTabConfig={contentTabConfig}
+2 -1
View File
@@ -51,7 +51,8 @@ const DocumentViewerRoute: React.FC = () => {
return <Navigate to="/documents" replace />;
}
if (!previewWorkspaceDocument || previewWorkspaceDocument.id !== documentId) {
const previewId = previewWorkspaceDocument?.id;
if (!previewWorkspaceDocument || String(previewId) !== String(documentId)) {
return <div className="document-viewer__message">Loading preview</div>;
}