@visns-studio/visns-components 5.18.1 → 5.20.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 CHANGED
@@ -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.18.1",
97
+ "version": "5.20.0",
98
98
  "description": "Various packages to assist in the development of our Custom Applications.",
99
99
  "main": "src/index.js",
100
100
  "files": [
@@ -2800,6 +2800,27 @@ const DataGrid = forwardRef(
2800
2800
  return { ...acc, ...result };
2801
2801
  }, {});
2802
2802
 
2803
+ // Settings that an 'action' column renders inline render there
2804
+ // instead of the trailing Action column (avoids duplication).
2805
+ const actionColumnSettingIds = new Set(
2806
+ (columns || [])
2807
+ .filter(
2808
+ (c) =>
2809
+ c.type === 'action' &&
2810
+ Array.isArray(c.settings)
2811
+ )
2812
+ .flatMap((c) => c.settings)
2813
+ );
2814
+
2815
+ // Width to fit N action icons: tablet-mode icons have a larger
2816
+ // tap target/margin (~44px) than the default (~34px), plus cell
2817
+ // padding. Used by both action columns and the trailing column.
2818
+ const actionColumnWidthFor = (count) =>
2819
+ Math.max(
2820
+ 100,
2821
+ count * (isTabletMode ? 44 : 34) + 16
2822
+ );
2823
+
2803
2824
  const renderColumn = (column) => {
2804
2825
  let childValue;
2805
2826
  let columnId;
@@ -2951,6 +2972,37 @@ const DataGrid = forwardRef(
2951
2972
  }
2952
2973
 
2953
2974
  switch (column.type) {
2975
+ case 'action': {
2976
+ const actionSettings = (column.settings || [])
2977
+ .map((id) =>
2978
+ memoizedSettings.find(
2979
+ (s) => s.id === id
2980
+ )
2981
+ )
2982
+ .filter(Boolean);
2983
+ const width = actionColumnWidthFor(
2984
+ actionSettings.length
2985
+ );
2986
+ return {
2987
+ ...commonProps,
2988
+ name: Array.isArray(column.id)
2989
+ ? column.id.join('_')
2990
+ : column.id,
2991
+ header: column.label ?? '',
2992
+ sortable: false,
2993
+ defaultFlex: undefined,
2994
+ defaultWidth: width,
2995
+ minWidth: width,
2996
+ textAlign: 'center',
2997
+ render: ({ data }) => (
2998
+ <div className={styles.tdactions}>
2999
+ {actionSettings.map((setting) =>
3000
+ renderSetting(setting, data)
3001
+ )}
3002
+ </div>
3003
+ ),
3004
+ };
3005
+ }
2954
3006
  case 'address':
2955
3007
  return renderAddressColumn({
2956
3008
  column,
@@ -3283,37 +3335,42 @@ const DataGrid = forwardRef(
3283
3335
 
3284
3336
  const newColumns = columns.map(renderColumn);
3285
3337
 
3286
- // Function to check if any settings would be visible for any row
3287
- const hasVisibleSettings = () => {
3288
- if (memoizedSettings.length === 0) return false;
3289
-
3290
- // Check if any setting would be visible based on role permissions
3291
- return memoizedSettings.some((setting) => {
3292
- // Check role-based permissions
3338
+ // Count settings that could render in the trailing Action
3339
+ // column: role-permitted AND not claimed by an 'action' column.
3340
+ // Per-row conditions (active/condition/hide) may hide more at
3341
+ // runtime, but the column must fit the maximum case.
3342
+ const trailingSettings = memoizedSettings.filter(
3343
+ (setting) => {
3344
+ if (actionColumnSettingIds.has(setting.id)) {
3345
+ return false;
3346
+ }
3293
3347
  if (
3294
3348
  setting.roles &&
3295
3349
  Array.isArray(setting.roles) &&
3296
3350
  setting.roles.length > 0
3297
3351
  ) {
3298
- const hasRequiredRole = setting.roles.some(
3299
- (requiredRole) =>
3300
- userProfile?.roles?.some(
3301
- (userRole) =>
3302
- userRole.name === requiredRole
3303
- )
3352
+ return setting.roles.some((requiredRole) =>
3353
+ userProfile?.roles?.some(
3354
+ (userRole) =>
3355
+ userRole.name === requiredRole
3356
+ )
3304
3357
  );
3305
- return hasRequiredRole;
3306
3358
  }
3307
3359
  // If no roles specified, setting is visible
3308
3360
  return true;
3309
- });
3310
- };
3361
+ }
3362
+ );
3363
+
3364
+ if (trailingSettings.length > 0) {
3365
+ const actionColumnWidth = actionColumnWidthFor(
3366
+ trailingSettings.length
3367
+ );
3311
3368
 
3312
- if (hasVisibleSettings()) {
3313
3369
  newColumns.push({
3314
3370
  name: 'setting',
3315
3371
  header: 'Action',
3316
- defaultWidth: 100,
3372
+ defaultWidth: actionColumnWidth,
3373
+ minWidth: actionColumnWidth,
3317
3374
  textAlign: 'center',
3318
3375
  textVerticalAlign:
3319
3376
  style && style.text_vertical_align
@@ -3322,9 +3379,16 @@ const DataGrid = forwardRef(
3322
3379
  render: ({ data }) => {
3323
3380
  return (
3324
3381
  <div className={styles.tdactions}>
3325
- {memoizedSettings.map((setting) =>
3326
- renderSetting(setting, data)
3327
- )}
3382
+ {memoizedSettings
3383
+ .filter(
3384
+ (setting) =>
3385
+ !actionColumnSettingIds.has(
3386
+ setting.id
3387
+ )
3388
+ )
3389
+ .map((setting) =>
3390
+ renderSetting(setting, data)
3391
+ )}
3328
3392
  </div>
3329
3393
  );
3330
3394
  },
@@ -3405,7 +3469,7 @@ const DataGrid = forwardRef(
3405
3469
  .catch((error) => {
3406
3470
  console.error('Error in fetching dropdown data: ', error);
3407
3471
  });
3408
- }, [columns, filterDataSource, memoizedSettings]);
3472
+ }, [columns, filterDataSource, memoizedSettings, isTabletMode]);
3409
3473
 
3410
3474
  // Force re-render when filterDataSource updates to ensure dropdown filters get their data
3411
3475
  const [renderKey, setRenderKey] = useState(0);
@@ -44,6 +44,37 @@ import DatePickerPortal from './utils/DatePickerPortal';
44
44
 
45
45
  import 'react-toggle/style.css';
46
46
  import 'react-datepicker/dist/react-datepicker.css';
47
+
48
+ /**
49
+ * Insert `token` at the textarea's caret (or replacing its selection) and
50
+ * fire a native input event so the controlled React value updates. Restores
51
+ * the caret just after the inserted token. Used by textarea fields that
52
+ * declare a `variables` list (click-to-insert).
53
+ */
54
+ function insertVariableAtCursor(el, token) {
55
+ if (!el) return;
56
+ const start = el.selectionStart ?? el.value.length;
57
+ const end = el.selectionEnd ?? el.value.length;
58
+ const next = el.value.slice(0, start) + token + el.value.slice(end);
59
+ const setter = Object.getOwnPropertyDescriptor(
60
+ window.HTMLTextAreaElement.prototype,
61
+ 'value'
62
+ )?.set;
63
+ if (setter) {
64
+ setter.call(el, next);
65
+ el.dispatchEvent(new Event('input', { bubbles: true }));
66
+ }
67
+ const caret = start + token.length;
68
+ requestAnimationFrame(() => {
69
+ el.focus();
70
+ try {
71
+ el.setSelectionRange(caret, caret);
72
+ } catch (e) {
73
+ /* noop */
74
+ }
75
+ });
76
+ }
77
+
47
78
  function Field({
48
79
  api,
49
80
  autocompleteCallback,
@@ -99,6 +130,7 @@ function Field({
99
130
  const [lineWidth, setLineWidth] = useState(2);
100
131
  const [lineColor, setLineColor] = useState('rgba(0, 0, 0, 1)');
101
132
  const sketchRef = useRef(null);
133
+ const variableTextareaRef = useRef(null);
102
134
 
103
135
  /** Email Functions */
104
136
  const validateEmail = (email) => {
@@ -2566,17 +2598,65 @@ function Field({
2566
2598
  };
2567
2599
 
2568
2600
  return (
2569
- <textarea
2570
- data-name={settings.id}
2571
- className={inputClass[settings.id]}
2572
- onChange={onChange}
2573
- value={
2574
- inputValue && inputValue !== 'null'
2575
- ? inputValue
2576
- : ''
2577
- }
2578
- rows={getTextareaRows(settings.textareaHeight)}
2579
- ></textarea>
2601
+ <>
2602
+ {Array.isArray(settings.variables) &&
2603
+ settings.variables.length > 0 && (
2604
+ <div
2605
+ style={{
2606
+ display: 'flex',
2607
+ flexWrap: 'wrap',
2608
+ gap: '6px',
2609
+ marginBottom: '8px',
2610
+ }}
2611
+ >
2612
+ {settings.variables.map((v) => {
2613
+ const token = v.token ?? v;
2614
+ return (
2615
+ <button
2616
+ type="button"
2617
+ key={token}
2618
+ title={`Insert ${token}`}
2619
+ onClick={() =>
2620
+ insertVariableAtCursor(
2621
+ variableTextareaRef.current,
2622
+ token
2623
+ )
2624
+ }
2625
+ style={{
2626
+ cursor: 'pointer',
2627
+ fontSize: '12px',
2628
+ fontWeight: 500,
2629
+ padding: '3px 9px',
2630
+ borderRadius: '999px',
2631
+ border:
2632
+ '1px solid rgba(var(--primary-rgb), 0.3)',
2633
+ background:
2634
+ 'rgba(var(--primary-rgb), 0.06)',
2635
+ color: 'var(--primary-color)',
2636
+ }}
2637
+ >
2638
+ {v.label ?? token}
2639
+ </button>
2640
+ );
2641
+ })}
2642
+ </div>
2643
+ )}
2644
+ <textarea
2645
+ ref={variableTextareaRef}
2646
+ data-name={settings.id}
2647
+ className={inputClass[settings.id]}
2648
+ onChange={onChange}
2649
+ value={
2650
+ inputValue && inputValue !== 'null'
2651
+ ? inputValue
2652
+ : ''
2653
+ }
2654
+ rows={
2655
+ settings.rows ||
2656
+ getTextareaRows(settings.textareaHeight)
2657
+ }
2658
+ ></textarea>
2659
+ </>
2580
2660
  );
2581
2661
  case 'text':
2582
2662
  return (
@@ -1145,17 +1145,58 @@ export const renderOptionColumn = ({
1145
1145
  let newData;
1146
1146
 
1147
1147
  if (data && column.options && column.options[data[column.id]]) {
1148
+ const rawValue = data[column.id];
1149
+
1148
1150
  if (
1149
1151
  column.key &&
1150
1152
  data[column.key] > 0 &&
1151
- column.options[data[column.id]].includes('#')
1153
+ column.options[rawValue].includes('#')
1152
1154
  ) {
1153
- newData = column.options[data[column.id]].replace(
1155
+ newData = column.options[rawValue].replace(
1154
1156
  /#/g,
1155
1157
  data[column.key]
1156
1158
  );
1157
1159
  } else {
1158
- newData = column.options[data[column.id]];
1160
+ newData = column.options[rawValue];
1161
+ }
1162
+
1163
+ // Coloured badge variant — opt in via column.badge. Per-value
1164
+ // colours come from column.badgeColours[rawValue] ({ bg, fg,
1165
+ // border }); unmapped values fall back to a neutral pill.
1166
+ // Without column.badge the original plain-text behaviour is
1167
+ // unchanged (backward compatible).
1168
+ if (column.badge) {
1169
+ const palette =
1170
+ (column.badgeColours &&
1171
+ column.badgeColours[rawValue]) || {
1172
+ bg: '#eceff1',
1173
+ fg: '#37474f',
1174
+ };
1175
+
1176
+ return (
1177
+ <CellWithTooltip value={newData} columnType="option">
1178
+ <span
1179
+ style={{
1180
+ display: 'inline-block',
1181
+ padding: '2px 10px',
1182
+ borderRadius: '999px',
1183
+ fontSize: '11px',
1184
+ fontWeight: 600,
1185
+ lineHeight: 1.6,
1186
+ letterSpacing: '0.02em',
1187
+ textTransform: 'uppercase',
1188
+ whiteSpace: 'nowrap',
1189
+ backgroundColor: palette.bg,
1190
+ color: palette.fg,
1191
+ border: palette.border
1192
+ ? `1px solid ${palette.border}`
1193
+ : '1px solid transparent',
1194
+ }}
1195
+ >
1196
+ {newData}
1197
+ </span>
1198
+ </CellWithTooltip>
1199
+ );
1159
1200
  }
1160
1201
 
1161
1202
  return (
@@ -231,8 +231,9 @@ function GenericDetail({
231
231
  layout = 'full',
232
232
  setting,
233
233
  setActiveNav = null,
234
- urlParam,
234
+ urlParam = 'dataId',
235
235
  userProfile,
236
+ actions = null,
236
237
  }) {
237
238
  const gridRef = useRef(null);
238
239
  const currentConfigRef = useRef(null);
@@ -2828,6 +2829,17 @@ function GenericDetail({
2828
2829
  {!inPlaceEditing ? (
2829
2830
  <button
2830
2831
  className={styles.btn}
2832
+ title="Edit"
2833
+ aria-label="Edit"
2834
+ style={{
2835
+ display: 'inline-flex',
2836
+ alignItems: 'center',
2837
+ justifyContent:
2838
+ 'center',
2839
+ gap: '7px',
2840
+ minWidth: '92px',
2841
+ height: '42px',
2842
+ }}
2831
2843
  onClick={() => {
2832
2844
  if (
2833
2845
  activeTabConfig.form
@@ -2846,7 +2858,11 @@ function GenericDetail({
2846
2858
  }
2847
2859
  }}
2848
2860
  >
2849
- Edit
2861
+ <Pencil
2862
+ size={18}
2863
+ strokeWidth={2.25}
2864
+ />
2865
+ <span>Edit</span>
2850
2866
  </button>
2851
2867
  ) : (
2852
2868
  editingTabId ===
@@ -3786,11 +3802,22 @@ function GenericDetail({
3786
3802
  }`}
3787
3803
  >
3788
3804
  <Breadcrumb data={data} page={page} />
3789
- {total > 0 && (
3790
- <div className={styles.titleInfo}>
3791
- <span>
3792
- [<strong>{total}</strong> Total]
3793
- </span>
3805
+ {(actions || total > 0) && (
3806
+ <div
3807
+ style={{
3808
+ display: 'flex',
3809
+ alignItems: 'center',
3810
+ gap: '0.75rem',
3811
+ }}
3812
+ >
3813
+ {total > 0 && (
3814
+ <div className={styles.titleInfo}>
3815
+ <span>
3816
+ [<strong>{total}</strong> Total]
3817
+ </span>
3818
+ </div>
3819
+ )}
3820
+ {actions}
3794
3821
  </div>
3795
3822
  )}
3796
3823
  </div>
@@ -1,7 +1,7 @@
1
1
  import '../styles/global.css';
2
2
 
3
- import React, { useCallback, useEffect, useInsertionEffect, useRef, useState } from 'react';
4
- import { useParams, Outlet } from 'react-router-dom';
3
+ import React, { useCallback, useEffect, useInsertionEffect, useMemo, useRef, useState } from 'react';
4
+ import { useParams, useSearchParams, Outlet } from 'react-router-dom';
5
5
  import { toast } from 'react-toastify';
6
6
  import { saveAs } from 'file-saver';
7
7
  import _ from 'lodash';
@@ -35,6 +35,61 @@ function useWindowDimensions() {
35
35
  return { windowHeight, windowWidth };
36
36
  }
37
37
 
38
+ /**
39
+ * Returns a copy of `filters` with the `show` flags adjusted so the subnav
40
+ * item (or child) whose id matches `tabId` is active, enabling deep-links
41
+ * such as /page?tab=tourFormats. If `tabId` is empty or matches nothing,
42
+ * the original filters are returned unchanged (default behaviour preserved).
43
+ */
44
+ function applyTabParam(filters, tabId) {
45
+ if (!filters || !tabId) {
46
+ return filters;
47
+ }
48
+
49
+ // The left subnav highlight is driven by each item's `class` (and
50
+ // `active`) — not `show` — so we must move all three. Reuse whatever
51
+ // class the currently-active item uses so we stay theme-consistent.
52
+ const activeClass =
53
+ filters.find((f) => f.show && f.class)?.class || 'subactive';
54
+ const activeChildClass =
55
+ filters
56
+ .flatMap((f) => f.children || [])
57
+ .find((c) => c.show && c.class)?.class || 'subactivechildren';
58
+
59
+ let matched = false;
60
+ const next = filters.map((f) => {
61
+ if (f.isParent && Array.isArray(f.children)) {
62
+ const childMatch = f.children.some((c) => c.id === tabId);
63
+ if (childMatch) matched = true;
64
+ return {
65
+ ...f,
66
+ show: childMatch,
67
+ active: childMatch,
68
+ class: childMatch ? f.class || activeClass : '',
69
+ children: f.children.map((c) => {
70
+ const isMatch = c.id === tabId;
71
+ return {
72
+ ...c,
73
+ show: isMatch,
74
+ active: isMatch,
75
+ class: isMatch ? c.class || activeChildClass : '',
76
+ };
77
+ }),
78
+ };
79
+ }
80
+ const isMatch = f.id === tabId;
81
+ if (isMatch) matched = true;
82
+ return {
83
+ ...f,
84
+ show: isMatch,
85
+ active: isMatch,
86
+ class: isMatch ? activeClass : '',
87
+ };
88
+ });
89
+
90
+ return matched ? next : filters;
91
+ }
92
+
38
93
  function useConfig(setting, tabs, filters, setRowsSelected, tableSetting) {
39
94
  const [config, setConfig] = useState({});
40
95
  const [subnav, setSubnav] = useState(filters || []);
@@ -258,10 +313,20 @@ function GenericIndex({
258
313
  };
259
314
  }, [setting?.tabletMode]);
260
315
 
316
+ // Honour a ?tab=<id> deep-link by seeding the active subnav item.
317
+ // Memoised so a stable reference is passed to useConfig (otherwise its
318
+ // reset effect would clobber the user's tab clicks every render).
319
+ const [searchParams] = useSearchParams();
320
+ const tabParam = searchParams.get('tab');
321
+ const effectiveFilters = useMemo(
322
+ () => applyTabParam(setting.filters, tabParam),
323
+ [setting.filters, tabParam]
324
+ );
325
+
261
326
  const { config, setConfig, subnav, setSubnav } = useConfig(
262
327
  setting,
263
328
  setting.tabs,
264
- setting.filters,
329
+ effectiveFilters,
265
330
  setRowsSelected,
266
331
  setting.tableSetting
267
332
  );