zinkee 0.1.38 → 0.1.40
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/dist/index.js +213 -13
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/api/automations.ts +1 -0
- package/src/api/displays.ts +1 -0
- package/src/api/schemas.ts +1 -0
- package/src/commands/automations.ts +3 -3
- package/src/commands/displays.test.ts +141 -0
- package/src/commands/displays.ts +149 -2
- package/src/commands/schemas.ts +3 -2
- package/src/utils/examples.test.ts +2 -0
- package/src/utils/examples.ts +108 -4
- package/src/utils/output.test.ts +27 -1
- package/src/utils/output.ts +30 -0
package/package.json
CHANGED
package/src/api/automations.ts
CHANGED
package/src/api/displays.ts
CHANGED
|
@@ -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 {
|
package/src/api/schemas.ts
CHANGED
|
@@ -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;
|
|
@@ -800,12 +800,12 @@ function automationRows(automations: readonly ApiAutomation[]): Record<string, u
|
|
|
800
800
|
id: automation.id ?? "",
|
|
801
801
|
name: automation.name ?? "",
|
|
802
802
|
status: automation.status ?? "",
|
|
803
|
-
folderId: automation.folderId ?? "",
|
|
804
803
|
triggerType:
|
|
805
804
|
isJsonObject(automation.trigger) && typeof automation.trigger.type === "string"
|
|
806
805
|
? automation.trigger.type
|
|
807
806
|
: "",
|
|
808
807
|
actionCount: Array.isArray(automation.actions) ? automation.actions.length : "",
|
|
808
|
+
...folderColumns(automation.folder),
|
|
809
809
|
}));
|
|
810
810
|
}
|
|
811
811
|
|
|
@@ -1569,7 +1569,7 @@ export function registerAutomationsCommands(
|
|
|
1569
1569
|
|
|
1570
1570
|
printTable(
|
|
1571
1571
|
automationRows(data),
|
|
1572
|
-
["id", "name", "status", "
|
|
1572
|
+
["id", "name", "status", "triggerType", "actionCount", ...FOLDER_TABLE_KEYS],
|
|
1573
1573
|
stdout,
|
|
1574
1574
|
);
|
|
1575
1575
|
});
|
|
@@ -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();
|
package/src/commands/displays.ts
CHANGED
|
@@ -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";
|
|
@@ -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;
|
|
@@ -504,6 +628,7 @@ function displayRows(displays: readonly DisplaySummary[]): Record<string, unknow
|
|
|
504
628
|
layoutTemplate: display.layoutTemplate ?? "",
|
|
505
629
|
variableCount: display.variableCount ?? "",
|
|
506
630
|
widgetCount: display.widgetCount ?? "",
|
|
631
|
+
...folderColumns(display.folder),
|
|
507
632
|
}));
|
|
508
633
|
}
|
|
509
634
|
|
|
@@ -697,6 +822,14 @@ function buildWidgetBody(options: WidgetMutationOptions): Record<string, unknown
|
|
|
697
822
|
};
|
|
698
823
|
}
|
|
699
824
|
|
|
825
|
+
const lockedFields = buildLockedFields(options);
|
|
826
|
+
if (lockedFields !== undefined) {
|
|
827
|
+
body.behavior = {
|
|
828
|
+
...(typeof body.behavior === "object" && body.behavior ? body.behavior as Record<string, unknown> : {}),
|
|
829
|
+
lockedFields,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
700
833
|
if (options.calc || options.calcField || options.kpiConfig) {
|
|
701
834
|
body.kpi = {
|
|
702
835
|
...(typeof body.kpi === "object" && body.kpi ? body.kpi as Record<string, unknown> : {}),
|
|
@@ -821,7 +954,11 @@ export function registerDisplaysCommands(
|
|
|
821
954
|
emitJson(stdout, "displays list", data);
|
|
822
955
|
return;
|
|
823
956
|
}
|
|
824
|
-
printTable(
|
|
957
|
+
printTable(
|
|
958
|
+
displayRows(data),
|
|
959
|
+
["id", "name", "layoutTemplate", "variableCount", "widgetCount", ...FOLDER_TABLE_KEYS],
|
|
960
|
+
stdout,
|
|
961
|
+
);
|
|
825
962
|
});
|
|
826
963
|
|
|
827
964
|
displays
|
|
@@ -1131,6 +1268,11 @@ export function registerDisplaysCommands(
|
|
|
1131
1268
|
.option("--sort <sort>", "repeatable sort", (v, p: string[] = []) => [...p, v], [])
|
|
1132
1269
|
.option("--filter <expr>", "repeatable filter", collectFilters, [])
|
|
1133
1270
|
.option("--widget-read-only").option("--hide-add-button").option("--hide-delete-button").option("--hide-sort-button").option("--hide-filter-button").option("--hide-see-button")
|
|
1271
|
+
.option("--lock-field <field>", "lock a field permanently in this widget (repeatable; field UUID or slug)", (v, p: string[] = []) => [...p, v], [])
|
|
1272
|
+
.option("--lock-condition <expr>", 'conditional lock "<lockedField>:<field>:<comparator>[:<value>]"; locks <lockedField> when all its conditions match (AND, repeatable)', collectLockConditions, [])
|
|
1273
|
+
.option("--lock-message-before <field:text>", "message shown while a conditional lock is not met yet (repeatable)", collectLockMessages, [])
|
|
1274
|
+
.option("--lock-message-after <field:text>", "message shown once the field is locked (repeatable)", collectLockMessages, [])
|
|
1275
|
+
.option("--clear-locked-fields", "clear the widget locked-fields configuration")
|
|
1134
1276
|
.option("--calc <type>").option("--calc-field <uuid>").option("--kpi-config <json>")
|
|
1135
1277
|
.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
1278
|
.option("--y-axis-serie <json>", "repeatable y axis serie", (v, p: string[] = []) => [...p, v], [])
|
|
@@ -1178,6 +1320,11 @@ export function registerDisplaysCommands(
|
|
|
1178
1320
|
.option("--sort <sort>", "repeatable sort", (v, p: string[] = []) => [...p, v], [])
|
|
1179
1321
|
.option("--filter <expr>", "repeatable filter", collectFilters, [])
|
|
1180
1322
|
.option("--widget-read-only").option("--hide-add-button").option("--hide-delete-button").option("--hide-sort-button").option("--hide-filter-button").option("--hide-see-button")
|
|
1323
|
+
.option("--lock-field <field>", "lock a field permanently in this widget (repeatable; field UUID or slug)", (v, p: string[] = []) => [...p, v], [])
|
|
1324
|
+
.option("--lock-condition <expr>", 'conditional lock "<lockedField>:<field>:<comparator>[:<value>]"; locks <lockedField> when all its conditions match (AND, repeatable)', collectLockConditions, [])
|
|
1325
|
+
.option("--lock-message-before <field:text>", "message shown while a conditional lock is not met yet (repeatable)", collectLockMessages, [])
|
|
1326
|
+
.option("--lock-message-after <field:text>", "message shown once the field is locked (repeatable)", collectLockMessages, [])
|
|
1327
|
+
.option("--clear-locked-fields", "clear the widget locked-fields configuration")
|
|
1181
1328
|
.option("--calc <type>").option("--calc-field <uuid>").option("--kpi-config <json>")
|
|
1182
1329
|
.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
1330
|
.option("--y-axis-serie <json>", "repeatable y axis serie", (v, p: string[] = []) => [...p, v], [])
|
package/src/commands/schemas.ts
CHANGED
|
@@ -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", () => {
|
package/src/utils/examples.ts
CHANGED
|
@@ -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
|
{
|
|
@@ -2825,21 +2909,36 @@ const specificExamples: Record<string, readonly CommandExample[]> = {
|
|
|
2825
2909
|
"name": "Executive Revenue",
|
|
2826
2910
|
"layoutTemplate": "widget_row_tabs",
|
|
2827
2911
|
"variableCount": 3,
|
|
2828
|
-
"widgetCount": 6
|
|
2912
|
+
"widgetCount": 6,
|
|
2913
|
+
"folder": {
|
|
2914
|
+
"id": "b1a2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
|
2915
|
+
"name": "📊 Executive",
|
|
2916
|
+
"isRoot": false
|
|
2917
|
+
}
|
|
2829
2918
|
},
|
|
2830
2919
|
{
|
|
2831
2920
|
"id": "6b7c8d9e-0f12-3456-789a-bcdef0123456",
|
|
2832
2921
|
"name": "Sales Pipeline",
|
|
2833
2922
|
"layoutTemplate": "freeform",
|
|
2834
2923
|
"variableCount": 1,
|
|
2835
|
-
"widgetCount": 3
|
|
2924
|
+
"widgetCount": 3,
|
|
2925
|
+
"folder": {
|
|
2926
|
+
"id": "a0b1c2d3-e4f5-4061-8273-8495a6b7c8d9",
|
|
2927
|
+
"name": null,
|
|
2928
|
+
"isRoot": true
|
|
2929
|
+
}
|
|
2836
2930
|
},
|
|
2837
2931
|
{
|
|
2838
2932
|
"id": "ef012345-6789-4abc-8def-0123456789ab",
|
|
2839
2933
|
"name": "Customer Detail",
|
|
2840
2934
|
"layoutTemplate": "grid",
|
|
2841
2935
|
"variableCount": 0,
|
|
2842
|
-
"widgetCount": 2
|
|
2936
|
+
"widgetCount": 2,
|
|
2937
|
+
"folder": {
|
|
2938
|
+
"id": "b1a2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
|
2939
|
+
"name": "📊 Executive",
|
|
2940
|
+
"isRoot": false
|
|
2941
|
+
}
|
|
2843
2942
|
}
|
|
2844
2943
|
]`,
|
|
2845
2944
|
},
|
|
@@ -2972,7 +3071,12 @@ const specificExamples: Record<string, readonly CommandExample[]> = {
|
|
|
2972
3071
|
"targetVariableId": "aaaaaaaa-1111-4111-a111-111111111111",
|
|
2973
3072
|
"label": "Open customer"
|
|
2974
3073
|
}
|
|
2975
|
-
]
|
|
3074
|
+
],
|
|
3075
|
+
"folder": {
|
|
3076
|
+
"id": "b1a2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
|
|
3077
|
+
"name": "📊 Executive",
|
|
3078
|
+
"isRoot": false
|
|
3079
|
+
}
|
|
2976
3080
|
}`,
|
|
2977
3081
|
},
|
|
2978
3082
|
]),
|
package/src/utils/output.test.ts
CHANGED
|
@@ -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", () => {
|