@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.
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +37 -12
- package/src/components/Navigation.jsx +59 -3
- package/src/components/auth/Login.jsx +203 -175
- package/src/components/columns/ColumnRenderers.jsx +34 -0
- package/src/components/generic/GenericDashboard.jsx +181 -1
- package/src/components/generic/GenericFormBuilder.jsx +735 -37
- package/src/components/generic/GenericIndex.jsx +15 -5
- package/src/components/generic/OutstandingRuleEditor.jsx +313 -0
- package/src/components/styles/DataGrid.module.scss +38 -22
- package/src/components/styles/Form.module.scss +32 -33
- package/src/components/styles/GenericDashboard.module.scss +155 -0
- package/src/components/styles/GenericDetail.module.scss +28 -30
- package/src/components/styles/GenericDynamic.module.scss +7 -21
- package/src/components/styles/GenericEditableTable.module.scss +5 -22
- package/src/components/styles/GenericFormBuilder.module.scss +605 -30
- package/src/components/styles/GenericIndex.module.scss +17 -32
- package/src/components/styles/GenericMain.module.scss +18 -6
- package/src/components/styles/GenericQuote.module.scss +7 -22
- package/src/components/styles/GenericReport.module.scss +5 -37
- package/src/components/styles/Login.module.scss +415 -239
- package/src/components/styles/Navigation.module.scss +124 -35
- package/src/components/styles/Profile.module.scss +5 -26
- package/src/components/styles/QuickAction.module.scss +34 -29
- package/src/components/styles/_controls.scss +139 -0
- 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 {
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
1352
|
-
|
|
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
|
-
|
|
1837
|
+
<div className={styles.builderTools}>
|
|
1359
1838
|
{data.detail && data.detail.length > 5 && (
|
|
1360
|
-
<div
|
|
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={
|
|
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
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
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
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
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
|
-
|
|
2555
|
+
nextOptionId;
|
|
1905
2556
|
setDataField(
|
|
1906
2557
|
(
|
|
1907
2558
|
items
|
|
1908
|
-
) =>
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
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
|