@visns-studio/visns-components 5.16.3 → 5.18.1

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.16.3",
97
+ "version": "5.18.1",
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) {
@@ -921,6 +947,36 @@ const DataGrid = forwardRef(
921
947
  [selected, ajaxSetting]
922
948
  );
923
949
 
950
+ const isGroupPartiallySelected = useCallback(
951
+ (groupValue) => {
952
+ if (!dataRef.current || !ajaxSetting?.groupBy) {
953
+ return false;
954
+ }
955
+
956
+ const groupField = ajaxSetting.groupBy[0];
957
+ const validRows = dataRef.current.filter(
958
+ (row) =>
959
+ row &&
960
+ row[groupField] === groupValue &&
961
+ row.id !== undefined &&
962
+ row.id !== null
963
+ );
964
+
965
+ if (validRows.length === 0) {
966
+ return false;
967
+ }
968
+
969
+ const selectedCount = validRows.filter(
970
+ (row) =>
971
+ selected[row.id] &&
972
+ !selected[row.id].__group
973
+ ).length;
974
+
975
+ return selectedCount > 0 && selectedCount < validRows.length;
976
+ },
977
+ [selected, ajaxSetting]
978
+ );
979
+
924
980
  const findUpdatedFilter = (filters, currentValues) => {
925
981
  for (const filter of filters) {
926
982
  const matchingCurrentValue = currentValues.find(
@@ -1398,11 +1454,21 @@ const DataGrid = forwardRef(
1398
1454
  // Check if all show conditions are met
1399
1455
  return iconConfig.show.every((condition) => {
1400
1456
  const { id, value } = condition;
1457
+ const hasValueProp = condition.hasOwnProperty('value');
1401
1458
 
1402
1459
  // Check if any row in the group matches the condition
1403
1460
  return groupRows.some((row) => {
1404
1461
  const rowValue = row[id];
1405
1462
 
1463
+ // No value prop → field must be truthy (set/non-empty)
1464
+ if (!hasValueProp) {
1465
+ return (
1466
+ rowValue !== null &&
1467
+ rowValue !== undefined &&
1468
+ rowValue !== ''
1469
+ );
1470
+ }
1471
+
1406
1472
  // Handle null value condition
1407
1473
  if (value === null) {
1408
1474
  return (
@@ -1574,6 +1640,35 @@ const DataGrid = forwardRef(
1574
1640
  const sortBy = id || null;
1575
1641
  const sort = dir === 1 ? 'asc' : dir === -1 ? 'desc' : null;
1576
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
+
1577
1672
  return {
1578
1673
  ...prevState,
1579
1674
  ajaxSetting: {
@@ -1586,6 +1681,51 @@ const DataGrid = forwardRef(
1586
1681
  }
1587
1682
  };
1588
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
+
1589
1729
  /**
1590
1730
  * DEBUG: Row Click Analysis
1591
1731
  *
@@ -3532,6 +3672,7 @@ const DataGrid = forwardRef(
3532
3672
 
3533
3673
  {/* DataGrid container with its own border */}
3534
3674
  <div className={styles.dataGridContainer}>
3675
+
3535
3676
  {limit && limit > 0 ? (
3536
3677
  <ReactDataGrid
3537
3678
  key={`datagrid-${ajaxSetting?.url || ''}-${
@@ -3754,6 +3895,37 @@ const DataGrid = forwardRef(
3754
3895
  styles.groupActionContainer
3755
3896
  }
3756
3897
  >
3898
+ {tableSetting?.checkboxColumn && (
3899
+ <input
3900
+ type="checkbox"
3901
+ className={
3902
+ styles.groupHeaderCheckbox
3903
+ }
3904
+ checked={isGroupSelected(
3905
+ groupValue
3906
+ )}
3907
+ ref={(el) => {
3908
+ if (el) {
3909
+ el.indeterminate =
3910
+ isGroupPartiallySelected(
3911
+ groupValue
3912
+ );
3913
+ }
3914
+ }}
3915
+ onClick={(e) =>
3916
+ e.stopPropagation()
3917
+ }
3918
+ onChange={(e) => {
3919
+ e.stopPropagation();
3920
+ handleGroupSelectionChange(
3921
+ groupValue,
3922
+ e.target
3923
+ .checked
3924
+ );
3925
+ }}
3926
+ aria-label="Select all items in this group"
3927
+ />
3928
+ )}
3757
3929
  <button
3758
3930
  className={`group-expand-btn ${styles.groupExpandBtn}`}
3759
3931
  onClick={(e) => {
@@ -4059,7 +4231,7 @@ const DataGrid = forwardRef(
4059
4231
  }
4060
4232
  headerProps={headerProps}
4061
4233
  idProperty="id"
4062
- minRowHeight={32}
4234
+ minRowHeight={isTabletMode ? 52 : 32}
4063
4235
  onFilterValueChange={(fv) => {
4064
4236
  handleFilterChange(fv, filterValue);
4065
4237
  }}
@@ -4071,7 +4243,7 @@ const DataGrid = forwardRef(
4071
4243
  renderRowContextMenu={renderRowContextMenu}
4072
4244
  renderColumnContextMenu={renderColumnContextMenu}
4073
4245
  rowClassName="vs-datagrid--row"
4074
- rowHeight={null}
4246
+ rowHeight={isTabletMode ? 52 : null}
4075
4247
  rowStyle={getRowStyle}
4076
4248
  selected={selected}
4077
4249
  showZebraRows={true}
@@ -2229,6 +2229,62 @@ function Form({
2229
2229
  }
2230
2230
  }, [tableData.dataSource]);
2231
2231
 
2232
+ // Auto-submit on mount when formSettings.autoSubmit is true.
2233
+ // Used for "report" tabs that should show their results immediately instead of
2234
+ // requiring the user to click "View Report" with an empty filter form.
2235
+ // We trigger an actual click on the rendered Save button so the path is
2236
+ // identical to the user clicking it manually.
2237
+ const autoSubmittedRef = useRef(false);
2238
+ useEffect(() => {
2239
+ if (formSettings?.autoSubmit && !autoSubmittedRef.current) {
2240
+ autoSubmittedRef.current = true;
2241
+ const timer = setTimeout(() => {
2242
+ const saveBtn = document.querySelector('[data-type="save"]');
2243
+ if (saveBtn) {
2244
+ saveBtn.click();
2245
+ } else {
2246
+ handleSubmit({
2247
+ preventDefault: () => {},
2248
+ target: { dataset: { type: 'save' } },
2249
+ });
2250
+ }
2251
+ }, 150);
2252
+ return () => clearTimeout(timer);
2253
+ }
2254
+ }, [formSettings?.autoSubmit]);
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
+
2232
2288
  useEffect(() => {
2233
2289
  formSettings.fields.forEach((field) => {
2234
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';
@@ -241,6 +241,23 @@ function GenericIndex({
241
241
  const [customActionFormData, setCustomActionFormData] = useState({});
242
242
  const [customActionConfig, setCustomActionConfig] = useState(null);
243
243
  const { windowHeight, windowWidth } = useWindowDimensions();
244
+
245
+ // Toggle a body-level "tablet-mode" class when this view opts in via
246
+ // setting.tabletMode. Lets consumer projects style the whole UI
247
+ // (including app-level chrome) for tablet use without touching this lib.
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(() => {
252
+ if (!setting?.tabletMode) {
253
+ return undefined;
254
+ }
255
+ document.body.classList.add('tablet-mode');
256
+ return () => {
257
+ document.body.classList.remove('tablet-mode');
258
+ };
259
+ }, [setting?.tabletMode]);
260
+
244
261
  const { config, setConfig, subnav, setSubnav } = useConfig(
245
262
  setting,
246
263
  setting.tabs,
@@ -879,6 +879,21 @@
879
879
  position: relative;
880
880
  }
881
881
 
882
+ .groupHeaderCheckbox {
883
+ width: 22px;
884
+ height: 22px;
885
+ margin-right: 12px;
886
+ cursor: pointer;
887
+ accent-color: var(--primary-color, #3b82f6);
888
+ flex-shrink: 0;
889
+ border-radius: 4px;
890
+ transition: transform 0.15s ease;
891
+
892
+ &:hover {
893
+ transform: scale(1.08);
894
+ }
895
+ }
896
+
882
897
  .groupExpandBtn {
883
898
  background: var(--primary-color, #3b82f6);
884
899
  border: none;
@@ -1200,3 +1215,5 @@
1200
1215
  transform: scale(1.2);
1201
1216
  }
1202
1217
  }
1218
+
1219
+