typescript

This commit is contained in:
2025-11-13 01:07:55 +01:00
parent b812d748ea
commit ada089c05b
147 changed files with 7427 additions and 2534 deletions
@@ -7,7 +7,7 @@ import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace';
import { useDocumentsPreferences } from './useDocumentsPreferences';
import SettingsRoute from './SettingsRoute';
const AppLayout = () => {
const AppLayout: React.FC = () => {
const documentsPreferences = useDocumentsPreferences();
const {
appStatus,
@@ -1,9 +1,14 @@
import React from 'react';
import React, { ReactNode } from 'react';
import Sidebar from '../sidebar/Sidebar';
import { useSidebarContext } from '../sidebar/SidebarContext';
import { usePanelManager } from './PanelManagerContext';
const DocumentsLayout = ({ sidebarProps, children }) => {
interface DocumentsLayoutProps {
sidebarProps?: Record<string, unknown>;
children?: ReactNode;
}
const DocumentsLayout: React.FC<DocumentsLayoutProps> = ({ sidebarProps = {}, children }) => {
const { collapsed } = useSidebarContext();
const { sidebarSuppressed } = usePanelManager();
const sidebarHidden = collapsed || sidebarSuppressed;
@@ -8,7 +8,9 @@ import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import { SidebarProvider, useSidebarContext } from '../sidebar/SidebarContext';
import { PanelManagerProvider, usePanelManager } from './PanelManagerContext';
const DocumentsRouteContent = () => {
type Breadcrumb = { id?: string | number; name?: string; label?: string; title?: string };
const DocumentsRouteContent: React.FC = () => {
const {
sidebarProps,
documentsTableProps,
@@ -64,7 +66,7 @@ const DocumentsRouteContent = () => {
navigate(target);
}, [navigate, parentBreadcrumb]);
const handleHeaderBreadcrumbClick = useCallback((crumb) => {
const handleHeaderBreadcrumbClick = useCallback((crumb: Breadcrumb) => {
if (!crumb || !crumb.id) {
return;
}
@@ -121,7 +123,7 @@ const DocumentsRouteContent = () => {
const header = surface.header || null;
let headerTitle = null;
let headerTitle: React.ReactNode = null;
if (header) {
const breadcrumbEntries = Array.isArray(header.breadcrumbs) ? header.breadcrumbs.filter(Boolean) : [];
const lastIndex = breadcrumbEntries.length - 1;
@@ -176,7 +178,7 @@ const DocumentsRouteContent = () => {
);
};
const DocumentsRoute = () => (
const DocumentsRoute: React.FC = () => (
<SidebarProvider>
<PanelManagerProvider>
<DocumentsRouteContent />
@@ -1,6 +1,11 @@
import React from 'react';
const DropOverlay = ({ active, folderName }) => (
interface DropOverlayProps {
active?: boolean;
folderName?: string | null;
}
const DropOverlay: React.FC<DropOverlayProps> = ({ active = false, folderName }) => (
<div className={`drop-overlay${active ? ' active' : ''}`}>
<div className="drop-overlay__content">
Drop files to upload to <strong>{folderName || 'this location'}</strong>
@@ -11,11 +11,31 @@ import {
} from '../utils/webauthn';
import { api, useAppDispatch, useAppState } from './appState';
const LoginRoute = () => {
const { status: appStatus, tenantSelection } = useAppState();
type StatusVariant = 'info' | 'success' | 'error';
interface StatusMessage {
message: string;
variant: StatusVariant;
}
interface TenantOption {
id?: string | number | null;
name?: string | null;
}
interface TenantSelectionState {
selectionToken?: string | null;
tenants?: TenantOption[];
}
const LoginRoute: React.FC = () => {
const appState = useAppState();
const { status: appStatus } = appState;
const tenantSelection = (appState.tenantSelection ?? null) as TenantSelectionState | null;
const appDispatch = useAppDispatch();
const location = useLocation();
const [status, setStatus] = useState(null);
const [status, setStatus] = useState<StatusMessage | null>(null);
const [selectingTenantId, setSelectingTenantId] = useState(null);
const passkeySupported = isWebAuthnAvailable();
const [passkeyLoading, setPasskeyLoading] = useState(false);
@@ -1,4 +1,5 @@
import React, {
ReactNode,
createContext,
useCallback,
useContext,
@@ -7,48 +8,85 @@ import React, {
useRef,
useState,
} from 'react';
import type { CSSProperties, PointerEvent as ReactPointerEvent, RefObject } from 'react';
import { useSidebarContext } from '../sidebar/SidebarContext';
const PanelManagerContext = createContext(null);
type PanelKey = 'sidebar' | 'detail';
const PANEL_LIMITS = {
interface PanelLimits {
maxRatio: number;
minPx: number;
}
interface SetPanelWidthOptions {
commit?: boolean;
log?: boolean;
}
interface PanelManagerContextValue {
sidebarWidth: number;
detailWidth: number;
sidebarSuppressed: boolean;
resizingPanel: PanelKey | null;
setPanelWidth: (panel: PanelKey, width: number, options?: SetPanelWidthOptions) => number;
startPanelResize: (panel: PanelKey) => void;
stopPanelResize: () => void;
getPanelWidth: (panel: PanelKey) => number;
setDetailActive: (isOpen: boolean) => void;
closeDetailPanel: () => void;
collapseSidebar: () => void;
expandSidebar: () => void;
registerDetailCloseHandler: (handler?: (() => void) | null) => void;
detailPanelOpen: boolean;
}
type PanelResizeBindings = {
panelStyle?: CSSProperties;
handleProps: {
onPointerDown?: (event: ReactPointerEvent<HTMLDivElement>) => void;
};
isPanelResizing: boolean;
};
const PanelManagerContext = createContext<PanelManagerContextValue | null>(null);
const PANEL_LIMITS: Record<PanelKey, PanelLimits> = {
sidebar: {
minRatio: 1 / 6,
maxRatio: 1 / 4,
minPx: 240,
maxRatio: 1 / 3,
minPx: 280,
},
detail: {
minRatio: 1 / 4,
maxRatio: 3 / 4,
maxRatio: 2 / 3,
minPx: 320,
},
};
const STORAGE_KEYS = {
const STORAGE_KEYS: Record<PanelKey, string> = {
sidebar: 'papercrate_sidebar_width',
detail: 'papercrate_detail_width',
};
const MINIMAL_FREE_RATIO = 1 / 3;
const DEFAULT_SIDEBAR_WIDTH = 320;
const DEFAULT_DETAIL_WIDTH = 420;
const clampPanelWidth = (panel, value) => {
const MINIMAL_FREE_RATIO = 1 / 3;
const SIDEBAR_SOLO_THRESHOLD = 1 / 2;
const MINIMUM_MAIN_CONTENT_WIDTH = 160;
const clampPanelWidth = (panel: PanelKey, value: number): number => {
const numeric = Number(value);
const limits = PANEL_LIMITS[panel];
if (!limits) {
return numeric;
}
const viewport = window.innerWidth;
const ratioMin = Math.round(viewport * limits.minRatio);
const minLimit = Math.max(0, limits.minPx);
const ratioMax = Math.round(viewport * limits.maxRatio);
const minLimit = Math.max(0, Math.max(ratioMin, limits.minPx));
const rawMax = Math.max(minLimit, ratioMax);
const maxAllowed = Math.min(rawMax, viewport - 160);
const targetMax = Math.max(minLimit, maxAllowed);
return Math.min(Math.max(numeric, minLimit), targetMax);
const rawMax = Math.max(ratioMax, minLimit);
const maxAllowed = Math.min(rawMax, viewport - MINIMUM_MAIN_CONTENT_WIDTH);
const targetMax = Math.min(viewport, Math.max(minLimit, maxAllowed));
return Math.min(Math.max(numeric, minLimit), Math.max(0, targetMax));
};
const readStoredWidth = (panel, fallback) => {
const raw = window?.localStorage?.getItem(STORAGE_KEYS[panel]);
const readStoredWidth = (panel: PanelKey, fallback: number): number => {
const raw = window.localStorage.getItem(STORAGE_KEYS[panel]);
if (!raw) {
return fallback;
}
@@ -56,22 +94,26 @@ const readStoredWidth = (panel, fallback) => {
return Number.isFinite(parsed) ? parsed : fallback;
};
const persistWidth = (panel, value) => {
window?.localStorage?.setItem(STORAGE_KEYS[panel], String(Math.round(value)));
const persistWidth = (panel: PanelKey, value: number): void => {
window.localStorage.setItem(STORAGE_KEYS[panel], String(Math.round(value)));
};
const applyPanelWidthToRoot = (panel, width) => {
const applyPanelWidthToRoot = (panel: PanelKey, width: number): void => {
if (!Number.isFinite(width)) {
return;
}
const varName = panel === 'sidebar' ? '--sidebar-width' : '--detail-panel-width';
document?.documentElement?.style.setProperty(varName, `${width}px`);
document.documentElement.style.setProperty(varName, `${width}px`);
};
export const PanelManagerProvider = ({ children }) => {
interface PanelManagerProviderProps {
children: ReactNode;
}
export const PanelManagerProvider: React.FC<PanelManagerProviderProps> = ({ children }) => {
const { collapsed, setCollapsed } = useSidebarContext();
const initialSidebarWidth = readStoredWidth('sidebar', 320);
const initialDetailWidth = readStoredWidth('detail', 420);
const initialSidebarWidth = readStoredWidth('sidebar', DEFAULT_SIDEBAR_WIDTH);
const initialDetailWidth = readStoredWidth('detail', DEFAULT_DETAIL_WIDTH);
const [sidebarWidth, setSidebarWidthState] = useState(() => clampPanelWidth('sidebar', initialSidebarWidth));
const [detailWidth, setDetailWidthState] = useState(() => clampPanelWidth('detail', initialDetailWidth));
@@ -81,6 +123,7 @@ export const PanelManagerProvider = ({ children }) => {
const detailCloseHandlerRef = useRef(null);
const panelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
const preferredPanelWidthsRef = useRef({ sidebar: sidebarWidth, detail: detailWidth });
const closeDetailPanel = useCallback(() => {
const handler = detailCloseHandlerRef.current;
@@ -97,15 +140,12 @@ export const PanelManagerProvider = ({ children }) => {
applyPanelWidthToRoot('detail', detailWidth);
}, [detailWidth]);
useEffect(() => {
persistWidth('sidebar', sidebarWidth);
}, [sidebarWidth]);
useEffect(() => {
persistWidth('detail', detailWidth);
}, [detailWidth]);
const logPanelState = useCallback((panel, action, value) => {
const handlePanelLayoutChange = useCallback((
panel: PanelKey,
action: 'opened' | 'closed' | 'resized',
value?: number,
{ detailOpen }: { detailOpen?: boolean } = {},
) => {
const viewportWidth = window.innerWidth;
const sidebarWidth = panelWidthsRef.current.sidebar;
const detailWidth = panelWidthsRef.current.detail;
@@ -113,6 +153,7 @@ export const PanelManagerProvider = ({ children }) => {
const freeRatio = viewportWidth > 0 ? freeSpace / viewportWidth : 0;
const meetsThreshold = freeRatio >= MINIMAL_FREE_RATIO;
const effectiveDetailOpen = detailOpen ?? detailPanelOpen;
if (panel === 'detail' && !collapsed) {
if (action === 'opened' || action === 'resized') {
@@ -122,7 +163,7 @@ export const PanelManagerProvider = ({ children }) => {
}
}
if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && detailPanelOpen) {
if (panel === 'sidebar' && (action === 'opened' || action === 'resized') && !meetsThreshold && effectiveDetailOpen) {
closeDetailPanel();
}
@@ -138,18 +179,17 @@ export const PanelManagerProvider = ({ children }) => {
minimalFreeRatio: MINIMAL_FREE_RATIO,
meetsThreshold,
});
}, [collapsed, sidebarSuppressed, closeDetailPanel, detailPanelOpen]);
}, [collapsed, closeDetailPanel, detailPanelOpen]);
const collapseSidebar = useCallback(() => {
if (!collapsed) {
setCollapsed(true);
setSidebarSuppressed(false);
logPanelState('sidebar', 'closed');
handlePanelLayoutChange('sidebar', 'closed');
}
}, [collapsed, setCollapsed, logPanelState]);
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
const setPanelWidth = useCallback(
(panel, width, commit = true) => {
(panel: PanelKey, width: number, { commit = true, log = true }: SetPanelWidthOptions = {}) => {
const clamped = clampPanelWidth(panel, width);
if (!Number.isFinite(clamped)) {
return panelWidthsRef.current[panel];
@@ -162,34 +202,78 @@ export const PanelManagerProvider = ({ children }) => {
panelWidthsRef.current[panel] = clamped;
applyPanelWidthToRoot(panel, clamped);
if (commit) {
preferredPanelWidthsRef.current[panel] = clamped;
persistWidth(panel, clamped);
}
logPanelState(panel, 'resized', clamped);
if (log) {
handlePanelLayoutChange(panel, 'resized', clamped);
}
return clamped;
},
[collapsed, logPanelState, sidebarSuppressed],
[handlePanelLayoutChange],
);
const registerDetailCloseHandler = useCallback((handler = null) => {
detailCloseHandlerRef.current = typeof handler === 'function' ? handler : null;
const resetSidebarPreferredWidth = useCallback(() => {
if (preferredPanelWidthsRef.current.sidebar === DEFAULT_SIDEBAR_WIDTH) {
return;
}
preferredPanelWidthsRef.current.sidebar = DEFAULT_SIDEBAR_WIDTH;
persistWidth('sidebar', DEFAULT_SIDEBAR_WIDTH);
}, []);
const clampPanelsWithinViewport = useCallback(() => {
const viewportWidth = Math.max(0, Number(window.innerWidth) || 0);
if (viewportWidth === 0) {
return;
}
const desiredSidebarWidth = preferredPanelWidthsRef.current.sidebar;
const desiredDetailWidth = preferredPanelWidthsRef.current.detail;
let sidebarDisplayWidth = Math.min(desiredSidebarWidth, viewportWidth);
let remainingWidth = Math.max(0, viewportWidth - sidebarDisplayWidth);
let detailDisplayWidth = Math.min(desiredDetailWidth, remainingWidth);
if (detailPanelOpen && sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) {
sidebarDisplayWidth = 0;
detailDisplayWidth = Math.min(desiredDetailWidth || viewportWidth, viewportWidth);
} else if (sidebarDisplayWidth > viewportWidth * SIDEBAR_SOLO_THRESHOLD) {
sidebarDisplayWidth = viewportWidth;
detailDisplayWidth = 0;
resetSidebarPreferredWidth();
}
panelWidthsRef.current.sidebar = sidebarDisplayWidth;
panelWidthsRef.current.detail = detailDisplayWidth;
setSidebarWidthState((prev) => (prev === sidebarDisplayWidth ? prev : sidebarDisplayWidth));
setDetailWidthState((prev) => (prev === detailDisplayWidth ? prev : detailDisplayWidth));
}, [detailPanelOpen, resetSidebarPreferredWidth]);
useEffect(() => {
clampPanelsWithinViewport();
window.addEventListener('resize', clampPanelsWithinViewport);
return () => window.removeEventListener('resize', clampPanelsWithinViewport);
}, [clampPanelsWithinViewport]);
const registerDetailCloseHandler = useCallback((handler: (() => void) | null = null) => {
detailCloseHandlerRef.current = handler ?? null;
}, []);
const setDetailActive = useCallback(
(isOpen) => {
setDetailPanelOpen(Boolean(isOpen));
logPanelState('detail', isOpen ? 'opened' : 'closed');
handlePanelLayoutChange('detail', isOpen ? 'opened' : 'closed');
},
[logPanelState],
[handlePanelLayoutChange],
);
const expandSidebar = useCallback(() => {
if (collapsed) {
setCollapsed(false);
}
setSidebarSuppressed(false);
logPanelState('sidebar', 'opened');
}, [collapsed, setCollapsed, logPanelState]);
handlePanelLayoutChange('sidebar', 'opened');
}, [collapsed, setCollapsed, handlePanelLayoutChange]);
const startPanelResize = useCallback((panel) => {
setResizingPanel(panel);
@@ -247,7 +331,10 @@ export const usePanelManager = () => {
return context;
};
export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true } = {}) => {
export const usePanelResizeBindings = (
panel: PanelKey,
{ panelRef = null, enabled = true }: { panelRef?: RefObject<HTMLElement> | null; enabled?: boolean } = {},
): PanelResizeBindings => {
const {
sidebarWidth,
detailWidth,
@@ -277,7 +364,7 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true
useEffect(() => () => teardownListeners(), [teardownListeners]);
const handlePointerDown = useCallback(
(event) => {
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!enabled || !panelRef?.current) {
return;
}
@@ -294,18 +381,18 @@ export const usePanelResizeBindings = (panel, { panelRef = null, enabled = true
event.currentTarget?.setPointerCapture?.(pointerId);
let lastWidth = startWidth;
const handlePointerMove = (moveEvent) => {
const handlePointerMove = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== pointerId) {
return;
}
const delta = panel === 'sidebar'
? moveEvent.clientX - startX
: startX - moveEvent.clientX;
lastWidth = setPanelWidth(panel, startWidth + delta, false);
lastWidth = setPanelWidth(panel, startWidth + delta, { commit: false });
latestWidthRef.current = lastWidth;
};
const handlePointerUp = (upEvent) => {
const handlePointerUp = (upEvent: PointerEvent) => {
if (upEvent.pointerId !== pointerId) {
return;
}
@@ -6,7 +6,12 @@ import useCapabilitySets from '../settings/useCapabilitySets';
import useCapabilities from '../settings/useCapabilities';
import { api } from './appState';
const SettingsRoute = ({ open = true, onClose }) => {
interface SettingsRouteProps {
open?: boolean;
onClose?: () => void;
}
const SettingsRoute: React.FC<SettingsRouteProps> = ({ open = true, onClose }) => {
const {
token,
notifyApiError,
@@ -19,7 +24,7 @@ const SettingsRoute = ({ open = true, onClose }) => {
refreshPasskeys,
registerPasskey,
revokePasskey,
} = useAppShell();
} = useAppShell() as Record<string, any>;
const {
tokens,
@@ -1,4 +1,5 @@
import React, { useMemo, useState, useEffect } from 'react';
import { useMemo, useState, useEffect } from 'react';
import type { JSX } from 'react';
import { useNavigate } from 'react-router-dom';
import {
CloseIcon,
@@ -12,7 +13,23 @@ import {
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
const STATUS_META = {
type UploadStatus = 'pending' | 'uploading' | 'success' | 'duplicate' | 'error' | (string & {});
interface UploadQueueItem {
id: string | number;
name: string;
status: UploadStatus;
error?: string | null;
document?: { id?: string | number; title?: string };
conflictDocumentId?: string | number;
}
interface UploadQueueOverlayProps {
queue?: UploadQueueItem[];
onClearQueue?: () => void;
}
const STATUS_META: Record<string, { label: string; tone: string; icon: JSX.Element }> = {
pending: {
label: 'Queued',
tone: 'muted',
@@ -40,7 +57,7 @@ const STATUS_META = {
},
};
const UploadQueueOverlay = ({ queue = [], onClearQueue }) => {
const UploadQueueOverlay = ({ queue = [], onClearQueue }: UploadQueueOverlayProps): JSX.Element | null => {
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
const [dismissed, setDismissed] = useState(false);
@@ -1,10 +1,52 @@
import React, { useContext, useEffect, useMemo, useReducer } from 'react';
import api from '../lib/api';
const storage = window.sessionStorage;
type Tenant = Record<string, unknown> | null;
interface TenantSelection {
selectionToken: string;
tenants: Tenant[];
}
type AppStatus =
| 'logged-out'
| 'authenticating'
| 'authenticated'
| 'selecting-tenant'
| 'bootstrapping'
| 'ready';
interface AppState {
status: AppStatus;
token: string;
error: string | null;
isRefreshing: boolean;
tenantSelection: TenantSelection | null;
tenant: Tenant;
tenants: Tenant[];
}
type AppAction =
| { type: 'LOGIN_REQUEST' }
| { type: 'LOGIN_SUCCESS'; token: string; tenant?: Tenant }
| { type: 'LOGIN_FAILURE'; error?: string | null }
| { type: 'TENANT_SELECTION_REQUIRED'; selectionToken: string; tenants: Tenant[] }
| { type: 'CLEAR_TENANT_SELECTION' }
| { type: 'LOGOUT_SUCCESS' }
| { type: 'BOOTSTRAP_START' }
| { type: 'BOOTSTRAP_SUCCESS' }
| { type: 'BOOTSTRAP_FAILURE'; error?: string | null }
| { type: 'TOKEN_REFRESH_START' }
| { type: 'TOKEN_REFRESH_SUCCESS'; token: string; tenant?: Tenant }
| { type: 'TOKEN_REFRESH_FAILURE'; error?: string | null }
| { type: 'LOGOUT' }
| { type: 'RESET_ERROR' }
| { type: 'SET_TENANTS'; tenants: Tenant[] };
const storage = typeof window !== 'undefined' ? window.sessionStorage : null;
const STORED_TOKEN = storage?.getItem('papercrate_token') ?? '';
let STORED_TENANT = null;
let STORED_TENANT: Tenant = null;
if (storage) {
try {
@@ -21,7 +63,7 @@ if (STORED_TOKEN) {
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
}
const initialAppState = {
const initialAppState: AppState = {
status: STORED_TOKEN ? 'authenticated' : 'logged-out',
token: STORED_TOKEN,
error: null,
@@ -31,10 +73,10 @@ const initialAppState = {
tenants: [],
};
const AppStateContext = React.createContext(null);
const AppDispatchContext = React.createContext(null);
const AppStateContext = React.createContext<AppState | null>(null);
const AppDispatchContext = React.createContext<React.Dispatch<AppAction> | null>(null);
const appStateReducer = (state, action) => {
const appStateReducer = (state: AppState, action: AppAction): AppState => {
switch (action.type) {
case 'LOGIN_REQUEST':
return {
@@ -52,14 +94,14 @@ const appStateReducer = (state, action) => {
token: action.token,
error: null,
tenantSelection: null,
tenant: action.tenant || null,
tenant: action.tenant ?? null,
tenants: state.tenants,
};
case 'LOGIN_FAILURE':
return {
status: 'logged-out',
token: '',
error: action.error || null,
error: action.error ?? null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
@@ -89,6 +131,7 @@ const appStateReducer = (state, action) => {
tenants: [],
};
case 'LOGOUT_SUCCESS':
case 'LOGOUT':
return {
status: 'logged-out',
token: '',
@@ -103,7 +146,7 @@ const appStateReducer = (state, action) => {
case 'BOOTSTRAP_SUCCESS':
return { ...state, status: 'ready', error: null };
case 'BOOTSTRAP_FAILURE':
return { ...state, status: 'authenticated', error: action.error || null };
return { ...state, status: 'authenticated', error: action.error ?? null };
case 'TOKEN_REFRESH_START':
return { ...state, isRefreshing: true, error: null };
case 'TOKEN_REFRESH_SUCCESS':
@@ -113,24 +156,14 @@ const appStateReducer = (state, action) => {
isRefreshing: false,
status: state.status === 'logged-out' ? 'authenticated' : state.status,
tenantSelection: null,
tenant: action.tenant || state.tenant || null,
tenant: action.tenant ?? state.tenant ?? null,
tenants: state.tenants,
};
case 'TOKEN_REFRESH_FAILURE':
return {
status: 'logged-out',
token: '',
error: action.error || null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'LOGOUT':
return {
status: 'logged-out',
token: '',
error: null,
error: action.error ?? null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
@@ -148,7 +181,7 @@ const appStateReducer = (state, action) => {
}
};
const AppStateProvider = ({ children }) => {
const AppStateProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(appStateReducer, initialAppState);
useEffect(() => {
@@ -216,7 +249,7 @@ const AppStateProvider = ({ children }) => {
);
};
const useAppState = () => {
const useAppState = (): AppState => {
const context = useContext(AppStateContext);
if (!context) {
throw new Error('useAppState must be used within an AppStateProvider.');
@@ -224,7 +257,7 @@ const useAppState = () => {
return context;
};
const useAppDispatch = () => {
const useAppDispatch = (): React.Dispatch<AppAction> => {
const context = useContext(AppDispatchContext);
if (!context) {
throw new Error('useAppDispatch must be used within an AppStateProvider.');
@@ -1,13 +1,30 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export interface DetailDocument {
id?: string | number;
[key: string]: unknown;
}
interface UseDetailPanelOptions {
documentLookup: Map<string | number, DetailDocument>;
orderedSelectedDocuments: DetailDocument[];
}
interface OpenDetailPanelArgs {
documentId?: string | number;
document?: DetailDocument | null;
documentIds?: Array<string | number>;
documents?: DetailDocument[];
}
export const useDetailPanel = ({
documentLookup,
orderedSelectedDocuments,
}) => {
}: UseDetailPanelOptions) => {
const [detailPanelOpen, setDetailPanelOpen] = useState(false);
const [detailPanelDocId, setDetailPanelDocId] = useState(null);
const [detailPanelDocument, setDetailPanelDocument] = useState(null);
const latestOrderedDocsRef = useRef([]);
const [detailPanelDocId, setDetailPanelDocId] = useState<string | number | null>(null);
const [detailPanelDocument, setDetailPanelDocument] = useState<DetailDocument | null>(null);
const latestOrderedDocsRef = useRef<DetailDocument[]>([]);
useEffect(() => {
latestOrderedDocsRef.current = orderedSelectedDocuments;
@@ -34,7 +51,7 @@ export const useDetailPanel = ({
}, [detailPanelDocId, documentLookup, detailPanelDocument, detailPanelOpen]);
const openDetailPanel = useCallback(
({ documentId, document, documentIds, documents } = {}) => {
({ documentId, document, documentIds, documents }: OpenDetailPanelArgs = {}) => {
let targetDoc = document || null;
let targetId = documentId ?? document?.id ?? null;
@@ -66,7 +83,7 @@ export const useDetailPanel = ({
return;
}
setDetailPanelDocId(targetDoc?.id || targetId || null);
setDetailPanelDocId(targetDoc?.id ?? targetId ?? null);
setDetailPanelDocument(targetDoc || null);
setDetailPanelOpen(Boolean(targetDoc || targetId));
},
@@ -1,4 +1,72 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
Dispatch,
MutableRefObject,
SetStateAction,
} from 'react';
type DocumentId = string | number;
type FolderId = DocumentId | 'root';
type DocumentLike = {
id?: DocumentId;
folder_id?: FolderId | null;
filename?: string | null;
current_version?: Record<string, unknown>;
[key: string]: unknown;
};
type PreviewEntry = {
url?: string;
contentType?: string | null;
filename?: string | null;
expiresAt?: number;
canGoPrev?: boolean;
canGoNext?: boolean;
goPrev?: () => void;
goNext?: () => void;
} | null;
interface AssetManagerLike {
hydrateDetail: (payload: unknown) => { document?: DocumentLike } | null | undefined;
hydrateDocument: (payload: unknown) => DocumentLike | null | undefined;
}
interface ApiClient {
get: <T = unknown>(path: string) => Promise<{ data: T }>;
}
type NavigateHandler = (path: string, options?: { replace?: boolean }) => void;
interface UseDocumentPreviewArgs {
routeDocumentId?: DocumentId | null;
documents: DocumentLike[];
searchResults: DocumentLike[] | null;
selectedFolder?: FolderId | null;
assetManager: AssetManagerLike;
api: ApiClient;
resolveApiPath?: (path: string) => string;
notifyApiError: (error: unknown, message: string) => void;
navigate: NavigateHandler;
locationPathname: string;
locationSearch: string;
detailPanelControlRef: MutableRefObject<{
open?: (args?: { documentIds?: DocumentId[] }) => void;
close?: () => void;
} | null>;
setActivePreviewId: Dispatch<SetStateAction<DocumentId | null>>;
}
interface UseDocumentPreviewResult {
previewEntries: Map<DocumentId, PreviewEntry>;
previewDocuments: Map<DocumentId, DocumentLike>;
ensurePreviewUrl: (documentId: DocumentId | null, options?: { force?: boolean }) => Promise<PreviewEntry | null>;
ensurePreviewData: (documentId: DocumentId | null) => Promise<DocumentLike | null>;
openDocumentPreview: (documentId: DocumentId | null, options?: { replace?: boolean }) => void;
closeDocumentPreview: (folderId?: FolderId | null) => void;
resetPreviewState: () => void;
removePreviewEntries: (ids: DocumentId[]) => void;
}
const useDocumentPreview = ({
routeDocumentId,
@@ -14,11 +82,11 @@ const useDocumentPreview = ({
locationSearch,
detailPanelControlRef,
setActivePreviewId,
}) => {
const [previewEntries, setPreviewEntries] = useState(() => new Map());
const [previewDocuments, setPreviewDocuments] = useState(() => new Map());
const previewInflightRef = useRef(new Map());
const previewReturnPathRef = useRef(null);
}: UseDocumentPreviewArgs): UseDocumentPreviewResult => {
const [previewEntries, setPreviewEntries] = useState<Map<DocumentId, PreviewEntry>>(() => new Map());
const [previewDocuments, setPreviewDocuments] = useState<Map<DocumentId, DocumentLike>>(() => new Map());
const previewInflightRef = useRef<Map<DocumentId, Promise<PreviewEntry | null>>>(new Map());
const previewReturnPathRef = useRef<string | null>(null);
const resetPreviewState = useCallback(() => {
setPreviewEntries(() => new Map());
@@ -26,7 +94,7 @@ const useDocumentPreview = ({
previewReturnPathRef.current = null;
}, []);
const removePreviewEntries = useCallback((ids) => {
const removePreviewEntries = useCallback((ids: DocumentId[]) => {
if (!Array.isArray(ids) || ids.length === 0) {
return;
}
@@ -46,7 +114,7 @@ const useDocumentPreview = ({
});
}, []);
const cachePreviewDocument = useCallback((doc) => {
const cachePreviewDocument = useCallback((doc: DocumentLike | null | undefined) => {
if (!doc?.id) {
return;
}
@@ -61,7 +129,7 @@ const useDocumentPreview = ({
});
}, []);
const removeCachedPreviewDocument = useCallback((documentId) => {
const removeCachedPreviewDocument = useCallback((documentId?: DocumentId | null) => {
if (!documentId) {
return;
}
@@ -76,7 +144,7 @@ const useDocumentPreview = ({
}, []);
const ensurePreviewUrl = useCallback(
async (documentId, { force = false } = {}) => {
async (documentId: DocumentId | null, { force = false }: { force?: boolean } = {}): Promise<PreviewEntry | null> => {
if (!documentId) return null;
const existing = previewEntries.get(documentId) || null;
@@ -87,19 +155,19 @@ const useDocumentPreview = ({
}
if (!force && previewInflightRef.current.has(documentId)) {
return previewInflightRef.current.get(documentId);
return previewInflightRef.current.get(documentId) || null;
}
const request = (async () => {
const request: Promise<PreviewEntry | null> = (async () => {
try {
const docResponse = await api.get(`/documents/${documentId}`);
const docResponse = await api.get<{ document?: Record<string, any> }>(`/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 = {
const entry: PreviewEntry = {
url: href,
contentType: docResponse.data?.document?.current_version?.version?.content_type || null,
filename: docResponse.data?.document?.filename,
@@ -122,11 +190,11 @@ const useDocumentPreview = ({
previewInflightRef.current.set(documentId, request);
return request;
},
[previewEntries, api, resolveApiPath, notifyApiError, setPreviewEntries],
[previewEntries, api, resolveApiPath, notifyApiError],
);
const ensurePreviewData = useCallback(
async (documentId) => {
async (documentId: DocumentId | null): Promise<DocumentLike | null> => {
if (!documentId) return null;
const findInCache = () => {
@@ -139,18 +207,18 @@ const useDocumentPreview = ({
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;
const fetched = hydratedDetail?.document || (data as { document?: DocumentLike })?.document || data;
doc = fetched ? assetManager.hydrateDocument(fetched) || null : null;
if (!doc) {
throw new Error('Document metadata unavailable.');
}
const existsInDocuments = documents.some((item) => item.id === doc.id);
const existsInDocuments = documents.some((item) => item.id === doc?.id);
const existsInSearch = Array.isArray(searchResults)
? searchResults.some((item) => item.id === doc.id)
? searchResults.some((item) => item.id === doc?.id)
: false;
if (existsInDocuments || existsInSearch) {
if (doc?.id && (existsInDocuments || existsInSearch)) {
removeCachedPreviewDocument(doc.id);
} else {
cachePreviewDocument(doc);
@@ -180,9 +248,9 @@ const useDocumentPreview = ({
);
const openDocumentPreview = useCallback(
(documentId, { replace = false } = {}) => {
(documentId: DocumentId | null, { replace = false }: { replace?: boolean } = {}) => {
if (!documentId) return;
detailPanelControlRef.current.close();
detailPanelControlRef.current?.close?.();
previewReturnPathRef.current = `${locationPathname}${locationSearch}`;
navigate(`/documents/${documentId}`, { replace });
},
@@ -190,7 +258,7 @@ const useDocumentPreview = ({
);
const closeDocumentPreview = useCallback(
(folderId = null) => {
(folderId: FolderId | null = null) => {
const fallbackPath = previewReturnPathRef.current;
previewReturnPathRef.current = null;
@@ -211,7 +279,7 @@ const useDocumentPreview = ({
return undefined;
}
const handleKeyDown = (event) => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeDocumentPreview();
}
@@ -248,7 +316,7 @@ const useDocumentPreview = ({
}
const next = new Map(prev);
let changed = false;
const prune = (list) => {
const prune = (list?: DocumentLike[] | null) => {
if (!Array.isArray(list)) {
return;
}
@@ -1,6 +1,30 @@
import { useCallback, useRef, useState } from 'react';
const DEFAULT_INITIAL_ENTRIES = [];
type RowKey = string;
type DocumentId = string | number;
interface SelectionEventLike {
shiftKey?: boolean;
metaKey?: boolean;
ctrlKey?: boolean;
preventDefault?: () => void;
}
interface UseDocumentSelectionOptions {
resolveDocumentRowKey: (id: DocumentId | null | undefined) => RowKey | null | undefined;
resolveFolderRowKey: (id: DocumentId | null | undefined) => RowKey | null | undefined;
isDocumentRowKey: (key?: RowKey | null) => boolean;
isFolderRowKey: (key?: RowKey | null) => boolean;
getRowId: (key?: RowKey | null) => DocumentId | null | undefined;
initialEntries?: RowKey[];
}
interface ApplySelectionOptions {
anchor?: RowKey | null;
interactedKeys?: RowKey[];
}
const DEFAULT_INITIAL_ENTRIES: RowKey[] = [];
export const useDocumentSelection = ({
resolveDocumentRowKey,
@@ -9,21 +33,24 @@ export const useDocumentSelection = ({
isFolderRowKey,
getRowId,
initialEntries = DEFAULT_INITIAL_ENTRIES,
}) => {
const [selectedEntries, setSelectedEntries] = useState(initialEntries);
const [selectionOrder, setSelectionOrder] = useState(initialEntries);
const selectionOrderRef = useRef(initialEntries);
const selectionAnchorRef = useRef(null);
}: UseDocumentSelectionOptions) => {
const [selectedEntries, setSelectedEntries] = useState<RowKey[]>(initialEntries);
const [selectionOrder, setSelectionOrder] = useState<RowKey[]>(initialEntries);
const selectionOrderRef = useRef<RowKey[]>(initialEntries);
const selectionAnchorRef = useRef<RowKey | null>(null);
const selectionInitializedRef = useRef(false);
const [focusedDocumentId, setFocusedDocumentId] = useState(null);
const [focusedRowKey, setFocusedRowKey] = useState(null);
const [focusedDocumentId, setFocusedDocumentId] = useState<DocumentId | null>(null);
const [focusedRowKey, setFocusedRowKey] = useState<RowKey | null>(null);
const visibleRowKeySetRef = useRef(new Set());
const navigableRowKeysRef = useRef([]);
const visibleRowKeySetRef = useRef<Set<RowKey>>(new Set());
const navigableRowKeysRef = useRef<RowKey[]>([]);
const configureSelectionEnvironment = useCallback(({
visibleRowKeySet,
navigableRowKeys,
}: {
visibleRowKeySet?: Set<RowKey>;
navigableRowKeys?: RowKey[];
}) => {
if (visibleRowKeySet) {
visibleRowKeySetRef.current = visibleRowKeySet;
@@ -33,7 +60,7 @@ export const useDocumentSelection = ({
}
}, []);
const updateSelectionOrder = useCallback((nextSelection, interactedKeys = []) => {
const updateSelectionOrder = useCallback((nextSelection: RowKey[], interactedKeys: RowKey[] = []) => {
const nextSet = new Set(nextSelection);
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
const interacted = (interactedKeys || []).filter((id, index, array) => array.indexOf(id) === index);
@@ -65,13 +92,16 @@ export const useDocumentSelection = ({
}, []);
const applySelection = useCallback(
(rowKeys, { anchor, interactedKeys = [] } = {}) => {
(
rowKeys: Array<RowKey | null | undefined>,
{ anchor, interactedKeys = [] }: ApplySelectionOptions = {},
) => {
const visibleRowKeySet = visibleRowKeySetRef.current;
const unique = [];
const unique: RowKey[] = [];
(rowKeys || []).forEach((key) => {
(rowKeys || []).forEach((key) => {
if (!key) return;
let canonicalKey = null;
let canonicalKey: RowKey | null | undefined = null;
if (visibleRowKeySet.has(key)) {
canonicalKey = key;
} else if (isDocumentRowKey(key)) {
@@ -99,7 +129,7 @@ export const useDocumentSelection = ({
setSelectedEntries(unique);
updateSelectionOrder(unique, interactedKeys);
const nextFocusedDocumentId = (() => {
const nextFocusedDocumentId: DocumentId | null = (() => {
if (focusedDocumentId) {
const focusKey = resolveDocumentRowKey(focusedDocumentId);
if (focusKey && unique.includes(focusKey)) {
@@ -108,11 +138,11 @@ export const useDocumentSelection = ({
}
if (resolvedAnchor && isDocumentRowKey(resolvedAnchor)) {
return getRowId(resolvedAnchor) || null;
return getRowId(resolvedAnchor) ?? null;
}
const lastDocKey = [...unique].reverse().find(isDocumentRowKey);
return lastDocKey ? getRowId(lastDocKey) || null : null;
const lastDocKey = [...unique].reverse().find((key) => isDocumentRowKey(key)) ?? null;
return lastDocKey ? getRowId(lastDocKey) ?? null : null;
})();
setFocusedDocumentId(nextFocusedDocumentId);
@@ -144,7 +174,7 @@ export const useDocumentSelection = ({
}, [applySelection]);
const handleEntrySelection = useCallback(
(rowKey, event) => {
(rowKey: RowKey | null | undefined, event?: SelectionEventLike) => {
const visibleRowKeySet = visibleRowKeySetRef.current;
const navigableRowKeys = navigableRowKeysRef.current;
if (!rowKey || !visibleRowKeySet.has(rowKey)) {
@@ -170,8 +200,8 @@ export const useDocumentSelection = ({
anchorKey = rowKey;
}
let nextKeys = [];
let interactedKeys = [];
let nextKeys: RowKey[] = [];
let interactedKeys: RowKey[] = [];
if (shiftKey && anchorKey) {
const anchorIndex = navigableRowKeys.indexOf(anchorKey);
@@ -213,7 +243,7 @@ export const useDocumentSelection = ({
);
const promoteSelectionOrder = useCallback(
(docId) => {
(docId?: DocumentId | null) => {
if (!docId) return;
const rowKey = resolveDocumentRowKey(docId);
if (!rowKey) return;
@@ -10,7 +10,7 @@ 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) => {
const readSessionStorage = (key: string): string | null => {
try {
return window.sessionStorage.getItem(key);
} catch (error) {
@@ -19,7 +19,7 @@ const readSessionStorage = (key) => {
}
};
const writeSessionStorage = (key, value) => {
const writeSessionStorage = (key: string, value: string): void => {
try {
window.sessionStorage.setItem(key, value);
} catch (error) {
@@ -28,7 +28,7 @@ const writeSessionStorage = (key, value) => {
};
export const useDocumentsPreferences = () => {
const [documentsViewMode, setDocumentsViewModeState] = useState(() => {
const [documentsViewMode, setDocumentsViewModeState] = useState<'list' | 'grid' | 'desk'>(() => {
const stored = readSessionStorage(VIEW_MODE_STORAGE_KEY);
if (stored === 'grid' || stored === 'desk') {
return stored;
@@ -36,9 +36,9 @@ export const useDocumentsPreferences = () => {
return 'list';
});
const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode);
const lastNonDeskViewRef = useRef<'list' | 'grid'>((documentsViewMode === 'desk' ? 'list' : documentsViewMode) as 'list' | 'grid');
const setDocumentsViewMode = useCallback((mode) => {
const setDocumentsViewMode = useCallback((mode: string) => {
const next = mode === 'grid' ? 'grid' : mode === 'desk' ? 'desk' : 'list';
setDocumentsViewModeState((previous) => {
if (next !== previous) {
@@ -75,7 +75,7 @@ export const useDocumentsPreferences = () => {
writeSessionStorage(SORT_DIRECTION_STORAGE_KEY, documentsSortDirection);
}, [documentsSortDirection]);
const handleDocumentsSortFieldChange = useCallback((field) => {
const handleDocumentsSortFieldChange = useCallback((field: string) => {
const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD;
setDocumentsSortField((previous) => (previous === nextField ? previous : nextField));
}, []);
@@ -1,10 +1,77 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import {
TAG_FILTER_UNTAGGED,
resolveDocumentRowKey,
isDocumentRowKey,
} from './appLayoutUtils';
type Identifier = string | number;
type DocumentLike = { id?: Identifier } & Record<string, unknown>;
type ApiClient = {
get: <T = unknown>(url: string, config?: { params?: Record<string, unknown> }) => Promise<{ data: T }>;
};
type AssetManagerLike = {
hydrateDocuments: (payload: unknown[]) => DocumentLike[];
};
type SelectionHelpers = {
setSelectedEntries: (
updater:
| Array<Identifier | string>
| ((prev: Array<Identifier | string>) => Array<Identifier | string>),
) => void;
setSelectionOrder: (
updater:
| Array<Identifier | string>
| ((prev: Array<Identifier | string>) => Array<Identifier | string>),
) => void;
selectionOrderRef: { current: Array<Identifier | string> };
selectionAnchorRef: { current: Identifier | string | null };
setFocusedDocumentId: (
updater: Identifier | null | ((prev: Identifier | null) => Identifier | null),
) => void;
};
interface UseDocumentsSearchArgs {
api: ApiClient;
assetManager: AssetManagerLike;
token?: string | null;
selectedFolder?: Identifier | 'root' | null;
navigate?: (path: string, options?: { replace?: boolean }) => void;
locationPathname?: string;
isDocumentsRoute?: boolean;
selectionHelpers: SelectionHelpers;
searchIncludeDescendants?: boolean;
documentsSortField?: string;
documentsSortDirection?: string;
notifyApiError: (error: unknown, message: string) => void;
setLoading: (state: boolean) => void;
setSearchIncludeDescendants: (value: boolean) => void;
}
interface UseDocumentsSearchResult {
searchQuery: string;
setSearchQuery: Dispatch<SetStateAction<string>>;
searchResults: DocumentLike[] | null;
setSearchResults: Dispatch<SetStateAction<DocumentLike[] | null>>;
searchLoading: boolean;
setSearchLoading: Dispatch<SetStateAction<boolean>>;
activeTagFilters: Identifier[];
setActiveTagFilters: Dispatch<SetStateAction<Identifier[]>>;
activeCorrespondentFilters: Identifier[];
setActiveCorrespondentFilters: Dispatch<SetStateAction<Identifier[]>>;
toggleTagFilter: (tagId: Identifier) => void;
toggleCorrespondentFilter: (correspondentId?: Identifier | null) => void;
isFilterActive: boolean;
clearFilters: () => void;
handleSearchChange: (value: string) => void;
handleSearchSubmit: () => void;
}
const useDocumentsSearch = ({
api,
assetManager,
@@ -20,14 +87,14 @@ const useDocumentsSearch = ({
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);
}: 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 [searchLoading, setSearchLoading] = useState<boolean>(false);
const toggleTagFilter = useCallback((tagId) => {
const toggleTagFilter = useCallback((tagId: Identifier) => {
if (!tagId) return;
setActiveTagFilters((previous) => {
if (tagId === TAG_FILTER_UNTAGGED) {
@@ -41,7 +108,7 @@ const useDocumentsSearch = ({
});
}, []);
const toggleCorrespondentFilter = useCallback((correspondentId) => {
const toggleCorrespondentFilter = useCallback((correspondentId?: Identifier | null) => {
setActiveCorrespondentFilters((previous) => {
if (!correspondentId) {
return [];
@@ -68,7 +135,7 @@ const useDocumentsSearch = ({
setSearchIncludeDescendants,
]);
const handleSearchChange = useCallback((value) => {
const handleSearchChange = useCallback((value: string) => {
setSearchQuery(value);
}, []);
@@ -98,7 +165,7 @@ const useDocumentsSearch = ({
started = true;
setLoading(true);
try {
const params = {};
const params: Record<string, unknown> = {};
const trimmedQuery = searchQuery.trim();
if (trimmedQuery.length) {
params.query = trimmedQuery;
@@ -131,7 +198,7 @@ const useDocumentsSearch = ({
if (documentsSortDirection) {
params.dir = documentsSortDirection;
}
const { data } = await api.get('/documents', { params });
const { data } = await api.get<unknown[]>('/documents', { params });
if (cancelled) return;
const results = assetManager.hydrateDocuments(data || []);
@@ -148,11 +215,11 @@ const useDocumentsSearch = ({
}
const resultKeys = results
.map((doc) => resolveDocumentRowKey(doc.id))
.filter(Boolean);
.map((doc) => resolveDocumentRowKey(doc.id as Identifier))
.filter(Boolean) as Array<Identifier | string>;
let targetKey = null;
let nextSelectionKeys = [];
let targetKey: Identifier | string | null = null;
let nextSelectionKeys: Array<Identifier | string> = [];
selectionHelpers.setSelectedEntries((previous) => {
const previousDocKeys = previous.filter(isDocumentRowKey);
@@ -171,7 +238,7 @@ const useDocumentsSearch = ({
selectionHelpers.setSelectionOrder(nextSelectionKeys);
selectionHelpers.setFocusedDocumentId((previous) => {
if (previous && resultKeys.includes(resolveDocumentRowKey(previous))) {
if (previous && resultKeys.includes(resolveDocumentRowKey(previous as Identifier))) {
return previous;
}
return null;
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import TagsPanel from '../tags/TagsPanel';
import CorrespondentsPanel from '../correspondents/CorrespondentsPanel';
import PanelHeader from '../ui/PanelHeader';
@@ -6,21 +7,56 @@ import PanelHeader from '../ui/PanelHeader';
const TAGS_MODAL = 'tags';
const CORRESPONDENTS_MODAL = 'correspondents';
interface TagRecord {
id?: string | number;
label?: string;
[key: string]: unknown;
}
interface CorrespondentRecord {
id?: string | number;
name?: string;
[key: string]: unknown;
}
interface UseManagementModalsArgs {
locationPathname?: string;
tags?: TagRecord[];
refreshTags?: () => void | Promise<void>;
onTagCreate?: (...args: any[]) => void | Promise<void>;
onTagUpdate?: (...args: any[]) => void | Promise<void>;
onTagDelete?: (...args: any[]) => void | Promise<void>;
correspondents?: CorrespondentRecord[];
refreshCorrespondents?: () => void | Promise<void>;
onCorrespondentCreate?: (...args: any[]) => void | Promise<void>;
onCorrespondentUpdate?: (...args: any[]) => void | Promise<void>;
onCorrespondentDelete?: (...args: any[]) => void | Promise<void>;
setStatusMessage?: (message: string, variant?: string) => void;
}
interface UseManagementModalsResult {
managementModals: ReactNode;
openTagsModal: () => void;
openCorrespondentsModal: () => void;
closeActiveModal: () => void;
activeModal: string | null;
}
export const useManagementModals = ({
locationPathname,
tags,
tags = [],
refreshTags,
onTagCreate,
onTagUpdate,
onTagDelete,
correspondents,
correspondents = [],
refreshCorrespondents,
onCorrespondentCreate,
onCorrespondentUpdate,
onCorrespondentDelete,
setStatusMessage,
}) => {
const [activeModal, setActiveModal] = useState(null);
}: UseManagementModalsArgs): UseManagementModalsResult => {
const [activeModal, setActiveModal] = useState<string | null>(null);
const openTagsModal = useCallback(() => setActiveModal(TAGS_MODAL), []);
const openCorrespondentsModal = useCallback(
@@ -37,7 +73,7 @@ export const useManagementModals = ({
if (!activeModal) {
return undefined;
}
const handleKeyDown = (event) => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
setActiveModal(null);
@@ -1,17 +1,34 @@
import { useCallback, useMemo } from 'react';
import { useDocumentSelection } from './useDocumentSelection';
const identity = (value) => value;
type RowKey = string;
interface SelectionEntry {
rowKey?: RowKey;
[key: string]: unknown;
}
interface WorkspaceSelectionOptions {
resolveDocumentRowKey?: (id: string | number) => RowKey | null | undefined;
resolveFolderRowKey?: (id: string | number) => RowKey | null | undefined;
isDocumentRowKey?: (key: RowKey | SelectionEntry) => boolean;
isFolderRowKey?: (key: RowKey | SelectionEntry) => boolean;
getRowId?: (key: RowKey | SelectionEntry) => string | number | null;
onInspectDocument?: (id: string | number) => void;
onInspectFolder?: (id: string | number) => void;
}
const identity = <T,>(value: T) => value;
export const useWorkspaceSelection = ({
resolveDocumentRowKey,
resolveFolderRowKey,
isDocumentRowKey,
isFolderRowKey,
getRowId,
isDocumentRowKey = () => false,
isFolderRowKey = () => false,
getRowId = () => null,
onInspectDocument = identity,
onInspectFolder = identity,
} = {}) => {
}: WorkspaceSelectionOptions = {}) => {
const selection = useDocumentSelection({
resolveDocumentRowKey,
resolveFolderRowKey,
@@ -58,8 +75,10 @@ export const useWorkspaceSelection = ({
);
const selectEntry = useCallback(
(entry, event) => {
const rowKey = entry?.rowKey ?? (typeof entry?.split === 'function' ? entry : null);
(entry: SelectionEntry | string | null, event?: unknown) => {
const rowKey = typeof entry === 'string'
? entry
: entry?.rowKey ?? null;
if (!rowKey) return;
handleEntrySelection(rowKey, event);
},
@@ -67,7 +86,7 @@ export const useWorkspaceSelection = ({
);
const inspectDocument = useCallback(
(documentId) => {
(documentId?: string | number | null) => {
if (!documentId) return;
onInspectDocument(documentId);
},
@@ -75,7 +94,7 @@ export const useWorkspaceSelection = ({
);
const inspectFolder = useCallback(
(folderId) => {
(folderId?: string | number | null) => {
if (!folderId) return;
onInspectFolder(folderId);
},
@@ -1,17 +1,63 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import type { ReactNode } from 'react';
import { SidebarExpandIcon } from '../ui/icons';
import { createDocumentsSurface } from '../documents/DocumentsPanel';
import { createDocumentViewerSurface } from '../preview/DocumentViewerPanel';
import createDesktopSurface from '../desktop/createDesktopSurface';
import { usePanelManager } from './PanelManagerContext';
type Identifier = string | number;
type EnsureAssetUrl = (docId: Identifier, asset: unknown, options?: Record<string, unknown>) => Promise<unknown> | void;
type EnsurePreviewData = (docId: Identifier, options?: Record<string, unknown>) => Promise<unknown>;
type GetDocumentAsset = (document: unknown, assetType: string) => unknown;
type ResolveApiPath = (path: string) => string;
type NotifyApiError = (error: unknown, fallbackMessage?: string) => void;
type DocumentsSurface = ReturnType<typeof createDocumentsSurface> | null;
type PreviewSurface = ReturnType<typeof createDocumentViewerSurface> | null;
type DesktopSurface = ReturnType<typeof createDesktopSurface> | null;
type WorkspaceSurface = DocumentsSurface | PreviewSurface | DesktopSurface | null;
interface Breadcrumb {
id?: Identifier;
name?: string;
label?: string;
title?: string;
}
interface UseWorkspaceSurfaceArgs {
sidebarHidden?: boolean;
onExpandSidebar?: () => void;
documentsTableProps?: Record<string, any> | null;
detailPanelProps?: (Record<string, any> & { onClose?: () => void }) | null;
detailPanelOpen?: boolean;
viewMode?: string;
deskWorkspaceProps?: Record<string, any> | null;
previewWorkspaceDocument?: unknown;
previewWorkspaceEntry?: unknown;
previewDocumentId?: Identifier | null;
ensureAssetUrl?: EnsureAssetUrl;
ensurePreviewData?: EnsurePreviewData;
getDocumentAsset?: GetDocumentAsset;
resolveApiPath?: ResolveApiPath;
notifyApiError?: NotifyApiError;
closeDocumentPreview?: () => void;
parentBreadcrumb?: Breadcrumb | null;
onNavigateParent?: () => void;
}
interface UseWorkspaceSurfaceResult {
surface: WorkspaceSurface;
}
export const useWorkspaceSurface = ({
sidebarHidden,
sidebarHidden = false,
onExpandSidebar,
documentsTableProps,
detailPanelProps,
detailPanelOpen,
viewMode,
detailPanelOpen = false,
viewMode = 'list',
deskWorkspaceProps,
previewWorkspaceDocument,
previewWorkspaceEntry,
@@ -24,7 +70,7 @@ export const useWorkspaceSurface = ({
closeDocumentPreview,
parentBreadcrumb,
onNavigateParent,
}) => {
}: UseWorkspaceSurfaceArgs): UseWorkspaceSurfaceResult => {
const { registerDetailCloseHandler, setDetailActive } = usePanelManager();
useEffect(() => {
@@ -38,7 +84,7 @@ export const useWorkspaceSurface = ({
return () => setDetailActive(false);
}, [detailPanelOpen, setDetailActive]);
const renderSidebarToggle = useCallback(() => {
const renderSidebarToggle = useCallback<() => ReactNode>(() => {
if (!sidebarHidden) {
return null;
}
@@ -55,7 +101,7 @@ export const useWorkspaceSurface = ({
);
}, [sidebarHidden, onExpandSidebar]);
const documentsSurface = useMemo(() => {
const documentsSurface = useMemo<DocumentsSurface>(() => {
if (!documentsTableProps) {
return null;
}
@@ -78,7 +124,7 @@ export const useWorkspaceSurface = ({
const showPreviewWorkspace = Boolean(previewDocumentId);
const previewSurface = useMemo(() => {
const previewSurface = useMemo<PreviewSurface>(() => {
if (!showPreviewWorkspace) {
return null;
}
@@ -132,7 +178,7 @@ export const useWorkspaceSurface = ({
detailPanelProps,
]);
const workspaceSurface = useMemo(() => {
const workspaceSurface = useMemo<DesktopSurface>(() => {
if (viewMode !== 'desk') {
return null;
}
@@ -154,7 +200,7 @@ export const useWorkspaceSurface = ({
detailPanelOpen,
]);
const surface = useMemo(() => {
const surface = useMemo<WorkspaceSurface>(() => {
if (showPreviewWorkspace) {
return previewSurface;
}