@open-mercato/ui 0.9.1-develop.7473.1.de5b22888f → 0.9.1-develop.7475.1.28ca93ed97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +10 -3
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/inputs/LookupPickers.js +149 -0
- package/dist/backend/inputs/LookupPickers.js.map +7 -0
- package/dist/backend/inputs/TagsInput.js +4 -1
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/inputs/index.js +2 -0
- package/dist/backend/inputs/index.js.map +2 -2
- package/dist/backend/inputs/lookupSources.js +84 -0
- package/dist/backend/inputs/lookupSources.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +9 -0
- package/src/backend/__tests__/CrudForm.disabledFields.test.tsx +57 -0
- package/src/backend/inputs/LookupPickers.tsx +181 -0
- package/src/backend/inputs/TagsInput.tsx +4 -0
- package/src/backend/inputs/__tests__/LookupPickers.test.tsx +120 -0
- package/src/backend/inputs/__tests__/TagsInput.test.tsx +41 -0
- package/src/backend/inputs/__tests__/lookupSources.test.ts +87 -0
- package/src/backend/inputs/index.ts +2 -0
- package/src/backend/inputs/lookupSources.ts +114 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
5
|
+
import { ComboboxInput } from "./ComboboxInput.js";
|
|
6
|
+
import { TagsInput } from "./TagsInput.js";
|
|
7
|
+
import {
|
|
8
|
+
resolveLookupFailureReason
|
|
9
|
+
} from "./lookupSources.js";
|
|
10
|
+
function LookupFailureMessage({ failure }) {
|
|
11
|
+
const t = useT();
|
|
12
|
+
if (!failure) return null;
|
|
13
|
+
return /* @__PURE__ */ jsx("p", { className: "mt-1 text-xs text-status-error-text", role: "alert", "data-testid": "lookup-picker-failure", children: failure === "forbidden" ? t("ui.inputs.lookupPicker.forbidden", "You do not have permission to load these options.") : t("ui.inputs.lookupPicker.loadFailed", "Could not load options. Try again.") });
|
|
14
|
+
}
|
|
15
|
+
function useLookupFailure() {
|
|
16
|
+
const [failure, setFailure] = React.useState(null);
|
|
17
|
+
const guard = React.useCallback(async (run, fallback) => {
|
|
18
|
+
try {
|
|
19
|
+
const value = await run();
|
|
20
|
+
setFailure(null);
|
|
21
|
+
return value;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
setFailure(resolveLookupFailureReason(error));
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
}, []);
|
|
27
|
+
return { failure, setFailure, guard };
|
|
28
|
+
}
|
|
29
|
+
function useLookupLabels(source, ids) {
|
|
30
|
+
const [options, setOptions] = React.useState({});
|
|
31
|
+
const { failure, setFailure, guard } = useLookupFailure();
|
|
32
|
+
const requested = React.useRef(/* @__PURE__ */ new Set());
|
|
33
|
+
const register = React.useCallback((next) => {
|
|
34
|
+
if (next.length === 0) return;
|
|
35
|
+
setOptions((previous) => {
|
|
36
|
+
const merged = { ...previous };
|
|
37
|
+
for (const option of next) merged[option.value] = option;
|
|
38
|
+
return merged;
|
|
39
|
+
});
|
|
40
|
+
}, []);
|
|
41
|
+
const idsKey = ids.join(",");
|
|
42
|
+
React.useEffect(() => {
|
|
43
|
+
const missing = idsKey.split(",").filter((id) => id.length > 0 && !requested.current.has(id));
|
|
44
|
+
if (missing.length === 0) return;
|
|
45
|
+
for (const id of missing) requested.current.add(id);
|
|
46
|
+
let cancelled = false;
|
|
47
|
+
let settled = false;
|
|
48
|
+
const release = () => {
|
|
49
|
+
for (const id of missing) requested.current.delete(id);
|
|
50
|
+
};
|
|
51
|
+
void source.resolve(missing).then(
|
|
52
|
+
(resolved) => {
|
|
53
|
+
settled = true;
|
|
54
|
+
if (cancelled) return;
|
|
55
|
+
register(resolved);
|
|
56
|
+
setFailure(null);
|
|
57
|
+
},
|
|
58
|
+
(error) => {
|
|
59
|
+
settled = true;
|
|
60
|
+
if (cancelled) return;
|
|
61
|
+
release();
|
|
62
|
+
setFailure(resolveLookupFailureReason(error));
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
return () => {
|
|
66
|
+
cancelled = true;
|
|
67
|
+
if (!settled) release();
|
|
68
|
+
};
|
|
69
|
+
}, [idsKey, register, setFailure, source]);
|
|
70
|
+
const search = React.useCallback(
|
|
71
|
+
(query) => guard(async () => {
|
|
72
|
+
const found = await source.search(query);
|
|
73
|
+
register(found);
|
|
74
|
+
return found;
|
|
75
|
+
}, []),
|
|
76
|
+
[guard, register, source]
|
|
77
|
+
);
|
|
78
|
+
return { options, search, failure };
|
|
79
|
+
}
|
|
80
|
+
function LookupMultiPicker({ source, value, onChange, placeholder, disabled }) {
|
|
81
|
+
const { options, search, failure } = useLookupLabels(source, value);
|
|
82
|
+
const suggestions = React.useMemo(() => Object.values(options), [options]);
|
|
83
|
+
return /* @__PURE__ */ jsxs("div", { children: [
|
|
84
|
+
/* @__PURE__ */ jsx(
|
|
85
|
+
TagsInput,
|
|
86
|
+
{
|
|
87
|
+
value,
|
|
88
|
+
onChange,
|
|
89
|
+
suggestions,
|
|
90
|
+
loadSuggestions: search,
|
|
91
|
+
allowCustomValues: false,
|
|
92
|
+
closeSuggestionsOnSelect: true,
|
|
93
|
+
resolveLabel: (id) => options[id]?.label ?? id,
|
|
94
|
+
resolveDescription: (id) => options[id]?.description ?? null,
|
|
95
|
+
placeholder,
|
|
96
|
+
disabled
|
|
97
|
+
}
|
|
98
|
+
),
|
|
99
|
+
/* @__PURE__ */ jsx(LookupFailureMessage, { failure })
|
|
100
|
+
] });
|
|
101
|
+
}
|
|
102
|
+
function LookupSinglePicker({
|
|
103
|
+
source,
|
|
104
|
+
value,
|
|
105
|
+
onChange,
|
|
106
|
+
placeholder,
|
|
107
|
+
disabled,
|
|
108
|
+
clearable
|
|
109
|
+
}) {
|
|
110
|
+
const { failure, guard } = useLookupFailure();
|
|
111
|
+
const resolveLabel = React.useCallback(
|
|
112
|
+
(id) => guard(async () => {
|
|
113
|
+
const [option] = await source.resolve([id]);
|
|
114
|
+
return option?.label ?? id;
|
|
115
|
+
}, id),
|
|
116
|
+
[guard, source]
|
|
117
|
+
);
|
|
118
|
+
const search = React.useCallback((query) => guard(() => source.search(query), []), [guard, source]);
|
|
119
|
+
return /* @__PURE__ */ jsxs("div", { children: [
|
|
120
|
+
/* @__PURE__ */ jsx(
|
|
121
|
+
ComboboxInput,
|
|
122
|
+
{
|
|
123
|
+
value,
|
|
124
|
+
onChange,
|
|
125
|
+
allowCustomValues: false,
|
|
126
|
+
clearable,
|
|
127
|
+
placeholder,
|
|
128
|
+
disabled,
|
|
129
|
+
loadSuggestions: search,
|
|
130
|
+
resolveLabel
|
|
131
|
+
}
|
|
132
|
+
),
|
|
133
|
+
/* @__PURE__ */ jsx(LookupFailureMessage, { failure })
|
|
134
|
+
] });
|
|
135
|
+
}
|
|
136
|
+
function useLookupLabelMap(source, ids) {
|
|
137
|
+
const { options } = useLookupLabels(source, ids);
|
|
138
|
+
return React.useMemo(() => {
|
|
139
|
+
const labels = {};
|
|
140
|
+
for (const [id, option] of Object.entries(options)) labels[id] = option.label;
|
|
141
|
+
return labels;
|
|
142
|
+
}, [options]);
|
|
143
|
+
}
|
|
144
|
+
export {
|
|
145
|
+
LookupMultiPicker,
|
|
146
|
+
LookupSinglePicker,
|
|
147
|
+
useLookupLabelMap
|
|
148
|
+
};
|
|
149
|
+
//# sourceMappingURL=LookupPickers.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/backend/inputs/LookupPickers.tsx"],
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { ComboboxInput } from './ComboboxInput'\nimport { TagsInput } from './TagsInput'\nimport {\n resolveLookupFailureReason,\n type LookupLoadFailureReason,\n type LookupOption,\n type LookupSource,\n} from './lookupSources'\n\nfunction LookupFailureMessage({ failure }: { failure: LookupLoadFailureReason | null }) {\n const t = useT()\n if (!failure) return null\n return (\n <p className=\"mt-1 text-xs text-status-error-text\" role=\"alert\" data-testid=\"lookup-picker-failure\">\n {failure === 'forbidden'\n ? t('ui.inputs.lookupPicker.forbidden', 'You do not have permission to load these options.')\n : t('ui.inputs.lookupPicker.loadFailed', 'Could not load options. Try again.')}\n </p>\n )\n}\n\nfunction useLookupFailure() {\n const [failure, setFailure] = React.useState<LookupLoadFailureReason | null>(null)\n const guard = React.useCallback(async <TValue,>(run: () => Promise<TValue>, fallback: TValue): Promise<TValue> => {\n try {\n const value = await run()\n setFailure(null)\n return value\n } catch (error) {\n setFailure(resolveLookupFailureReason(error))\n return fallback\n }\n }, [])\n return { failure, setFailure, guard }\n}\n\nfunction useLookupLabels(source: LookupSource, ids: readonly string[]) {\n const [options, setOptions] = React.useState<Record<string, LookupOption>>({})\n const { failure, setFailure, guard } = useLookupFailure()\n const requested = React.useRef(new Set<string>())\n\n const register = React.useCallback((next: readonly LookupOption[]) => {\n if (next.length === 0) return\n setOptions((previous) => {\n const merged = { ...previous }\n for (const option of next) merged[option.value] = option\n return merged\n })\n }, [])\n\n const idsKey = ids.join(',')\n React.useEffect(() => {\n const missing = idsKey\n .split(',')\n .filter((id) => id.length > 0 && !requested.current.has(id))\n if (missing.length === 0) return\n for (const id of missing) requested.current.add(id)\n let cancelled = false\n let settled = false\n const release = () => {\n for (const id of missing) requested.current.delete(id)\n }\n void source.resolve(missing).then(\n (resolved) => {\n settled = true\n if (cancelled) return\n register(resolved)\n setFailure(null)\n },\n (error: unknown) => {\n settled = true\n if (cancelled) return\n release()\n setFailure(resolveLookupFailureReason(error))\n },\n )\n return () => {\n cancelled = true\n if (!settled) release()\n }\n }, [idsKey, register, setFailure, source])\n\n const search = React.useCallback(\n (query?: string) =>\n guard(async () => {\n const found = await source.search(query)\n register(found)\n return found\n }, []),\n [guard, register, source],\n )\n\n return { options, search, failure }\n}\n\ntype LookupMultiPickerProps = {\n source: LookupSource\n value: string[]\n onChange: (next: string[]) => void\n placeholder?: string\n disabled?: boolean\n}\n\nexport function LookupMultiPicker({ source, value, onChange, placeholder, disabled }: LookupMultiPickerProps) {\n const { options, search, failure } = useLookupLabels(source, value)\n const suggestions = React.useMemo(() => Object.values(options), [options])\n return (\n <div>\n <TagsInput\n value={value}\n onChange={onChange}\n suggestions={suggestions}\n loadSuggestions={search}\n allowCustomValues={false}\n closeSuggestionsOnSelect\n resolveLabel={(id) => options[id]?.label ?? id}\n resolveDescription={(id) => options[id]?.description ?? null}\n placeholder={placeholder}\n disabled={disabled}\n />\n <LookupFailureMessage failure={failure} />\n </div>\n )\n}\n\ntype LookupSinglePickerProps = {\n source: LookupSource\n value: string\n onChange: (next: string) => void\n placeholder?: string\n disabled?: boolean\n clearable?: boolean\n}\n\nexport function LookupSinglePicker({\n source,\n value,\n onChange,\n placeholder,\n disabled,\n clearable,\n}: LookupSinglePickerProps) {\n const { failure, guard } = useLookupFailure()\n const resolveLabel = React.useCallback(\n (id: string) =>\n guard(async () => {\n const [option] = await source.resolve([id])\n return option?.label ?? id\n }, id),\n [guard, source],\n )\n const search = React.useCallback((query?: string) => guard(() => source.search(query), []), [guard, source])\n return (\n <div>\n <ComboboxInput\n value={value}\n onChange={onChange}\n allowCustomValues={false}\n clearable={clearable}\n placeholder={placeholder}\n disabled={disabled}\n loadSuggestions={search}\n resolveLabel={resolveLabel}\n />\n <LookupFailureMessage failure={failure} />\n </div>\n )\n}\n\nexport function useLookupLabelMap(source: LookupSource, ids: readonly string[]): Record<string, string> {\n const { options } = useLookupLabels(source, ids)\n return React.useMemo(() => {\n const labels: Record<string, string> = {}\n for (const [id, option] of Object.entries(options)) labels[id] = option.label\n return labels\n }, [options])\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -34,7 +34,8 @@ function TagsInput({
|
|
|
34
34
|
disabled = false,
|
|
35
35
|
allowCustomValues = true,
|
|
36
36
|
showSuggestionsOnFocus = true,
|
|
37
|
-
suppressInitialSuggestionsOnFocus = false
|
|
37
|
+
suppressInitialSuggestionsOnFocus = false,
|
|
38
|
+
closeSuggestionsOnSelect = false
|
|
38
39
|
}) {
|
|
39
40
|
const t = useT();
|
|
40
41
|
const [input, setInput] = React.useState("");
|
|
@@ -232,6 +233,7 @@ function TagsInput({
|
|
|
232
233
|
event.preventDefault();
|
|
233
234
|
addTag(input);
|
|
234
235
|
setInput("");
|
|
236
|
+
if (closeSuggestionsOnSelect) setSuggestionsDismissed(true);
|
|
235
237
|
} else if (event.key === "Backspace" && input === "" && value.length > 0) {
|
|
236
238
|
removeTag(value[value.length - 1]);
|
|
237
239
|
}
|
|
@@ -264,6 +266,7 @@ function TagsInput({
|
|
|
264
266
|
suppressBlurCommitRef.current = false;
|
|
265
267
|
addValue(option.value);
|
|
266
268
|
setInput("");
|
|
269
|
+
if (closeSuggestionsOnSelect) setSuggestionsDismissed(true);
|
|
267
270
|
},
|
|
268
271
|
children: [
|
|
269
272
|
/* @__PURE__ */ jsx("span", { children: option.label }),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/inputs/TagsInput.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { Tag } from '../../primitives/tag'\n\nexport type TagsInputOption = {\n value: string\n label: string\n description?: string | null\n}\n\nexport type TagsInputProps = {\n value: string[]\n onChange: (next: string[]) => void\n placeholder?: string\n suggestions?: Array<string | TagsInputOption>\n loadSuggestions?: (query?: string) => Promise<Array<string | TagsInputOption>>\n selectedOptions?: TagsInputOption[]\n resolveLabel?: (value: string) => string\n resolveDescription?: (value: string) => string | null | undefined\n autoFocus?: boolean\n disabled?: boolean\n allowCustomValues?: boolean\n showSuggestionsOnFocus?: boolean\n suppressInitialSuggestionsOnFocus?: boolean\n}\n\nfunction normalizeOptions(input?: Array<string | TagsInputOption>): TagsInputOption[] {\n if (!Array.isArray(input)) return []\n return input\n .map((option) => {\n if (typeof option === 'string') {\n const trimmed = option.trim()\n if (!trimmed) return null\n return { value: trimmed, label: trimmed }\n }\n const value = typeof option.value === 'string' ? option.value.trim() : ''\n if (!value) return null\n return {\n value,\n label: option.label?.trim() || value,\n description: option.description ?? null,\n }\n })\n .filter((option): option is TagsInputOption => !!option)\n}\n\nexport function TagsInput({\n value,\n onChange,\n placeholder,\n suggestions,\n loadSuggestions,\n selectedOptions,\n resolveLabel,\n resolveDescription,\n autoFocus,\n disabled = false,\n allowCustomValues = true,\n showSuggestionsOnFocus = true,\n suppressInitialSuggestionsOnFocus = false,\n}: TagsInputProps) {\n const t = useT()\n const [input, setInput] = React.useState('')\n const [asyncOptions, setAsyncOptions] = React.useState<TagsInputOption[]>([])\n const [loading, setLoading] = React.useState(false)\n const [touched, setTouched] = React.useState(false)\n const [suggestionsDismissed, setSuggestionsDismissed] = React.useState(false)\n const inputRef = React.useRef<HTMLInputElement>(null)\n const suppressBlurCommitRef = React.useRef(false)\n const suppressSuggestionsOnFocusRef = React.useRef(Boolean(autoFocus && suppressInitialSuggestionsOnFocus && !disabled))\n const valueRef = React.useRef(value)\n\n React.useEffect(() => {\n valueRef.current = value\n }, [value])\n\n React.useEffect(() => {\n suppressSuggestionsOnFocusRef.current = Boolean(autoFocus && suppressInitialSuggestionsOnFocus && !disabled)\n }, [autoFocus, disabled, suppressInitialSuggestionsOnFocus])\n\n const staticOptions = React.useMemo(() => normalizeOptions(suggestions), [suggestions])\n const selectedOptionList = React.useMemo(\n () => normalizeOptions(selectedOptions),\n [selectedOptions]\n )\n\n const optionMap = React.useMemo(() => {\n const map = new Map<string, TagsInputOption>()\n const register = (option: TagsInputOption) => {\n if (!map.has(option.value)) {\n map.set(option.value, option)\n }\n }\n staticOptions.forEach(register)\n asyncOptions.forEach(register)\n selectedOptionList.forEach(register)\n value.forEach((val) => {\n if (map.has(val)) return\n map.set(val, {\n value: val,\n label: resolveLabel?.(val) ?? val,\n description: resolveDescription?.(val) ?? null,\n })\n })\n return map\n }, [asyncOptions, resolveDescription, resolveLabel, selectedOptionList, staticOptions, value])\n\n const availableOptions = React.useMemo(() => {\n return Array.from(optionMap.values()).filter((option) => !value.includes(option.value))\n }, [optionMap, value])\n\n const filteredSuggestions = React.useMemo(() => {\n const query = input.toLowerCase().trim()\n if (!query) return availableOptions.slice(0, 8)\n return availableOptions.filter((option) => {\n const labelMatch = option.label.toLowerCase().includes(query)\n const descMatch = option.description?.toLowerCase().includes(query)\n return labelMatch || Boolean(descMatch)\n })\n }, [availableOptions, input])\n\n React.useEffect(() => {\n if (!loadSuggestions || !touched || disabled) return\n const query = input.trim()\n let cancelled = false\n const handle = window.setTimeout(async () => {\n setLoading(true)\n try {\n const items = await loadSuggestions(query)\n if (!cancelled) {\n setAsyncOptions(normalizeOptions(items))\n }\n } finally {\n if (!cancelled) setLoading(false)\n }\n }, 200)\n return () => {\n cancelled = true\n window.clearTimeout(handle)\n }\n }, [disabled, input, loadSuggestions, touched])\n\n const suggestionsOpen = !suggestionsDismissed\n && ((loading && touched) || (!loading && filteredSuggestions.length > 0))\n\n React.useEffect(() => {\n if (!suggestionsOpen) return\n const handleEscape = (event: KeyboardEvent) => {\n if (event.key !== 'Escape' || event.target !== inputRef.current) return\n event.preventDefault()\n event.stopPropagation()\n setSuggestionsDismissed(true)\n }\n window.addEventListener('keydown', handleEscape, { capture: true })\n return () => window.removeEventListener('keydown', handleEscape, { capture: true })\n }, [suggestionsOpen])\n\n const addValue = React.useCallback(\n (nextValue: string) => {\n if (disabled) return\n const trimmed = nextValue.trim()\n if (!trimmed) return\n const currentValue = valueRef.current\n if (currentValue.includes(trimmed)) return\n const next = [...currentValue, trimmed]\n valueRef.current = next\n onChange(next)\n },\n [disabled, onChange]\n )\n\n const findOptionForInput = React.useCallback(\n (raw: string): TagsInputOption | null => {\n const query = raw.trim().toLowerCase()\n if (!query) return null\n for (const option of optionMap.values()) {\n if (option.value === raw.trim()) return option\n if (option.label.toLowerCase() === query) return option\n }\n return null\n },\n [optionMap]\n )\n\n const addTag = React.useCallback(\n (raw: string) => {\n if (disabled) return\n const option = findOptionForInput(raw)\n if (option) {\n addValue(option.value)\n return\n }\n if (!allowCustomValues) return\n addValue(raw)\n },\n [addValue, allowCustomValues, disabled, findOptionForInput]\n )\n\n const removeTag = React.useCallback(\n (tag: string) => {\n if (disabled) return\n const next = valueRef.current.filter((candidate) => candidate !== tag)\n valueRef.current = next\n onChange(next)\n },\n [disabled, onChange]\n )\n\n return (\n <div\n className={[\n 'w-full rounded border px-2 py-1',\n disabled ? 'bg-muted text-muted-foreground/80 cursor-not-allowed' : '',\n ]\n .filter(Boolean)\n .join(' ')}\n aria-disabled={disabled || undefined}\n >\n <div className=\"flex flex-wrap gap-1\">\n {value.map((tag) => {\n const option = optionMap.get(tag)\n const label = option?.label ?? tag\n const description = option?.description\n return (\n <Tag\n key={tag}\n shape=\"square\"\n variant=\"default\"\n disabled={disabled}\n onRemove={() => removeTag(tag)}\n removeAriaLabel={t('ui.inputs.tagsInput.removeTag', 'Remove {label}', { label })}\n >\n <span className=\"flex flex-col items-start leading-tight\">\n <span className=\"whitespace-nowrap\">{label}</span>\n {description ? (\n <span className=\"text-overline text-muted-foreground\">{description}</span>\n ) : null}\n </span>\n </Tag>\n )\n })}\n <input\n ref={inputRef}\n className=\"flex-1 min-w-[80px] sm:min-w-[120px] border-0 py-1 text-sm outline-none disabled:bg-transparent\"\n value={input}\n placeholder={placeholder || t('ui.inputs.tagsInput.placeholder', 'Add tag and press Enter')}\n autoFocus={autoFocus}\n data-crud-focus-target=\"\"\n disabled={disabled}\n onFocus={() => {\n if (suppressSuggestionsOnFocusRef.current) {\n suppressSuggestionsOnFocusRef.current = false\n return\n }\n if (showSuggestionsOnFocus) {\n setTouched(true)\n setSuggestionsDismissed(false)\n }\n }}\n onMouseDown={() => {\n setTouched(true)\n setSuggestionsDismissed(false)\n }}\n onChange={(event) => {\n setTouched(true)\n setSuggestionsDismissed(false)\n setInput(event.target.value)\n }}\n onKeyDown={(event) => {\n if (disabled) return\n if (event.key === 'Enter' || event.key === ',') {\n event.preventDefault()\n addTag(input)\n setInput('')\n } else if (event.key === 'Backspace' && input === '' && value.length > 0) {\n removeTag(value[value.length - 1])\n }\n }}\n onBlur={() => {\n if (disabled) return\n if (suppressBlurCommitRef.current) {\n suppressBlurCommitRef.current = false\n setInput('')\n return\n }\n addTag(input)\n setInput('')\n }}\n />\n {suggestionsOpen && loading && touched ? (\n <div className=\"basis-full mt-1 text-xs text-muted-foreground\">{t('ui.inputs.tagsInput.loading', 'Loading suggestions\u2026')}</div>\n ) : null}\n {suggestionsOpen && !loading && filteredSuggestions.length ? (\n <div className=\"basis-full mt-1 flex flex-col gap-1\">\n {filteredSuggestions.map((option) => (\n <Button\n key={option.value}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full justify-start font-normal flex flex-col items-start text-xs px-1.5 py-1\"\n onMouseDown={(event) => {\n suppressBlurCommitRef.current = true\n event.preventDefault()\n }}\n onClick={() => {\n suppressBlurCommitRef.current = false\n addValue(option.value)\n setInput('')\n }}\n >\n <span>{option.label}</span>\n {option.description ? (\n <span className=\"text-overline text-muted-foreground\">{option.description}</span>\n ) : null}\n </Button>\n ))}\n </div>\n ) : null}\n </div>\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { Tag } from '../../primitives/tag'\n\nexport type TagsInputOption = {\n value: string\n label: string\n description?: string | null\n}\n\nexport type TagsInputProps = {\n value: string[]\n onChange: (next: string[]) => void\n placeholder?: string\n suggestions?: Array<string | TagsInputOption>\n loadSuggestions?: (query?: string) => Promise<Array<string | TagsInputOption>>\n selectedOptions?: TagsInputOption[]\n resolveLabel?: (value: string) => string\n resolveDescription?: (value: string) => string | null | undefined\n autoFocus?: boolean\n disabled?: boolean\n allowCustomValues?: boolean\n showSuggestionsOnFocus?: boolean\n suppressInitialSuggestionsOnFocus?: boolean\n closeSuggestionsOnSelect?: boolean\n}\n\nfunction normalizeOptions(input?: Array<string | TagsInputOption>): TagsInputOption[] {\n if (!Array.isArray(input)) return []\n return input\n .map((option) => {\n if (typeof option === 'string') {\n const trimmed = option.trim()\n if (!trimmed) return null\n return { value: trimmed, label: trimmed }\n }\n const value = typeof option.value === 'string' ? option.value.trim() : ''\n if (!value) return null\n return {\n value,\n label: option.label?.trim() || value,\n description: option.description ?? null,\n }\n })\n .filter((option): option is TagsInputOption => !!option)\n}\n\nexport function TagsInput({\n value,\n onChange,\n placeholder,\n suggestions,\n loadSuggestions,\n selectedOptions,\n resolveLabel,\n resolveDescription,\n autoFocus,\n disabled = false,\n allowCustomValues = true,\n showSuggestionsOnFocus = true,\n suppressInitialSuggestionsOnFocus = false,\n closeSuggestionsOnSelect = false,\n}: TagsInputProps) {\n const t = useT()\n const [input, setInput] = React.useState('')\n const [asyncOptions, setAsyncOptions] = React.useState<TagsInputOption[]>([])\n const [loading, setLoading] = React.useState(false)\n const [touched, setTouched] = React.useState(false)\n const [suggestionsDismissed, setSuggestionsDismissed] = React.useState(false)\n const inputRef = React.useRef<HTMLInputElement>(null)\n const suppressBlurCommitRef = React.useRef(false)\n const suppressSuggestionsOnFocusRef = React.useRef(Boolean(autoFocus && suppressInitialSuggestionsOnFocus && !disabled))\n const valueRef = React.useRef(value)\n\n React.useEffect(() => {\n valueRef.current = value\n }, [value])\n\n React.useEffect(() => {\n suppressSuggestionsOnFocusRef.current = Boolean(autoFocus && suppressInitialSuggestionsOnFocus && !disabled)\n }, [autoFocus, disabled, suppressInitialSuggestionsOnFocus])\n\n const staticOptions = React.useMemo(() => normalizeOptions(suggestions), [suggestions])\n const selectedOptionList = React.useMemo(\n () => normalizeOptions(selectedOptions),\n [selectedOptions]\n )\n\n const optionMap = React.useMemo(() => {\n const map = new Map<string, TagsInputOption>()\n const register = (option: TagsInputOption) => {\n if (!map.has(option.value)) {\n map.set(option.value, option)\n }\n }\n staticOptions.forEach(register)\n asyncOptions.forEach(register)\n selectedOptionList.forEach(register)\n value.forEach((val) => {\n if (map.has(val)) return\n map.set(val, {\n value: val,\n label: resolveLabel?.(val) ?? val,\n description: resolveDescription?.(val) ?? null,\n })\n })\n return map\n }, [asyncOptions, resolveDescription, resolveLabel, selectedOptionList, staticOptions, value])\n\n const availableOptions = React.useMemo(() => {\n return Array.from(optionMap.values()).filter((option) => !value.includes(option.value))\n }, [optionMap, value])\n\n const filteredSuggestions = React.useMemo(() => {\n const query = input.toLowerCase().trim()\n if (!query) return availableOptions.slice(0, 8)\n return availableOptions.filter((option) => {\n const labelMatch = option.label.toLowerCase().includes(query)\n const descMatch = option.description?.toLowerCase().includes(query)\n return labelMatch || Boolean(descMatch)\n })\n }, [availableOptions, input])\n\n React.useEffect(() => {\n if (!loadSuggestions || !touched || disabled) return\n const query = input.trim()\n let cancelled = false\n const handle = window.setTimeout(async () => {\n setLoading(true)\n try {\n const items = await loadSuggestions(query)\n if (!cancelled) {\n setAsyncOptions(normalizeOptions(items))\n }\n } finally {\n if (!cancelled) setLoading(false)\n }\n }, 200)\n return () => {\n cancelled = true\n window.clearTimeout(handle)\n }\n }, [disabled, input, loadSuggestions, touched])\n\n const suggestionsOpen = !suggestionsDismissed\n && ((loading && touched) || (!loading && filteredSuggestions.length > 0))\n\n React.useEffect(() => {\n if (!suggestionsOpen) return\n const handleEscape = (event: KeyboardEvent) => {\n if (event.key !== 'Escape' || event.target !== inputRef.current) return\n event.preventDefault()\n event.stopPropagation()\n setSuggestionsDismissed(true)\n }\n window.addEventListener('keydown', handleEscape, { capture: true })\n return () => window.removeEventListener('keydown', handleEscape, { capture: true })\n }, [suggestionsOpen])\n\n const addValue = React.useCallback(\n (nextValue: string) => {\n if (disabled) return\n const trimmed = nextValue.trim()\n if (!trimmed) return\n const currentValue = valueRef.current\n if (currentValue.includes(trimmed)) return\n const next = [...currentValue, trimmed]\n valueRef.current = next\n onChange(next)\n },\n [disabled, onChange]\n )\n\n const findOptionForInput = React.useCallback(\n (raw: string): TagsInputOption | null => {\n const query = raw.trim().toLowerCase()\n if (!query) return null\n for (const option of optionMap.values()) {\n if (option.value === raw.trim()) return option\n if (option.label.toLowerCase() === query) return option\n }\n return null\n },\n [optionMap]\n )\n\n const addTag = React.useCallback(\n (raw: string) => {\n if (disabled) return\n const option = findOptionForInput(raw)\n if (option) {\n addValue(option.value)\n return\n }\n if (!allowCustomValues) return\n addValue(raw)\n },\n [addValue, allowCustomValues, disabled, findOptionForInput]\n )\n\n const removeTag = React.useCallback(\n (tag: string) => {\n if (disabled) return\n const next = valueRef.current.filter((candidate) => candidate !== tag)\n valueRef.current = next\n onChange(next)\n },\n [disabled, onChange]\n )\n\n return (\n <div\n className={[\n 'w-full rounded border px-2 py-1',\n disabled ? 'bg-muted text-muted-foreground/80 cursor-not-allowed' : '',\n ]\n .filter(Boolean)\n .join(' ')}\n aria-disabled={disabled || undefined}\n >\n <div className=\"flex flex-wrap gap-1\">\n {value.map((tag) => {\n const option = optionMap.get(tag)\n const label = option?.label ?? tag\n const description = option?.description\n return (\n <Tag\n key={tag}\n shape=\"square\"\n variant=\"default\"\n disabled={disabled}\n onRemove={() => removeTag(tag)}\n removeAriaLabel={t('ui.inputs.tagsInput.removeTag', 'Remove {label}', { label })}\n >\n <span className=\"flex flex-col items-start leading-tight\">\n <span className=\"whitespace-nowrap\">{label}</span>\n {description ? (\n <span className=\"text-overline text-muted-foreground\">{description}</span>\n ) : null}\n </span>\n </Tag>\n )\n })}\n <input\n ref={inputRef}\n className=\"flex-1 min-w-[80px] sm:min-w-[120px] border-0 py-1 text-sm outline-none disabled:bg-transparent\"\n value={input}\n placeholder={placeholder || t('ui.inputs.tagsInput.placeholder', 'Add tag and press Enter')}\n autoFocus={autoFocus}\n data-crud-focus-target=\"\"\n disabled={disabled}\n onFocus={() => {\n if (suppressSuggestionsOnFocusRef.current) {\n suppressSuggestionsOnFocusRef.current = false\n return\n }\n if (showSuggestionsOnFocus) {\n setTouched(true)\n setSuggestionsDismissed(false)\n }\n }}\n onMouseDown={() => {\n setTouched(true)\n setSuggestionsDismissed(false)\n }}\n onChange={(event) => {\n setTouched(true)\n setSuggestionsDismissed(false)\n setInput(event.target.value)\n }}\n onKeyDown={(event) => {\n if (disabled) return\n if (event.key === 'Enter' || event.key === ',') {\n event.preventDefault()\n addTag(input)\n setInput('')\n if (closeSuggestionsOnSelect) setSuggestionsDismissed(true)\n } else if (event.key === 'Backspace' && input === '' && value.length > 0) {\n removeTag(value[value.length - 1])\n }\n }}\n onBlur={() => {\n if (disabled) return\n if (suppressBlurCommitRef.current) {\n suppressBlurCommitRef.current = false\n setInput('')\n return\n }\n addTag(input)\n setInput('')\n }}\n />\n {suggestionsOpen && loading && touched ? (\n <div className=\"basis-full mt-1 text-xs text-muted-foreground\">{t('ui.inputs.tagsInput.loading', 'Loading suggestions\u2026')}</div>\n ) : null}\n {suggestionsOpen && !loading && filteredSuggestions.length ? (\n <div className=\"basis-full mt-1 flex flex-col gap-1\">\n {filteredSuggestions.map((option) => (\n <Button\n key={option.value}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full justify-start font-normal flex flex-col items-start text-xs px-1.5 py-1\"\n onMouseDown={(event) => {\n suppressBlurCommitRef.current = true\n event.preventDefault()\n }}\n onClick={() => {\n suppressBlurCommitRef.current = false\n addValue(option.value)\n setInput('')\n if (closeSuggestionsOnSelect) setSuggestionsDismissed(true)\n }}\n >\n <span>{option.label}</span>\n {option.description ? (\n <span className=\"text-overline text-muted-foreground\">{option.description}</span>\n ) : null}\n </Button>\n ))}\n </div>\n ) : null}\n </div>\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/inputs/index.ts"],
|
|
4
|
-
"sourcesContent": ["export * from './PhoneNumberField'\nexport * from './TagsInput'\nexport * from './LookupSelect'\nexport * from './ComboboxInput'\nexport * from './SwitchableMarkdownInput'\nexport * from './EventSelect'\nexport * from './EventPatternInput'\nexport * from './TimeInput'\nexport * from './TimePicker'\nexport * from './DateTimePicker'\nexport * from './DatePicker'\nexport * from './DurationInput'\n"],
|
|
5
|
-
"mappings": "AAAA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;",
|
|
4
|
+
"sourcesContent": ["export * from './PhoneNumberField'\nexport * from './TagsInput'\nexport * from './LookupSelect'\nexport * from './ComboboxInput'\nexport * from './SwitchableMarkdownInput'\nexport * from './EventSelect'\nexport * from './EventPatternInput'\nexport * from './TimeInput'\nexport * from './TimePicker'\nexport * from './DateTimePicker'\nexport * from './DatePicker'\nexport * from './DurationInput'\nexport * from './lookupSources'\nexport * from './LookupPickers'\n"],
|
|
5
|
+
"mappings": "AAAA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
2
|
+
import { readApiResultOrThrow } from "../utils/apiCall.js";
|
|
3
|
+
const logger = createLogger("ui").child({ component: "lookup-sources" });
|
|
4
|
+
class LookupLoadError extends Error {
|
|
5
|
+
constructor(reason, path) {
|
|
6
|
+
super(`[internal] lookup load failed (${reason}) for ${path}`);
|
|
7
|
+
this.name = "LookupLoadError";
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function resolveLookupFailureReason(error) {
|
|
12
|
+
if (!error || typeof error !== "object") return "failed";
|
|
13
|
+
const candidate = error;
|
|
14
|
+
return candidate.name === "LookupLoadError" && candidate.reason === "forbidden" ? "forbidden" : "failed";
|
|
15
|
+
}
|
|
16
|
+
function readErrorStatus(error) {
|
|
17
|
+
if (!error || typeof error !== "object") return null;
|
|
18
|
+
const status = error.status;
|
|
19
|
+
return typeof status === "number" ? status : null;
|
|
20
|
+
}
|
|
21
|
+
const SEARCH_PAGE_SIZE = "50";
|
|
22
|
+
const RESOLVE_CHUNK_SIZE = 100;
|
|
23
|
+
function pickLookupString(item, ...keys) {
|
|
24
|
+
for (const key of keys) {
|
|
25
|
+
const value = item[key];
|
|
26
|
+
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
|
27
|
+
}
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
async function loadItems(path, params) {
|
|
31
|
+
try {
|
|
32
|
+
const search = new URLSearchParams(params);
|
|
33
|
+
const payload = await readApiResultOrThrow(`${path}?${search.toString()}`, void 0, {
|
|
34
|
+
fallback: { items: [] }
|
|
35
|
+
});
|
|
36
|
+
return Array.isArray(payload?.items) ? payload.items : [];
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const status = readErrorStatus(err);
|
|
39
|
+
logger.warn("ui.lookups.load.failed", { err, path, status });
|
|
40
|
+
throw new LookupLoadError(status === 401 || status === 403 ? "forbidden" : "failed", path);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function mapItems(items, mapItem) {
|
|
44
|
+
return items.map(mapItem).filter((option) => option !== null);
|
|
45
|
+
}
|
|
46
|
+
function chunkIds(ids) {
|
|
47
|
+
const unique = Array.from(new Set(ids));
|
|
48
|
+
const chunks = [];
|
|
49
|
+
for (let start = 0; start < unique.length; start += RESOLVE_CHUNK_SIZE) {
|
|
50
|
+
chunks.push(unique.slice(start, start + RESOLVE_CHUNK_SIZE));
|
|
51
|
+
}
|
|
52
|
+
return chunks;
|
|
53
|
+
}
|
|
54
|
+
async function resolveByIds(path, ids, mapItem, extraParams = {}) {
|
|
55
|
+
const chunks = chunkIds(ids);
|
|
56
|
+
const pages = await Promise.all(
|
|
57
|
+
chunks.map(
|
|
58
|
+
(chunk) => loadItems(path, { ...extraParams, ids: chunk.join(","), pageSize: String(chunk.length) })
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
return mapItems(pages.flat(), mapItem);
|
|
62
|
+
}
|
|
63
|
+
function searchParams(query, pageSize) {
|
|
64
|
+
const trimmed = (query ?? "").trim();
|
|
65
|
+
return trimmed.length > 0 ? { search: trimmed, pageSize } : { pageSize };
|
|
66
|
+
}
|
|
67
|
+
function createIdsLookupSource(id, path, mapItem, extraParams = {}) {
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
search: async (query) => mapItems(await loadItems(path, { ...extraParams, ...searchParams(query, SEARCH_PAGE_SIZE) }), mapItem),
|
|
71
|
+
resolve: (ids) => resolveByIds(path, ids, mapItem, extraParams)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function lookupLabelWithCode(label, code) {
|
|
75
|
+
return code && code !== label ? `${label} (${code})` : label;
|
|
76
|
+
}
|
|
77
|
+
export {
|
|
78
|
+
LookupLoadError,
|
|
79
|
+
createIdsLookupSource,
|
|
80
|
+
lookupLabelWithCode,
|
|
81
|
+
pickLookupString,
|
|
82
|
+
resolveLookupFailureReason
|
|
83
|
+
};
|
|
84
|
+
//# sourceMappingURL=lookupSources.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/backend/inputs/lookupSources.ts"],
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { readApiResultOrThrow } from '../utils/apiCall'\n\nconst logger = createLogger('ui').child({ component: 'lookup-sources' })\n\nexport type LookupOption = { value: string; label: string; description?: string | null }\n\nexport type LookupSource = {\n id: string\n search: (query?: string) => Promise<LookupOption[]>\n resolve: (ids: readonly string[]) => Promise<LookupOption[]>\n}\n\nexport type LookupRemoteItem = Record<string, unknown>\n\nexport type LookupLoadFailureReason = 'forbidden' | 'failed'\n\nexport class LookupLoadError extends Error {\n readonly reason: LookupLoadFailureReason\n\n constructor(reason: LookupLoadFailureReason, path: string) {\n super(`[internal] lookup load failed (${reason}) for ${path}`)\n this.name = 'LookupLoadError'\n this.reason = reason\n }\n}\n\nexport function resolveLookupFailureReason(error: unknown): LookupLoadFailureReason {\n if (!error || typeof error !== 'object') return 'failed'\n const candidate = error as { name?: unknown; reason?: unknown }\n return candidate.name === 'LookupLoadError' && candidate.reason === 'forbidden' ? 'forbidden' : 'failed'\n}\n\nfunction readErrorStatus(error: unknown): number | null {\n if (!error || typeof error !== 'object') return null\n const status = (error as { status?: unknown }).status\n return typeof status === 'number' ? status : null\n}\n\nconst SEARCH_PAGE_SIZE = '50'\nconst RESOLVE_CHUNK_SIZE = 100\n\nexport function pickLookupString(item: LookupRemoteItem, ...keys: string[]): string {\n for (const key of keys) {\n const value = item[key]\n if (typeof value === 'string' && value.trim().length > 0) return value.trim()\n }\n return ''\n}\n\nasync function loadItems(path: string, params: Record<string, string>): Promise<LookupRemoteItem[]> {\n try {\n const search = new URLSearchParams(params)\n const payload = await readApiResultOrThrow<{ items?: LookupRemoteItem[] }>(`${path}?${search.toString()}`, undefined, {\n fallback: { items: [] },\n })\n return Array.isArray(payload?.items) ? payload.items : []\n } catch (err) {\n const status = readErrorStatus(err)\n logger.warn('ui.lookups.load.failed', { err, path, status })\n throw new LookupLoadError(status === 401 || status === 403 ? 'forbidden' : 'failed', path)\n }\n}\n\nfunction mapItems(items: LookupRemoteItem[], mapItem: (item: LookupRemoteItem) => LookupOption | null): LookupOption[] {\n return items.map(mapItem).filter((option): option is LookupOption => option !== null)\n}\n\nfunction chunkIds(ids: readonly string[]): string[][] {\n const unique = Array.from(new Set(ids))\n const chunks: string[][] = []\n for (let start = 0; start < unique.length; start += RESOLVE_CHUNK_SIZE) {\n chunks.push(unique.slice(start, start + RESOLVE_CHUNK_SIZE))\n }\n return chunks\n}\n\n/** Resolves `ids` through the `?ids=` filter in chunks of at most 100, one request per chunk. */\nasync function resolveByIds(\n path: string,\n ids: readonly string[],\n mapItem: (item: LookupRemoteItem) => LookupOption | null,\n extraParams: Record<string, string> = {},\n): Promise<LookupOption[]> {\n const chunks = chunkIds(ids)\n const pages = await Promise.all(\n chunks.map((chunk) =>\n loadItems(path, { ...extraParams, ids: chunk.join(','), pageSize: String(chunk.length) }),\n ),\n )\n return mapItems(pages.flat(), mapItem)\n}\n\nfunction searchParams(query: string | undefined, pageSize: string): Record<string, string> {\n const trimmed = (query ?? '').trim()\n return trimmed.length > 0 ? { search: trimmed, pageSize } : { pageSize }\n}\n\nexport function createIdsLookupSource(\n id: string,\n path: string,\n mapItem: (item: LookupRemoteItem) => LookupOption | null,\n extraParams: Record<string, string> = {},\n): LookupSource {\n return {\n id,\n search: async (query) => mapItems(await loadItems(path, { ...extraParams, ...searchParams(query, SEARCH_PAGE_SIZE) }), mapItem),\n resolve: (ids) => resolveByIds(path, ids, mapItem, extraParams),\n }\n}\n\nexport function lookupLabelWithCode(label: string, code: string): string {\n return code && code !== label ? `${label} (${code})` : label\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.9.1-develop.
|
|
3
|
+
"version": "0.9.1-develop.7475.1.28ca93ed97",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
158
|
+
"@open-mercato/shared": "0.9.1-develop.7475.1.28ca93ed97",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
165
|
+
"@open-mercato/shared": "0.9.1-develop.7475.1.28ca93ed97",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.1",
|
|
168
168
|
"@testing-library/react": "^16.3.3",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -4266,12 +4266,16 @@ function NumberInput({
|
|
|
4266
4266
|
placeholder,
|
|
4267
4267
|
autoFocus,
|
|
4268
4268
|
onSubmit,
|
|
4269
|
+
disabled,
|
|
4270
|
+
readOnly,
|
|
4269
4271
|
}: {
|
|
4270
4272
|
value: number | string | null | undefined
|
|
4271
4273
|
onChange: (v: number | undefined) => void
|
|
4272
4274
|
placeholder?: string
|
|
4273
4275
|
autoFocus?: boolean
|
|
4274
4276
|
onSubmit?: () => void
|
|
4277
|
+
disabled?: boolean
|
|
4278
|
+
readOnly?: boolean
|
|
4275
4279
|
}) {
|
|
4276
4280
|
const locale = useOptionalLocale()
|
|
4277
4281
|
const serializedValue = value !== undefined && value !== null ? String(value) : ''
|
|
@@ -4353,6 +4357,8 @@ function NumberInput({
|
|
|
4353
4357
|
onFocus={handleFocus}
|
|
4354
4358
|
onBlur={handleBlur}
|
|
4355
4359
|
autoFocus={autoFocus}
|
|
4360
|
+
disabled={disabled}
|
|
4361
|
+
readOnly={readOnly}
|
|
4356
4362
|
data-crud-focus-target=""
|
|
4357
4363
|
/>
|
|
4358
4364
|
)
|
|
@@ -4743,6 +4749,8 @@ const FieldControl = React.memo(function FieldControlImpl({
|
|
|
4743
4749
|
onChange={fieldSetValue}
|
|
4744
4750
|
autoFocus={autoFocusField}
|
|
4745
4751
|
onSubmit={onSubmitRequest}
|
|
4752
|
+
disabled={disabled}
|
|
4753
|
+
readOnly={readOnly}
|
|
4746
4754
|
/>
|
|
4747
4755
|
)}
|
|
4748
4756
|
{field.type === 'date' && (
|
|
@@ -4842,6 +4850,7 @@ const FieldControl = React.memo(function FieldControlImpl({
|
|
|
4842
4850
|
suppressInitialSuggestionsOnFocus={autoFocusField}
|
|
4843
4851
|
suggestions={options.map((opt) => ({ value: opt.value, label: opt.label }))}
|
|
4844
4852
|
loadSuggestions={loadFieldSuggestions}
|
|
4853
|
+
disabled={disabled}
|
|
4845
4854
|
/>
|
|
4846
4855
|
)}
|
|
4847
4856
|
{field.type === 'combobox' && (
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
jest.setTimeout(15000)
|
|
3
|
+
|
|
4
|
+
jest.mock('next/navigation', () => ({
|
|
5
|
+
useRouter: () => ({ push: () => {} }),
|
|
6
|
+
usePathname: () => '/',
|
|
7
|
+
useSearchParams: () => new URLSearchParams(),
|
|
8
|
+
}))
|
|
9
|
+
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
10
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
InjectionSpot: () => null,
|
|
13
|
+
useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
|
|
14
|
+
useInjectionSpotEvents: () => ({ triggerEvent: async () => ({ ok: true }) }),
|
|
15
|
+
}))
|
|
16
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
17
|
+
__esModule: true,
|
|
18
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
import * as React from 'react'
|
|
22
|
+
import { act, fireEvent } from '@testing-library/react'
|
|
23
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
24
|
+
import { CrudForm, type CrudField } from '../CrudForm'
|
|
25
|
+
|
|
26
|
+
const dict = {
|
|
27
|
+
'ui.forms.actions.save': 'Save',
|
|
28
|
+
'ui.forms.select.emptyOption': '—',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('CrudForm field-level disabled', () => {
|
|
32
|
+
it('disables a number field', () => {
|
|
33
|
+
const fields: CrudField[] = [{ id: 'leadTime', label: 'Lead time', type: 'number', disabled: true }]
|
|
34
|
+
const { container } = renderWithProviders(
|
|
35
|
+
<CrudForm title="Form" fields={fields} initialValues={{ leadTime: 5 }} onSubmit={() => {}} />,
|
|
36
|
+
{ dict },
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const input = container.querySelector('[data-crud-field-id="leadTime"] input') as HTMLInputElement
|
|
40
|
+
expect(input).not.toBeNull()
|
|
41
|
+
expect(input).toBeDisabled()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('disables a tags field so values cannot be added or removed', () => {
|
|
45
|
+
const fields: CrudField[] = [{ id: 'locales', label: 'Locales', type: 'tags', disabled: true }]
|
|
46
|
+
const { container } = renderWithProviders(
|
|
47
|
+
<CrudForm title="Form" fields={fields} initialValues={{ locales: ['en', 'pl'] }} onSubmit={() => {}} />,
|
|
48
|
+
{ dict },
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const field = container.querySelector('[data-crud-field-id="locales"]') as HTMLElement
|
|
52
|
+
expect(field.querySelector('input')).toBeDisabled()
|
|
53
|
+
const removeButtons = Array.from(field.querySelectorAll('button'))
|
|
54
|
+
expect(removeButtons.length).toBeGreaterThan(0)
|
|
55
|
+
removeButtons.forEach((button) => expect(button).toBeDisabled())
|
|
56
|
+
})
|
|
57
|
+
})
|