@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,1678 @@
|
|
|
1
|
+
import Column from "../../../../UI/Components/ModelTable/Column";
|
|
2
|
+
import Columns from "../../../../UI/Components/ModelTable/Columns";
|
|
3
|
+
import {
|
|
4
|
+
ColumnPreference,
|
|
5
|
+
CustomizableColumn,
|
|
6
|
+
EmptyColumnPreference,
|
|
7
|
+
applyColumnPreference,
|
|
8
|
+
buildColumnPreference,
|
|
9
|
+
fromJSON,
|
|
10
|
+
getColumnBaseId,
|
|
11
|
+
getColumnIds,
|
|
12
|
+
getCustomizableColumns,
|
|
13
|
+
isEmptyColumnPreference,
|
|
14
|
+
sanitizeColumnPreference,
|
|
15
|
+
toJSON,
|
|
16
|
+
} from "../../../../UI/Components/ModelTable/ColumnPreference";
|
|
17
|
+
import FieldType from "../../../../UI/Components/Types/FieldType";
|
|
18
|
+
import Monitor from "../../../../Models/DatabaseModels/Monitor";
|
|
19
|
+
import { JSONObject } from "../../../../Types/JSON";
|
|
20
|
+
import { beforeEach, describe, expect, test } from "@jest/globals";
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* A ModelTable's column layout is per viewer and is persisted, which means it
|
|
24
|
+
* routinely outlives the release that produced it: the column set is edited
|
|
25
|
+
* every time someone touches the page, and for tables with custom fields the
|
|
26
|
+
* set is not even fixed within a release.
|
|
27
|
+
*
|
|
28
|
+
* So the module has one job that everything else serves — a stored layout must
|
|
29
|
+
* degrade gracefully. Two invariants carry that:
|
|
30
|
+
*
|
|
31
|
+
* 1. Identity comes from the declared field, never from array position, so
|
|
32
|
+
* inserting a column does not silently re-point every stored id one slot
|
|
33
|
+
* to the left.
|
|
34
|
+
* 2. Anything the layout does not recognise fails *open*: an unmentioned
|
|
35
|
+
* column is visible, a stale id is dropped, and a layout that would leave
|
|
36
|
+
* the table blank is refused outright.
|
|
37
|
+
*
|
|
38
|
+
* These are pinned here rather than through a rendered table because a wrong
|
|
39
|
+
* answer shows up as "my columns are gone", which nobody reports as a bug —
|
|
40
|
+
* they just re-pick them and lose the layout again next release.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
type ColumnOverrides = Partial<Column<Monitor>>;
|
|
44
|
+
|
|
45
|
+
type MakeColumnFunction = (overrides: ColumnOverrides) => Column<Monitor>;
|
|
46
|
+
|
|
47
|
+
const makeColumn: MakeColumnFunction = (
|
|
48
|
+
overrides: ColumnOverrides,
|
|
49
|
+
): Column<Monitor> => {
|
|
50
|
+
return {
|
|
51
|
+
field: {},
|
|
52
|
+
title: "Untitled",
|
|
53
|
+
type: FieldType.Text,
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type TitlesOfFunction = (columns: Columns<Monitor>) => Array<string>;
|
|
59
|
+
|
|
60
|
+
const titlesOf: TitlesOfFunction = (
|
|
61
|
+
columns: Columns<Monitor>,
|
|
62
|
+
): Array<string> => {
|
|
63
|
+
return columns.map((column: Column<Monitor>): string => {
|
|
64
|
+
return column.title;
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type EntryIdsFunction = (
|
|
69
|
+
entries: Array<CustomizableColumn<Monitor>>,
|
|
70
|
+
) => Array<string>;
|
|
71
|
+
|
|
72
|
+
const entryIds: EntryIdsFunction = (
|
|
73
|
+
entries: Array<CustomizableColumn<Monitor>>,
|
|
74
|
+
): Array<string> => {
|
|
75
|
+
return entries.map((entry: CustomizableColumn<Monitor>): string => {
|
|
76
|
+
return entry.id;
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type VisibilityOfFunction = (
|
|
81
|
+
entries: Array<CustomizableColumn<Monitor>>,
|
|
82
|
+
) => Record<string, boolean>;
|
|
83
|
+
|
|
84
|
+
const visibilityOf: VisibilityOfFunction = (
|
|
85
|
+
entries: Array<CustomizableColumn<Monitor>>,
|
|
86
|
+
): Record<string, boolean> => {
|
|
87
|
+
const map: Record<string, boolean> = {};
|
|
88
|
+
|
|
89
|
+
entries.forEach((entry: CustomizableColumn<Monitor>): void => {
|
|
90
|
+
map[entry.id] = entry.isVisible;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return map;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
type FindEntryFunction = (
|
|
97
|
+
entries: Array<CustomizableColumn<Monitor>>,
|
|
98
|
+
id: string,
|
|
99
|
+
) => CustomizableColumn<Monitor>;
|
|
100
|
+
|
|
101
|
+
const findEntry: FindEntryFunction = (
|
|
102
|
+
entries: Array<CustomizableColumn<Monitor>>,
|
|
103
|
+
id: string,
|
|
104
|
+
): CustomizableColumn<Monitor> => {
|
|
105
|
+
const entry: CustomizableColumn<Monitor> | undefined = entries.find(
|
|
106
|
+
(candidate: CustomizableColumn<Monitor>): boolean => {
|
|
107
|
+
return candidate.id === id;
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (!entry) {
|
|
112
|
+
throw new Error(`No column entry with id "${id}"`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return entry;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
/*
|
|
120
|
+
* Nothing here reads storage, but the Common suite runs --runInBand and
|
|
121
|
+
* localStorage leaks between files; clearing keeps a future refactor that
|
|
122
|
+
* memoizes layouts from inheriting another suite's state.
|
|
123
|
+
*/
|
|
124
|
+
window.localStorage.clear();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("getColumnBaseId", () => {
|
|
128
|
+
test("an explicit id beats the derived one", () => {
|
|
129
|
+
/*
|
|
130
|
+
* The escape hatch for cells rendered entirely through getElement off a
|
|
131
|
+
* placeholder field — their derived id would be a title slug, and titles
|
|
132
|
+
* are the part of a column authors change most freely.
|
|
133
|
+
*/
|
|
134
|
+
expect(
|
|
135
|
+
getColumnBaseId<Monitor>(
|
|
136
|
+
makeColumn({
|
|
137
|
+
id: "monitor-uptime",
|
|
138
|
+
field: { name: true },
|
|
139
|
+
title: "Uptime",
|
|
140
|
+
}),
|
|
141
|
+
),
|
|
142
|
+
).toBe("monitor-uptime");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("derives from the first declared field key", () => {
|
|
146
|
+
expect(
|
|
147
|
+
getColumnBaseId<Monitor>(
|
|
148
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
149
|
+
),
|
|
150
|
+
).toBe("name");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("uses only the first key when a column selects several fields", () => {
|
|
154
|
+
/*
|
|
155
|
+
* A composite cell (say "3/5 online") selects more than one field, but its
|
|
156
|
+
* identity has to be a single stable string, so the first declared key is
|
|
157
|
+
* the one that counts.
|
|
158
|
+
*/
|
|
159
|
+
expect(
|
|
160
|
+
getColumnBaseId<Monitor>(
|
|
161
|
+
makeColumn({
|
|
162
|
+
field: { monitorType: true, description: true },
|
|
163
|
+
title: "Type",
|
|
164
|
+
}),
|
|
165
|
+
),
|
|
166
|
+
).toBe("monitorType");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a selectedProperty makes each custom field its own column", () => {
|
|
170
|
+
/*
|
|
171
|
+
* This is what the whole custom-field feature rests on: every custom field
|
|
172
|
+
* hangs off the same `customFields` JSON column, so without the property
|
|
173
|
+
* suffix they would all collapse onto one id and switching one off would
|
|
174
|
+
* switch off all of them.
|
|
175
|
+
*/
|
|
176
|
+
expect(
|
|
177
|
+
getColumnBaseId<Monitor>(
|
|
178
|
+
makeColumn({
|
|
179
|
+
field: { customFields: true },
|
|
180
|
+
selectedProperty: "Severity",
|
|
181
|
+
title: "Severity",
|
|
182
|
+
}),
|
|
183
|
+
),
|
|
184
|
+
).toBe("customFields.Severity");
|
|
185
|
+
|
|
186
|
+
expect(
|
|
187
|
+
getColumnBaseId<Monitor>(
|
|
188
|
+
makeColumn({
|
|
189
|
+
field: { customFields: true },
|
|
190
|
+
selectedProperty: "Owning Team",
|
|
191
|
+
title: "Owning Team",
|
|
192
|
+
}),
|
|
193
|
+
),
|
|
194
|
+
).toBe("customFields.Owning Team");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("falls back to a slug of the title when there is no field key", () => {
|
|
198
|
+
expect(getColumnBaseId<Monitor>(makeColumn({ title: "Row Actions" }))).toBe(
|
|
199
|
+
"row-actions",
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("the title slug collapses punctuation and trims stray separators", () => {
|
|
204
|
+
expect(getColumnBaseId<Monitor>(makeColumn({ title: "CPU % (avg)" }))).toBe(
|
|
205
|
+
"cpu-avg",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("falls back to the title when `field` is missing altogether", () => {
|
|
210
|
+
// Hand-written columns in the wild do omit `field`; the guard must hold.
|
|
211
|
+
const column: Column<Monitor> = {
|
|
212
|
+
...makeColumn({ title: "Actions" }),
|
|
213
|
+
field: undefined,
|
|
214
|
+
} as unknown as Column<Monitor>;
|
|
215
|
+
|
|
216
|
+
expect(getColumnBaseId<Monitor>(column)).toBe("actions");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('degrades to the literal "column" when there is nothing to derive from', () => {
|
|
220
|
+
// An id must exist even for a column with neither field nor title.
|
|
221
|
+
expect(getColumnBaseId<Monitor>(makeColumn({ title: "" }))).toBe("column");
|
|
222
|
+
expect(getColumnBaseId<Monitor>(makeColumn({ title: "!!!" }))).toBe(
|
|
223
|
+
"column",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("getColumnIds", () => {
|
|
229
|
+
test("returns one id per column, in declaration order", () => {
|
|
230
|
+
const columns: Columns<Monitor> = [
|
|
231
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
232
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
233
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
234
|
+
];
|
|
235
|
+
|
|
236
|
+
expect(getColumnIds<Monitor>(columns)).toEqual([
|
|
237
|
+
"name",
|
|
238
|
+
"description",
|
|
239
|
+
"labels",
|
|
240
|
+
]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("empty column set produces no ids", () => {
|
|
244
|
+
expect(getColumnIds<Monitor>([])).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("columns sharing a base field are disambiguated by title slug", () => {
|
|
248
|
+
/*
|
|
249
|
+
* Several cells rendering from the same placeholder `field: { _id: true }`
|
|
250
|
+
* is the common shape, not an edge case. Title is used first because it is
|
|
251
|
+
* what the viewer sees in the picker and it does not move when a column is
|
|
252
|
+
* inserted next to it.
|
|
253
|
+
*/
|
|
254
|
+
const columns: Columns<Monitor> = [
|
|
255
|
+
makeColumn({ field: { _id: true }, title: "Actions" }),
|
|
256
|
+
makeColumn({ field: { _id: true }, title: "Owner" }),
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
expect(getColumnIds<Monitor>(columns)).toEqual([
|
|
260
|
+
"_id:actions",
|
|
261
|
+
"_id:owner",
|
|
262
|
+
]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("columns sharing a base AND a title fall back to a positional suffix", () => {
|
|
266
|
+
const columns: Columns<Monitor> = [
|
|
267
|
+
makeColumn({ field: { _id: true }, title: "Actions" }),
|
|
268
|
+
makeColumn({ field: { _id: true }, title: "Actions" }),
|
|
269
|
+
makeColumn({ field: { _id: true }, title: "Actions" }),
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
expect(getColumnIds<Monitor>(columns)).toEqual([
|
|
273
|
+
"_id:actions",
|
|
274
|
+
"_id:actions#2",
|
|
275
|
+
"_id:actions#3",
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("colliding columns with no title at all still get distinct ids", () => {
|
|
280
|
+
const columns: Columns<Monitor> = [
|
|
281
|
+
makeColumn({ field: { _id: true }, title: "" }),
|
|
282
|
+
makeColumn({ field: { _id: true }, title: "" }),
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
expect(getColumnIds<Monitor>(columns)).toEqual(["_id", "_id#2"]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("only the colliding columns are suffixed", () => {
|
|
289
|
+
const columns: Columns<Monitor> = [
|
|
290
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
291
|
+
makeColumn({ field: { _id: true }, title: "Actions" }),
|
|
292
|
+
makeColumn({ field: { _id: true }, title: "Owner" }),
|
|
293
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
expect(getColumnIds<Monitor>(columns)).toEqual([
|
|
297
|
+
"name",
|
|
298
|
+
"_id:actions",
|
|
299
|
+
"_id:owner",
|
|
300
|
+
"labels",
|
|
301
|
+
]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("every custom field on one JSON column gets its own id", () => {
|
|
305
|
+
const columns: Columns<Monitor> = [
|
|
306
|
+
makeColumn({
|
|
307
|
+
field: { customFields: true },
|
|
308
|
+
selectedProperty: "Severity",
|
|
309
|
+
title: "Severity",
|
|
310
|
+
}),
|
|
311
|
+
makeColumn({
|
|
312
|
+
field: { customFields: true },
|
|
313
|
+
selectedProperty: "Team",
|
|
314
|
+
title: "Team",
|
|
315
|
+
}),
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
// Different base ids, so no collision handling kicks in.
|
|
319
|
+
expect(getColumnIds<Monitor>(columns)).toEqual([
|
|
320
|
+
"customFields.Severity",
|
|
321
|
+
"customFields.Team",
|
|
322
|
+
]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("an id does not move when unrelated columns are added around it", () => {
|
|
326
|
+
/*
|
|
327
|
+
* The point of deriving from the field: a release that adds three columns
|
|
328
|
+
* must not invalidate the stored layout for the ones that already existed.
|
|
329
|
+
*/
|
|
330
|
+
const small: Columns<Monitor> = [
|
|
331
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
332
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
const large: Columns<Monitor> = [
|
|
336
|
+
makeColumn({ field: { monitorType: true }, title: "Type" }),
|
|
337
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
338
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
339
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
340
|
+
makeColumn({ field: { slug: true }, title: "Slug" }),
|
|
341
|
+
];
|
|
342
|
+
|
|
343
|
+
const smallIds: Array<string> = getColumnIds<Monitor>(small);
|
|
344
|
+
const largeIds: Array<string> = getColumnIds<Monitor>(large);
|
|
345
|
+
|
|
346
|
+
expect(smallIds).toEqual(["name", "description"]);
|
|
347
|
+
expect(largeIds).toContain("name");
|
|
348
|
+
expect(largeIds).toContain("description");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("an id is unaffected by the column's title when nothing collides", () => {
|
|
352
|
+
const before: Columns<Monitor> = [
|
|
353
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
354
|
+
];
|
|
355
|
+
const after: Columns<Monitor> = [
|
|
356
|
+
makeColumn({ field: { description: true }, title: "Summary" }),
|
|
357
|
+
];
|
|
358
|
+
|
|
359
|
+
expect(getColumnIds<Monitor>(before)).toEqual(getColumnIds<Monitor>(after));
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("introducing a collision does re-key the column that was alone", () => {
|
|
363
|
+
/*
|
|
364
|
+
* The documented trade-off, pinned so it is a decision rather than a
|
|
365
|
+
* surprise: adding a second `_id` column changes the first one's id, the
|
|
366
|
+
* stored entry goes stale and the column simply defaults back to visible.
|
|
367
|
+
* Authors who cannot afford that set an explicit `id`.
|
|
368
|
+
*/
|
|
369
|
+
expect(
|
|
370
|
+
getColumnIds<Monitor>([
|
|
371
|
+
makeColumn({ field: { _id: true }, title: "Actions" }),
|
|
372
|
+
]),
|
|
373
|
+
).toEqual(["_id"]);
|
|
374
|
+
|
|
375
|
+
expect(
|
|
376
|
+
getColumnIds<Monitor>([
|
|
377
|
+
makeColumn({ field: { _id: true }, title: "Actions" }),
|
|
378
|
+
makeColumn({ field: { _id: true }, title: "Owner" }),
|
|
379
|
+
]),
|
|
380
|
+
).toEqual(["_id:actions", "_id:owner"]);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("ids are unique across a pathological column set", () => {
|
|
384
|
+
const columns: Columns<Monitor> = [
|
|
385
|
+
makeColumn({ field: { _id: true }, title: "Actions" }),
|
|
386
|
+
makeColumn({ field: { _id: true }, title: "Actions" }),
|
|
387
|
+
makeColumn({ field: { _id: true }, title: "" }),
|
|
388
|
+
makeColumn({ field: {}, title: "" }),
|
|
389
|
+
makeColumn({ field: {}, title: "" }),
|
|
390
|
+
makeColumn({ id: "explicit", field: { name: true }, title: "Name" }),
|
|
391
|
+
];
|
|
392
|
+
|
|
393
|
+
const ids: Array<string> = getColumnIds<Monitor>(columns);
|
|
394
|
+
|
|
395
|
+
expect(ids).toHaveLength(columns.length);
|
|
396
|
+
expect(new Set(ids).size).toBe(columns.length);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe("getCustomizableColumns without a preference", () => {
|
|
401
|
+
test("keeps declaration order and shows everything", () => {
|
|
402
|
+
const columns: Columns<Monitor> = [
|
|
403
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
404
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
405
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
406
|
+
];
|
|
407
|
+
|
|
408
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
409
|
+
getCustomizableColumns<Monitor>({ columns });
|
|
410
|
+
|
|
411
|
+
expect(entryIds(entries)).toEqual(["name", "description", "labels"]);
|
|
412
|
+
expect(visibilityOf(entries)).toEqual({
|
|
413
|
+
name: true,
|
|
414
|
+
description: true,
|
|
415
|
+
labels: true,
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("isHiddenByDefault columns start switched off but are still listed", () => {
|
|
420
|
+
// They have to be listed, otherwise there is no way to switch them on.
|
|
421
|
+
const columns: Columns<Monitor> = [
|
|
422
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
423
|
+
makeColumn({
|
|
424
|
+
field: { customFields: true },
|
|
425
|
+
selectedProperty: "Severity",
|
|
426
|
+
title: "Severity",
|
|
427
|
+
isHiddenByDefault: true,
|
|
428
|
+
}),
|
|
429
|
+
];
|
|
430
|
+
|
|
431
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
432
|
+
getCustomizableColumns<Monitor>({ columns });
|
|
433
|
+
|
|
434
|
+
expect(entryIds(entries)).toEqual(["name", "customFields.Severity"]);
|
|
435
|
+
expect(findEntry(entries, "customFields.Severity").isVisible).toBe(false);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("null and undefined preferences behave the same as no preference", () => {
|
|
439
|
+
const columns: Columns<Monitor> = [
|
|
440
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
441
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
442
|
+
];
|
|
443
|
+
|
|
444
|
+
expect(entryIds(getCustomizableColumns<Monitor>({ columns }))).toEqual([
|
|
445
|
+
"name",
|
|
446
|
+
"labels",
|
|
447
|
+
]);
|
|
448
|
+
expect(
|
|
449
|
+
entryIds(getCustomizableColumns<Monitor>({ columns, preference: null })),
|
|
450
|
+
).toEqual(["name", "labels"]);
|
|
451
|
+
expect(
|
|
452
|
+
entryIds(
|
|
453
|
+
getCustomizableColumns<Monitor>({ columns, preference: undefined }),
|
|
454
|
+
),
|
|
455
|
+
).toEqual(["name", "labels"]);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("carries the original column object through untouched", () => {
|
|
459
|
+
// The picker and the table both need the real column, not a copy of it.
|
|
460
|
+
const name: Column<Monitor> = makeColumn({
|
|
461
|
+
field: { name: true },
|
|
462
|
+
title: "Name",
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
466
|
+
getCustomizableColumns<Monitor>({ columns: [name] });
|
|
467
|
+
|
|
468
|
+
expect(findEntry(entries, "name").column).toBe(name);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("an empty column set yields no entries", () => {
|
|
472
|
+
expect(getCustomizableColumns<Monitor>({ columns: [] })).toEqual([]);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe("getCustomizableColumns pinning", () => {
|
|
477
|
+
test("isNotCustomizable marks the entry pinned", () => {
|
|
478
|
+
const columns: Columns<Monitor> = [
|
|
479
|
+
makeColumn({
|
|
480
|
+
field: { name: true },
|
|
481
|
+
title: "Name",
|
|
482
|
+
isNotCustomizable: true,
|
|
483
|
+
}),
|
|
484
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
485
|
+
];
|
|
486
|
+
|
|
487
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
488
|
+
getCustomizableColumns<Monitor>({ columns });
|
|
489
|
+
|
|
490
|
+
expect(findEntry(entries, "name").isPinned).toBe(true);
|
|
491
|
+
expect(findEntry(entries, "labels").isPinned).toBe(false);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("a pinned column is never hidden by a preference that names it", () => {
|
|
495
|
+
/*
|
|
496
|
+
* Pinned columns are absent from the picker, so a stored `hidden` entry
|
|
497
|
+
* for one would be unrecoverable: the viewer would have no control to
|
|
498
|
+
* bring the row's identifying column back.
|
|
499
|
+
*/
|
|
500
|
+
const columns: Columns<Monitor> = [
|
|
501
|
+
makeColumn({
|
|
502
|
+
field: { name: true },
|
|
503
|
+
title: "Name",
|
|
504
|
+
isNotCustomizable: true,
|
|
505
|
+
}),
|
|
506
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
507
|
+
];
|
|
508
|
+
|
|
509
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
510
|
+
getCustomizableColumns<Monitor>({
|
|
511
|
+
columns,
|
|
512
|
+
preference: { order: ["labels", "name"], hidden: ["name", "labels"] },
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
expect(findEntry(entries, "name").isVisible).toBe(true);
|
|
516
|
+
expect(findEntry(entries, "labels").isVisible).toBe(false);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("isHiddenByDefault is ignored on a pinned column", () => {
|
|
520
|
+
// Contradictory flags: pinning wins, because it is the stronger promise.
|
|
521
|
+
const columns: Columns<Monitor> = [
|
|
522
|
+
makeColumn({
|
|
523
|
+
field: { name: true },
|
|
524
|
+
title: "Name",
|
|
525
|
+
isNotCustomizable: true,
|
|
526
|
+
isHiddenByDefault: true,
|
|
527
|
+
}),
|
|
528
|
+
];
|
|
529
|
+
|
|
530
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
531
|
+
getCustomizableColumns<Monitor>({ columns });
|
|
532
|
+
|
|
533
|
+
expect(findEntry(entries, "name").isVisible).toBe(true);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("a pinned column keeps its declared position under a stale order", () => {
|
|
537
|
+
const columns: Columns<Monitor> = [
|
|
538
|
+
makeColumn({
|
|
539
|
+
field: { name: true },
|
|
540
|
+
title: "Name",
|
|
541
|
+
isNotCustomizable: true,
|
|
542
|
+
}),
|
|
543
|
+
makeColumn({ field: { monitorType: true }, title: "Type" }),
|
|
544
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
/*
|
|
548
|
+
* `name` appears in `order` — a layout written before the column was
|
|
549
|
+
* pinned, or hand-edited. It must not drag the pinned column anywhere.
|
|
550
|
+
*/
|
|
551
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
552
|
+
getCustomizableColumns<Monitor>({
|
|
553
|
+
columns,
|
|
554
|
+
preference: {
|
|
555
|
+
order: ["labels", "monitorType", "name"],
|
|
556
|
+
hidden: ["name"],
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
expect(entryIds(entries)).toEqual(["name", "labels", "monitorType"]);
|
|
561
|
+
expect(findEntry(entries, "name").isVisible).toBe(true);
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
describe("getCustomizableColumns ordering", () => {
|
|
566
|
+
test("a preference reorders the entries", () => {
|
|
567
|
+
const columns: Columns<Monitor> = [
|
|
568
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
569
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
570
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
571
|
+
];
|
|
572
|
+
|
|
573
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
574
|
+
getCustomizableColumns<Monitor>({
|
|
575
|
+
columns,
|
|
576
|
+
preference: { order: ["labels", "name", "description"], hidden: [] },
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
expect(entryIds(entries)).toEqual(["labels", "name", "description"]);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("an order that lists ids the table no longer has is simply skipped", () => {
|
|
583
|
+
const columns: Columns<Monitor> = [
|
|
584
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
585
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
586
|
+
];
|
|
587
|
+
|
|
588
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
589
|
+
getCustomizableColumns<Monitor>({
|
|
590
|
+
columns,
|
|
591
|
+
preference: {
|
|
592
|
+
order: ["labels", "deletedColumn", "name"],
|
|
593
|
+
hidden: [],
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
expect(entryIds(entries)).toEqual(["labels", "name"]);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("a column declared in the middle and absent from the order stays in the middle", () => {
|
|
601
|
+
/*
|
|
602
|
+
* The load-bearing case for a new release: `slug` ships between
|
|
603
|
+
* Description and Labels and the stored layout has never heard of it. It
|
|
604
|
+
* has to appear where its author put it, not be swept to the end where
|
|
605
|
+
* nobody looks.
|
|
606
|
+
*/
|
|
607
|
+
const columns: Columns<Monitor> = [
|
|
608
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
609
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
610
|
+
makeColumn({ field: { slug: true }, title: "Slug" }),
|
|
611
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
612
|
+
makeColumn({ field: { monitorType: true }, title: "Type" }),
|
|
613
|
+
];
|
|
614
|
+
|
|
615
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
616
|
+
getCustomizableColumns<Monitor>({
|
|
617
|
+
columns,
|
|
618
|
+
preference: {
|
|
619
|
+
order: ["name", "description", "labels", "monitorType"],
|
|
620
|
+
hidden: [],
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
expect(entryIds(entries)).toEqual([
|
|
625
|
+
"name",
|
|
626
|
+
"description",
|
|
627
|
+
"slug",
|
|
628
|
+
"labels",
|
|
629
|
+
"monitorType",
|
|
630
|
+
]);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("an unmentioned column follows the last arranged column declared before it", () => {
|
|
634
|
+
/*
|
|
635
|
+
* The rule is "declared neighbours", not "declared index": once the viewer
|
|
636
|
+
* has reshuffled things, `slug`'s anchor is Description — the last column
|
|
637
|
+
* that both precedes it in the declaration and appears in the layout.
|
|
638
|
+
*/
|
|
639
|
+
const columns: Columns<Monitor> = [
|
|
640
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
641
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
642
|
+
makeColumn({ field: { slug: true }, title: "Slug" }),
|
|
643
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
644
|
+
];
|
|
645
|
+
|
|
646
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
647
|
+
getCustomizableColumns<Monitor>({
|
|
648
|
+
columns,
|
|
649
|
+
preference: {
|
|
650
|
+
order: ["labels", "name", "description"],
|
|
651
|
+
hidden: [],
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
expect(entryIds(entries)).toEqual([
|
|
656
|
+
"labels",
|
|
657
|
+
"name",
|
|
658
|
+
"description",
|
|
659
|
+
"slug",
|
|
660
|
+
]);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("a new first column sorts ahead of everything the layout arranged", () => {
|
|
664
|
+
const columns: Columns<Monitor> = [
|
|
665
|
+
makeColumn({ field: { slug: true }, title: "Slug" }),
|
|
666
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
667
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
668
|
+
];
|
|
669
|
+
|
|
670
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
671
|
+
getCustomizableColumns<Monitor>({
|
|
672
|
+
columns,
|
|
673
|
+
preference: { order: ["labels", "name"], hidden: [] },
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
expect(entryIds(entries)).toEqual(["slug", "labels", "name"]);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("several consecutive new columns keep their relative declaration order", () => {
|
|
680
|
+
const columns: Columns<Monitor> = [
|
|
681
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
682
|
+
makeColumn({ field: { slug: true }, title: "Slug" }),
|
|
683
|
+
makeColumn({ field: { monitorType: true }, title: "Type" }),
|
|
684
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
685
|
+
];
|
|
686
|
+
|
|
687
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
688
|
+
getCustomizableColumns<Monitor>({
|
|
689
|
+
columns,
|
|
690
|
+
preference: { order: ["name", "labels"], hidden: [] },
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// Equal sort keys must be broken by declaration order, i.e. stably.
|
|
694
|
+
expect(entryIds(entries)).toEqual([
|
|
695
|
+
"name",
|
|
696
|
+
"slug",
|
|
697
|
+
"monitorType",
|
|
698
|
+
"labels",
|
|
699
|
+
]);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("a duplicated id in the order uses its first occurrence", () => {
|
|
703
|
+
const columns: Columns<Monitor> = [
|
|
704
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
705
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
706
|
+
];
|
|
707
|
+
|
|
708
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
709
|
+
getCustomizableColumns<Monitor>({
|
|
710
|
+
columns,
|
|
711
|
+
preference: { order: ["labels", "name", "labels"], hidden: [] },
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
expect(entryIds(entries)).toEqual(["labels", "name"]);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("an empty order leaves declaration order alone", () => {
|
|
718
|
+
const columns: Columns<Monitor> = [
|
|
719
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
720
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
721
|
+
];
|
|
722
|
+
|
|
723
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
724
|
+
getCustomizableColumns<Monitor>({
|
|
725
|
+
columns,
|
|
726
|
+
preference: { order: [], hidden: ["labels"] },
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
expect(entryIds(entries)).toEqual(["name", "labels"]);
|
|
730
|
+
expect(findEntry(entries, "labels").isVisible).toBe(false);
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
describe("getCustomizableColumns visibility", () => {
|
|
735
|
+
test("an id in `hidden` switches the column off", () => {
|
|
736
|
+
const columns: Columns<Monitor> = [
|
|
737
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
738
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
739
|
+
];
|
|
740
|
+
|
|
741
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
742
|
+
getCustomizableColumns<Monitor>({
|
|
743
|
+
columns,
|
|
744
|
+
preference: { order: ["name", "labels"], hidden: ["labels"] },
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
expect(visibilityOf(entries)).toEqual({ name: true, labels: false });
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("an id in `order` but not `hidden` switches a default-hidden column on", () => {
|
|
751
|
+
/*
|
|
752
|
+
* This is the only mechanism by which a viewer turns a custom field on:
|
|
753
|
+
* custom-field columns ship hidden, and the layout says "on" by naming the
|
|
754
|
+
* id in `order` while leaving it out of `hidden`.
|
|
755
|
+
*/
|
|
756
|
+
const columns: Columns<Monitor> = [
|
|
757
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
758
|
+
makeColumn({
|
|
759
|
+
field: { customFields: true },
|
|
760
|
+
selectedProperty: "Severity",
|
|
761
|
+
title: "Severity",
|
|
762
|
+
isHiddenByDefault: true,
|
|
763
|
+
}),
|
|
764
|
+
];
|
|
765
|
+
|
|
766
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
767
|
+
getCustomizableColumns<Monitor>({
|
|
768
|
+
columns,
|
|
769
|
+
preference: {
|
|
770
|
+
order: ["name", "customFields.Severity"],
|
|
771
|
+
hidden: [],
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
expect(findEntry(entries, "customFields.Severity").isVisible).toBe(true);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test("`hidden` wins over `order` when an id is in both", () => {
|
|
779
|
+
const columns: Columns<Monitor> = [
|
|
780
|
+
makeColumn({
|
|
781
|
+
field: { customFields: true },
|
|
782
|
+
selectedProperty: "Severity",
|
|
783
|
+
title: "Severity",
|
|
784
|
+
isHiddenByDefault: true,
|
|
785
|
+
}),
|
|
786
|
+
];
|
|
787
|
+
|
|
788
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
789
|
+
getCustomizableColumns<Monitor>({
|
|
790
|
+
columns,
|
|
791
|
+
preference: {
|
|
792
|
+
order: ["customFields.Severity"],
|
|
793
|
+
hidden: ["customFields.Severity"],
|
|
794
|
+
},
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
expect(findEntry(entries, "customFields.Severity").isVisible).toBe(false);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test("a default-hidden column the layout never mentions stays hidden", () => {
|
|
801
|
+
// A custom field added after the viewer last touched this table.
|
|
802
|
+
const columns: Columns<Monitor> = [
|
|
803
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
804
|
+
makeColumn({
|
|
805
|
+
field: { customFields: true },
|
|
806
|
+
selectedProperty: "Team",
|
|
807
|
+
title: "Team",
|
|
808
|
+
isHiddenByDefault: true,
|
|
809
|
+
}),
|
|
810
|
+
];
|
|
811
|
+
|
|
812
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
813
|
+
getCustomizableColumns<Monitor>({
|
|
814
|
+
columns,
|
|
815
|
+
preference: { order: ["name"], hidden: [] },
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
expect(findEntry(entries, "customFields.Team").isVisible).toBe(false);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test("a normal column the layout never mentions stays visible", () => {
|
|
822
|
+
const columns: Columns<Monitor> = [
|
|
823
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
824
|
+
makeColumn({ field: { slug: true }, title: "Slug" }),
|
|
825
|
+
];
|
|
826
|
+
|
|
827
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
828
|
+
getCustomizableColumns<Monitor>({
|
|
829
|
+
columns,
|
|
830
|
+
preference: { order: ["name"], hidden: [] },
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
expect(findEntry(entries, "slug").isVisible).toBe(true);
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
describe("applyColumnPreference", () => {
|
|
838
|
+
test("renders only the visible columns, in layout order", () => {
|
|
839
|
+
const columns: Columns<Monitor> = [
|
|
840
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
841
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
842
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
843
|
+
];
|
|
844
|
+
|
|
845
|
+
const rendered: Columns<Monitor> = applyColumnPreference<Monitor>({
|
|
846
|
+
columns,
|
|
847
|
+
preference: {
|
|
848
|
+
order: ["labels", "name", "description"],
|
|
849
|
+
hidden: ["description"],
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
expect(titlesOf(rendered)).toEqual(["Labels", "Name"]);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("returns the very same column objects, not copies", () => {
|
|
857
|
+
const name: Column<Monitor> = makeColumn({
|
|
858
|
+
field: { name: true },
|
|
859
|
+
title: "Name",
|
|
860
|
+
});
|
|
861
|
+
const labels: Column<Monitor> = makeColumn({
|
|
862
|
+
field: { labels: true },
|
|
863
|
+
title: "Labels",
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
const rendered: Columns<Monitor> = applyColumnPreference<Monitor>({
|
|
867
|
+
columns: [name, labels],
|
|
868
|
+
preference: { order: ["labels", "name"], hidden: [] },
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
expect(rendered).toEqual([labels, name]);
|
|
872
|
+
expect(rendered[0]).toBe(labels);
|
|
873
|
+
expect(rendered[1]).toBe(name);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
test("shows everything when the layout would leave the table blank", () => {
|
|
877
|
+
/*
|
|
878
|
+
* The safety net. An empty table has no header, therefore no "Columns"
|
|
879
|
+
* button rendered against a column, and a viewer who hid the last column
|
|
880
|
+
* would have nothing left to click to undo it. Refuse the layout instead.
|
|
881
|
+
*/
|
|
882
|
+
const columns: Columns<Monitor> = [
|
|
883
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
884
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
885
|
+
];
|
|
886
|
+
|
|
887
|
+
const rendered: Columns<Monitor> = applyColumnPreference<Monitor>({
|
|
888
|
+
columns,
|
|
889
|
+
preference: {
|
|
890
|
+
order: ["description", "name"],
|
|
891
|
+
hidden: ["name", "description"],
|
|
892
|
+
},
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
expect(rendered).toHaveLength(2);
|
|
896
|
+
expect(titlesOf(rendered)).toEqual(["Description", "Name"]);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
test("the never-empty fallback also covers a single-column table", () => {
|
|
900
|
+
const columns: Columns<Monitor> = [
|
|
901
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
902
|
+
];
|
|
903
|
+
|
|
904
|
+
expect(
|
|
905
|
+
titlesOf(
|
|
906
|
+
applyColumnPreference<Monitor>({
|
|
907
|
+
columns,
|
|
908
|
+
preference: { order: ["name"], hidden: ["name"] },
|
|
909
|
+
}),
|
|
910
|
+
),
|
|
911
|
+
).toEqual(["Name"]);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test("with no preference the input comes back unchanged", () => {
|
|
915
|
+
const columns: Columns<Monitor> = [
|
|
916
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
917
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
918
|
+
makeColumn({
|
|
919
|
+
field: { labels: true },
|
|
920
|
+
title: "Labels",
|
|
921
|
+
}),
|
|
922
|
+
];
|
|
923
|
+
|
|
924
|
+
expect(titlesOf(applyColumnPreference<Monitor>({ columns }))).toEqual([
|
|
925
|
+
"Name",
|
|
926
|
+
"Description",
|
|
927
|
+
"Labels",
|
|
928
|
+
]);
|
|
929
|
+
expect(
|
|
930
|
+
titlesOf(applyColumnPreference<Monitor>({ columns, preference: null })),
|
|
931
|
+
).toEqual(["Name", "Description", "Labels"]);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
test("a default-hidden column is dropped even without a preference", () => {
|
|
935
|
+
const columns: Columns<Monitor> = [
|
|
936
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
937
|
+
makeColumn({
|
|
938
|
+
field: { customFields: true },
|
|
939
|
+
selectedProperty: "Severity",
|
|
940
|
+
title: "Severity",
|
|
941
|
+
isHiddenByDefault: true,
|
|
942
|
+
}),
|
|
943
|
+
];
|
|
944
|
+
|
|
945
|
+
expect(titlesOf(applyColumnPreference<Monitor>({ columns }))).toEqual([
|
|
946
|
+
"Name",
|
|
947
|
+
]);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
test("no columns in, no columns out", () => {
|
|
951
|
+
expect(applyColumnPreference<Monitor>({ columns: [] })).toEqual([]);
|
|
952
|
+
expect(
|
|
953
|
+
applyColumnPreference<Monitor>({
|
|
954
|
+
columns: [],
|
|
955
|
+
preference: { order: ["gone"], hidden: ["gone"] },
|
|
956
|
+
}),
|
|
957
|
+
).toEqual([]);
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
describe("buildColumnPreference", () => {
|
|
962
|
+
test("round-trips through getCustomizableColumns", () => {
|
|
963
|
+
const columns: Columns<Monitor> = [
|
|
964
|
+
makeColumn({
|
|
965
|
+
field: { name: true },
|
|
966
|
+
title: "Name",
|
|
967
|
+
isNotCustomizable: true,
|
|
968
|
+
}),
|
|
969
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
970
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
971
|
+
makeColumn({ field: { monitorType: true }, title: "Type" }),
|
|
972
|
+
];
|
|
973
|
+
|
|
974
|
+
const preference: ColumnPreference = {
|
|
975
|
+
order: ["monitorType", "description", "labels"],
|
|
976
|
+
hidden: ["labels"],
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
const rebuilt: ColumnPreference = buildColumnPreference<Monitor>(
|
|
980
|
+
getCustomizableColumns<Monitor>({ columns, preference }),
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
expect(rebuilt).toEqual(preference);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
test("re-applying what it produced is a no-op", () => {
|
|
987
|
+
// Saving the picker without touching it must not perturb the table.
|
|
988
|
+
const columns: Columns<Monitor> = [
|
|
989
|
+
makeColumn({ field: { name: true }, title: "Name" }),
|
|
990
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
991
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
992
|
+
];
|
|
993
|
+
|
|
994
|
+
const first: Array<CustomizableColumn<Monitor>> =
|
|
995
|
+
getCustomizableColumns<Monitor>({
|
|
996
|
+
columns,
|
|
997
|
+
preference: { order: ["labels", "name", "description"], hidden: [] },
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
const second: Array<CustomizableColumn<Monitor>> =
|
|
1001
|
+
getCustomizableColumns<Monitor>({
|
|
1002
|
+
columns,
|
|
1003
|
+
preference: buildColumnPreference<Monitor>(first),
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
expect(entryIds(second)).toEqual(entryIds(first));
|
|
1007
|
+
expect(visibilityOf(second)).toEqual(visibilityOf(first));
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
test("pinned entries are left out of both lists", () => {
|
|
1011
|
+
/*
|
|
1012
|
+
* Recording a pinned column would create an entry that goes stale the
|
|
1013
|
+
* moment a table stops pinning it — and worse, a `hidden` entry for a
|
|
1014
|
+
* column the picker cannot show.
|
|
1015
|
+
*/
|
|
1016
|
+
const columnA: Column<Monitor> = makeColumn({ title: "A" });
|
|
1017
|
+
const columnB: Column<Monitor> = makeColumn({ title: "B" });
|
|
1018
|
+
const columnC: Column<Monitor> = makeColumn({ title: "C" });
|
|
1019
|
+
|
|
1020
|
+
const entries: Array<CustomizableColumn<Monitor>> = [
|
|
1021
|
+
{ id: "a", column: columnA, isVisible: true, isPinned: true },
|
|
1022
|
+
{ id: "b", column: columnB, isVisible: false, isPinned: true },
|
|
1023
|
+
{ id: "c", column: columnC, isVisible: true, isPinned: false },
|
|
1024
|
+
];
|
|
1025
|
+
|
|
1026
|
+
expect(buildColumnPreference<Monitor>(entries)).toEqual({
|
|
1027
|
+
order: ["c"],
|
|
1028
|
+
hidden: [],
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test("hidden holds exactly the not-visible, non-pinned ids", () => {
|
|
1033
|
+
const column: Column<Monitor> = makeColumn({ title: "X" });
|
|
1034
|
+
|
|
1035
|
+
const entries: Array<CustomizableColumn<Monitor>> = [
|
|
1036
|
+
{ id: "a", column, isVisible: true, isPinned: false },
|
|
1037
|
+
{ id: "b", column, isVisible: false, isPinned: false },
|
|
1038
|
+
{ id: "c", column, isVisible: true, isPinned: false },
|
|
1039
|
+
{ id: "d", column, isVisible: false, isPinned: false },
|
|
1040
|
+
];
|
|
1041
|
+
|
|
1042
|
+
expect(buildColumnPreference<Monitor>(entries)).toEqual({
|
|
1043
|
+
order: ["a", "b", "c", "d"],
|
|
1044
|
+
hidden: ["b", "d"],
|
|
1045
|
+
});
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
test("no entries produce an empty layout", () => {
|
|
1049
|
+
expect(buildColumnPreference<Monitor>([])).toEqual({
|
|
1050
|
+
order: [],
|
|
1051
|
+
hidden: [],
|
|
1052
|
+
});
|
|
1053
|
+
expect(isEmptyColumnPreference(buildColumnPreference<Monitor>([]))).toBe(
|
|
1054
|
+
true,
|
|
1055
|
+
);
|
|
1056
|
+
});
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
describe("sanitizeColumnPreference", () => {
|
|
1060
|
+
test("drops ids that name no current column", () => {
|
|
1061
|
+
expect(
|
|
1062
|
+
sanitizeColumnPreference({
|
|
1063
|
+
preference: {
|
|
1064
|
+
order: ["name", "removedInThisRelease", "labels"],
|
|
1065
|
+
hidden: ["removedInThisRelease"],
|
|
1066
|
+
},
|
|
1067
|
+
knownColumnIds: ["name", "labels"],
|
|
1068
|
+
}),
|
|
1069
|
+
).toEqual({ order: ["name", "labels"], hidden: [] });
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
test("de-duplicates each list", () => {
|
|
1073
|
+
expect(
|
|
1074
|
+
sanitizeColumnPreference({
|
|
1075
|
+
preference: {
|
|
1076
|
+
order: ["name", "labels", "name"],
|
|
1077
|
+
hidden: ["labels", "labels"],
|
|
1078
|
+
},
|
|
1079
|
+
knownColumnIds: ["name", "labels"],
|
|
1080
|
+
}),
|
|
1081
|
+
).toEqual({ order: ["name", "labels"], hidden: ["labels"] });
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test("returns null when nothing survives", () => {
|
|
1085
|
+
expect(
|
|
1086
|
+
sanitizeColumnPreference({
|
|
1087
|
+
preference: { order: ["gone"], hidden: ["alsoGone"] },
|
|
1088
|
+
knownColumnIds: ["name"],
|
|
1089
|
+
}),
|
|
1090
|
+
).toBeNull();
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
test("returns null when the table has no columns at all", () => {
|
|
1094
|
+
expect(
|
|
1095
|
+
sanitizeColumnPreference({
|
|
1096
|
+
preference: { order: ["name"], hidden: [] },
|
|
1097
|
+
knownColumnIds: [],
|
|
1098
|
+
}),
|
|
1099
|
+
).toBeNull();
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
test("returns null for a null preference", () => {
|
|
1103
|
+
expect(
|
|
1104
|
+
sanitizeColumnPreference({
|
|
1105
|
+
preference: null,
|
|
1106
|
+
knownColumnIds: ["name"],
|
|
1107
|
+
}),
|
|
1108
|
+
).toBeNull();
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
test("returns null for an already-empty preference", () => {
|
|
1112
|
+
expect(
|
|
1113
|
+
sanitizeColumnPreference({
|
|
1114
|
+
preference: { order: [], hidden: [] },
|
|
1115
|
+
knownColumnIds: ["name"],
|
|
1116
|
+
}),
|
|
1117
|
+
).toBeNull();
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
test("a partially stale layout keeps its valid entries", () => {
|
|
1121
|
+
expect(
|
|
1122
|
+
sanitizeColumnPreference({
|
|
1123
|
+
preference: {
|
|
1124
|
+
order: ["customFields.Deleted", "name", "customFields.Severity"],
|
|
1125
|
+
hidden: ["customFields.Deleted", "customFields.Severity"],
|
|
1126
|
+
},
|
|
1127
|
+
knownColumnIds: ["name", "customFields.Severity"],
|
|
1128
|
+
}),
|
|
1129
|
+
).toEqual({
|
|
1130
|
+
order: ["name", "customFields.Severity"],
|
|
1131
|
+
hidden: ["customFields.Severity"],
|
|
1132
|
+
});
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
test("survives when only `hidden` has anything left", () => {
|
|
1136
|
+
// `order` empty but `hidden` populated is still a meaningful layout.
|
|
1137
|
+
expect(
|
|
1138
|
+
sanitizeColumnPreference({
|
|
1139
|
+
preference: { order: ["gone"], hidden: ["labels"] },
|
|
1140
|
+
knownColumnIds: ["labels"],
|
|
1141
|
+
}),
|
|
1142
|
+
).toEqual({ order: [], hidden: ["labels"] });
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
test("does not mutate the preference it was given", () => {
|
|
1146
|
+
const preference: ColumnPreference = {
|
|
1147
|
+
order: ["name", "gone"],
|
|
1148
|
+
hidden: ["gone"],
|
|
1149
|
+
};
|
|
1150
|
+
|
|
1151
|
+
sanitizeColumnPreference({ preference, knownColumnIds: ["name"] });
|
|
1152
|
+
|
|
1153
|
+
expect(preference).toEqual({ order: ["name", "gone"], hidden: ["gone"] });
|
|
1154
|
+
});
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
describe("isEmptyColumnPreference", () => {
|
|
1158
|
+
test("null and undefined are empty", () => {
|
|
1159
|
+
expect(isEmptyColumnPreference(null)).toBe(true);
|
|
1160
|
+
expect(isEmptyColumnPreference(undefined)).toBe(true);
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
test("two empty lists are empty", () => {
|
|
1164
|
+
expect(isEmptyColumnPreference({ order: [], hidden: [] })).toBe(true);
|
|
1165
|
+
expect(isEmptyColumnPreference(EmptyColumnPreference)).toBe(true);
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
test("either list carrying something makes it non-empty", () => {
|
|
1169
|
+
expect(isEmptyColumnPreference({ order: ["name"], hidden: [] })).toBe(
|
|
1170
|
+
false,
|
|
1171
|
+
);
|
|
1172
|
+
expect(isEmptyColumnPreference({ order: [], hidden: ["name"] })).toBe(
|
|
1173
|
+
false,
|
|
1174
|
+
);
|
|
1175
|
+
expect(isEmptyColumnPreference({ order: ["name"], hidden: ["name"] })).toBe(
|
|
1176
|
+
false,
|
|
1177
|
+
);
|
|
1178
|
+
});
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
describe("toJSON", () => {
|
|
1182
|
+
test("a layout that changes nothing is stored as nothing", () => {
|
|
1183
|
+
/*
|
|
1184
|
+
* Otherwise every table anybody merely opened would write a row of default
|
|
1185
|
+
* preferences, and there would be no way to tell "never customised" from
|
|
1186
|
+
* "customised back to the default".
|
|
1187
|
+
*/
|
|
1188
|
+
expect(toJSON(null)).toBeNull();
|
|
1189
|
+
expect(toJSON(undefined)).toBeNull();
|
|
1190
|
+
expect(toJSON({ order: [], hidden: [] })).toBeNull();
|
|
1191
|
+
expect(toJSON(EmptyColumnPreference)).toBeNull();
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
test("writes both lists", () => {
|
|
1195
|
+
expect(toJSON({ order: ["labels", "name"], hidden: ["name"] })).toEqual({
|
|
1196
|
+
order: ["labels", "name"],
|
|
1197
|
+
hidden: ["name"],
|
|
1198
|
+
});
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
test("writes an order-only layout", () => {
|
|
1202
|
+
expect(toJSON({ order: ["labels", "name"], hidden: [] })).toEqual({
|
|
1203
|
+
order: ["labels", "name"],
|
|
1204
|
+
hidden: [],
|
|
1205
|
+
});
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
test("writes a hidden-only layout", () => {
|
|
1209
|
+
expect(toJSON({ order: [], hidden: ["labels"] })).toEqual({
|
|
1210
|
+
order: [],
|
|
1211
|
+
hidden: ["labels"],
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
test("copies the arrays rather than aliasing them", () => {
|
|
1216
|
+
// The stored value must not change under a later picker edit.
|
|
1217
|
+
const preference: ColumnPreference = { order: ["name"], hidden: ["name"] };
|
|
1218
|
+
const json: JSONObject | null = toJSON(preference);
|
|
1219
|
+
|
|
1220
|
+
expect(json?.["order"]).not.toBe(preference.order);
|
|
1221
|
+
expect(json?.["hidden"]).not.toBe(preference.hidden);
|
|
1222
|
+
});
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
describe("fromJSON", () => {
|
|
1226
|
+
test("rejects anything that is not a plain object", () => {
|
|
1227
|
+
/*
|
|
1228
|
+
* The stored value is untrusted: it may have been hand-edited, or written
|
|
1229
|
+
* by a release that shaped it differently.
|
|
1230
|
+
*/
|
|
1231
|
+
expect(fromJSON(null)).toBeNull();
|
|
1232
|
+
expect(fromJSON(undefined)).toBeNull();
|
|
1233
|
+
expect(fromJSON("nope" as unknown as JSONObject)).toBeNull();
|
|
1234
|
+
expect(fromJSON(7 as unknown as JSONObject)).toBeNull();
|
|
1235
|
+
expect(fromJSON(true as unknown as JSONObject)).toBeNull();
|
|
1236
|
+
expect(fromJSON([] as unknown as JSONObject)).toBeNull();
|
|
1237
|
+
expect(fromJSON(["name", "labels"] as unknown as JSONObject)).toBeNull();
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
test("reads a well-formed layout", () => {
|
|
1241
|
+
expect(fromJSON({ order: ["labels", "name"], hidden: ["name"] })).toEqual({
|
|
1242
|
+
order: ["labels", "name"],
|
|
1243
|
+
hidden: ["name"],
|
|
1244
|
+
});
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
test("an object with neither list reads as no layout", () => {
|
|
1248
|
+
expect(fromJSON({})).toBeNull();
|
|
1249
|
+
expect(fromJSON({ order: [], hidden: [] })).toBeNull();
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
test("a list that is not an array degrades to empty", () => {
|
|
1253
|
+
expect(fromJSON({ order: "labels", hidden: ["name"] })).toEqual({
|
|
1254
|
+
order: [],
|
|
1255
|
+
hidden: ["name"],
|
|
1256
|
+
});
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
test("one malformed list does not discard the other", () => {
|
|
1260
|
+
// Each list is validated on its own so a single bad key is survivable.
|
|
1261
|
+
expect(
|
|
1262
|
+
fromJSON({
|
|
1263
|
+
order: ["name"],
|
|
1264
|
+
hidden: { name: true } as unknown as string,
|
|
1265
|
+
}),
|
|
1266
|
+
).toEqual({ order: ["name"], hidden: [] });
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
test("non-string entries are dropped", () => {
|
|
1270
|
+
expect(
|
|
1271
|
+
fromJSON({
|
|
1272
|
+
order: ["name", 5, null, true, { a: 1 }, "labels"],
|
|
1273
|
+
hidden: [["labels"], "labels"],
|
|
1274
|
+
} as unknown as JSONObject),
|
|
1275
|
+
).toEqual({ order: ["name", "labels"], hidden: ["labels"] });
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
test("empty-string entries are dropped", () => {
|
|
1279
|
+
expect(fromJSON({ order: ["", "name", ""], hidden: [""] })).toEqual({
|
|
1280
|
+
order: ["name"],
|
|
1281
|
+
hidden: [],
|
|
1282
|
+
});
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
test("duplicates are dropped within each list independently", () => {
|
|
1286
|
+
// The same id in both lists is legitimate: arranged, then switched off.
|
|
1287
|
+
expect(
|
|
1288
|
+
fromJSON({
|
|
1289
|
+
order: ["name", "labels", "name"],
|
|
1290
|
+
hidden: ["name", "name"],
|
|
1291
|
+
}),
|
|
1292
|
+
).toEqual({ order: ["name", "labels"], hidden: ["name"] });
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
test("unknown keys are ignored", () => {
|
|
1296
|
+
expect(
|
|
1297
|
+
fromJSON({
|
|
1298
|
+
order: ["name"],
|
|
1299
|
+
hidden: [],
|
|
1300
|
+
pinned: ["name"],
|
|
1301
|
+
version: 3,
|
|
1302
|
+
} as unknown as JSONObject),
|
|
1303
|
+
).toEqual({ order: ["name"], hidden: [] });
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
test("a layout that sanitizes down to nothing reads as no layout", () => {
|
|
1307
|
+
expect(
|
|
1308
|
+
fromJSON({ order: [1, 2], hidden: [""] } as unknown as JSONObject),
|
|
1309
|
+
).toBeNull();
|
|
1310
|
+
});
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
describe("ColumnPreference round trip", () => {
|
|
1314
|
+
test("survives toJSON -> JSON -> fromJSON unchanged", () => {
|
|
1315
|
+
const preference: ColumnPreference = {
|
|
1316
|
+
order: ["labels", "name", "customFields.Severity", "description"],
|
|
1317
|
+
hidden: ["description"],
|
|
1318
|
+
};
|
|
1319
|
+
|
|
1320
|
+
const json: JSONObject | null = toJSON(preference);
|
|
1321
|
+
|
|
1322
|
+
expect(fromJSON(JSON.parse(JSON.stringify(json)))).toEqual(preference);
|
|
1323
|
+
});
|
|
1324
|
+
|
|
1325
|
+
test("survives the full picker cycle: columns -> entries -> JSON -> columns", () => {
|
|
1326
|
+
const columns: Columns<Monitor> = [
|
|
1327
|
+
makeColumn({
|
|
1328
|
+
field: { name: true },
|
|
1329
|
+
title: "Name",
|
|
1330
|
+
isNotCustomizable: true,
|
|
1331
|
+
}),
|
|
1332
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
1333
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
1334
|
+
makeColumn({
|
|
1335
|
+
field: { customFields: true },
|
|
1336
|
+
selectedProperty: "Severity",
|
|
1337
|
+
title: "Severity",
|
|
1338
|
+
isHiddenByDefault: true,
|
|
1339
|
+
}),
|
|
1340
|
+
];
|
|
1341
|
+
|
|
1342
|
+
// The viewer drags Severity to the front and switches Description off.
|
|
1343
|
+
const edited: Array<CustomizableColumn<Monitor>> = [
|
|
1344
|
+
{ ...findEntry(getCustomizableColumns<Monitor>({ columns }), "name") },
|
|
1345
|
+
{
|
|
1346
|
+
...findEntry(
|
|
1347
|
+
getCustomizableColumns<Monitor>({ columns }),
|
|
1348
|
+
"customFields.Severity",
|
|
1349
|
+
),
|
|
1350
|
+
isVisible: true,
|
|
1351
|
+
},
|
|
1352
|
+
{ ...findEntry(getCustomizableColumns<Monitor>({ columns }), "labels") },
|
|
1353
|
+
{
|
|
1354
|
+
...findEntry(
|
|
1355
|
+
getCustomizableColumns<Monitor>({ columns }),
|
|
1356
|
+
"description",
|
|
1357
|
+
),
|
|
1358
|
+
isVisible: false,
|
|
1359
|
+
},
|
|
1360
|
+
];
|
|
1361
|
+
|
|
1362
|
+
const stored: JSONObject | null = toJSON(
|
|
1363
|
+
buildColumnPreference<Monitor>(edited),
|
|
1364
|
+
);
|
|
1365
|
+
|
|
1366
|
+
const restored: ColumnPreference | null = fromJSON(
|
|
1367
|
+
JSON.parse(JSON.stringify(stored)),
|
|
1368
|
+
);
|
|
1369
|
+
|
|
1370
|
+
expect(
|
|
1371
|
+
titlesOf(
|
|
1372
|
+
applyColumnPreference<Monitor>({ columns, preference: restored }),
|
|
1373
|
+
),
|
|
1374
|
+
).toEqual(["Name", "Severity", "Labels"]);
|
|
1375
|
+
});
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
/*
|
|
1379
|
+
* ---------------------------------------------------------------------------
|
|
1380
|
+
* The property the whole feature rests on
|
|
1381
|
+
* ---------------------------------------------------------------------------
|
|
1382
|
+
*
|
|
1383
|
+
* A layout is written against one release's column set and read back against
|
|
1384
|
+
* another's. Every case below takes ONE stored layout - the one a viewer would
|
|
1385
|
+
* plausibly have - and replays it against a changed table. The bar is not
|
|
1386
|
+
* "the layout is honoured exactly"; it is "the table is still usable and
|
|
1387
|
+
* nothing the viewer did not ask to hide has disappeared".
|
|
1388
|
+
*/
|
|
1389
|
+
describe("a stored layout replayed against a changed column set", () => {
|
|
1390
|
+
type ReleaseOneColumnsFunction = () => Columns<Monitor>;
|
|
1391
|
+
|
|
1392
|
+
// Name (pinned) | Description | Labels | Severity(custom field)
|
|
1393
|
+
const releaseOneColumns: ReleaseOneColumnsFunction = (): Columns<Monitor> => {
|
|
1394
|
+
return [
|
|
1395
|
+
makeColumn({
|
|
1396
|
+
field: { name: true },
|
|
1397
|
+
title: "Name",
|
|
1398
|
+
isNotCustomizable: true,
|
|
1399
|
+
}),
|
|
1400
|
+
makeColumn({ field: { description: true }, title: "Description" }),
|
|
1401
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
1402
|
+
makeColumn({
|
|
1403
|
+
field: { customFields: true },
|
|
1404
|
+
selectedProperty: "Severity",
|
|
1405
|
+
title: "Severity",
|
|
1406
|
+
isHiddenByDefault: true,
|
|
1407
|
+
}),
|
|
1408
|
+
];
|
|
1409
|
+
};
|
|
1410
|
+
|
|
1411
|
+
/*
|
|
1412
|
+
* What the viewer did: pulled the Severity custom field to the front and
|
|
1413
|
+
* switched Labels off.
|
|
1414
|
+
*/
|
|
1415
|
+
const storedPreference: ColumnPreference = {
|
|
1416
|
+
order: ["customFields.Severity", "description", "labels"],
|
|
1417
|
+
hidden: ["labels"],
|
|
1418
|
+
};
|
|
1419
|
+
|
|
1420
|
+
type ReplayFunction = (columns: Columns<Monitor>) => Array<string>;
|
|
1421
|
+
|
|
1422
|
+
const replay: ReplayFunction = (columns: Columns<Monitor>): Array<string> => {
|
|
1423
|
+
const sanitized: ColumnPreference | null = sanitizeColumnPreference({
|
|
1424
|
+
preference: storedPreference,
|
|
1425
|
+
knownColumnIds: getColumnIds<Monitor>(columns),
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
return titlesOf(
|
|
1429
|
+
applyColumnPreference<Monitor>({ columns, preference: sanitized }),
|
|
1430
|
+
);
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
test("the layout is honoured when nothing changed", () => {
|
|
1434
|
+
expect(replay(releaseOneColumns())).toEqual([
|
|
1435
|
+
"Name",
|
|
1436
|
+
"Severity",
|
|
1437
|
+
"Description",
|
|
1438
|
+
]);
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
test("a removed column just falls out; the rest keep their arrangement", () => {
|
|
1442
|
+
const columns: Columns<Monitor> = releaseOneColumns().filter(
|
|
1443
|
+
(column: Column<Monitor>): boolean => {
|
|
1444
|
+
return column.title !== "Labels";
|
|
1445
|
+
},
|
|
1446
|
+
);
|
|
1447
|
+
|
|
1448
|
+
expect(replay(columns)).toEqual(["Name", "Severity", "Description"]);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
test("a removed CUSTOM FIELD does not drag its neighbours out of order", () => {
|
|
1452
|
+
/*
|
|
1453
|
+
* Custom fields are deleted by project admins, so this is the most common
|
|
1454
|
+
* way a stored id goes stale — much more common than a shipped column
|
|
1455
|
+
* being removed.
|
|
1456
|
+
*/
|
|
1457
|
+
const columns: Columns<Monitor> = releaseOneColumns().filter(
|
|
1458
|
+
(column: Column<Monitor>): boolean => {
|
|
1459
|
+
return column.selectedProperty !== "Severity";
|
|
1460
|
+
},
|
|
1461
|
+
);
|
|
1462
|
+
|
|
1463
|
+
expect(replay(columns)).toEqual(["Name", "Description"]);
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
test("a column added at the start appears there, and visible", () => {
|
|
1467
|
+
const columns: Columns<Monitor> = releaseOneColumns();
|
|
1468
|
+
columns.splice(1, 0, makeColumn({ field: { slug: true }, title: "Slug" }));
|
|
1469
|
+
|
|
1470
|
+
expect(replay(columns)).toEqual([
|
|
1471
|
+
"Name",
|
|
1472
|
+
"Slug",
|
|
1473
|
+
"Severity",
|
|
1474
|
+
"Description",
|
|
1475
|
+
]);
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
test("a column added in the middle lands next to its declared predecessor", () => {
|
|
1479
|
+
const columns: Columns<Monitor> = releaseOneColumns();
|
|
1480
|
+
// Declared between Description and Labels.
|
|
1481
|
+
columns.splice(2, 0, makeColumn({ field: { slug: true }, title: "Slug" }));
|
|
1482
|
+
|
|
1483
|
+
expect(replay(columns)).toEqual([
|
|
1484
|
+
"Name",
|
|
1485
|
+
"Severity",
|
|
1486
|
+
"Description",
|
|
1487
|
+
"Slug",
|
|
1488
|
+
]);
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
test("a column added at the end is visible and does not disturb the layout", () => {
|
|
1492
|
+
const columns: Columns<Monitor> = releaseOneColumns();
|
|
1493
|
+
columns.push(makeColumn({ field: { monitorType: true }, title: "Type" }));
|
|
1494
|
+
|
|
1495
|
+
const rendered: Array<string> = replay(columns);
|
|
1496
|
+
|
|
1497
|
+
expect(rendered).toContain("Type");
|
|
1498
|
+
// Labels stays hidden; the arranged columns keep their relative order.
|
|
1499
|
+
expect(rendered).not.toContain("Labels");
|
|
1500
|
+
expect(rendered.indexOf("Severity")).toBeLessThan(
|
|
1501
|
+
rendered.indexOf("Description"),
|
|
1502
|
+
);
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
test("a new custom field is offered in the picker but stays switched off", () => {
|
|
1506
|
+
/*
|
|
1507
|
+
* New custom fields ship hidden by default, and a layout written before
|
|
1508
|
+
* they existed says nothing about them — so they stay off until the viewer
|
|
1509
|
+
* opts in, rather than widening everyone's table overnight.
|
|
1510
|
+
*/
|
|
1511
|
+
const columns: Columns<Monitor> = releaseOneColumns();
|
|
1512
|
+
columns.push(
|
|
1513
|
+
makeColumn({
|
|
1514
|
+
field: { customFields: true },
|
|
1515
|
+
selectedProperty: "Team",
|
|
1516
|
+
title: "Team",
|
|
1517
|
+
isHiddenByDefault: true,
|
|
1518
|
+
}),
|
|
1519
|
+
);
|
|
1520
|
+
|
|
1521
|
+
const entries: Array<CustomizableColumn<Monitor>> =
|
|
1522
|
+
getCustomizableColumns<Monitor>({
|
|
1523
|
+
columns,
|
|
1524
|
+
preference: storedPreference,
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
// Listed in the picker, so it can be switched on...
|
|
1528
|
+
expect(entryIds(entries)).toContain("customFields.Team");
|
|
1529
|
+
// ...but off until then.
|
|
1530
|
+
expect(findEntry(entries, "customFields.Team").isVisible).toBe(false);
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
test("renaming a column does not disturb a layout keyed off its field", () => {
|
|
1534
|
+
const columns: Columns<Monitor> = releaseOneColumns().map(
|
|
1535
|
+
(column: Column<Monitor>): Column<Monitor> => {
|
|
1536
|
+
return column.title === "Description"
|
|
1537
|
+
? { ...column, title: "Summary" }
|
|
1538
|
+
: column;
|
|
1539
|
+
},
|
|
1540
|
+
);
|
|
1541
|
+
|
|
1542
|
+
expect(replay(columns)).toEqual(["Name", "Severity", "Summary"]);
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
test("renaming a COLLIDING column loses its layout entry but never the column", () => {
|
|
1546
|
+
/*
|
|
1547
|
+
* Colliding columns are keyed partly by title, so a rename is a new id.
|
|
1548
|
+
* The consequence is bounded on purpose: the entry goes stale, sanitize
|
|
1549
|
+
* drops it, and the column comes back as if it were newly shipped —
|
|
1550
|
+
* visible. Failing open is the whole point.
|
|
1551
|
+
*/
|
|
1552
|
+
const before: Columns<Monitor> = [
|
|
1553
|
+
makeColumn({
|
|
1554
|
+
field: { name: true },
|
|
1555
|
+
title: "Name",
|
|
1556
|
+
isNotCustomizable: true,
|
|
1557
|
+
}),
|
|
1558
|
+
makeColumn({ field: { _id: true }, title: "Actions" }),
|
|
1559
|
+
makeColumn({ field: { _id: true }, title: "Owner" }),
|
|
1560
|
+
];
|
|
1561
|
+
|
|
1562
|
+
const preference: ColumnPreference = {
|
|
1563
|
+
order: ["_id:actions", "_id:owner"],
|
|
1564
|
+
hidden: ["_id:owner"],
|
|
1565
|
+
};
|
|
1566
|
+
|
|
1567
|
+
expect(
|
|
1568
|
+
titlesOf(applyColumnPreference<Monitor>({ columns: before, preference })),
|
|
1569
|
+
).toEqual(["Name", "Actions"]);
|
|
1570
|
+
|
|
1571
|
+
const after: Columns<Monitor> = [
|
|
1572
|
+
makeColumn({
|
|
1573
|
+
field: { name: true },
|
|
1574
|
+
title: "Name",
|
|
1575
|
+
isNotCustomizable: true,
|
|
1576
|
+
}),
|
|
1577
|
+
makeColumn({ field: { _id: true }, title: "Actions" }),
|
|
1578
|
+
makeColumn({ field: { _id: true }, title: "Owned By" }),
|
|
1579
|
+
];
|
|
1580
|
+
|
|
1581
|
+
const sanitized: ColumnPreference | null = sanitizeColumnPreference({
|
|
1582
|
+
preference,
|
|
1583
|
+
knownColumnIds: getColumnIds<Monitor>(after),
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
expect(sanitized).toEqual({ order: ["_id:actions"], hidden: [] });
|
|
1587
|
+
expect(
|
|
1588
|
+
titlesOf(
|
|
1589
|
+
applyColumnPreference<Monitor>({
|
|
1590
|
+
columns: after,
|
|
1591
|
+
preference: sanitized,
|
|
1592
|
+
}),
|
|
1593
|
+
),
|
|
1594
|
+
).toEqual(["Name", "Actions", "Owned By"]);
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
test("a layout whose columns are all gone sanitizes away and the table renders its own defaults", () => {
|
|
1598
|
+
const columns: Columns<Monitor> = [
|
|
1599
|
+
makeColumn({
|
|
1600
|
+
field: { name: true },
|
|
1601
|
+
title: "Name",
|
|
1602
|
+
isNotCustomizable: true,
|
|
1603
|
+
}),
|
|
1604
|
+
makeColumn({ field: { monitorType: true }, title: "Type" }),
|
|
1605
|
+
makeColumn({ field: { slug: true }, title: "Slug" }),
|
|
1606
|
+
];
|
|
1607
|
+
|
|
1608
|
+
const sanitized: ColumnPreference | null = sanitizeColumnPreference({
|
|
1609
|
+
preference: storedPreference,
|
|
1610
|
+
knownColumnIds: getColumnIds<Monitor>(columns),
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
expect(sanitized).toBeNull();
|
|
1614
|
+
expect(
|
|
1615
|
+
titlesOf(
|
|
1616
|
+
applyColumnPreference<Monitor>({
|
|
1617
|
+
columns,
|
|
1618
|
+
preference: sanitized,
|
|
1619
|
+
}),
|
|
1620
|
+
),
|
|
1621
|
+
).toEqual(["Name", "Type", "Slug"]);
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
test("a column that becomes pinned stops obeying the layout that hid it", () => {
|
|
1625
|
+
/*
|
|
1626
|
+
* Tables do gain a pinned column over time (usually the name). A layout
|
|
1627
|
+
* that hid it must not survive that change, or the row would be
|
|
1628
|
+
* unidentifiable with no control to fix it.
|
|
1629
|
+
*/
|
|
1630
|
+
const columns: Columns<Monitor> = [
|
|
1631
|
+
makeColumn({
|
|
1632
|
+
field: { name: true },
|
|
1633
|
+
title: "Name",
|
|
1634
|
+
isNotCustomizable: true,
|
|
1635
|
+
}),
|
|
1636
|
+
makeColumn({
|
|
1637
|
+
field: { description: true },
|
|
1638
|
+
title: "Description",
|
|
1639
|
+
isNotCustomizable: true,
|
|
1640
|
+
}),
|
|
1641
|
+
makeColumn({ field: { labels: true }, title: "Labels" }),
|
|
1642
|
+
];
|
|
1643
|
+
|
|
1644
|
+
expect(
|
|
1645
|
+
titlesOf(
|
|
1646
|
+
applyColumnPreference<Monitor>({
|
|
1647
|
+
columns,
|
|
1648
|
+
preference: {
|
|
1649
|
+
order: ["labels", "description"],
|
|
1650
|
+
hidden: ["description", "labels"],
|
|
1651
|
+
},
|
|
1652
|
+
}),
|
|
1653
|
+
),
|
|
1654
|
+
).toEqual(["Name", "Description"]);
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
test("the whole table changing shape at once still renders something usable", () => {
|
|
1658
|
+
// Nothing in common with the layout except the pinned column.
|
|
1659
|
+
const columns: Columns<Monitor> = [
|
|
1660
|
+
makeColumn({
|
|
1661
|
+
field: { name: true },
|
|
1662
|
+
title: "Name",
|
|
1663
|
+
isNotCustomizable: true,
|
|
1664
|
+
}),
|
|
1665
|
+
makeColumn({ field: { _id: true }, title: "Actions" }),
|
|
1666
|
+
makeColumn({ field: { _id: true }, title: "Actions" }),
|
|
1667
|
+
makeColumn({ field: {}, title: "" }),
|
|
1668
|
+
];
|
|
1669
|
+
|
|
1670
|
+
const rendered: Columns<Monitor> = applyColumnPreference<Monitor>({
|
|
1671
|
+
columns,
|
|
1672
|
+
preference: storedPreference,
|
|
1673
|
+
});
|
|
1674
|
+
|
|
1675
|
+
expect(rendered).toHaveLength(4);
|
|
1676
|
+
expect(titlesOf(rendered)).toEqual(["Name", "Actions", "Actions", ""]);
|
|
1677
|
+
});
|
|
1678
|
+
});
|