@visns-studio/visns-components 6.0.1 → 6.0.3

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.
@@ -1202,12 +1202,28 @@ function Form({
1202
1202
  let validation = '';
1203
1203
 
1204
1204
  if (['create', 'update'].includes(formType)) {
1205
+ // A group modal renders only the handful of fields it
1206
+ // names, so the rest of the form's required fields are
1207
+ // not on screen for the user to fill — validating them
1208
+ // would make the modal impossible to save
1209
+ const modalFieldIds =
1210
+ bulkEditMode &&
1211
+ bulkEditGroupData?.iconConfig?.formModal?.fields
1212
+ ? new Set(
1213
+ bulkEditGroupData.iconConfig.formModal.fields
1214
+ )
1215
+ : null;
1216
+
1205
1217
  fields.forEach((item) => {
1206
1218
  // Skip createOnly fields if formType is 'update'
1207
1219
  if (formType === 'update' && item.createOnly) {
1208
1220
  return;
1209
1221
  }
1210
1222
 
1223
+ if (modalFieldIds && !modalFieldIds.has(item.id)) {
1224
+ return;
1225
+ }
1226
+
1211
1227
  // Check if the item is required
1212
1228
  if (item.required) {
1213
1229
  // Find the corresponding Field component to check for auto values
@@ -1573,6 +1589,20 @@ function Form({
1573
1589
  bulkEditGroupData.iconConfig.formModal;
1574
1590
  url = formModalConfig.url || url;
1575
1591
  method = formModalConfig.method || 'POST';
1592
+
1593
+ // `anchorRow` modals act on the group through
1594
+ // one of its real records (the endpoint reads
1595
+ // the rest of the group off that row), so the
1596
+ // id goes in the path like an ordinary update
1597
+ if (formModalConfig.anchorRow) {
1598
+ const anchorRow =
1599
+ bulkEditGroupData.groupRows?.[0];
1600
+ url += `/${
1601
+ anchorRow?.[
1602
+ formSettings.primaryKey || 'id'
1603
+ ]
1604
+ }`;
1605
+ }
1576
1606
  } else {
1577
1607
  switch (formType) {
1578
1608
  case 'create':
@@ -1853,16 +1883,39 @@ function Form({
1853
1883
 
1854
1884
  // Add bulk edit data to payload if in bulk edit mode
1855
1885
  if (bulkEditMode && bulkEditGroupData) {
1856
- payload = {
1857
- ...payload,
1858
- bulkEdit: true,
1859
- groupValue: bulkEditGroupData.groupValue,
1860
- groupKey:
1861
- bulkEditGroupData.iconConfig.formModal
1862
- ?.groupKey || ajaxSetting?.groupBy?.[0],
1863
- rowIds: bulkEditGroupData.groupRowIds,
1864
- ...bulkEditGroupData.iconConfig.formModal?.data,
1865
- };
1886
+ const formModalConfig =
1887
+ bulkEditGroupData.iconConfig.formModal;
1888
+
1889
+ if (formModalConfig?.anchorRow) {
1890
+ // A plain record update: send only what the
1891
+ // modal asked for, or the untouched blanks of
1892
+ // every other form field would wipe the row
1893
+ const allowed = new Set([
1894
+ ...(formModalConfig.fields || []),
1895
+ ...Object.keys(formModalConfig.data || {}),
1896
+ ]);
1897
+
1898
+ payload = Object.keys(payload).reduce(
1899
+ (acc, key) => {
1900
+ if (allowed.has(key)) {
1901
+ acc[key] = payload[key];
1902
+ }
1903
+ return acc;
1904
+ },
1905
+ { ...formModalConfig.data }
1906
+ );
1907
+ } else {
1908
+ payload = {
1909
+ ...payload,
1910
+ bulkEdit: true,
1911
+ groupValue: bulkEditGroupData.groupValue,
1912
+ groupKey:
1913
+ formModalConfig?.groupKey ||
1914
+ ajaxSetting?.groupBy?.[0],
1915
+ rowIds: bulkEditGroupData.groupRowIds,
1916
+ ...formModalConfig?.data,
1917
+ };
1918
+ }
1866
1919
  }
1867
1920
 
1868
1921
  const res = await CustomFetch(url, method, payload);
@@ -2416,13 +2469,83 @@ function Form({
2416
2469
  toast.error('Failed to fetch data:', error);
2417
2470
  }
2418
2471
  } else {
2472
+ const formModalConfig =
2473
+ bulkEditGroupData?.iconConfig?.formModal || null;
2474
+
2475
+ // Anchor-row modals submit against one real record, and the
2476
+ // endpoint reads that record's current values back out of the
2477
+ // payload (adding a header means "these plus this new one"), so
2478
+ // seed from the anchor rather than from what the group shares.
2479
+ // `seedFields` adds rows values a field needs but does not submit
2480
+ // — e.g. the job id a conflict check excludes itself by.
2481
+ if (
2482
+ formModalConfig?.anchorRow &&
2483
+ bulkEditGroupData?.groupRows?.length
2484
+ ) {
2485
+ const anchorRow = bulkEditGroupData.groupRows[0];
2486
+ const seedIds = [
2487
+ ...(formModalConfig.fields || []),
2488
+ ...(formModalConfig.seedFields || []),
2489
+ ];
2490
+
2491
+ seedIds.forEach((id) => {
2492
+ const field = formSettings.fields.find((f) => f.id === id);
2493
+ const relationKey = field?.relation?.[0];
2494
+ const relationValue = relationKey
2495
+ ? anchorRow[relationKey]
2496
+ : undefined;
2497
+
2498
+ if (Array.isArray(relationValue)) {
2499
+ // The modal represents the whole group, but each
2500
+ // member row may hold only its own slice of the
2501
+ // relation (a split member carries one header), so
2502
+ // seed the union across every row, deduped. The
2503
+ // grow endpoint ignores ids a member already holds.
2504
+ const keyOf = (entry) =>
2505
+ entry[field.relation_from || 'id'];
2506
+ const seen = new Set();
2507
+
2508
+ _formData[id] = bulkEditGroupData.groupRows
2509
+ .flatMap((row) =>
2510
+ Array.isArray(row[relationKey])
2511
+ ? row[relationKey]
2512
+ : []
2513
+ )
2514
+ .filter((entry) => {
2515
+ if (seen.has(keyOf(entry))) {
2516
+ return false;
2517
+ }
2518
+ seen.add(keyOf(entry));
2519
+ return true;
2520
+ })
2521
+ .map((entry) => ({
2522
+ value: keyOf(entry),
2523
+ label:
2524
+ entry[field.relationName] ??
2525
+ entry.label ??
2526
+ entry.name,
2527
+ // Existing memberships are locked in the
2528
+ // modal — it can only add, never take away
2529
+ isFixed: true,
2530
+ }));
2531
+ return;
2532
+ }
2533
+
2534
+ if (anchorRow[id] !== undefined) {
2535
+ _formData[id] = anchorRow[id];
2536
+ }
2537
+ });
2538
+
2539
+ setFormData(_formData);
2540
+ return;
2541
+ }
2542
+
2419
2543
  // Bulk edit opens without a record fetch (columnId is null), so
2420
2544
  // seed each field with the value the group rows already share —
2421
2545
  // otherwise saved values (e.g. an assigned user) render blank and
2422
2546
  // required fields force a re-pick on every open.
2423
2547
  if (bulkEditMode && bulkEditGroupData?.groupRows?.length) {
2424
- const modalFields =
2425
- bulkEditGroupData.iconConfig?.formModal?.fields || [];
2548
+ const modalFields = formModalConfig?.fields || [];
2426
2549
 
2427
2550
  formSettings.fields.forEach((item) => {
2428
2551
  const { id, type, parent } = item;
@@ -3418,6 +3541,20 @@ function Form({
3418
3541
  formData
3419
3542
  )
3420
3543
  : false;
3544
+ // A group modal whose save means one
3545
+ // narrow thing (e.g. "add a header")
3546
+ // must not also offer the stage-
3547
+ // completing saveAnother, which would
3548
+ // fire against every row in the group
3549
+ const showSaveAnother =
3550
+ formSettings.update?.hasOwnProperty(
3551
+ 'saveAnother'
3552
+ ) &&
3553
+ !(
3554
+ bulkEditMode &&
3555
+ bulkEditGroupData?.iconConfig
3556
+ ?.formModal?.hideSaveAnother
3557
+ );
3421
3558
 
3422
3559
  return (
3423
3560
  <>
@@ -3431,9 +3568,7 @@ function Form({
3431
3568
  >
3432
3569
  Save & Close
3433
3570
  </button>
3434
- {formSettings.update?.hasOwnProperty(
3435
- 'saveAnother'
3436
- ) && (
3571
+ {showSaveAnother && (
3437
3572
  <button
3438
3573
  className={
3439
3574
  styles.btn
@@ -64,6 +64,13 @@ const CustomMultiValue = (props) => {
64
64
 
65
65
  // Custom MultiValueRemove component with proper removal handling
66
66
  const CustomMultiValueRemove = (props) => {
67
+ // Fixed values cannot be deselected: render no × at all — the module
68
+ // SCSS forces `display` on the remove button with !important, so
69
+ // hiding it from the styles function is not enough.
70
+ if (props.data?.isFixed) {
71
+ return null;
72
+ }
73
+
67
74
  const handleRemove = (e) => {
68
75
  e.stopPropagation();
69
76
 
@@ -334,7 +341,27 @@ function MultiSelect({
334
341
  action: action
335
342
  });
336
343
  }
337
-
344
+
345
+ // Values seeded with isFixed represent memberships this
346
+ // field is not allowed to take away (e.g. a tag's existing
347
+ // headers in an add-only group modal): ignore their removal
348
+ // and survive a clear-all.
349
+ if (
350
+ ['remove-value', 'pop-value'].includes(action.action) &&
351
+ action.removedValue?.isFixed
352
+ ) {
353
+ return;
354
+ }
355
+
356
+ if (action.action === 'clear') {
357
+ const fixed = selectValue.filter((v) => v?.isFixed);
358
+
359
+ if (fixed.length) {
360
+ onChange(fixed, action, settings.id);
361
+ return;
362
+ }
363
+ }
364
+
338
365
  // Special handling for limit=1: keep only the most recent selection
339
366
  if (
340
367
  settings.limit === 1 &&
@@ -435,7 +462,8 @@ function MultiSelect({
435
462
  multiValueRemove: (base, state) => ({
436
463
  ...base,
437
464
  padding: '0 4px',
438
- display: 'flex',
465
+ // Fixed values cannot be deselected, so they get no ×
466
+ display: state.data?.isFixed ? 'none' : 'flex',
439
467
  alignItems: 'center',
440
468
  justifyContent: 'center',
441
469
  margin: '0',
@@ -357,7 +357,7 @@
357
357
 
358
358
  /* Add custom styles to override react-grid-gallery */
359
359
  :global {
360
- /* These styles will be applied globally */
360
+ // These styles will be applied globally
361
361
  .ReactGridGallery_tile {
362
362
  border-radius: 6px !important;
363
363
  overflow: hidden !important;
@@ -508,7 +508,7 @@
508
508
 
509
509
  /* Date field styling for uniform width */
510
510
  :global {
511
- /* Target date cells specifically */
511
+ // Target date cells specifically
512
512
  .InovuaReactDataGrid__cell[data-column-id*='date'],
513
513
  .InovuaReactDataGrid__cell[data-column-id*='Date'] {
514
514
  min-width: 120px !important;
@@ -516,7 +516,7 @@
516
516
  max-width: 120px !important;
517
517
  }
518
518
 
519
- /* Target datetime cells specifically */
519
+ // Target datetime cells specifically
520
520
  .InovuaReactDataGrid__cell[data-column-id*='datetime'],
521
521
  .InovuaReactDataGrid__cell[data-column-id*='Datetime'] {
522
522
  min-width: 160px !important;
@@ -524,7 +524,7 @@
524
524
  max-width: 160px !important;
525
525
  }
526
526
 
527
- /* Target daterange cells specifically */
527
+ // Target daterange cells specifically
528
528
  .InovuaReactDataGrid__cell[data-column-id*='daterange'] {
529
529
  min-width: 200px !important;
530
530
  width: 200px !important;
@@ -1048,6 +1048,24 @@
1048
1048
  margin-left: 4px;
1049
1049
  }
1050
1050
 
1051
+ // treeGroupBy parent rows: the group's controls live in an ordinary grid
1052
+ // cell, so keep the buttons on one line and let the label take what is left
1053
+ .treeParentHeader {
1054
+ display: flex;
1055
+ align-items: center;
1056
+ gap: 2px;
1057
+ width: 100%;
1058
+ overflow: hidden;
1059
+ }
1060
+
1061
+ .treeParentLabel {
1062
+ font-weight: 700;
1063
+ white-space: nowrap;
1064
+ overflow: hidden;
1065
+ text-overflow: ellipsis;
1066
+ margin-right: 6px;
1067
+ }
1068
+
1051
1069
  .groupValue {
1052
1070
  font-size: 0.85rem;
1053
1071
  color: var(--secondary-color, #1f2937);
@@ -91,14 +91,14 @@
91
91
  overflow-y: auto !important;
92
92
  }
93
93
 
94
- /* Control styles */
94
+ // Control styles
95
95
  .visns-select__control {
96
96
  min-height: 50px !important;
97
97
  height: auto !important; /* Allow height to grow with content */
98
98
  overflow: visible !important;
99
99
  }
100
100
 
101
- /* Value container styles */
101
+ // Value container styles
102
102
  .visns-select__value-container {
103
103
  padding: 2px 8px !important;
104
104
  flex-wrap: wrap !important;
@@ -106,7 +106,7 @@
106
106
  overflow-y: auto !important; /* Add scrolling when needed */
107
107
  }
108
108
 
109
- /* Indicator styles */
109
+ // Indicator styles
110
110
  .visns-select__indicators {
111
111
  display: flex !important;
112
112
  align-items: center !important;
@@ -130,7 +130,7 @@
130
130
  margin-left: 2px !important;
131
131
  }
132
132
 
133
- /* Multi-value styles */
133
+ // Multi-value styles
134
134
  .visns-select__multi-value {
135
135
  margin: 2px !important;
136
136
  padding: 2px 4px !important;
@@ -187,7 +187,7 @@
187
187
  transform: scale(0.95) !important;
188
188
  }
189
189
 
190
- /* Fix for modals */
190
+ // Fix for modals
191
191
  .popup-content {
192
192
  overflow: visible !important;
193
193
  }
@@ -196,7 +196,7 @@
196
196
  overflow: visible !important;
197
197
  }
198
198
 
199
- /* CSS class fixes for React Select */
199
+ // CSS class fixes for React Select
200
200
  .css-13cymwt-control,
201
201
  .css-t3ipsp-control {
202
202
  min-height: 50px !important;
@@ -204,7 +204,7 @@
204
204
  overflow: visible !important;
205
205
  }
206
206
 
207
- /* Option styling for description */
207
+ // Option styling for description
208
208
  .visns-select__option {
209
209
  padding: 8px 12px !important;
210
210
  }
@@ -216,7 +216,7 @@
216
216
  overflow-y: auto !important;
217
217
  }
218
218
 
219
- /* Indicators container */
219
+ // Indicators container
220
220
  .css-1hb7zxy-IndicatorsContainer {
221
221
  display: flex !important;
222
222
  align-items: center !important;
@@ -224,7 +224,7 @@
224
224
  height: auto !important;
225
225
  }
226
226
 
227
- /* Individual indicators */
227
+ // Individual indicators
228
228
  .css-1xc3v61-indicatorContainer,
229
229
  .css-15lsz6c-indicatorContainer {
230
230
  padding: 8px !important;
@@ -233,7 +233,7 @@
233
233
  align-self: stretch !important;
234
234
  }
235
235
 
236
- /* Separator */
236
+ // Separator
237
237
  .css-1u9des2-indicatorSeparator {
238
238
  align-self: stretch !important;
239
239
  margin: 4px 0 !important;
@@ -316,18 +316,22 @@ input[type]:not([type='search']):not([type='url']):not([type='hidden']):not(
316
316
  border-width: 1px;
317
317
  box-sizing: border-box;
318
318
  box-shadow: none !important;
319
+ }
319
320
 
320
- &--is-focused {
321
- border-color: var(--primary-color) !important;
322
- box-shadow: none !important;
323
- border-width: 1px !important;
324
- }
321
+ /* These were written as `&--is-focused` / `&--menu-is-open` nested inside the
322
+ rule above. That is Sass syntax — this is a plain .css file, and CSS nesting
323
+ cannot concatenate a suffix onto the parent selector, so both rules were
324
+ invalid and the focus/menu-open border colour never applied. */
325
+ .visns-select__control--is-focused {
326
+ border-color: var(--primary-color) !important;
327
+ box-shadow: none !important;
328
+ border-width: 1px !important;
329
+ }
325
330
 
326
- &--menu-is-open {
327
- border-color: var(--primary-color) !important;
328
- box-shadow: none !important;
329
- border-width: 1px !important;
330
- }
331
+ .visns-select__control--menu-is-open {
332
+ border-color: var(--primary-color) !important;
333
+ box-shadow: none !important;
334
+ border-width: 1px !important;
331
335
  }
332
336
 
333
337
  .visns-select__menu {