attribute sorting

This commit is contained in:
Jurgis Sakalauskas
2025-10-15 09:51:18 +03:00
parent 3f81dc3e94
commit 5caeb68f3e
3 changed files with 88 additions and 40 deletions
+57 -21
View File
@@ -218,35 +218,67 @@ const normalizeArrayMembership = (entries: AttributeEntry[]): AttributeEntry[] =
}); });
}; };
const computeTrackedKeySet = (attributes: string[]): Set<string> => interface TrackedKeyMetadata {
new Set( set: Set<string>;
attributes order: Map<string, number>;
.map((attribute) => attribute.trim().toLowerCase()) }
.filter((attribute) => attribute.length)
);
const isTrackedKey = (key: string, trackedKeys: Set<string>): boolean => { const computeTrackedKeyMetadata = (attributes: string[]): TrackedKeyMetadata => {
const order = new Map<string, number>();
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) { if (!key) {
return false; return false;
} }
return trackedKeys.has(key.trim().toLowerCase()); return metadata.set.has(key.trim().toLowerCase());
}; };
const sortAttributeEntries = ( const sortAttributeEntries = (
entries: AttributeEntry[], entries: AttributeEntry[],
trackedKeys: Set<string> trackedMetadata: TrackedKeyMetadata
): AttributeEntry[] => { ): AttributeEntry[] => {
const decorated = entries.map((entry, index) => ({ const decorated = entries.map((entry, index) => ({
entry, entry,
index, index,
isTracked: isTrackedKey(entry.key, trackedKeys) isTracked: isTrackedKey(entry.key, trackedMetadata)
})); }));
decorated.sort((a, b) => { 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.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); return decorated.map(({ entry }) => entry);
@@ -254,8 +286,9 @@ const sortAttributeEntries = (
const normalizeAndSortEntries = ( const normalizeAndSortEntries = (
entries: AttributeEntry[], entries: AttributeEntry[],
trackedKeys: Set<string> trackedMetadata: TrackedKeyMetadata
): AttributeEntry[] => sortAttributeEntries(normalizeArrayMembership(entries), trackedKeys); ): AttributeEntry[] =>
sortAttributeEntries(normalizeArrayMembership(entries), trackedMetadata);
export function ItemFormModal({ export function ItemFormModal({
isOpen, isOpen,
@@ -277,18 +310,18 @@ export function ItemFormModal({
const [attributeEntries, setAttributeEntries] = useState<AttributeEntry[]>([]); const [attributeEntries, setAttributeEntries] = useState<AttributeEntry[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isFormDisabled = isSubmitting || isInitialDataLoading; const isFormDisabled = isSubmitting || isInitialDataLoading;
const trackedAttributeSet = useMemo( const trackedAttributeMetadata = useMemo(
() => computeTrackedKeySet(projectAttributes), () => computeTrackedKeyMetadata(projectAttributes),
[projectAttributes] [projectAttributes]
); );
const updateAttributeEntries = useCallback( const updateAttributeEntries = useCallback(
(updater: (previous: AttributeEntry[]) => AttributeEntry[]) => { (updater: (previous: AttributeEntry[]) => AttributeEntry[]) => {
setAttributeEntries((previous) => setAttributeEntries((previous) =>
normalizeAndSortEntries(updater(previous), trackedAttributeSet) normalizeAndSortEntries(updater(previous), trackedAttributeMetadata)
); );
}, },
[trackedAttributeSet] [trackedAttributeMetadata]
); );
useEffect(() => { useEffect(() => {
@@ -336,9 +369,12 @@ export function ItemFormModal({
}); });
setAttributeEntries( setAttributeEntries(
normalizeAndSortEntries([...trackedEntries, ...additionalEntries], trackedAttributeSet) normalizeAndSortEntries(
[...trackedEntries, ...additionalEntries],
trackedAttributeMetadata
)
); );
}, [isOpen, initialUrl, initialData, projectAttributes, trackedAttributeSet]); }, [isOpen, initialUrl, initialData, projectAttributes, trackedAttributeMetadata]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@@ -623,7 +659,7 @@ export function ItemFormModal({
trimmedValue === inference.canonicalText trimmedValue === inference.canonicalText
? null ? null
: inference.canonicalText; : inference.canonicalText;
const isTracked = isTrackedKey(attribute.key, trackedAttributeSet); const isTracked = isTrackedKey(attribute.key, trackedAttributeMetadata);
return ( return (
<div <div
@@ -1,5 +1,14 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
const normalizeSelection = (attributes: string[]): string[] =>
Array.from(
new Set(
attributes
.map((attribute) => attribute.trim())
.filter((attribute) => attribute.length > 0)
)
).sort((a, b) => a.localeCompare(b));
export interface TrackedAttributesModalProps { export interface TrackedAttributesModalProps {
isOpen: boolean; isOpen: boolean;
availableAttributes: string[]; availableAttributes: string[];
@@ -19,13 +28,15 @@ export function TrackedAttributesModal({
isSubmitting, isSubmitting,
errorMessage errorMessage
}: TrackedAttributesModalProps) { }: TrackedAttributesModalProps) {
const [selectedAttributes, setSelectedAttributes] = useState<string[]>(initialSelection); const [selectedAttributes, setSelectedAttributes] = useState<string[]>(() =>
normalizeSelection(initialSelection)
);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
return; return;
} }
setSelectedAttributes(initialSelection); setSelectedAttributes(normalizeSelection(initialSelection));
}, [isOpen, initialSelection]); }, [isOpen, initialSelection]);
const sortedAttributes = useMemo( const sortedAttributes = useMemo(
@@ -38,21 +49,16 @@ export function TrackedAttributesModal({
} }
const toggleAttribute = (attribute: string) => { const toggleAttribute = (attribute: string) => {
setSelectedAttributes((current) => setSelectedAttributes((current) => {
current.includes(attribute) if (current.includes(attribute)) {
? current.filter((value) => value !== attribute) return normalizeSelection(current.filter((value) => value !== attribute));
: [...current, attribute] }
); return normalizeSelection([...current, attribute]);
});
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
const normalized = Array.from( const normalized = normalizeSelection(selectedAttributes);
new Set(
selectedAttributes
.map((attribute) => attribute.trim())
.filter((attribute) => attribute.length > 0)
)
);
await onSubmit(normalized); await onSubmit(normalized);
}; };
@@ -41,14 +41,20 @@ export function ProjectDetailPage() {
const project = projectQuery.data; const project = projectQuery.data;
const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.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 availableAttributes = useMemo(() => {
const attributeSet = new Set<string>(); const attributeSet = new Set<string>();
projectAttributes.forEach((attribute) => { projectAttributes.forEach((attribute) => {
if (attribute.trim().length) { attributeSet.add(attribute);
attributeSet.add(attribute.trim());
}
}); });
items.forEach((item) => { 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]); }, [projectAttributes, items]);
const openItemModal = (mode: 'url' | 'manual' | 'edit', initialUrl: string | null) => { const openItemModal = (mode: 'url' | 'manual' | 'edit', initialUrl: string | null) => {