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