cleanup
This commit is contained in:
+1
-5247
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { AppShellContext } from '../appShellContext';
|
||||
import DropOverlay from './DropOverlay';
|
||||
import useDocumentsWorkspace from './useDocumentsWorkspace';
|
||||
import { useDocumentsPreferences } from './useDocumentsPreferences';
|
||||
|
||||
const DocumentsAppLayout = () => {
|
||||
const documentsPreferences = useDocumentsPreferences();
|
||||
const {
|
||||
appStatus,
|
||||
location,
|
||||
shellRef,
|
||||
dropOverlayState,
|
||||
managementModals,
|
||||
contextValue,
|
||||
} = useDocumentsWorkspace({
|
||||
documentsViewMode: documentsPreferences.documentsViewMode,
|
||||
documentsSortField: documentsPreferences.documentsSortField,
|
||||
documentsSortDirection: documentsPreferences.documentsSortDirection,
|
||||
documentsSortFieldRef: documentsPreferences.documentsSortFieldRef,
|
||||
documentsSortDirectionRef: documentsPreferences.documentsSortDirectionRef,
|
||||
onDocumentsViewModeChange: documentsPreferences.handleDocumentsViewModeChange,
|
||||
onDocumentsSortFieldChange: documentsPreferences.handleDocumentsSortFieldChange,
|
||||
onDocumentsSortDirectionToggle: documentsPreferences.handleDocumentsSortDirectionToggle,
|
||||
searchIncludeDescendants: documentsPreferences.searchIncludeDescendants,
|
||||
onToggleSearchIncludeDescendants: documentsPreferences.toggleSearchIncludeDescendants,
|
||||
onSetSearchIncludeDescendants: documentsPreferences.setSearchIncludeDescendants,
|
||||
sortRefreshReadyRef: documentsPreferences.sortRefreshReadyRef,
|
||||
deskHelpOpen: documentsPreferences.deskHelpOpen,
|
||||
setDeskHelpOpen: documentsPreferences.setDeskHelpOpen,
|
||||
handleDeskExit: documentsPreferences.handleDeskExit,
|
||||
});
|
||||
|
||||
if (['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
|
||||
const shouldRememberLastLocation = appStatus !== 'logged-out';
|
||||
return (
|
||||
<Navigate
|
||||
to="/account/login"
|
||||
replace
|
||||
state={
|
||||
shouldRememberLastLocation
|
||||
? { from: location.pathname + location.search }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShellContext.Provider value={contextValue}>
|
||||
<div className="app-shell" ref={shellRef}>
|
||||
<DropOverlay
|
||||
active={dropOverlayState.active}
|
||||
folderName={dropOverlayState.folderName}
|
||||
/>
|
||||
<Outlet />
|
||||
{managementModals}
|
||||
</div>
|
||||
</AppShellContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentsAppLayout;
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createAssetView } from '../asset_manager';
|
||||
|
||||
export const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
||||
export const DEFAULT_FOLDER_NAME = 'Documents';
|
||||
export const DEFAULT_SORT_FIELD = 'title';
|
||||
export const DEFAULT_SORT_DIRECTION = 'asc';
|
||||
export const SORT_FIELD_VALUES = ['title', 'issued_at', 'created_at', 'updated_at'];
|
||||
export const TAG_FILTER_UNTAGGED = '__UNTAGGED__';
|
||||
|
||||
const ROW_KEY_SEPARATOR = ':';
|
||||
const DOCUMENT_ROW_PREFIX = 'document';
|
||||
const FOLDER_ROW_PREFIX = 'folder';
|
||||
|
||||
export const resolveApiPath = (path = '') => path;
|
||||
|
||||
const makeRowKey = (type, id) =>
|
||||
id ? `${type}${ROW_KEY_SEPARATOR}${id}` : `${type}${ROW_KEY_SEPARATOR}`;
|
||||
|
||||
const getRowType = (key) => (typeof key === 'string' ? key.split(ROW_KEY_SEPARATOR, 1)[0] : '');
|
||||
|
||||
export const getRowId = (key) => {
|
||||
if (typeof key !== 'string') return '';
|
||||
const separatorIndex = key.indexOf(ROW_KEY_SEPARATOR);
|
||||
if (separatorIndex === -1) return key;
|
||||
return key.slice(separatorIndex + 1);
|
||||
};
|
||||
|
||||
export const isDocumentRowKey = (key) => getRowType(key) === DOCUMENT_ROW_PREFIX;
|
||||
export const isFolderRowKey = (key) => getRowType(key) === FOLDER_ROW_PREFIX;
|
||||
|
||||
export const resolveDocumentRowKey = (documentId) =>
|
||||
documentId ? makeRowKey(DOCUMENT_ROW_PREFIX, documentId) : null;
|
||||
|
||||
export const resolveFolderRowKey = (folderId) =>
|
||||
folderId ? makeRowKey(FOLDER_ROW_PREFIX, folderId) : null;
|
||||
|
||||
export const hasFiles = (event) =>
|
||||
Array.from(event.dataTransfer?.types || []).includes('Files');
|
||||
|
||||
const isAssetEquivalent = (lhs, rhs) => {
|
||||
if (!lhs || !rhs) return false;
|
||||
const lhsView = createAssetView(lhs);
|
||||
const rhsView = createAssetView(rhs);
|
||||
const lhsPrimaryMetadata = lhsView.getPrimaryMetadata() || lhs?.metadata;
|
||||
const rhsPrimaryMetadata = rhsView.getPrimaryMetadata() || rhs?.metadata;
|
||||
const lhsCardinality = lhsView.getCardinality() || lhs?.cardinality || null;
|
||||
const rhsCardinality = rhsView.getCardinality() || rhs?.cardinality || null;
|
||||
const lhsObjects = lhsView.getObjects();
|
||||
const rhsObjects = rhsView.getObjects();
|
||||
const objectsComparable =
|
||||
lhsObjects.length === rhsObjects.length
|
||||
&& lhsObjects.every((entry, index) => {
|
||||
const other = rhsObjects[index];
|
||||
if (!other) return false;
|
||||
if (entry.ordinal !== other.ordinal) return false;
|
||||
if (entry.url && other.url && entry.url === other.url) {
|
||||
return true;
|
||||
}
|
||||
if (!entry.url && !other.url) {
|
||||
return JSON.stringify(entry.metadata || null) === JSON.stringify(other.metadata || null);
|
||||
}
|
||||
return entry.url === other.url;
|
||||
});
|
||||
return (
|
||||
lhs.id === rhs.id
|
||||
&& lhs.url === rhs.url
|
||||
&& lhsPrimaryMetadata?.width === rhsPrimaryMetadata?.width
|
||||
&& lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height
|
||||
&& lhs.mime_type === rhs.mime_type
|
||||
&& lhs.asset_type === rhs.asset_type
|
||||
&& lhs.updated_at === rhs.updated_at
|
||||
&& lhsCardinality === rhsCardinality
|
||||
&& objectsComparable
|
||||
);
|
||||
};
|
||||
|
||||
const mergeAssetIntoGroup = (group, assetData) => {
|
||||
if (!assetData || !assetData.asset_type) {
|
||||
if (Array.isArray(group)) {
|
||||
return group;
|
||||
}
|
||||
return group || {};
|
||||
}
|
||||
|
||||
if (Array.isArray(group) || !group) {
|
||||
const list = Array.isArray(group) ? group : [];
|
||||
const index = list.findIndex((item) => item?.id === assetData.id);
|
||||
if (index >= 0) {
|
||||
const existing = list[index];
|
||||
if (isAssetEquivalent(existing, assetData)) {
|
||||
return list;
|
||||
}
|
||||
const next = list.slice();
|
||||
next[index] = { ...existing, ...assetData };
|
||||
return next;
|
||||
}
|
||||
return list.concat({ ...assetData });
|
||||
}
|
||||
|
||||
const key = assetData.asset_type;
|
||||
const previous = group?.[key];
|
||||
if (previous && isAssetEquivalent(previous, assetData)) {
|
||||
return group;
|
||||
}
|
||||
|
||||
const next = { ...(group || {}) };
|
||||
next[key] = { ...(previous || {}), ...assetData };
|
||||
return next;
|
||||
};
|
||||
|
||||
export const mergeAssetIntoDocument = (doc, assetData) => {
|
||||
if (!doc) return doc;
|
||||
const existingGroup = doc.current_version?.assets || null;
|
||||
const nextGroup = mergeAssetIntoGroup(existingGroup, assetData);
|
||||
if (nextGroup === existingGroup) {
|
||||
return doc;
|
||||
}
|
||||
const updatedCurrentVersion = doc.current_version
|
||||
? { ...doc.current_version, assets: nextGroup }
|
||||
: { assets: nextGroup };
|
||||
return { ...doc, current_version: updatedCurrentVersion };
|
||||
};
|
||||
|
||||
export const createRootNode = () => ({
|
||||
id: 'root',
|
||||
name: DEFAULT_FOLDER_NAME,
|
||||
parentId: null,
|
||||
children: [],
|
||||
expanded: true,
|
||||
loaded: false,
|
||||
hasChildren: false,
|
||||
});
|
||||
@@ -1,10 +1,5 @@
|
||||
import React, { useContext, useEffect, useMemo, useReducer } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
});
|
||||
import api from '../lib/api';
|
||||
|
||||
const storage = typeof window !== 'undefined' ? window.sessionStorage : undefined;
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const useDocumentPreview = ({
|
||||
routeDocumentId,
|
||||
documents,
|
||||
searchResults,
|
||||
setDocuments,
|
||||
selectedFolder,
|
||||
assetManager,
|
||||
api,
|
||||
resolveApiPath,
|
||||
notifyApiError,
|
||||
navigate,
|
||||
locationPathname,
|
||||
locationSearch,
|
||||
detailPanelControlRef,
|
||||
setActivePreviewId,
|
||||
}) => {
|
||||
const [previewEntries, setPreviewEntries] = useState(() => new Map());
|
||||
const previewInflightRef = useRef(new Map());
|
||||
const previewReturnPathRef = useRef(null);
|
||||
|
||||
const resetPreviewState = useCallback(() => {
|
||||
setPreviewEntries(() => new Map());
|
||||
previewInflightRef.current = new Map();
|
||||
previewReturnPathRef.current = null;
|
||||
}, []);
|
||||
|
||||
const removePreviewEntries = useCallback((ids) => {
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
setPreviewEntries((prev) => {
|
||||
if (!prev.size) {
|
||||
return prev;
|
||||
}
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
ids.forEach((id) => {
|
||||
if (next.delete(id)) {
|
||||
changed = true;
|
||||
}
|
||||
previewInflightRef.current.delete(id);
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const ensurePreviewUrl = useCallback(
|
||||
async (documentId, { force = false } = {}) => {
|
||||
if (!documentId) return null;
|
||||
|
||||
const existing = previewEntries.get(documentId) || null;
|
||||
const now = Date.now();
|
||||
const expiresAt = typeof existing?.expiresAt === 'number' ? existing.expiresAt : null;
|
||||
if (!force && existing && (!expiresAt || expiresAt > now)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (!force && previewInflightRef.current.has(documentId)) {
|
||||
return previewInflightRef.current.get(documentId);
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const docResponse = await api.get(`/documents/${documentId}`);
|
||||
const downloadPath = docResponse.data?.document?.current_version?.download_path;
|
||||
if (!downloadPath || !resolveApiPath) {
|
||||
throw new Error('Document missing download path');
|
||||
}
|
||||
|
||||
const href = resolveApiPath(downloadPath);
|
||||
const entry = {
|
||||
url: href,
|
||||
contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
|
||||
filename: docResponse.data?.document?.filename,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||
};
|
||||
setPreviewEntries((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(documentId, entry);
|
||||
return next;
|
||||
});
|
||||
return entry;
|
||||
} catch (error) {
|
||||
notifyApiError(error, 'Unable to fetch document preview.');
|
||||
throw error;
|
||||
} finally {
|
||||
previewInflightRef.current.delete(documentId);
|
||||
}
|
||||
})();
|
||||
|
||||
previewInflightRef.current.set(documentId, request);
|
||||
return request;
|
||||
},
|
||||
[previewEntries, api, resolveApiPath, notifyApiError, setPreviewEntries],
|
||||
);
|
||||
|
||||
const ensurePreviewData = useCallback(
|
||||
async (documentId) => {
|
||||
if (!documentId) return null;
|
||||
|
||||
const findInCache = () => {
|
||||
const pool = searchResults ?? documents;
|
||||
return pool.find((item) => item.id === documentId) || null;
|
||||
};
|
||||
|
||||
let doc = findInCache();
|
||||
|
||||
if (!doc) {
|
||||
const { data } = await api.get(`/documents/${documentId}`);
|
||||
const hydratedDetail = assetManager.hydrateDetail(data);
|
||||
const fetched = hydratedDetail?.document || data.document || data;
|
||||
doc = fetched ? assetManager.hydrateDocument(fetched) : null;
|
||||
if (!doc) {
|
||||
throw new Error('Document metadata unavailable.');
|
||||
}
|
||||
|
||||
setDocuments((prev) => {
|
||||
if (prev.some((item) => item.id === doc.id)) {
|
||||
return prev;
|
||||
}
|
||||
return [doc, ...prev];
|
||||
});
|
||||
}
|
||||
|
||||
if (!previewReturnPathRef.current) {
|
||||
const fallbackFolderId = doc?.folder_id || 'root';
|
||||
previewReturnPathRef.current =
|
||||
fallbackFolderId === 'root' ? '/documents' : `/documents/folder/${fallbackFolderId}`;
|
||||
}
|
||||
|
||||
await ensurePreviewUrl(documentId, { force: false });
|
||||
setActivePreviewId(documentId);
|
||||
return doc;
|
||||
},
|
||||
[
|
||||
searchResults,
|
||||
documents,
|
||||
assetManager,
|
||||
setDocuments,
|
||||
ensurePreviewUrl,
|
||||
setActivePreviewId,
|
||||
api,
|
||||
],
|
||||
);
|
||||
|
||||
const openDocumentPreview = useCallback(
|
||||
(documentId, { replace = false } = {}) => {
|
||||
if (!documentId) return;
|
||||
detailPanelControlRef.current.close();
|
||||
previewReturnPathRef.current = `${locationPathname}${locationSearch}`;
|
||||
navigate(`/documents/${documentId}`, { replace });
|
||||
},
|
||||
[navigate, locationPathname, locationSearch, detailPanelControlRef],
|
||||
);
|
||||
|
||||
const closeDocumentPreview = useCallback(
|
||||
(folderId = null) => {
|
||||
const fallbackPath = previewReturnPathRef.current;
|
||||
previewReturnPathRef.current = null;
|
||||
|
||||
if (fallbackPath) {
|
||||
navigate(fallbackPath, { replace: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = folderId || selectedFolder || 'root';
|
||||
const path = targetId === 'root' ? '/documents' : `/documents/folder/${targetId}`;
|
||||
navigate(path, { replace: false });
|
||||
},
|
||||
[navigate, selectedFolder],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeDocumentId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDocumentPreview();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [routeDocumentId, closeDocumentPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeDocumentId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
ensurePreviewData(routeDocumentId).catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
notifyApiError(error, 'Failed to open document preview.');
|
||||
closeDocumentPreview();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [routeDocumentId, ensurePreviewData, notifyApiError, closeDocumentPreview]);
|
||||
|
||||
return {
|
||||
previewEntries,
|
||||
ensurePreviewUrl,
|
||||
ensurePreviewData,
|
||||
openDocumentPreview,
|
||||
closeDocumentPreview,
|
||||
resetPreviewState,
|
||||
removePreviewEntries,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentPreview;
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
DEFAULT_SORT_DIRECTION,
|
||||
DEFAULT_SORT_FIELD,
|
||||
SORT_FIELD_VALUES,
|
||||
} from './appLayoutUtils';
|
||||
|
||||
const VIEW_MODE_STORAGE_KEY = 'papercrate_view_mode';
|
||||
const SORT_FIELD_STORAGE_KEY = 'papercrate_sort_field';
|
||||
const SORT_DIRECTION_STORAGE_KEY = 'papercrate_sort_direction';
|
||||
const INCLUDE_DESCENDANTS_STORAGE_KEY = 'papercrate_include_descendants';
|
||||
|
||||
const readSessionStorage = (key) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return window.sessionStorage.getItem(key);
|
||||
} catch (error) {
|
||||
console.warn(`[session-storage] failed to read ${key}`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeSessionStorage = (key, value) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.sessionStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
console.warn(`[session-storage] failed to persist ${key}`, error);
|
||||
}
|
||||
};
|
||||
|
||||
export const useDocumentsPreferences = () => {
|
||||
const [documentsViewMode, setDocumentsViewModeState] = useState(() => {
|
||||
const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY);
|
||||
if (stored === 'grid' || stored === 'desk') {
|
||||
return stored;
|
||||
}
|
||||
return 'list';
|
||||
});
|
||||
|
||||
const [deskHelpOpen, setDeskHelpOpen] = useState(false);
|
||||
const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode);
|
||||
|
||||
useEffect(() => {
|
||||
if (documentsViewMode !== 'desk') {
|
||||
lastNonDeskViewRef.current = documentsViewMode;
|
||||
} else if (deskHelpOpen) {
|
||||
setDeskHelpOpen(false);
|
||||
}
|
||||
}, [documentsViewMode, deskHelpOpen]);
|
||||
|
||||
const setDocumentsViewMode = useCallback((mode) => {
|
||||
const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list';
|
||||
setDocumentsViewModeState((previous) => {
|
||||
if (next !== previous) {
|
||||
writeSessionStorage(VIEW_MODE_STORAGE_KEY, next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDeskExit = useCallback(() => {
|
||||
const fallback = lastNonDeskViewRef.current && lastNonDeskViewRef.current !== 'desk'
|
||||
? lastNonDeskViewRef.current
|
||||
: 'list';
|
||||
setDocumentsViewMode(fallback);
|
||||
}, [setDocumentsViewMode]);
|
||||
|
||||
const [documentsSortField, setDocumentsSortField] = useState(() => {
|
||||
const stored = readSessionStorage(SORT_FIELD_STORAGE_KEY);
|
||||
return SORT_FIELD_VALUES.includes(stored) ? stored : DEFAULT_SORT_FIELD;
|
||||
});
|
||||
const documentsSortFieldRef = useRef(documentsSortField);
|
||||
useEffect(() => {
|
||||
documentsSortFieldRef.current = documentsSortField;
|
||||
writeSessionStorage(SORT_FIELD_STORAGE_KEY, documentsSortField);
|
||||
}, [documentsSortField]);
|
||||
|
||||
const [documentsSortDirection, setDocumentsSortDirection] = useState(() => {
|
||||
const stored = readSessionStorage(SORT_DIRECTION_STORAGE_KEY);
|
||||
return stored === 'desc' || stored === 'asc' ? stored : DEFAULT_SORT_DIRECTION;
|
||||
});
|
||||
const documentsSortDirectionRef = useRef(documentsSortDirection);
|
||||
useEffect(() => {
|
||||
documentsSortDirectionRef.current = documentsSortDirection;
|
||||
writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection);
|
||||
}, [documentsSortDirection]);
|
||||
|
||||
const handleDocumentsSortFieldChange = useCallback((field) => {
|
||||
const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD;
|
||||
setDocumentsSortField((previous) => (previous === nextField ? previous : nextField));
|
||||
}, []);
|
||||
|
||||
const handleDocumentsSortDirectionToggle = useCallback(() => {
|
||||
setDocumentsSortDirection((previous) => (previous === 'asc' ? 'desc' : 'asc'));
|
||||
}, []);
|
||||
|
||||
const [searchIncludeDescendants, setSearchIncludeDescendants] = useState(() => {
|
||||
const stored = readSessionStorage(INCLUDE_DESCENDANTS_STORAGE_KEY);
|
||||
if (stored === 'true') return true;
|
||||
if (stored === 'false') return false;
|
||||
return true;
|
||||
});
|
||||
useEffect(() => {
|
||||
writeSessionStorage(
|
||||
INCLUDE_DESCENDANTS_STORAGE_KEY,
|
||||
searchIncludeDescendants ? 'true' : 'false',
|
||||
);
|
||||
}, [searchIncludeDescendants]);
|
||||
|
||||
const toggleSearchIncludeDescendants = useCallback(() => {
|
||||
setSearchIncludeDescendants((previous) => !previous);
|
||||
}, []);
|
||||
|
||||
const sortRefreshReadyRef = useRef(false);
|
||||
|
||||
return {
|
||||
documentsViewMode,
|
||||
handleDocumentsViewModeChange: setDocumentsViewMode,
|
||||
handleDeskExit,
|
||||
deskHelpOpen,
|
||||
setDeskHelpOpen,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
documentsSortFieldRef,
|
||||
documentsSortDirectionRef,
|
||||
handleDocumentsSortFieldChange,
|
||||
handleDocumentsSortDirectionToggle,
|
||||
searchIncludeDescendants,
|
||||
setSearchIncludeDescendants,
|
||||
toggleSearchIncludeDescendants,
|
||||
sortRefreshReadyRef,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
TAG_FILTER_UNTAGGED,
|
||||
resolveDocumentRowKey,
|
||||
isDocumentRowKey,
|
||||
} from './appLayoutUtils';
|
||||
|
||||
const useDocumentsSearch = ({
|
||||
api,
|
||||
assetManager,
|
||||
token,
|
||||
selectedFolder,
|
||||
navigate,
|
||||
locationPathname,
|
||||
isDocumentsRoute,
|
||||
selectionHelpers,
|
||||
searchIncludeDescendants,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
notifyApiError,
|
||||
setLoading,
|
||||
setSearchIncludeDescendants,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTagFilters, setActiveTagFilters] = useState([]);
|
||||
const [activeCorrespondentFilters, setActiveCorrespondentFilters] = useState([]);
|
||||
const [searchResults, setSearchResults] = useState(null);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
|
||||
const toggleTagFilter = useCallback((tagId) => {
|
||||
if (!tagId) return;
|
||||
setActiveTagFilters((previous) => {
|
||||
if (tagId === TAG_FILTER_UNTAGGED) {
|
||||
return previous.includes(TAG_FILTER_UNTAGGED) ? [] : [TAG_FILTER_UNTAGGED];
|
||||
}
|
||||
const sanitized = previous.filter((id) => id !== TAG_FILTER_UNTAGGED);
|
||||
if (sanitized.includes(tagId)) {
|
||||
return sanitized.filter((id) => id !== tagId);
|
||||
}
|
||||
return sanitized.concat([tagId]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleCorrespondentFilter = useCallback((correspondentId) => {
|
||||
setActiveCorrespondentFilters((previous) => {
|
||||
if (!correspondentId) {
|
||||
return [];
|
||||
}
|
||||
return previous.includes(correspondentId) ? [] : [correspondentId];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isFilterActive = useMemo(
|
||||
() =>
|
||||
searchQuery.trim().length > 0
|
||||
|| activeTagFilters.length > 0
|
||||
|| activeCorrespondentFilters.length > 0,
|
||||
[searchQuery, activeTagFilters, activeCorrespondentFilters],
|
||||
);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setSearchQuery('');
|
||||
setActiveTagFilters([]);
|
||||
setActiveCorrespondentFilters([]);
|
||||
setSearchLoading(false);
|
||||
setSearchIncludeDescendants(true);
|
||||
}, [
|
||||
setSearchIncludeDescendants,
|
||||
]);
|
||||
|
||||
const handleSearchChange = useCallback((value) => {
|
||||
setSearchQuery(value);
|
||||
}, []);
|
||||
|
||||
const handleSearchSubmit = useCallback(() => {
|
||||
if (!navigate) return;
|
||||
const targetFolder = selectedFolder && selectedFolder !== 'root' ? selectedFolder : 'root';
|
||||
const targetPath = targetFolder === 'root' ? '/documents' : `/documents/folder/${targetFolder}`;
|
||||
if (!isDocumentsRoute || locationPathname !== targetPath) {
|
||||
navigate(targetPath, { replace: false });
|
||||
}
|
||||
}, [navigate, selectedFolder, isDocumentsRoute, locationPathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return undefined;
|
||||
|
||||
if (!isFilterActive) {
|
||||
setSearchResults(null);
|
||||
setSearchLoading(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let started = false;
|
||||
setSearchLoading(true);
|
||||
|
||||
const debounce = setTimeout(async () => {
|
||||
started = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = {};
|
||||
const trimmedQuery = searchQuery.trim();
|
||||
if (trimmedQuery.length) {
|
||||
params.query = trimmedQuery;
|
||||
}
|
||||
if (activeTagFilters.length) {
|
||||
const onlyUntagged = activeTagFilters.length === 1
|
||||
&& activeTagFilters[0] === TAG_FILTER_UNTAGGED;
|
||||
if (onlyUntagged) {
|
||||
params.tags = 'none';
|
||||
} else {
|
||||
const tagIds = activeTagFilters.filter((id) => id !== TAG_FILTER_UNTAGGED);
|
||||
if (tagIds.length) {
|
||||
params.tags = tagIds.join(',');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (activeCorrespondentFilters.length) {
|
||||
params.correspondents = activeCorrespondentFilters.join(',');
|
||||
}
|
||||
const folderIdentifier = selectedFolder === 'root' ? null : selectedFolder;
|
||||
if (folderIdentifier) {
|
||||
params.folder_id = folderIdentifier;
|
||||
}
|
||||
if (!searchIncludeDescendants) {
|
||||
params.include_descendants = false;
|
||||
}
|
||||
if (documentsSortField) {
|
||||
params.sort = documentsSortField;
|
||||
}
|
||||
if (documentsSortDirection) {
|
||||
params.dir = documentsSortDirection;
|
||||
}
|
||||
const { data } = await api.get('/documents', { params });
|
||||
if (cancelled) return;
|
||||
|
||||
const results = assetManager.hydrateDocuments(data || []);
|
||||
setSearchResults(results);
|
||||
|
||||
if (!results.length) {
|
||||
setSearchLoading(false);
|
||||
selectionHelpers.setSelectedEntries([]);
|
||||
selectionHelpers.setFocusedDocumentId(null);
|
||||
selectionHelpers.selectionOrderRef.current = [];
|
||||
selectionHelpers.setSelectionOrder([]);
|
||||
selectionHelpers.selectionAnchorRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const resultKeys = results
|
||||
.map((doc) => resolveDocumentRowKey(doc.id))
|
||||
.filter(Boolean);
|
||||
|
||||
let targetKey = null;
|
||||
let nextSelectionKeys = [];
|
||||
|
||||
selectionHelpers.setSelectedEntries((previous) => {
|
||||
const previousDocKeys = previous.filter(isDocumentRowKey);
|
||||
const filtered = previousDocKeys.filter((key) => resultKeys.includes(key));
|
||||
if (filtered.length) {
|
||||
targetKey = filtered[filtered.length - 1];
|
||||
nextSelectionKeys = filtered;
|
||||
return filtered;
|
||||
}
|
||||
targetKey = null;
|
||||
nextSelectionKeys = [];
|
||||
return [];
|
||||
});
|
||||
|
||||
selectionHelpers.selectionOrderRef.current = nextSelectionKeys;
|
||||
selectionHelpers.setSelectionOrder(nextSelectionKeys);
|
||||
|
||||
selectionHelpers.setFocusedDocumentId((previous) => {
|
||||
if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) {
|
||||
return previous;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
selectionHelpers.selectionAnchorRef.current = targetKey;
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
notifyApiError(error, 'Search failed. Please try again.');
|
||||
setSearchResults(null);
|
||||
} finally {
|
||||
if (!cancelled && started) {
|
||||
setLoading(false);
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(debounce);
|
||||
if (started) {
|
||||
setLoading(false);
|
||||
setSearchLoading(false);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
api,
|
||||
token,
|
||||
isFilterActive,
|
||||
searchQuery,
|
||||
activeTagFilters,
|
||||
activeCorrespondentFilters,
|
||||
searchIncludeDescendants,
|
||||
documentsSortField,
|
||||
documentsSortDirection,
|
||||
selectedFolder,
|
||||
notifyApiError,
|
||||
assetManager,
|
||||
selectionHelpers,
|
||||
setLoading,
|
||||
]);
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
searchResults,
|
||||
setSearchResults,
|
||||
searchLoading,
|
||||
setSearchLoading,
|
||||
activeTagFilters,
|
||||
setActiveTagFilters,
|
||||
activeCorrespondentFilters,
|
||||
setActiveCorrespondentFilters,
|
||||
toggleTagFilter,
|
||||
toggleCorrespondentFilter,
|
||||
isFilterActive,
|
||||
clearFilters,
|
||||
handleSearchChange,
|
||||
handleSearchSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDocumentsSearch;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from '../hooks/documents/useDocumentsWorkspace';
|
||||
Reference in New Issue
Block a user