@oneuptime/common 11.7.2 → 11.7.3

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 (60) hide show
  1. package/Models/AnalyticsModels/KubernetesCostAllocation.ts +103 -0
  2. package/Models/DatabaseModels/TableView.ts +40 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.ts +13 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Tests/Models/AnalyticsModels/KubernetesCostAllocation.test.ts +16 -0
  6. package/Tests/Types/JSONFunctions.test.ts +103 -1
  7. package/Tests/UI/Components/ModelTable/BaseModelTableColumnCustomization.test.tsx +679 -0
  8. package/Tests/UI/Components/ModelTable/ColumnCustomizationModal.test.tsx +572 -0
  9. package/Tests/UI/Components/ModelTable/ColumnPreference.test.ts +1678 -0
  10. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +1094 -0
  11. package/Tests/Utils/UserPreferences.test.ts +563 -0
  12. package/Types/JSONFunctions.ts +7 -2
  13. package/Types/Kubernetes/KubernetesCostIngest.ts +37 -1
  14. package/UI/Components/MasterPage/MasterPage.tsx +65 -0
  15. package/UI/Components/ModelTable/BaseModelTable.tsx +387 -4
  16. package/UI/Components/ModelTable/Column.ts +17 -0
  17. package/UI/Components/ModelTable/ColumnCustomizationModal.tsx +420 -0
  18. package/UI/Components/ModelTable/ColumnPreference.ts +482 -0
  19. package/UI/Components/ModelTable/CustomFieldColumns.tsx +326 -0
  20. package/UI/Components/ModelTable/TableView.tsx +24 -2
  21. package/UI/Components/ModelTable/useCustomFieldColumns.ts +150 -0
  22. package/UI/Components/SideMenu/SideMenu.tsx +24 -4
  23. package/UI/Components/Table/Table.tsx +14 -1
  24. package/UI/Components/Table/TableRow.tsx +180 -175
  25. package/UI/Components/Table/Types/Column.ts +2 -0
  26. package/Utils/UserPreferences.ts +53 -0
  27. package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js +86 -0
  28. package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js.map +1 -1
  29. package/build/dist/Models/DatabaseModels/TableView.js +40 -0
  30. package/build/dist/Models/DatabaseModels/TableView.js.map +1 -1
  31. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.js +12 -0
  32. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.js.map +1 -0
  33. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  34. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  35. package/build/dist/Types/JSONFunctions.js +8 -3
  36. package/build/dist/Types/JSONFunctions.js.map +1 -1
  37. package/build/dist/Types/Kubernetes/KubernetesCostIngest.js.map +1 -1
  38. package/build/dist/UI/Components/MasterPage/MasterPage.js +50 -1
  39. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  40. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +239 -7
  41. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  42. package/build/dist/UI/Components/ModelTable/ColumnCustomizationModal.js +189 -0
  43. package/build/dist/UI/Components/ModelTable/ColumnCustomizationModal.js.map +1 -0
  44. package/build/dist/UI/Components/ModelTable/ColumnPreference.js +258 -0
  45. package/build/dist/UI/Components/ModelTable/ColumnPreference.js.map +1 -0
  46. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +168 -0
  47. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -0
  48. package/build/dist/UI/Components/ModelTable/TableView.js +13 -2
  49. package/build/dist/UI/Components/ModelTable/TableView.js.map +1 -1
  50. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +84 -0
  51. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -0
  52. package/build/dist/UI/Components/SideMenu/SideMenu.js +15 -5
  53. package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
  54. package/build/dist/UI/Components/Table/Table.js +15 -2
  55. package/build/dist/UI/Components/Table/Table.js.map +1 -1
  56. package/build/dist/UI/Components/Table/TableRow.js +11 -6
  57. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  58. package/build/dist/Utils/UserPreferences.js +33 -0
  59. package/build/dist/Utils/UserPreferences.js.map +1 -1
  60. package/package.json +1 -1
@@ -27,6 +27,70 @@ const MasterPage: FunctionComponent<ComponentProps> = (
27
27
  props: ComponentProps,
28
28
  ): ReactElement => {
29
29
  const [isOnline, setIsOnline] = React.useState(true);
30
+ const topSectionRef: React.RefObject<HTMLDivElement> =
31
+ React.useRef<HTMLDivElement>(null);
32
+
33
+ /*
34
+ * Publish the sticky top section's height as --app-header-height so anything
35
+ * that sticks underneath it (the side menu, for example) can offset itself by
36
+ * the real header instead of guessing and ending up tucked behind it. The
37
+ * height is not a constant — it changes when the navbar is hidden, when the
38
+ * header wraps on narrow viewports and when top alerts appear — so keep it in
39
+ * sync rather than measuring once.
40
+ */
41
+ React.useLayoutEffect(() => {
42
+ const setHeaderHeight: (height: number) => void = (
43
+ height: number,
44
+ ): void => {
45
+ document.documentElement.style.setProperty(
46
+ "--app-header-height",
47
+ `${height}px`,
48
+ );
49
+ };
50
+
51
+ const element: HTMLDivElement | null = topSectionRef.current;
52
+
53
+ if (!element || props.makeTopSectionUnstick) {
54
+ // Nothing is overlaying the content, so no offset is needed.
55
+ setHeaderHeight(0);
56
+ return undefined;
57
+ }
58
+
59
+ const measure: () => void = (): void => {
60
+ setHeaderHeight(element.offsetHeight);
61
+ };
62
+
63
+ measure();
64
+
65
+ /*
66
+ * ResizeObserver is missing in jsdom and in older browsers, so fall back to
67
+ * resize events there — less precise, but the header only really changes
68
+ * height when the viewport does.
69
+ */
70
+ if (typeof ResizeObserver === "undefined") {
71
+ window.addEventListener("resize", measure);
72
+
73
+ return () => {
74
+ window.removeEventListener("resize", measure);
75
+ document.documentElement.style.removeProperty("--app-header-height");
76
+ };
77
+ }
78
+
79
+ const observer: ResizeObserver = new ResizeObserver(measure);
80
+ observer.observe(element);
81
+
82
+ return () => {
83
+ observer.disconnect();
84
+ document.documentElement.style.removeProperty("--app-header-height");
85
+ };
86
+ }, [
87
+ props.makeTopSectionUnstick,
88
+ props.hideHeader,
89
+ props.isLoading,
90
+ props.error,
91
+ props.disableMainContentWrapper,
92
+ isOnline,
93
+ ]);
30
94
 
31
95
  if (props.isLoading) {
32
96
  return (
@@ -62,6 +126,7 @@ const MasterPage: FunctionComponent<ComponentProps> = (
62
126
  </a>
63
127
  )}
64
128
  <div
129
+ ref={topSectionRef}
65
130
  className={props.makeTopSectionUnstick ? "" : "sticky top-0 z-10"}
66
131
  >
67
132
  <TopSection
@@ -58,6 +58,23 @@ import TableColumn from "../Table/Types/Column";
58
58
  import FieldType from "../Types/FieldType";
59
59
  import ModelTableColumn from "./Column";
60
60
  import Columns from "./Columns";
61
+ import ColumnCustomizationModal from "./ColumnCustomizationModal";
62
+ import {
63
+ ColumnPreference,
64
+ CustomizableColumn,
65
+ applyColumnPreference,
66
+ buildColumnPreference,
67
+ fromJSON as columnPreferenceFromJSON,
68
+ getCustomizableColumns,
69
+ isEmptyColumnPreference,
70
+ sanitizeColumnPreference,
71
+ getColumnIds,
72
+ toJSON as columnPreferenceToJSON,
73
+ } from "./ColumnPreference";
74
+ import { CustomFieldsColumnKey } from "./CustomFieldColumns";
75
+ import useCustomFieldColumns, {
76
+ CustomFieldColumnsResult,
77
+ } from "./useCustomFieldColumns";
61
78
  import { getExportKeysFromColumn } from "./ExportFromColumns";
62
79
  import {
63
80
  getRelationSelectFromColumns,
@@ -323,6 +340,26 @@ export interface BaseTableProps<
323
340
  * outlive the page.
324
341
  */
325
342
  disableUrlState?: boolean | undefined;
343
+
344
+ /**
345
+ * Hide the "Columns" control and always render the declared column set.
346
+ *
347
+ * Column customization is on by default for every table that renders
348
+ * columns. Turn it off for tables whose layout is load-bearing — a
349
+ * two-column key/value table, or one the surrounding page reads positions
350
+ * out of.
351
+ */
352
+ disableColumnCustomization?: boolean | undefined;
353
+
354
+ /**
355
+ * The model holding this resource's custom field *definitions* — e.g.
356
+ * `MonitorCustomField` for a table of monitors. Set it and every custom
357
+ * field the project has defined becomes an optional column, off by default
358
+ * and listed in the column picker.
359
+ *
360
+ * Only meaningful for resources with a `customFields` column of their own.
361
+ */
362
+ customFieldsModelType?: { new (): BaseModel } | undefined;
326
363
  }
327
364
 
328
365
  export interface ComponentProps<
@@ -395,6 +432,115 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
395
432
  [],
396
433
  );
397
434
 
435
+ /*
436
+ * ---------------------------------------------------------------------
437
+ * Viewer-customizable columns
438
+ * ---------------------------------------------------------------------
439
+ *
440
+ * The viewer's layout (which columns are on, and in what order) is a
441
+ * personal, presentational preference, so it lives in localStorage next to
442
+ * the page size rather than on the server: saved views are named, explicit,
443
+ * plan-gated and need write permission, none of which should stand between
444
+ * someone and hiding a column they don't care about. A saved view can still
445
+ * carry a layout of its own, and when one is active it wins.
446
+ *
447
+ * Custom fields are appended to the declared columns as additional,
448
+ * off-by-default columns; from here on they are ordinary columns.
449
+ */
450
+ const customFieldColumns: CustomFieldColumnsResult<TBaseModel> =
451
+ useCustomFieldColumns<TBaseModel>({
452
+ customFieldsModelType: props.customFieldsModelType,
453
+ });
454
+
455
+ const isColumnCustomizationEnabled: boolean = Boolean(
456
+ !props.disableColumnCustomization &&
457
+ props.userPreferencesKey &&
458
+ showAs !== ShowAs.OrderedStatesList,
459
+ );
460
+
461
+ const allColumns: Columns<TBaseModel> = useMemo(() => {
462
+ if (customFieldColumns.columns.length === 0) {
463
+ return props.columns || [];
464
+ }
465
+
466
+ return [...(props.columns || []), ...customFieldColumns.columns];
467
+ }, [props.columns, customFieldColumns.columns]);
468
+
469
+ type ReadStoredColumnPreferenceFunction = () => ColumnPreference | null;
470
+
471
+ const readStoredColumnPreference: ReadStoredColumnPreferenceFunction =
472
+ (): ColumnPreference | null => {
473
+ if (!props.userPreferencesKey) {
474
+ return null;
475
+ }
476
+
477
+ return columnPreferenceFromJSON(
478
+ UserPreferences.getUserPreferenceByTypeAsJSON({
479
+ key: props.userPreferencesKey,
480
+ userPreferenceType: UserPreferenceType.BaseModelTableColumns,
481
+ }),
482
+ );
483
+ };
484
+
485
+ const [columnPreference, setColumnPreference] =
486
+ useState<ColumnPreference | null>(readStoredColumnPreference);
487
+
488
+ const [showColumnCustomizationModal, setShowColumnCustomizationModal] =
489
+ useState<boolean>(false);
490
+
491
+ /*
492
+ * Ids are derived over the *whole* declared set, so a column dropping out
493
+ * for lack of permission can never renumber the ones that remain.
494
+ */
495
+ const allColumnIds: Array<string> = useMemo(() => {
496
+ return getColumnIds<TBaseModel>(allColumns);
497
+ }, [allColumns]);
498
+
499
+ const effectiveColumnPreference: ColumnPreference | null = useMemo(() => {
500
+ if (!isColumnCustomizationEnabled) {
501
+ return null;
502
+ }
503
+
504
+ /*
505
+ * A stored layout outlives the release that wrote it, so anything naming
506
+ * a column this table no longer has is dropped before it is applied.
507
+ */
508
+ return sanitizeColumnPreference({
509
+ preference: columnPreference,
510
+ knownColumnIds: allColumnIds,
511
+ });
512
+ }, [columnPreference, allColumnIds, isColumnCustomizationEnabled]);
513
+
514
+ type SaveColumnPreferenceFunction = (
515
+ preference: ColumnPreference | null,
516
+ ) => void;
517
+
518
+ const saveColumnPreference: SaveColumnPreferenceFunction = (
519
+ preference: ColumnPreference | null,
520
+ ): void => {
521
+ setColumnPreference(preference);
522
+
523
+ if (!props.userPreferencesKey) {
524
+ return;
525
+ }
526
+
527
+ const json: JSONObject | null = columnPreferenceToJSON(preference);
528
+
529
+ if (!json) {
530
+ UserPreferences.removeUserPreferenceByType({
531
+ key: props.userPreferencesKey,
532
+ userPreferenceType: UserPreferenceType.BaseModelTableColumns,
533
+ });
534
+ return;
535
+ }
536
+
537
+ UserPreferences.saveUserPreferenceByTypeAsJSON({
538
+ key: props.userPreferencesKey,
539
+ userPreferenceType: UserPreferenceType.BaseModelTableColumns,
540
+ value: json,
541
+ });
542
+ };
543
+
398
544
  const [classicTableFilters, setClassicTableFilters] = useState<
399
545
  Array<ClassicFilterType<TBaseModel>>
400
546
  >([]);
@@ -903,8 +1049,11 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
903
1049
 
904
1050
  const getRelationSelect: GetRelationSelectFunction =
905
1051
  (): Select<TBaseModel> => {
1052
+ /*
1053
+ * Deliberately the *unfiltered* column set. See getSelect() below.
1054
+ */
906
1055
  return getRelationSelectFromColumns<TBaseModel>({
907
- columns: props.columns || [],
1056
+ columns: allColumns,
908
1057
  model: model,
909
1058
  });
910
1059
  };
@@ -950,7 +1099,30 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
950
1099
  const accessControl: Dictionary<ColumnAccessControl> =
951
1100
  model.getColumnAccessControlForAllColumns();
952
1101
 
953
- for (const column of props.columns || []) {
1102
+ /*
1103
+ * The viewer's layout is applied to the *input* of this loop, never to
1104
+ * the finished array: the Actions column is appended below and has to
1105
+ * stay last, and it is generated rather than declared, so it is not the
1106
+ * viewer's to move or switch off.
1107
+ */
1108
+ const columnsToRender: Columns<TBaseModel> =
1109
+ applyColumnPreference<TBaseModel>({
1110
+ columns: allColumns,
1111
+ preference: effectiveColumnPreference,
1112
+ });
1113
+
1114
+ const columnIdByColumn: Map<
1115
+ ModelTableColumn<TBaseModel>,
1116
+ string
1117
+ > = new Map();
1118
+
1119
+ allColumns.forEach(
1120
+ (column: ModelTableColumn<TBaseModel>, index: number) => {
1121
+ columnIdByColumn.set(column, allColumnIds[index] as string);
1122
+ },
1123
+ );
1124
+
1125
+ for (const column of columnsToRender) {
954
1126
  const hasPermission: boolean =
955
1127
  hasPermissionToReadColumn(column) || User.isMasterAdmin();
956
1128
  const key: keyof TBaseModel | null = getColumnKey(column);
@@ -983,6 +1155,7 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
983
1155
 
984
1156
  columns.push({
985
1157
  ...column,
1158
+ id: columnIdByColumn.get(column),
986
1159
  disableSort: column.disableSort || shouldDisableSort(key),
987
1160
  key: columnKey,
988
1161
  /*
@@ -1692,14 +1865,39 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1692
1865
  type GetSelectFunction = () => Select<TBaseModel>;
1693
1866
 
1694
1867
  const getSelect: GetSelectFunction = (): Select<TBaseModel> => {
1868
+ /*
1869
+ * Every declared column, including the ones the viewer has switched off.
1870
+ *
1871
+ * Hiding must not narrow the request. A column's `getElement` is arbitrary
1872
+ * caller code that can read any field on the row — a "Status" cell that
1873
+ * also reads `currentMonitorStatus`, say — so dropping a hidden column's
1874
+ * fields from the select would silently blank a *different*, visible cell,
1875
+ * and there is no way to detect that statically. The sort field is
1876
+ * likewise independent of what is on screen. The cost is a few unused
1877
+ * fields on the wire.
1878
+ */
1695
1879
  const selectFields: Select<TBaseModel> = getSelectFromColumns<TBaseModel>({
1696
- columns: props.columns || [],
1880
+ columns: allColumns,
1697
1881
  model: model,
1698
1882
  hasPermissionToReadField: (field: string): boolean => {
1699
1883
  return hasPermissionToReadField(field as keyof TBaseModel);
1700
1884
  },
1701
1885
  });
1702
1886
 
1887
+ /*
1888
+ * Custom field columns arrive asynchronously, but the table does not
1889
+ * refetch when its column set changes — so the JSON column that backs
1890
+ * every one of them is selected up front, off the synchronous prop, and
1891
+ * the values are there the moment the definitions land.
1892
+ */
1893
+ if (
1894
+ props.customFieldsModelType &&
1895
+ model.hasColumn(CustomFieldsColumnKey) &&
1896
+ hasPermissionToReadField(CustomFieldsColumnKey as keyof TBaseModel)
1897
+ ) {
1898
+ (selectFields as Dictionary<boolean>)[CustomFieldsColumnKey] = true;
1899
+ }
1900
+
1703
1901
  const selectMoreFields: Array<keyof TBaseModel> = props.selectMoreFields
1704
1902
  ? (Object.keys(props.selectMoreFields) as Array<keyof TBaseModel>)
1705
1903
  : [];
@@ -1751,6 +1949,9 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1751
1949
  currentItemsOnPage={itemsOnPage}
1752
1950
  currentSortOrder={sortOrder}
1753
1951
  currentFacetState={props.currentFacetState}
1952
+ currentColumns={
1953
+ columnPreferenceToJSON(effectiveColumnPreference) || undefined
1954
+ }
1754
1955
  onViewChange={async (tableView: TableView | null) => {
1755
1956
  setTableView(tableView);
1756
1957
 
@@ -1785,6 +1986,20 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1785
1986
  (tableView.facets as JSONObject | undefined) || null,
1786
1987
  );
1787
1988
  }
1989
+
1990
+ /*
1991
+ * A saved view carries the columns it was saved with. Views
1992
+ * created before this existed have none, which restores the
1993
+ * table's default layout rather than leaving the previous
1994
+ * view's columns in place.
1995
+ */
1996
+ if (isColumnCustomizationEnabled) {
1997
+ saveColumnPreference(
1998
+ columnPreferenceFromJSON(
1999
+ (tableView.columns as JSONObject | undefined) || null,
2000
+ ),
2001
+ );
2002
+ }
1788
2003
  } else {
1789
2004
  setQuery({});
1790
2005
  /*
@@ -1802,6 +2017,13 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1802
2017
  if (props.onFacetStateRestored) {
1803
2018
  props.onFacetStateRestored(null);
1804
2019
  }
2020
+
2021
+ /*
2022
+ * Columns are deliberately left alone here. Clearing a saved
2023
+ * view means "stop filtering like that", not "throw away the
2024
+ * column layout I built" - which the viewer may well have set
2025
+ * up long before this view existed.
2026
+ */
1805
2027
  }
1806
2028
  }}
1807
2029
  />
@@ -1935,6 +2157,23 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
1935
2157
  });
1936
2158
  }
1937
2159
 
2160
+ /*
2161
+ * Only worth offering once there is a choice to make. A single-column
2162
+ * table has nothing to hide and nothing to reorder.
2163
+ */
2164
+ if (isColumnCustomizationEnabled && allColumns.length > 1) {
2165
+ headerbuttons.push({
2166
+ title: "",
2167
+ buttonStyle: ButtonStyleType.ICON,
2168
+ buttonSize: ButtonSize.Small,
2169
+ className: "",
2170
+ onClick: () => {
2171
+ setShowColumnCustomizationModal(true);
2172
+ },
2173
+ icon: IconProp.TableCells,
2174
+ });
2175
+ }
2176
+
1938
2177
  setCardButtons(headerbuttons);
1939
2178
  };
1940
2179
 
@@ -2108,6 +2347,14 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
2108
2347
  serializeToTableColumns();
2109
2348
  }, [props.columns]);
2110
2349
 
2350
+ /*
2351
+ * The viewer changed their layout, or the project's custom field
2352
+ * definitions finally arrived.
2353
+ */
2354
+ useEffect(() => {
2355
+ serializeToTableColumns();
2356
+ }, [effectiveColumnPreference, customFieldColumns.columns]);
2357
+
2111
2358
  const setActionSchema: VoidFunction = () => {
2112
2359
  const permissions: Array<Permission> = PermissionUtil.getAllPermissions();
2113
2360
 
@@ -3375,6 +3622,8 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
3375
3622
  return "Watch Demo";
3376
3623
  case IconProp.Search:
3377
3624
  return "Search";
3625
+ case IconProp.TableCells:
3626
+ return "Columns";
3378
3627
  default:
3379
3628
  return "Action";
3380
3629
  }
@@ -3581,6 +3830,133 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
3581
3830
  return originalTitle;
3582
3831
  };
3583
3832
 
3833
+ type IsPickableColumnFunction = (
3834
+ column: ModelTableColumn<TBaseModel>,
3835
+ ) => boolean;
3836
+
3837
+ /*
3838
+ * A column the viewer cannot read is left out of the picker entirely.
3839
+ * Listing it would advertise a field they have no access to, and switching
3840
+ * it on would put that field in the select — which the API rejects for the
3841
+ * whole request, blanking the table.
3842
+ */
3843
+ const isPickableColumn: IsPickableColumnFunction = (
3844
+ column: ModelTableColumn<TBaseModel>,
3845
+ ): boolean => {
3846
+ if (column.isNotCustomizable) {
3847
+ return false;
3848
+ }
3849
+
3850
+ return hasPermissionToReadColumn(column) || User.isMasterAdmin();
3851
+ };
3852
+
3853
+ type GetPickerEntriesFunction = (
3854
+ preference: ColumnPreference | null,
3855
+ ) => Array<CustomizableColumn<TBaseModel>>;
3856
+
3857
+ const getPickerEntries: GetPickerEntriesFunction = (
3858
+ preference: ColumnPreference | null,
3859
+ ): Array<CustomizableColumn<TBaseModel>> => {
3860
+ return getCustomizableColumns<TBaseModel>({
3861
+ columns: allColumns,
3862
+ preference: preference,
3863
+ }).filter((entry: CustomizableColumn<TBaseModel>) => {
3864
+ return isPickableColumn(entry.column);
3865
+ });
3866
+ };
3867
+
3868
+ type GetColumnSortKeyFunction = (
3869
+ column: ModelTableColumn<TBaseModel>,
3870
+ ) => string | null;
3871
+
3872
+ // Mirrors how serializeToTableColumns derives the key the header sorts on.
3873
+ const getColumnSortKey: GetColumnSortKeyFunction = (
3874
+ column: ModelTableColumn<TBaseModel>,
3875
+ ): string | null => {
3876
+ const key: keyof TBaseModel | null = getColumnKey(column);
3877
+
3878
+ if (!key) {
3879
+ return null;
3880
+ }
3881
+
3882
+ return column.selectedProperty
3883
+ ? `${String(key)}.${column.selectedProperty}`
3884
+ : String(key);
3885
+ };
3886
+
3887
+ type OnColumnCustomizationSaveFunction = (
3888
+ entries: Array<CustomizableColumn<TBaseModel>>,
3889
+ ) => void;
3890
+
3891
+ const onColumnCustomizationSave: OnColumnCustomizationSaveFunction = (
3892
+ entries: Array<CustomizableColumn<TBaseModel>>,
3893
+ ): void => {
3894
+ const preference: ColumnPreference =
3895
+ buildColumnPreference<TBaseModel>(entries);
3896
+
3897
+ /*
3898
+ * If the layout the viewer just saved matches what the table ships with,
3899
+ * store nothing. Otherwise every table anyone ever opened the picker on
3900
+ * would be pinned to the column set of the release they opened it in, and
3901
+ * columns added later would arrive already stale.
3902
+ */
3903
+ const defaultPreference: ColumnPreference =
3904
+ buildColumnPreference<TBaseModel>(getPickerEntries(null));
3905
+
3906
+ const isBackToDefault: boolean =
3907
+ JSON.stringify(preference) === JSON.stringify(defaultPreference);
3908
+
3909
+ /*
3910
+ * Sorting by a column that is no longer on screen leaves the viewer with
3911
+ * an ordering they can neither see nor undo — there is no header left to
3912
+ * click. Fall back to the table's own default sort.
3913
+ */
3914
+ if (sortBy) {
3915
+ const hiddenIds: Set<string> = new Set(preference.hidden);
3916
+
3917
+ const isSortedColumnHidden: boolean = entries.some(
3918
+ (entry: CustomizableColumn<TBaseModel>) => {
3919
+ return (
3920
+ hiddenIds.has(entry.id) &&
3921
+ getColumnSortKey(entry.column) === String(sortBy)
3922
+ );
3923
+ },
3924
+ );
3925
+
3926
+ if (isSortedColumnHidden) {
3927
+ setSortBy((props.sortBy as keyof TBaseModel | undefined) || null);
3928
+ setSortOrder(props.sortOrder || SortOrder.Ascending);
3929
+ setCurrentPageNumber(1);
3930
+ }
3931
+ }
3932
+
3933
+ saveColumnPreference(isBackToDefault ? null : preference);
3934
+ setShowColumnCustomizationModal(false);
3935
+ };
3936
+
3937
+ const getColumnCustomizationModal: () => ReactElement | null =
3938
+ (): ReactElement | null => {
3939
+ if (!showColumnCustomizationModal || !isColumnCustomizationEnabled) {
3940
+ return null;
3941
+ }
3942
+
3943
+ return (
3944
+ <ColumnCustomizationModal<TBaseModel>
3945
+ columns={getPickerEntries(effectiveColumnPreference)}
3946
+ isDefaultLayout={isEmptyColumnPreference(effectiveColumnPreference)}
3947
+ title={tx("Customize Columns")}
3948
+ onSave={onColumnCustomizationSave}
3949
+ onReset={() => {
3950
+ saveColumnPreference(null);
3951
+ setShowColumnCustomizationModal(false);
3952
+ }}
3953
+ onClose={() => {
3954
+ setShowColumnCustomizationModal(false);
3955
+ }}
3956
+ />
3957
+ );
3958
+ };
3959
+
3584
3960
  const getCardComponent: GetReactElementFunction = (): ReactElement => {
3585
3961
  const headerButtons: Array<CardButtonSchema | ReactElement> =
3586
3962
  getHeaderButtonsWithSearch();
@@ -3601,7 +3977,12 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
3601
3977
  getCardTitle(props.cardProps.title || ""),
3602
3978
  )}
3603
3979
  >
3604
- {tableColumns.length === 0 && props.columns.length > 0 ? (
3980
+ {/*
3981
+ * A viewer's layout can never empty this out — applyColumnPreference
3982
+ * refuses to return nothing — so zero columns still means exactly
3983
+ * what it always did: every column was denied by permission.
3984
+ */}
3985
+ {tableColumns.length === 0 && allColumns.length > 0 ? (
3605
3986
  <ErrorMessage
3606
3987
  message={`You are not authorized to view this table. You need any one of these permissions: ${PermissionHelper.getPermissionTitles(
3607
3988
  model.getReadPermissions(),
@@ -3799,6 +4180,8 @@ const BaseModelTable: <TBaseModel extends BaseModel | AnalyticsBaseModel>(
3799
4180
  </div>
3800
4181
  </Modal>
3801
4182
  )}
4183
+
4184
+ {getColumnCustomizationModal()}
3802
4185
  </>
3803
4186
  );
3804
4187
  };
@@ -19,6 +19,23 @@ export default interface Columns<
19
19
  field: SelectEntityField<TEntity>;
20
20
  selectedProperty?: string | undefined;
21
21
  title: string;
22
+ /*
23
+ * Stable identity for this column, used to persist the viewer's show/hide
24
+ * and ordering choices. Leave it unset and one is derived from the declared
25
+ * field (see ColumnPreference.getColumnIds) - set it only when the derived
26
+ * id would be unstable, e.g. a cell rendered entirely through `getElement`
27
+ * off a placeholder `field: { _id: true }` whose title is likely to change.
28
+ */
29
+ id?: string | undefined;
30
+ /*
31
+ * Keep this column out of the "Customize Columns" picker: it is always
32
+ * shown and can never be moved. Use it for the column that identifies the
33
+ * row (usually the name), so a table can never be customized into
34
+ * anonymity.
35
+ */
36
+ isNotCustomizable?: boolean | undefined;
37
+ // Start hidden. The viewer can still switch it on from the picker.
38
+ isHiddenByDefault?: boolean | undefined;
22
39
  contentClassName?: string | undefined;
23
40
  colSpan?: number | undefined;
24
41
  disableSort?: boolean;