@visns-studio/visns-components 5.16.0 → 5.16.2

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 CHANGED
@@ -13,7 +13,7 @@
13
13
  "@nivo/line": "^0.87.0",
14
14
  "@nivo/pie": "^0.87.0",
15
15
  "@react-pdf/renderer": "^4.5.1",
16
- "@tanstack/react-query": "^5.99.2",
16
+ "@tanstack/react-query": "^5.100.9",
17
17
  "@tinymce/miniature": "^6.0.0",
18
18
  "@tinymce/tinymce-react": "^6.3.0",
19
19
  "@uiw/react-color": "^2.10.1",
@@ -33,7 +33,7 @@
33
33
  "lodash": "^4.18.1",
34
34
  "lodash.debounce": "^4.0.8",
35
35
  "lucide-react": "^0.544.0",
36
- "mapbox-gl": "^3.22.0",
36
+ "mapbox-gl": "^3.23.1",
37
37
  "moment": "^2.30.1",
38
38
  "motion": "^12.38.0",
39
39
  "numeral": "^2.0.6",
@@ -67,17 +67,17 @@
67
67
  "sweetalert2": "^11.26.24",
68
68
  "tesseract.js": "^6.0.1",
69
69
  "truncate": "^3.0.0",
70
- "uuid": "^11.1.0",
70
+ "uuid": "^11.1.1",
71
71
  "validator": "^13.15.35",
72
72
  "vite": "^6.4.2",
73
73
  "xlsx": "^0.18.5",
74
74
  "yarn": "^1.22.22",
75
- "yet-another-react-lightbox": "^3.31.0"
75
+ "yet-another-react-lightbox": "^3.32.0"
76
76
  },
77
77
  "devDependencies": {
78
78
  "@babel/core": "^7.29.0",
79
79
  "@babel/plugin-transform-runtime": "^7.29.0",
80
- "@babel/preset-env": "^7.29.2",
80
+ "@babel/preset-env": "^7.29.3",
81
81
  "@babel/preset-react": "^7.28.5",
82
82
  "babel-loader": "^10.1.1",
83
83
  "copy-webpack-plugin": "^13.0.1",
@@ -94,7 +94,7 @@
94
94
  "react-dom": "^17.0.0 || ^18.0.0"
95
95
  },
96
96
  "name": "@visns-studio/visns-components",
97
- "version": "5.16.0",
97
+ "version": "5.16.2",
98
98
  "description": "Various packages to assist in the development of our Custom Applications.",
99
99
  "main": "src/index.js",
100
100
  "files": [
@@ -11,6 +11,7 @@ import {
11
11
  LogOut,
12
12
  Shield,
13
13
  Image,
14
+ Phone,
14
15
  } from 'lucide-react';
15
16
  import CustomFetch from './Fetch';
16
17
  import Notification from './Notification';
@@ -232,6 +233,7 @@ function Navigation({
232
233
  image: Image,
233
234
  peopleGroup: Users,
234
235
  person: User,
236
+ phone: Phone,
235
237
  shield: Shield,
236
238
  signOut: LogOut,
237
239
  };
@@ -0,0 +1,448 @@
1
+ import React, { useMemo } from 'react';
2
+ import { X as CircleX } from 'lucide-react';
3
+ import Select, { components } from 'react-select';
4
+ import styles from '../styles/GenericFormBuilder.module.scss';
5
+
6
+ // Field types that cannot produce a value we can compare against — disabled in the picker.
7
+ const NON_VALUE_TYPES = new Set([
8
+ 'plaintext',
9
+ 'plaintextheading',
10
+ 'signature',
11
+ 'canvas',
12
+ 'file',
13
+ 'image',
14
+ 'videoUpload',
15
+ 'table_text',
16
+ ]);
17
+
18
+ // Field types whose `options` array should populate the Value dropdown.
19
+ const OPTION_VALUE_TYPES = new Set([
20
+ 'checkbox',
21
+ 'dropdown',
22
+ 'radio',
23
+ 'toggle',
24
+ 'table_radio',
25
+ ]);
26
+
27
+ // Stored as a literal string so the existing PDF blade (pdf/form.blade.php:132) keeps working.
28
+ const FILLED_IN_SENTINEL = 'not null';
29
+
30
+ const operatorOf = (conditionalValue) =>
31
+ conditionalValue === FILLED_IN_SENTINEL ? 'is filled in' : 'is';
32
+
33
+ // Group fields by their preceding `type: 'section'` header. Fields that can't be
34
+ // used in a condition (non-value types, or fields that appear after the current
35
+ // one so their value isn't yet known) are omitted from the list entirely.
36
+ const buildGroupedOptions = (allFields, currentField, typeLabelFor) => {
37
+ const currentIndex = allFields.findIndex(
38
+ (f) => f.id === currentField.id
39
+ );
40
+ // Brand-new field not yet in the array → treat as "at the end" so every
41
+ // existing field qualifies as an earlier reference.
42
+ const currentPos = currentIndex === -1 ? allFields.length : currentIndex;
43
+
44
+ const groups = [];
45
+ let group = { label: 'General', options: [] };
46
+ groups.push(group);
47
+
48
+ allFields.forEach((f, idx) => {
49
+ if (!f || !f.id) return;
50
+ if (f.type === 'section') {
51
+ group = { label: f.label || 'Section', options: [] };
52
+ groups.push(group);
53
+ return;
54
+ }
55
+ if (f.id === currentField.id) return;
56
+ if (NON_VALUE_TYPES.has(f.type)) return;
57
+ if (idx >= currentPos) return;
58
+
59
+ group.options.push({
60
+ value: f.id,
61
+ label: f.label || '(untitled)',
62
+ type: f.type,
63
+ typeLabel: typeLabelFor(f.type),
64
+ sectionLabel: group.label,
65
+ hasOptions: OPTION_VALUE_TYPES.has(f.type),
66
+ });
67
+ });
68
+
69
+ // Within each group, surface option-bearing fields first; preserve document
70
+ // order inside each bucket via a stable sort index.
71
+ groups.forEach((g) => {
72
+ g.options = g.options
73
+ .map((o, i) => ({ ...o, __i: i }))
74
+ .sort((a, b) => {
75
+ if (a.hasOptions !== b.hasOptions)
76
+ return a.hasOptions ? -1 : 1;
77
+ return a.__i - b.__i;
78
+ })
79
+ .map(({ __i, ...rest }) => rest);
80
+ });
81
+
82
+ return groups.filter((g) => g.options.length > 0);
83
+ };
84
+
85
+ // Custom Option renderer — shows the field label alongside a small type pill
86
+ // so duplicate labels stay distinguishable.
87
+ const FieldOption = (props) => {
88
+ const { data } = props;
89
+ return (
90
+ <components.Option {...props}>
91
+ <div
92
+ style={{
93
+ display: 'flex',
94
+ alignItems: 'center',
95
+ justifyContent: 'space-between',
96
+ gap: 8,
97
+ }}
98
+ >
99
+ <span
100
+ style={{
101
+ overflow: 'hidden',
102
+ textOverflow: 'ellipsis',
103
+ whiteSpace: 'nowrap',
104
+ }}
105
+ >
106
+ {data.label}
107
+ </span>
108
+ <span
109
+ style={{
110
+ fontSize: '0.72rem',
111
+ color: '#666',
112
+ background: 'rgba(0,0,0,0.06)',
113
+ borderRadius: 4,
114
+ padding: '1px 6px',
115
+ flexShrink: 0,
116
+ }}
117
+ >
118
+ {data.typeLabel}
119
+ </span>
120
+ </div>
121
+ </components.Option>
122
+ );
123
+ };
124
+
125
+ // Collapsed (selected) display carries the section breadcrumb so duplicate labels
126
+ // (e.g. many "Notes" fields) remain distinguishable after selection.
127
+ const FieldSingleValue = (props) => {
128
+ const { data } = props;
129
+ return (
130
+ <components.SingleValue {...props}>
131
+ {data.sectionLabel && data.sectionLabel !== 'General' ? (
132
+ <span style={{ color: '#888' }}>
133
+ {data.sectionLabel} ›{' '}
134
+ </span>
135
+ ) : null}
136
+ {data.label}
137
+ {data.typeLabel ? (
138
+ <span style={{ color: '#888' }}> — {data.typeLabel}</span>
139
+ ) : null}
140
+ </components.SingleValue>
141
+ );
142
+ };
143
+
144
+ function ConditionalDisplayEditor({
145
+ currentField,
146
+ allFields = [],
147
+ fieldTypes = [],
148
+ onChange,
149
+ }) {
150
+ const conditionalFieldId = currentField.conditional_field || '';
151
+ const conditionalValue = currentField.conditional_value || '';
152
+ const operator = operatorOf(conditionalValue);
153
+
154
+ const selectedField = allFields.find((f) => f.id === conditionalFieldId);
155
+ const referenceMissing = conditionalFieldId && !selectedField;
156
+
157
+ const typeLabelFor = (type) =>
158
+ fieldTypes.find((t) => t.value === type)?.label || type;
159
+
160
+ const groupedOptions = useMemo(
161
+ () => buildGroupedOptions(allFields, currentField, typeLabelFor),
162
+ // typeLabelFor is pure over fieldTypes; depend on its source array.
163
+ // eslint-disable-next-line react-hooks/exhaustive-deps
164
+ [allFields, currentField.id, fieldTypes]
165
+ );
166
+
167
+ const selectedOption = useMemo(() => {
168
+ if (!conditionalFieldId) return null;
169
+ for (const g of groupedOptions) {
170
+ const match = g.options.find((o) => o.value === conditionalFieldId);
171
+ if (match) return match;
172
+ }
173
+ return null;
174
+ }, [groupedOptions, conditionalFieldId]);
175
+
176
+ const targetHasOptions =
177
+ selectedField && OPTION_VALUE_TYPES.has(selectedField.type);
178
+ const targetOptions = targetHasOptions ? selectedField.options || [] : [];
179
+ const selectedValueOption = targetOptions.find(
180
+ (o) => o.id === conditionalValue
181
+ );
182
+
183
+ const emitChange = (patch) => {
184
+ if (onChange) onChange(patch);
185
+ };
186
+
187
+ const handleFieldChange = (option) => {
188
+ emitChange({
189
+ conditional_field: option ? option.value : '',
190
+ conditional_value: '',
191
+ });
192
+ };
193
+
194
+ const handleOperatorChange = (e) => {
195
+ const nextOperator = e.target.value;
196
+ if (nextOperator === 'is filled in') {
197
+ emitChange({ conditional_value: FILLED_IN_SENTINEL });
198
+ } else if (conditionalValue === FILLED_IN_SENTINEL) {
199
+ emitChange({ conditional_value: '' });
200
+ }
201
+ };
202
+
203
+ const handleValueChange = (e) => {
204
+ emitChange({ conditional_value: e.target.value });
205
+ };
206
+
207
+ const handleClear = (e) => {
208
+ e.preventDefault();
209
+ emitChange({ conditional_field: '', conditional_value: '' });
210
+ };
211
+
212
+ const renderValueControl = () => {
213
+ if (operator === 'is filled in') return null;
214
+ if (!selectedField) return null;
215
+
216
+ if (targetHasOptions) {
217
+ return (
218
+ <select
219
+ name="conditional_value"
220
+ value={conditionalValue}
221
+ onChange={handleValueChange}
222
+ >
223
+ <option value="">Please select a value</option>
224
+ {targetOptions.map((opt, i) => (
225
+ <option
226
+ value={opt.id}
227
+ key={`cond-value-${opt.id}-${i}`}
228
+ >
229
+ {opt.label}
230
+ </option>
231
+ ))}
232
+ </select>
233
+ );
234
+ }
235
+
236
+ const inputType = selectedField.type === 'number' ? 'number' : 'text';
237
+ return (
238
+ <input
239
+ name="conditional_value"
240
+ value={conditionalValue}
241
+ type={inputType}
242
+ onChange={handleValueChange}
243
+ />
244
+ );
245
+ };
246
+
247
+ const fieldBreadcrumb = selectedOption
248
+ ? `${
249
+ selectedOption.sectionLabel &&
250
+ selectedOption.sectionLabel !== 'General'
251
+ ? `${selectedOption.sectionLabel} › `
252
+ : ''
253
+ }${selectedOption.label}`
254
+ : selectedField
255
+ ? selectedField.label
256
+ : '…';
257
+
258
+ const renderPreview = () => {
259
+ if (!conditionalFieldId && !conditionalValue) {
260
+ return (
261
+ <span>
262
+ No condition set — this field will always be shown.
263
+ </span>
264
+ );
265
+ }
266
+
267
+ if (referenceMissing) return null;
268
+
269
+ if (operator === 'is filled in') {
270
+ return (
271
+ <span>
272
+ This field will only show when{' '}
273
+ <strong>{fieldBreadcrumb}</strong> is filled in.
274
+ </span>
275
+ );
276
+ }
277
+
278
+ if (!conditionalValue) {
279
+ return (
280
+ <span>
281
+ Pick a value for <strong>{fieldBreadcrumb}</strong> to
282
+ finish the rule.
283
+ </span>
284
+ );
285
+ }
286
+
287
+ const valueLabel = targetHasOptions
288
+ ? selectedValueOption?.label || conditionalValue
289
+ : conditionalValue;
290
+
291
+ return (
292
+ <span>
293
+ This field will only show when{' '}
294
+ <strong>{fieldBreadcrumb}</strong> is{' '}
295
+ <strong>{valueLabel}</strong>.
296
+ </span>
297
+ );
298
+ };
299
+
300
+ const hasAnyRule = !!(conditionalFieldId || conditionalValue);
301
+
302
+ const reactSelectStyles = {
303
+ menu: (provided) => ({ ...provided, zIndex: 99999 }),
304
+ menuPortal: (base) => ({ ...base, zIndex: 99999 }),
305
+ control: (base) => ({
306
+ ...base,
307
+ minHeight: 44,
308
+ borderRadius: 6,
309
+ }),
310
+ groupHeading: (base) => ({
311
+ ...base,
312
+ fontSize: '0.7rem',
313
+ textTransform: 'uppercase',
314
+ letterSpacing: '0.04em',
315
+ color: 'var(--primary-color)',
316
+ fontWeight: 700,
317
+ padding: '6px 12px',
318
+ background: 'rgba(var(--primary-rgb), 0.05)',
319
+ }),
320
+ option: (base, state) => ({
321
+ ...base,
322
+ cursor: state.isDisabled ? 'not-allowed' : 'pointer',
323
+ }),
324
+ };
325
+
326
+ return (
327
+ <>
328
+ <div className={`${styles.formItem} ${styles.fwItem}`}>
329
+ <div className={styles.gridtxt__header}>
330
+ <span>Conditional Field Display Criteria</span>
331
+ </div>
332
+ </div>
333
+
334
+ {referenceMissing ? (
335
+ <div
336
+ className={`${styles.formItem} ${styles.fwItem}`}
337
+ style={{
338
+ background: '#fff8e1',
339
+ border: '1px solid #f4c544',
340
+ borderRadius: 6,
341
+ padding: '8px 12px',
342
+ color: '#7a5c00',
343
+ fontSize: '0.85rem',
344
+ }}
345
+ >
346
+ The field this rule references no longer exists. Pick
347
+ another field or clear the rule.
348
+ </div>
349
+ ) : null}
350
+
351
+ <div className={styles.formItem}>
352
+ <label className={styles.fi__label}>
353
+ <Select
354
+ inputId="conditional_field"
355
+ classNamePrefix="visns-select"
356
+ options={groupedOptions}
357
+ value={selectedOption}
358
+ onChange={handleFieldChange}
359
+ isClearable
360
+ isSearchable
361
+ placeholder="Always show this field (type to search…)"
362
+ menuPortalTarget={
363
+ typeof document !== 'undefined'
364
+ ? document.body
365
+ : undefined
366
+ }
367
+ menuPlacement="auto"
368
+ components={{
369
+ Option: FieldOption,
370
+ SingleValue: FieldSingleValue,
371
+ }}
372
+ styles={reactSelectStyles}
373
+ noOptionsMessage={() => 'No matching fields'}
374
+ />
375
+ <span
376
+ className={`${styles.fi__span} ${styles.prefocus}`}
377
+ >
378
+ Show only when
379
+ </span>
380
+ </label>
381
+ </div>
382
+
383
+ <div className={styles.formItem}>
384
+ <label className={styles.fi__label}>
385
+ <select
386
+ name="conditional_operator"
387
+ value={operator}
388
+ onChange={handleOperatorChange}
389
+ disabled={!conditionalFieldId}
390
+ >
391
+ <option value="is">is</option>
392
+ <option value="is filled in">is filled in</option>
393
+ </select>
394
+ <span className={styles.fi__span}>Condition</span>
395
+ </label>
396
+ </div>
397
+
398
+ {(() => {
399
+ const valueControl = renderValueControl();
400
+ if (!valueControl) return null;
401
+ return (
402
+ <div className={styles.formItem}>
403
+ <label className={styles.fi__label}>
404
+ {valueControl}
405
+ <span className={styles.fi__span}>Value</span>
406
+ </label>
407
+ </div>
408
+ );
409
+ })()}
410
+
411
+ <div
412
+ className={`${styles.formItem} ${styles.fwItem}`}
413
+ style={{
414
+ background: 'rgba(0,0,0,0.03)',
415
+ borderRadius: 6,
416
+ padding: '8px 12px',
417
+ color: 'var(--paragraph-color)',
418
+ fontSize: '0.85rem',
419
+ display: 'flex',
420
+ alignItems: 'center',
421
+ justifyContent: 'space-between',
422
+ gap: 8,
423
+ }}
424
+ >
425
+ <div>{renderPreview()}</div>
426
+ {hasAnyRule ? (
427
+ <button
428
+ type="button"
429
+ onClick={handleClear}
430
+ title="Clear rule"
431
+ style={{
432
+ background: 'transparent',
433
+ border: 'none',
434
+ cursor: 'pointer',
435
+ display: 'inline-flex',
436
+ alignItems: 'center',
437
+ color: '#666',
438
+ }}
439
+ >
440
+ <CircleX size={16} />
441
+ </button>
442
+ ) : null}
443
+ </div>
444
+ </>
445
+ );
446
+ }
447
+
448
+ export default ConditionalDisplayEditor;
@@ -47,6 +47,7 @@ import 'react-datepicker/dist/react-datepicker.css';
47
47
  import Breadcrumb from '../Breadcrumb';
48
48
  import CustomFetch from '../Fetch';
49
49
  import SketchConfig from '../sketch/json/config.json';
50
+ import ConditionalDisplayEditor from './ConditionalDisplayEditor';
50
51
 
51
52
  import styles from '../styles/GenericFormBuilder.module.scss'; // Import the CSS module
52
53
 
@@ -69,6 +70,8 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
69
70
  size: 'half',
70
71
  required: 'no',
71
72
  textareaHeight: 'normal',
73
+ conditional_field: '',
74
+ conditional_value: '',
72
75
  });
73
76
  const [dataOption, setDataOption] = useState({
74
77
  id: '',
@@ -657,6 +660,8 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
657
660
  size: 'half',
658
661
  required: 'no',
659
662
  textareaHeight: 'normal',
663
+ conditional_field: '',
664
+ conditional_value: '',
660
665
  }));
661
666
  setModalType(() => ({
662
667
  type: 'create',
@@ -739,11 +744,32 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
739
744
  let newDetail = [...data.detail];
740
745
  newDetail.splice(index, 1);
741
746
 
747
+ let orphanCount = 0;
748
+ newDetail = newDetail.map((item) => {
749
+ if (item.conditional_field === id) {
750
+ orphanCount += 1;
751
+ return {
752
+ ...item,
753
+ conditional_field: '',
754
+ conditional_value: '',
755
+ };
756
+ }
757
+ return item;
758
+ });
759
+
742
760
  setData((items) => ({
743
761
  ...items,
744
762
  detail: newDetail,
745
763
  }));
746
764
 
765
+ if (orphanCount > 0) {
766
+ toast.info(
767
+ `Cleared ${orphanCount} conditional rule${
768
+ orphanCount === 1 ? '' : 's'
769
+ } that referenced the deleted field.`
770
+ );
771
+ }
772
+
747
773
  handleCloseModal();
748
774
  }
749
775
  },
@@ -2214,63 +2240,17 @@ function GenericFormBuilder({ setting, urlParam, userProfile }) {
2214
2240
  </div>
2215
2241
  ) : null}
2216
2242
 
2217
- <div
2218
- className={`${styles.formItem} ${styles.fwItem}`}
2219
- >
2220
- <div className={styles.gridtxt__header}>
2221
- <span>
2222
- Conditional Field Display
2223
- Criteria
2224
- </span>
2225
- </div>
2226
- </div>
2227
-
2228
- <div className={styles.formItem}>
2229
- <label className={styles.fi__label}>
2230
- <select
2231
- name="conditional_field"
2232
- value={
2233
- dataField.conditional_field
2234
- }
2235
- onChange={handleChangeForm}
2236
- >
2237
- <option>
2238
- Please Select an Option
2239
- </option>
2240
- {data.detail &&
2241
- data.detail.length > 0 &&
2242
- data.detail.map(
2243
- (item, key) => (
2244
- <option
2245
- value={item.id}
2246
- key={`conditional-field-${item.id}-${key}`}
2247
- >
2248
- {item.label}
2249
- </option>
2250
- )
2251
- )}
2252
- </select>
2253
- <span className={styles.fi__span}>
2254
- Field
2255
- </span>
2256
- </label>
2257
- </div>
2258
-
2259
- <div className={styles.formItem}>
2260
- <label className={styles.fi__label}>
2261
- <input
2262
- name="conditional_value"
2263
- value={
2264
- dataField.conditional_value
2265
- }
2266
- type="text"
2267
- onChange={handleChangeForm}
2268
- />
2269
- <span className={styles.fi__span}>
2270
- Value
2271
- </span>
2272
- </label>
2273
- </div>
2243
+ <ConditionalDisplayEditor
2244
+ currentField={dataField}
2245
+ allFields={data.detail || []}
2246
+ fieldTypes={fieldTypes}
2247
+ onChange={(patch) =>
2248
+ setDataField((prev) => ({
2249
+ ...prev,
2250
+ ...patch,
2251
+ }))
2252
+ }
2253
+ />
2274
2254
 
2275
2255
  <div
2276
2256
  className={`${styles.formItem} ${styles.fwItem} ${styles.lastItem}`}