@visns-studio/visns-components 6.0.4 → 6.1.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.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/src/components/DataGrid.jsx +37 -12
  3. package/src/components/Navigation.jsx +59 -3
  4. package/src/components/auth/Login.jsx +203 -175
  5. package/src/components/columns/ColumnRenderers.jsx +34 -0
  6. package/src/components/generic/GenericDashboard.jsx +181 -1
  7. package/src/components/generic/GenericFormBuilder.jsx +735 -37
  8. package/src/components/generic/GenericIndex.jsx +15 -5
  9. package/src/components/generic/OutstandingRuleEditor.jsx +313 -0
  10. package/src/components/styles/DataGrid.module.scss +38 -22
  11. package/src/components/styles/Form.module.scss +32 -33
  12. package/src/components/styles/GenericDashboard.module.scss +155 -0
  13. package/src/components/styles/GenericDetail.module.scss +28 -30
  14. package/src/components/styles/GenericDynamic.module.scss +7 -21
  15. package/src/components/styles/GenericEditableTable.module.scss +5 -22
  16. package/src/components/styles/GenericFormBuilder.module.scss +605 -30
  17. package/src/components/styles/GenericIndex.module.scss +17 -32
  18. package/src/components/styles/GenericMain.module.scss +18 -6
  19. package/src/components/styles/GenericQuote.module.scss +7 -22
  20. package/src/components/styles/GenericReport.module.scss +5 -37
  21. package/src/components/styles/Login.module.scss +415 -239
  22. package/src/components/styles/Navigation.module.scss +124 -35
  23. package/src/components/styles/Profile.module.scss +5 -26
  24. package/src/components/styles/QuickAction.module.scss +34 -29
  25. package/src/components/styles/_controls.scss +139 -0
  26. package/src/components/styles/global.css +54 -15
@@ -1,6 +1,6 @@
1
1
  import '../styles/global.css';
2
2
 
3
- import React, { useEffect, useRef, useState } from 'react';
3
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { useParams } from 'react-router-dom';
5
5
  import StandardModal from './StandardModal';
6
6
  import { arrayMove } from '@dnd-kit/sortable';
@@ -38,6 +38,8 @@ import {
38
38
  Search,
39
39
  ChevronDown,
40
40
  ChevronRight,
41
+ Copy,
42
+ List,
41
43
  } from 'lucide-react';
42
44
  import { confirmDialog } from '../utils/ConfirmDialog';
43
45
 
@@ -48,6 +50,16 @@ import Breadcrumb from '../Breadcrumb';
48
50
  import CustomFetch from '../Fetch';
49
51
  import SketchConfig from '../sketch/json/config.json';
50
52
  import ConditionalDisplayEditor from './ConditionalDisplayEditor';
53
+ import {
54
+ OutstandingOptionToggle,
55
+ OutstandingRuleHint,
56
+ applyOutstandingRule,
57
+ deriveOutstandingSelection,
58
+ isOutstandingConfigurable,
59
+ remapOutstandingWhen,
60
+ staleOutstandingIds,
61
+ summariseOutstandingRule,
62
+ } from './OutstandingRuleEditor';
51
63
 
52
64
  import styles from '../styles/GenericFormBuilder.module.scss'; // Import the CSS module
53
65
 
@@ -55,8 +67,19 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
55
67
  const editorRef = useRef(null);
56
68
  const routeParams = useParams();
57
69
 
58
- const { dynamicDropdowns, fetchUrl, fields, parentUrl, formTitle } =
59
- setting;
70
+ const {
71
+ dynamicDropdowns,
72
+ fetchUrl,
73
+ fields,
74
+ parentUrl,
75
+ formTitle,
76
+ /**
77
+ * Opt-in, per-question "outstanding item" rules. Absent for every app
78
+ * that does not model outstanding items, in which case none of the
79
+ * `outstanding_when` handling below does anything at all.
80
+ */
81
+ outstandingItems,
82
+ } = setting;
60
83
 
61
84
  const { dataId } = useParams();
62
85
  const [data, setData] = useState({});
@@ -113,6 +136,17 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
113
136
  key: '',
114
137
  });
115
138
  const [modalFormShow, setModalFormShow] = useState(false);
139
+ /**
140
+ * The open field modal's outstanding-item switch. `touched` is false until
141
+ * the author actually flips one, and until then the displayed state is
142
+ * derived from the field on every render — so a brand-new question picks up
143
+ * the standard rule as soon as its answers exist, rather than being frozen
144
+ * at whatever was true when the modal opened.
145
+ */
146
+ const [outstandingRule, setOutstandingRule] = useState({
147
+ touched: false,
148
+ id: null,
149
+ });
116
150
  const [roles, setRoles] = useState([]);
117
151
  const [hoveredField, setHoveredField] = useState(null);
118
152
  const [activeId, setActiveId] = useState(null);
@@ -124,10 +158,59 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
124
158
  });
125
159
  const [moveToPosition, setMoveToPosition] = useState('');
126
160
  const [collapsedSections, setCollapsedSections] = useState(new Set());
161
+
162
+ /**
163
+ * Compact mode renders each field as a one-line summary instead of a live
164
+ * control. Site Start Inspection has 87 fields; previewing them all means
165
+ * two dozen full-height textareas and file pickers, so arranging the form
166
+ * turns into a scroll expedition through mostly empty boxes. Preview is
167
+ * still a click away when you want to see the real thing.
168
+ */
169
+ const [compactMode, setCompactMode] = useState(true);
170
+ const [showOutline, setShowOutline] = useState(true);
127
171
  const sortTriggeredRef = useRef(false);
128
172
 
129
173
  const canvasTypes = SketchConfig.canvasTypes;
130
174
 
175
+ /**
176
+ * Templates whose fields are composed per form rather than authored here
177
+ * (outstanding items build theirs from the linked inspection). Offering
178
+ * "add a field" on one of these would be offering something that has no
179
+ * effect.
180
+ */
181
+ const dynamicFields = data.field_source === 'dynamic';
182
+
183
+ /** Does the open field carry an editable outstanding-item rule at all? */
184
+ const outstandingEnabled = isOutstandingConfigurable(
185
+ outstandingItems,
186
+ dataField.type
187
+ );
188
+
189
+ /**
190
+ * Which answer currently has its "Outstanding" switch on. Always sanitised
191
+ * against the answers the field offers right now, so renaming or deleting
192
+ * an answer can never leave a dangling id to be saved.
193
+ */
194
+ const outstandingSelection = useMemo(() => {
195
+ if (!outstandingEnabled) return null;
196
+
197
+ const candidate = outstandingRule.touched
198
+ ? outstandingRule.id
199
+ : deriveOutstandingSelection(dataField);
200
+
201
+ return (dataField.options || []).some(
202
+ (option) => option && option.id === candidate
203
+ )
204
+ ? candidate
205
+ : null;
206
+ }, [outstandingEnabled, outstandingRule, dataField]);
207
+
208
+ /** Stored ids the field no longer offers — drives the amber warning. */
209
+ const outstandingStaleIds = useMemo(
210
+ () => (outstandingEnabled ? staleOutstandingIds(dataField) : []),
211
+ [outstandingEnabled, dataField]
212
+ );
213
+
131
214
  // Generate compact field info for display in the center top
132
215
  const getFieldInfo = (field) => {
133
216
  const fieldTypeLabel =
@@ -162,6 +245,14 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
162
245
  info.push('Conditional');
163
246
  }
164
247
 
248
+ // Only surfaced for apps that opted into outstanding-item rules.
249
+ if (outstandingItems?.enabled) {
250
+ const outstandingSummary = summariseOutstandingRule(field);
251
+ if (outstandingSummary) {
252
+ info.push(outstandingSummary);
253
+ }
254
+ }
255
+
165
256
  return info.join(' • ');
166
257
  };
167
258
 
@@ -209,6 +300,13 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
209
300
  tooltip += `Show when "${field.conditional_field}" = "${field.conditional_value}"\n`;
210
301
  }
211
302
 
303
+ if (outstandingItems?.enabled) {
304
+ const outstandingSummary = summariseOutstandingRule(field);
305
+ if (outstandingSummary) {
306
+ tooltip += `\nOutstanding rule: ${outstandingSummary}\n`;
307
+ }
308
+ }
309
+
212
310
  // Add other conditional properties if they exist
213
311
  if (field.conditionalOperator) {
214
312
  tooltip += `Operator: ${field.conditionalOperator}\n`;
@@ -236,6 +334,181 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
236
334
  return collapsed;
237
335
  };
238
336
 
337
+ /**
338
+ * Sections are implicit: `type: 'section'` acts as a divider in a flat
339
+ * array, and everything after it belongs to it until the next one. This
340
+ * turns that into groups for the outline and the section headers.
341
+ */
342
+ const sectionGroups = useMemo(() => {
343
+ const groups = [];
344
+ let current = null;
345
+
346
+ (data.detail || []).forEach((field, index) => {
347
+ if (field.type === 'section') {
348
+ current = {
349
+ id: field.id,
350
+ label: field.label || 'Untitled section',
351
+ index,
352
+ fields: [],
353
+ };
354
+ groups.push(current);
355
+ return;
356
+ }
357
+
358
+ // Fields before the first section still need somewhere to live.
359
+ if (!current) {
360
+ current = {
361
+ id: '__ungrouped',
362
+ label: 'Before first section',
363
+ index: -1,
364
+ fields: [],
365
+ };
366
+ groups.push(current);
367
+ }
368
+
369
+ current.fields.push({ field, index });
370
+ });
371
+
372
+ return groups;
373
+ }, [data.detail]);
374
+
375
+ /**
376
+ * A duplicate needs its own id or React keys collide and the sortable
377
+ * list starts swapping the wrong rows. Ids are "<slug>::<uuid>", so the
378
+ * slug is kept for readability and the uuid regenerated.
379
+ */
380
+ const freshFieldId = (existingId) => {
381
+ const slug = String(existingId || 'field').split('::')[0];
382
+ return `${slug}::${uuidv4()}`;
383
+ };
384
+
385
+ const handleDuplicateField = (id) => {
386
+ const index = (data.detail || []).findIndex((item) => item.id === id);
387
+ if (index < 0) return;
388
+
389
+ const original = data.detail[index];
390
+ const copy = {
391
+ ...original,
392
+ id: freshFieldId(original.id),
393
+ label: `${original.label} (copy)`,
394
+ };
395
+
396
+ const newDetail = [...data.detail];
397
+ newDetail.splice(index + 1, 0, copy);
398
+ setData((items) => ({ ...items, detail: newDetail }));
399
+ toast.success(`Duplicated "${original.label}".`);
400
+ };
401
+
402
+ /**
403
+ * Duplicating a section takes its fields with it — the whole point is the
404
+ * repeated checkbox / photo / comments block, which is tedious to rebuild
405
+ * one field at a time.
406
+ */
407
+ const handleDuplicateSection = (sectionId) => {
408
+ const group = sectionGroups.find((entry) => entry.id === sectionId);
409
+ if (!group || group.index < 0) return;
410
+
411
+ const section = data.detail[group.index];
412
+ const block = [section, ...group.fields.map((entry) => entry.field)];
413
+ const copies = block.map((field, offset) => ({
414
+ ...field,
415
+ id: freshFieldId(field.id),
416
+ label: offset === 0 ? `${field.label} (copy)` : field.label,
417
+ // A copied field must not keep pointing at the original's
418
+ // conditional source, or editing one would silently drive both.
419
+ conditional_field: '',
420
+ conditional_value: '',
421
+ }));
422
+
423
+ const insertAt = group.index + block.length;
424
+ const newDetail = [...data.detail];
425
+ newDetail.splice(insertAt, 0, ...copies);
426
+ setData((items) => ({ ...items, detail: newDetail }));
427
+ toast.success(
428
+ `Duplicated "${section.label}" and ${group.fields.length} field${
429
+ group.fields.length === 1 ? '' : 's'
430
+ }.`
431
+ );
432
+ };
433
+
434
+
435
+ /**
436
+ * Which section is currently on screen, so the outline reflects where you
437
+ * are rather than just listing what exists. Without it the rail is a
438
+ * static menu and you still lose your place in 87 fields.
439
+ */
440
+ const [activeSection, setActiveSection] = useState(null);
441
+
442
+ useEffect(() => {
443
+ if (!showOutline || sectionGroups.length === 0) return undefined;
444
+
445
+ const onScroll = () => {
446
+ let current = null;
447
+ for (const group of sectionGroups) {
448
+ if (group.index < 0) continue;
449
+ const node = document.getElementById(`fb-field-${group.id}`);
450
+ if (!node) continue;
451
+ // A section counts as current once its header passes the
452
+ // upper third of the viewport.
453
+ if (node.getBoundingClientRect().top <= window.innerHeight / 3) {
454
+ current = group.id;
455
+ }
456
+ }
457
+ setActiveSection(current);
458
+ };
459
+
460
+ onScroll();
461
+ window.addEventListener('scroll', onScroll, { passive: true });
462
+
463
+ return () => window.removeEventListener('scroll', onScroll);
464
+ }, [showOutline, sectionGroups]);
465
+
466
+
467
+ /**
468
+ * The app header is `position: fixed` with a content-driven height, so a
469
+ * hard-coded sticky offset puts the builder toolbar underneath it.
470
+ * Measure it and expose it as a custom property instead.
471
+ */
472
+ useEffect(() => {
473
+ const measure = () => {
474
+ // The <header> TAG, not a class: GenericMain renders it with a
475
+ // CSS-module class, so the DOM name is hashed and a `.header`
476
+ // selector never matches.
477
+ const header = document.querySelector('header');
478
+
479
+ // Only a fixed or sticky header overlaps the page; a static one
480
+ // scrolls away and must not add an offset.
481
+ let height = 0;
482
+ if (header) {
483
+ const pos = window.getComputedStyle(header).position;
484
+ if (pos === 'fixed' || pos === 'sticky') {
485
+ height = Math.round(header.getBoundingClientRect().height);
486
+ }
487
+ }
488
+
489
+ document.documentElement.style.setProperty(
490
+ '--fb-sticky-top',
491
+ `${height}px`
492
+ );
493
+ };
494
+
495
+ measure();
496
+ window.addEventListener('resize', measure);
497
+ // Fonts and logo images can land after first paint and change the
498
+ // header height, so re-measure once things settle.
499
+ const settle = setTimeout(measure, 400);
500
+
501
+ return () => {
502
+ window.removeEventListener('resize', measure);
503
+ clearTimeout(settle);
504
+ };
505
+ }, []);
506
+
507
+ const scrollToField = (fieldId) => {
508
+ const node = document.getElementById(`fb-field-${fieldId}`);
509
+ if (node) node.scrollIntoView({ behavior: 'smooth', block: 'center' });
510
+ };
511
+
239
512
  const toggleSectionCollapse = (sectionId) => {
240
513
  setCollapsedSections((prev) => {
241
514
  const newSet = new Set(prev);
@@ -273,6 +546,155 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
273
546
  const collapsedFieldIds = getCollapsedFieldIds();
274
547
 
275
548
  // Create sortable field item using @dnd-kit
549
+
550
+ /**
551
+ * Compact edit row.
552
+ *
553
+ * Deliberately shares NO markup or styles with SortableFieldItem. That
554
+ * component renders a tall preview card — absolutely positioned control
555
+ * bar, fixed heights, a two-column grid — and every attempt to override
556
+ * those from the outside produced a different broken layout. Two modes,
557
+ * two renderers, no shared stylesheet.
558
+ *
559
+ * The behaviour is identical: same sortable id, same handlers, same
560
+ * DndContext. Only the presentation differs.
561
+ */
562
+
563
+ /**
564
+ * Preview row — the form as it will actually appear.
565
+ *
566
+ * No drag handle, index badge, action icons or hover chrome: editing all
567
+ * happens in the compact list, so carrying the authoring furniture in
568
+ * here only obscures what the preview is for. Not sortable either, which
569
+ * is why it needs no DndContext.
570
+ */
571
+ const PreviewFieldItem = ({ field }) => (
572
+ <div
573
+ className={`${renderClassName(field)} ${styles.pvItem}`}
574
+ data-field-size={field.size}
575
+ >
576
+ {renderField(field)}
577
+ </div>
578
+ );
579
+
580
+ const CompactFieldRow = ({ field, index: counter }) => {
581
+ const {
582
+ attributes,
583
+ listeners,
584
+ setNodeRef,
585
+ transform,
586
+ transition,
587
+ isDragging,
588
+ } = useSortable({
589
+ id: field.id,
590
+ data: { type: 'field', size: field.size, index: counter },
591
+ });
592
+
593
+ const isSection = field.type === 'section';
594
+ const group = isSection
595
+ ? sectionGroups.find((entry) => entry.id === field.id)
596
+ : null;
597
+ const isSectionCollapsed = isSection && collapsedSections.has(field.id);
598
+
599
+ const isDimmed =
600
+ searchQuery &&
601
+ !(
602
+ field.label?.toLowerCase().includes(searchQuery.toLowerCase()) ||
603
+ field.type?.toLowerCase().includes(searchQuery.toLowerCase()) ||
604
+ field.id?.toLowerCase().includes(searchQuery.toLowerCase())
605
+ );
606
+
607
+ return (
608
+ <div
609
+ ref={setNodeRef}
610
+ id={`fb-field-${field.id}`}
611
+ className={`${styles.cRow} ${isSection ? styles.cRowSection : ''} ${
612
+ isDragging ? styles.cRowDragging : ''
613
+ }`}
614
+ style={{
615
+ transform: CSS.Transform.toString(transform),
616
+ transition: isDragging ? 'none' : transition,
617
+ opacity: isDimmed ? 0.3 : 1,
618
+ }}
619
+ >
620
+ <span
621
+ {...attributes}
622
+ {...listeners}
623
+ className={styles.cGrip}
624
+ title="Drag to reorder"
625
+ >
626
+ <ChevronVertical size={15} strokeWidth={2.5} />
627
+ </span>
628
+
629
+ {isSection ? (
630
+ <button
631
+ className={styles.cCollapse}
632
+ onClick={() => toggleSectionCollapse(field.id)}
633
+ title={isSectionCollapsed ? 'Expand' : 'Collapse'}
634
+ >
635
+ {isSectionCollapsed ? (
636
+ <ChevronRight size={15} />
637
+ ) : (
638
+ <ChevronDown size={15} />
639
+ )}
640
+ </button>
641
+ ) : (
642
+ <span className={styles.cNum}>{counter + 1}</span>
643
+ )}
644
+
645
+ <span className={styles.cLabel} title={field.label}>
646
+ {field.label || <em>Untitled</em>}
647
+ </span>
648
+
649
+ <span className={styles.cMeta}>
650
+ {isSection
651
+ ? `${group ? group.fields.length : 0} fields`
652
+ : getFieldInfo(field)}
653
+ </span>
654
+
655
+ <span className={styles.cActions}>
656
+ <button
657
+ title={
658
+ isSection
659
+ ? 'Duplicate section and its fields'
660
+ : 'Duplicate field'
661
+ }
662
+ onClick={() =>
663
+ isSection
664
+ ? handleDuplicateSection(field.id)
665
+ : handleDuplicateField(field.id)
666
+ }
667
+ >
668
+ <Copy size={15} />
669
+ </button>
670
+ <button
671
+ title="Move to position"
672
+ onClick={() => {
673
+ setMoveToDialog({
674
+ show: true,
675
+ fieldId: field.id,
676
+ currentPos: counter + 1,
677
+ });
678
+ setMoveToPosition(String(counter + 1));
679
+ }}
680
+ >
681
+ <ArrowUpDown size={15} />
682
+ </button>
683
+ <button title="Edit" onClick={() => handleEdit(counter)}>
684
+ <Pencil size={15} />
685
+ </button>
686
+ <button
687
+ className={styles.cDelete}
688
+ title="Delete"
689
+ onClick={() => handleDeleteField(field.id, field.label)}
690
+ >
691
+ <TrashCan size={15} />
692
+ </button>
693
+ </span>
694
+ </div>
695
+ );
696
+ };
697
+
276
698
  const SortableFieldItem = ({ field, index: counter }) => {
277
699
  const {
278
700
  attributes,
@@ -413,6 +835,27 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
413
835
  )}
414
836
  </button>
415
837
  )}
838
+ <button
839
+ className={styles.duplicateBtn}
840
+ data-tooltip-id="action-tooltip"
841
+ data-tooltip-content={
842
+ isSection
843
+ ? 'Duplicate section and its fields'
844
+ : 'Duplicate field'
845
+ }
846
+ data-tooltip-place="top"
847
+ onClick={(e) => {
848
+ e.preventDefault();
849
+ e.stopPropagation();
850
+ if (isSection) {
851
+ handleDuplicateSection(field.id);
852
+ } else {
853
+ handleDuplicateField(field.id);
854
+ }
855
+ }}
856
+ >
857
+ <Copy strokeWidth={2.5} size={17} />
858
+ </button>
416
859
  <ArrowUpDown
417
860
  className={styles.moveIcon}
418
861
  strokeWidth={3}
@@ -464,7 +907,7 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
464
907
  />
465
908
  </div>
466
909
  </div>
467
- <div className={styles.fieldContent}>
910
+ <div className={styles.fieldContent} id={`fb-field-${field.id}`}>
468
911
  {renderField(field)}
469
912
  </div>
470
913
  </div>
@@ -662,7 +1105,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
662
1105
  textareaHeight: 'normal',
663
1106
  conditional_field: '',
664
1107
  conditional_value: '',
1108
+ // `outstanding_when` is deliberately NOT seeded here: absent must
1109
+ // stay absent so a field that was never configured never gains the
1110
+ // key just by being opened.
665
1111
  }));
1112
+ setOutstandingRule({ touched: false, id: null });
666
1113
  setModalType(() => ({
667
1114
  type: 'create',
668
1115
  key: '',
@@ -682,10 +1129,28 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
682
1129
  const { name, value } = e.target;
683
1130
  const updatedValue = name === 'id' ? slugify(value) : value;
684
1131
 
685
- setDataField((items) => ({
686
- ...items,
687
- [name]: updatedValue,
688
- }));
1132
+ setDataField((items) => {
1133
+ const next = { ...items, [name]: updatedValue };
1134
+
1135
+ // Switching to a type that cannot carry an outstanding rule
1136
+ // would strand the key with no UI left to edit it.
1137
+ if (
1138
+ name === 'type' &&
1139
+ 'outstanding_when' in next &&
1140
+ !isOutstandingConfigurable(outstandingItems, updatedValue)
1141
+ ) {
1142
+ delete next.outstanding_when;
1143
+ }
1144
+
1145
+ return next;
1146
+ });
1147
+
1148
+ if (
1149
+ name === 'type' &&
1150
+ !isOutstandingConfigurable(outstandingItems, updatedValue)
1151
+ ) {
1152
+ setOutstandingRule({ touched: false, id: null });
1153
+ }
689
1154
 
690
1155
  if (name === 'label') {
691
1156
  setDataField((items) => ({
@@ -715,6 +1180,10 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
715
1180
  ...detail[key],
716
1181
  }));
717
1182
 
1183
+ // Untouched: the switch shows whatever the stored rule — or the
1184
+ // standard rule — implies, recomputed as the author edits.
1185
+ setOutstandingRule({ touched: false, id: null });
1186
+
718
1187
  setModalType(() => ({
719
1188
  type: 'update',
720
1189
  key: key,
@@ -793,6 +1262,11 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
793
1262
 
794
1263
  field.id = formatFieldId(field.id);
795
1264
 
1265
+ // No-op unless the consuming app enabled outstanding-item rules. Writes
1266
+ // `outstanding_when` only when the switch differs from the standard
1267
+ // rule, so opening and saving an untouched field changes nothing.
1268
+ field = applyOutstandingRule(field, outstandingSelection, outstandingItems);
1269
+
796
1270
  let errorMessage = validateField(field);
797
1271
 
798
1272
  if (errorMessage === '') {
@@ -1348,16 +1822,23 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1348
1822
  <div className={styles.grid}>
1349
1823
  <div className={styles.grid__row}>
1350
1824
  <div className={styles.grid__fullwidth}>
1351
- <div className={styles.formSplit}>
1352
- <div className={styles.gridtxt__header}>
1825
+ <div
1826
+ className={styles.formSplit}
1827
+ >
1828
+ <div className={styles.stickyHead}>
1829
+ <div
1830
+ className={`${styles.gridtxt__header} ${styles.builderBar}`}
1831
+ >
1353
1832
  <span>
1354
1833
  {formTitle
1355
1834
  ? formTitle
1356
1835
  : 'Form Template Preview'}
1357
1836
  </span>
1358
- </div>
1837
+ <div className={styles.builderTools}>
1359
1838
  {data.detail && data.detail.length > 5 && (
1360
- <div className={styles.searchBar}>
1839
+ <div
1840
+ className={`${styles.searchBar} ${styles.searchBarTop}`}
1841
+ >
1361
1842
  <Search
1362
1843
  size={16}
1363
1844
  className={styles.searchIcon}
@@ -1406,6 +1887,108 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1406
1887
  )}
1407
1888
  </div>
1408
1889
  )}
1890
+ <span className={styles.fieldCount}>
1891
+ {(data.detail || []).length} fields ·{' '}
1892
+ {sectionGroups.filter(
1893
+ (group) => group.index >= 0
1894
+ ).length}{' '}
1895
+ sections
1896
+ </span>
1897
+ <button
1898
+ className={`${styles.toolBtn} ${
1899
+ showOutline ? styles.toolBtnOn : ''
1900
+ }`}
1901
+ onClick={() =>
1902
+ setShowOutline((open) => !open)
1903
+ }
1904
+ data-tooltip-id="action-tooltip"
1905
+ data-tooltip-content="Toggle the section outline"
1906
+ >
1907
+ <List size={15} /> Sections
1908
+ </button>
1909
+ <div className={styles.modeSwitch}>
1910
+ <button
1911
+ className={
1912
+ compactMode
1913
+ ? styles.modeOn
1914
+ : undefined
1915
+ }
1916
+ onClick={() => setCompactMode(true)}
1917
+ >
1918
+ Edit
1919
+ </button>
1920
+ <button
1921
+ className={
1922
+ compactMode
1923
+ ? undefined
1924
+ : styles.modeOn
1925
+ }
1926
+ onClick={() =>
1927
+ setCompactMode(false)
1928
+ }
1929
+ >
1930
+ Preview
1931
+ </button>
1932
+ </div>
1933
+ </div>
1934
+ </div>
1935
+ {showOutline && sectionGroups.length > 0 && (
1936
+ <nav className={styles.sectionNav}>
1937
+ {sectionGroups.map((group) => {
1938
+ const collapsed = collapsedSections.has(
1939
+ group.id
1940
+ );
1941
+
1942
+ return (
1943
+ <button
1944
+ key={group.id}
1945
+ className={`${styles.sectionChip} ${
1946
+ activeSection === group.id
1947
+ ? styles.sectionChipActive
1948
+ : ''
1949
+ } ${
1950
+ collapsed
1951
+ ? styles.sectionChipCollapsed
1952
+ : ''
1953
+ }`}
1954
+ title={
1955
+ collapsed
1956
+ ? `${group.label} (collapsed)`
1957
+ : group.label
1958
+ }
1959
+ onClick={() => {
1960
+ // Jumping into a collapsed
1961
+ // section opens it first,
1962
+ // otherwise the scroll
1963
+ // lands on nothing.
1964
+ if (collapsed) {
1965
+ toggleSectionCollapse(
1966
+ group.id
1967
+ );
1968
+ }
1969
+ scrollToField(group.id);
1970
+ }}
1971
+ >
1972
+ <span
1973
+ className={
1974
+ styles.sectionChip__label
1975
+ }
1976
+ >
1977
+ {group.label}
1978
+ </span>
1979
+ <span
1980
+ className={
1981
+ styles.sectionChip__count
1982
+ }
1983
+ >
1984
+ {group.fields.length}
1985
+ </span>
1986
+ </button>
1987
+ );
1988
+ })}
1989
+ </nav>
1990
+ )}
1991
+ </div>
1409
1992
  {data.detail && data.detail.length > 0 ? (
1410
1993
  <div className={styles.modal__content}>
1411
1994
  <div className={styles.formcontainer}>
@@ -1431,16 +2014,30 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1431
2014
  strategy={rectSortingStrategy}
1432
2015
  >
1433
2016
  <div
1434
- className={styles.modalForm}
2017
+ className={`${
2018
+ compactMode
2019
+ ? styles.cList
2020
+ : styles.modalForm
2021
+ } ${
2022
+ compactMode
2023
+ ? ''
2024
+ : styles.pvForm
2025
+ }`}
1435
2026
  >
1436
2027
  {data.detail.map(
1437
- (field, index) => (
1438
- <SortableFieldItem
1439
- key={field.id}
1440
- field={field}
1441
- index={index}
1442
- />
1443
- )
2028
+ (field, index) =>
2029
+ compactMode ? (
2030
+ <CompactFieldRow
2031
+ key={field.id}
2032
+ field={field}
2033
+ index={index}
2034
+ />
2035
+ ) : (
2036
+ <PreviewFieldItem
2037
+ key={field.id}
2038
+ field={field}
2039
+ />
2040
+ )
1444
2041
  )}
1445
2042
  </div>
1446
2043
  </SortableContext>
@@ -1481,21 +2078,58 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1481
2078
  </div>
1482
2079
  </div>
1483
2080
  ) : (
1484
- <div className={styles.emptyState}>
1485
- <p>
1486
- No fields added yet. Click "Add Field"
1487
- to get started.
1488
- </p>
2081
+ <div
2082
+ className={`${styles.emptyState} ${
2083
+ dynamicFields
2084
+ ? styles.emptyStateDynamic
2085
+ : ''
2086
+ }`}
2087
+ >
2088
+ <span className={styles.emptyState__icon}>
2089
+ <List size={26} strokeWidth={1.75} />
2090
+ </span>
2091
+ {dynamicFields ? (
2092
+ <>
2093
+ <h3>Fields are added automatically</h3>
2094
+ <p>
2095
+ This form builds its own fields
2096
+ each time it is created, from
2097
+ the outstanding items on the
2098
+ linked inspection. Anything
2099
+ added here would not appear on
2100
+ the form.
2101
+ </p>
2102
+ </>
2103
+ ) : (
2104
+ <>
2105
+ <h3>This form has no fields yet</h3>
2106
+ <p>
2107
+ Add a section to group related
2108
+ questions, then add the fields
2109
+ that go inside it.
2110
+ </p>
2111
+ <button
2112
+ className={styles.btn}
2113
+ onClick={handleOpenModal}
2114
+ >
2115
+ Add the first field
2116
+ </button>
2117
+ </>
2118
+ )}
1489
2119
  </div>
1490
2120
  )}
1491
2121
  </div>
1492
2122
  <div className={styles.polActions}>
1493
- <button
1494
- className={styles.btn}
1495
- onClick={handleOpenModal}
1496
- >
1497
- Add Field
1498
- </button>
2123
+ {/* Nothing to author on a dynamic template, but
2124
+ Edit Form stays so it can still be renamed. */}
2125
+ {!dynamicFields && (
2126
+ <button
2127
+ className={styles.btn}
2128
+ onClick={handleOpenModal}
2129
+ >
2130
+ Add Field
2131
+ </button>
2132
+ )}
1499
2133
  <button
1500
2134
  className={styles.btn}
1501
2135
  onClick={handleOpenModalForm}
@@ -1853,6 +2487,14 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1853
2487
  </span>
1854
2488
  )}
1855
2489
  </div>
2490
+ {outstandingEnabled ? (
2491
+ <OutstandingRuleHint
2492
+ config={outstandingItems}
2493
+ staleIds={
2494
+ outstandingStaleIds
2495
+ }
2496
+ />
2497
+ ) : null}
1856
2498
  {dataField.options.length >
1857
2499
  0 ? (
1858
2500
  <div
@@ -1890,6 +2532,15 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1890
2532
  onChange={(
1891
2533
  e
1892
2534
  ) => {
2535
+ // Captured before the id is regenerated so any
2536
+ // outstanding rule pointing at it can follow.
2537
+ const previousOptionId =
2538
+ dataField
2539
+ .options[
2540
+ optionKey
2541
+ ]
2542
+ ?.id;
2543
+ const nextOptionId = `${slugify(e.target.value)}-`;
1893
2544
  const newOptions =
1894
2545
  [
1895
2546
  ...dataField.options,
@@ -1901,18 +2552,65 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1901
2552
  newOptions[
1902
2553
  optionKey
1903
2554
  ].id =
1904
- `${slugify(e.target.value)}-`;
2555
+ nextOptionId;
1905
2556
  setDataField(
1906
2557
  (
1907
2558
  items
1908
- ) => ({
1909
- ...items,
1910
- options:
1911
- newOptions,
1912
- })
2559
+ ) =>
2560
+ remapOutstandingWhen(
2561
+ {
2562
+ ...items,
2563
+ options:
2564
+ newOptions,
2565
+ },
2566
+ previousOptionId,
2567
+ nextOptionId
2568
+ )
2569
+ );
2570
+
2571
+ // An unsaved switch has to follow the
2572
+ // rename too, or it would be sanitised
2573
+ // away as a dangling id.
2574
+ setOutstandingRule(
2575
+ (
2576
+ rule
2577
+ ) =>
2578
+ rule.touched &&
2579
+ rule.id ===
2580
+ previousOptionId
2581
+ ? {
2582
+ ...rule,
2583
+ id: nextOptionId,
2584
+ }
2585
+ : rule
1913
2586
  );
1914
2587
  }}
1915
2588
  />
2589
+ {outstandingEnabled ? (
2590
+ <OutstandingOptionToggle
2591
+ optionId={
2592
+ option.id
2593
+ }
2594
+ label={
2595
+ option.label ||
2596
+ option.id
2597
+ }
2598
+ checked={
2599
+ outstandingSelection ===
2600
+ option.id
2601
+ }
2602
+ onToggle={(
2603
+ id
2604
+ ) =>
2605
+ setOutstandingRule(
2606
+ {
2607
+ touched: true,
2608
+ id,
2609
+ }
2610
+ )
2611
+ }
2612
+ />
2613
+ ) : null}
1916
2614
  <button
1917
2615
  className={
1918
2616
  styles.optionDelete