attributes

This commit is contained in:
Jurgis Sakalauskas
2025-10-15 09:30:29 +03:00
parent a02c600858
commit a7626f42ab
2 changed files with 584 additions and 95 deletions
+479 -95
View File
@@ -1,5 +1,7 @@
import { FormEvent, useEffect, useState } from 'react'; import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import type { CreateItemPayload, ImportedItemData } from '../api/items'; import type { CreateItemPayload, ImportedItemData } from '../api/items';
import type { AttributeScalarType } from '../utils/attribute-values';
import { inferScalarValue } from '../utils/attribute-values';
export interface ItemFormModalProps { export interface ItemFormModalProps {
isOpen: boolean; isOpen: boolean;
@@ -19,27 +21,242 @@ interface AttributeEntry {
id: string; id: string;
key: string; key: string;
value: string; value: string;
forcedString: boolean;
expectedType: AttributeScalarType | null;
isArrayMember: boolean;
} }
const createUniqueId = () => Math.random().toString(36).slice(2); const createUniqueId = () => Math.random().toString(36).slice(2);
const stringifyAttributeValue = (value: unknown): string => { const createBlankEntry = (key = '', isArrayMember = false): AttributeEntry => ({
if (value === null || value === undefined) { id: createUniqueId(),
return ''; key,
} value: '',
if (typeof value === 'string') { forcedString: false,
return value; expectedType: null,
} isArrayMember
if (typeof value === 'number' || typeof value === 'boolean') { });
return String(value);
const parseScalarArrayCandidate = (raw: string): unknown[] | null => {
const trimmed = raw.trim();
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) {
return null;
} }
try { try {
return JSON.stringify(value); const parsed = JSON.parse(trimmed);
if (!Array.isArray(parsed)) {
return null;
}
if (
parsed.every(
(item) =>
item === null ||
typeof item === 'string' ||
typeof item === 'number' ||
typeof item === 'boolean'
)
) {
return parsed;
}
} catch { } catch {
return String(value); return null;
} }
return null;
}; };
const createForcedStringEntry = (
key: string,
value: string,
isArrayMember = false
): AttributeEntry => ({
id: createUniqueId(),
key,
value,
forcedString: true,
expectedType: 'string',
isArrayMember
});
const createEntryFromScalar = (
key: string,
rawValue: unknown,
isArrayMember = false
): AttributeEntry => {
if (rawValue === null || rawValue === undefined) {
return { ...createBlankEntry(key, isArrayMember), key };
}
if (typeof rawValue === 'boolean') {
return {
id: createUniqueId(),
key,
value: rawValue ? 'true' : 'false',
forcedString: false,
expectedType: 'boolean',
isArrayMember
};
}
if (typeof rawValue === 'number') {
return {
id: createUniqueId(),
key,
value: String(rawValue),
forcedString: false,
expectedType: 'number',
isArrayMember
};
}
if (typeof rawValue === 'string') {
if (rawValue.startsWith("'")) {
return createForcedStringEntry(key, rawValue.slice(1), isArrayMember);
}
const inference = inferScalarValue(rawValue, false, null);
const expectedType =
inference.normalizedValue === null ? null : inference.type;
return {
id: createUniqueId(),
key,
value: inference.canonicalText,
forcedString: false,
expectedType,
isArrayMember
};
}
try {
const serialized = JSON.stringify(rawValue);
if (typeof serialized === 'string') {
return createForcedStringEntry(key, serialized, isArrayMember);
}
} catch {
// ignore serialization errors
}
return createForcedStringEntry(key, String(rawValue), isArrayMember);
};
const expandAttributeValue = (key: string, value: unknown): AttributeEntry[] => {
if (Array.isArray(value)) {
const entries = value
.map((item) => {
if (Array.isArray(item)) {
try {
return createForcedStringEntry(key, JSON.stringify(item), true);
} catch {
return createForcedStringEntry(key, String(item), true);
}
}
return createEntryFromScalar(key, item, true);
})
.flat();
return entries.length
? entries.map((entry) => ({
...entry,
id: createUniqueId(),
key,
isArrayMember: true
}))
: [createBlankEntry(key, true)];
}
if (typeof value === 'string' && !value.startsWith("'")) {
const parsed = parseScalarArrayCandidate(value);
if (parsed) {
const entries = parsed
.map((item) => createEntryFromScalar(key, item, true))
.flat();
return entries.length
? entries.map((entry) => ({
...entry,
id: createUniqueId(),
key,
isArrayMember: true
}))
: [createBlankEntry(key, true)];
}
}
return [createEntryFromScalar(key, value, false)];
};
const normalizeArrayMembership = (entries: AttributeEntry[]): AttributeEntry[] => {
const keyLedger = new Map<
string,
{
count: number;
flagged: boolean;
}
>();
entries.forEach((entry) => {
const key = entry.key.trim();
if (!key.length) {
return;
}
const current = keyLedger.get(key) ?? { count: 0, flagged: false };
current.count += 1;
current.flagged = current.flagged || entry.isArrayMember;
keyLedger.set(key, current);
});
return entries.map((entry) => {
const key = entry.key.trim();
if (!key.length) {
return entry;
}
const info = keyLedger.get(key);
if (!info) {
return entry;
}
const shouldBeArray = info.flagged || info.count > 1;
if (entry.isArrayMember === shouldBeArray) {
return entry;
}
return { ...entry, isArrayMember: shouldBeArray };
});
};
const computeTrackedKeySet = (attributes: string[]): Set<string> =>
new Set(
attributes
.map((attribute) => attribute.trim().toLowerCase())
.filter((attribute) => attribute.length)
);
const isTrackedKey = (key: string, trackedKeys: Set<string>): boolean => {
if (!key) {
return false;
}
return trackedKeys.has(key.trim().toLowerCase());
};
const sortAttributeEntries = (
entries: AttributeEntry[],
trackedKeys: Set<string>
): AttributeEntry[] => {
const decorated = entries.map((entry, index) => ({
entry,
index,
isTracked: isTrackedKey(entry.key, trackedKeys)
}));
decorated.sort((a, b) => {
if (a.isTracked === b.isTracked) {
return a.index - b.index;
}
return a.isTracked ? -1 : 1;
});
return decorated.map(({ entry }) => entry);
};
const normalizeAndSortEntries = (
entries: AttributeEntry[],
trackedKeys: Set<string>
): AttributeEntry[] => sortAttributeEntries(normalizeArrayMembership(entries), trackedKeys);
export function ItemFormModal({ export function ItemFormModal({
isOpen, isOpen,
mode, mode,
@@ -60,6 +277,19 @@ 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(
() => computeTrackedKeySet(projectAttributes),
[projectAttributes]
);
const updateAttributeEntries = useCallback(
(updater: (previous: AttributeEntry[]) => AttributeEntry[]) => {
setAttributeEntries((previous) =>
normalizeAndSortEntries(updater(previous), trackedAttributeSet)
);
},
[trackedAttributeSet]
);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@@ -73,7 +303,7 @@ export function ItemFormModal({
setModel(initialData?.model ?? ''); setModel(initialData?.model ?? '');
setNote(initialData?.note ?? ''); setNote(initialData?.note ?? '');
const trackedAttributeSet = new Set( const remainingTrackedKeys = new Set(
projectAttributes.map((attribute) => attribute.trim()).filter((attribute) => attribute.length) projectAttributes.map((attribute) => attribute.trim()).filter((attribute) => attribute.length)
); );
@@ -86,30 +316,29 @@ export function ItemFormModal({
if (!key.length) { if (!key.length) {
return; return;
} }
const entry = { const isTracked = remainingTrackedKeys.has(key);
id: createUniqueId(), const entries = expandAttributeValue(key, value).map((entry) => ({
key, ...entry,
value: stringifyAttributeValue(value) key
}; }));
if (trackedAttributeSet.has(key)) {
trackedEntries.push(entry); if (isTracked) {
trackedAttributeSet.delete(key); trackedEntries.push(...entries);
remainingTrackedKeys.delete(key);
} else { } else {
additionalEntries.push(entry); additionalEntries.push(...entries);
} }
}); });
} }
trackedAttributeSet.forEach((key) => { remainingTrackedKeys.forEach((key) => {
trackedEntries.push({ trackedEntries.push(createBlankEntry(key));
id: createUniqueId(),
key,
value: ''
});
}); });
setAttributeEntries([...trackedEntries, ...additionalEntries]); setAttributeEntries(
}, [isOpen, initialUrl, initialData, projectAttributes]); normalizeAndSortEntries([...trackedEntries, ...additionalEntries], trackedAttributeSet)
);
}, [isOpen, initialUrl, initialData, projectAttributes, trackedAttributeSet]);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
@@ -141,7 +370,8 @@ export function ItemFormModal({
} }
const attributesPayload: Record<string, unknown> = {}; const attributesPayload: Record<string, unknown> = {};
const seenAttributeKeys = new Set<string>(); const arraySourceKeys = new Set<string>();
const keyCounts = new Map<string, number>();
for (const attribute of attributeEntries) { for (const attribute of attributeEntries) {
const key = attribute.key.trim(); const key = attribute.key.trim();
@@ -150,16 +380,58 @@ export function ItemFormModal({
return; return;
} }
const normalizedKey = key.toLowerCase(); const inference = inferScalarValue(
if (seenAttributeKeys.has(normalizedKey)) { attribute.value,
setError(`Attribute name "${key}" is already in use.`); attribute.forcedString,
attribute.expectedType
);
if (inference.error) {
setError(`Attribute "${key}" has an invalid value. ${inference.error}`);
return; return;
} }
seenAttributeKeys.add(normalizedKey); let normalizedValue: unknown;
attributesPayload[key] = attribute.value.trim() ? attribute.value.trim() : null;
if (attribute.forcedString) {
normalizedValue = `'${attribute.value}`;
} else {
normalizedValue = inference.normalizedValue;
}
const existing = attributesPayload[key];
if (existing === undefined) {
attributesPayload[key] = normalizedValue;
} else if (Array.isArray(existing)) {
existing.push(normalizedValue);
} else {
attributesPayload[key] = [existing, normalizedValue];
}
const currentCount = keyCounts.get(key) ?? 0;
keyCounts.set(key, currentCount + 1);
if (attribute.isArrayMember) {
arraySourceKeys.add(key);
}
} }
arraySourceKeys.forEach((key) => {
const value = attributesPayload[key];
if (Array.isArray(value)) {
return;
}
attributesPayload[key] = value !== undefined ? [value] : [];
});
keyCounts.forEach((count, key) => {
if (count > 1 && !Array.isArray(attributesPayload[key])) {
const existing = attributesPayload[key];
attributesPayload[key] =
existing === undefined ? [] : [existing];
}
});
try { try {
const payload: CreateItemPayload = { const payload: CreateItemPayload = {
manufacturer: manufacturer.trim() ? manufacturer.trim() : null, manufacturer: manufacturer.trim() ? manufacturer.trim() : null,
@@ -330,62 +602,177 @@ export function ItemFormModal({
<span>Value</span> <span>Value</span>
<span className="text-right">Actions</span> <span className="text-right">Actions</span>
</div> </div>
{attributeEntries.map((attribute) => ( {attributeEntries.map((attribute) => {
<div const inference = inferScalarValue(
key={attribute.id} attribute.value,
className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-start gap-3" attribute.forcedString,
> attribute.expectedType
<input );
id={`attribute-name-${attribute.id}`} const trimmedValue = attribute.value.trim();
type="text" const canonicalHint =
value={attribute.key} attribute.forcedString ||
disabled={isFormDisabled} !trimmedValue.length ||
onChange={(event) => { trimmedValue === inference.canonicalText
const { value } = event.target; ? null
setAttributeEntries((previous) => : inference.canonicalText;
previous.map((item) => const isTracked = isTrackedKey(attribute.key, trackedAttributeSet);
item.id === attribute.id ? { ...item, key: value } : item
) return (
); <div
setError(null); key={attribute.id}
}} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-start gap-3"
className="rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100" >
placeholder="Attribute name" <div className="flex flex-col gap-1">
/> <input
<input id={`attribute-name-${attribute.id}`}
id={`attribute-value-${attribute.id}`} type="text"
type="text" value={attribute.key}
value={attribute.value} disabled={isFormDisabled}
disabled={isFormDisabled} onChange={(event) => {
onChange={(event) => { const { value } = event.target;
const { value } = event.target; updateAttributeEntries((previous) =>
setAttributeEntries((previous) => previous.map((item) =>
previous.map((item) => item.id === attribute.id ? { ...item, key: value } : item
item.id === attribute.id ? { ...item, value } : item )
) );
); setError(null);
setError(null); }}
}} className="rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100"
className="rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100" placeholder="Attribute name"
placeholder="Attribute value" />
/> {isTracked ? (
<div className="flex justify-end"> <span className="text-xs font-semibold uppercase tracking-wide text-blue-600">
<button Tracked
type="button" </span>
onClick={() => { ) : null}
setAttributeEntries((previous) => </div>
previous.filter((item) => item.id !== attribute.id) <div className="flex flex-col gap-1">
); <input
setError(null); id={`attribute-value-${attribute.id}`}
}} type="text"
disabled={isFormDisabled} value={attribute.value}
className="rounded-md border border-red-200 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:border-slate-200 disabled:text-slate-400 disabled:hover:bg-transparent" disabled={isFormDisabled}
> onChange={(event) => {
Remove const { value } = event.target;
</button> updateAttributeEntries((previous) =>
previous.map((item) => {
if (item.id !== attribute.id) {
return item;
}
const shouldForceString =
!item.forcedString && value.startsWith("'");
const nextForcedString =
shouldForceString ? true : item.forcedString;
const normalizedValue = shouldForceString
? value.slice(1)
: value;
const expectedSourceType = nextForcedString
? 'string'
: item.expectedType;
let nextInference = inferScalarValue(
normalizedValue,
nextForcedString,
expectedSourceType
);
let nextExpectedType: AttributeScalarType | null;
if (!nextForcedString && item.expectedType && nextInference.error) {
const relaxedInference = inferScalarValue(
normalizedValue,
false,
null
);
if (!relaxedInference.error) {
nextInference = relaxedInference;
nextExpectedType =
relaxedInference.normalizedValue === null
? null
: relaxedInference.type;
} else {
nextExpectedType = item.expectedType;
}
} else {
nextExpectedType = nextForcedString
? 'string'
: item.expectedType ??
(nextInference.normalizedValue === null
? null
: nextInference.type);
}
return {
...item,
value: normalizedValue,
forcedString: nextForcedString,
expectedType: nextExpectedType
};
})
);
setError(null);
}}
onBlur={() => {
updateAttributeEntries((previous) =>
previous.map((item) => {
if (item.id !== attribute.id) {
return item;
}
if (item.forcedString) {
return item;
}
const nextInference = inferScalarValue(
item.value,
false,
item.expectedType
);
const nextExpectedType =
item.expectedType ??
(nextInference.normalizedValue === null
? null
: nextInference.type);
return {
...item,
value: nextInference.canonicalText,
expectedType: nextExpectedType
};
})
);
}}
className={`rounded-md border px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100 ${
inference.error ? 'border-red-300 focus:border-red-500 focus:ring-red-200' : 'border-slate-300'
}`}
placeholder="Attribute value"
/>
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">
{attribute.forcedString
? 'String literal'
: `Detected ${inference.type}${
canonicalHint ? `${canonicalHint}` : ''
}`}
</span>
{inference.error ? (
<span className="text-xs font-medium text-red-600">
{inference.error}
</span>
) : null}
</div>
</div>
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={() => {
updateAttributeEntries((previous) =>
previous.filter((item) => item.id !== attribute.id)
);
setError(null);
}}
disabled={isFormDisabled}
className="rounded-md border border-red-200 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:border-slate-200 disabled:text-slate-400 disabled:hover:bg-transparent"
>
Remove
</button>
</div>
</div> </div>
</div> );
))} })}
</div> </div>
</div> </div>
) : ( ) : (
@@ -395,10 +782,7 @@ export function ItemFormModal({
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setAttributeEntries((previous) => [ updateAttributeEntries((previous) => [...previous, createBlankEntry()]);
...previous,
{ id: createUniqueId(), key: '', value: '' }
]);
setError(null); setError(null);
}} }}
disabled={isFormDisabled} disabled={isFormDisabled}
+105
View File
@@ -0,0 +1,105 @@
const BOOLEAN_TRUE_TOKENS = new Set(['true', 'yes', 'on', 'enabled', 'y']);
const BOOLEAN_FALSE_TOKENS = new Set(['false', 'no', 'off', 'disabled', 'n']);
export type AttributeScalarType = 'boolean' | 'number' | 'string';
export interface AttributeInferenceResult {
type: AttributeScalarType;
canonicalText: string;
normalizedValue: boolean | number | string | null;
error: string | null;
}
const NUMERIC_PATTERN =
/^[+-]?(?:\d+|\d*\.\d+)(?:[eE][+-]?\d+)?$/;
export const inferScalarValue = (
rawValue: string,
forcedString: boolean,
expectedType: AttributeScalarType | null
): AttributeInferenceResult => {
if (forcedString) {
return {
type: 'string',
canonicalText: rawValue,
normalizedValue: rawValue,
error: null
};
}
const trimmed = rawValue.trim();
if (!trimmed.length) {
return {
type: expectedType ?? 'string',
canonicalText: '',
normalizedValue: null,
error: null
};
}
const lower = trimmed.toLowerCase();
if (BOOLEAN_TRUE_TOKENS.has(lower)) {
return {
type: 'boolean',
canonicalText: 'true',
normalizedValue: true,
error: expectedType && expectedType !== 'boolean' ? 'Expecting a boolean value.' : null
};
}
if (BOOLEAN_FALSE_TOKENS.has(lower)) {
return {
type: 'boolean',
canonicalText: 'false',
normalizedValue: false,
error: expectedType && expectedType !== 'boolean' ? 'Expecting a boolean value.' : null
};
}
if (NUMERIC_PATTERN.test(trimmed)) {
const numericValue = Number(trimmed);
if (!Number.isFinite(numericValue)) {
return {
type: 'number',
canonicalText: trimmed,
normalizedValue: null,
error: 'Enter a finite numeric value.'
};
}
return {
type: 'number',
canonicalText: String(numericValue),
normalizedValue: numericValue,
error: expectedType && expectedType !== 'number' ? 'Expecting a numeric value.' : null
};
}
const canonical = trimmed;
if (expectedType === 'boolean') {
return {
type: 'string',
canonicalText: canonical,
normalizedValue: canonical,
error: 'Expecting a boolean value.'
};
}
if (expectedType === 'number') {
return {
type: 'string',
canonicalText: canonical,
normalizedValue: canonical,
error: 'Expecting a numeric value.'
};
}
return {
type: 'string',
canonicalText: canonical,
normalizedValue: canonical,
error: null
};
};