documentsmanager
This commit is contained in:
Generated
+39
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user