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
+200
View File
@@ -0,0 +1,200 @@
import { shallowEqual } from 'react-redux';
type DocumentId = string | number;
export type ManagedDocument = { id?: DocumentId | null } & Record<string, unknown>;
type FetchDocument = (id: DocumentId) => Promise<unknown>;
type HydrateDocument<T extends ManagedDocument> = (payload: unknown) => T | null;
type HydrateDocuments<T extends ManagedDocument> = (payload: unknown[]) => T[];
class DocumentsManager<T extends ManagedDocument = ManagedDocument> {
private byId: Map<DocumentId, T>;
private fetcher?: FetchDocument;
private hydrateDocument?: HydrateDocument<T>;
private hydrateDocuments?: HydrateDocuments<T>;
private extractDocument?: HydrateDocument<T>;
private inflight: Map<DocumentId, Promise<T | null>>;
private listeners: Set<() => void>;
constructor(
fetchDocument?: FetchDocument,
options?: {
hydrateDocument?: HydrateDocument<T>;
hydrateDocuments?: HydrateDocuments<T>;
extractDocument?: HydrateDocument<T>;
},
) {
this.byId = new Map();
this.fetcher = fetchDocument;
this.hydrateDocument = options?.hydrateDocument;
this.hydrateDocuments = options?.hydrateDocuments;
this.extractDocument = options?.extractDocument;
this.inflight = new Map();
this.listeners = new Set();
}
private emit() {
this.listeners.forEach((fn) => fn());
}
subscribe(listener: () => void) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
setFetcher(fetchDocument?: FetchDocument) {
this.fetcher = fetchDocument;
}
ingest(rawDocs: unknown[] = []): { canonical: T[]; changed: boolean } {
const normalize = (docs: unknown[]) => {
if (this.hydrateDocuments) {
return this.hydrateDocuments(docs).filter(Boolean) as T[];
}
if (this.hydrateDocument) {
return docs
.map((doc) => this.hydrateDocument ? this.hydrateDocument(doc) : (doc as T | null))
.filter(Boolean) as T[];
}
return docs.filter(Boolean) as T[];
};
const docs = normalize(rawDocs);
let changed = false;
let nextById = this.byId;
const canonical: T[] = [];
docs.forEach((doc) => {
const id = doc?.id;
if (id == null) {
canonical.push(doc);
return;
}
const existing = nextById.get(id as DocumentId);
const merged = existing ? ({ ...existing, ...doc } as T) : ({ ...(doc as T) } as T);
const useExisting = existing && shallowEqual(existing, merged);
const nextDoc = useExisting ? (existing as T) : merged;
if (!useExisting) {
if (!changed) {
nextById = new Map(this.byId);
}
nextById.set(id as DocumentId, nextDoc);
changed = true;
}
canonical.push(nextDoc);
});
if (changed) {
this.byId = nextById;
this.emit();
}
return { canonical, changed };
}
async ensure(id: DocumentId, fetcherOverride?: FetchDocument): Promise<T | null> {
if (id == null) {
return null;
}
const cached = this.byId.get(id);
if (cached) {
return cached;
}
const fetcher = fetcherOverride || this.fetcher;
if (!fetcher) {
return null;
}
const inflight = this.inflight.get(id);
if (inflight) {
return inflight;
}
const request = (async () => {
try {
const fetched = await fetcher(id);
const extracted = this.extractDocument ? this.extractDocument(fetched) : fetched;
const { canonical } = this.ingest([extracted as unknown]);
return canonical[0] ?? null;
} finally {
this.inflight.delete(id);
}
})();
this.inflight.set(id, request);
return request;
}
map(mapper: (doc: T) => T | undefined): boolean {
if (!this.byId.size) {
return false;
}
let changed = false;
const next = new Map<DocumentId, T>();
this.byId.forEach((doc, key) => {
const updated = mapper(doc);
const nextDoc = updated === undefined ? doc : updated;
if (nextDoc !== doc) {
changed = true;
}
next.set(key, nextDoc ?? doc);
});
if (changed) {
this.byId = next;
this.emit();
}
return changed;
}
remove(ids: Array<DocumentId>): boolean {
if (!Array.isArray(ids) || ids.length === 0) {
return false;
}
let changed = false;
let next = this.byId;
ids.forEach((id) => {
if (next.has(id)) {
if (!changed) {
next = new Map(this.byId);
}
next.delete(id);
changed = true;
}
});
if (changed) {
this.byId = next;
this.emit();
}
return changed;
}
getById(id: DocumentId): T | null {
return this.byId.get(id) ?? null;
}
getMany(ids: Array<DocumentId> = []): T[] {
return ids
.map((id) => this.byId.get(id) || null)
.filter((doc): doc is T => Boolean(doc));
}
getSnapshot(): Map<DocumentId, T> {
return this.byId;
}
}
export default DocumentsManager;
@@ -4,7 +4,7 @@ type Identifier = string | number;
export interface DocumentsFilterValue {
query: string;
searchResults: Array<Record<string, unknown>> | null;
searchResultIds: Array<string | number> | null;
searchLoading: boolean;
includeDescendants: boolean;
activeTagIds: Identifier[];
@@ -27,7 +27,7 @@ export interface UseDocumentsPanelPropsArgs {
refreshCurrentFolder?: () => void | Promise<void>;
currentSubfolders?: unknown[];
documents?: unknown[];
searchResults?: unknown[] | null;
searchResultIds?: Identifier[] | null;
folderClickHandlers: FolderClickHandlers;
selectFolder?: (...args: unknown[]) => void;
handleFolderDragStart?: (...args: unknown[]) => void;
@@ -80,7 +80,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
refreshCurrentFolder,
currentSubfolders,
documents,
searchResults,
searchResultIds,
folderClickHandlers,
selectFolder,
handleFolderDragStart,
@@ -130,7 +130,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
onRefresh: refreshCurrentFolder,
subfolders: currentSubfolders,
documents,
searchResults,
searchResultIds,
onFolderSelect: selectFolder,
onFolderDrop: folderClickHandlers.onDrop,
onFolderDragOver: folderClickHandlers.onDragOver,
@@ -211,7 +211,7 @@ const useDocumentsPanelProps = (props: UseDocumentsPanelPropsArgs) => {
openDocumentPreview,
refreshCurrentFolder,
searchLoading,
searchResults,
searchResultIds,
selectFolder,
tagLookupById,
tags,
@@ -47,7 +47,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
breadcrumbs,
subfolders,
documents,
searchResults,
searchResultIds,
onFolderSelect,
onFolderDrop,
onFolderDragOver,
@@ -104,10 +104,19 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
toggleTag: toggleTagFilter,
toggleCorrespondent: toggleCorrespondentFilter,
} = useDocumentsFilter();
const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents;
const searchDocuments = useMemo(
() =>
Array.isArray(searchResultIds)
? searchResultIds
.map((id) => documentLookup?.get?.(id) || null)
.filter((doc): doc is Record<string, unknown> => Boolean(doc))
: null,
[searchResultIds, documentLookup],
);
const showingSearchResults = Array.isArray(searchResultIds);
const rows = showingSearchResults && searchDocuments ? searchDocuments : documents;
const documentLinkMap = documentLinks instanceof Map ? documentLinks : null;
const searchResultCount = Array.isArray(searchResults) ? searchResults.length : 0;
const searchResultCount = Array.isArray(searchResultIds) ? searchResultIds.length : 0;
const headerTitle = showingSearchResults
? 'Search results'
: currentFolderName || 'Documents';
@@ -201,7 +210,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
const selectionContextRef = useRef(null);
useEffect(() => {
const nextContext = showingSearchResults
? { type: 'search', marker: searchResults }
? { type: 'search', marker: searchResultIds }
: { type: 'folder', marker: currentFolderId || 'root' };
const previous = selectionContextRef.current;
selectionContextRef.current = nextContext;
@@ -213,7 +222,7 @@ const DocumentsPanelInner: React.FC<DocumentsPanelInnerProps> = ({
if (changed) {
clearSelection();
}
}, [showingSearchResults, currentFolderId, searchResults, clearSelection]);
}, [showingSearchResults, currentFolderId, searchResultIds, clearSelection]);
const entries = useMemo(() => {
const list = [];