@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.
- package/Models/AnalyticsModels/KubernetesCostAllocation.ts +103 -0
- package/Models/DatabaseModels/TableView.ts +40 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.ts +13 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Tests/Models/AnalyticsModels/KubernetesCostAllocation.test.ts +16 -0
- package/Tests/Types/JSONFunctions.test.ts +103 -1
- package/Tests/UI/Components/ModelTable/BaseModelTableColumnCustomization.test.tsx +679 -0
- package/Tests/UI/Components/ModelTable/ColumnCustomizationModal.test.tsx +572 -0
- package/Tests/UI/Components/ModelTable/ColumnPreference.test.ts +1678 -0
- package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +1094 -0
- package/Tests/Utils/UserPreferences.test.ts +563 -0
- package/Types/JSONFunctions.ts +7 -2
- package/Types/Kubernetes/KubernetesCostIngest.ts +37 -1
- package/UI/Components/MasterPage/MasterPage.tsx +65 -0
- package/UI/Components/ModelTable/BaseModelTable.tsx +387 -4
- package/UI/Components/ModelTable/Column.ts +17 -0
- package/UI/Components/ModelTable/ColumnCustomizationModal.tsx +420 -0
- package/UI/Components/ModelTable/ColumnPreference.ts +482 -0
- package/UI/Components/ModelTable/CustomFieldColumns.tsx +326 -0
- package/UI/Components/ModelTable/TableView.tsx +24 -2
- package/UI/Components/ModelTable/useCustomFieldColumns.ts +150 -0
- package/UI/Components/SideMenu/SideMenu.tsx +24 -4
- package/UI/Components/Table/Table.tsx +14 -1
- package/UI/Components/Table/TableRow.tsx +180 -175
- package/UI/Components/Table/Types/Column.ts +2 -0
- package/Utils/UserPreferences.ts +53 -0
- package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js +86 -0
- package/build/dist/Models/AnalyticsModels/KubernetesCostAllocation.js.map +1 -1
- package/build/dist/Models/DatabaseModels/TableView.js +40 -0
- package/build/dist/Models/DatabaseModels/TableView.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.js +12 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785241000000-AddColumnsToTableView.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Types/JSONFunctions.js +8 -3
- package/build/dist/Types/JSONFunctions.js.map +1 -1
- package/build/dist/Types/Kubernetes/KubernetesCostIngest.js.map +1 -1
- package/build/dist/UI/Components/MasterPage/MasterPage.js +50 -1
- package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js +239 -7
- package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/ColumnCustomizationModal.js +189 -0
- package/build/dist/UI/Components/ModelTable/ColumnCustomizationModal.js.map +1 -0
- package/build/dist/UI/Components/ModelTable/ColumnPreference.js +258 -0
- package/build/dist/UI/Components/ModelTable/ColumnPreference.js.map +1 -0
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +168 -0
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -0
- package/build/dist/UI/Components/ModelTable/TableView.js +13 -2
- package/build/dist/UI/Components/ModelTable/TableView.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +84 -0
- package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -0
- package/build/dist/UI/Components/SideMenu/SideMenu.js +15 -5
- package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
- package/build/dist/UI/Components/Table/Table.js +15 -2
- package/build/dist/UI/Components/Table/Table.js.map +1 -1
- package/build/dist/UI/Components/Table/TableRow.js +11 -6
- package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
- package/build/dist/Utils/UserPreferences.js +33 -0
- package/build/dist/Utils/UserPreferences.js.map +1 -1
- 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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
{
|
|
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;
|