@visns-studio/visns-components 6.17.2 → 6.20.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.
Files changed (83) hide show
  1. package/package.json +10 -7
  2. package/src/components/AssociationManager.jsx +344 -177
  3. package/src/components/AsyncSelect.jsx +2 -2
  4. package/src/components/Autocomplete.jsx +57 -12
  5. package/src/components/Breadcrumb.jsx +23 -4
  6. package/src/components/ClientAssociationManager.jsx +321 -145
  7. package/src/components/DataGrid.jsx +625 -20
  8. package/src/components/DropZone.jsx +4 -1
  9. package/src/components/Form.jsx +233 -6
  10. package/src/components/MultiSelect.jsx +2 -2
  11. package/src/components/Navigation.jsx +39 -23
  12. package/src/components/TableFilter.jsx +172 -12
  13. package/src/components/UniversalSearch.jsx +390 -0
  14. package/src/components/auth/Login.jsx +164 -8
  15. package/src/components/auth/Profile.jsx +209 -22
  16. package/src/components/auth/authEndpoints.js +4 -0
  17. package/src/components/auth/webauthn.js +194 -0
  18. package/src/components/columns/ColumnRenderers.jsx +6 -1
  19. package/src/components/controls/DataGridSearch.jsx +11 -5
  20. package/src/components/generic/GenericAuth.jsx +6 -0
  21. package/src/components/generic/GenericDashboard.jsx +93 -36
  22. package/src/components/generic/GenericDetail.jsx +146 -61
  23. package/src/components/generic/GenericIndex.jsx +142 -38
  24. package/src/components/generic/GenericSettings.jsx +220 -0
  25. package/src/components/generic/StandardModal.jsx +165 -8
  26. package/src/components/generic/dashboardChartDefaults.js +230 -0
  27. package/src/components/generic/shared/indexLayout.js +38 -0
  28. package/src/components/settings/IntegrationsManager.jsx +474 -0
  29. package/src/components/sms/SmsLineSettings.jsx +16 -12
  30. package/src/components/styles/AssociationManager.module.scss +4 -363
  31. package/src/components/styles/AuditLog.module.scss +6 -6
  32. package/src/components/styles/AuditLogs.module.scss +3 -3
  33. package/src/components/styles/Autocomplete.module.scss +7 -7
  34. package/src/components/styles/BusinessCardOcr.module.scss +6 -1
  35. package/src/components/styles/CallQueueSettings.module.scss +4 -1
  36. package/src/components/styles/CameraPlacement.module.scss +19 -19
  37. package/src/components/styles/CategorizedDropZone.module.scss +6 -1
  38. package/src/components/styles/ClientAssociationManager.module.scss +5 -289
  39. package/src/components/styles/ClientAuth.module.scss +1 -1
  40. package/src/components/styles/DataGrid.module.scss +455 -8
  41. package/src/components/styles/Field.module.scss +32 -24
  42. package/src/components/styles/Form.module.scss +123 -3
  43. package/src/components/styles/GenericDashboard.module.scss +612 -825
  44. package/src/components/styles/GenericDetail.module.scss +406 -61
  45. package/src/components/styles/GenericDynamic.module.scss +25 -25
  46. package/src/components/styles/GenericEditableTable.module.scss +33 -33
  47. package/src/components/styles/GenericFormBuilder.module.scss +119 -119
  48. package/src/components/styles/GenericIndex.module.scss +483 -38
  49. package/src/components/styles/GenericMain.module.scss +6 -3
  50. package/src/components/styles/GenericQuote.module.scss +399 -81
  51. package/src/components/styles/GenericReport.module.scss +8 -8
  52. package/src/components/styles/GenericSettings.module.scss +209 -0
  53. package/src/components/styles/GenericSort.module.scss +9 -9
  54. package/src/components/styles/Integrations.module.scss +321 -0
  55. package/src/components/styles/List.module.scss +4 -4
  56. package/src/components/styles/Login.module.scss +57 -0
  57. package/src/components/styles/MultiSelect.module.scss +4 -4
  58. package/src/components/styles/Navigation.module.scss +12 -12
  59. package/src/components/styles/Notification.module.scss +12 -8
  60. package/src/components/styles/NotificationList.module.scss +5 -5
  61. package/src/components/styles/Profile.module.scss +5 -5
  62. package/src/components/styles/QrCode.module.scss +4 -4
  63. package/src/components/styles/QuickAction.module.scss +20 -20
  64. package/src/components/styles/SortableQuoteItems.module.scss +4 -4
  65. package/src/components/styles/TableFilter.module.scss +128 -17
  66. package/src/components/styles/TicketConversation.module.scss +497 -0
  67. package/src/components/styles/UniversalSearch.module.scss +229 -0
  68. package/src/components/styles/Vault.module.scss +413 -0
  69. package/src/components/styles/_associationManager.scss +467 -0
  70. package/src/components/styles/_authBrandPanel.scss +15 -9
  71. package/src/components/styles/_controls.scss +43 -39
  72. package/src/components/styles/global-datagrid.css +196 -0
  73. package/src/components/styles/global.css +168 -17
  74. package/src/components/tickets/TicketConversation.jsx +455 -0
  75. package/src/components/utils/rowActionSettings.js +117 -0
  76. package/src/components/vault/VaultClientPicker.jsx +183 -0
  77. package/src/components/vault/VaultEntryForm.jsx +23 -0
  78. package/src/components/vault/VaultManager.jsx +382 -34
  79. package/src/components/vault/VaultShareModal.jsx +454 -0
  80. package/src/components/vault/vaultEndpoints.js +16 -2
  81. package/src/components/vault/vaultShares.js +247 -0
  82. package/src/index.js +35 -0
  83. package/src/utils/fetchUtil.js +38 -14
@@ -3,6 +3,35 @@ import React, { useState, useEffect, useRef } from 'react';
3
3
  import { Link, useNavigate } from 'react-router-dom';
4
4
  import { ResponsiveBar } from '@nivo/bar';
5
5
  import { ResponsiveLine } from '@nivo/line';
6
+
7
+ // Shared Nivo defaults so every chart on a dashboard reads the same way:
8
+ // room for the axis legends, a recessive grid, and point sizing that thins
9
+ // out as series are added. `widget.props` is spread after these, so any
10
+ // individual widget can still override a default from its JSON config.
11
+ const defaultsFor = (type, seriesCount = 1) => {
12
+ const base = {
13
+ margin: { top: 20, right: 24, bottom: 56, left: 56 },
14
+ colors: { scheme: 'nivo' },
15
+ animate: true,
16
+ theme: {
17
+ axis: { ticks: { text: { fontSize: 11 } } },
18
+ grid: { line: { strokeOpacity: 0.25 } },
19
+ },
20
+ };
21
+
22
+ if (type === 'line') {
23
+ return {
24
+ ...base,
25
+ curve: 'monotoneX',
26
+ enableGridX: false,
27
+ useMesh: true,
28
+ pointSize: seriesCount > 3 ? 4 : 8,
29
+ pointBorderWidth: 2,
30
+ };
31
+ }
32
+
33
+ return base;
34
+ };
6
35
  import { ResponsivePie } from '@nivo/pie';
7
36
  import dayjs from 'dayjs';
8
37
  import parse from 'html-react-parser';
@@ -1281,7 +1310,10 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
1281
1310
  widgetData !== null &&
1282
1311
  widgetData !== undefined);
1283
1312
 
1284
- if (hasData || widget.type === 'bar') {
1313
+ // `list` widgets render their own empty state (`emptyMessage`), so they
1314
+ // must reach the switch even with zero rows — gating them out here made
1315
+ // that message dead code and painted an empty widget instead.
1316
+ if (hasData || widget.type === 'bar' || widget.type === 'list') {
1285
1317
  // Always try to render bar charts for debugging
1286
1318
  switch (widget.type) {
1287
1319
  case 'counter':
@@ -1622,44 +1654,70 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
1622
1654
  case 'line':
1623
1655
  if (widgetData.length > 0) {
1624
1656
  return (
1625
- <ResponsiveLine
1626
- key={`${widget.id}-${widget.size}`}
1627
- data={widgetData}
1628
- {...widget.props}
1629
- />
1657
+ <div
1658
+ style={{ height: widget.height || '320px' }}
1659
+ >
1660
+ <ResponsiveLine
1661
+ key={`${widget.id}-${widget.size}`}
1662
+ data={widgetData}
1663
+ {...defaultsFor('line', widgetData.length)}
1664
+ {...widget.props}
1665
+ />
1666
+ </div>
1630
1667
  );
1631
1668
  }
1632
1669
  break;
1633
- case 'list':
1670
+ case 'list': {
1671
+ // A `url` supplied by the API row wins; otherwise build the
1672
+ // href from `link.base`/`link.key`. A widget with neither
1673
+ // still renders, it just isn't clickable — a missing `link`
1674
+ // used to throw and blank the entire dashboard.
1675
+ const listRows = Array.isArray(widgetData)
1676
+ ? widgetData
1677
+ : [];
1678
+ const rowHref = (item) => {
1679
+ if (item?.url) return item.url;
1680
+ if (widget.link?.base && widget.link?.key) {
1681
+ return `${widget.link.base}/${
1682
+ item[widget.link.key]
1683
+ }`;
1684
+ }
1685
+ return null;
1686
+ };
1687
+ const rowBody = (item) => (
1688
+ <button>
1689
+ <strong>
1690
+ {(widget?.item?.label &&
1691
+ item[widget.item.label]) ||
1692
+ ''}
1693
+ </strong>
1694
+ {widget.showDate && <span>{item.date}</span>}
1695
+ </button>
1696
+ );
1697
+
1634
1698
  return (
1635
1699
  <ul className={styles.dashList}>
1636
- {widgetData.length > 0 ? (
1637
- widgetData
1700
+ {listRows.length > 0 ? (
1701
+ listRows
1638
1702
  .slice(0, widget.limit || 4)
1639
- .map((item) => (
1640
- <li key={`${widget.id}-${item.id}`}>
1641
- <Link
1642
- to={`${widget.link.base}/$${
1643
- item[widget.link.key]
1703
+ .map((item, i) => {
1704
+ const href = rowHref(item);
1705
+ return (
1706
+ <li
1707
+ key={`${widget.id}-${
1708
+ item.id ?? i
1644
1709
  }`}
1645
1710
  >
1646
- <button>
1647
- <strong>
1648
- {widget?.item?.label &&
1649
- item[widget.item.label]
1650
- ? item[
1651
- widget.item
1652
- .label
1653
- ]
1654
- : ''}
1655
- </strong>
1656
- {widget.showDate && (
1657
- <span>{item.date}</span>
1658
- )}
1659
- </button>
1660
- </Link>
1661
- </li>
1662
- ))
1711
+ {href ? (
1712
+ <Link to={href}>
1713
+ {rowBody(item)}
1714
+ </Link>
1715
+ ) : (
1716
+ rowBody(item)
1717
+ )}
1718
+ </li>
1719
+ );
1720
+ })
1663
1721
  ) : (
1664
1722
  <li>
1665
1723
  <button>
@@ -1669,6 +1727,7 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
1669
1727
  )}
1670
1728
  </ul>
1671
1729
  );
1730
+ }
1672
1731
  case 'table': {
1673
1732
  const tableData = widgetData.data || widgetData;
1674
1733
  const tableKeys = widgetData.keys || [];
@@ -2050,11 +2109,9 @@ function GenericDashboard({ setting, userProfile, dynamicDashboard = false }) {
2050
2109
  <div
2051
2110
  key={widget.id}
2052
2111
  className={`${getGridClass(widget.size)} ${
2053
- widget.type !== 'list'
2054
- ? widget.highlight
2055
- ? styles['dashwidget--highlight']
2056
- : styles.dashwidget
2057
- : ''
2112
+ widget.highlight
2113
+ ? styles['dashwidget--highlight']
2114
+ : styles.dashwidget
2058
2115
  } ${
2059
2116
  editMode
2060
2117
  ? widget.highlight
@@ -84,7 +84,7 @@ import QrCode from '../QrCode';
84
84
  import StagePopupModal from './StagePopupModal';
85
85
  import StandardModal from './StandardModal';
86
86
  import Table from '../DataGrid';
87
- import TableFilter from '../TableFilter';
87
+ import TableFilter, { isFlatFilterList } from '../TableFilter';
88
88
  import CategorizedDropZone from '../CategorizedDropZone';
89
89
  import VisnsDropZone from '../DropZone';
90
90
 
@@ -238,8 +238,26 @@ function GenericDetail({
238
238
  urlParam = 'dataId',
239
239
  userProfile,
240
240
  actions = null,
241
+ /**
242
+ * Host-supplied components for `type: "custom"` tabs, keyed by the tab's
243
+ * `renderer`.
244
+ *
245
+ * Same shape as Navigation's `renderers` slot, deliberately — that is
246
+ * already how VaultQuickSearch mounts into the app chrome, so there is one
247
+ * pattern for "the library lays this out, the app decides what goes in it"
248
+ * rather than two.
249
+ *
250
+ * Every tab type before this had to be a type the library knew about, so a
251
+ * page that wanted to show something the library had no case for (a vault,
252
+ * a map, a live feed) had no way in short of a new `case` here. Each
253
+ * renderer is called with { data, tab, routeParams, reload }.
254
+ */
255
+ renderers = {},
241
256
  }) {
242
257
  const gridRef = useRef(null);
258
+
259
+ /** The open form's guarded close — see the note in DataGrid.jsx. */
260
+ const formCloseRef = useRef(null);
243
261
  const currentConfigRef = useRef(null);
244
262
  const routeParams = useParams();
245
263
 
@@ -1720,6 +1738,62 @@ function GenericDetail({
1720
1738
  }
1721
1739
  };
1722
1740
 
1741
+ /**
1742
+ * The built-in `type: "custom"` components, addressed by `tab.component`.
1743
+ *
1744
+ * Extracted so the single `case 'custom'` above can serve both this and
1745
+ * the host-supplied `renderers` slot. Previously these lived in a second
1746
+ * `case 'custom'` in the same switch, which JavaScript never reaches.
1747
+ */
1748
+ const renderCustomComponentTab = (activeTabConfig) => {
1749
+ if (
1750
+ activeTabConfig.component === 'AssociationManager' ||
1751
+ activeTabConfig.component === 'ClientAssociationManager'
1752
+ ) {
1753
+ return (
1754
+ <AssociationManager
1755
+ endpoints={activeTabConfig.endpoints}
1756
+ config={activeTabConfig.config}
1757
+ dataId={routeParams[urlParam]}
1758
+ onUpdate={handleReload}
1759
+ />
1760
+ );
1761
+ }
1762
+
1763
+ if (activeTabConfig.component === 'MergeEntity') {
1764
+ return (
1765
+ <MergeEntity
1766
+ endpoints={activeTabConfig.endpoints}
1767
+ config={activeTabConfig.config}
1768
+ dataId={routeParams[urlParam]}
1769
+ onUpdate={handleReload}
1770
+ />
1771
+ );
1772
+ }
1773
+
1774
+ if (activeTabConfig.component === 'ProposalTemplateSectionManager') {
1775
+ return (
1776
+ <ProposalTemplateSectionManager
1777
+ {...activeTabConfig.props}
1778
+ templateId={routeParams[urlParam]}
1779
+ onUpdate={handleReload}
1780
+ />
1781
+ );
1782
+ }
1783
+
1784
+ if (activeTabConfig.component === 'ProposalTemplatePreview') {
1785
+ return (
1786
+ <ProposalTemplatePreview
1787
+ {...activeTabConfig.props}
1788
+ templateId={routeParams[urlParam]}
1789
+ onUpdate={handleReload}
1790
+ />
1791
+ );
1792
+ }
1793
+
1794
+ return null;
1795
+ };
1796
+
1723
1797
  const renderContent = () => {
1724
1798
  if (subnav && subnav.length > 0) {
1725
1799
  const activeTab = subnav.find((s) => s.show === true);
@@ -1730,6 +1804,52 @@ function GenericDetail({
1730
1804
 
1731
1805
  if (activeTabConfig && activeTabConfig.type) {
1732
1806
  switch (activeTabConfig.type) {
1807
+ case 'custom': {
1808
+ // A tab naming a `component` is the ORIGINAL
1809
+ // built-in form of this case — AssociationManager,
1810
+ // MergeEntity, the proposal panels. Both cases
1811
+ // live in this one switch, so adding a second
1812
+ // `case 'custom'` above made the built-in one
1813
+ // unreachable and silently emptied the Customer
1814
+ // Assignments tab. Hand those straight over.
1815
+ if (activeTabConfig.component) {
1816
+ return renderCustomComponentTab(activeTabConfig);
1817
+ }
1818
+
1819
+ const render =
1820
+ renderers[activeTabConfig.renderer];
1821
+
1822
+ if (typeof render !== 'function') {
1823
+ // A named renderer the host did not supply is
1824
+ // a config error, and a blank panel is the one
1825
+ // outcome that makes it invisible. Say which
1826
+ // name is missing.
1827
+ if (
1828
+ process.env.NODE_ENV !== 'production' &&
1829
+ activeTabConfig.renderer
1830
+ ) {
1831
+ console.warn(
1832
+ `[GenericDetail] Tab "${activeTabConfig.id}" asks for renderer "${activeTabConfig.renderer}", which was not passed in the \`renderers\` prop.`
1833
+ );
1834
+ }
1835
+
1836
+ return null;
1837
+ }
1838
+
1839
+ // `data` can be null on the first paint, before the
1840
+ // record has loaded. Renderers that key off a
1841
+ // record id must not be mounted with undefined.
1842
+ if (!data) {
1843
+ return null;
1844
+ }
1845
+
1846
+ return render({
1847
+ data,
1848
+ tab: activeTabConfig,
1849
+ routeParams,
1850
+ reload: handleReload,
1851
+ });
1852
+ }
1733
1853
  case 'calendar': {
1734
1854
  // Prepare events from your data, converting moment objects to Date objects
1735
1855
  const events =
@@ -3577,58 +3697,6 @@ function GenericDetail({
3577
3697
  userProfile={userProfile}
3578
3698
  />
3579
3699
  );
3580
- case 'custom':
3581
- // Handle custom components
3582
- if (
3583
- activeTabConfig.component ===
3584
- 'AssociationManager' ||
3585
- activeTabConfig.component ===
3586
- 'ClientAssociationManager'
3587
- ) {
3588
- return (
3589
- <AssociationManager
3590
- endpoints={activeTabConfig.endpoints}
3591
- config={activeTabConfig.config}
3592
- dataId={routeParams[urlParam]}
3593
- onUpdate={handleReload}
3594
- />
3595
- );
3596
- }
3597
- if (activeTabConfig.component === 'MergeEntity') {
3598
- return (
3599
- <MergeEntity
3600
- endpoints={activeTabConfig.endpoints}
3601
- config={activeTabConfig.config}
3602
- dataId={routeParams[urlParam]}
3603
- onUpdate={handleReload}
3604
- />
3605
- );
3606
- }
3607
- if (
3608
- activeTabConfig.component ===
3609
- 'ProposalTemplateSectionManager'
3610
- ) {
3611
- return (
3612
- <ProposalTemplateSectionManager
3613
- {...activeTabConfig.props}
3614
- templateId={routeParams[urlParam]}
3615
- onUpdate={handleReload}
3616
- />
3617
- );
3618
- }
3619
- if (
3620
- activeTabConfig.component ===
3621
- 'ProposalTemplatePreview'
3622
- ) {
3623
- return (
3624
- <ProposalTemplatePreview
3625
- {...activeTabConfig.props}
3626
- templateId={routeParams[urlParam]}
3627
- onUpdate={handleReload}
3628
- />
3629
- );
3630
- }
3631
- return null;
3632
3700
  case 'proposalSectionManager':
3633
3701
  return (
3634
3702
  <ProposalTemplateSectionManager
@@ -3955,15 +4023,27 @@ function GenericDetail({
3955
4023
  };
3956
4024
 
3957
4025
  /**
3958
- * `page.tabLayout: "underline"` lays the tabs out as a horizontal strip
3959
- * above the content instead of a rail down its left edge.
4026
+ * Detail tabs are a horizontal strip above the content panel.
4027
+ *
4028
+ * They were a vertical rail down the left edge, and this was an opt-in
4029
+ * (`page.tabLayout: "underline"`) that almost nothing opted into. The rail
4030
+ * costs a fifth of the page width on every detail page in every project,
4031
+ * permanently, to show six words — and it is the CONTENT panel beside it,
4032
+ * the thing people opened the page to read, that pays for it. A strip
4033
+ * under the breadcrumb costs one row of height and gives the panel the
4034
+ * whole width back.
4035
+ *
4036
+ * The rail survives for the one shape a single row cannot express: tabs
4037
+ * with expandable children, which are a two-level menu rather than a tab
4038
+ * bar. `isFlatFilterList` is TableFilter's own test, imported rather than
4039
+ * repeated, so the container laid out here always agrees with the markup
4040
+ * TableFilter actually renders into it.
3960
4041
  *
3961
- * The rail costs a fifth of the page width, which is worth paying when
3962
- * there are seven tabs to keep in view and is not when there are two — a
3963
- * two-item rail beside a wide panel reads as an empty column. Opt-in, so
3964
- * every existing page keeps the rail it was designed against.
4042
+ * No opt-out: this is the detail-page layout now, not a mode. `page` keeps
4043
+ * its shape either way, so no config has to change and `tabLayout` in an
4044
+ * existing config is simply redundant.
3965
4045
  */
3966
- const tabsUnderlined = page?.tabLayout === 'underline';
4046
+ const tabsUnderlined = isFlatFilterList(filters);
3967
4047
 
3968
4048
  return (
3969
4049
  <>
@@ -4183,12 +4263,17 @@ function GenericDetail({
4183
4263
 
4184
4264
  <StandardModal
4185
4265
  isOpen={modalShow}
4186
- onClose={modalClose}
4266
+ onClose={() => (formCloseRef.current ?? modalClose)()}
4187
4267
  title={formData.title || 'Edit Details'}
4188
4268
  closeOnDocumentClick={false}
4189
4269
  verticalAlign={formData.verticalAlign || 'center'}
4270
+ variant={formData.layout === 'modal' ? 'modal' : 'sheet'}
4271
+ size={formData.modalSize || 'medium'}
4190
4272
  >
4191
4273
  <Form
4274
+ registerClose={(fn) => {
4275
+ formCloseRef.current = fn;
4276
+ }}
4192
4277
  closeModal={modalClose}
4193
4278
  columnId={formId}
4194
4279
  fetchTable={handleReload}
@@ -13,6 +13,7 @@ import Download from '../Download';
13
13
  import Form from '../Form';
14
14
  import Table from '../DataGrid';
15
15
  import TableFilter from '../TableFilter';
16
+ import { shouldUnderlineTabs } from './shared/indexLayout';
16
17
  import GenericGrid from './GenericGrid';
17
18
  import GenericReportForm from './GenericReportForm';
18
19
  import StandardModal from './StandardModal';
@@ -267,12 +268,46 @@ function useConfig(setting, tabs, filters, setRowsSelected, tableSetting) {
267
268
  return { config, setConfig, subnav, setSubnav };
268
269
  }
269
270
 
271
+ /**
272
+ * An index page: a title bar, an optional filter rail, and the grid.
273
+ *
274
+ * EMBEDDING. An index is normally the whole route, so it prints its own page
275
+ * title and puts its filters in a rail down the left. Both of those are wrong
276
+ * when the index is one panel inside another page that has already drawn a
277
+ * header and a rail of its own — the settings hub, for one, which stacked a
278
+ * second `<h1>` and a second vertical rail inside a panel that already had
279
+ * both. `showTitle` and `tabLayout` are the two opt-outs, and they are opt-ins:
280
+ * with neither passed this renders exactly what it rendered before, which is
281
+ * what every existing index page relies on.
282
+ *
283
+ * @param {boolean} [showTitle=true] Print the page-title bar (`page.title`
284
+ * plus the record count). `false` suppresses the bar only — the count moves
285
+ * to the tab strip, or to its own quiet row above the grid when there is no
286
+ * strip, because "how many rows am I looking at" is still a question an
287
+ * embedded index has to answer.
288
+ * @param {'rail'|'underline'} [tabLayout='rail'] Where the filter menu goes.
289
+ * `'underline'` asks for TableFilter's horizontal tab strip above the grid
290
+ * instead of the rail beside it, which gives the grid the full width back.
291
+ * Honoured only for a FLAT filter list, and only outside the `simple`
292
+ * layout — see `shouldUnderlineTabs`, which reuses TableFilter's own gate so
293
+ * the container built here can never disagree with the markup TableFilter
294
+ * puts inside it. A rail with expandable parents is a two-level menu with no
295
+ * single row to lay out, and falls back to the rail.
296
+ */
270
297
  function GenericIndex({
271
298
  layout = 'full',
272
299
  setting,
273
300
  setActiveNav = null,
274
301
  themeType,
275
302
  userProfile,
303
+ showTitle = true,
304
+ /* Whether the embedded "N total" count (and tableInfo headings) render
305
+ when showTitle is off. Defaults on — normally it is the only place
306
+ the row count prints above the fold. A host that embeds the grid
307
+ under its own summary strip is already printing that figure and
308
+ turns this off rather than saying the same number twice. */
309
+ showTotal = true,
310
+ tabLayout = 'rail',
276
311
  }) {
277
312
  const gridRef = useRef(null);
278
313
  useParams(); // Using router params if needed in the future
@@ -739,8 +774,93 @@ function GenericIndex({
739
774
  [setSubnav, setConfig]
740
775
  );
741
776
 
777
+ /**
778
+ * The record count and any `tableInfo` headings, as one node.
779
+ *
780
+ * It normally lives in the page-title bar. With `showTitle={false}` there
781
+ * is no bar to live in, so the same node is rendered against the grid
782
+ * instead — at the right-hand end of the tab strip when there is one, in
783
+ * its own row above the grid when there is not. Dropping it was not an
784
+ * option: it is the only place the total row count is printed above the
785
+ * fold.
786
+ */
787
+ const headings = setting.page?.tableInfo?.heading;
788
+ const hasHeadings = headings?.length > 0;
789
+
790
+ // Memoised because in embedded mode this node is a dependency of
791
+ // `renderContent`, and a fresh element every render would rebuild the grid
792
+ // on every render with it.
793
+ const titleInfo = useMemo(
794
+ () => (
795
+ <div className={styles.titleInfo}>
796
+ {hasHeadings &&
797
+ headings.map((h) => (
798
+ <span key={h.id}>
799
+ <strong>{tableInfo[h.id]}</strong> {h.label}
800
+ </span>
801
+ ))}
802
+ {/* Square brackets round a count is a convention from a
803
+ spreadsheet, not an interface — and it repeated the page
804
+ title back at you. The count alone is what anyone actually
805
+ reads. */}
806
+ {total > 0 && (
807
+ <span>
808
+ {/* Grouped, matching the pager's own "of 32,134" — the
809
+ same figure was printing two different ways on one
810
+ screen. */}
811
+ <strong>{Number(total).toLocaleString()}</strong> total
812
+ </span>
813
+ )}
814
+ </div>
815
+ ),
816
+ [hasHeadings, headings, tableInfo, total]
817
+ );
818
+
819
+ // An empty box is worth printing inside the title bar (it holds the bar's
820
+ // right-hand column open) and not worth printing anywhere else.
821
+ const embeddedInfo =
822
+ !showTitle && showTotal && (total > 0 || hasHeadings)
823
+ ? titleInfo
824
+ : null;
825
+
826
+ // Lay the filter menu out as a horizontal strip rather than a rail. The
827
+ // conditions are written out in `shouldUnderlineTabs`, which is where they
828
+ // are also tested.
829
+ const tabsUnderlined = shouldUnderlineTabs({
830
+ tabLayout,
831
+ layout,
832
+ filters: subnav,
833
+ });
834
+
742
835
  const renderContent = useCallback(() => {
743
836
  if (subnav?.length > 0) {
837
+ if (tabsUnderlined) {
838
+ return (
839
+ <div
840
+ className={`${styles.grid__subrow} ${styles['grid__subrow--stacked']}`}
841
+ >
842
+ <div
843
+ className={`${styles.grid__subnav} ${styles['grid__subnav--underline']}`}
844
+ >
845
+ <TableFilter
846
+ filters={subnav}
847
+ setFilters={setSubnav}
848
+ setSettings={setConfig}
849
+ settings={config}
850
+ type={layout}
851
+ onFilterChange={handleFilterChange}
852
+ tabs={setting.tabs}
853
+ variant="underline"
854
+ />
855
+ {embeddedInfo}
856
+ </div>
857
+ <div className={styles.grid__subcontent}>
858
+ {renderContentBasedOnType()}
859
+ </div>
860
+ </div>
861
+ );
862
+ }
863
+
744
864
  return (
745
865
  <div className={styles.grid__subrow}>
746
866
  {layout === 'simple' ? (
@@ -801,6 +921,8 @@ function GenericIndex({
801
921
  renderContentBasedOnType,
802
922
  handleFilterChange,
803
923
  setting.tabs,
924
+ tabsUnderlined,
925
+ embeddedInfo,
804
926
  ]);
805
927
 
806
928
  useEffect(() => {
@@ -912,47 +1034,29 @@ function GenericIndex({
912
1034
 
913
1035
  return (
914
1036
  <>
915
- <div className={styles.grid}>
916
- <div className={styles.grid__row}>
917
- <div
918
- className={`${styles.grid__full} ${
919
- layout === 'full'
920
- ? styles.crmtitle
921
- : styles.crmtitleSimple
922
- }`}
923
- >
924
- <h1>{parse(setting.page?.title || '')}</h1>
925
- <div className={styles.titleInfo}>
926
- {setting.page?.tableInfo?.hasOwnProperty(
927
- 'heading'
928
- ) &&
929
- setting.page.tableInfo.heading.length > 0 &&
930
- setting.page.tableInfo.heading.map((h) => (
931
- <span key={h.id}>
932
- <strong>{tableInfo[h.id]}</strong>{' '}
933
- {h.label}
934
- </span>
935
- ))}
936
- {/* Square brackets round a count is a convention
937
- from a spreadsheet, not an interface — and it
938
- repeated the page title back at you. The count
939
- alone is what anyone actually reads. */}
940
- {total > 0 && (
941
- <span>
942
- {/* Grouped, matching the pager's own
943
- "of 32,134" — the same figure was
944
- printing two different ways on one
945
- screen. */}
946
- <strong>
947
- {Number(total).toLocaleString()}
948
- </strong>{' '}
949
- total
950
- </span>
951
- )}
1037
+ {showTitle && (
1038
+ <div className={styles.grid}>
1039
+ <div className={styles.grid__row}>
1040
+ <div
1041
+ className={`${styles.grid__full} ${
1042
+ layout === 'full'
1043
+ ? styles.crmtitle
1044
+ : styles.crmtitleSimple
1045
+ }`}
1046
+ >
1047
+ <h1>{parse(setting.page?.title || '')}</h1>
1048
+ {titleInfo}
952
1049
  </div>
953
1050
  </div>
954
1051
  </div>
955
- </div>
1052
+ )}
1053
+
1054
+ {/* The count without a title bar and without a strip to sit in.
1055
+ Its own quiet, right-aligned row above the grid — the host page
1056
+ has already drawn the heading this belongs under. */}
1057
+ {!tabsUnderlined && embeddedInfo && (
1058
+ <div className={styles.embeddedInfoRow}>{embeddedInfo}</div>
1059
+ )}
956
1060
 
957
1061
  <div className={styles.grid}>{renderContent()}</div>
958
1062
  <Outlet />