zinkee 0.1.39 → 0.1.41

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.
@@ -1,95 +1,64 @@
1
1
  # Publish to npm
2
2
 
3
- Simple release checklist for publishing a patch update of the `zinkee` CLI to the npm package registry.
3
+ The `Publish to npm` GitHub Actions workflow publishes new CLI versions when
4
+ they reach `main`. It uses npm trusted publishing with GitHub OIDC, so the
5
+ repository does not need an `NPM_TOKEN`.
4
6
 
5
- ## Prerequisites
7
+ ## One-time npm configuration
6
8
 
7
- - You are on the release branch with a clean working tree.
8
- - You have npm publish permissions for the `zinkee` package.
9
- - You are logged in to npm:
9
+ Configure the `zinkee` package on npmjs.com with this trusted publisher:
10
10
 
11
- ```bash
12
- npm whoami
13
- ```
14
-
15
- If needed:
16
-
17
- ```bash
18
- npm login
19
- ```
20
-
21
- ## 1. Verify the package
22
-
23
- Run the local checks before changing the package version:
24
-
25
- ```bash
26
- npm install
27
- npm run build
28
- npm run typecheck
29
- npm test
30
- ```
31
-
32
- Optionally inspect what would be published:
33
-
34
- ```bash
35
- npm pack --dry-run
36
- ```
37
-
38
- ## 2. Bump the patch version
39
-
40
- For a normal patch release, use:
11
+ - Provider: GitHub Actions
12
+ - Organization: `Zinkee-Engineering`
13
+ - Repository: `cli`
14
+ - Workflow filename: `npm-publish.yml`
15
+ - Environment: empty
16
+ - Allowed action: `npm publish`
41
17
 
42
- ```bash
43
- npm version patch
44
- ```
18
+ Enter only the workflow filename, not `.github/workflows/npm-publish.yml`.
45
19
 
46
- This updates `package.json` and `package-lock.json`, creates a git commit, and creates a version tag like `v0.1.8`.
20
+ ## Release flow
47
21
 
48
- Use the other npm version levels only when needed:
22
+ Every pull request that ships a user-visible change must update the package
23
+ version. For a patch release, run:
49
24
 
50
25
  ```bash
51
- npm version minor
52
- npm version major
26
+ npm version patch --no-git-tag-version
53
27
  ```
54
28
 
55
- ## 3. Publish to npm
56
-
57
- Build again after the version bump:
29
+ This updates `package.json` and `package-lock.json` without creating a commit or
30
+ tag. Include both files in the feature pull request and run:
58
31
 
59
32
  ```bash
33
+ npm ci
34
+ npm test
35
+ npm run typecheck
60
36
  npm run build
37
+ npm pack --dry-run
61
38
  ```
62
39
 
63
- Publish the package:
40
+ After the pull request is merged, `.github/workflows/npm-publish.yml`:
64
41
 
65
- ```bash
66
- npm publish
67
- ```
68
-
69
- If npm requires explicit public access, use:
42
+ 1. Checks whether the package version already exists on npm.
43
+ 2. Skips the publish successfully when the version already exists.
44
+ 3. Runs the full quality gate for a new version.
45
+ 4. Publishes the package with `npm publish --access public`.
70
46
 
71
- ```bash
72
- npm publish --access public
73
- ```
47
+ ## Verification
74
48
 
75
- ## 4. Push the release commit and tag
49
+ Check the workflow run and the published version:
76
50
 
77
51
  ```bash
78
- git push
79
- git push --tags
52
+ gh run list --workflow npm-publish.yml
53
+ npm view zinkee version
80
54
  ```
81
55
 
82
- ## 5. Confirm the published version
56
+ Optionally install the published version and exercise the CLI:
83
57
 
84
58
  ```bash
85
- npm view zinkee version
86
59
  npm install -g zinkee@latest
87
60
  zinkee --help
88
61
  ```
89
62
 
90
- ## Notes
91
-
92
- - Do not manually edit the version unless there is a specific reason; prefer `npm version patch`.
93
- - If the publish fails after `npm version patch`, fix the issue and run `npm publish` again. Do not create another version bump unless the failed version was already published.
94
- - Keep stdout-sensitive CLI changes verified with `--json` before publishing.
95
-
63
+ Do not create another version bump after a failed publish unless npm confirms
64
+ that the failed version was already published.
@@ -0,0 +1,37 @@
1
+ # CLI pull request checks
2
+
3
+ ## Context
4
+
5
+ The CLI repository has no GitHub Actions workflow that validates pull
6
+ requests. Its documented local quality gate consists of tests, TypeScript type
7
+ checking, and a production build.
8
+
9
+ ## Decision
10
+
11
+ Add `.github/workflows/pr-checks.yml` and run it for pull requests targeting
12
+ `main`. The workflow will grant only `contents: read` permission and use one
13
+ Ubuntu job so dependencies are installed once.
14
+
15
+ The job will:
16
+
17
+ 1. Check out the repository.
18
+ 2. Set up Node.js 24 with npm dependency caching.
19
+ 3. Install the locked dependency graph with `npm ci`.
20
+ 4. Run `npm test`.
21
+ 5. Run `npm run typecheck`.
22
+ 6. Run `npm run build`.
23
+
24
+ Each quality check will be a separate step so GitHub identifies the failing
25
+ gate directly. A failure in any step will fail the job and prevent subsequent
26
+ steps from running.
27
+
28
+ ## Scope
29
+
30
+ The workflow will not publish packages, deploy artifacts, access secrets,
31
+ change versions, run on pushes, or test a Node.js version matrix.
32
+
33
+ ## Verification
34
+
35
+ Validate the workflow YAML and inspect its trigger, permissions, Node version,
36
+ dependency installation, and commands. Run the same three quality checks
37
+ locally from the CLI repository before completion.
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "zinkee",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "CLI for Zinkee API v2",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Zinkee-Engineering/cli.git"
8
+ },
5
9
  "type": "module",
6
10
  "bin": {
7
11
  "zinkee": "dist/index.js"
@@ -16,6 +16,7 @@ export interface ApiAutomation {
16
16
  description?: string;
17
17
  status?: string;
18
18
  folderId?: string;
19
+ folder?: { id?: string; name?: string | null; isRoot?: boolean };
19
20
  trigger?: ApiAutomationTrigger;
20
21
  actions?: ApiAutomationAction[];
21
22
  }
@@ -16,6 +16,7 @@ export interface ApiDisplay {
16
16
  widgets?: Array<Record<string, unknown>>;
17
17
  navigationTargets?: Array<Record<string, unknown>>;
18
18
  crossWidgetFilter?: Array<Record<string, unknown>> | null;
19
+ folder?: { id?: string; name?: string | null; isRoot?: boolean };
19
20
  }
20
21
 
21
22
  export interface ApiDisplayTab {
@@ -20,6 +20,7 @@ export interface ApiSchema {
20
20
  highlightedField?: string;
21
21
  fields?: ApiSchemaField[];
22
22
  uniqueFields?: string[];
23
+ folder?: { id?: string; name?: string | null; isRoot?: boolean };
23
24
  }
24
25
 
25
26
  export interface CreateSchemaInput {
@@ -611,6 +611,146 @@ describe("automations commands", () => {
611
611
  });
612
612
  });
613
613
 
614
+ it("accepts the field:comparator literal form for no-value comparators", async () => {
615
+ const configPath = createApiConfig();
616
+ const output = createStdoutBuffer();
617
+ const fetchMock = createFetchMock();
618
+ fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }));
619
+ const program = buildAutomationsProgram({ configPath, stdout: output.stdout });
620
+
621
+ await program.parseAsync([
622
+ "node",
623
+ "zinkee",
624
+ "--json",
625
+ "automations",
626
+ "trigger",
627
+ "set",
628
+ "automation-1",
629
+ "--trigger-type",
630
+ "record_changed",
631
+ "--trigger-schema",
632
+ "schema-1",
633
+ "--trigger-field",
634
+ "field-1",
635
+ "--condition",
636
+ "field-1:chg",
637
+ ]);
638
+
639
+ expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
640
+ type: "record_changed",
641
+ config: {
642
+ schemaId: "schema-1",
643
+ fieldIds: ["field-1"],
644
+ conditions: [
645
+ {
646
+ field: "field-1",
647
+ comparator: "chg",
648
+ values: [],
649
+ },
650
+ ],
651
+ },
652
+ });
653
+ });
654
+
655
+ it("accepts empty condition-json values for no-value comparators", async () => {
656
+ const configPath = createApiConfig();
657
+ const output = createStdoutBuffer();
658
+ const fetchMock = createFetchMock();
659
+ fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }));
660
+ const program = buildAutomationsProgram({ configPath, stdout: output.stdout });
661
+
662
+ await program.parseAsync([
663
+ "node",
664
+ "zinkee",
665
+ "--json",
666
+ "automations",
667
+ "trigger",
668
+ "set",
669
+ "automation-1",
670
+ "--trigger-type",
671
+ "record_changed",
672
+ "--trigger-schema",
673
+ "schema-1",
674
+ "--trigger-field",
675
+ "field-1",
676
+ "--condition-json",
677
+ '{"field":"field-1","comparator":"ie","values":[]}',
678
+ ]);
679
+
680
+ expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
681
+ type: "record_changed",
682
+ config: {
683
+ schemaId: "schema-1",
684
+ fieldIds: ["field-1"],
685
+ conditions: [
686
+ {
687
+ field: "field-1",
688
+ comparator: "ie",
689
+ values: [],
690
+ },
691
+ ],
692
+ },
693
+ });
694
+ });
695
+
696
+ it("rejects the field:comparator literal form for comparators that need a value", async () => {
697
+ const configPath = createApiConfig();
698
+ const output = createStdoutBuffer();
699
+ const fetchMock = createFetchMock();
700
+ const program = buildAutomationsProgram({ configPath, stdout: output.stdout });
701
+
702
+ await expect(
703
+ program.parseAsync([
704
+ "node",
705
+ "zinkee",
706
+ "--json",
707
+ "automations",
708
+ "trigger",
709
+ "set",
710
+ "automation-1",
711
+ "--trigger-type",
712
+ "record_changed",
713
+ "--trigger-schema",
714
+ "schema-1",
715
+ "--trigger-field",
716
+ "field-1",
717
+ "--condition",
718
+ "field-1:eq",
719
+ ]),
720
+ ).rejects.toThrowError(/field:comparator for comparators without value/);
721
+
722
+ expect(fetchMock).not.toHaveBeenCalled();
723
+ });
724
+
725
+ it("rejects empty condition-json values for comparators that need a value", async () => {
726
+ const configPath = createApiConfig();
727
+ const output = createStdoutBuffer();
728
+ const fetchMock = createFetchMock();
729
+ const program = buildAutomationsProgram({ configPath, stdout: output.stdout });
730
+
731
+ await expect(
732
+ program.parseAsync([
733
+ "node",
734
+ "zinkee",
735
+ "--json",
736
+ "automations",
737
+ "trigger",
738
+ "set",
739
+ "automation-1",
740
+ "--trigger-type",
741
+ "record_changed",
742
+ "--trigger-schema",
743
+ "schema-1",
744
+ "--trigger-field",
745
+ "field-1",
746
+ "--condition-json",
747
+ '{"field":"field-1","comparator":"eq","values":[]}',
748
+ ]),
749
+ ).rejects.toThrowError(/non-empty "values" array/);
750
+
751
+ expect(fetchMock).not.toHaveBeenCalled();
752
+ });
753
+
614
754
  it("rejects source-based trigger conditions", async () => {
615
755
  const configPath = createApiConfig();
616
756
  const output = createStdoutBuffer();
@@ -29,7 +29,7 @@ import {
29
29
  import { resolveRuntimeContext } from "../runtime-context.js";
30
30
  import type { JsonObject, JsonValue, RuntimeOverrides } from "../types.js";
31
31
  import { CliError } from "../utils/errors.js";
32
- import { buildJsonSuccess, printTable } from "../utils/output.js";
32
+ import { buildJsonSuccess, folderColumns, FOLDER_TABLE_KEYS, printTable } from "../utils/output.js";
33
33
 
34
34
  interface CommandIo {
35
35
  write(chunk: string): unknown;
@@ -450,15 +450,47 @@ function readJsonObjectOption(
450
450
  ) as JsonObject;
451
451
  }
452
452
 
453
+ const NO_VALUE_COMPARATORS = new Set([
454
+ "chg",
455
+ "changes",
456
+ "ie",
457
+ "is_empty",
458
+ "ine",
459
+ "is_not_empty",
460
+ ]);
461
+
462
+ function isNoValueComparator(comparator: string): boolean {
463
+ return NO_VALUE_COMPARATORS.has(comparator.toLowerCase());
464
+ }
465
+
466
+ function conditionFormatError(input: string): CliError {
467
+ return new CliError(
468
+ "invalid_cli_usage",
469
+ `Expected condition in the form field:comparator:value, or field:comparator for comparators without value (chg, ie, ine), received "${input}".`,
470
+ );
471
+ }
472
+
453
473
  function parseCondition(input: string): ConditionSpec {
454
474
  const firstSeparator = input.indexOf(":");
455
475
  const secondSeparator = input.indexOf(":", firstSeparator + 1);
456
476
 
457
- if (firstSeparator <= 0 || secondSeparator <= firstSeparator + 1) {
458
- throw new CliError(
459
- "invalid_cli_usage",
460
- `Expected condition in the form field:comparator:value, received "${input}".`,
461
- );
477
+ if (firstSeparator <= 0) {
478
+ throw conditionFormatError(input);
479
+ }
480
+
481
+ if (secondSeparator === -1) {
482
+ const field = input.slice(0, firstSeparator).trim();
483
+ const comparator = input.slice(firstSeparator + 1).trim();
484
+
485
+ if (!field || !comparator || !isNoValueComparator(comparator)) {
486
+ throw conditionFormatError(input);
487
+ }
488
+
489
+ return { field, comparator, values: [] };
490
+ }
491
+
492
+ if (secondSeparator <= firstSeparator + 1) {
493
+ throw conditionFormatError(input);
462
494
  }
463
495
 
464
496
  const field = input.slice(0, firstSeparator).trim();
@@ -466,10 +498,7 @@ function parseCondition(input: string): ConditionSpec {
466
498
  const value = input.slice(secondSeparator + 1).trim();
467
499
 
468
500
  if (!field || !comparator || !value) {
469
- throw new CliError(
470
- "invalid_cli_usage",
471
- `Expected condition in the form field:comparator:value, received "${input}".`,
472
- );
501
+ throw conditionFormatError(input);
473
502
  }
474
503
 
475
504
  return {
@@ -683,10 +712,11 @@ function normalizeAutomationConditions(
683
712
  const comparator = typeof condition.comparator === "string" ? condition.comparator.trim() : "";
684
713
  const values = condition.values;
685
714
 
686
- if (!field || !comparator || !Array.isArray(values) || values.length === 0) {
715
+ const allowsEmptyValues = isNoValueComparator(comparator);
716
+ if (!field || !comparator || !Array.isArray(values) || (values.length === 0 && !allowsEmptyValues)) {
687
717
  throw new CliError(
688
718
  "invalid_cli_usage",
689
- `Expected ${label} ${index + 1} to contain "field", "comparator", and a non-empty "values" array.`,
719
+ `Expected ${label} ${index + 1} to contain "field", "comparator", and a non-empty "values" array (comparators without value, e.g. chg/ie/ine, accept an empty array).`,
690
720
  );
691
721
  }
692
722
 
@@ -800,12 +830,12 @@ function automationRows(automations: readonly ApiAutomation[]): Record<string, u
800
830
  id: automation.id ?? "",
801
831
  name: automation.name ?? "",
802
832
  status: automation.status ?? "",
803
- folderId: automation.folderId ?? "",
804
833
  triggerType:
805
834
  isJsonObject(automation.trigger) && typeof automation.trigger.type === "string"
806
835
  ? automation.trigger.type
807
836
  : "",
808
837
  actionCount: Array.isArray(automation.actions) ? automation.actions.length : "",
838
+ ...folderColumns(automation.folder),
809
839
  }));
810
840
  }
811
841
 
@@ -1329,7 +1359,7 @@ function addTriggerPayloadOptions(command: Command): Command {
1329
1359
  )
1330
1360
  .option(
1331
1361
  "--condition <field:comparator:value>",
1332
- "trigger condition",
1362
+ "trigger condition (use field:comparator for comparators without value: chg, ie, ine)",
1333
1363
  collectValues,
1334
1364
  [],
1335
1365
  )
@@ -1368,7 +1398,12 @@ function addActionPayloadOptions(command: Command): Command {
1368
1398
  .option("--target-schema <uuid>", "target schema id")
1369
1399
  .option("--map <field=value>", "field mapping", collectValues, [])
1370
1400
  .option("--map-json <field=json>", "JSON field mapping", collectValues, [])
1371
- .option("--condition <field:comparator:value>", "action condition", collectValues, [])
1401
+ .option(
1402
+ "--condition <field:comparator:value>",
1403
+ "action condition (use field:comparator for comparators without value: ie, ine)",
1404
+ collectValues,
1405
+ [],
1406
+ )
1372
1407
  .option("--condition-json <json>", "action condition as JSON", collectValues, [])
1373
1408
  .option("--use-trigger-record", "use the triggering record")
1374
1409
  .option("--method <method>", "HTTP method")
@@ -1569,7 +1604,7 @@ export function registerAutomationsCommands(
1569
1604
 
1570
1605
  printTable(
1571
1606
  automationRows(data),
1572
- ["id", "name", "status", "folderId", "triggerType", "actionCount"],
1607
+ ["id", "name", "status", "triggerType", "actionCount", ...FOLDER_TABLE_KEYS],
1573
1608
  stdout,
1574
1609
  );
1575
1610
  });
@@ -20,7 +20,7 @@ import { normalizeUuidOrSlug, type NormalizedUuidOrSlug } from "../parsers/selec
20
20
  import { resolveRuntimeContext } from "../runtime-context.js";
21
21
  import type { RuntimeOverrides } from "../types.js";
22
22
  import { CliError } from "../utils/errors.js";
23
- import { buildJsonSuccess, printTable } from "../utils/output.js";
23
+ import { buildJsonSuccess, folderColumns, FOLDER_TABLE_KEYS, printTable } from "../utils/output.js";
24
24
 
25
25
  const DATE_DYNAMIC_OPTIONS =
26
26
  "today, thisWeek, thisMonth, thisQuarter, thisSemester, thisYear, next7Days, next30Days, next6Months, next12Months, last12Months";
@@ -628,6 +628,7 @@ function displayRows(displays: readonly DisplaySummary[]): Record<string, unknow
628
628
  layoutTemplate: display.layoutTemplate ?? "",
629
629
  variableCount: display.variableCount ?? "",
630
630
  widgetCount: display.widgetCount ?? "",
631
+ ...folderColumns(display.folder),
631
632
  }));
632
633
  }
633
634
 
@@ -953,7 +954,11 @@ export function registerDisplaysCommands(
953
954
  emitJson(stdout, "displays list", data);
954
955
  return;
955
956
  }
956
- printTable(displayRows(data), ["id", "name", "layoutTemplate", "variableCount", "widgetCount"], stdout);
957
+ printTable(
958
+ displayRows(data),
959
+ ["id", "name", "layoutTemplate", "variableCount", "widgetCount", ...FOLDER_TABLE_KEYS],
960
+ stdout,
961
+ );
957
962
  });
958
963
 
959
964
  displays
@@ -31,7 +31,7 @@ import {
31
31
  normalizeSchemaFieldUpdatePayload,
32
32
  normalizeSchemaFieldType,
33
33
  } from "../utils/schema-fields.js";
34
- import { buildJsonSuccess, printTable } from "../utils/output.js";
34
+ import { buildJsonSuccess, folderColumns, FOLDER_TABLE_KEYS, printTable } from "../utils/output.js";
35
35
 
36
36
  interface CommandIo {
37
37
  write(chunk: string): unknown;
@@ -284,8 +284,9 @@ function emitSchemasTable(stdout: CommandIo, schemas: readonly ApiSchema[]): voi
284
284
  slug: schema.slug ?? "",
285
285
  auditable: schema.auditable ?? "",
286
286
  commentable: schema.commentable ?? "",
287
+ ...folderColumns(schema.folder),
287
288
  })),
288
- ["id", "name", "slug", "auditable", "commentable"],
289
+ ["id", "name", "slug", "auditable", "commentable", ...FOLDER_TABLE_KEYS],
289
290
  stdout,
290
291
  );
291
292
  }
@@ -274,6 +274,8 @@ describe("getCommandExamples", () => {
274
274
  expect(examples[0]?.response).toContain('"layoutTemplate": "widget_row_tabs"');
275
275
  expect(examples[0]?.response).toContain('"layoutTemplate": "freeform"');
276
276
  expect(examples[0]?.response).toContain('"layoutTemplate": "grid"');
277
+ expect(examples[0]?.response).toContain('"folder"');
278
+ expect(examples[0]?.response).toContain('"isRoot": true');
277
279
  });
278
280
 
279
281
  it("attaches a response body to schemas create and teamspace resources publish", () => {
@@ -2909,21 +2909,36 @@ const specificExamples: Record<string, readonly CommandExample[]> = {
2909
2909
  "name": "Executive Revenue",
2910
2910
  "layoutTemplate": "widget_row_tabs",
2911
2911
  "variableCount": 3,
2912
- "widgetCount": 6
2912
+ "widgetCount": 6,
2913
+ "folder": {
2914
+ "id": "b1a2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
2915
+ "name": "📊 Executive",
2916
+ "isRoot": false
2917
+ }
2913
2918
  },
2914
2919
  {
2915
2920
  "id": "6b7c8d9e-0f12-3456-789a-bcdef0123456",
2916
2921
  "name": "Sales Pipeline",
2917
2922
  "layoutTemplate": "freeform",
2918
2923
  "variableCount": 1,
2919
- "widgetCount": 3
2924
+ "widgetCount": 3,
2925
+ "folder": {
2926
+ "id": "a0b1c2d3-e4f5-4061-8273-8495a6b7c8d9",
2927
+ "name": null,
2928
+ "isRoot": true
2929
+ }
2920
2930
  },
2921
2931
  {
2922
2932
  "id": "ef012345-6789-4abc-8def-0123456789ab",
2923
2933
  "name": "Customer Detail",
2924
2934
  "layoutTemplate": "grid",
2925
2935
  "variableCount": 0,
2926
- "widgetCount": 2
2936
+ "widgetCount": 2,
2937
+ "folder": {
2938
+ "id": "b1a2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
2939
+ "name": "📊 Executive",
2940
+ "isRoot": false
2941
+ }
2927
2942
  }
2928
2943
  ]`,
2929
2944
  },
@@ -3056,7 +3071,12 @@ const specificExamples: Record<string, readonly CommandExample[]> = {
3056
3071
  "targetVariableId": "aaaaaaaa-1111-4111-a111-111111111111",
3057
3072
  "label": "Open customer"
3058
3073
  }
3059
- ]
3074
+ ],
3075
+ "folder": {
3076
+ "id": "b1a2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
3077
+ "name": "📊 Executive",
3078
+ "isRoot": false
3079
+ }
3060
3080
  }`,
3061
3081
  },
3062
3082
  ]),
@@ -2,7 +2,33 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { getCommandMeta } from "../command-registry.js";
4
4
 
5
- import { buildJsonError, buildJsonSuccess } from "./output.js";
5
+ import { buildJsonError, buildJsonSuccess, folderColumns } from "./output.js";
6
+
7
+ describe("folderColumns", () => {
8
+ it("flattens a named folder into id/name/isRoot columns", () => {
9
+ expect(folderColumns({ id: "f1", name: "Finance", isRoot: false })).toEqual({
10
+ folderId: "f1",
11
+ folderName: "Finance",
12
+ folderIsRoot: "false",
13
+ });
14
+ });
15
+
16
+ it("renders the root folder (null name) as (root)", () => {
17
+ expect(folderColumns({ id: "root-1", name: null, isRoot: true })).toEqual({
18
+ folderId: "root-1",
19
+ folderName: "(root)",
20
+ folderIsRoot: "true",
21
+ });
22
+ });
23
+
24
+ it("leaves every column empty when there is no folder", () => {
25
+ expect(folderColumns(undefined)).toEqual({
26
+ folderId: "",
27
+ folderName: "",
28
+ folderIsRoot: "",
29
+ });
30
+ });
31
+ });
6
32
 
7
33
  describe("buildJsonSuccess", () => {
8
34
  it("wraps data in the standard success envelope", () => {
@@ -59,6 +59,36 @@ export function buildTableColumns(keys: readonly string[]): readonly TableColumn
59
59
  return keys.map((key) => ({ key, label: key }));
60
60
  }
61
61
 
62
+ export interface FolderLocation {
63
+ id?: string;
64
+ name?: string | null;
65
+ isRoot?: boolean;
66
+ }
67
+
68
+ /** Column keys, in order, produced by {@link folderColumns}. Append them to a table's key list. */
69
+ export const FOLDER_TABLE_KEYS = ["folderId", "folderName", "folderIsRoot"] as const;
70
+
71
+ /**
72
+ * Flattens the API `folder` object (`{ id, name, isRoot }`) into the folderId/folderName/folderIsRoot table columns.
73
+ * The root folder arrives with a null name, so folderName renders it as "(root)". A missing folder (e.g. a workspace
74
+ * without a default group) leaves every column empty.
75
+ */
76
+ export function folderColumns(folder: FolderLocation | null | undefined): {
77
+ folderId: string;
78
+ folderName: string;
79
+ folderIsRoot: string;
80
+ } {
81
+ if (!folder) {
82
+ return { folderId: "", folderName: "", folderIsRoot: "" };
83
+ }
84
+
85
+ return {
86
+ folderId: folder.id ?? "",
87
+ folderName: folder.name ?? (folder.isRoot === true ? "(root)" : ""),
88
+ folderIsRoot: folder.isRoot === undefined ? "" : String(folder.isRoot),
89
+ };
90
+ }
91
+
62
92
  export function buildTableRows(
63
93
  rows: readonly Record<string, unknown>[],
64
94
  columns: readonly TableColumn[],