@visns-studio/visns-components 5.18.0 → 5.19.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.0",
97
+ "version": "5.19.0",
98
98
  "description": "Various packages to assist in the development of our Custom Applications.",
99
99
  "main": "src/index.js",
100
100
  "files": [
@@ -618,6 +618,32 @@ const DataGrid = forwardRef(
618
618
  };
619
619
  }, []);
620
620
 
621
+ // Track body.tablet-mode so we can fix rowHeight in tablet views.
622
+ // With rowHeight={null} Inovua measures each row's content, which is
623
+ // unreliable after auto-refresh on an idle tab (background-throttled
624
+ // rAF/timers). A fixed height in tablet-mode bypasses measurement
625
+ // entirely and prevents action buttons from being clipped.
626
+ const [isTabletMode, setIsTabletMode] = useState(() =>
627
+ typeof document !== 'undefined' &&
628
+ document.body.classList.contains('tablet-mode')
629
+ );
630
+ useEffect(() => {
631
+ if (typeof document === 'undefined') {
632
+ return undefined;
633
+ }
634
+ const sync = () =>
635
+ setIsTabletMode(
636
+ document.body.classList.contains('tablet-mode')
637
+ );
638
+ sync();
639
+ const observer = new MutationObserver(sync);
640
+ observer.observe(document.body, {
641
+ attributes: true,
642
+ attributeFilter: ['class'],
643
+ });
644
+ return () => observer.disconnect();
645
+ }, []);
646
+
621
647
  // Effect to handle autoRefresh setting
622
648
  useEffect(() => {
623
649
  if (ajaxSetting && ajaxSetting.autoRefresh !== undefined) {
@@ -1614,6 +1640,35 @@ const DataGrid = forwardRef(
1614
1640
  const sortBy = id || null;
1615
1641
  const sort = dir === 1 ? 'asc' : dir === -1 ? 'desc' : null;
1616
1642
 
1643
+ // Mirror sort to the URL when opted in via ajaxSetting.syncSortToUrl.
1644
+ // Uses replaceState so sort changes don't pollute back-button history.
1645
+ // Router-library-agnostic — works regardless of whether the consumer
1646
+ // uses react-router, next router, or vanilla history.
1647
+ if (prevState.ajaxSetting?.syncSortToUrl && typeof window !== 'undefined') {
1648
+ try {
1649
+ const params = new URLSearchParams(window.location.search);
1650
+ if (sortBy && sort) {
1651
+ params.set('sortBy', sortBy);
1652
+ params.set('sort', sort);
1653
+ } else {
1654
+ params.delete('sortBy');
1655
+ params.delete('sort');
1656
+ }
1657
+ const qs = params.toString();
1658
+ const url =
1659
+ window.location.pathname +
1660
+ (qs ? '?' + qs : '') +
1661
+ window.location.hash;
1662
+ window.history.replaceState(
1663
+ window.history.state,
1664
+ '',
1665
+ url
1666
+ );
1667
+ } catch (e) {
1668
+ // URL sync is best-effort; never block sort behaviour
1669
+ }
1670
+ }
1671
+
1617
1672
  return {
1618
1673
  ...prevState,
1619
1674
  ajaxSetting: {
@@ -1626,6 +1681,51 @@ const DataGrid = forwardRef(
1626
1681
  }
1627
1682
  };
1628
1683
 
1684
+ /**
1685
+ * Hydrate sort state from URL on first mount when ajaxSetting.syncSortToUrl
1686
+ * is true. Trade-off: the initial fetch may use the default (config) sort
1687
+ * before this effect re-fires the fetch with URL-derived sort. For typical
1688
+ * paginated admin tables that one-extra-fetch is acceptable; the user sees
1689
+ * a brief flash of default-sorted data, then it resorts.
1690
+ */
1691
+ const urlSortHydratedRef = useRef(false);
1692
+ useEffect(() => {
1693
+ if (
1694
+ !ajaxSetting?.syncSortToUrl ||
1695
+ urlSortHydratedRef.current ||
1696
+ typeof window === 'undefined'
1697
+ ) {
1698
+ return;
1699
+ }
1700
+ urlSortHydratedRef.current = true;
1701
+ let urlSortBy = null;
1702
+ let urlSort = null;
1703
+ try {
1704
+ const params = new URLSearchParams(window.location.search);
1705
+ urlSortBy = params.get('sortBy');
1706
+ urlSort = params.get('sort');
1707
+ } catch (e) {
1708
+ return;
1709
+ }
1710
+ if (!urlSortBy || (urlSort !== 'asc' && urlSort !== 'desc')) return;
1711
+ if (
1712
+ urlSortBy === ajaxSetting.sortBy &&
1713
+ urlSort === ajaxSetting.sort
1714
+ ) {
1715
+ return;
1716
+ }
1717
+ if (setConfig) {
1718
+ setConfig((prev) => ({
1719
+ ...prev,
1720
+ ajaxSetting: {
1721
+ ...prev.ajaxSetting,
1722
+ sortBy: urlSortBy,
1723
+ sort: urlSort,
1724
+ },
1725
+ }));
1726
+ }
1727
+ }, [ajaxSetting?.syncSortToUrl]);
1728
+
1629
1729
  /**
1630
1730
  * DEBUG: Row Click Analysis
1631
1731
  *
@@ -2700,6 +2800,27 @@ const DataGrid = forwardRef(
2700
2800
  return { ...acc, ...result };
2701
2801
  }, {});
2702
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
+
2703
2824
  const renderColumn = (column) => {
2704
2825
  let childValue;
2705
2826
  let columnId;
@@ -2851,6 +2972,37 @@ const DataGrid = forwardRef(
2851
2972
  }
2852
2973
 
2853
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
+ }
2854
3006
  case 'address':
2855
3007
  return renderAddressColumn({
2856
3008
  column,
@@ -3183,37 +3335,42 @@ const DataGrid = forwardRef(
3183
3335
 
3184
3336
  const newColumns = columns.map(renderColumn);
3185
3337
 
3186
- // Function to check if any settings would be visible for any row
3187
- const hasVisibleSettings = () => {
3188
- if (memoizedSettings.length === 0) return false;
3189
-
3190
- // Check if any setting would be visible based on role permissions
3191
- return memoizedSettings.some((setting) => {
3192
- // 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
+ }
3193
3347
  if (
3194
3348
  setting.roles &&
3195
3349
  Array.isArray(setting.roles) &&
3196
3350
  setting.roles.length > 0
3197
3351
  ) {
3198
- const hasRequiredRole = setting.roles.some(
3199
- (requiredRole) =>
3200
- userProfile?.roles?.some(
3201
- (userRole) =>
3202
- userRole.name === requiredRole
3203
- )
3352
+ return setting.roles.some((requiredRole) =>
3353
+ userProfile?.roles?.some(
3354
+ (userRole) =>
3355
+ userRole.name === requiredRole
3356
+ )
3204
3357
  );
3205
- return hasRequiredRole;
3206
3358
  }
3207
3359
  // If no roles specified, setting is visible
3208
3360
  return true;
3209
- });
3210
- };
3361
+ }
3362
+ );
3363
+
3364
+ if (trailingSettings.length > 0) {
3365
+ const actionColumnWidth = actionColumnWidthFor(
3366
+ trailingSettings.length
3367
+ );
3211
3368
 
3212
- if (hasVisibleSettings()) {
3213
3369
  newColumns.push({
3214
3370
  name: 'setting',
3215
3371
  header: 'Action',
3216
- defaultWidth: 100,
3372
+ defaultWidth: actionColumnWidth,
3373
+ minWidth: actionColumnWidth,
3217
3374
  textAlign: 'center',
3218
3375
  textVerticalAlign:
3219
3376
  style && style.text_vertical_align
@@ -3222,9 +3379,16 @@ const DataGrid = forwardRef(
3222
3379
  render: ({ data }) => {
3223
3380
  return (
3224
3381
  <div className={styles.tdactions}>
3225
- {memoizedSettings.map((setting) =>
3226
- renderSetting(setting, data)
3227
- )}
3382
+ {memoizedSettings
3383
+ .filter(
3384
+ (setting) =>
3385
+ !actionColumnSettingIds.has(
3386
+ setting.id
3387
+ )
3388
+ )
3389
+ .map((setting) =>
3390
+ renderSetting(setting, data)
3391
+ )}
3228
3392
  </div>
3229
3393
  );
3230
3394
  },
@@ -3305,7 +3469,7 @@ const DataGrid = forwardRef(
3305
3469
  .catch((error) => {
3306
3470
  console.error('Error in fetching dropdown data: ', error);
3307
3471
  });
3308
- }, [columns, filterDataSource, memoizedSettings]);
3472
+ }, [columns, filterDataSource, memoizedSettings, isTabletMode]);
3309
3473
 
3310
3474
  // Force re-render when filterDataSource updates to ensure dropdown filters get their data
3311
3475
  const [renderKey, setRenderKey] = useState(0);
@@ -4131,7 +4295,7 @@ const DataGrid = forwardRef(
4131
4295
  }
4132
4296
  headerProps={headerProps}
4133
4297
  idProperty="id"
4134
- minRowHeight={32}
4298
+ minRowHeight={isTabletMode ? 52 : 32}
4135
4299
  onFilterValueChange={(fv) => {
4136
4300
  handleFilterChange(fv, filterValue);
4137
4301
  }}
@@ -4143,7 +4307,7 @@ const DataGrid = forwardRef(
4143
4307
  renderRowContextMenu={renderRowContextMenu}
4144
4308
  renderColumnContextMenu={renderColumnContextMenu}
4145
4309
  rowClassName="vs-datagrid--row"
4146
- rowHeight={null}
4310
+ rowHeight={isTabletMode ? 52 : null}
4147
4311
  rowStyle={getRowStyle}
4148
4312
  selected={selected}
4149
4313
  showZebraRows={true}
@@ -2253,6 +2253,38 @@ function Form({
2253
2253
  }
2254
2254
  }, [formSettings?.autoSubmit]);
2255
2255
 
2256
+ // Auto-focus a named field on mount. Pass the field id as
2257
+ // formSettings.autoFocus, e.g. {"autoFocus": "prefix"}. We wait one
2258
+ // tick for the modal animation to finish so the input is actually
2259
+ // mounted and the browser will honour focus(). Falls back to the
2260
+ // first non-hidden text-ish input if no name match is found.
2261
+ useEffect(() => {
2262
+ if (!formSettings?.autoFocus) return;
2263
+ const target = formSettings.autoFocus;
2264
+ const timer = setTimeout(() => {
2265
+ let el = null;
2266
+ if (typeof target === 'string' && target) {
2267
+ el = document.querySelector(`[name="${target}"]`);
2268
+ }
2269
+ if (!el) {
2270
+ el = document.querySelector(
2271
+ 'form input:not([type="hidden"]):not([disabled]), form textarea:not([disabled]), form select:not([disabled])'
2272
+ );
2273
+ }
2274
+ if (el && typeof el.focus === 'function') {
2275
+ el.focus();
2276
+ if (typeof el.select === 'function') {
2277
+ try {
2278
+ el.select();
2279
+ } catch (e) {
2280
+ // some input types throw on select(); ignore
2281
+ }
2282
+ }
2283
+ }
2284
+ }, 150);
2285
+ return () => clearTimeout(timer);
2286
+ }, [formSettings?.autoFocus]);
2287
+
2256
2288
  useEffect(() => {
2257
2289
  formSettings.fields.forEach((field) => {
2258
2290
  // showIfEmpty: only show field when its own value is empty/null/undefined/[]
@@ -1,6 +1,6 @@
1
1
  import '../styles/global.css';
2
2
 
3
- import React, { useCallback, useEffect, useRef, useState } from 'react';
3
+ import React, { useCallback, useEffect, useInsertionEffect, useRef, useState } from 'react';
4
4
  import { useParams, Outlet } from 'react-router-dom';
5
5
  import { toast } from 'react-toastify';
6
6
  import { saveAs } from 'file-saver';
@@ -245,7 +245,10 @@ function GenericIndex({
245
245
  // Toggle a body-level "tablet-mode" class when this view opts in via
246
246
  // setting.tabletMode. Lets consumer projects style the whole UI
247
247
  // (including app-level chrome) for tablet use without touching this lib.
248
- useEffect(() => {
248
+ // useInsertionEffect fires before child useLayoutEffects, so the class
249
+ // lands before the DataGrid measures row heights — otherwise rows can
250
+ // be sized for the non-tablet CSS and clip larger tablet-mode buttons.
251
+ useInsertionEffect(() => {
249
252
  if (!setting?.tabletMode) {
250
253
  return undefined;
251
254
  }