@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.
@@ -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
+ }