mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
attributes
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user