zinkee 0.1.24 → 0.1.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zinkee",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "CLI for Zinkee API v2",
5
5
  "type": "module",
6
6
  "bin": {
@@ -180,7 +180,7 @@ export class AutomationsApi {
180
180
 
181
181
  moveAutomation(input: MoveAutomationInput): Promise<Record<string, unknown>> {
182
182
  return this.client.requestJson<Record<string, unknown>>(
183
- `/api/v2/automation-folders/${segment(input.targetFolderId)}/automations:move`,
183
+ "/api/v2/automation-folders/automations:move",
184
184
  jsonOptions(input, "POST"),
185
185
  );
186
186
  }
@@ -45,6 +45,19 @@ export interface ApiDisplayNavigationTarget {
45
45
  label?: string;
46
46
  }
47
47
 
48
+ export interface ApiDisplayFolder {
49
+ [key: string]: unknown;
50
+ id?: string;
51
+ name?: string | null;
52
+ isRoot?: boolean;
53
+ displayCount?: number;
54
+ }
55
+
56
+ export interface MoveDisplayInput {
57
+ displayId: string;
58
+ targetFolderId: string;
59
+ }
60
+
48
61
  export type DisplaySummary = ApiDisplay;
49
62
  export type DisplayResource = ApiDisplay;
50
63
 
@@ -273,4 +286,39 @@ export class DisplaysApi {
273
286
  { method: "DELETE" },
274
287
  );
275
288
  }
289
+
290
+ listFolders(): Promise<ApiDisplayFolder[]> {
291
+ return this.client.getJson<ApiDisplayFolder[]>("/api/v2/display-folders");
292
+ }
293
+
294
+ createFolder(body: Record<string, unknown>): Promise<ApiDisplayFolder> {
295
+ return this.client.requestJson<ApiDisplayFolder>(
296
+ "/api/v2/display-folders",
297
+ jsonOptions(body, "POST"),
298
+ );
299
+ }
300
+
301
+ updateFolder(
302
+ folder: NormalizedUuidOrSlug,
303
+ body: Record<string, unknown>,
304
+ ): Promise<ApiDisplayFolder> {
305
+ return this.client.requestJson<ApiDisplayFolder>(
306
+ `/api/v2/display-folders/${selectorValue(folder)}`,
307
+ jsonOptions(body, "PATCH"),
308
+ );
309
+ }
310
+
311
+ deleteFolder(folder: NormalizedUuidOrSlug): Promise<Record<string, unknown>> {
312
+ return this.client.requestJson<Record<string, unknown>>(
313
+ `/api/v2/display-folders/${selectorValue(folder)}`,
314
+ { method: "DELETE" },
315
+ );
316
+ }
317
+
318
+ moveDisplay(input: MoveDisplayInput): Promise<Record<string, unknown>> {
319
+ return this.client.requestJson<Record<string, unknown>>(
320
+ "/api/v2/display-folders/displays:move",
321
+ jsonOptions(input, "POST"),
322
+ );
323
+ }
276
324
  }
@@ -16,7 +16,6 @@ export interface NavigationFolderMutation {
16
16
 
17
17
  export interface NavigationMoveResourceInput {
18
18
  resourceId: string;
19
- resourceType: string;
20
19
  targetFolderId: string;
21
20
  }
22
21
 
@@ -280,6 +280,41 @@ export const commandRegistry = {
280
280
  supportsRaw: false,
281
281
  output: writeOutput,
282
282
  },
283
+ "displays folders list": {
284
+ command: "displays folders list",
285
+ accessLevel: "read",
286
+ supportsExample: true,
287
+ supportsRaw: false,
288
+ output: readOutput,
289
+ },
290
+ "displays folders create": {
291
+ command: "displays folders create",
292
+ accessLevel: "write",
293
+ supportsExample: true,
294
+ supportsRaw: false,
295
+ output: writeOutput,
296
+ },
297
+ "displays folders update": {
298
+ command: "displays folders update",
299
+ accessLevel: "write",
300
+ supportsExample: true,
301
+ supportsRaw: false,
302
+ output: writeOutput,
303
+ },
304
+ "displays folders delete": {
305
+ command: "displays folders delete",
306
+ accessLevel: "destructive",
307
+ supportsExample: true,
308
+ supportsRaw: false,
309
+ output: writeOutput,
310
+ },
311
+ "displays folders move": {
312
+ command: "displays folders move",
313
+ accessLevel: "write",
314
+ supportsExample: true,
315
+ supportsRaw: false,
316
+ output: writeOutput,
317
+ },
283
318
  "displays layout get": {
284
319
  command: "displays layout get",
285
320
  accessLevel: "read",
@@ -87,6 +87,38 @@ describe("automations commands", () => {
87
87
  expect(program.commands.some((command) => command.name() === "automations")).toBe(true);
88
88
  });
89
89
 
90
+ it("moves an automation with the folder id in the body, not the path", async () => {
91
+ const configPath = createApiConfig();
92
+ const output = createStdoutBuffer();
93
+ const fetchMock = createFetchMock();
94
+ fetchMock.mockResolvedValueOnce(
95
+ jsonResponse({ id: "folder-1", name: "Ops", isRoot: false }),
96
+ );
97
+ const program = buildAutomationsProgram({ configPath, stdout: output.stdout });
98
+
99
+ await program.parseAsync([
100
+ "node",
101
+ "zinkee",
102
+ "--json",
103
+ "automations",
104
+ "folders",
105
+ "move",
106
+ "11111111-1111-4111-a111-111111111111",
107
+ "--to",
108
+ "22222222-2222-4222-a222-222222222222",
109
+ ]);
110
+
111
+ expect(fetchMock.mock.calls[0]?.[0]).toBe(
112
+ "https://api.zinkee.test/api/v2/automation-folders/automations:move",
113
+ );
114
+ const init = fetchMock.mock.calls[0]?.[1];
115
+ expect(init?.method).toBe("POST");
116
+ expect(JSON.parse(String(init?.body))).toEqual({
117
+ automationId: "11111111-1111-4111-a111-111111111111",
118
+ targetFolderId: "22222222-2222-4222-a222-222222222222",
119
+ });
120
+ });
121
+
90
122
  it("sets trigger payloads through the generic trigger flags", async () => {
91
123
  const configPath = createApiConfig();
92
124
  const output = createStdoutBuffer();
@@ -1408,11 +1408,13 @@ export function registerAutomationsCommands(
1408
1408
  .command("automations")
1409
1409
  .description("Manage automations, triggers, actions, and connections");
1410
1410
 
1411
- const folders = automations.command("folders").description("Manage automation folders");
1411
+ const folders = automations
1412
+ .command("folders")
1413
+ .description("Manage automation folders (flat: a folder only has a name, no order or properties)");
1412
1414
 
1413
1415
  folders
1414
1416
  .command("list")
1415
- .description("List automation folders")
1417
+ .description("List automation folders (non-root folders plus the root bucket, whose name is null)")
1416
1418
  .action(async function action(this: Command) {
1417
1419
  const { api, json } = createAutomationsApi(this, filePath);
1418
1420
  const data = await api.listFolders();
@@ -1500,7 +1502,11 @@ export function registerAutomationsCommands(
1500
1502
  .command("move")
1501
1503
  .description("Move an automation into a folder")
1502
1504
  .argument("<automation>", "automation UUID", parseUuidOrSlugSelector)
1503
- .requiredOption("--to <folder>", "target folder UUID or exact name", parseUuidOrSlugSelector)
1505
+ .requiredOption(
1506
+ "--to <folder>",
1507
+ "target folder UUID or exact name (use the root folder to move it out of any folder)",
1508
+ parseUuidOrSlugSelector,
1509
+ )
1504
1510
  .action(async function action(
1505
1511
  this: Command,
1506
1512
  automation: NormalizedUuidOrSlug,
@@ -252,6 +252,243 @@ describe("displays commands", () => {
252
252
  });
253
253
  });
254
254
 
255
+ it("builds KANBAN viewState fieldMapping, cardConfig and aggregation from flags", async () => {
256
+ const configPath = createApiConfig();
257
+ const output = createStdoutBuffer();
258
+ const fetchMock = createFetchMock();
259
+ fetchMock.mockResolvedValueOnce(jsonResponse({ displayId: "display-1", widgetId: "widget-k1" }));
260
+ const program = buildProgram({ configPath, stdout: output.stdout });
261
+
262
+ await program.parseAsync([
263
+ "node",
264
+ "zinkee",
265
+ "--json",
266
+ "displays",
267
+ "widgets",
268
+ "create",
269
+ "display-1",
270
+ "--type",
271
+ "kanban",
272
+ "--field",
273
+ "name-uuid",
274
+ "--column-field",
275
+ "status-uuid",
276
+ "--swimlane-field",
277
+ "owner-uuid",
278
+ "--card-title-field",
279
+ "name-uuid",
280
+ "--card-category3-field",
281
+ "amount-uuid",
282
+ "--aggregation-method",
283
+ "sum",
284
+ "--aggregation-field",
285
+ "amount-uuid",
286
+ "--view-state",
287
+ '{"showNoValueColumn":true,"hideEmptyColumns":false}',
288
+ ]);
289
+
290
+ const init = fetchMock.mock.calls[0]?.[1];
291
+ expect(JSON.parse(String(init?.body))).toMatchObject({
292
+ type: "KANBAN",
293
+ view: {
294
+ fields: ["name-uuid"],
295
+ viewAdditionalProperties: {
296
+ viewState: {
297
+ showNoValueColumn: true,
298
+ hideEmptyColumns: false,
299
+ fieldMapping: {
300
+ columnField: "status-uuid",
301
+ swimlaneField: "owner-uuid",
302
+ cardConfig: { titleField: "name-uuid", category3Field: "amount-uuid" },
303
+ aggregationConfig: { method: "sum", fieldId: "amount-uuid" },
304
+ },
305
+ },
306
+ },
307
+ },
308
+ });
309
+ });
310
+
311
+ it("builds TIMELINE viewState fieldMapping with category4 from flags", async () => {
312
+ const configPath = createApiConfig();
313
+ const output = createStdoutBuffer();
314
+ const fetchMock = createFetchMock();
315
+ fetchMock.mockResolvedValueOnce(jsonResponse({ displayId: "display-1", widgetId: "widget-t1" }));
316
+ const program = buildProgram({ configPath, stdout: output.stdout });
317
+
318
+ await program.parseAsync([
319
+ "node",
320
+ "zinkee",
321
+ "--json",
322
+ "displays",
323
+ "widgets",
324
+ "create",
325
+ "display-1",
326
+ "--type",
327
+ "timeline",
328
+ "--start-date-field",
329
+ "start-uuid",
330
+ "--end-date-field",
331
+ "end-uuid",
332
+ "--label-field",
333
+ "name-uuid",
334
+ "--card-category4-field",
335
+ "amount-uuid",
336
+ "--view-state",
337
+ '{"granularity":"month","sidebarWidth":300}',
338
+ ]);
339
+
340
+ const init = fetchMock.mock.calls[0]?.[1];
341
+ expect(JSON.parse(String(init?.body))).toMatchObject({
342
+ type: "TIMELINE",
343
+ view: {
344
+ viewAdditionalProperties: {
345
+ viewState: {
346
+ granularity: "month",
347
+ sidebarWidth: 300,
348
+ fieldMapping: {
349
+ startDateField: "start-uuid",
350
+ endDateField: "end-uuid",
351
+ labelField: "name-uuid",
352
+ cardConfig: { category4Field: "amount-uuid" },
353
+ },
354
+ },
355
+ },
356
+ },
357
+ });
358
+ });
359
+
360
+ it("builds HIERARCHY hierarchyConfig from columns, root and child flags", async () => {
361
+ const configPath = createApiConfig();
362
+ const output = createStdoutBuffer();
363
+ const fetchMock = createFetchMock();
364
+ fetchMock.mockResolvedValueOnce(jsonResponse({ displayId: "display-1", widgetId: "widget-h1" }));
365
+ const program = buildProgram({ configPath, stdout: output.stdout });
366
+
367
+ await program.parseAsync([
368
+ "node",
369
+ "zinkee",
370
+ "--json",
371
+ "displays",
372
+ "widgets",
373
+ "create",
374
+ "display-1",
375
+ "--type",
376
+ "hierarchy",
377
+ "--hierarchy-label",
378
+ "Name",
379
+ "--hierarchy-info",
380
+ "col-info-status:Status",
381
+ "--hierarchy-number",
382
+ "col-num-amount:Total amount:sum",
383
+ "--hierarchy-root-name",
384
+ "Customers",
385
+ "--hierarchy-root-label-field",
386
+ "name-uuid",
387
+ "--hierarchy-root-info",
388
+ "col-info-status:status-uuid",
389
+ "--hierarchy-root-number",
390
+ "col-num-amount:amount-uuid",
391
+ "--hierarchy-child",
392
+ '{"name":"Projects","schemaId":"projects-uuid","childFieldId":"child-uuid","labelFieldId":"project-name-uuid","infoMappings":[],"numberMappings":[{"columnId":"col-num-amount","fieldId":"project-amount-uuid"}]}',
393
+ ]);
394
+
395
+ const init = fetchMock.mock.calls[0]?.[1];
396
+ expect(JSON.parse(String(init?.body))).toMatchObject({
397
+ type: "HIERARCHY",
398
+ view: {
399
+ hierarchyConfig: {
400
+ columns: {
401
+ label: "Name",
402
+ infos: [{ id: "col-info-status", name: "Status" }],
403
+ numbers: [{ id: "col-num-amount", name: "Total amount", aggregation: "SUM" }],
404
+ },
405
+ rootLevel: {
406
+ name: "Customers",
407
+ labelFieldId: "name-uuid",
408
+ infoMappings: [{ columnId: "col-info-status", fieldId: "status-uuid" }],
409
+ numberMappings: [{ columnId: "col-num-amount", fieldId: "amount-uuid" }],
410
+ },
411
+ childLevels: [
412
+ {
413
+ name: "Projects",
414
+ schemaId: "projects-uuid",
415
+ childFieldId: "child-uuid",
416
+ labelFieldId: "project-name-uuid",
417
+ infoMappings: [],
418
+ numberMappings: [{ columnId: "col-num-amount", fieldId: "project-amount-uuid" }],
419
+ },
420
+ ],
421
+ },
422
+ },
423
+ });
424
+ });
425
+
426
+ it("deep merges KANBAN flags over view state coming from --raw", async () => {
427
+ const configPath = createApiConfig();
428
+ const output = createStdoutBuffer();
429
+ const fetchMock = createFetchMock();
430
+ fetchMock.mockResolvedValueOnce(jsonResponse({ displayId: "display-1", widgetId: "widget-k2" }));
431
+ const program = buildProgram({ configPath, stdout: output.stdout });
432
+
433
+ await program.parseAsync([
434
+ "node",
435
+ "zinkee",
436
+ "--json",
437
+ "displays",
438
+ "widgets",
439
+ "create",
440
+ "display-1",
441
+ "--type",
442
+ "kanban",
443
+ "--raw",
444
+ '{"view":{"viewAdditionalProperties":{"viewState":{"columnOrder":["a","b"],"fieldMapping":{"columnField":"old-uuid","cardConfig":{"titleField":"name-uuid"}}}}}}',
445
+ "--column-field",
446
+ "status-uuid",
447
+ "--card-category1-field",
448
+ "status-uuid",
449
+ ]);
450
+
451
+ const init = fetchMock.mock.calls[0]?.[1];
452
+ expect(JSON.parse(String(init?.body))).toMatchObject({
453
+ type: "KANBAN",
454
+ view: {
455
+ viewAdditionalProperties: {
456
+ viewState: {
457
+ columnOrder: ["a", "b"],
458
+ fieldMapping: {
459
+ columnField: "status-uuid",
460
+ cardConfig: { titleField: "name-uuid", category1Field: "status-uuid" },
461
+ },
462
+ },
463
+ },
464
+ },
465
+ });
466
+ });
467
+
468
+ it("rejects --hierarchy-number with an invalid aggregation", async () => {
469
+ const configPath = createApiConfig();
470
+ const output = createStdoutBuffer();
471
+ const fetchMock = createFetchMock();
472
+ const program = buildProgram({ configPath, stdout: output.stdout });
473
+
474
+ await expect(
475
+ program.parseAsync([
476
+ "node",
477
+ "zinkee",
478
+ "--json",
479
+ "displays",
480
+ "widgets",
481
+ "create",
482
+ "display-1",
483
+ "--type",
484
+ "hierarchy",
485
+ "--hierarchy-number",
486
+ "col-num-amount:Total:BOGUS",
487
+ ]),
488
+ ).rejects.toThrow(/aggregation/i);
489
+ expect(fetchMock).not.toHaveBeenCalled();
490
+ });
491
+
255
492
  it("translates --filter into the structured display filter contract", async () => {
256
493
  const configPath = createApiConfig();
257
494
  const output = createStdoutBuffer();
@@ -486,4 +723,78 @@ describe("displays commands", () => {
486
723
 
487
724
  expect(fetchMock).not.toHaveBeenCalled();
488
725
  });
726
+
727
+ it("creates a display folder with just a name", async () => {
728
+ const configPath = createApiConfig();
729
+ const output = createStdoutBuffer();
730
+ const fetchMock = createFetchMock();
731
+ fetchMock.mockResolvedValueOnce(
732
+ jsonResponse({ id: "folder-1", name: "Executive Dashboards", isRoot: false, displayCount: 0 }),
733
+ );
734
+ const program = buildProgram({ configPath, stdout: output.stdout });
735
+
736
+ await program.parseAsync([
737
+ "node",
738
+ "zinkee",
739
+ "--json",
740
+ "displays",
741
+ "folders",
742
+ "create",
743
+ "--name",
744
+ "Executive Dashboards",
745
+ ]);
746
+
747
+ expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.zinkee.test/api/v2/display-folders");
748
+ const init = fetchMock.mock.calls[0]?.[1];
749
+ expect(init?.method).toBe("POST");
750
+ expect(JSON.parse(String(init?.body))).toEqual({ name: "Executive Dashboards" });
751
+ });
752
+
753
+ it("moves a display into a folder resolved by name", async () => {
754
+ const configPath = createApiConfig();
755
+ const output = createStdoutBuffer();
756
+ const fetchMock = createFetchMock();
757
+ fetchMock.mockResolvedValueOnce(
758
+ jsonResponse([{ id: "folder-1", name: "Executive Dashboards", isRoot: false, displayCount: 2 }]),
759
+ );
760
+ fetchMock.mockResolvedValueOnce(
761
+ jsonResponse({ id: "folder-1", name: "Executive Dashboards", isRoot: false }),
762
+ );
763
+ const program = buildProgram({ configPath, stdout: output.stdout });
764
+
765
+ await program.parseAsync([
766
+ "node",
767
+ "zinkee",
768
+ "--json",
769
+ "displays",
770
+ "folders",
771
+ "move",
772
+ "0f6f062f-1478-4df2-96a1-c495d037d260",
773
+ "--to",
774
+ "Executive Dashboards",
775
+ ]);
776
+
777
+ expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.zinkee.test/api/v2/display-folders");
778
+ expect(fetchMock.mock.calls[1]?.[0]).toBe(
779
+ "https://api.zinkee.test/api/v2/display-folders/displays:move",
780
+ );
781
+ const moveInit = fetchMock.mock.calls[1]?.[1];
782
+ expect(moveInit?.method).toBe("POST");
783
+ expect(JSON.parse(String(moveInit?.body))).toEqual({
784
+ displayId: "0f6f062f-1478-4df2-96a1-c495d037d260",
785
+ targetFolderId: "folder-1",
786
+ });
787
+ });
788
+
789
+ it("blocks destructive display folder commands in read-only mode", async () => {
790
+ const configPath = createApiConfig();
791
+ const fetchMock = createFetchMock();
792
+ const program = buildProgram({ configPath });
793
+
794
+ await expect(
795
+ program.parseAsync(["node", "zinkee", "--read-only", "displays", "folders", "delete", "folder-1"]),
796
+ ).rejects.toThrowError('Command "displays folders delete" is not allowed in read-only mode.');
797
+
798
+ expect(fetchMock).not.toHaveBeenCalled();
799
+ });
489
800
  });