@visns-studio/visns-components 5.19.0 → 5.21.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.19.0",
97
+ "version": "5.21.0",
98
98
  "description": "Various packages to assist in the development of our Custom Applications.",
99
99
  "main": "src/index.js",
100
100
  "files": [
@@ -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 (
@@ -8,6 +8,8 @@ import {
8
8
  Users,
9
9
  User,
10
10
  Search,
11
+ UserSearch,
12
+ ClipboardCheck,
11
13
  LogOut,
12
14
  Shield,
13
15
  Image,
@@ -236,6 +238,9 @@ function Navigation({
236
238
  phone: Phone,
237
239
  shield: Shield,
238
240
  signOut: LogOut,
241
+ search: Search,
242
+ userSearch: UserSearch,
243
+ clipboardCheck: ClipboardCheck,
239
244
  };
240
245
 
241
246
  // Check if the setting's URL matches the current page
@@ -302,7 +307,9 @@ function Navigation({
302
307
  target={n.target ? n.target : '_self'}
303
308
  title={n.label}
304
309
  >
305
- <IconComponent size={19} strokeWidth={2} />
310
+ {IconComponent && (
311
+ <IconComponent size={19} strokeWidth={2} />
312
+ )}
306
313
  </Link>
307
314
  </li>
308
315
  );
@@ -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
  );