@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
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import Column from "./Column";
|
|
2
|
+
import Columns from "./Columns";
|
|
3
|
+
import FieldType from "../Types/FieldType";
|
|
4
|
+
import AnalyticsBaseModel from "../../../Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
|
|
5
|
+
import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
|
|
6
|
+
import CustomFieldType from "../../../Types/CustomField/CustomFieldType";
|
|
7
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
8
|
+
import React, { ReactElement } from "react";
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* ---------------------------------------------------------------------------
|
|
12
|
+
* One table column per custom field
|
|
13
|
+
* ---------------------------------------------------------------------------
|
|
14
|
+
*
|
|
15
|
+
* Custom field *definitions* live in a per-resource table (MonitorCustomField,
|
|
16
|
+
* IncidentCustomField, ...); the *values* live in a single `customFields`
|
|
17
|
+
* jsonb column on the resource itself, keyed by the definition's name. So a
|
|
18
|
+
* generated column reads the whole JSON column and picks one key out of it:
|
|
19
|
+
*
|
|
20
|
+
* field: { customFields: true } <- a real model column
|
|
21
|
+
* selectedProperty: "Severity" <- the key inside it
|
|
22
|
+
*
|
|
23
|
+
* which BaseModelTable turns into the column key "customFields.Severity".
|
|
24
|
+
*
|
|
25
|
+
* Two things about that shape are load-bearing:
|
|
26
|
+
*
|
|
27
|
+
* - the declared field has to be the real column. A synthetic
|
|
28
|
+
* `field: { "customFields.Severity": true }` would fail the model's column
|
|
29
|
+
* check in getSelectFromColumns and throw the whole table away.
|
|
30
|
+
*
|
|
31
|
+
* - sorting has to be off. Nothing downstream validates `sort` before it
|
|
32
|
+
* reaches TypeORM, so a clickable header here would send an unknown
|
|
33
|
+
* property into the query builder. Postgres can only order by a jsonb
|
|
34
|
+
* key through an explicit ->> expression, which the sort path has no
|
|
35
|
+
* concept of.
|
|
36
|
+
*
|
|
37
|
+
* Cells render through `getElement` for the same class of reason: the typed
|
|
38
|
+
* cell renderers index `item[column.key]` directly, so they would look for a
|
|
39
|
+
* literal "customFields.Severity" property and find nothing. Only the
|
|
40
|
+
* fall-through text branch understands a dotted key.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export interface CustomFieldDefinition {
|
|
44
|
+
name: string;
|
|
45
|
+
description?: string | undefined;
|
|
46
|
+
customFieldType?: CustomFieldType | undefined;
|
|
47
|
+
dropdownOptions?: string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const CustomFieldsColumnKey: string = "customFields";
|
|
51
|
+
|
|
52
|
+
export type ParseDropdownOptionsFunction = (
|
|
53
|
+
value: string | undefined | null,
|
|
54
|
+
) => Array<string>;
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
* Dropdown options are stored as one option per line, the same format the
|
|
58
|
+
* settings page writes.
|
|
59
|
+
*/
|
|
60
|
+
export const parseDropdownOptions: ParseDropdownOptionsFunction = (
|
|
61
|
+
value: string | undefined | null,
|
|
62
|
+
): Array<string> => {
|
|
63
|
+
if (typeof value !== "string" || !value) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return value
|
|
68
|
+
.split("\n")
|
|
69
|
+
.map((line: string) => {
|
|
70
|
+
return line.trim();
|
|
71
|
+
})
|
|
72
|
+
.filter((line: string) => {
|
|
73
|
+
return line.length > 0;
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type GetCustomFieldDefinitionsFunction = (
|
|
78
|
+
items: Array<BaseModel | JSONObject>,
|
|
79
|
+
) => Array<CustomFieldDefinition>;
|
|
80
|
+
|
|
81
|
+
/*
|
|
82
|
+
* Normalise whatever the definition API returned into a plain, sorted list.
|
|
83
|
+
*
|
|
84
|
+
* Sorting matters: the definition models carry no ordering column and the
|
|
85
|
+
* list endpoint is queried with an empty sort, so Postgres is free to hand
|
|
86
|
+
* back a different order on every request. Without a deterministic order
|
|
87
|
+
* here, a viewer's saved column layout would appear to shuffle itself
|
|
88
|
+
* between page loads.
|
|
89
|
+
*/
|
|
90
|
+
export const getCustomFieldDefinitions: GetCustomFieldDefinitionsFunction = (
|
|
91
|
+
items: Array<BaseModel | JSONObject>,
|
|
92
|
+
): Array<CustomFieldDefinition> => {
|
|
93
|
+
const definitions: Array<CustomFieldDefinition> = [];
|
|
94
|
+
const seenNames: Set<string> = new Set();
|
|
95
|
+
|
|
96
|
+
for (const item of items || []) {
|
|
97
|
+
const name: unknown = (item as JSONObject)?.["name"];
|
|
98
|
+
|
|
99
|
+
if (typeof name !== "string" || name.trim().length === 0) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const trimmedName: string = name.trim();
|
|
104
|
+
|
|
105
|
+
if (seenNames.has(trimmedName)) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
seenNames.add(trimmedName);
|
|
110
|
+
|
|
111
|
+
const description: unknown = (item as JSONObject)["description"];
|
|
112
|
+
const customFieldType: unknown = (item as JSONObject)["customFieldType"];
|
|
113
|
+
const dropdownOptions: unknown = (item as JSONObject)["dropdownOptions"];
|
|
114
|
+
|
|
115
|
+
definitions.push({
|
|
116
|
+
name: trimmedName,
|
|
117
|
+
description: typeof description === "string" ? description : undefined,
|
|
118
|
+
customFieldType:
|
|
119
|
+
typeof customFieldType === "string"
|
|
120
|
+
? (customFieldType as CustomFieldType)
|
|
121
|
+
: undefined,
|
|
122
|
+
dropdownOptions:
|
|
123
|
+
typeof dropdownOptions === "string" ? dropdownOptions : undefined,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return definitions.sort(
|
|
128
|
+
(a: CustomFieldDefinition, b: CustomFieldDefinition) => {
|
|
129
|
+
return a.name.localeCompare(b.name);
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export type GetCustomFieldValueFunction = (
|
|
135
|
+
item: unknown,
|
|
136
|
+
name: string,
|
|
137
|
+
) => unknown;
|
|
138
|
+
|
|
139
|
+
export const getCustomFieldValue: GetCustomFieldValueFunction = (
|
|
140
|
+
item: unknown,
|
|
141
|
+
name: string,
|
|
142
|
+
): unknown => {
|
|
143
|
+
const customFields: unknown = (item as JSONObject | undefined)?.[
|
|
144
|
+
CustomFieldsColumnKey
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
if (!customFields || typeof customFields !== "object") {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (customFields as JSONObject)[name];
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
type BadgeFunction = (data: { label: string; key: string }) => ReactElement;
|
|
155
|
+
|
|
156
|
+
const badge: BadgeFunction = (data: {
|
|
157
|
+
label: string;
|
|
158
|
+
key: string;
|
|
159
|
+
}): ReactElement => {
|
|
160
|
+
return (
|
|
161
|
+
<span
|
|
162
|
+
key={data.key}
|
|
163
|
+
className="inline-flex items-center gap-x-1.5 px-2 py-1 rounded-md bg-indigo-50 text-indigo-700 text-xs font-medium ring-1 ring-inset ring-indigo-700/10"
|
|
164
|
+
>
|
|
165
|
+
<svg
|
|
166
|
+
className="h-1.5 w-1.5 fill-indigo-500"
|
|
167
|
+
viewBox="0 0 6 6"
|
|
168
|
+
aria-hidden="true"
|
|
169
|
+
>
|
|
170
|
+
<circle cx={3} cy={3} r={3} />
|
|
171
|
+
</svg>
|
|
172
|
+
{data.label}
|
|
173
|
+
</span>
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export type RenderCustomFieldValueFunction = (data: {
|
|
178
|
+
value: unknown;
|
|
179
|
+
definition: CustomFieldDefinition;
|
|
180
|
+
noValueMessage?: string | undefined;
|
|
181
|
+
}) => ReactElement;
|
|
182
|
+
|
|
183
|
+
export const renderCustomFieldValue: RenderCustomFieldValueFunction = (data: {
|
|
184
|
+
value: unknown;
|
|
185
|
+
definition: CustomFieldDefinition;
|
|
186
|
+
noValueMessage?: string | undefined;
|
|
187
|
+
}): ReactElement => {
|
|
188
|
+
const { value, definition } = data;
|
|
189
|
+
const noValueMessage: string = data.noValueMessage ?? "-";
|
|
190
|
+
|
|
191
|
+
const empty: ReactElement = (
|
|
192
|
+
<span className="text-gray-400">{noValueMessage}</span>
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (value === undefined || value === null || value === "") {
|
|
196
|
+
return empty;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (definition.customFieldType === CustomFieldType.Boolean) {
|
|
200
|
+
/*
|
|
201
|
+
* A boolean custom field renders "No" rather than falling into the empty
|
|
202
|
+
* branch above - `false` is a real answer, not a missing one.
|
|
203
|
+
*/
|
|
204
|
+
const isTrue: boolean = value === true || value === "true";
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<span
|
|
208
|
+
className={
|
|
209
|
+
isTrue
|
|
210
|
+
? "inline-flex items-center px-2 py-1 rounded-md bg-green-50 text-green-700 text-xs font-medium ring-1 ring-inset ring-green-600/20"
|
|
211
|
+
: "inline-flex items-center px-2 py-1 rounded-md bg-gray-50 text-gray-600 text-xs font-medium ring-1 ring-inset ring-gray-500/10"
|
|
212
|
+
}
|
|
213
|
+
>
|
|
214
|
+
{isTrue ? "Yes" : "No"}
|
|
215
|
+
</span>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (definition.customFieldType === CustomFieldType.MultiSelectDropdown) {
|
|
220
|
+
/*
|
|
221
|
+
* Tolerant on the way out: an older single-select value that was later
|
|
222
|
+
* switched to multi-select is still a bare string in the JSON.
|
|
223
|
+
*/
|
|
224
|
+
const values: Array<unknown> = Array.isArray(value) ? value : [value];
|
|
225
|
+
|
|
226
|
+
const labels: Array<string> = values
|
|
227
|
+
.filter((entry: unknown) => {
|
|
228
|
+
return entry !== undefined && entry !== null && entry !== "";
|
|
229
|
+
})
|
|
230
|
+
.map((entry: unknown) => {
|
|
231
|
+
return String(entry);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
if (labels.length === 0) {
|
|
235
|
+
return empty;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<div className="flex flex-wrap gap-1.5">
|
|
240
|
+
{labels.map((label: string, index: number) => {
|
|
241
|
+
return badge({ label, key: `${label}-${index}` });
|
|
242
|
+
})}
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (definition.customFieldType === CustomFieldType.Dropdown) {
|
|
248
|
+
return badge({ label: String(value), key: String(value) });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (Array.isArray(value)) {
|
|
252
|
+
return <span>{value.join(", ")}</span>;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (typeof value === "object") {
|
|
256
|
+
return <span>{JSON.stringify(value)}</span>;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return <span>{String(value)}</span>;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export type GetCustomFieldColumnsFunction = <
|
|
263
|
+
TBaseModel extends BaseModel | AnalyticsBaseModel,
|
|
264
|
+
>(data: {
|
|
265
|
+
definitions: Array<CustomFieldDefinition>;
|
|
266
|
+
isHiddenByDefault?: boolean | undefined;
|
|
267
|
+
}) => Columns<TBaseModel>;
|
|
268
|
+
|
|
269
|
+
/*
|
|
270
|
+
* Custom field columns default to hidden. A project is free to define a dozen
|
|
271
|
+
* of them, and turning every one on by default would push the columns the
|
|
272
|
+
* table was designed around off the side of the screen the first time anyone
|
|
273
|
+
* opens it. They are all listed in the column picker, one click away.
|
|
274
|
+
*/
|
|
275
|
+
export const getCustomFieldColumns: GetCustomFieldColumnsFunction = <
|
|
276
|
+
TBaseModel extends BaseModel | AnalyticsBaseModel,
|
|
277
|
+
>(data: {
|
|
278
|
+
definitions: Array<CustomFieldDefinition>;
|
|
279
|
+
isHiddenByDefault?: boolean | undefined;
|
|
280
|
+
}): Columns<TBaseModel> => {
|
|
281
|
+
const isHidden: boolean = data.isHiddenByDefault !== false;
|
|
282
|
+
|
|
283
|
+
return (data.definitions || []).map(
|
|
284
|
+
(definition: CustomFieldDefinition): Column<TBaseModel> => {
|
|
285
|
+
return {
|
|
286
|
+
id: `${CustomFieldsColumnKey}.${definition.name}`,
|
|
287
|
+
field: {
|
|
288
|
+
[CustomFieldsColumnKey]: true,
|
|
289
|
+
} as Column<TBaseModel>["field"],
|
|
290
|
+
selectedProperty: definition.name,
|
|
291
|
+
title: definition.name,
|
|
292
|
+
description: definition.description,
|
|
293
|
+
type: FieldType.Element,
|
|
294
|
+
/*
|
|
295
|
+
* See the file header: sorting a jsonb key is not something the
|
|
296
|
+
* query path can express.
|
|
297
|
+
*/
|
|
298
|
+
disableSort: true,
|
|
299
|
+
isHiddenByDefault: isHidden,
|
|
300
|
+
getExportValue: (item: TBaseModel): string => {
|
|
301
|
+
const value: unknown = getCustomFieldValue(item, definition.name);
|
|
302
|
+
|
|
303
|
+
if (value === undefined || value === null) {
|
|
304
|
+
return "";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (Array.isArray(value)) {
|
|
308
|
+
return value.join(", ");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (typeof value === "object") {
|
|
312
|
+
return JSON.stringify(value);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return String(value);
|
|
316
|
+
},
|
|
317
|
+
getElement: (item: TBaseModel): ReactElement => {
|
|
318
|
+
return renderCustomFieldValue({
|
|
319
|
+
value: getCustomFieldValue(item, definition.name),
|
|
320
|
+
definition: definition,
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
},
|
|
325
|
+
);
|
|
326
|
+
};
|
|
@@ -40,6 +40,13 @@ export interface ComponentProps<T extends GenericObject> {
|
|
|
40
40
|
* `TableView.facets`). Owner: the parent's filter hook.
|
|
41
41
|
*/
|
|
42
42
|
currentFacetState?: JSONObject | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* The viewer's current column layout ({ order, hidden }), persisted on
|
|
45
|
+
* `TableView.columns` so a saved view restores the columns it was saved
|
|
46
|
+
* with. `undefined` means "the table's default layout", which is stored as
|
|
47
|
+
* no value at all.
|
|
48
|
+
*/
|
|
49
|
+
currentColumns?: JSONObject | undefined;
|
|
43
50
|
tableView: TableView | null;
|
|
44
51
|
}
|
|
45
52
|
|
|
@@ -90,6 +97,7 @@ const TableViewElement: <T extends DatabaseBaseModel | AnalyticsBaseModel>(
|
|
|
90
97
|
query: true,
|
|
91
98
|
name: true,
|
|
92
99
|
facets: true,
|
|
100
|
+
columns: true,
|
|
93
101
|
},
|
|
94
102
|
sort: {
|
|
95
103
|
name: SortOrder.Ascending,
|
|
@@ -223,6 +231,17 @@ const TableViewElement: <T extends DatabaseBaseModel | AnalyticsBaseModel>(
|
|
|
223
231
|
|
|
224
232
|
const hasActiveFilters: boolean = hasQueryFilters || hasFacetState;
|
|
225
233
|
|
|
234
|
+
/*
|
|
235
|
+
* A customized column layout is worth saving on its own - "the columns I
|
|
236
|
+
* care about, in my order" is a view even when nothing is filtered.
|
|
237
|
+
*/
|
|
238
|
+
const hasColumnLayout: boolean = Boolean(
|
|
239
|
+
props.currentColumns &&
|
|
240
|
+
Object.keys(props.currentColumns as JSONObject).length > 0,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const hasSavableState: boolean = hasActiveFilters || hasColumnLayout;
|
|
244
|
+
|
|
226
245
|
const getMenuContents: GetReactElementArrayFunction =
|
|
227
246
|
(): Array<ReactElement> => {
|
|
228
247
|
if (isLoading) {
|
|
@@ -239,7 +258,7 @@ const TableViewElement: <T extends DatabaseBaseModel | AnalyticsBaseModel>(
|
|
|
239
258
|
);
|
|
240
259
|
}
|
|
241
260
|
|
|
242
|
-
if (
|
|
261
|
+
if (hasSavableState) {
|
|
243
262
|
elements.push(
|
|
244
263
|
<MoreMenuItem
|
|
245
264
|
key={"save-new-view"}
|
|
@@ -359,6 +378,9 @@ const TableViewElement: <T extends DatabaseBaseModel | AnalyticsBaseModel>(
|
|
|
359
378
|
if (props.currentFacetState) {
|
|
360
379
|
tableView.facets = props.currentFacetState;
|
|
361
380
|
}
|
|
381
|
+
if (props.currentColumns) {
|
|
382
|
+
tableView.columns = props.currentColumns;
|
|
383
|
+
}
|
|
362
384
|
return Promise.resolve(tableView);
|
|
363
385
|
}}
|
|
364
386
|
onSuccess={async (tableView: TableView) => {
|
|
@@ -476,7 +498,7 @@ const TableViewElement: <T extends DatabaseBaseModel | AnalyticsBaseModel>(
|
|
|
476
498
|
if (
|
|
477
499
|
!isLoading &&
|
|
478
500
|
allTableViews.length === 0 &&
|
|
479
|
-
!
|
|
501
|
+
!hasSavableState &&
|
|
480
502
|
!currentlySelectedView
|
|
481
503
|
) {
|
|
482
504
|
return <></>;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import Columns from "./Columns";
|
|
2
|
+
import {
|
|
3
|
+
CustomFieldDefinition,
|
|
4
|
+
getCustomFieldColumns,
|
|
5
|
+
getCustomFieldDefinitions,
|
|
6
|
+
} from "./CustomFieldColumns";
|
|
7
|
+
import ModelAPI from "../../Utils/ModelAPI/ModelAPI";
|
|
8
|
+
import ProjectUtil from "../../Utils/Project";
|
|
9
|
+
import { Logger } from "../../Utils/Logger";
|
|
10
|
+
import AnalyticsBaseModel from "../../../Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel";
|
|
11
|
+
import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
|
|
12
|
+
import ListResult from "../../../Types/BaseDatabase/ListResult";
|
|
13
|
+
import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
|
|
14
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
15
|
+
import { useEffect, useMemo, useState } from "react";
|
|
16
|
+
|
|
17
|
+
export interface CustomFieldColumnsResult<
|
|
18
|
+
TBaseModel extends BaseModel | AnalyticsBaseModel,
|
|
19
|
+
> {
|
|
20
|
+
columns: Columns<TBaseModel>;
|
|
21
|
+
definitions: Array<CustomFieldDefinition>;
|
|
22
|
+
isLoading: boolean;
|
|
23
|
+
error: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
* Loads a resource's custom field *definitions* and turns them into table
|
|
28
|
+
* columns.
|
|
29
|
+
*
|
|
30
|
+
* The definitions arrive after the table's first render, which is fine: they
|
|
31
|
+
* only add columns, and every one of them is hidden until the viewer switches
|
|
32
|
+
* it on. What must NOT wait on this request is the `customFields` entry in the
|
|
33
|
+
* API select - the table does not refetch when its column set changes, so the
|
|
34
|
+
* select has to be right from the first request. BaseModelTable handles that
|
|
35
|
+
* from the synchronous `customFieldsModelType` prop instead.
|
|
36
|
+
*
|
|
37
|
+
* Pass `undefined` for the model type to switch the whole thing off; the hook
|
|
38
|
+
* still runs (hooks cannot be conditional) but makes no request.
|
|
39
|
+
*/
|
|
40
|
+
export type UseCustomFieldColumnsFunction = <
|
|
41
|
+
TBaseModel extends BaseModel | AnalyticsBaseModel,
|
|
42
|
+
>(data: {
|
|
43
|
+
customFieldsModelType?: { new (): BaseModel } | undefined;
|
|
44
|
+
projectId?: ObjectID | null | undefined;
|
|
45
|
+
}) => CustomFieldColumnsResult<TBaseModel>;
|
|
46
|
+
|
|
47
|
+
const useCustomFieldColumns: UseCustomFieldColumnsFunction = <
|
|
48
|
+
TBaseModel extends BaseModel | AnalyticsBaseModel,
|
|
49
|
+
>(data: {
|
|
50
|
+
customFieldsModelType?: { new (): BaseModel } | undefined;
|
|
51
|
+
projectId?: ObjectID | null | undefined;
|
|
52
|
+
}): CustomFieldColumnsResult<TBaseModel> => {
|
|
53
|
+
const { customFieldsModelType } = data;
|
|
54
|
+
|
|
55
|
+
const [definitions, setDefinitions] = useState<Array<CustomFieldDefinition>>(
|
|
56
|
+
[],
|
|
57
|
+
);
|
|
58
|
+
const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
59
|
+
const [error, setError] = useState<string>("");
|
|
60
|
+
|
|
61
|
+
const projectId: ObjectID | null =
|
|
62
|
+
data.projectId ?? ProjectUtil.getCurrentProjectId();
|
|
63
|
+
|
|
64
|
+
const projectIdString: string = projectId?.toString() || "";
|
|
65
|
+
const modelName: string = customFieldsModelType?.name || "";
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!customFieldsModelType || !projectId) {
|
|
69
|
+
setDefinitions([]);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let isCancelled: boolean = false;
|
|
74
|
+
|
|
75
|
+
const load: () => Promise<void> = async (): Promise<void> => {
|
|
76
|
+
setIsLoading(true);
|
|
77
|
+
setError("");
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const result: ListResult<BaseModel> = await ModelAPI.getList<BaseModel>(
|
|
81
|
+
{
|
|
82
|
+
modelType: customFieldsModelType,
|
|
83
|
+
query: {
|
|
84
|
+
projectId: projectId,
|
|
85
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
86
|
+
} as any,
|
|
87
|
+
limit: LIMIT_PER_PROJECT,
|
|
88
|
+
skip: 0,
|
|
89
|
+
select: {
|
|
90
|
+
name: true,
|
|
91
|
+
customFieldType: true,
|
|
92
|
+
description: true,
|
|
93
|
+
dropdownOptions: true,
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
95
|
+
} as any,
|
|
96
|
+
sort: {},
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (isCancelled) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setDefinitions(getCustomFieldDefinitions(result.data || []));
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (isCancelled) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/*
|
|
111
|
+
* Custom fields are a paid feature and the caller may not have read
|
|
112
|
+
* access to the definition table, so a failure here is expected in
|
|
113
|
+
* normal operation. It costs the viewer some optional columns; it must
|
|
114
|
+
* never take the table down with it.
|
|
115
|
+
*/
|
|
116
|
+
Logger.warn(
|
|
117
|
+
`Could not load custom field definitions for ${modelName}: ${
|
|
118
|
+
(err as Error)?.message || String(err)
|
|
119
|
+
}`,
|
|
120
|
+
);
|
|
121
|
+
setDefinitions([]);
|
|
122
|
+
setError((err as Error)?.message || "");
|
|
123
|
+
} finally {
|
|
124
|
+
if (!isCancelled) {
|
|
125
|
+
setIsLoading(false);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
load().catch(() => {
|
|
131
|
+
// load() already funnels every failure into the catch above.
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return () => {
|
|
135
|
+
isCancelled = true;
|
|
136
|
+
};
|
|
137
|
+
}, [modelName, projectIdString]);
|
|
138
|
+
|
|
139
|
+
const columns: Columns<TBaseModel> = useMemo(() => {
|
|
140
|
+
if (definitions.length === 0) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return getCustomFieldColumns<TBaseModel>({ definitions });
|
|
145
|
+
}, [definitions]);
|
|
146
|
+
|
|
147
|
+
return { columns, definitions, isLoading, error };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export default useCustomFieldColumns;
|
|
@@ -415,30 +415,50 @@ const SideMenu: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
|
|
|
415
415
|
role="navigation"
|
|
416
416
|
aria-label="Main navigation"
|
|
417
417
|
>
|
|
418
|
-
|
|
418
|
+
{/*
|
|
419
|
+
* Stick below the app header, not at the top of the viewport. The header
|
|
420
|
+
* is sticky too, so a plain `top-6` slides the first menu items behind it
|
|
421
|
+
* as soon as the page scrolls. --app-header-height is measured and
|
|
422
|
+
* published by MasterPage; the 0px fallback keeps this correct on pages
|
|
423
|
+
* that render without a sticky header.
|
|
424
|
+
*/}
|
|
425
|
+
<div
|
|
426
|
+
className="sticky"
|
|
427
|
+
style={{ top: "calc(var(--app-header-height, 0px) + 1.5rem)" }}
|
|
428
|
+
>
|
|
419
429
|
<div
|
|
420
430
|
className={`
|
|
431
|
+
flex flex-col
|
|
421
432
|
bg-white rounded-2xl
|
|
422
433
|
border border-gray-200/80
|
|
423
434
|
shadow-sm
|
|
424
435
|
overflow-hidden
|
|
425
436
|
`}
|
|
437
|
+
/*
|
|
438
|
+
* Menus longer than the viewport (the Kubernetes cluster menu, for
|
|
439
|
+
* one) would otherwise get pushed back up behind the header near the
|
|
440
|
+
* bottom of a long page. Cap the card to the space left under the
|
|
441
|
+
* header and let the item list scroll inside it instead.
|
|
442
|
+
*/
|
|
443
|
+
style={{
|
|
444
|
+
maxHeight: "calc(100vh - var(--app-header-height, 0px) - 3rem)",
|
|
445
|
+
}}
|
|
426
446
|
>
|
|
427
447
|
{/* Optional Header */}
|
|
428
448
|
{props.header && (
|
|
429
|
-
<div className="px-3 py-3 border-b border-gray-100 bg-gradient-to-b from-gray-50 to-white">
|
|
449
|
+
<div className="flex-shrink-0 px-3 py-3 border-b border-gray-100 bg-gradient-to-b from-gray-50 to-white">
|
|
430
450
|
{props.header}
|
|
431
451
|
</div>
|
|
432
452
|
)}
|
|
433
453
|
|
|
434
454
|
{/* Menu Content */}
|
|
435
|
-
<nav className="p-2">
|
|
455
|
+
<nav className="p-2 min-h-0 overflow-y-auto">
|
|
436
456
|
<div className="space-y-0.5">{renderMenuContent()}</div>
|
|
437
457
|
</nav>
|
|
438
458
|
|
|
439
459
|
{/* Optional Footer */}
|
|
440
460
|
{props.footer && (
|
|
441
|
-
<div className="px-3 py-2 border-t border-gray-100 bg-gray-50/50">
|
|
461
|
+
<div className="flex-shrink-0 px-3 py-2 border-t border-gray-100 bg-gray-50/50">
|
|
442
462
|
{props.footer}
|
|
443
463
|
</div>
|
|
444
464
|
)}
|
|
@@ -181,10 +181,23 @@ const Table: TableFunction = <T extends GenericObject>(
|
|
|
181
181
|
}
|
|
182
182
|
}, [props.bulkSelectedItems]);
|
|
183
183
|
|
|
184
|
+
/*
|
|
185
|
+
* Width of the loading / error / "no items" row, in cells. It has to match
|
|
186
|
+
* what TableHeader actually renders or those messages sit under part of the
|
|
187
|
+
* table instead of spanning it: the Actions column is already one of
|
|
188
|
+
* `props.columns`, and the drag-handle and bulk-select cells are extra
|
|
189
|
+
* leading columns that only exist when those features are on.
|
|
190
|
+
*/
|
|
184
191
|
let colspan: number = props.columns.length || 0;
|
|
185
|
-
if (props.
|
|
192
|
+
if (props.enableDragAndDrop) {
|
|
186
193
|
colspan++;
|
|
187
194
|
}
|
|
195
|
+
if (isBulkActionsEnabled) {
|
|
196
|
+
colspan++;
|
|
197
|
+
}
|
|
198
|
+
if (colspan === 0) {
|
|
199
|
+
colspan = 1;
|
|
200
|
+
}
|
|
188
201
|
|
|
189
202
|
const getTablebody: GetReactElementFunction = (): ReactElement => {
|
|
190
203
|
if (props.isLoading) {
|