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 SelectionAssignmentMenu, {
SelectionAssignmentMenuItem,
@@ -240,10 +240,17 @@ export const TagSection: React.FC<TagSectionProps> = ({
if (!item) {
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 };
handleSelect(payload);
},
[handleSelect],
[handleSelect, onRemove, tags],
);
return (
@@ -280,7 +287,9 @@ export const TagSection: React.FC<TagSectionProps> = ({
showCounts={false}
positionStrategy="fixed"
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}
{!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(
(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;
}
const source = (item.payload ?? item) as QuickAddOption | string | null;
@@ -375,7 +394,7 @@ export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
: { id: null, name: resolvedName };
onAdd({ name: resolvedName, option: payload, input: null });
},
[onAdd],
[entries, onAdd, onRemove],
);
return (
@@ -416,7 +435,9 @@ export const CorrespondentSection: React.FC<CorrespondentSectionProps> = ({
showCounts={false}
positionStrategy="fixed"
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}
</div>
@@ -720,29 +741,23 @@ const DocumentSummarySection: React.FC<DocumentSummarySectionProps> = ({
/>
);
const summaryRowOverrides = useMemo(
() => ({
title: { valueContent: titleMetaDisplay, error: titleError },
issued: { valueContent: issuedDisplay, error: issuedError },
tags: { valueContent: tagsValueContent },
correspondents: { valueContent: correspondentsValueContent },
}),
[titleMetaDisplay, titleError, issuedDisplay, issuedError, tagsValueContent, correspondentsValueContent],
);
const summaryRowOverrides = {
title: { valueContent: titleMetaDisplay, error: titleError },
issued: { valueContent: issuedDisplay, error: issuedError },
tags: { valueContent: tagsValueContent },
correspondents: { valueContent: correspondentsValueContent },
} as Record<string, { valueContent?: React.ReactNode | null; error?: string | null }>;
const baseRows: MetaItem[] = useMemo(
() => [...summaryRows, ...extraSummaryRows].map((row) => {
const overrides = summaryRowOverrides[row.key] || {};
return {
key: row.key,
label: row.label,
valueContent: overrides.valueContent ?? null,
fallbackValue: overrides.valueContent ? row.value : row.value,
error: overrides.error ?? null,
};
}),
[summaryRows, extraSummaryRows, summaryRowOverrides],
);
const baseRows: MetaItem[] = [...summaryRows, ...extraSummaryRows].map((row) => {
const overrides = summaryRowOverrides[row.key] || {};
return {
key: row.key,
label: row.label,
valueContent: overrides.valueContent ?? null,
fallbackValue: overrides.valueContent ? row.value : row.value,
error: overrides.error ?? null,
};
});
const allRows = baseRows;
const summaryClass = `document-summary${isCompactLayout ? ' document-summary--compact' : ''}`;
@@ -41,6 +41,9 @@ export interface SelectionAssignmentMenuProps {
onOpenMenu?: () => void;
renderItemLabel?: (item: NormalizedSelectionAssignmentItem) => React.ReactNode;
positionStrategy?: 'absolute' | 'fixed';
closeOnSelection?: boolean;
sortByState?: boolean;
freezeSortOnOpen?: boolean;
}
const STATE_ORDER: Record<AssignmentState, number> = {
@@ -94,11 +97,15 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
onOpenMenu,
renderItemLabel,
positionStrategy = 'absolute',
closeOnSelection = true,
sortByState = true,
freezeSortOnOpen = false,
}) => {
const anchorRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState('');
const [pending, setPending] = useState(false);
const [sortSnapshot, setSortSnapshot] = useState<Array<string | number> | null>(null);
const {
isOpen,
@@ -145,20 +152,55 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
const normalizedItems = useMemo(() => normalizeItems(items), [items]);
const filteredItems = useMemo(() => {
const search = query.trim().toLowerCase();
const sorted = normalizedItems.slice().sort((a, b) => {
const sortedByStateItems = useMemo(() => {
if (!sortByState) {
return normalizedItems;
}
return normalizedItems.slice().sort((a, b) => {
const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state];
if (stateDiff !== 0) {
return stateDiff;
}
return a.label.localeCompare(b.label);
});
if (!search) {
return sorted;
}, [normalizedItems, sortByState]);
useEffect(() => {
if (!isOpen || !freezeSortOnOpen || !sortByState) {
setSortSnapshot(null);
return;
}
return sorted.filter((item) => item.label.toLowerCase().includes(search));
}, [normalizedItems, query]);
setSortSnapshot((prev) => prev ?? sortedByStateItems.map((item) => item.id));
}, [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(
async (item: NormalizedSelectionAssignmentItem) => {
@@ -169,13 +211,15 @@ const SelectionAssignmentMenu: React.FC<SelectionAssignmentMenuProps> = ({
try {
await onToggle(item);
setPending(false);
close();
if (closeOnSelection) {
close();
}
} catch (error) {
setPending(false);
console.error('[selection-assignment] toggle failed', error);
}
},
[onToggle, close],
[onToggle, close, closeOnSelection],
);
const handleCreate = useCallback(
@@ -8,6 +8,7 @@ interface DocumentPageMetadata {
interface DocumentVersion {
size_bytes?: number | string | null;
metadata?: DocumentPageMetadata | null;
checksum?: string | null;
}
interface TagEntry {
@@ -21,6 +22,7 @@ interface CorrespondentEntry {
export interface SummaryDocument {
title?: string | null;
original_name?: string | null;
filename?: string | null;
content_type?: string | null;
current_version?: DocumentVersion | null;
created_at?: string | null;