@visns-studio/visns-components 6.0.3 → 6.0.5

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.
@@ -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
 
@@ -124,10 +126,28 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
124
126
  });
125
127
  const [moveToPosition, setMoveToPosition] = useState('');
126
128
  const [collapsedSections, setCollapsedSections] = useState(new Set());
129
+
130
+ /**
131
+ * Compact mode renders each field as a one-line summary instead of a live
132
+ * control. Site Start Inspection has 87 fields; previewing them all means
133
+ * two dozen full-height textareas and file pickers, so arranging the form
134
+ * turns into a scroll expedition through mostly empty boxes. Preview is
135
+ * still a click away when you want to see the real thing.
136
+ */
137
+ const [compactMode, setCompactMode] = useState(true);
138
+ const [showOutline, setShowOutline] = useState(true);
127
139
  const sortTriggeredRef = useRef(false);
128
140
 
129
141
  const canvasTypes = SketchConfig.canvasTypes;
130
142
 
143
+ /**
144
+ * Templates whose fields are composed per form rather than authored here
145
+ * (outstanding items build theirs from the linked inspection). Offering
146
+ * "add a field" on one of these would be offering something that has no
147
+ * effect.
148
+ */
149
+ const dynamicFields = data.field_source === 'dynamic';
150
+
131
151
  // Generate compact field info for display in the center top
132
152
  const getFieldInfo = (field) => {
133
153
  const fieldTypeLabel =
@@ -236,6 +256,181 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
236
256
  return collapsed;
237
257
  };
238
258
 
259
+ /**
260
+ * Sections are implicit: `type: 'section'` acts as a divider in a flat
261
+ * array, and everything after it belongs to it until the next one. This
262
+ * turns that into groups for the outline and the section headers.
263
+ */
264
+ const sectionGroups = useMemo(() => {
265
+ const groups = [];
266
+ let current = null;
267
+
268
+ (data.detail || []).forEach((field, index) => {
269
+ if (field.type === 'section') {
270
+ current = {
271
+ id: field.id,
272
+ label: field.label || 'Untitled section',
273
+ index,
274
+ fields: [],
275
+ };
276
+ groups.push(current);
277
+ return;
278
+ }
279
+
280
+ // Fields before the first section still need somewhere to live.
281
+ if (!current) {
282
+ current = {
283
+ id: '__ungrouped',
284
+ label: 'Before first section',
285
+ index: -1,
286
+ fields: [],
287
+ };
288
+ groups.push(current);
289
+ }
290
+
291
+ current.fields.push({ field, index });
292
+ });
293
+
294
+ return groups;
295
+ }, [data.detail]);
296
+
297
+ /**
298
+ * A duplicate needs its own id or React keys collide and the sortable
299
+ * list starts swapping the wrong rows. Ids are "<slug>::<uuid>", so the
300
+ * slug is kept for readability and the uuid regenerated.
301
+ */
302
+ const freshFieldId = (existingId) => {
303
+ const slug = String(existingId || 'field').split('::')[0];
304
+ return `${slug}::${uuidv4()}`;
305
+ };
306
+
307
+ const handleDuplicateField = (id) => {
308
+ const index = (data.detail || []).findIndex((item) => item.id === id);
309
+ if (index < 0) return;
310
+
311
+ const original = data.detail[index];
312
+ const copy = {
313
+ ...original,
314
+ id: freshFieldId(original.id),
315
+ label: `${original.label} (copy)`,
316
+ };
317
+
318
+ const newDetail = [...data.detail];
319
+ newDetail.splice(index + 1, 0, copy);
320
+ setData((items) => ({ ...items, detail: newDetail }));
321
+ toast.success(`Duplicated "${original.label}".`);
322
+ };
323
+
324
+ /**
325
+ * Duplicating a section takes its fields with it — the whole point is the
326
+ * repeated checkbox / photo / comments block, which is tedious to rebuild
327
+ * one field at a time.
328
+ */
329
+ const handleDuplicateSection = (sectionId) => {
330
+ const group = sectionGroups.find((entry) => entry.id === sectionId);
331
+ if (!group || group.index < 0) return;
332
+
333
+ const section = data.detail[group.index];
334
+ const block = [section, ...group.fields.map((entry) => entry.field)];
335
+ const copies = block.map((field, offset) => ({
336
+ ...field,
337
+ id: freshFieldId(field.id),
338
+ label: offset === 0 ? `${field.label} (copy)` : field.label,
339
+ // A copied field must not keep pointing at the original's
340
+ // conditional source, or editing one would silently drive both.
341
+ conditional_field: '',
342
+ conditional_value: '',
343
+ }));
344
+
345
+ const insertAt = group.index + block.length;
346
+ const newDetail = [...data.detail];
347
+ newDetail.splice(insertAt, 0, ...copies);
348
+ setData((items) => ({ ...items, detail: newDetail }));
349
+ toast.success(
350
+ `Duplicated "${section.label}" and ${group.fields.length} field${
351
+ group.fields.length === 1 ? '' : 's'
352
+ }.`
353
+ );
354
+ };
355
+
356
+
357
+ /**
358
+ * Which section is currently on screen, so the outline reflects where you
359
+ * are rather than just listing what exists. Without it the rail is a
360
+ * static menu and you still lose your place in 87 fields.
361
+ */
362
+ const [activeSection, setActiveSection] = useState(null);
363
+
364
+ useEffect(() => {
365
+ if (!showOutline || sectionGroups.length === 0) return undefined;
366
+
367
+ const onScroll = () => {
368
+ let current = null;
369
+ for (const group of sectionGroups) {
370
+ if (group.index < 0) continue;
371
+ const node = document.getElementById(`fb-field-${group.id}`);
372
+ if (!node) continue;
373
+ // A section counts as current once its header passes the
374
+ // upper third of the viewport.
375
+ if (node.getBoundingClientRect().top <= window.innerHeight / 3) {
376
+ current = group.id;
377
+ }
378
+ }
379
+ setActiveSection(current);
380
+ };
381
+
382
+ onScroll();
383
+ window.addEventListener('scroll', onScroll, { passive: true });
384
+
385
+ return () => window.removeEventListener('scroll', onScroll);
386
+ }, [showOutline, sectionGroups]);
387
+
388
+
389
+ /**
390
+ * The app header is `position: fixed` with a content-driven height, so a
391
+ * hard-coded sticky offset puts the builder toolbar underneath it.
392
+ * Measure it and expose it as a custom property instead.
393
+ */
394
+ useEffect(() => {
395
+ const measure = () => {
396
+ // The <header> TAG, not a class: GenericMain renders it with a
397
+ // CSS-module class, so the DOM name is hashed and a `.header`
398
+ // selector never matches.
399
+ const header = document.querySelector('header');
400
+
401
+ // Only a fixed or sticky header overlaps the page; a static one
402
+ // scrolls away and must not add an offset.
403
+ let height = 0;
404
+ if (header) {
405
+ const pos = window.getComputedStyle(header).position;
406
+ if (pos === 'fixed' || pos === 'sticky') {
407
+ height = Math.round(header.getBoundingClientRect().height);
408
+ }
409
+ }
410
+
411
+ document.documentElement.style.setProperty(
412
+ '--fb-sticky-top',
413
+ `${height}px`
414
+ );
415
+ };
416
+
417
+ measure();
418
+ window.addEventListener('resize', measure);
419
+ // Fonts and logo images can land after first paint and change the
420
+ // header height, so re-measure once things settle.
421
+ const settle = setTimeout(measure, 400);
422
+
423
+ return () => {
424
+ window.removeEventListener('resize', measure);
425
+ clearTimeout(settle);
426
+ };
427
+ }, []);
428
+
429
+ const scrollToField = (fieldId) => {
430
+ const node = document.getElementById(`fb-field-${fieldId}`);
431
+ if (node) node.scrollIntoView({ behavior: 'smooth', block: 'center' });
432
+ };
433
+
239
434
  const toggleSectionCollapse = (sectionId) => {
240
435
  setCollapsedSections((prev) => {
241
436
  const newSet = new Set(prev);
@@ -273,6 +468,155 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
273
468
  const collapsedFieldIds = getCollapsedFieldIds();
274
469
 
275
470
  // Create sortable field item using @dnd-kit
471
+
472
+ /**
473
+ * Compact edit row.
474
+ *
475
+ * Deliberately shares NO markup or styles with SortableFieldItem. That
476
+ * component renders a tall preview card — absolutely positioned control
477
+ * bar, fixed heights, a two-column grid — and every attempt to override
478
+ * those from the outside produced a different broken layout. Two modes,
479
+ * two renderers, no shared stylesheet.
480
+ *
481
+ * The behaviour is identical: same sortable id, same handlers, same
482
+ * DndContext. Only the presentation differs.
483
+ */
484
+
485
+ /**
486
+ * Preview row — the form as it will actually appear.
487
+ *
488
+ * No drag handle, index badge, action icons or hover chrome: editing all
489
+ * happens in the compact list, so carrying the authoring furniture in
490
+ * here only obscures what the preview is for. Not sortable either, which
491
+ * is why it needs no DndContext.
492
+ */
493
+ const PreviewFieldItem = ({ field }) => (
494
+ <div
495
+ className={`${renderClassName(field)} ${styles.pvItem}`}
496
+ data-field-size={field.size}
497
+ >
498
+ {renderField(field)}
499
+ </div>
500
+ );
501
+
502
+ const CompactFieldRow = ({ field, index: counter }) => {
503
+ const {
504
+ attributes,
505
+ listeners,
506
+ setNodeRef,
507
+ transform,
508
+ transition,
509
+ isDragging,
510
+ } = useSortable({
511
+ id: field.id,
512
+ data: { type: 'field', size: field.size, index: counter },
513
+ });
514
+
515
+ const isSection = field.type === 'section';
516
+ const group = isSection
517
+ ? sectionGroups.find((entry) => entry.id === field.id)
518
+ : null;
519
+ const isSectionCollapsed = isSection && collapsedSections.has(field.id);
520
+
521
+ const isDimmed =
522
+ searchQuery &&
523
+ !(
524
+ field.label?.toLowerCase().includes(searchQuery.toLowerCase()) ||
525
+ field.type?.toLowerCase().includes(searchQuery.toLowerCase()) ||
526
+ field.id?.toLowerCase().includes(searchQuery.toLowerCase())
527
+ );
528
+
529
+ return (
530
+ <div
531
+ ref={setNodeRef}
532
+ id={`fb-field-${field.id}`}
533
+ className={`${styles.cRow} ${isSection ? styles.cRowSection : ''} ${
534
+ isDragging ? styles.cRowDragging : ''
535
+ }`}
536
+ style={{
537
+ transform: CSS.Transform.toString(transform),
538
+ transition: isDragging ? 'none' : transition,
539
+ opacity: isDimmed ? 0.3 : 1,
540
+ }}
541
+ >
542
+ <span
543
+ {...attributes}
544
+ {...listeners}
545
+ className={styles.cGrip}
546
+ title="Drag to reorder"
547
+ >
548
+ <ChevronVertical size={15} strokeWidth={2.5} />
549
+ </span>
550
+
551
+ {isSection ? (
552
+ <button
553
+ className={styles.cCollapse}
554
+ onClick={() => toggleSectionCollapse(field.id)}
555
+ title={isSectionCollapsed ? 'Expand' : 'Collapse'}
556
+ >
557
+ {isSectionCollapsed ? (
558
+ <ChevronRight size={15} />
559
+ ) : (
560
+ <ChevronDown size={15} />
561
+ )}
562
+ </button>
563
+ ) : (
564
+ <span className={styles.cNum}>{counter + 1}</span>
565
+ )}
566
+
567
+ <span className={styles.cLabel} title={field.label}>
568
+ {field.label || <em>Untitled</em>}
569
+ </span>
570
+
571
+ <span className={styles.cMeta}>
572
+ {isSection
573
+ ? `${group ? group.fields.length : 0} fields`
574
+ : getFieldInfo(field)}
575
+ </span>
576
+
577
+ <span className={styles.cActions}>
578
+ <button
579
+ title={
580
+ isSection
581
+ ? 'Duplicate section and its fields'
582
+ : 'Duplicate field'
583
+ }
584
+ onClick={() =>
585
+ isSection
586
+ ? handleDuplicateSection(field.id)
587
+ : handleDuplicateField(field.id)
588
+ }
589
+ >
590
+ <Copy size={15} />
591
+ </button>
592
+ <button
593
+ title="Move to position"
594
+ onClick={() => {
595
+ setMoveToDialog({
596
+ show: true,
597
+ fieldId: field.id,
598
+ currentPos: counter + 1,
599
+ });
600
+ setMoveToPosition(String(counter + 1));
601
+ }}
602
+ >
603
+ <ArrowUpDown size={15} />
604
+ </button>
605
+ <button title="Edit" onClick={() => handleEdit(counter)}>
606
+ <Pencil size={15} />
607
+ </button>
608
+ <button
609
+ className={styles.cDelete}
610
+ title="Delete"
611
+ onClick={() => handleDeleteField(field.id, field.label)}
612
+ >
613
+ <TrashCan size={15} />
614
+ </button>
615
+ </span>
616
+ </div>
617
+ );
618
+ };
619
+
276
620
  const SortableFieldItem = ({ field, index: counter }) => {
277
621
  const {
278
622
  attributes,
@@ -413,6 +757,27 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
413
757
  )}
414
758
  </button>
415
759
  )}
760
+ <button
761
+ className={styles.duplicateBtn}
762
+ data-tooltip-id="action-tooltip"
763
+ data-tooltip-content={
764
+ isSection
765
+ ? 'Duplicate section and its fields'
766
+ : 'Duplicate field'
767
+ }
768
+ data-tooltip-place="top"
769
+ onClick={(e) => {
770
+ e.preventDefault();
771
+ e.stopPropagation();
772
+ if (isSection) {
773
+ handleDuplicateSection(field.id);
774
+ } else {
775
+ handleDuplicateField(field.id);
776
+ }
777
+ }}
778
+ >
779
+ <Copy strokeWidth={2.5} size={17} />
780
+ </button>
416
781
  <ArrowUpDown
417
782
  className={styles.moveIcon}
418
783
  strokeWidth={3}
@@ -464,7 +829,7 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
464
829
  />
465
830
  </div>
466
831
  </div>
467
- <div className={styles.fieldContent}>
832
+ <div className={styles.fieldContent} id={`fb-field-${field.id}`}>
468
833
  {renderField(field)}
469
834
  </div>
470
835
  </div>
@@ -1348,16 +1713,23 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1348
1713
  <div className={styles.grid}>
1349
1714
  <div className={styles.grid__row}>
1350
1715
  <div className={styles.grid__fullwidth}>
1351
- <div className={styles.formSplit}>
1352
- <div className={styles.gridtxt__header}>
1716
+ <div
1717
+ className={styles.formSplit}
1718
+ >
1719
+ <div className={styles.stickyHead}>
1720
+ <div
1721
+ className={`${styles.gridtxt__header} ${styles.builderBar}`}
1722
+ >
1353
1723
  <span>
1354
1724
  {formTitle
1355
1725
  ? formTitle
1356
1726
  : 'Form Template Preview'}
1357
1727
  </span>
1358
- </div>
1728
+ <div className={styles.builderTools}>
1359
1729
  {data.detail && data.detail.length > 5 && (
1360
- <div className={styles.searchBar}>
1730
+ <div
1731
+ className={`${styles.searchBar} ${styles.searchBarTop}`}
1732
+ >
1361
1733
  <Search
1362
1734
  size={16}
1363
1735
  className={styles.searchIcon}
@@ -1406,6 +1778,108 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1406
1778
  )}
1407
1779
  </div>
1408
1780
  )}
1781
+ <span className={styles.fieldCount}>
1782
+ {(data.detail || []).length} fields ·{' '}
1783
+ {sectionGroups.filter(
1784
+ (group) => group.index >= 0
1785
+ ).length}{' '}
1786
+ sections
1787
+ </span>
1788
+ <button
1789
+ className={`${styles.toolBtn} ${
1790
+ showOutline ? styles.toolBtnOn : ''
1791
+ }`}
1792
+ onClick={() =>
1793
+ setShowOutline((open) => !open)
1794
+ }
1795
+ data-tooltip-id="action-tooltip"
1796
+ data-tooltip-content="Toggle the section outline"
1797
+ >
1798
+ <List size={15} /> Sections
1799
+ </button>
1800
+ <div className={styles.modeSwitch}>
1801
+ <button
1802
+ className={
1803
+ compactMode
1804
+ ? styles.modeOn
1805
+ : undefined
1806
+ }
1807
+ onClick={() => setCompactMode(true)}
1808
+ >
1809
+ Edit
1810
+ </button>
1811
+ <button
1812
+ className={
1813
+ compactMode
1814
+ ? undefined
1815
+ : styles.modeOn
1816
+ }
1817
+ onClick={() =>
1818
+ setCompactMode(false)
1819
+ }
1820
+ >
1821
+ Preview
1822
+ </button>
1823
+ </div>
1824
+ </div>
1825
+ </div>
1826
+ {showOutline && sectionGroups.length > 0 && (
1827
+ <nav className={styles.sectionNav}>
1828
+ {sectionGroups.map((group) => {
1829
+ const collapsed = collapsedSections.has(
1830
+ group.id
1831
+ );
1832
+
1833
+ return (
1834
+ <button
1835
+ key={group.id}
1836
+ className={`${styles.sectionChip} ${
1837
+ activeSection === group.id
1838
+ ? styles.sectionChipActive
1839
+ : ''
1840
+ } ${
1841
+ collapsed
1842
+ ? styles.sectionChipCollapsed
1843
+ : ''
1844
+ }`}
1845
+ title={
1846
+ collapsed
1847
+ ? `${group.label} (collapsed)`
1848
+ : group.label
1849
+ }
1850
+ onClick={() => {
1851
+ // Jumping into a collapsed
1852
+ // section opens it first,
1853
+ // otherwise the scroll
1854
+ // lands on nothing.
1855
+ if (collapsed) {
1856
+ toggleSectionCollapse(
1857
+ group.id
1858
+ );
1859
+ }
1860
+ scrollToField(group.id);
1861
+ }}
1862
+ >
1863
+ <span
1864
+ className={
1865
+ styles.sectionChip__label
1866
+ }
1867
+ >
1868
+ {group.label}
1869
+ </span>
1870
+ <span
1871
+ className={
1872
+ styles.sectionChip__count
1873
+ }
1874
+ >
1875
+ {group.fields.length}
1876
+ </span>
1877
+ </button>
1878
+ );
1879
+ })}
1880
+ </nav>
1881
+ )}
1882
+ </div>
1409
1883
  {data.detail && data.detail.length > 0 ? (
1410
1884
  <div className={styles.modal__content}>
1411
1885
  <div className={styles.formcontainer}>
@@ -1431,16 +1905,30 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1431
1905
  strategy={rectSortingStrategy}
1432
1906
  >
1433
1907
  <div
1434
- className={styles.modalForm}
1908
+ className={`${
1909
+ compactMode
1910
+ ? styles.cList
1911
+ : styles.modalForm
1912
+ } ${
1913
+ compactMode
1914
+ ? ''
1915
+ : styles.pvForm
1916
+ }`}
1435
1917
  >
1436
1918
  {data.detail.map(
1437
- (field, index) => (
1438
- <SortableFieldItem
1439
- key={field.id}
1440
- field={field}
1441
- index={index}
1442
- />
1443
- )
1919
+ (field, index) =>
1920
+ compactMode ? (
1921
+ <CompactFieldRow
1922
+ key={field.id}
1923
+ field={field}
1924
+ index={index}
1925
+ />
1926
+ ) : (
1927
+ <PreviewFieldItem
1928
+ key={field.id}
1929
+ field={field}
1930
+ />
1931
+ )
1444
1932
  )}
1445
1933
  </div>
1446
1934
  </SortableContext>
@@ -1481,21 +1969,58 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
1481
1969
  </div>
1482
1970
  </div>
1483
1971
  ) : (
1484
- <div className={styles.emptyState}>
1485
- <p>
1486
- No fields added yet. Click "Add Field"
1487
- to get started.
1488
- </p>
1972
+ <div
1973
+ className={`${styles.emptyState} ${
1974
+ dynamicFields
1975
+ ? styles.emptyStateDynamic
1976
+ : ''
1977
+ }`}
1978
+ >
1979
+ <span className={styles.emptyState__icon}>
1980
+ <List size={26} strokeWidth={1.75} />
1981
+ </span>
1982
+ {dynamicFields ? (
1983
+ <>
1984
+ <h3>Fields are added automatically</h3>
1985
+ <p>
1986
+ This form builds its own fields
1987
+ each time it is created, from
1988
+ the outstanding items on the
1989
+ linked inspection. Anything
1990
+ added here would not appear on
1991
+ the form.
1992
+ </p>
1993
+ </>
1994
+ ) : (
1995
+ <>
1996
+ <h3>This form has no fields yet</h3>
1997
+ <p>
1998
+ Add a section to group related
1999
+ questions, then add the fields
2000
+ that go inside it.
2001
+ </p>
2002
+ <button
2003
+ className={styles.btn}
2004
+ onClick={handleOpenModal}
2005
+ >
2006
+ Add the first field
2007
+ </button>
2008
+ </>
2009
+ )}
1489
2010
  </div>
1490
2011
  )}
1491
2012
  </div>
1492
2013
  <div className={styles.polActions}>
1493
- <button
1494
- className={styles.btn}
1495
- onClick={handleOpenModal}
1496
- >
1497
- Add Field
1498
- </button>
2014
+ {/* Nothing to author on a dynamic template, but
2015
+ Edit Form stays so it can still be renamed. */}
2016
+ {!dynamicFields && (
2017
+ <button
2018
+ className={styles.btn}
2019
+ onClick={handleOpenModal}
2020
+ >
2021
+ Add Field
2022
+ </button>
2023
+ )}
1499
2024
  <button
1500
2025
  className={styles.btn}
1501
2026
  onClick={handleOpenModalForm}