This commit is contained in:
2025-11-16 00:39:37 +01:00
parent d0379c57ce
commit 7e0768a09a
3 changed files with 98 additions and 37 deletions
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState, type ReactNode, type FormEvent } from 'react'; import React, { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react';
import { EditIcon, IconX, CheckIcon, PlusIcon } from '../ui/icons'; import { EditIcon, IconX, CheckIcon, PlusIcon } from '../ui/icons';
import SelectionAssignmentMenu, { import SelectionAssignmentMenu, {
SelectionAssignmentMenuItem, SelectionAssignmentMenuItem,
@@ -240,10 +240,17 @@ export const TagSection: React.FC<TagSectionProps> = ({
if (!item) { if (!item) {
return; return;
} }
if (item.state === 'all' && onRemove) {
const payload = isPlainObject(item.payload)
? (item.payload as TagEntry)
: tags.find((tag) => (tag.id ?? tag.label) === item.id) ?? { id: item.id, label: item.label };
onRemove(payload);
return;
}
const payload = item.payload ?? { label: item.label }; const payload = item.payload ?? { label: item.label };
handleSelect(payload); handleSelect(payload);
}, },
[handleSelect], [handleSelect, onRemove, tags],
); );
return ( return (
@@ -281,6 +288,8 @@ export const TagSection: React.FC<TagSectionProps> = ({
positionStrategy="fixed" positionStrategy="fixed"
triggerClassName="quick-add__chip quick-add__trigger" triggerClassName="quick-add__chip quick-add__trigger"
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />} triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
closeOnSelection={false}
freezeSortOnOpen
/> />
) : null} ) : null}
{!tags.length && !showQuickAdd ? <span className="tag-list__empty meta">{emptyMessage}</span> : null} {!tags.length && !showQuickAdd ? <span className="tag-list__empty meta">{emptyMessage}</span> : null}
@@ -362,7 +371,17 @@ export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
const handleAssignmentSelect = useCallback( const handleAssignmentSelect = useCallback(
(item: NormalizedSelectionAssignmentItem) => { (item: NormalizedSelectionAssignmentItem) => {
if (!onAdd || !item) { if (!item) {
return;
}
if (item.state === 'all' && onRemove) {
const payload = isPlainObject(item.payload)
? (item.payload as CorrespondentEntry)
: entries.find((entry) => (entry.id ?? entry.name) === item.id) ?? { id: item.id, name: item.label };
onRemove(payload);
return;
}
if (!onAdd) {
return; return;
} }
const source = (item.payload ?? item) as QuickAddOption | string | null; const source = (item.payload ?? item) as QuickAddOption | string | null;
@@ -375,7 +394,7 @@ export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
: { id: null, name: resolvedName }; : { id: null, name: resolvedName };
onAdd({ name: resolvedName, option: payload, input: null }); onAdd({ name: resolvedName, option: payload, input: null });
}, },
[onAdd], [entries, onAdd, onRemove],
); );
return ( return (
@@ -417,6 +436,8 @@ export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
positionStrategy="fixed" positionStrategy="fixed"
triggerClassName="quick-add__chip quick-add__trigger" triggerClassName="quick-add__chip quick-add__trigger"
triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />} triggerContent={<PlusIcon className="icon-inline" aria-hidden="true" />}
closeOnSelection={false}
freezeSortOnOpen
/> />
) : null} ) : null}
</div> </div>
@@ -720,18 +741,14 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
/> />
); );
const summaryRowOverrides = useMemo( const summaryRowOverrides = {
() => ({
title: { valueContent: titleMetaDisplay, error: titleError }, title: { valueContent: titleMetaDisplay, error: titleError },
issued: { valueContent: issuedDisplay, error: issuedError }, issued: { valueContent: issuedDisplay, error: issuedError },
tags: { valueContent: tagsValueContent }, tags: { valueContent: tagsValueContent },
correspondents: { valueContent: correspondentsValueContent }, correspondents: { valueContent: correspondentsValueContent },
}), } as Record<string, { valueContent?: React.ReactNode | null; error?: string | null }>;
[titleMetaDisplay, titleError, issuedDisplay, issuedError, tagsValueContent, correspondentsValueContent],
);
const baseRows: MetaItem[] = useMemo( const baseRows: MetaItem[] = [...summaryRows, ...extraSummaryRows].map((row) => {
() => [...summaryRows, ...extraSummaryRows].map((row) => {
const overrides = summaryRowOverrides[row.key] || {}; const overrides = summaryRowOverrides[row.key] || {};
return { return {
key: row.key, key: row.key,
@@ -740,9 +757,7 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
fallbackValue: overrides.valueContent ? row.value : row.value, fallbackValue: overrides.valueContent ? row.value : row.value,
error: overrides.error ?? null, error: overrides.error ?? null,
}; };
}), });
[summaryRows, extraSummaryRows, summaryRowOverrides],
);
const allRows = baseRows; const allRows = baseRows;
const summaryClass = `document-summary${isCompactLayout ? ' document-summary--compact' : ''}`; const summaryClass = `document-summary${isCompactLayout ? ' document-summary--compact' : ''}`;
@@ -41,6 +41,9 @@ export interface SelectionAssignmentMenuProps {
onOpenMenu?: () => void; onOpenMenu?: () => void;
renderItemLabel?: (item: NormalizedSelectionAssignmentItem) => React.ReactNode; renderItemLabel?: (item: NormalizedSelectionAssignmentItem) => React.ReactNode;
positionStrategy?: 'absolute' | 'fixed'; positionStrategy?: 'absolute' | 'fixed';
closeOnSelection?: boolean;
sortByState?: boolean;
freezeSortOnOpen?: boolean;
} }
const STATE_ORDER: Record<AssignmentState, number> = { const STATE_ORDER: Record<AssignmentState, number> = {
@@ -94,11 +97,15 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
onOpenMenu, onOpenMenu,
renderItemLabel, renderItemLabel,
positionStrategy = 'absolute', positionStrategy = 'absolute',
closeOnSelection = true,
sortByState = true,
freezeSortOnOpen = false,
}) => { }) => {
const anchorRef = useRef<HTMLButtonElement | null>(null); const anchorRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [sortSnapshot, setSortSnapshot] = useState<Array<string | number> | null>(null);
const { const {
isOpen, isOpen,
@@ -145,20 +152,55 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
const normalizedItems = useMemo(() => normalizeItems(items), [items]); const normalizedItems = useMemo(() => normalizeItems(items), [items]);
const filteredItems = useMemo(() => { const sortedByStateItems = useMemo(() => {
const search = query.trim().toLowerCase(); if (!sortByState) {
const sorted = normalizedItems.slice().sort((a, b) => { return normalizedItems;
}
return normalizedItems.slice().sort((a, b) => {
const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state]; const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state];
if (stateDiff !== 0) { if (stateDiff !== 0) {
return stateDiff; return stateDiff;
} }
return a.label.localeCompare(b.label); return a.label.localeCompare(b.label);
}); });
if (!search) { }, [normalizedItems, sortByState]);
return sorted;
useEffect(() => {
if (!isOpen || !freezeSortOnOpen || !sortByState) {
setSortSnapshot(null);
return;
} }
return sorted.filter((item) => item.label.toLowerCase().includes(search)); setSortSnapshot((prev) => prev ?? sortedByStateItems.map((item) => item.id));
}, [normalizedItems, query]); }, [isOpen, freezeSortOnOpen, sortByState, sortedByStateItems]);
const orderedItems = useMemo(() => {
if (freezeSortOnOpen && sortSnapshot && sortByState) {
const itemMap = new Map<string | number, NormalizedSelectionAssignmentItem>(
sortedByStateItems.map((item) => [item.id, item]),
);
const seen = new Set<string | number>();
const fromSnapshot = sortSnapshot
.map((id) => {
const entry = itemMap.get(id);
if (entry) {
seen.add(entry.id);
}
return entry || null;
})
.filter((entry): entry is NormalizedSelectionAssignmentItem => Boolean(entry));
const remaining = sortedByStateItems.filter((item) => !seen.has(item.id));
return [...fromSnapshot, ...remaining];
}
return sortedByStateItems;
}, [freezeSortOnOpen, sortSnapshot, sortByState, sortedByStateItems]);
const filteredItems = useMemo(() => {
const search = query.trim().toLowerCase();
if (!search) {
return orderedItems;
}
return orderedItems.filter((item) => item.label.toLowerCase().includes(search));
}, [orderedItems, query]);
const handleToggle = useCallback( const handleToggle = useCallback(
async (item: NormalizedSelectionAssignmentItem) => { async (item: NormalizedSelectionAssignmentItem) => {
@@ -169,13 +211,15 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
try { try {
await onToggle(item); await onToggle(item);
setPending(false); setPending(false);
if (closeOnSelection) {
close(); close();
}
} catch (error) { } catch (error) {
setPending(false); setPending(false);
console.error('[selection-assignment] toggle failed', error); console.error('[selection-assignment] toggle failed', error);
} }
}, },
[onToggle, close], [onToggle, close, closeOnSelection],
); );
const handleCreate = useCallback( const handleCreate = useCallback(
@@ -8,6 +8,7 @@ interface DocumentPageMetadata {
interface DocumentVersion { interface DocumentVersion {
size_bytes?: number | string | null; size_bytes?: number | string | null;
metadata?: DocumentPageMetadata | null; metadata?: DocumentPageMetadata | null;
checksum?: string | null;
} }
interface TagEntry { interface TagEntry {
@@ -21,6 +22,7 @@ interface CorrespondentEntry {
export interface SummaryDocument { export interface SummaryDocument {
title?: string | null; title?: string | null;
original_name?: string | null; original_name?: string | null;
filename?: string | null;
content_type?: string | null; content_type?: string | null;
current_version?: DocumentVersion | null; current_version?: DocumentVersion | null;
created_at?: string | null; created_at?: string | null;