@gridframe/server 1.0.0 → 1.0.1

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/src/index.test.ts DELETED
@@ -1,480 +0,0 @@
1
- import { beforeEach, describe, expect, it } from "vitest";
2
- import type {
3
- DashboardCardLayout,
4
- PanelCardDataResponse,
5
- } from "@gridframe/core";
6
-
7
- import {
8
- DashboardInvalidLibraryItemError,
9
- DashboardNotFoundError,
10
- DashboardRevisionConflictError,
11
- createDashboardHandlers,
12
- type CardLibraryTemplate,
13
- type DashboardBootstrap,
14
- type DashboardRepository,
15
- type DashboardSeed,
16
- type PersistedDashboard,
17
- type PersistedDashboardCard,
18
- } from ".";
19
-
20
- const templates = [
21
- {
22
- key: "revenue",
23
- name: "Revenue",
24
- description: "Revenue metric",
25
- visualization: "metric",
26
- defaultLayout: { width: 1, height: 2 },
27
- deeplinkLabel: "View revenue",
28
- },
29
- {
30
- key: "orders",
31
- name: "Orders",
32
- visualization: "bar",
33
- defaultLayout: { width: 3, height: 4 },
34
- },
35
- ] satisfies CardLibraryTemplate[];
36
-
37
- const defaultDashboard = {
38
- title: "Operations overview",
39
- description: "Seeded dashboard",
40
- footer: { text: "Example footer" },
41
- cards: [
42
- { libraryItemKey: "revenue", layout: { x: 0, y: 0, width: 1, height: 2 } },
43
- ],
44
- } satisfies DashboardSeed;
45
-
46
- describe("createDashboardHandlers", () => {
47
- let repository: MemoryDashboardRepository;
48
- let resolverResult: PanelCardDataResponse;
49
-
50
- beforeEach(() => {
51
- repository = new MemoryDashboardRepository();
52
- resolverResult = {
53
- status: "success",
54
- data: {
55
- visualization: "metric",
56
- value: 42,
57
- label: "Revenue",
58
- },
59
- };
60
- });
61
-
62
- function handlers() {
63
- return createDashboardHandlers({
64
- repository,
65
- cardLibrary: templates,
66
- defaultDashboard: () => defaultDashboard,
67
- resolveCardData: () => Promise.resolve(resolverResult),
68
- });
69
- }
70
-
71
- it("bootstraps a default Dashboard when the user has none", async () => {
72
- const response = await handlers().bootstrap(
73
- jsonRequest({}),
74
- { userId: "user-1" },
75
- );
76
-
77
- expect(response.status).toBe(200);
78
- await expect(response.json()).resolves.toMatchObject({
79
- dashboards: [{ title: "Operations overview", isDefault: true }],
80
- dashboard: {
81
- revision: "1",
82
- config: {
83
- title: "Operations overview",
84
- cards: [
85
- {
86
- name: "Revenue",
87
- visualization: "metric",
88
- query:
89
- "/api/gridframe/users/user-1/dashboards/dashboard-1/cards/card-1/data",
90
- deeplink: {
91
- href:
92
- "/gridframe/users/user-1/dashboards/dashboard-1/cards/card-1",
93
- label: "View revenue",
94
- },
95
- },
96
- ],
97
- },
98
- },
99
- });
100
- });
101
-
102
- it("returns not found when an explicit Dashboard is missing", async () => {
103
- await handlers().bootstrap(jsonRequest({}), { userId: "user-1" });
104
-
105
- const response = await handlers().bootstrap(
106
- jsonRequest({ dashboardId: "missing" }),
107
- { userId: "user-1" },
108
- );
109
-
110
- expect(response.status).toBe(404);
111
- await expect(response.json()).resolves.toEqual({
112
- error: { code: "DASHBOARD_NOT_FOUND", message: "Dashboard not found" },
113
- });
114
- });
115
-
116
- it("persists a complete valid layout", async () => {
117
- const dashboard = await seedDashboard();
118
-
119
- const response = await handlers().updateLayout(
120
- jsonRequest({
121
- revision: String(dashboard.revision),
122
- cards: [{ id: "card-1", x: 1, y: 0, width: 1, height: 2 }],
123
- }),
124
- { userId: "user-1", dashboardId: dashboard.id },
125
- );
126
-
127
- expect(response.status).toBe(200);
128
- await expect(response.json()).resolves.toMatchObject({
129
- revision: "2",
130
- config: { cards: [{ id: "card-1", layout: { x: 1 } }] },
131
- });
132
- });
133
-
134
- it("rejects layouts that do not contain exactly the Dashboard Cards", async () => {
135
- const dashboard = await seedDashboard();
136
-
137
- const response = await handlers().updateLayout(
138
- jsonRequest({ revision: String(dashboard.revision), cards: [] }),
139
- { userId: "user-1", dashboardId: dashboard.id },
140
- );
141
-
142
- expect(response.status).toBe(400);
143
- await expect(response.json()).resolves.toEqual({
144
- error: {
145
- code: "INVALID_REQUEST",
146
- message: "Invalid Dashboard layout",
147
- },
148
- });
149
- });
150
-
151
- it("returns a revision conflict when a mutation uses a stale revision", async () => {
152
- const dashboard = await seedDashboard();
153
-
154
- const response = await handlers().updateCard(
155
- jsonRequest({ revision: "99", name: "Updated" }),
156
- { userId: "user-1", dashboardId: dashboard.id, cardId: "card-1" },
157
- );
158
-
159
- expect(response.status).toBe(409);
160
- await expect(response.json()).resolves.toEqual({
161
- error: {
162
- code: "REVISION_CONFLICT",
163
- message: "Dashboard was changed by another request",
164
- },
165
- });
166
- });
167
-
168
- it("lists Card library templates with installed state", async () => {
169
- const dashboard = await seedDashboard();
170
-
171
- const response = await handlers().listCardLibrary(new Request("http://x"), {
172
- userId: "user-1",
173
- dashboardId: dashboard.id,
174
- });
175
-
176
- expect(response.status).toBe(200);
177
- await expect(response.json()).resolves.toEqual({
178
- items: [
179
- {
180
- key: "revenue",
181
- name: "Revenue",
182
- description: "Revenue metric",
183
- visualization: "metric",
184
- defaultLayout: { width: 1, height: 2 },
185
- addedCardId: "card-1",
186
- },
187
- {
188
- key: "orders",
189
- name: "Orders",
190
- visualization: "bar",
191
- defaultLayout: { width: 3, height: 4 },
192
- },
193
- ],
194
- });
195
- });
196
-
197
- it("adds a Card library item at the first available layout", async () => {
198
- const dashboard = await seedDashboard();
199
-
200
- const response = await handlers().addCard(
201
- jsonRequest({
202
- revision: String(dashboard.revision),
203
- libraryItemKey: "orders",
204
- }),
205
- { userId: "user-1", dashboardId: dashboard.id },
206
- );
207
-
208
- expect(response.status).toBe(200);
209
- await expect(response.json()).resolves.toMatchObject({
210
- dashboard: {
211
- revision: "2",
212
- config: {
213
- cards: [
214
- { id: "card-1" },
215
- { id: "card-2", layout: { x: 1, y: 0, width: 3, height: 4 } },
216
- ],
217
- },
218
- },
219
- cardLibrary: {
220
- items: [
221
- { key: "revenue", addedCardId: "card-1" },
222
- { key: "orders", addedCardId: "card-2" },
223
- ],
224
- },
225
- });
226
- });
227
-
228
- it("rejects duplicate Card library items", async () => {
229
- const dashboard = await seedDashboard();
230
-
231
- const response = await handlers().addCard(
232
- jsonRequest({
233
- revision: String(dashboard.revision),
234
- libraryItemKey: "revenue",
235
- }),
236
- { userId: "user-1", dashboardId: dashboard.id },
237
- );
238
-
239
- expect(response.status).toBe(409);
240
- await expect(response.json()).resolves.toEqual({
241
- error: {
242
- code: "CARD_ALREADY_ADDED",
243
- message: "Card is already on this Dashboard",
244
- },
245
- });
246
- });
247
-
248
- it("removes the final Card and returns an empty Dashboard", async () => {
249
- const dashboard = await seedDashboard();
250
-
251
- const response = await handlers().removeCard(
252
- jsonRequest({ revision: String(dashboard.revision) }),
253
- { userId: "user-1", dashboardId: dashboard.id, cardId: "card-1" },
254
- );
255
-
256
- expect(response.status).toBe(200);
257
- await expect(response.json()).resolves.toMatchObject({
258
- dashboard: { revision: "2", config: { cards: [] } },
259
- cardLibrary: {
260
- items: [{ key: "revenue" }, { key: "orders" }],
261
- },
262
- });
263
- });
264
-
265
- it("returns validated Card data from the consumer resolver", async () => {
266
- const dashboard = await seedDashboard();
267
-
268
- const response = await handlers().fetchCardData(
269
- new Request("http://example.test/data"),
270
- { userId: "user-1", dashboardId: dashboard.id, cardId: "card-1" },
271
- );
272
-
273
- expect(response.status).toBe(200);
274
- await expect(response.json()).resolves.toEqual(resolverResult);
275
- });
276
-
277
- it("rejects invalid Card data resolver output", async () => {
278
- const dashboard = await seedDashboard();
279
- resolverResult = { status: "success", data: undefined } as never;
280
-
281
- const response = await handlers().fetchCardData(
282
- new Request("http://example.test/data"),
283
- { userId: "user-1", dashboardId: dashboard.id, cardId: "card-1" },
284
- );
285
-
286
- expect(response.status).toBe(502);
287
- await expect(response.json()).resolves.toEqual({
288
- error: {
289
- code: "CARD_QUERY_FAILED",
290
- message: "Card data could not be loaded",
291
- },
292
- });
293
- });
294
-
295
- async function seedDashboard() {
296
- const response = await handlers().bootstrap(
297
- jsonRequest({}),
298
- { userId: "user-1" },
299
- );
300
- expect(response.status).toBe(200);
301
- return repository.loadDashboard("user-1", "dashboard-1");
302
- }
303
- });
304
-
305
- function jsonRequest(body: unknown) {
306
- return new Request("http://example.test", {
307
- method: "POST",
308
- headers: { "Content-Type": "application/json" },
309
- body: JSON.stringify(body),
310
- });
311
- }
312
-
313
- class MemoryDashboardRepository implements DashboardRepository {
314
- private readonly dashboards = new Map<string, PersistedDashboard>();
315
- private cardSequence = 1;
316
- private dashboardSequence = 1;
317
-
318
- async bootstrap(
319
- ownerUserId: string,
320
- dashboardId: string | undefined,
321
- seed: DashboardSeed,
322
- cardLibrary: readonly CardLibraryTemplate[],
323
- ): Promise<DashboardBootstrap> {
324
- const owned = this.listOwnedDashboards(ownerUserId);
325
- let dashboard = dashboardId
326
- ? this.dashboards.get(dashboardId)
327
- : owned.find((item) => item.isDefault);
328
-
329
- if (!dashboard && !dashboardId) {
330
- dashboard = this.createSeededDashboard(ownerUserId, seed, cardLibrary);
331
- }
332
-
333
- if (!dashboard || dashboard.ownerUserId !== ownerUserId) {
334
- throw new DashboardNotFoundError();
335
- }
336
-
337
- return {
338
- dashboards: this.listOwnedDashboards(ownerUserId).map((item) => ({
339
- id: item.id,
340
- title: item.title,
341
- isDefault: item.isDefault,
342
- })),
343
- dashboard,
344
- };
345
- }
346
-
347
- async loadDashboard(ownerUserId: string, dashboardId: string) {
348
- const dashboard = this.dashboards.get(dashboardId);
349
- if (!dashboard || dashboard.ownerUserId !== ownerUserId) {
350
- throw new DashboardNotFoundError();
351
- }
352
- return dashboard;
353
- }
354
-
355
- async updateLayout(
356
- ownerUserId: string,
357
- dashboardId: string,
358
- revision: number,
359
- cards: Array<{ id: string } & DashboardCardLayout>,
360
- ) {
361
- const dashboard = await this.loadDashboard(ownerUserId, dashboardId);
362
- this.assertRevision(dashboard, revision);
363
- dashboard.revision += 1;
364
- dashboard.cards = dashboard.cards.map((card) => ({
365
- ...card,
366
- layout: cards.find((item) => item.id === card.id) ?? card.layout,
367
- }));
368
- return dashboard;
369
- }
370
-
371
- async updateCardName(
372
- ownerUserId: string,
373
- dashboardId: string,
374
- cardId: string,
375
- revision: number,
376
- name: string,
377
- ) {
378
- const dashboard = await this.loadDashboard(ownerUserId, dashboardId);
379
- this.assertRevision(dashboard, revision);
380
- const card = dashboard.cards.find((item) => item.id === cardId);
381
- if (!card) throw new DashboardNotFoundError();
382
- card.name = name;
383
- dashboard.revision += 1;
384
- return dashboard;
385
- }
386
-
387
- async addCard(
388
- ownerUserId: string,
389
- dashboardId: string,
390
- revision: number,
391
- card: Omit<PersistedDashboardCard, "id" | "dashboardId" | "sortOrder">,
392
- ) {
393
- const dashboard = await this.loadDashboard(ownerUserId, dashboardId);
394
- this.assertRevision(dashboard, revision);
395
- const created = {
396
- ...card,
397
- id: `card-${this.cardSequence++}`,
398
- dashboardId,
399
- sortOrder: dashboard.cards.length,
400
- };
401
- dashboard.cards.push(created);
402
- dashboard.revision += 1;
403
- return dashboard;
404
- }
405
-
406
- async removeCard(
407
- ownerUserId: string,
408
- dashboardId: string,
409
- cardId: string,
410
- revision: number,
411
- ) {
412
- const dashboard = await this.loadDashboard(ownerUserId, dashboardId);
413
- this.assertRevision(dashboard, revision);
414
- if (!dashboard.cards.some((card) => card.id === cardId)) {
415
- throw new DashboardNotFoundError();
416
- }
417
- dashboard.cards = dashboard.cards.filter((card) => card.id !== cardId);
418
- dashboard.revision += 1;
419
- return dashboard;
420
- }
421
-
422
- async findOwnedCard(
423
- ownerUserId: string,
424
- dashboardId: string,
425
- cardId: string,
426
- ) {
427
- return (await this.loadDashboard(ownerUserId, dashboardId)).cards.find(
428
- (card) => card.id === cardId,
429
- );
430
- }
431
-
432
- private createSeededDashboard(
433
- ownerUserId: string,
434
- seed: DashboardSeed,
435
- cardLibrary: readonly CardLibraryTemplate[],
436
- ) {
437
- const dashboardId = `dashboard-${this.dashboardSequence++}`;
438
- const dashboard: PersistedDashboard = {
439
- id: dashboardId,
440
- ownerUserId,
441
- title: seed.title,
442
- description: seed.description,
443
- footer: seed.footer,
444
- isDefault: true,
445
- revision: 1,
446
- cards: seed.cards.map((seedCard, index) => {
447
- const template = cardLibrary.find(
448
- (item) => item.key === seedCard.libraryItemKey,
449
- );
450
- if (!template) throw new DashboardInvalidLibraryItemError();
451
- return {
452
- id: `card-${this.cardSequence++}`,
453
- dashboardId,
454
- libraryItemKey: template.key,
455
- name: template.name,
456
- visualization: template.visualization,
457
- deeplink: template.deeplinkLabel
458
- ? { label: template.deeplinkLabel }
459
- : undefined,
460
- layout: seedCard.layout ?? { x: 0, y: index * 4, ...template.defaultLayout },
461
- sortOrder: index,
462
- };
463
- }),
464
- };
465
- this.dashboards.set(dashboard.id, dashboard);
466
- return dashboard;
467
- }
468
-
469
- private listOwnedDashboards(ownerUserId: string) {
470
- return [...this.dashboards.values()].filter(
471
- (dashboard) => dashboard.ownerUserId === ownerUserId,
472
- );
473
- }
474
-
475
- private assertRevision(dashboard: PersistedDashboard, revision: number) {
476
- if (dashboard.revision !== revision) {
477
- throw new DashboardRevisionConflictError();
478
- }
479
- }
480
- }