@bygghammar/cms-platform 0.6.0 → 0.8.0
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/package.json +10 -2
- package/src/backoffice/SaveGlobalDialog.tsx +221 -0
- package/src/backoffice/forms/FieldEditor.tsx +611 -0
- package/src/backoffice/forms/FormPreview.tsx +108 -0
- package/src/backoffice/forms/TestDeliveryPanel.tsx +201 -0
- package/src/backoffice/forms/VersionHistory.tsx +142 -0
- package/src/backoffice/media/MediaCleanup.tsx +186 -0
- package/src/backoffice/media/MediaReplaceDialog.tsx +158 -0
- package/src/backoffice/media/ReuploadOverwrite.tsx +226 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ChevronDown, ChevronUp, Copy, GripVertical, Plus, RotateCcw, Trash2 } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
AUTOCOMPLETE_OPTIONS,
|
|
5
|
+
fieldTypes,
|
|
6
|
+
tripFieldOptions,
|
|
7
|
+
tripYears,
|
|
8
|
+
makeFieldKey,
|
|
9
|
+
type TripSource,
|
|
10
|
+
type FormFieldDef,
|
|
11
|
+
type FormFieldType,
|
|
12
|
+
} from "../../forms/field-sets";
|
|
13
|
+
import { FIELD_FORMATS } from "../../forms/conditions";
|
|
14
|
+
import { LocalizedField, TextField } from "../inspectors/fields";
|
|
15
|
+
import { ConditionBuilder, RuleSummary } from "./ConditionBuilder";
|
|
16
|
+
import { getTripTerminology } from "../../trips";
|
|
17
|
+
|
|
18
|
+
const TRIP_TERMINOLOGY = getTripTerminology();
|
|
19
|
+
|
|
20
|
+
/** Labels depend on registered site terminology, so resolve after startup. */
|
|
21
|
+
const FIELD_TYPES = fieldTypes();
|
|
22
|
+
|
|
23
|
+
/** Trip fields use the shared trips source and the site's terminology. */
|
|
24
|
+
function TripOptions({
|
|
25
|
+
field,
|
|
26
|
+
trips,
|
|
27
|
+
onChange,
|
|
28
|
+
}: {
|
|
29
|
+
field: FormFieldDef;
|
|
30
|
+
trips: TripSource[];
|
|
31
|
+
onChange: (patch: Partial<FormFieldDef>) => void;
|
|
32
|
+
}) {
|
|
33
|
+
const years = tripYears(trips);
|
|
34
|
+
const options = tripFieldOptions(trips, field.tripYear ?? field.courseYear);
|
|
35
|
+
return (
|
|
36
|
+
<div className="space-y-2">
|
|
37
|
+
<div className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
|
|
38
|
+
{TRIP_TERMINOLOGY.plural}
|
|
39
|
+
</div>
|
|
40
|
+
<label className="block">
|
|
41
|
+
<span className="mb-1 block text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
|
|
42
|
+
Year filter
|
|
43
|
+
</span>
|
|
44
|
+
<select
|
|
45
|
+
value={field.tripYear ?? field.courseYear ?? ""}
|
|
46
|
+
onChange={(e) => onChange({ tripYear: e.target.value || undefined })}
|
|
47
|
+
className="w-full border border-ink/15 bg-white px-3 py-2 text-sm"
|
|
48
|
+
>
|
|
49
|
+
<option value="">All years</option>
|
|
50
|
+
{years.map((y) => (
|
|
51
|
+
<option key={y} value={y}>
|
|
52
|
+
{y}
|
|
53
|
+
</option>
|
|
54
|
+
))}
|
|
55
|
+
</select>
|
|
56
|
+
</label>
|
|
57
|
+
<p className="text-xs text-ink/50">
|
|
58
|
+
Choices are generated from the {TRIP_TERMINOLOGY.plural} section, so they stay in sync
|
|
59
|
+
automatically.
|
|
60
|
+
</p>
|
|
61
|
+
{options.length === 0 ? (
|
|
62
|
+
<p className="text-xs text-ink/45">
|
|
63
|
+
No {TRIP_TERMINOLOGY.plural.toLowerCase()} match this filter yet.
|
|
64
|
+
</p>
|
|
65
|
+
) : (
|
|
66
|
+
<ul className="space-y-1 border border-ink/10 bg-white px-3 py-2 text-xs text-ink/70">
|
|
67
|
+
{options.map((o) => (
|
|
68
|
+
<li key={o.value}>
|
|
69
|
+
{o.label.sv || o.label.en}
|
|
70
|
+
<span className="text-ink/35"> · saved as “{o.value}”</span>
|
|
71
|
+
</li>
|
|
72
|
+
))}
|
|
73
|
+
</ul>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function Toggle({
|
|
80
|
+
label,
|
|
81
|
+
checked,
|
|
82
|
+
onChange,
|
|
83
|
+
}: {
|
|
84
|
+
label: string;
|
|
85
|
+
checked: boolean;
|
|
86
|
+
onChange: (v: boolean) => void;
|
|
87
|
+
}) {
|
|
88
|
+
return (
|
|
89
|
+
<label className="flex items-center gap-2 text-xs text-ink/70">
|
|
90
|
+
<input
|
|
91
|
+
type="checkbox"
|
|
92
|
+
checked={checked}
|
|
93
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
94
|
+
className="size-4 accent-sea"
|
|
95
|
+
/>
|
|
96
|
+
{label}
|
|
97
|
+
</label>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function OptionsEditor({
|
|
102
|
+
field,
|
|
103
|
+
onChange,
|
|
104
|
+
}: {
|
|
105
|
+
field: FormFieldDef;
|
|
106
|
+
onChange: (patch: Partial<FormFieldDef>) => void;
|
|
107
|
+
}) {
|
|
108
|
+
const options = field.options ?? [];
|
|
109
|
+
const patchOption = (i: number, patch: Partial<(typeof options)[number]>) =>
|
|
110
|
+
onChange({ options: options.map((o, idx) => (idx === i ? { ...o, ...patch } : o)) });
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="space-y-2">
|
|
114
|
+
<div className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">Choices</div>
|
|
115
|
+
{options.length === 0 ? <p className="text-xs text-ink/45">No choices yet.</p> : null}
|
|
116
|
+
{options.map((o, i) => (
|
|
117
|
+
<div key={i} className="flex flex-wrap items-center gap-2">
|
|
118
|
+
<input
|
|
119
|
+
value={o.value}
|
|
120
|
+
onChange={(e) => patchOption(i, { value: e.target.value })}
|
|
121
|
+
placeholder="value"
|
|
122
|
+
className="w-32 border border-ink/15 px-2 py-1 text-xs"
|
|
123
|
+
/>
|
|
124
|
+
<input
|
|
125
|
+
value={o.label?.sv ?? ""}
|
|
126
|
+
onChange={(e) => patchOption(i, { label: { ...o.label, sv: e.target.value } })}
|
|
127
|
+
placeholder="Swedish label"
|
|
128
|
+
className="flex-1 min-w-[140px] border border-ink/15 px-2 py-1 text-xs"
|
|
129
|
+
/>
|
|
130
|
+
<input
|
|
131
|
+
value={o.label?.en ?? ""}
|
|
132
|
+
onChange={(e) => patchOption(i, { label: { ...o.label, en: e.target.value } })}
|
|
133
|
+
placeholder="English label"
|
|
134
|
+
className="flex-1 min-w-[140px] border border-ink/15 px-2 py-1 text-xs"
|
|
135
|
+
/>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={() => onChange({ options: options.filter((_, idx) => idx !== i) })}
|
|
139
|
+
className="text-ink/35 hover:text-red-500"
|
|
140
|
+
title="Remove choice"
|
|
141
|
+
>
|
|
142
|
+
<Trash2 className="size-3.5" />
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
))}
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={() =>
|
|
149
|
+
onChange({ options: [...options, { value: `option_${options.length + 1}`, label: {} }] })
|
|
150
|
+
}
|
|
151
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea"
|
|
152
|
+
>
|
|
153
|
+
<Plus className="size-3.5" />
|
|
154
|
+
Add choice
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function FieldEditor({
|
|
161
|
+
fields,
|
|
162
|
+
usedKeys,
|
|
163
|
+
onChange,
|
|
164
|
+
trips: tripRows,
|
|
165
|
+
}: {
|
|
166
|
+
fields: FormFieldDef[];
|
|
167
|
+
/** Keys that already have stored answers — locked against deletion. */
|
|
168
|
+
usedKeys: string[];
|
|
169
|
+
onChange: (next: FormFieldDef[]) => void;
|
|
170
|
+
/** Trip/course rows from the host site, used to preview trip field options. */
|
|
171
|
+
trips?: TripSource[];
|
|
172
|
+
}) {
|
|
173
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
174
|
+
const [showRemoved, setShowRemoved] = useState(false);
|
|
175
|
+
const [newLabel, setNewLabel] = useState("");
|
|
176
|
+
const [newType, setNewType] = useState<FormFieldType>("text");
|
|
177
|
+
const used = new Set(usedKeys);
|
|
178
|
+
const trips = (tripRows ?? []) as TripSource[];
|
|
179
|
+
|
|
180
|
+
const patch = (key: string, p: Partial<FormFieldDef>) =>
|
|
181
|
+
onChange(fields.map((f) => (f.key === key ? { ...f, ...p } : f)));
|
|
182
|
+
|
|
183
|
+
/** Reorder within the visible fields, then write the order back in full. */
|
|
184
|
+
const move = (key: string, dir: -1 | 1) => {
|
|
185
|
+
const visible = fields.filter((f) => !f.removed);
|
|
186
|
+
const i = visible.findIndex((f) => f.key === key);
|
|
187
|
+
const j = i + dir;
|
|
188
|
+
if (i < 0 || j < 0 || j >= visible.length) return;
|
|
189
|
+
const reordered = [...visible];
|
|
190
|
+
[reordered[i], reordered[j]] = [reordered[j], reordered[i]];
|
|
191
|
+
onChange([...reordered, ...fields.filter((f) => f.removed)]);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const addField = () => {
|
|
195
|
+
const label = newLabel.trim();
|
|
196
|
+
if (!label) return;
|
|
197
|
+
const key = makeFieldKey(
|
|
198
|
+
label,
|
|
199
|
+
fields.map((f) => f.key),
|
|
200
|
+
);
|
|
201
|
+
onChange([
|
|
202
|
+
...fields,
|
|
203
|
+
{
|
|
204
|
+
key,
|
|
205
|
+
type: newType,
|
|
206
|
+
label: { sv: label, en: label },
|
|
207
|
+
enabled: true,
|
|
208
|
+
required: false,
|
|
209
|
+
custom: true,
|
|
210
|
+
options: newType === "select" ? [{ value: "option_1", label: {} }] : undefined,
|
|
211
|
+
},
|
|
212
|
+
]);
|
|
213
|
+
setNewLabel("");
|
|
214
|
+
setOpen(key);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const duplicate = (f: FormFieldDef) => {
|
|
218
|
+
const key = makeFieldKey(
|
|
219
|
+
`${f.key}_copy`,
|
|
220
|
+
fields.map((x) => x.key),
|
|
221
|
+
);
|
|
222
|
+
onChange([...fields, { ...f, key, custom: true }]);
|
|
223
|
+
setOpen(key);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Custom fields are dropped outright. Built-in fields keep a tombstone so
|
|
228
|
+
* the canonical set does not rebuild them on the next load, and so old
|
|
229
|
+
* answers still resolve their label.
|
|
230
|
+
*/
|
|
231
|
+
const removeField = (f: FormFieldDef) => {
|
|
232
|
+
if (used.has(f.key)) return;
|
|
233
|
+
if (
|
|
234
|
+
!window.confirm(
|
|
235
|
+
f.custom
|
|
236
|
+
? `Remove the field "${f.label.sv || f.label.en || f.key}"? It has no answers, so nothing is lost.`
|
|
237
|
+
: `Remove the built-in field "${f.label.sv || f.label.en || f.key}"? You can restore it again from "Removed built-in fields" below.`,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
return;
|
|
241
|
+
if (f.custom) {
|
|
242
|
+
onChange(fields.filter((x) => x.key !== f.key));
|
|
243
|
+
} else {
|
|
244
|
+
onChange(fields.map((x) => (x.key === f.key ? { ...x, removed: true, enabled: false } : x)));
|
|
245
|
+
}
|
|
246
|
+
setOpen(null);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const restoreField = (key: string) =>
|
|
250
|
+
onChange(fields.map((x) => (x.key === key ? { ...x, removed: false, enabled: true } : x)));
|
|
251
|
+
|
|
252
|
+
const visibleFields = fields.filter((f) => !f.removed);
|
|
253
|
+
const removedFields = fields.filter((f) => f.removed);
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<div className="space-y-3">
|
|
257
|
+
<p className="text-xs text-ink/50">
|
|
258
|
+
Reorder, rename, add your own fields. A field key never changes.{" "}
|
|
259
|
+
<strong className="font-normal text-ink/70">Off</strong> keeps the field and its history but
|
|
260
|
+
hides it from the site; <strong className="font-normal text-ink/70">Delete</strong> takes it
|
|
261
|
+
out of the form entirely. A field that already has answers can only be switched off, so
|
|
262
|
+
historic submissions stay readable.
|
|
263
|
+
</p>
|
|
264
|
+
|
|
265
|
+
<div className="space-y-2">
|
|
266
|
+
{visibleFields.map((f) => {
|
|
267
|
+
const locked = used.has(f.key);
|
|
268
|
+
const isOpen = open === f.key;
|
|
269
|
+
return (
|
|
270
|
+
<div
|
|
271
|
+
key={f.key}
|
|
272
|
+
className={
|
|
273
|
+
f.enabled
|
|
274
|
+
? "border border-ink/10"
|
|
275
|
+
: "border border-dashed border-ink/15 bg-ink/[0.02]"
|
|
276
|
+
}
|
|
277
|
+
>
|
|
278
|
+
<div className="flex flex-wrap items-center gap-3 px-3 py-2">
|
|
279
|
+
<GripVertical className="size-4 shrink-0 text-ink/25" />
|
|
280
|
+
<div className="flex flex-col">
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
onClick={() => setOpen(isOpen ? null : f.key)}
|
|
284
|
+
className="text-left text-sm text-ink hover:text-sea"
|
|
285
|
+
>
|
|
286
|
+
{f.label.sv || f.label.en || f.key}
|
|
287
|
+
</button>
|
|
288
|
+
<span className="text-[0.6rem] uppercase tracking-[0.18em] text-ink/40">
|
|
289
|
+
{f.key} · {f.type}
|
|
290
|
+
{f.custom ? " · custom" : ""}
|
|
291
|
+
{used.has(f.key) ? " · has answers" : ""}
|
|
292
|
+
</span>
|
|
293
|
+
{!f.enabled ? (
|
|
294
|
+
<span className="mt-1 w-fit border border-ink/15 px-1.5 py-0.5 text-[0.55rem] uppercase tracking-[0.18em] text-ink/45">
|
|
295
|
+
Off — not shown on the site
|
|
296
|
+
</span>
|
|
297
|
+
) : null}
|
|
298
|
+
</div>
|
|
299
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
300
|
+
<RuleSummary rule={f.showWhen} fields={fields} prefix="Shown when" />
|
|
301
|
+
<RuleSummary rule={f.requireWhen} fields={fields} prefix="Required when" />
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<div className="ml-auto flex items-center gap-3">
|
|
305
|
+
<Toggle
|
|
306
|
+
label="On"
|
|
307
|
+
checked={f.enabled}
|
|
308
|
+
onChange={(v) => patch(f.key, { enabled: v })}
|
|
309
|
+
/>
|
|
310
|
+
<Toggle
|
|
311
|
+
label="Required"
|
|
312
|
+
checked={f.required}
|
|
313
|
+
onChange={(v) => patch(f.key, { required: v })}
|
|
314
|
+
/>
|
|
315
|
+
<Toggle
|
|
316
|
+
label="Full width"
|
|
317
|
+
checked={f.wide === true}
|
|
318
|
+
onChange={(v) => patch(f.key, { wide: v })}
|
|
319
|
+
/>
|
|
320
|
+
<button
|
|
321
|
+
type="button"
|
|
322
|
+
onClick={() => move(f.key, -1)}
|
|
323
|
+
className="text-ink/35 hover:text-sea"
|
|
324
|
+
title="Move up"
|
|
325
|
+
>
|
|
326
|
+
<ChevronUp className="size-4" />
|
|
327
|
+
</button>
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
onClick={() => move(f.key, 1)}
|
|
331
|
+
className="text-ink/35 hover:text-sea"
|
|
332
|
+
title="Move down"
|
|
333
|
+
>
|
|
334
|
+
<ChevronDown className="size-4" />
|
|
335
|
+
</button>
|
|
336
|
+
<button
|
|
337
|
+
type="button"
|
|
338
|
+
onClick={() => duplicate(f)}
|
|
339
|
+
className="text-ink/35 hover:text-sea"
|
|
340
|
+
title="Duplicate field"
|
|
341
|
+
>
|
|
342
|
+
<Copy className="size-4" />
|
|
343
|
+
</button>
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
disabled={locked}
|
|
347
|
+
onClick={() => removeField(f)}
|
|
348
|
+
className="text-ink/35 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-30"
|
|
349
|
+
title={
|
|
350
|
+
locked
|
|
351
|
+
? "This field already has answers — switch it off instead"
|
|
352
|
+
: "Remove field"
|
|
353
|
+
}
|
|
354
|
+
>
|
|
355
|
+
<Trash2 className="size-4" />
|
|
356
|
+
</button>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
{isOpen ? (
|
|
361
|
+
<div className="space-y-4 border-t border-ink/10 bg-ink/[0.015] px-4 py-4">
|
|
362
|
+
{f.custom ? (
|
|
363
|
+
<label className="block">
|
|
364
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
|
|
365
|
+
Type
|
|
366
|
+
</span>
|
|
367
|
+
<select
|
|
368
|
+
value={f.type}
|
|
369
|
+
onChange={(e) => patch(f.key, { type: e.target.value as FormFieldType })}
|
|
370
|
+
className="mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
371
|
+
>
|
|
372
|
+
{FIELD_TYPES.map((t) => (
|
|
373
|
+
<option key={t.value} value={t.value}>
|
|
374
|
+
{t.label}
|
|
375
|
+
</option>
|
|
376
|
+
))}
|
|
377
|
+
</select>
|
|
378
|
+
</label>
|
|
379
|
+
) : null}
|
|
380
|
+
|
|
381
|
+
<LocalizedField
|
|
382
|
+
label="Label"
|
|
383
|
+
value={f.label}
|
|
384
|
+
onChange={(v) => patch(f.key, { label: v })}
|
|
385
|
+
/>
|
|
386
|
+
<LocalizedField
|
|
387
|
+
label="Placeholder"
|
|
388
|
+
value={f.placeholder}
|
|
389
|
+
onChange={(v) => patch(f.key, { placeholder: v })}
|
|
390
|
+
/>
|
|
391
|
+
<LocalizedField
|
|
392
|
+
label="Help text"
|
|
393
|
+
value={f.help}
|
|
394
|
+
onChange={(v) => patch(f.key, { help: v })}
|
|
395
|
+
/>
|
|
396
|
+
|
|
397
|
+
{f.type === "select" ? (
|
|
398
|
+
<OptionsEditor field={f} onChange={(p) => patch(f.key, p)} />
|
|
399
|
+
) : null}
|
|
400
|
+
{f.type === "trip" || f.type === "course" ? (
|
|
401
|
+
<TripOptions field={f} trips={trips} onChange={(p) => patch(f.key, p)} />
|
|
402
|
+
) : null}
|
|
403
|
+
|
|
404
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
405
|
+
<label className="block">
|
|
406
|
+
<span className="mb-1 block text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
|
|
407
|
+
Autofill
|
|
408
|
+
</span>
|
|
409
|
+
<select
|
|
410
|
+
value={f.autocomplete ?? ""}
|
|
411
|
+
onChange={(e) =>
|
|
412
|
+
patch(f.key, { autocomplete: e.target.value || undefined })
|
|
413
|
+
}
|
|
414
|
+
className="w-full border border-ink/15 bg-white px-3 py-2 text-sm"
|
|
415
|
+
>
|
|
416
|
+
<option value="">Automatic</option>
|
|
417
|
+
{AUTOCOMPLETE_OPTIONS.map((o) => (
|
|
418
|
+
<option key={o.value} value={o.value}>
|
|
419
|
+
{o.label}
|
|
420
|
+
</option>
|
|
421
|
+
))}
|
|
422
|
+
</select>
|
|
423
|
+
</label>
|
|
424
|
+
<label className="block">
|
|
425
|
+
<span className="mb-1 block text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">
|
|
426
|
+
Format
|
|
427
|
+
</span>
|
|
428
|
+
<select
|
|
429
|
+
value={f.format ?? ""}
|
|
430
|
+
onChange={(e) =>
|
|
431
|
+
patch(f.key, {
|
|
432
|
+
format: (e.target.value || undefined) as FormFieldDef["format"],
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
className="w-full border border-ink/15 bg-white px-3 py-2 text-sm"
|
|
436
|
+
>
|
|
437
|
+
<option value="">No extra format check</option>
|
|
438
|
+
{FIELD_FORMATS.map((o) => (
|
|
439
|
+
<option key={o.value} value={o.value}>
|
|
440
|
+
{o.label}
|
|
441
|
+
</option>
|
|
442
|
+
))}
|
|
443
|
+
</select>
|
|
444
|
+
</label>
|
|
445
|
+
<TextField
|
|
446
|
+
label="Min length"
|
|
447
|
+
value={f.minLength ? String(f.minLength) : ""}
|
|
448
|
+
onChange={(v) => patch(f.key, { minLength: Number(v) || undefined })}
|
|
449
|
+
/>
|
|
450
|
+
<TextField
|
|
451
|
+
label="Max length"
|
|
452
|
+
value={f.maxLength ? String(f.maxLength) : ""}
|
|
453
|
+
onChange={(v) => patch(f.key, { maxLength: Number(v) || undefined })}
|
|
454
|
+
/>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
458
|
+
<TextField
|
|
459
|
+
label="Pattern (regex)"
|
|
460
|
+
value={f.pattern ?? ""}
|
|
461
|
+
onChange={(v) => patch(f.key, { pattern: v || undefined })}
|
|
462
|
+
/>
|
|
463
|
+
{f.type === "date" ? (
|
|
464
|
+
<>
|
|
465
|
+
<TextField
|
|
466
|
+
label="Earliest date (YYYY-MM-DD)"
|
|
467
|
+
value={f.minDate ?? ""}
|
|
468
|
+
onChange={(v) => patch(f.key, { minDate: v || undefined })}
|
|
469
|
+
/>
|
|
470
|
+
<TextField
|
|
471
|
+
label="Latest date (YYYY-MM-DD)"
|
|
472
|
+
value={f.maxDate ?? ""}
|
|
473
|
+
onChange={(v) => patch(f.key, { maxDate: v || undefined })}
|
|
474
|
+
/>
|
|
475
|
+
</>
|
|
476
|
+
) : (
|
|
477
|
+
<>
|
|
478
|
+
<TextField
|
|
479
|
+
label="Min value"
|
|
480
|
+
value={f.min === undefined ? "" : String(f.min)}
|
|
481
|
+
onChange={(v) => patch(f.key, { min: v === "" ? undefined : Number(v) })}
|
|
482
|
+
/>
|
|
483
|
+
<TextField
|
|
484
|
+
label="Max value"
|
|
485
|
+
value={f.max === undefined ? "" : String(f.max)}
|
|
486
|
+
onChange={(v) => patch(f.key, { max: v === "" ? undefined : Number(v) })}
|
|
487
|
+
/>
|
|
488
|
+
</>
|
|
489
|
+
)}
|
|
490
|
+
</div>
|
|
491
|
+
|
|
492
|
+
<LocalizedField
|
|
493
|
+
label="Custom error message"
|
|
494
|
+
value={f.errorMessage}
|
|
495
|
+
onChange={(v) => patch(f.key, { errorMessage: v })}
|
|
496
|
+
/>
|
|
497
|
+
|
|
498
|
+
<div className="grid gap-3 lg:grid-cols-2">
|
|
499
|
+
<ConditionBuilder
|
|
500
|
+
title="Show this field when"
|
|
501
|
+
description="Leave empty to always show it."
|
|
502
|
+
rule={f.showWhen}
|
|
503
|
+
selfKey={f.key}
|
|
504
|
+
fields={fields}
|
|
505
|
+
onChange={(r) => patch(f.key, { showWhen: r })}
|
|
506
|
+
/>
|
|
507
|
+
<ConditionBuilder
|
|
508
|
+
title="Make it required when"
|
|
509
|
+
description="Required only while these conditions match."
|
|
510
|
+
rule={f.requireWhen}
|
|
511
|
+
selfKey={f.key}
|
|
512
|
+
fields={fields}
|
|
513
|
+
onChange={(r) => patch(f.key, { requireWhen: r })}
|
|
514
|
+
/>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
) : null}
|
|
518
|
+
</div>
|
|
519
|
+
);
|
|
520
|
+
})}
|
|
521
|
+
</div>
|
|
522
|
+
|
|
523
|
+
{removedFields.length ? (
|
|
524
|
+
<div className="border border-dashed border-ink/20 p-4">
|
|
525
|
+
<button
|
|
526
|
+
type="button"
|
|
527
|
+
onClick={() => setShowRemoved((v) => !v)}
|
|
528
|
+
className="flex w-full items-center justify-between gap-3 text-left"
|
|
529
|
+
>
|
|
530
|
+
<span className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/45">
|
|
531
|
+
Removed built-in fields ({removedFields.length})
|
|
532
|
+
</span>
|
|
533
|
+
{showRemoved ? (
|
|
534
|
+
<ChevronUp className="size-4 text-ink/35" />
|
|
535
|
+
) : (
|
|
536
|
+
<ChevronDown className="size-4 text-ink/35" />
|
|
537
|
+
)}
|
|
538
|
+
</button>
|
|
539
|
+
{showRemoved ? (
|
|
540
|
+
<>
|
|
541
|
+
<p className="mt-1 text-xs text-ink/45">
|
|
542
|
+
These are not part of the form any more. Restore one to bring it back with its
|
|
543
|
+
original key.
|
|
544
|
+
</p>
|
|
545
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
546
|
+
{removedFields.map((f) => (
|
|
547
|
+
<button
|
|
548
|
+
key={f.key}
|
|
549
|
+
type="button"
|
|
550
|
+
onClick={() => restoreField(f.key)}
|
|
551
|
+
className="inline-flex items-center gap-2 border border-ink/15 bg-white px-3 py-2 text-xs text-ink/70 hover:border-sea hover:text-ink"
|
|
552
|
+
>
|
|
553
|
+
<RotateCcw className="size-3.5" />
|
|
554
|
+
{f.label.sv || f.label.en || f.key}
|
|
555
|
+
<span className="text-[0.6rem] uppercase tracking-[0.18em] text-ink/40">
|
|
556
|
+
{f.key}
|
|
557
|
+
</span>
|
|
558
|
+
</button>
|
|
559
|
+
))}
|
|
560
|
+
</div>
|
|
561
|
+
</>
|
|
562
|
+
) : null}
|
|
563
|
+
</div>
|
|
564
|
+
) : null}
|
|
565
|
+
|
|
566
|
+
<div className="flex flex-wrap items-end gap-3 border border-dashed border-ink/20 p-4">
|
|
567
|
+
<label className="flex-1 min-w-[180px]">
|
|
568
|
+
<span className="text-xs text-ink/55">New field label</span>
|
|
569
|
+
<input
|
|
570
|
+
value={newLabel}
|
|
571
|
+
onChange={(e) => setNewLabel(e.target.value)}
|
|
572
|
+
placeholder="Company"
|
|
573
|
+
className="mt-1 w-full border border-ink/20 px-3 py-2 text-sm"
|
|
574
|
+
/>
|
|
575
|
+
</label>
|
|
576
|
+
<label className="min-w-[160px]">
|
|
577
|
+
<span className="text-xs text-ink/55">Type</span>
|
|
578
|
+
<select
|
|
579
|
+
value={newType}
|
|
580
|
+
onChange={(e) => setNewType(e.target.value as FormFieldType)}
|
|
581
|
+
className="mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
582
|
+
>
|
|
583
|
+
{FIELD_TYPES.map((t) => (
|
|
584
|
+
<option key={t.value} value={t.value}>
|
|
585
|
+
{t.label}
|
|
586
|
+
</option>
|
|
587
|
+
))}
|
|
588
|
+
</select>
|
|
589
|
+
</label>
|
|
590
|
+
<button
|
|
591
|
+
type="button"
|
|
592
|
+
onClick={addField}
|
|
593
|
+
disabled={!newLabel.trim()}
|
|
594
|
+
className="inline-flex items-center gap-2 border border-ink/15 px-4 py-2.5 text-xs uppercase tracking-[0.2em] hover:border-sea disabled:opacity-40"
|
|
595
|
+
>
|
|
596
|
+
<Plus className="size-4" />
|
|
597
|
+
Add field
|
|
598
|
+
</button>
|
|
599
|
+
{newLabel.trim() ? (
|
|
600
|
+
<span className="text-xs text-ink/45">
|
|
601
|
+
Key:{" "}
|
|
602
|
+
{makeFieldKey(
|
|
603
|
+
newLabel,
|
|
604
|
+
fields.map((f) => f.key),
|
|
605
|
+
)}
|
|
606
|
+
</span>
|
|
607
|
+
) : null}
|
|
608
|
+
</div>
|
|
609
|
+
</div>
|
|
610
|
+
);
|
|
611
|
+
}
|