zinkee 0.1.38 → 0.1.39

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.38",
3
+ "version": "0.1.39",
4
4
  "description": "CLI for Zinkee API v2",
5
5
  "type": "module",
6
6
  "bin": {
@@ -489,6 +489,147 @@ describe("displays commands", () => {
489
489
  expect(fetchMock).not.toHaveBeenCalled();
490
490
  });
491
491
 
492
+ it("translates --lock-* flags into behavior.lockedFields", async () => {
493
+ const configPath = createApiConfig();
494
+ const output = createStdoutBuffer();
495
+ const fetchMock = createFetchMock();
496
+ fetchMock.mockResolvedValueOnce(jsonResponse({ displayId: "display-1", widgetId: "widget-l1" }));
497
+ const program = buildProgram({ configPath, stdout: output.stdout });
498
+
499
+ await program.parseAsync([
500
+ "node",
501
+ "zinkee",
502
+ "--json",
503
+ "displays",
504
+ "widgets",
505
+ "create",
506
+ "display-1",
507
+ "--type",
508
+ "table",
509
+ "--origin-resource",
510
+ "schema-1",
511
+ "--lock-field",
512
+ "contact-name",
513
+ "--lock-condition",
514
+ "status:invoice-number:is_empty",
515
+ "--lock-condition",
516
+ "status:amount:gte:500",
517
+ "--lock-message-before",
518
+ "status:Status locks until the invoice number is set.",
519
+ "--lock-message-after",
520
+ "status:Status is locked because the invoice number is missing.",
521
+ ]);
522
+
523
+ const init = fetchMock.mock.calls[0]?.[1];
524
+ expect(JSON.parse(String(init?.body))).toMatchObject({
525
+ type: "TABLE_OR_SUBTABLE",
526
+ behavior: {
527
+ lockedFields: [
528
+ {
529
+ field: { fieldId: "contact-name" },
530
+ conditions: [],
531
+ },
532
+ {
533
+ field: { fieldId: "status" },
534
+ conditions: [
535
+ {
536
+ origin: { fieldId: "invoice-number" },
537
+ comparisonOperator: "IS_EMPTY",
538
+ values: [],
539
+ },
540
+ {
541
+ origin: { fieldId: "amount" },
542
+ comparisonOperator: "GREATER_OR_EQUAL",
543
+ values: ["500"],
544
+ },
545
+ ],
546
+ messageBeforeLock: "Status locks until the invoice number is set.",
547
+ messageAfterLock: "Status is locked because the invoice number is missing.",
548
+ },
549
+ ],
550
+ },
551
+ });
552
+ });
553
+
554
+ it("clears locked fields with --clear-locked-fields", async () => {
555
+ const configPath = createApiConfig();
556
+ const output = createStdoutBuffer();
557
+ const fetchMock = createFetchMock();
558
+ fetchMock.mockResolvedValueOnce(jsonResponse({ displayId: "display-1", widgetId: "widget-l2" }));
559
+ const program = buildProgram({ configPath, stdout: output.stdout });
560
+
561
+ await program.parseAsync([
562
+ "node",
563
+ "zinkee",
564
+ "--json",
565
+ "displays",
566
+ "widgets",
567
+ "update",
568
+ "display-1",
569
+ "widget-l2",
570
+ "--clear-locked-fields",
571
+ ]);
572
+
573
+ const init = fetchMock.mock.calls[0]?.[1];
574
+ expect(init?.method).toBe("PATCH");
575
+ expect(JSON.parse(String(init?.body))).toEqual({
576
+ behavior: { lockedFields: [] },
577
+ });
578
+ });
579
+
580
+ it("rejects --clear-locked-fields combined with --lock-* flags", async () => {
581
+ const configPath = createApiConfig();
582
+ const output = createStdoutBuffer();
583
+ const fetchMock = createFetchMock();
584
+ const program = buildProgram({ configPath, stdout: output.stdout });
585
+
586
+ await expect(
587
+ program.parseAsync([
588
+ "node",
589
+ "zinkee",
590
+ "--json",
591
+ "displays",
592
+ "widgets",
593
+ "update",
594
+ "display-1",
595
+ "widget-l3",
596
+ "--clear-locked-fields",
597
+ "--lock-field",
598
+ "status",
599
+ ]),
600
+ ).rejects.toThrow(/not both/i);
601
+ expect(fetchMock).not.toHaveBeenCalled();
602
+ });
603
+
604
+ it("rejects a --lock-condition without an expression", async () => {
605
+ const configPath = createApiConfig();
606
+ const output = createStdoutBuffer();
607
+ const fetchMock = createFetchMock();
608
+ const errorSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
609
+ const program = buildProgram({ configPath, stdout: output.stdout });
610
+
611
+ // Option arg parser errors are handled by commander, which prints the
612
+ // InvalidArgumentError message to stderr and exits with code 1 (same
613
+ // behavior as an invalid --filter expression).
614
+ await expect(
615
+ program.parseAsync([
616
+ "node",
617
+ "zinkee",
618
+ "--json",
619
+ "displays",
620
+ "widgets",
621
+ "create",
622
+ "display-1",
623
+ "--type",
624
+ "table",
625
+ "--lock-condition",
626
+ "status",
627
+ ]),
628
+ ).rejects.toThrow(/process.exit/);
629
+ expect(errorSpy.mock.calls.flat().join("\n")).toMatch(/lockedField/i);
630
+ expect(fetchMock).not.toHaveBeenCalled();
631
+ });
632
+
492
633
  it("translates --filter into the structured display filter contract", async () => {
493
634
  const configPath = createApiConfig();
494
635
  const output = createStdoutBuffer();
@@ -98,6 +98,11 @@ interface WidgetMutationOptions {
98
98
  hideSortButton?: boolean;
99
99
  hideFilterButton?: boolean;
100
100
  hideSeeButton?: boolean;
101
+ lockField?: string[];
102
+ lockCondition?: LockConditionOption[];
103
+ lockMessageBefore?: LockMessageOption[];
104
+ lockMessageAfter?: LockMessageOption[];
105
+ clearLockedFields?: boolean;
101
106
  calc?: string;
102
107
  calcField?: string;
103
108
  kpiConfig?: string;
@@ -141,6 +146,16 @@ interface WidgetMutationOptions {
141
146
  rawFile?: string;
142
147
  }
143
148
 
149
+ interface LockConditionOption {
150
+ lockedField: string;
151
+ expression: ParsedExpression;
152
+ }
153
+
154
+ interface LockMessageOption {
155
+ lockedField: string;
156
+ message: string;
157
+ }
158
+
144
159
  interface NavigationTargetMutationOptions {
145
160
  sourceWidget?: string;
146
161
  targetDisplay?: string;
@@ -275,6 +290,115 @@ function collectFilters(value: string, previous: ParsedExpression[] = []): Parse
275
290
  return [...previous, parseFilterExpression(value)];
276
291
  }
277
292
 
293
+ function parseLockConditionOption(input: string): LockConditionOption {
294
+ const trimmed = input.trim();
295
+ let lockedField: string;
296
+ let expressionText: string;
297
+
298
+ if (trimmed.includes(":") && !trimmed.includes(" ")) {
299
+ const separatorIndex = trimmed.indexOf(":");
300
+ lockedField = trimmed.slice(0, separatorIndex).trim();
301
+ expressionText = trimmed.slice(separatorIndex + 1).trim();
302
+ } else {
303
+ const parts = trimmed.split(/\s+/);
304
+ lockedField = parts[0]?.trim() ?? "";
305
+ expressionText = parts.slice(1).join(" ").trim();
306
+ }
307
+
308
+ if (!lockedField || !expressionText) {
309
+ throw new InvalidArgumentError(
310
+ 'Expected "<lockedField>:<field>:<comparator>[:<value>]" (or space-separated), e.g. "status:invoice-number:is_empty".',
311
+ );
312
+ }
313
+
314
+ return { lockedField, expression: parseFilterExpression(expressionText) };
315
+ }
316
+
317
+ function collectLockConditions(value: string, previous: LockConditionOption[] = []): LockConditionOption[] {
318
+ return [...previous, parseLockConditionOption(value)];
319
+ }
320
+
321
+ function parseLockMessageOption(input: string): LockMessageOption {
322
+ const separatorIndex = input.indexOf(":");
323
+ const lockedField = separatorIndex > 0 ? input.slice(0, separatorIndex).trim() : "";
324
+ const message = separatorIndex > 0 ? input.slice(separatorIndex + 1).trim() : "";
325
+
326
+ if (!lockedField || !message) {
327
+ throw new InvalidArgumentError('Expected "<lockedField>:<message>", e.g. "status:Locked once approved.".');
328
+ }
329
+
330
+ return { lockedField, message };
331
+ }
332
+
333
+ function collectLockMessages(value: string, previous: LockMessageOption[] = []): LockMessageOption[] {
334
+ return [...previous, parseLockMessageOption(value)];
335
+ }
336
+
337
+ /**
338
+ * Builds behavior.lockedFields from the --lock-* flags. Every flag names its
339
+ * locked field explicitly, so ordering between flags never matters. A field
340
+ * with no conditions is locked permanently; with conditions it locks when all
341
+ * of them match (AND). Returns [] for --clear-locked-fields and undefined when
342
+ * no lock flag was used.
343
+ */
344
+ function buildLockedFields(options: WidgetMutationOptions): Array<Record<string, unknown>> | undefined {
345
+ const hasLockFlags =
346
+ (options.lockField?.length ?? 0) > 0 ||
347
+ (options.lockCondition?.length ?? 0) > 0 ||
348
+ (options.lockMessageBefore?.length ?? 0) > 0 ||
349
+ (options.lockMessageAfter?.length ?? 0) > 0;
350
+
351
+ if (options.clearLockedFields) {
352
+ if (hasLockFlags) {
353
+ throw new CliError(
354
+ "invalid_cli_usage",
355
+ 'Use either "--clear-locked-fields" or the "--lock-*" flags, not both.',
356
+ );
357
+ }
358
+ return [];
359
+ }
360
+
361
+ if (!hasLockFlags) {
362
+ return undefined;
363
+ }
364
+
365
+ interface LockRule {
366
+ conditions: Array<Record<string, unknown>>;
367
+ messageBeforeLock?: string;
368
+ messageAfterLock?: string;
369
+ }
370
+
371
+ const rules = new Map<string, LockRule>();
372
+ const ensureRule = (lockedField: string): LockRule => {
373
+ let rule = rules.get(lockedField);
374
+ if (!rule) {
375
+ rule = { conditions: [] };
376
+ rules.set(lockedField, rule);
377
+ }
378
+ return rule;
379
+ };
380
+
381
+ for (const lockedField of options.lockField ?? []) {
382
+ ensureRule(lockedField);
383
+ }
384
+ for (const { lockedField, expression } of options.lockCondition ?? []) {
385
+ ensureRule(lockedField).conditions.push(toDisplayFilter(expression));
386
+ }
387
+ for (const { lockedField, message } of options.lockMessageBefore ?? []) {
388
+ ensureRule(lockedField).messageBeforeLock = message;
389
+ }
390
+ for (const { lockedField, message } of options.lockMessageAfter ?? []) {
391
+ ensureRule(lockedField).messageAfterLock = message;
392
+ }
393
+
394
+ return [...rules.entries()].map(([lockedField, rule]) => ({
395
+ field: { fieldId: lockedField },
396
+ conditions: rule.conditions,
397
+ ...(rule.messageBeforeLock !== undefined ? { messageBeforeLock: rule.messageBeforeLock } : {}),
398
+ ...(rule.messageAfterLock !== undefined ? { messageAfterLock: rule.messageAfterLock } : {}),
399
+ }));
400
+ }
401
+
278
402
  function parseBooleanFlag(input: string): boolean {
279
403
  const normalized = input.trim().toLowerCase();
280
404
  if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
@@ -697,6 +821,14 @@ function buildWidgetBody(options: WidgetMutationOptions): Record<string, unknown
697
821
  };
698
822
  }
699
823
 
824
+ const lockedFields = buildLockedFields(options);
825
+ if (lockedFields !== undefined) {
826
+ body.behavior = {
827
+ ...(typeof body.behavior === "object" && body.behavior ? body.behavior as Record<string, unknown> : {}),
828
+ lockedFields,
829
+ };
830
+ }
831
+
700
832
  if (options.calc || options.calcField || options.kpiConfig) {
701
833
  body.kpi = {
702
834
  ...(typeof body.kpi === "object" && body.kpi ? body.kpi as Record<string, unknown> : {}),
@@ -1131,6 +1263,11 @@ export function registerDisplaysCommands(
1131
1263
  .option("--sort <sort>", "repeatable sort", (v, p: string[] = []) => [...p, v], [])
1132
1264
  .option("--filter <expr>", "repeatable filter", collectFilters, [])
1133
1265
  .option("--widget-read-only").option("--hide-add-button").option("--hide-delete-button").option("--hide-sort-button").option("--hide-filter-button").option("--hide-see-button")
1266
+ .option("--lock-field <field>", "lock a field permanently in this widget (repeatable; field UUID or slug)", (v, p: string[] = []) => [...p, v], [])
1267
+ .option("--lock-condition <expr>", 'conditional lock "<lockedField>:<field>:<comparator>[:<value>]"; locks <lockedField> when all its conditions match (AND, repeatable)', collectLockConditions, [])
1268
+ .option("--lock-message-before <field:text>", "message shown while a conditional lock is not met yet (repeatable)", collectLockMessages, [])
1269
+ .option("--lock-message-after <field:text>", "message shown once the field is locked (repeatable)", collectLockMessages, [])
1270
+ .option("--clear-locked-fields", "clear the widget locked-fields configuration")
1134
1271
  .option("--calc <type>").option("--calc-field <uuid>").option("--kpi-config <json>")
1135
1272
  .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")
1136
1273
  .option("--y-axis-serie <json>", "repeatable y axis serie", (v, p: string[] = []) => [...p, v], [])
@@ -1178,6 +1315,11 @@ export function registerDisplaysCommands(
1178
1315
  .option("--sort <sort>", "repeatable sort", (v, p: string[] = []) => [...p, v], [])
1179
1316
  .option("--filter <expr>", "repeatable filter", collectFilters, [])
1180
1317
  .option("--widget-read-only").option("--hide-add-button").option("--hide-delete-button").option("--hide-sort-button").option("--hide-filter-button").option("--hide-see-button")
1318
+ .option("--lock-field <field>", "lock a field permanently in this widget (repeatable; field UUID or slug)", (v, p: string[] = []) => [...p, v], [])
1319
+ .option("--lock-condition <expr>", 'conditional lock "<lockedField>:<field>:<comparator>[:<value>]"; locks <lockedField> when all its conditions match (AND, repeatable)', collectLockConditions, [])
1320
+ .option("--lock-message-before <field:text>", "message shown while a conditional lock is not met yet (repeatable)", collectLockMessages, [])
1321
+ .option("--lock-message-after <field:text>", "message shown once the field is locked (repeatable)", collectLockMessages, [])
1322
+ .option("--clear-locked-fields", "clear the widget locked-fields configuration")
1181
1323
  .option("--calc <type>").option("--calc-field <uuid>").option("--kpi-config <json>")
1182
1324
  .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")
1183
1325
  .option("--y-axis-serie <json>", "repeatable y axis serie", (v, p: string[] = []) => [...p, v], [])
@@ -1458,6 +1458,50 @@ const specificExamples: Record<string, readonly CommandExample[]> = {
1458
1458
  response: `{
1459
1459
  "displayId": "0f6f062f-1478-4df2-96a1-c495d037d260",
1460
1460
  "widgetId": "11111111-1111-4111-a111-111111111111"
1461
+ }`,
1462
+ },
1463
+ {
1464
+ description:
1465
+ "Create a TABLE widget with locked fields: contact-name locked permanently (no conditions) and status locked while invoice-number is empty, with before/after lock messages.",
1466
+ command:
1467
+ `zinkee --json displays widgets create 0f6f062f-1478-4df2-96a1-c495d037d260 --type table --name Invoices --origin-resource 2eb8bdcf-6d9c-4fbb-9018-7fa8180f4c5e --layout '{"x":0,"y":0,"w":12,"h":8}' --field contact-name --field invoice-number --field status --field amount --lock-field contact-name --lock-condition "status:invoice-number:is_empty" --lock-message-before "status:Status locks until the invoice number is set." --lock-message-after "status:Status is locked because the invoice number is missing."`,
1468
+ raw: `{
1469
+ "type": "TABLE_OR_SUBTABLE",
1470
+ "source": {
1471
+ "originResourceId": "2eb8bdcf-6d9c-4fbb-9018-7fa8180f4c5e",
1472
+ "isOriginSubSchema": false
1473
+ },
1474
+ "placement": {
1475
+ "layout": { "x": 0, "y": 0, "w": 12, "h": 8 }
1476
+ },
1477
+ "presentation": { "name": "Invoices" },
1478
+ "view": {
1479
+ "fields": ["contact-name", "invoice-number", "status", "amount"]
1480
+ },
1481
+ "behavior": {
1482
+ "lockedFields": [
1483
+ {
1484
+ "field": { "fieldId": "contact-name" },
1485
+ "conditions": []
1486
+ },
1487
+ {
1488
+ "field": { "fieldId": "status" },
1489
+ "conditions": [
1490
+ {
1491
+ "origin": { "fieldId": "invoice-number" },
1492
+ "comparisonOperator": "IS_EMPTY",
1493
+ "values": []
1494
+ }
1495
+ ],
1496
+ "messageBeforeLock": "Status locks until the invoice number is set.",
1497
+ "messageAfterLock": "Status is locked because the invoice number is missing."
1498
+ }
1499
+ ]
1500
+ }
1501
+ }`,
1502
+ response: `{
1503
+ "displayId": "0f6f062f-1478-4df2-96a1-c495d037d260",
1504
+ "widgetId": "11111111-1111-4111-a111-111111111111"
1461
1505
  }`,
1462
1506
  },
1463
1507
  {
@@ -1858,6 +1902,46 @@ const specificExamples: Record<string, readonly CommandExample[]> = {
1858
1902
  "hideDeleteButton": true,
1859
1903
  "hideFilterButton": true
1860
1904
  }
1905
+ }`,
1906
+ },
1907
+ {
1908
+ description:
1909
+ "Configure locked fields (replaces the stored configuration): contact-name locked permanently, status locked while invoice-number is empty. Empty conditions = always locked; conditions combine with AND. Enforcement happens in display widgets only, never in the database view.",
1910
+ command:
1911
+ `zinkee --json displays widgets update 0f6f062f-1478-4df2-96a1-c495d037d260 11111111-1111-4111-a111-111111111111 --lock-field contact-name --lock-condition "status:invoice-number:is_empty" --lock-message-before "status:Status locks until the invoice number is set." --lock-message-after "status:Status is locked because the invoice number is missing."`,
1912
+ raw: `{
1913
+ "behavior": {
1914
+ "lockedFields": [
1915
+ {
1916
+ "field": { "fieldId": "contact-name" },
1917
+ "conditions": []
1918
+ },
1919
+ {
1920
+ "field": { "fieldId": "status" },
1921
+ "conditions": [
1922
+ {
1923
+ "origin": { "fieldId": "invoice-number" },
1924
+ "comparisonOperator": "IS_EMPTY",
1925
+ "values": []
1926
+ }
1927
+ ],
1928
+ "messageBeforeLock": "Status locks until the invoice number is set.",
1929
+ "messageAfterLock": "Status is locked because the invoice number is missing."
1930
+ }
1931
+ ]
1932
+ }
1933
+ }`,
1934
+ response: `{
1935
+ "displayId": "0f6f062f-1478-4df2-96a1-c495d037d260",
1936
+ "widgetId": "11111111-1111-4111-a111-111111111111"
1937
+ }`,
1938
+ },
1939
+ {
1940
+ description: "Clear the locked-fields configuration.",
1941
+ command:
1942
+ 'zinkee --json displays widgets update 0f6f062f-1478-4df2-96a1-c495d037d260 11111111-1111-4111-a111-111111111111 --clear-locked-fields',
1943
+ raw: `{
1944
+ "behavior": { "lockedFields": [] }
1861
1945
  }`,
1862
1946
  },
1863
1947
  {