documentsmanager
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
Reference in New Issue
Block a user