@oneuptime/common 12.0.31 → 12.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/Models/DatabaseModels/AlertState.ts +0 -2
  2. package/Models/DatabaseModels/Domain.ts +1 -1
  3. package/Models/DatabaseModels/Incident.ts +1 -1
  4. package/Models/DatabaseModels/IncidentTemplate.ts +1 -1
  5. package/Models/DatabaseModels/Index.ts +2 -0
  6. package/Models/DatabaseModels/MetricType.ts +0 -2
  7. package/Models/DatabaseModels/MonitorStatusTimeline.ts +0 -2
  8. package/Models/DatabaseModels/MonitorTest.ts +0 -2
  9. package/Models/DatabaseModels/NetworkDevice.ts +121 -2
  10. package/Models/DatabaseModels/NetworkDeviceOidTemplate.ts +3 -1
  11. package/Models/DatabaseModels/NetworkDeviceRole.ts +648 -0
  12. package/Models/DatabaseModels/ScheduledMaintenance.ts +1 -1
  13. package/Models/DatabaseModels/ScheduledMaintenanceTemplate.ts +1 -1
  14. package/Models/DatabaseModels/StatusPageAnnouncement.ts +0 -2
  15. package/Models/DatabaseModels/StatusPageAnnouncementTemplate.ts +0 -2
  16. package/Models/DatabaseModels/StatusPagePrivateUser.ts +0 -2
  17. package/Models/DatabaseModels/StatusPageResource.ts +0 -2
  18. package/Models/DatabaseModels/StatusPageSubscriber.ts +0 -2
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/1790800000000-AddNetworkDeviceRoleTable.ts +72 -0
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  21. package/Server/Services/DatabaseService.ts +39 -9
  22. package/Server/Services/Index.ts +2 -0
  23. package/Server/Services/NetworkDeviceRoleService.ts +90 -0
  24. package/Server/Services/ProjectService.ts +85 -0
  25. package/Server/Services/UserNotificationSettingService.ts +0 -6
  26. package/Tests/App/Dashboard/AddNeighborToMonitoringModal.test.tsx +31 -1
  27. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +35 -8
  28. package/Tests/App/Dashboard/DashboardCommandPalette.test.tsx +107 -3
  29. package/Tests/App/Dashboard/DashboardWideChartZoom.test.tsx +401 -0
  30. package/Tests/App/Dashboard/MonitorTemplateColumnAndFacet.test.tsx +318 -0
  31. package/Tests/App/Dashboard/NetworkDeviceBulkDelete.test.tsx +357 -0
  32. package/Tests/App/Dashboard/NetworkSideMenu.test.tsx +590 -0
  33. package/Tests/App/Dashboard/SideMenuHarness.tsx +11 -3
  34. package/Tests/Models/DatabaseModels/ModelRegistryInvariants.test.ts +115 -41
  35. package/Tests/Server/Infrastructure/Postgres/NetworkDeviceRoleTableMigration.test.ts +476 -0
  36. package/Tests/Server/Services/DatabaseServiceGenerateSlug.test.ts +215 -0
  37. package/Tests/Server/Services/NetworkDeviceRoleModel.test.ts +672 -0
  38. package/Tests/Server/Services/NetworkDeviceRuleEngines.test.ts +1 -1
  39. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +67 -4
  40. package/Tests/Server/Services/ScheduledMaintenanceSubscriberEndTime.test.ts +369 -0
  41. package/Tests/Server/Services/UserNotificationSettingMonitorDefaults.test.ts +248 -0
  42. package/Tests/Server/Services/UserNotificationSettingWorkspaceChannels.test.ts +43 -2
  43. package/Tests/Server/Utils/BasicCron.test.ts +283 -0
  44. package/Tests/Server/Utils/IncomingCallPhoneNumber.test.ts +174 -0
  45. package/Tests/Types/NetworkDevice/DefaultNetworkDeviceRole.test.ts +417 -0
  46. package/Tests/UI/Components/Charts/ChartClickDoubleClickDisambiguation.test.tsx +221 -0
  47. package/Tests/UI/Components/Charts/ChartDoubleClickReset.test.tsx +220 -0
  48. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +751 -0
  49. package/Tests/UI/Components/ModelTable/BaseModelTableColumnCustomization.test.tsx +475 -43
  50. package/Tests/UI/Components/ModelTable/ColumnPreference.test.ts +89 -0
  51. package/Tests/UI/Components/NavBarCommandKGating.test.tsx +103 -8
  52. package/Tests/UI/Components/SideMenuItem.test.tsx +30 -0
  53. package/Tests/UI/Utils/TableColumnsToCsv.test.ts +30 -0
  54. package/Tests/UI/Utils/UseDashboardTimeRangeZoom.test.tsx +187 -0
  55. package/Tests/Utils/Dashboard/DashboardResourceListSharedArgs.test.ts +302 -0
  56. package/Tests/Utils/Dashboard/DashboardTimeRangeZoom.test.ts +242 -0
  57. package/Tests/Utils/Monitor/NetworkDeviceRoleCatalog.test.ts +981 -0
  58. package/Tests/Utils/Monitor/NetworkTopologyRoleCatalog.test.ts +865 -0
  59. package/Tests/Utils/NetworkDevice/DeviceRoleKeyUtil.test.ts +538 -0
  60. package/Tests/Utils/Slo/SloEvaluation.test.ts +171 -0
  61. package/Tests/Utils/Slug.test.ts +61 -1
  62. package/Tests/jest.setup.ts +19 -0
  63. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +61 -0
  64. package/Types/NetworkDevice/DefaultNetworkDeviceRole.ts +145 -0
  65. package/Types/Permission.ts +45 -0
  66. package/UI/Components/Charts/Area/AreaChart.tsx +7 -0
  67. package/UI/Components/Charts/Bar/BarChart.tsx +6 -0
  68. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +9 -1
  69. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +97 -35
  70. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +14 -0
  71. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +97 -35
  72. package/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.ts +13 -0
  73. package/UI/Components/Charts/Line/LineChart.tsx +7 -0
  74. package/UI/Components/ModelTable/BaseModelTable.tsx +50 -15
  75. package/UI/Components/Navbar/NavBar.tsx +62 -13
  76. package/UI/Components/Navbar/NavBarMenuModal.tsx +7 -2
  77. package/UI/Components/SideMenu/CountModelSideMenuItem.tsx +2 -0
  78. package/UI/Components/SideMenu/SideMenu.tsx +12 -2
  79. package/UI/Components/SideMenu/SideMenuItem.tsx +4 -1
  80. package/UI/Components/SideMenu/SideMenuSection.tsx +14 -2
  81. package/UI/Utils/UseDashboardTimeRangeZoom.ts +77 -0
  82. package/Utils/Dashboard/DashboardTimeRangeZoom.ts +118 -0
  83. package/Utils/Monitor/NetworkDeviceRoleCatalog.ts +232 -0
  84. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +2 -1
  85. package/Utils/Monitor/NetworkTopologyUtil.ts +116 -33
  86. package/Utils/NetworkDevice/DeviceRoleKeyUtil.ts +137 -0
  87. package/Utils/Rules/RulePatternMatchUtil.ts +1 -1
  88. package/Utils/Slug.ts +34 -1
  89. package/build/dist/Models/DatabaseModels/AlertState.js +0 -2
  90. package/build/dist/Models/DatabaseModels/AlertState.js.map +1 -1
  91. package/build/dist/Models/DatabaseModels/Domain.js +1 -1
  92. package/build/dist/Models/DatabaseModels/Domain.js.map +1 -1
  93. package/build/dist/Models/DatabaseModels/Incident.js +1 -1
  94. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  95. package/build/dist/Models/DatabaseModels/IncidentTemplate.js +1 -1
  96. package/build/dist/Models/DatabaseModels/IncidentTemplate.js.map +1 -1
  97. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  98. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/MetricType.js +0 -2
  100. package/build/dist/Models/DatabaseModels/MetricType.js.map +1 -1
  101. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js +0 -2
  102. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js.map +1 -1
  103. package/build/dist/Models/DatabaseModels/MonitorTest.js +0 -2
  104. package/build/dist/Models/DatabaseModels/MonitorTest.js.map +1 -1
  105. package/build/dist/Models/DatabaseModels/NetworkDevice.js +124 -3
  106. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  107. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js +3 -1
  108. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js.map +1 -1
  109. package/build/dist/Models/DatabaseModels/NetworkDeviceRole.js +677 -0
  110. package/build/dist/Models/DatabaseModels/NetworkDeviceRole.js.map +1 -0
  111. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +1 -1
  112. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js +1 -1
  114. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js +0 -2
  116. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/StatusPageAnnouncementTemplate.js +0 -2
  118. package/build/dist/Models/DatabaseModels/StatusPageAnnouncementTemplate.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +0 -2
  120. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPageResource.js +0 -2
  122. package/build/dist/Models/DatabaseModels/StatusPageResource.js.map +1 -1
  123. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js +0 -2
  124. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js.map +1 -1
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790800000000-AddNetworkDeviceRoleTable.js +39 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790800000000-AddNetworkDeviceRoleTable.js.map +1 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  129. package/build/dist/Server/Services/DatabaseService.js +31 -5
  130. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  131. package/build/dist/Server/Services/Index.js +2 -0
  132. package/build/dist/Server/Services/Index.js.map +1 -1
  133. package/build/dist/Server/Services/NetworkDeviceRoleService.js +70 -0
  134. package/build/dist/Server/Services/NetworkDeviceRoleService.js.map +1 -0
  135. package/build/dist/Server/Services/ProjectService.js +65 -0
  136. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  137. package/build/dist/Server/Services/UserNotificationSettingService.js +0 -1
  138. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  139. package/build/dist/Types/NetworkDevice/DefaultNetworkDeviceRole.js +105 -0
  140. package/build/dist/Types/NetworkDevice/DefaultNetworkDeviceRole.js.map +1 -0
  141. package/build/dist/Types/Permission.js +40 -0
  142. package/build/dist/Types/Permission.js.map +1 -1
  143. package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
  144. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  145. package/build/dist/UI/Components/Charts/Bar/BarChart.js +1 -1
  146. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  147. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +6 -1
  148. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  149. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +79 -31
  150. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  151. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +7 -1
  152. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  153. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +79 -31
  154. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  155. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js +14 -0
  156. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js.map +1 -0
  157. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  158. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  159. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +39 -13
  160. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  161. package/build/dist/UI/Components/Navbar/NavBar.js +44 -9
  162. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  163. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +2 -2
  164. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  165. package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js +1 -1
  166. package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js.map +1 -1
  167. package/build/dist/UI/Components/SideMenu/SideMenu.js +9 -6
  168. package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
  169. package/build/dist/UI/Components/SideMenu/SideMenuItem.js +1 -1
  170. package/build/dist/UI/Components/SideMenu/SideMenuItem.js.map +1 -1
  171. package/build/dist/UI/Components/SideMenu/SideMenuSection.js +7 -2
  172. package/build/dist/UI/Components/SideMenu/SideMenuSection.js.map +1 -1
  173. package/build/dist/UI/Utils/UseDashboardTimeRangeZoom.js +42 -0
  174. package/build/dist/UI/Utils/UseDashboardTimeRangeZoom.js.map +1 -0
  175. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js +74 -0
  176. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js.map +1 -0
  177. package/build/dist/Utils/Monitor/NetworkDeviceRoleCatalog.js +126 -0
  178. package/build/dist/Utils/Monitor/NetworkDeviceRoleCatalog.js.map +1 -0
  179. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +2 -1
  180. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  181. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +67 -32
  182. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  183. package/build/dist/Utils/NetworkDevice/DeviceRoleKeyUtil.js +114 -0
  184. package/build/dist/Utils/NetworkDevice/DeviceRoleKeyUtil.js.map +1 -0
  185. package/build/dist/Utils/Rules/RulePatternMatchUtil.js +1 -1
  186. package/build/dist/Utils/Slug.js +31 -1
  187. package/build/dist/Utils/Slug.js.map +1 -1
  188. package/package.json +1 -1
@@ -8,7 +8,7 @@ import {
8
8
  screen,
9
9
  waitFor,
10
10
  } from "@testing-library/react";
11
- import React from "react";
11
+ import React, { ReactElement } from "react";
12
12
 
13
13
  /*
14
14
  * A real BaseModelTable mounts a card, a header, a full table and (in the
@@ -86,22 +86,31 @@ import BaseModelTable, {
86
86
  ComponentProps as BaseModelTableProps,
87
87
  } from "../../../../UI/Components/ModelTable/BaseModelTable";
88
88
  import Columns from "../../../../UI/Components/ModelTable/Columns";
89
+ import TableColumnsToCsv from "../../../../UI/Utils/TableColumnsToCsv";
89
90
  import TableFilterUrlState from "../../../../UI/Utils/TableFilterUrlState";
90
91
  import Filter from "../../../../UI/Components/ModelFilter/Filter";
91
92
  import FieldType from "../../../../UI/Components/Types/FieldType";
93
+ import { ButtonStyleType } from "../../../../UI/Components/Button/Button";
92
94
  import Monitor from "../../../../Models/DatabaseModels/Monitor";
93
95
  import Query from "../../../../Types/BaseDatabase/Query";
94
96
  import ListResult from "../../../../Types/BaseDatabase/ListResult";
97
+ import SortOrder from "../../../../Types/BaseDatabase/SortOrder";
95
98
  import UserPreferences, {
96
99
  UserPreferenceType,
97
100
  } from "../../../../Utils/UserPreferences";
98
- import { JSONObject } from "../../../../Types/JSON";
101
+ import { JSONObject, JSONValue } from "../../../../Types/JSON";
99
102
 
100
103
  const PREFS_KEY: string = "monitors-columns-table";
101
104
 
102
105
  type GetListCall = {
103
106
  query: Query<Monitor>;
104
107
  select: JSONObject;
108
+ sort: JSONObject;
109
+ };
110
+
111
+ type DownloadedCsvFile = {
112
+ csv: string;
113
+ filename: string;
105
114
  };
106
115
 
107
116
  const FILTERS: Array<Filter<Monitor>> = [
@@ -111,6 +120,14 @@ const FILTERS: Array<Filter<Monitor>> = [
111
120
  type MakeColumnsOptions = {
112
121
  // Adds a `isHiddenByDefault` column ("Monitor Type") at the end.
113
122
  withHiddenByDefault?: boolean | undefined;
123
+ /*
124
+ * Adds a `isHiddenByDefault` *entity* column ("Template") at the end, shaped
125
+ * like the one on the real Monitors table: several nested fields, and a cell
126
+ * rendered from the relation rather than from a scalar on the row. A scalar
127
+ * column cannot stand in for it - the nested part of the request is built by
128
+ * a different function, and the cell it feeds is the one that goes blank.
129
+ */
130
+ withHiddenByDefaultEntity?: boolean | undefined;
114
131
  // Marks "Name" `isNotCustomizable`, i.e. pinned and kept out of the picker.
115
132
  pinName?: boolean | undefined;
116
133
  };
@@ -158,9 +175,80 @@ const makeColumns: MakeColumnsFunction = (
158
175
  });
159
176
  }
160
177
 
178
+ if (options.withHiddenByDefaultEntity) {
179
+ columns.push({
180
+ field: { monitorTemplate: { _id: true, templateName: true } },
181
+ title: "Template",
182
+ type: FieldType.Entity,
183
+ isHiddenByDefault: true,
184
+ getElement: (item: Monitor): ReactElement => {
185
+ return <span>{item.monitorTemplate?.templateName || ""}</span>;
186
+ },
187
+ });
188
+ }
189
+
161
190
  return columns as unknown as Columns<Monitor>;
162
191
  };
163
192
 
193
+ const TEMPLATE_NAME: string = "Standard HTTP Template";
194
+
195
+ /*
196
+ * One fully populated row. The relation is nested exactly as the API returns
197
+ * it, because the point of these tests is what survives the round trip from
198
+ * the request's `select` into the rendered cell.
199
+ */
200
+ const ROWS: Array<JSONObject> = [
201
+ {
202
+ _id: "monitor-1",
203
+ name: "api-gateway",
204
+ description: "The public API gateway",
205
+ monitorTemplate: {
206
+ _id: "template-1",
207
+ templateName: TEMPLATE_NAME,
208
+ },
209
+ },
210
+ ];
211
+
212
+ type ProjectRowFunction = (row: JSONObject, select: JSONObject) => JSONObject;
213
+
214
+ /*
215
+ * The API hands back exactly the fields the request asked for and nothing
216
+ * else, so the fake has to as well. A fake that returned the whole row
217
+ * whatever the select said would keep rendering a value for a field the table
218
+ * had stopped asking for - which is precisely the regression these tests are
219
+ * here to catch.
220
+ */
221
+ const projectRow: ProjectRowFunction = (
222
+ row: JSONObject,
223
+ select: JSONObject,
224
+ ): JSONObject => {
225
+ const projected: JSONObject = {};
226
+
227
+ for (const key of Object.keys(select)) {
228
+ const value: JSONValue | undefined = row[key];
229
+ const selected: JSONValue | undefined = select[key];
230
+
231
+ if (value === undefined) {
232
+ continue;
233
+ }
234
+
235
+ // A nested select projects the relation, not just the presence of it.
236
+ if (
237
+ selected !== null &&
238
+ typeof selected === "object" &&
239
+ value !== null &&
240
+ typeof value === "object"
241
+ ) {
242
+ projected[key] = projectRow(value as JSONObject, selected as JSONObject);
243
+ continue;
244
+ }
245
+
246
+ projected[key] = value;
247
+ }
248
+
249
+ return projected;
250
+ };
251
+
164
252
  const ALL_TITLES: Array<string> = [
165
253
  "Name",
166
254
  "Description",
@@ -230,52 +318,79 @@ const getPickerRowIds: GetPickerRowIdsFunction = (): Array<string> => {
230
318
 
231
319
  describe("BaseModelTable column customization", () => {
232
320
  let calls: Array<GetListCall> = [];
233
-
234
- type MakeCallbacksFunction = () => BaseTableCallbacks<Monitor>;
235
-
236
- const makeCallbacks: MakeCallbacksFunction =
237
- (): BaseTableCallbacks<Monitor> => {
238
- return {
239
- deleteItem: async () => {
240
- return undefined;
241
- },
242
- getModelFromJSON: (item: JSONObject) => {
243
- return item as unknown as Monitor;
244
- },
245
- getJSONFromModel: (item: Monitor) => {
246
- return item as unknown as JSONObject;
247
- },
248
- addSlugToSelect: (select: unknown) => {
249
- return select;
250
- },
251
- getList: async (data: {
252
- query: Query<Monitor>;
253
- limit: number;
254
- select: JSONObject;
255
- }): Promise<ListResult<Monitor>> => {
256
- calls.push({
257
- query: data.query,
258
- select: (data.select || {}) as unknown as JSONObject,
259
- });
260
- return { data: [], count: 0, skip: 0, limit: data.limit };
261
- },
262
- toJSONArray: () => {
263
- return [];
264
- },
265
- updateById: async () => {
266
- return undefined;
267
- },
268
- showCreateEditModal: () => {
269
- return <></>;
270
- },
271
- } as unknown as BaseTableCallbacks<Monitor>;
272
- };
321
+ let downloadedCsvFiles: Array<DownloadedCsvFile> = [];
322
+
323
+ type MakeCallbacksFunction = (
324
+ rows: Array<JSONObject>,
325
+ ) => BaseTableCallbacks<Monitor>;
326
+
327
+ const makeCallbacks: MakeCallbacksFunction = (
328
+ rows: Array<JSONObject>,
329
+ ): BaseTableCallbacks<Monitor> => {
330
+ return {
331
+ deleteItem: async () => {
332
+ return undefined;
333
+ },
334
+ getModelFromJSON: (item: JSONObject) => {
335
+ return item as unknown as Monitor;
336
+ },
337
+ getJSONFromModel: (item: Monitor) => {
338
+ return item as unknown as JSONObject;
339
+ },
340
+ addSlugToSelect: (select: unknown) => {
341
+ return select;
342
+ },
343
+ getList: async (data: {
344
+ query: Query<Monitor>;
345
+ limit: number;
346
+ select: JSONObject;
347
+ sort: JSONObject;
348
+ }): Promise<ListResult<Monitor>> => {
349
+ const select: JSONObject = (data.select || {}) as unknown as JSONObject;
350
+
351
+ calls.push({
352
+ query: data.query,
353
+ select: select,
354
+ sort: (data.sort || {}) as unknown as JSONObject,
355
+ });
356
+
357
+ return {
358
+ data: rows.map((row: JSONObject) => {
359
+ return projectRow(row, select);
360
+ }) as unknown as Array<Monitor>,
361
+ count: rows.length,
362
+ skip: 0,
363
+ limit: data.limit,
364
+ };
365
+ },
366
+ toJSONArray: () => {
367
+ return [];
368
+ },
369
+ updateById: async () => {
370
+ return undefined;
371
+ },
372
+ showCreateEditModal: () => {
373
+ return <></>;
374
+ },
375
+ } as unknown as BaseTableCallbacks<Monitor>;
376
+ };
273
377
 
274
378
  type RenderTableOptions = {
275
379
  userPreferencesKey?: string | undefined;
276
380
  columns?: Columns<Monitor> | undefined;
277
381
  isDeleteable?: boolean | undefined;
278
382
  disableColumnCustomization?: boolean | undefined;
383
+ /*
384
+ * The rows the fake API has. Defaults to none, as most of this suite only
385
+ * looks at headers.
386
+ */
387
+ rows?: Array<JSONObject> | undefined;
388
+ /*
389
+ * Gives the table a bulk action. Table grows its row checkboxes - and
390
+ * offers "Export CSV" - only when this array is non-empty, so it is the
391
+ * only way to reach the export from here.
392
+ */
393
+ withBulkActions?: boolean | undefined;
279
394
  };
280
395
 
281
396
  type RenderTableFunction = (
@@ -303,10 +418,25 @@ describe("BaseModelTable column customization", () => {
303
418
  isEditable: false,
304
419
  // This suite is about columns; keep the URL out of it entirely.
305
420
  disableUrlState: true,
306
- callbacks: makeCallbacks(),
421
+ callbacks: makeCallbacks(options.rows || []),
307
422
  ...(options.disableColumnCustomization
308
423
  ? { disableColumnCustomization: true }
309
424
  : {}),
425
+ ...(options.withBulkActions
426
+ ? {
427
+ bulkActions: {
428
+ buttons: [
429
+ {
430
+ title: "Archive",
431
+ buttonStyleType: ButtonStyleType.NORMAL,
432
+ onClick: async (): Promise<void> => {
433
+ return Promise.resolve();
434
+ },
435
+ },
436
+ ],
437
+ },
438
+ }
439
+ : {}),
310
440
  } as unknown as BaseModelTableProps<Monitor>;
311
441
 
312
442
  return render(<BaseModelTable<Monitor> {...props} />);
@@ -324,6 +454,31 @@ describe("BaseModelTable column customization", () => {
324
454
  });
325
455
  };
326
456
 
457
+ type WaitForCallCountFunction = (count: number) => Promise<void>;
458
+
459
+ const waitForCallCount: WaitForCallCountFunction = async (
460
+ count: number,
461
+ ): Promise<void> => {
462
+ await waitFor(() => {
463
+ expect(calls.length).toBe(count);
464
+ });
465
+ };
466
+
467
+ type SortByColumnFunction = (title: string) => Promise<void>;
468
+
469
+ /*
470
+ * The real click path for sorting: the header cell is a button, so this is
471
+ * what a viewer does, and it drives the same sortBy/sortOrder state the
472
+ * picker later has to reason about.
473
+ */
474
+ const sortByColumn: SortByColumnFunction = async (
475
+ title: string,
476
+ ): Promise<void> => {
477
+ await act(async () => {
478
+ fireEvent.click(screen.getByRole("button", { name: title }));
479
+ });
480
+ };
481
+
327
482
  type OpenPickerFunction = () => Promise<void>;
328
483
 
329
484
  /*
@@ -363,8 +518,65 @@ describe("BaseModelTable column customization", () => {
363
518
  });
364
519
  };
365
520
 
521
+ type SelectRowsFunction = (container: HTMLElement) => Promise<void>;
522
+
523
+ /*
524
+ * Ticks the header checkbox, which selects every row on the page and is what
525
+ * puts the bulk bar - and with it "Export CSV" - on screen at all.
526
+ */
527
+ const selectRows: SelectRowsFunction = async (
528
+ container: HTMLElement,
529
+ ): Promise<void> => {
530
+ // The header checkbox is disabled until the first page has rendered.
531
+ await waitFor(() => {
532
+ expect(
533
+ container.querySelectorAll('input[type="checkbox"]:not([disabled])')
534
+ .length,
535
+ ).toBeGreaterThan(0);
536
+ });
537
+
538
+ await click(
539
+ container.querySelectorAll<HTMLInputElement>(
540
+ 'input[type="checkbox"]',
541
+ )[0]!,
542
+ );
543
+ };
544
+
545
+ type ExportCsvFunction = () => Promise<Array<string>>;
546
+
547
+ /*
548
+ * The real click path, run end to end and read back as the lines a viewer
549
+ * would open in a spreadsheet. Every bulk action lives behind the bulk bar's
550
+ * "Bulk Actions" menu, so the export takes two clicks. Intercepted at the
551
+ * download rather than at the Blob, so the assertions are on the exact bytes
552
+ * that would have been written to disk.
553
+ */
554
+ const exportCsv: ExportCsvFunction = async (): Promise<Array<string>> => {
555
+ await click(screen.getByText("Bulk Actions"));
556
+
557
+ await waitFor(() => {
558
+ expect(screen.queryByText("Export CSV")).not.toBeNull();
559
+ });
560
+
561
+ await click(screen.getByText("Export CSV"));
562
+
563
+ await waitFor(() => {
564
+ expect(downloadedCsvFiles.length).toBe(1);
565
+ });
566
+
567
+ return (downloadedCsvFiles[0]?.csv || "").split("\r\n");
568
+ };
569
+
366
570
  beforeEach(() => {
367
571
  calls = [];
572
+ downloadedCsvFiles = [];
573
+
574
+ jest
575
+ .spyOn(TableColumnsToCsv, "downloadCsv")
576
+ .mockImplementation((data: DownloadedCsvFile): void => {
577
+ downloadedCsvFiles.push(data);
578
+ });
579
+
368
580
  window.history.replaceState(
369
581
  window.history.state,
370
582
  "",
@@ -499,6 +711,226 @@ describe("BaseModelTable column customization", () => {
499
711
  expect(calls[0]?.select["description"]).toBe(true);
500
712
  expect(calls[0]?.select["name"]).toBe(true);
501
713
  });
714
+
715
+ test("a hidden entity column's relation is still selected, in full", async () => {
716
+ /*
717
+ * "monitorTemplate" is deliberately absent from the stored order: a
718
+ * column the layout does not mention falls back to its declared
719
+ * default, so this covers both ways an entity column ends up off
720
+ * screen - the viewer switched it off, and the table ships it off.
721
+ */
722
+ seedPreference({
723
+ key: PREFS_KEY,
724
+ order: ["name", "description", "currentMonitorStatus", "labels"],
725
+ hidden: ["currentMonitorStatus"],
726
+ });
727
+
728
+ renderTable({
729
+ columns: makeColumns({ withHiddenByDefaultEntity: true }),
730
+ });
731
+
732
+ await waitForTable();
733
+
734
+ expect(getHeaders()).not.toContain("Monitor Status");
735
+ expect(getHeaders()).not.toContain("Template");
736
+
737
+ /*
738
+ * A relation is not selected by naming it - it is selected by the shape
739
+ * nested under it, and that shape is built by a *different* function
740
+ * (getRelationSelectFromColumns) with its own reference to the full
741
+ * column set. Narrowing only that one would leave every scalar
742
+ * assertion in this file green while every hidden entity cell came back
743
+ * as a bare id, so the column would paint blank the moment a viewer
744
+ * switched it on. Deep equality, because dropping the second key of a
745
+ * multi-field relation is the same bug one field smaller.
746
+ */
747
+ expect(calls[0]?.select["currentMonitorStatus"]).toEqual({ name: true });
748
+ expect(calls[0]?.select["monitorTemplate"]).toEqual({
749
+ _id: true,
750
+ templateName: true,
751
+ });
752
+ });
753
+
754
+ test("a column that ships hidden is selected even with no stored layout", async () => {
755
+ renderTable({ columns: makeColumns({ withHiddenByDefault: true }) });
756
+
757
+ await waitForTable();
758
+
759
+ /*
760
+ * Hidden because the table author said so, not because this viewer
761
+ * chose it - there is nothing in localStorage at all. The two states
762
+ * reach the same select today, and an optimisation that only widened
763
+ * the request "when the viewer has a stored layout" would look
764
+ * perfectly reasonable and would break exactly the shipped default,
765
+ * which is the state almost every viewer is in.
766
+ */
767
+ expect(getHeaders()).not.toContain("Monitor Type");
768
+ expect(readPreference(PREFS_KEY)).toBeNull();
769
+ expect(calls[0]?.select["monitorType"]).toBe(true);
770
+ });
771
+ });
772
+
773
+ describe("switching a hidden column back on", () => {
774
+ test("paints its value without asking the API again", async () => {
775
+ renderTable({
776
+ columns: makeColumns({ withHiddenByDefaultEntity: true }),
777
+ rows: ROWS,
778
+ });
779
+
780
+ await waitForTable();
781
+
782
+ expect(getHeaders()).not.toContain("Template");
783
+ expect(screen.queryByText(TEMPLATE_NAME)).toBeNull();
784
+
785
+ const callsBefore: number = calls.length;
786
+
787
+ await openPicker();
788
+ await click(screen.getByTestId("column-toggle-monitorTemplate"));
789
+ await savePicker();
790
+
791
+ await waitFor(() => {
792
+ expect(getHeaders()).toContain("Template");
793
+ });
794
+
795
+ /*
796
+ * The whole contract this table's hidden columns rest on, in one test.
797
+ *
798
+ * The request is built from the DECLARED columns, never the visible
799
+ * ones, and the fetch effect does not depend on the column set - so a
800
+ * column the viewer switches on is painted from the page already in
801
+ * hand, with its relation intact and no spinner. Wire the request up to
802
+ * the visible columns instead (there is a preference-filtered
803
+ * `selectFields` sitting unused in serializeToTableColumns that invites
804
+ * exactly that) and this cell comes back empty, with nothing to refetch
805
+ * it: the viewer's click would produce a column of blanks.
806
+ */
807
+ expect(screen.getByText(TEMPLATE_NAME)).toBeInTheDocument();
808
+ expect(calls.length).toBe(callsBefore);
809
+ });
810
+ });
811
+
812
+ describe("what the viewer's layout does to the CSV export", () => {
813
+ test("a column that ships hidden is not in the exported file", async () => {
814
+ const { container } = renderTable({
815
+ columns: makeColumns({ withHiddenByDefaultEntity: true }),
816
+ rows: ROWS,
817
+ withBulkActions: true,
818
+ });
819
+
820
+ await waitForTable();
821
+
822
+ await selectRows(container);
823
+
824
+ const lines: Array<string> = await exportCsv();
825
+
826
+ /*
827
+ * "Export what you see". The export is built from the table's own
828
+ * column set, so a column the table ships switched off has no header
829
+ * and no cell in the file.
830
+ *
831
+ * This is the one assertion in the suite that is about data leaving the
832
+ * product rather than about pixels. Every other test here is happy for
833
+ * a hidden column's field to be fetched - it has to be, or switching
834
+ * the column on would paint blanks - so the row in memory is carrying
835
+ * the relation the whole time. Only the column filtering keeps it out
836
+ * of a file the viewer opens, mails and uploads.
837
+ *
838
+ * Build the Table columns from the unfiltered `allColumns` instead of
839
+ * the preference-filtered `columnsToRender` in serializeToTableColumns
840
+ * - a one-word change, and the first thing anyone would reach for after
841
+ * a ticket saying "the CSV lost my Template column" - and every table
842
+ * in the app starts exporting columns nobody asked to see.
843
+ */
844
+ expect(lines[0]).toBe("Name,Description,Monitor Status,Labels");
845
+ expect(lines[1]).toBe("api-gateway,The public API gateway,,");
846
+ expect(lines.join("\r\n")).not.toContain(TEMPLATE_NAME);
847
+ });
848
+
849
+ test("switching it on puts it in the exported file", async () => {
850
+ const { container } = renderTable({
851
+ columns: makeColumns({ withHiddenByDefaultEntity: true }),
852
+ rows: ROWS,
853
+ withBulkActions: true,
854
+ });
855
+
856
+ await waitForTable();
857
+
858
+ await openPicker();
859
+ await click(screen.getByTestId("column-toggle-monitorTemplate"));
860
+ await savePicker();
861
+
862
+ await waitFor(() => {
863
+ expect(getHeaders()).toContain("Template");
864
+ });
865
+
866
+ await selectRows(container);
867
+
868
+ const lines: Array<string> = await exportCsv();
869
+
870
+ /*
871
+ * The other half of the same contract, and the reason the test above
872
+ * cannot be satisfied by simply never exporting an `isHiddenByDefault`
873
+ * column: the file follows this viewer's layout, not the table author's
874
+ * default. Someone who switches Template on has almost certainly done
875
+ * it *because* they want it in the export, and freezing the export
876
+ * columns at the shipped default would hand them a file missing the one
877
+ * column they went to the picker for.
878
+ */
879
+ expect(lines[0]).toBe("Name,Description,Monitor Status,Labels,Template");
880
+ expect(lines[1]).toContain(TEMPLATE_NAME);
881
+ });
882
+ });
883
+
884
+ describe("the sort a hidden column leaves behind", () => {
885
+ test("showing a column does not refetch; hiding the sorted one does", async () => {
886
+ renderTable({
887
+ columns: makeColumns({ withHiddenByDefaultEntity: true }),
888
+ rows: ROWS,
889
+ });
890
+
891
+ await waitForTable();
892
+
893
+ await sortByColumn("Description");
894
+ await waitForCallCount(2);
895
+ expect(calls[1]?.sort).toEqual({
896
+ description: SortOrder.Descending,
897
+ });
898
+
899
+ await openPicker();
900
+ await click(screen.getByTestId("column-toggle-monitorTemplate"));
901
+ await savePicker();
902
+
903
+ await waitFor(() => {
904
+ expect(getHeaders()).toContain("Template");
905
+ });
906
+
907
+ /*
908
+ * Adding a column changes nothing the server was asked for, so the
909
+ * guard in onColumnCustomizationSave has to leave the sort alone. A
910
+ * reset-on-every-save would throw the viewer's ordering away - and
911
+ * their page - every time they ticked a box.
912
+ */
913
+ expect(calls.length).toBe(2);
914
+ expect(screen.getByText(TEMPLATE_NAME)).toBeInTheDocument();
915
+
916
+ await openPicker();
917
+ await click(screen.getByTestId("column-toggle-description"));
918
+ await savePicker();
919
+
920
+ await waitFor(() => {
921
+ expect(getHeaders()).not.toContain("Description");
922
+ });
923
+
924
+ /*
925
+ * Hiding the column the table is sorted by is different: the header
926
+ * that ordering came from is gone, so the viewer can neither see it nor
927
+ * click it off. The sort falls back to the table's default (here: none)
928
+ * and the page is refetched, rather than leaving them staring at rows
929
+ * in an order they cannot explain or undo.
930
+ */
931
+ await waitForCallCount(3);
932
+ expect(calls[2]?.sort).toEqual({});
933
+ });
502
934
  });
503
935
 
504
936
  describe("the generated Actions column", () => {
@@ -1675,4 +1675,93 @@ describe("a stored layout replayed against a changed column set", () => {
1675
1675
  expect(rendered).toHaveLength(4);
1676
1676
  expect(titlesOf(rendered)).toEqual(["Name", "Actions", "Actions", ""]);
1677
1677
  });
1678
+
1679
+ test("a layout saved against a column set missing a default-hidden column switches it back off", () => {
1680
+ /*
1681
+ * A column set changes between surfaces, not just between releases: the
1682
+ * same table component is embedded on a detail page with one column
1683
+ * spliced out (there is no point showing "Template" on the template's own
1684
+ * page). If both surfaces persist under one storage key, the narrow one
1685
+ * writes the layout for the wide one.
1686
+ *
1687
+ * buildColumnPreference records only what the picker was showing, so the
1688
+ * spliced-out id lands in neither `order` nor `hidden` — and reading it
1689
+ * back falls through to the column's DECLARED default.
1690
+ *
1691
+ * That fallback is why this stayed invisible until now, and why it is the
1692
+ * hazard the moment a column ships hidden: a dropped column that defaults
1693
+ * to visible is restored by accident, so nobody ever noticed the drop,
1694
+ * while a dropped column that defaults to hidden silently un-picks the one
1695
+ * choice the viewer made — and the picker is the only way back to it. A
1696
+ * table embedded on a second surface must therefore key its stored layout
1697
+ * per surface; this is the loss it is avoiding.
1698
+ */
1699
+ const fullColumns: Columns<Monitor> = [
1700
+ makeColumn({
1701
+ field: { name: true },
1702
+ title: "Name",
1703
+ isNotCustomizable: true,
1704
+ }),
1705
+ makeColumn({ field: { description: true }, title: "Description" }),
1706
+ makeColumn({
1707
+ field: { monitorTemplate: { _id: true, templateName: true } },
1708
+ title: "Template",
1709
+ isHiddenByDefault: true,
1710
+ }),
1711
+ makeColumn({ field: { labels: true }, title: "Labels" }),
1712
+ ];
1713
+
1714
+ // The embedded surface drops the default-hidden column and one other.
1715
+ const scopedColumns: Columns<Monitor> = fullColumns.filter(
1716
+ (column: Column<Monitor>): boolean => {
1717
+ return column.title !== "Template" && column.title !== "Labels";
1718
+ },
1719
+ );
1720
+
1721
+ // The viewer switches Template on from the full table and saves.
1722
+ const preferenceA: ColumnPreference = buildColumnPreference<Monitor>(
1723
+ getCustomizableColumns<Monitor>({
1724
+ columns: fullColumns,
1725
+ preference: {
1726
+ order: ["description", "monitorTemplate", "labels"],
1727
+ hidden: [],
1728
+ },
1729
+ }),
1730
+ );
1731
+
1732
+ expect(
1733
+ findEntry(
1734
+ getCustomizableColumns<Monitor>({
1735
+ columns: fullColumns,
1736
+ preference: preferenceA,
1737
+ }),
1738
+ "monitorTemplate",
1739
+ ).isVisible,
1740
+ ).toBe(true);
1741
+
1742
+ /*
1743
+ * Now the picker is saved from the embedded surface without the viewer
1744
+ * changing anything there.
1745
+ */
1746
+ const preferenceB: ColumnPreference = buildColumnPreference<Monitor>(
1747
+ getCustomizableColumns<Monitor>({
1748
+ columns: scopedColumns,
1749
+ preference: preferenceA,
1750
+ }),
1751
+ );
1752
+
1753
+ expect(preferenceB.order).not.toContain("monitorTemplate");
1754
+ expect(preferenceB.hidden).not.toContain("monitorTemplate");
1755
+
1756
+ const replayed: Array<CustomizableColumn<Monitor>> =
1757
+ getCustomizableColumns<Monitor>({
1758
+ columns: fullColumns,
1759
+ preference: preferenceB,
1760
+ });
1761
+
1762
+ // The viewer's one opt-in is gone...
1763
+ expect(findEntry(replayed, "monitorTemplate").isVisible).toBe(false);
1764
+ // ...while a column dropped exactly the same way comes back unharmed.
1765
+ expect(findEntry(replayed, "labels").isVisible).toBe(true);
1766
+ });
1678
1767
  });