zinkee 0.1.25 → 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.25",
3
+ "version": "0.1.26",
4
4
  "description": "CLI for Zinkee API v2",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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();
@@ -107,6 +107,36 @@ interface WidgetMutationOptions {
107
107
  xAxisTimeSeries?: boolean;
108
108
  yAxisSerie?: string[];
109
109
  legend?: string;
110
+ // KANBAN / TIMELINE viewState.fieldMapping (UUIDs only; slugs are not resolved here)
111
+ columnField?: string;
112
+ swimlaneField?: string;
113
+ startDateField?: string;
114
+ endDateField?: string;
115
+ labelField?: string;
116
+ groupField?: string;
117
+ subgroupField?: string;
118
+ progressField?: string;
119
+ cardTitleField?: string;
120
+ cardCategory1Field?: string;
121
+ cardCategory2Field?: string;
122
+ cardCategory3Field?: string;
123
+ cardCategory4Field?: string;
124
+ cardDescriptionField?: string;
125
+ aggregationMethod?: string;
126
+ aggregationField?: string;
127
+ viewState?: string;
128
+ viewStateFile?: string;
129
+ // HIERARCHY view.hierarchyConfig (UUIDs only; slugs are not resolved here)
130
+ hierarchyLabel?: string;
131
+ hierarchyInfo?: string[];
132
+ hierarchyNumber?: string[];
133
+ hierarchyRootName?: string;
134
+ hierarchyRootLabelField?: string;
135
+ hierarchyRootInfo?: string[];
136
+ hierarchyRootNumber?: string[];
137
+ hierarchyChild?: string[];
138
+ hierarchyConfig?: string;
139
+ hierarchyConfigFile?: string;
110
140
  raw?: string;
111
141
  rawFile?: string;
112
142
  }
@@ -295,6 +325,175 @@ function isEmptyObject(value: object): boolean {
295
325
  return Object.keys(value).length === 0;
296
326
  }
297
327
 
328
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
329
+ return typeof value === "object" && value !== null && !Array.isArray(value);
330
+ }
331
+
332
+ function deepMerge(
333
+ target: Record<string, unknown>,
334
+ source: Record<string, unknown>,
335
+ ): Record<string, unknown> {
336
+ const out: Record<string, unknown> = { ...target };
337
+ for (const [key, value] of Object.entries(source)) {
338
+ const existing = out[key];
339
+ out[key] = isPlainObject(existing) && isPlainObject(value) ? deepMerge(existing, value) : value;
340
+ }
341
+ return out;
342
+ }
343
+
344
+ function assignDefined(target: Record<string, unknown>, key: string, value: unknown): void {
345
+ if (value !== undefined) target[key] = value;
346
+ }
347
+
348
+ function readNamedJsonObject(
349
+ raw: string | undefined,
350
+ rawFile: string | undefined,
351
+ inlineFlag: string,
352
+ fileFlag: string,
353
+ ): Record<string, unknown> | undefined {
354
+ const parsed = readNamedJsonOption(raw, rawFile, inlineFlag, fileFlag);
355
+ if (parsed === undefined) return undefined;
356
+ if (!isPlainObject(parsed)) {
357
+ throw new CliError("invalid_cli_usage", `${inlineFlag} payload must be a JSON object.`);
358
+ }
359
+ return parsed;
360
+ }
361
+
362
+ const HIERARCHY_AGGREGATIONS = new Set([
363
+ "SUM",
364
+ "AVERAGE",
365
+ "MINIMUM",
366
+ "MAXIMUM",
367
+ "COUNT",
368
+ "COUNT_DISTINCT",
369
+ ]);
370
+
371
+ function parseHierarchyInfo(value: string): Record<string, unknown> {
372
+ const idx = value.indexOf(":");
373
+ if (idx <= 0 || idx === value.length - 1) {
374
+ throw new CliError("invalid_cli_usage", `--hierarchy-info "${value}" must use the format <id:name>.`);
375
+ }
376
+ return { id: value.slice(0, idx).trim(), name: value.slice(idx + 1).trim() };
377
+ }
378
+
379
+ function parseHierarchyNumber(value: string): Record<string, unknown> {
380
+ const parts = value.split(":");
381
+ if (parts.length < 3) {
382
+ throw new CliError(
383
+ "invalid_cli_usage",
384
+ `--hierarchy-number "${value}" must use the format <id:name:aggregation>.`,
385
+ );
386
+ }
387
+ const id = parts[0]?.trim() ?? "";
388
+ const aggregation = (parts[parts.length - 1]?.trim() ?? "").toUpperCase();
389
+ const name = parts.slice(1, -1).join(":").trim();
390
+ if (!id || !name) {
391
+ throw new CliError(
392
+ "invalid_cli_usage",
393
+ `--hierarchy-number "${value}" must use the format <id:name:aggregation>.`,
394
+ );
395
+ }
396
+ if (!HIERARCHY_AGGREGATIONS.has(aggregation)) {
397
+ throw new CliError(
398
+ "invalid_cli_usage",
399
+ `--hierarchy-number aggregation "${aggregation}" must be one of SUM, AVERAGE, MINIMUM, MAXIMUM, COUNT, COUNT_DISTINCT.`,
400
+ );
401
+ }
402
+ return { id, name, aggregation };
403
+ }
404
+
405
+ function parseHierarchyMapping(value: string, flag: string): Record<string, unknown> {
406
+ const idx = value.indexOf(":");
407
+ if (idx <= 0 || idx === value.length - 1) {
408
+ throw new CliError("invalid_cli_usage", `${flag} "${value}" must use the format <columnId:fieldId>.`);
409
+ }
410
+ return { columnId: value.slice(0, idx).trim(), fieldId: value.slice(idx + 1).trim() };
411
+ }
412
+
413
+ function buildWidgetFieldMapping(options: WidgetMutationOptions): Record<string, unknown> | undefined {
414
+ const mapping: Record<string, unknown> = {};
415
+ assignDefined(mapping, "columnField", options.columnField);
416
+ assignDefined(mapping, "swimlaneField", options.swimlaneField);
417
+ assignDefined(mapping, "startDateField", options.startDateField);
418
+ assignDefined(mapping, "endDateField", options.endDateField);
419
+ assignDefined(mapping, "labelField", options.labelField);
420
+ assignDefined(mapping, "groupField", options.groupField);
421
+ assignDefined(mapping, "subgroupField", options.subgroupField);
422
+ assignDefined(mapping, "progressField", options.progressField);
423
+
424
+ const cardConfig: Record<string, unknown> = {};
425
+ assignDefined(cardConfig, "titleField", options.cardTitleField);
426
+ assignDefined(cardConfig, "category1Field", options.cardCategory1Field);
427
+ assignDefined(cardConfig, "category2Field", options.cardCategory2Field);
428
+ assignDefined(cardConfig, "category3Field", options.cardCategory3Field);
429
+ assignDefined(cardConfig, "category4Field", options.cardCategory4Field);
430
+ assignDefined(cardConfig, "descriptionField", options.cardDescriptionField);
431
+ if (Object.keys(cardConfig).length > 0) mapping.cardConfig = cardConfig;
432
+
433
+ const aggregationConfig: Record<string, unknown> = {};
434
+ assignDefined(aggregationConfig, "method", options.aggregationMethod);
435
+ assignDefined(aggregationConfig, "fieldId", options.aggregationField);
436
+ if (Object.keys(aggregationConfig).length > 0) mapping.aggregationConfig = aggregationConfig;
437
+
438
+ return Object.keys(mapping).length > 0 ? mapping : undefined;
439
+ }
440
+
441
+ function buildHierarchyConfig(options: WidgetMutationOptions): Record<string, unknown> | undefined {
442
+ const base = readNamedJsonObject(
443
+ options.hierarchyConfig,
444
+ options.hierarchyConfigFile,
445
+ "--hierarchy-config",
446
+ "--hierarchy-config-file",
447
+ );
448
+ const patch: Record<string, unknown> = {};
449
+
450
+ const columns: Record<string, unknown> = {};
451
+ assignDefined(columns, "label", options.hierarchyLabel);
452
+ if (options.hierarchyInfo && options.hierarchyInfo.length > 0) {
453
+ columns.infos = options.hierarchyInfo.map(parseHierarchyInfo);
454
+ }
455
+ if (options.hierarchyNumber && options.hierarchyNumber.length > 0) {
456
+ columns.numbers = options.hierarchyNumber.map(parseHierarchyNumber);
457
+ }
458
+ if (Object.keys(columns).length > 0) patch.columns = columns;
459
+
460
+ const rootLevel: Record<string, unknown> = {};
461
+ assignDefined(rootLevel, "name", options.hierarchyRootName);
462
+ assignDefined(rootLevel, "labelFieldId", options.hierarchyRootLabelField);
463
+ if (options.hierarchyRootInfo && options.hierarchyRootInfo.length > 0) {
464
+ rootLevel.infoMappings = options.hierarchyRootInfo.map((entry) =>
465
+ parseHierarchyMapping(entry, "--hierarchy-root-info"),
466
+ );
467
+ }
468
+ if (options.hierarchyRootNumber && options.hierarchyRootNumber.length > 0) {
469
+ rootLevel.numberMappings = options.hierarchyRootNumber.map((entry) =>
470
+ parseHierarchyMapping(entry, "--hierarchy-root-number"),
471
+ );
472
+ }
473
+ if (Object.keys(rootLevel).length > 0) patch.rootLevel = rootLevel;
474
+
475
+ if (options.hierarchyChild && options.hierarchyChild.length > 0) {
476
+ patch.childLevels = options.hierarchyChild.map((entry, index) => {
477
+ let parsed: unknown;
478
+ try {
479
+ parsed = JSON.parse(entry);
480
+ } catch {
481
+ throw new CliError("invalid_cli_usage", `--hierarchy-child payload #${index + 1} is invalid JSON.`);
482
+ }
483
+ if (!isPlainObject(parsed)) {
484
+ throw new CliError(
485
+ "invalid_cli_usage",
486
+ `--hierarchy-child payload #${index + 1} must be a JSON object.`,
487
+ );
488
+ }
489
+ return parsed;
490
+ });
491
+ }
492
+
493
+ if (base === undefined && Object.keys(patch).length === 0) return undefined;
494
+ return deepMerge(base ?? {}, patch);
495
+ }
496
+
298
497
  function displayRows(displays: readonly DisplaySummary[]): Record<string, unknown>[] {
299
498
  return displays.map((display) => ({
300
499
  id: display.id,
@@ -435,12 +634,37 @@ function buildWidgetBody(options: WidgetMutationOptions): Record<string, unknown
435
634
  };
436
635
  }
437
636
 
637
+ const viewPatch: Record<string, unknown> = {};
438
638
  if (options.field && options.field.length > 0) {
439
- body.view = {
440
- ...(typeof body.view === "object" && body.view ? body.view as Record<string, unknown> : {}),
441
- fields: options.field,
442
- };
639
+ viewPatch.fields = options.field;
640
+ }
641
+
642
+ const fieldMapping = buildWidgetFieldMapping(options);
643
+ const viewStateBase = readNamedJsonObject(
644
+ options.viewState,
645
+ options.viewStateFile,
646
+ "--view-state",
647
+ "--view-state-file",
648
+ );
649
+ let viewState: Record<string, unknown> | undefined = viewStateBase ? { ...viewStateBase } : undefined;
650
+ if (fieldMapping) {
651
+ viewState = deepMerge(viewState ?? {}, { fieldMapping });
652
+ }
653
+ if (viewState !== undefined) {
654
+ viewPatch.viewAdditionalProperties = { viewState };
443
655
  }
656
+
657
+ const hierarchyConfig = buildHierarchyConfig(options);
658
+ if (hierarchyConfig !== undefined) {
659
+ viewPatch.hierarchyConfig = hierarchyConfig;
660
+ }
661
+
662
+ if (Object.keys(viewPatch).length > 0) {
663
+ const existingView =
664
+ typeof body.view === "object" && body.view ? (body.view as Record<string, unknown>) : {};
665
+ body.view = deepMerge(existingView, viewPatch);
666
+ }
667
+
444
668
  const queryMerge: Record<string, unknown> = {};
445
669
  if (options.sort && options.sort.length > 0) queryMerge.sorts = options.sort;
446
670
  if (options.filter && options.filter.length > 0) queryMerge.filters = options.filter.map(toDisplayFilter);
@@ -867,7 +1091,36 @@ export function registerDisplaysCommands(
867
1091
  .option("--calc <type>").option("--calc-field <uuid>").option("--kpi-config <json>")
868
1092
  .option("--chart-type <type>", "chart type (lines, bars, stackedBars, combined)").option("--x-axis-field <uuid>").option("--x-axis-date-format <granularity>", "time granularity (DATE, MONTH_YEAR, QUARTER_YEAR, YEAR)").option("--x-axis-time-series")
869
1093
  .option("--y-axis-serie <json>", "repeatable y axis serie", (v, p: string[] = []) => [...p, v], [])
870
- .option("--legend <json>").option("--raw <json>").option("--raw-file <path>")
1094
+ .option("--legend <json>")
1095
+ .option("--column-field <uuid>", "KANBAN column field (UUID, not slug)")
1096
+ .option("--swimlane-field <uuid>", "KANBAN swimlane field (UUID, not slug)")
1097
+ .option("--start-date-field <uuid>", "TIMELINE start date field (UUID, not slug)")
1098
+ .option("--end-date-field <uuid>", "TIMELINE end date field (UUID, not slug)")
1099
+ .option("--label-field <uuid>", "TIMELINE label field (UUID, not slug)")
1100
+ .option("--group-field <uuid>", "TIMELINE group field (UUID, not slug)")
1101
+ .option("--subgroup-field <uuid>", "TIMELINE subgroup field (UUID, not slug)")
1102
+ .option("--progress-field <uuid>", "TIMELINE progress field (UUID, not slug)")
1103
+ .option("--card-title-field <uuid>", "KANBAN/TIMELINE card title field (UUID, not slug)")
1104
+ .option("--card-category1-field <uuid>", "KANBAN/TIMELINE card category 1 field (UUID, not slug)")
1105
+ .option("--card-category2-field <uuid>", "KANBAN/TIMELINE card category 2 field (UUID, not slug)")
1106
+ .option("--card-category3-field <uuid>", "KANBAN card category 3 field (UUID, not slug)")
1107
+ .option("--card-category4-field <uuid>", "TIMELINE card category 4 field (UUID, not slug)")
1108
+ .option("--card-description-field <uuid>", "KANBAN/TIMELINE card description field (UUID, not slug)")
1109
+ .option("--aggregation-method <method>", "KANBAN card aggregation method (e.g. sum)")
1110
+ .option("--aggregation-field <uuid>", "KANBAN card aggregation field (UUID, not slug)")
1111
+ .option("--view-state <json>", "KANBAN/TIMELINE viewState UI scalars (columnOrder, granularity, sidebarWidth, show* ...)")
1112
+ .option("--view-state-file <path>", "read --view-state JSON from a file")
1113
+ .option("--hierarchy-label <name>", "HIERARCHY label column name")
1114
+ .option("--hierarchy-info <id:name>", "HIERARCHY info column; id is a unique opaque column id you choose (e.g. col_1779975207355_zfndknslw), not the name, and is reused as columnId in mappings (repeatable)", (v, p: string[] = []) => [...p, v], [])
1115
+ .option("--hierarchy-number <id:name:aggregation>", "HIERARCHY number column; id is a unique opaque column id (e.g. col_1779975207355_zfndknslw), not the name; aggregation SUM|AVERAGE|MINIMUM|MAXIMUM|COUNT|COUNT_DISTINCT (repeatable)", (v, p: string[] = []) => [...p, v], [])
1116
+ .option("--hierarchy-root-name <name>", "HIERARCHY root level name")
1117
+ .option("--hierarchy-root-label-field <uuid>", "HIERARCHY root level label field (UUID, not slug)")
1118
+ .option("--hierarchy-root-info <columnId:fieldId>", "HIERARCHY root info mapping; columnId must match a --hierarchy-info id (repeatable)", (v, p: string[] = []) => [...p, v], [])
1119
+ .option("--hierarchy-root-number <columnId:fieldId>", "HIERARCHY root number mapping; columnId must match a --hierarchy-number id (repeatable)", (v, p: string[] = []) => [...p, v], [])
1120
+ .option("--hierarchy-child <json>", "HIERARCHY child level JSON {name,schemaId,childFieldId,labelFieldId,infoMappings,numberMappings} (repeatable)", (v, p: string[] = []) => [...p, v], [])
1121
+ .option("--hierarchy-config <json>", "HIERARCHY full hierarchyConfig JSON (escape hatch)")
1122
+ .option("--hierarchy-config-file <path>", "read --hierarchy-config JSON from a file")
1123
+ .option("--raw <json>").option("--raw-file <path>")
871
1124
  .action(async function action(this: Command, display: NormalizedUuidOrSlug, commandOptions: WidgetMutationOptions) {
872
1125
  const { api, json, readOnly } = createDisplaysApi(this, filePath); ensureWritable("displays widgets create", readOnly, "write");
873
1126
  const data = await api.createWidget(display, buildWidgetBody(commandOptions));
@@ -885,7 +1138,36 @@ export function registerDisplaysCommands(
885
1138
  .option("--calc <type>").option("--calc-field <uuid>").option("--kpi-config <json>")
886
1139
  .option("--chart-type <type>", "chart type (lines, bars, stackedBars, combined)").option("--x-axis-field <uuid>").option("--x-axis-date-format <granularity>", "time granularity (DATE, MONTH_YEAR, QUARTER_YEAR, YEAR)").option("--x-axis-time-series")
887
1140
  .option("--y-axis-serie <json>", "repeatable y axis serie", (v, p: string[] = []) => [...p, v], [])
888
- .option("--legend <json>").option("--raw <json>").option("--raw-file <path>")
1141
+ .option("--legend <json>")
1142
+ .option("--column-field <uuid>", "KANBAN column field (UUID, not slug)")
1143
+ .option("--swimlane-field <uuid>", "KANBAN swimlane field (UUID, not slug)")
1144
+ .option("--start-date-field <uuid>", "TIMELINE start date field (UUID, not slug)")
1145
+ .option("--end-date-field <uuid>", "TIMELINE end date field (UUID, not slug)")
1146
+ .option("--label-field <uuid>", "TIMELINE label field (UUID, not slug)")
1147
+ .option("--group-field <uuid>", "TIMELINE group field (UUID, not slug)")
1148
+ .option("--subgroup-field <uuid>", "TIMELINE subgroup field (UUID, not slug)")
1149
+ .option("--progress-field <uuid>", "TIMELINE progress field (UUID, not slug)")
1150
+ .option("--card-title-field <uuid>", "KANBAN/TIMELINE card title field (UUID, not slug)")
1151
+ .option("--card-category1-field <uuid>", "KANBAN/TIMELINE card category 1 field (UUID, not slug)")
1152
+ .option("--card-category2-field <uuid>", "KANBAN/TIMELINE card category 2 field (UUID, not slug)")
1153
+ .option("--card-category3-field <uuid>", "KANBAN card category 3 field (UUID, not slug)")
1154
+ .option("--card-category4-field <uuid>", "TIMELINE card category 4 field (UUID, not slug)")
1155
+ .option("--card-description-field <uuid>", "KANBAN/TIMELINE card description field (UUID, not slug)")
1156
+ .option("--aggregation-method <method>", "KANBAN card aggregation method (e.g. sum)")
1157
+ .option("--aggregation-field <uuid>", "KANBAN card aggregation field (UUID, not slug)")
1158
+ .option("--view-state <json>", "KANBAN/TIMELINE viewState UI scalars (columnOrder, granularity, sidebarWidth, show* ...)")
1159
+ .option("--view-state-file <path>", "read --view-state JSON from a file")
1160
+ .option("--hierarchy-label <name>", "HIERARCHY label column name")
1161
+ .option("--hierarchy-info <id:name>", "HIERARCHY info column; id is a unique opaque column id you choose (e.g. col_1779975207355_zfndknslw), not the name, and is reused as columnId in mappings (repeatable)", (v, p: string[] = []) => [...p, v], [])
1162
+ .option("--hierarchy-number <id:name:aggregation>", "HIERARCHY number column; id is a unique opaque column id (e.g. col_1779975207355_zfndknslw), not the name; aggregation SUM|AVERAGE|MINIMUM|MAXIMUM|COUNT|COUNT_DISTINCT (repeatable)", (v, p: string[] = []) => [...p, v], [])
1163
+ .option("--hierarchy-root-name <name>", "HIERARCHY root level name")
1164
+ .option("--hierarchy-root-label-field <uuid>", "HIERARCHY root level label field (UUID, not slug)")
1165
+ .option("--hierarchy-root-info <columnId:fieldId>", "HIERARCHY root info mapping; columnId must match a --hierarchy-info id (repeatable)", (v, p: string[] = []) => [...p, v], [])
1166
+ .option("--hierarchy-root-number <columnId:fieldId>", "HIERARCHY root number mapping; columnId must match a --hierarchy-number id (repeatable)", (v, p: string[] = []) => [...p, v], [])
1167
+ .option("--hierarchy-child <json>", "HIERARCHY child level JSON {name,schemaId,childFieldId,labelFieldId,infoMappings,numberMappings} (repeatable)", (v, p: string[] = []) => [...p, v], [])
1168
+ .option("--hierarchy-config <json>", "HIERARCHY full hierarchyConfig JSON (escape hatch)")
1169
+ .option("--hierarchy-config-file <path>", "read --hierarchy-config JSON from a file")
1170
+ .option("--raw <json>").option("--raw-file <path>")
889
1171
  .action(async function action(this: Command, display: NormalizedUuidOrSlug, widgetId: string, commandOptions: WidgetMutationOptions) {
890
1172
  const { api, json, readOnly } = createDisplaysApi(this, filePath); ensureWritable("displays widgets update", readOnly, "write");
891
1173
  const data = await api.updateWidget(display, widgetId, buildWidgetBody(commandOptions));
@@ -200,6 +200,25 @@ describe("getCommandExamples", () => {
200
200
  }
201
201
  });
202
202
 
203
+ it("does not teach legacy system columns in display widget examples", () => {
204
+ const legacyColumns = ["id", "createdat", "updatedat", "createdby", "updatedby"];
205
+ const commands = ["displays widgets create", "displays widgets update", "displays list", "displays get"];
206
+
207
+ for (const command of commands) {
208
+ for (const example of getCommandExamples(command)) {
209
+ for (const column of legacyColumns) {
210
+ expect(example.command).not.toContain(`--field ${column}`);
211
+ }
212
+
213
+ const examplePayloads = [example.raw, example.response].filter(Boolean);
214
+ for (const payload of examplePayloads) {
215
+ expect(payload).not.toMatch(/"fields"\s*:\s*\[[^\]]*"(id|createdat|updatedat|createdby|updatedby)"/);
216
+ expect(payload).not.toMatch(/"fieldCalc"\s*:\s*"(id|createdat|updatedat|createdby|updatedby)"/);
217
+ }
218
+ }
219
+ }
220
+ });
221
+
203
222
  it("covers REFERENCE, MEMBER and DATE variants in displays variables create", () => {
204
223
  const examples = getCommandExamples("displays variables create");
205
224
  const rawPayloads = examples.map((example) => example.raw ?? "");