From 5caeb68f3e369208dc113852a949643238929cde Mon Sep 17 00:00:00 2001 From: Jurgis Sakalauskas Date: Wed, 15 Oct 2025 09:51:18 +0300 Subject: [PATCH] attribute sorting --- client/src/components/item-form-modal.tsx | 78 ++++++++++++++----- .../components/tracked-attributes-modal.tsx | 34 ++++---- .../project-detail/project-detail-page.tsx | 16 ++-- 3 files changed, 88 insertions(+), 40 deletions(-) diff --git a/client/src/components/item-form-modal.tsx b/client/src/components/item-form-modal.tsx index 0d81795..8f7f470 100644 --- a/client/src/components/item-form-modal.tsx +++ b/client/src/components/item-form-modal.tsx @@ -218,35 +218,67 @@ const normalizeArrayMembership = (entries: AttributeEntry[]): AttributeEntry[] = }); }; -const computeTrackedKeySet = (attributes: string[]): Set => - new Set( - attributes - .map((attribute) => attribute.trim().toLowerCase()) - .filter((attribute) => attribute.length) - ); +interface TrackedKeyMetadata { + set: Set; + order: Map; +} -const isTrackedKey = (key: string, trackedKeys: Set): boolean => { +const computeTrackedKeyMetadata = (attributes: string[]): TrackedKeyMetadata => { + const order = new Map(); + attributes + .map((attribute) => attribute.trim()) + .filter((attribute) => attribute.length) + .forEach((attribute, index) => { + const normalized = attribute.toLowerCase(); + if (!order.has(normalized)) { + order.set(normalized, index); + } + }); + return { + set: new Set(order.keys()), + order + }; +}; + +const isTrackedKey = (key: string, metadata: TrackedKeyMetadata): boolean => { if (!key) { return false; } - return trackedKeys.has(key.trim().toLowerCase()); + return metadata.set.has(key.trim().toLowerCase()); }; const sortAttributeEntries = ( entries: AttributeEntry[], - trackedKeys: Set + trackedMetadata: TrackedKeyMetadata ): AttributeEntry[] => { const decorated = entries.map((entry, index) => ({ entry, index, - isTracked: isTrackedKey(entry.key, trackedKeys) + isTracked: isTrackedKey(entry.key, trackedMetadata) })); decorated.sort((a, b) => { - if (a.isTracked === b.isTracked) { + if (a.isTracked && b.isTracked) { + const keyA = a.entry.key.trim().toLowerCase(); + const keyB = b.entry.key.trim().toLowerCase(); + const orderA = trackedMetadata.order.get(keyA); + const orderB = trackedMetadata.order.get(keyB); + if (orderA !== undefined && orderB !== undefined && orderA !== orderB) { + return orderA - orderB; + } + const keyComparison = keyA.localeCompare(keyB); + if (keyComparison !== 0) { + return keyComparison; + } return a.index - b.index; } - return a.isTracked ? -1 : 1; + if (a.isTracked) { + return -1; + } + if (b.isTracked) { + return 1; + } + return a.index - b.index; }); return decorated.map(({ entry }) => entry); @@ -254,8 +286,9 @@ const sortAttributeEntries = ( const normalizeAndSortEntries = ( entries: AttributeEntry[], - trackedKeys: Set -): AttributeEntry[] => sortAttributeEntries(normalizeArrayMembership(entries), trackedKeys); + trackedMetadata: TrackedKeyMetadata +): AttributeEntry[] => + sortAttributeEntries(normalizeArrayMembership(entries), trackedMetadata); export function ItemFormModal({ isOpen, @@ -277,18 +310,18 @@ export function ItemFormModal({ const [attributeEntries, setAttributeEntries] = useState([]); const [error, setError] = useState(null); const isFormDisabled = isSubmitting || isInitialDataLoading; - const trackedAttributeSet = useMemo( - () => computeTrackedKeySet(projectAttributes), + const trackedAttributeMetadata = useMemo( + () => computeTrackedKeyMetadata(projectAttributes), [projectAttributes] ); const updateAttributeEntries = useCallback( (updater: (previous: AttributeEntry[]) => AttributeEntry[]) => { setAttributeEntries((previous) => - normalizeAndSortEntries(updater(previous), trackedAttributeSet) + normalizeAndSortEntries(updater(previous), trackedAttributeMetadata) ); }, - [trackedAttributeSet] + [trackedAttributeMetadata] ); useEffect(() => { @@ -336,9 +369,12 @@ export function ItemFormModal({ }); setAttributeEntries( - normalizeAndSortEntries([...trackedEntries, ...additionalEntries], trackedAttributeSet) + normalizeAndSortEntries( + [...trackedEntries, ...additionalEntries], + trackedAttributeMetadata + ) ); - }, [isOpen, initialUrl, initialData, projectAttributes, trackedAttributeSet]); + }, [isOpen, initialUrl, initialData, projectAttributes, trackedAttributeMetadata]); useEffect(() => { if (!isOpen) { @@ -623,7 +659,7 @@ export function ItemFormModal({ trimmedValue === inference.canonicalText ? null : inference.canonicalText; - const isTracked = isTrackedKey(attribute.key, trackedAttributeSet); + const isTracked = isTrackedKey(attribute.key, trackedAttributeMetadata); return (
+ Array.from( + new Set( + attributes + .map((attribute) => attribute.trim()) + .filter((attribute) => attribute.length > 0) + ) + ).sort((a, b) => a.localeCompare(b)); + export interface TrackedAttributesModalProps { isOpen: boolean; availableAttributes: string[]; @@ -19,13 +28,15 @@ export function TrackedAttributesModal({ isSubmitting, errorMessage }: TrackedAttributesModalProps) { - const [selectedAttributes, setSelectedAttributes] = useState(initialSelection); + const [selectedAttributes, setSelectedAttributes] = useState(() => + normalizeSelection(initialSelection) + ); useEffect(() => { if (!isOpen) { return; } - setSelectedAttributes(initialSelection); + setSelectedAttributes(normalizeSelection(initialSelection)); }, [isOpen, initialSelection]); const sortedAttributes = useMemo( @@ -38,21 +49,16 @@ export function TrackedAttributesModal({ } const toggleAttribute = (attribute: string) => { - setSelectedAttributes((current) => - current.includes(attribute) - ? current.filter((value) => value !== attribute) - : [...current, attribute] - ); + setSelectedAttributes((current) => { + if (current.includes(attribute)) { + return normalizeSelection(current.filter((value) => value !== attribute)); + } + return normalizeSelection([...current, attribute]); + }); }; const handleSubmit = async () => { - const normalized = Array.from( - new Set( - selectedAttributes - .map((attribute) => attribute.trim()) - .filter((attribute) => attribute.length > 0) - ) - ); + const normalized = normalizeSelection(selectedAttributes); await onSubmit(normalized); }; diff --git a/client/src/pages/project-detail/project-detail-page.tsx b/client/src/pages/project-detail/project-detail-page.tsx index 2bace7d..0a8342d 100644 --- a/client/src/pages/project-detail/project-detail-page.tsx +++ b/client/src/pages/project-detail/project-detail-page.tsx @@ -41,14 +41,20 @@ export function ProjectDetailPage() { const project = projectQuery.data; const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]); - const projectAttributes = useMemo(() => project?.attributes ?? [], [project]); + const projectAttributes = useMemo(() => { + if (!project?.attributes) { + return []; + } + const normalized = project.attributes + .map((attribute) => attribute.trim()) + .filter((attribute) => attribute.length); + return Array.from(new Set(normalized)).sort((a, b) => a.localeCompare(b)); + }, [project]); const availableAttributes = useMemo(() => { const attributeSet = new Set(); projectAttributes.forEach((attribute) => { - if (attribute.trim().length) { - attributeSet.add(attribute.trim()); - } + attributeSet.add(attribute); }); items.forEach((item) => { @@ -61,7 +67,7 @@ export function ProjectDetailPage() { }); }); - return Array.from(attributeSet); + return Array.from(attributeSet).sort((a, b) => a.localeCompare(b)); }, [projectAttributes, items]); const openItemModal = (mode: 'url' | 'manual' | 'edit', initialUrl: string | null) => {