zinkee 0.1.18 → 0.1.20

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.18",
3
+ "version": "0.1.20",
4
4
  "description": "CLI for Zinkee API v2",
5
5
  "type": "module",
6
6
  "bin": {
@@ -252,6 +252,229 @@ describe("displays commands", () => {
252
252
  });
253
253
  });
254
254
 
255
+ it("translates --filter into the structured display filter contract", async () => {
256
+ const configPath = createApiConfig();
257
+ const output = createStdoutBuffer();
258
+ const fetchMock = createFetchMock();
259
+ fetchMock.mockResolvedValueOnce(jsonResponse({ displayId: "display-1", widgetId: "widget-f1" }));
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
+ "table",
272
+ "--origin-resource",
273
+ "schema-1",
274
+ "--filter",
275
+ "edad:gt:30",
276
+ ]);
277
+
278
+ const init = fetchMock.mock.calls[0]?.[1];
279
+ expect(JSON.parse(String(init?.body))).toMatchObject({
280
+ type: "TABLE_OR_SUBTABLE",
281
+ query: {
282
+ filters: [
283
+ {
284
+ origin: { fieldId: "edad" },
285
+ comparisonOperator: "GREATER",
286
+ values: ["30"],
287
+ },
288
+ ],
289
+ },
290
+ });
291
+ });
292
+
293
+ it("maps the `in` comparator to ANY_OF with a list of values", async () => {
294
+ const configPath = createApiConfig();
295
+ const fetchMock = createFetchMock();
296
+ fetchMock.mockResolvedValueOnce(jsonResponse({ widgetId: "widget-f2" }));
297
+ const program = buildProgram({ configPath });
298
+
299
+ await program.parseAsync([
300
+ "node",
301
+ "zinkee",
302
+ "--json",
303
+ "displays",
304
+ "widgets",
305
+ "create",
306
+ "display-1",
307
+ "--type",
308
+ "table",
309
+ "--filter",
310
+ "status:in:1,2,3",
311
+ ]);
312
+
313
+ const init = fetchMock.mock.calls[0]?.[1];
314
+ expect(JSON.parse(String(init?.body)).query.filters).toEqual([
315
+ {
316
+ origin: { fieldId: "status" },
317
+ comparisonOperator: "ANY_OF",
318
+ values: ["1", "2", "3"],
319
+ },
320
+ ]);
321
+ });
322
+
323
+ it("maps the `is_not_empty` comparator with empty values array", async () => {
324
+ const configPath = createApiConfig();
325
+ const fetchMock = createFetchMock();
326
+ fetchMock.mockResolvedValueOnce(jsonResponse({ widgetId: "widget-f3" }));
327
+ const program = buildProgram({ configPath });
328
+
329
+ await program.parseAsync([
330
+ "node",
331
+ "zinkee",
332
+ "--json",
333
+ "displays",
334
+ "widgets",
335
+ "create",
336
+ "display-1",
337
+ "--type",
338
+ "table",
339
+ "--filter",
340
+ "email:is_not_empty",
341
+ ]);
342
+
343
+ const init = fetchMock.mock.calls[0]?.[1];
344
+ expect(JSON.parse(String(init?.body)).query.filters).toEqual([
345
+ {
346
+ origin: { fieldId: "email" },
347
+ comparisonOperator: "IS_NOT_EMPTY",
348
+ values: [],
349
+ },
350
+ ]);
351
+ });
352
+
353
+ it("accumulates multiple --filter flags in order", async () => {
354
+ const configPath = createApiConfig();
355
+ const fetchMock = createFetchMock();
356
+ fetchMock.mockResolvedValueOnce(jsonResponse({ widgetId: "widget-f4" }));
357
+ const program = buildProgram({ configPath });
358
+
359
+ await program.parseAsync([
360
+ "node",
361
+ "zinkee",
362
+ "--json",
363
+ "displays",
364
+ "widgets",
365
+ "create",
366
+ "display-1",
367
+ "--type",
368
+ "table",
369
+ "--filter",
370
+ "status:eq:2",
371
+ "--filter",
372
+ "amount:gte:500",
373
+ ]);
374
+
375
+ const init = fetchMock.mock.calls[0]?.[1];
376
+ expect(JSON.parse(String(init?.body)).query.filters).toEqual([
377
+ { origin: { fieldId: "status" }, comparisonOperator: "EQUAL", values: ["2"] },
378
+ { origin: { fieldId: "amount" }, comparisonOperator: "GREATER_OR_EQUAL", values: ["500"] },
379
+ ]);
380
+ });
381
+
382
+ it("rejects --filter with an unsupported comparator before issuing a request", async () => {
383
+ const configPath = createApiConfig();
384
+ const fetchMock = createFetchMock();
385
+ const program = buildProgram({ configPath });
386
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
387
+
388
+ await expect(
389
+ program.parseAsync([
390
+ "node",
391
+ "zinkee",
392
+ "--json",
393
+ "displays",
394
+ "widgets",
395
+ "create",
396
+ "display-1",
397
+ "--type",
398
+ "table",
399
+ "--filter",
400
+ "edad:invalido:30",
401
+ ]),
402
+ ).rejects.toThrow();
403
+
404
+ expect(fetchMock).not.toHaveBeenCalled();
405
+ expect(stderrSpy.mock.calls.map(([chunk]) => String(chunk)).join("")).toMatch(/Unsupported comparator/);
406
+ stderrSpy.mockRestore();
407
+ });
408
+
409
+ it("preserves --raw query.filters when only --field is passed (no --filter)", async () => {
410
+ const configPath = createApiConfig();
411
+ const fetchMock = createFetchMock();
412
+ fetchMock.mockResolvedValueOnce(jsonResponse({ widgetId: "widget-f6" }));
413
+ const program = buildProgram({ configPath });
414
+
415
+ await program.parseAsync([
416
+ "node",
417
+ "zinkee",
418
+ "--json",
419
+ "displays",
420
+ "widgets",
421
+ "create",
422
+ "display-1",
423
+ "--type",
424
+ "table",
425
+ "--field",
426
+ "foo",
427
+ "--raw",
428
+ '{"query":{"filters":[{"origin":{"fieldId":"status"},"comparisonOperator":"EQUAL","values":["2"]}]}}',
429
+ ]);
430
+
431
+ const init = fetchMock.mock.calls[0]?.[1];
432
+ expect(JSON.parse(String(init?.body))).toMatchObject({
433
+ view: { fields: ["foo"] },
434
+ query: {
435
+ filters: [
436
+ { origin: { fieldId: "status" }, comparisonOperator: "EQUAL", values: ["2"] },
437
+ ],
438
+ },
439
+ });
440
+ });
441
+
442
+ it("translates --filter for variable mutations into the structured filter contract", async () => {
443
+ const configPath = createApiConfig();
444
+ const fetchMock = createFetchMock();
445
+ fetchMock.mockResolvedValueOnce(jsonResponse({ variableId: "var-1" }));
446
+ const program = buildProgram({ configPath });
447
+
448
+ await program.parseAsync([
449
+ "node",
450
+ "zinkee",
451
+ "--json",
452
+ "displays",
453
+ "variables",
454
+ "create",
455
+ "display-1",
456
+ "--type",
457
+ "reference",
458
+ "--name",
459
+ "Activos",
460
+ "--filter",
461
+ "estado:eq:activo",
462
+ ]);
463
+
464
+ const init = fetchMock.mock.calls[0]?.[1];
465
+ expect(JSON.parse(String(init?.body))).toMatchObject({
466
+ type: "reference",
467
+ name: "Activos",
468
+ filters: [
469
+ {
470
+ origin: { fieldId: "estado" },
471
+ comparisonOperator: "EQUAL",
472
+ values: ["activo"],
473
+ },
474
+ ],
475
+ });
476
+ });
477
+
255
478
  it("blocks destructive display commands in read-only mode", async () => {
256
479
  const configPath = createApiConfig();
257
480
  const fetchMock = createFetchMock();
@@ -6,6 +6,11 @@ import { Command, InvalidArgumentError } from "commander";
6
6
  import { DisplaysApi, type DisplayResource, type DisplaySummary } from "../api/displays.js";
7
7
  import { ApiClient } from "../client.js";
8
8
  import { defaultConfigPath, readRuntimeConfig } from "../config.js";
9
+ import {
10
+ parseExpression,
11
+ type ExpressionComparator,
12
+ type ParsedExpression,
13
+ } from "../parsers/expressions.js";
9
14
  import { normalizeUuidOrSlug, type NormalizedUuidOrSlug } from "../parsers/selectors.js";
10
15
  import { resolveRuntimeContext } from "../runtime-context.js";
11
16
  import type { RuntimeOverrides } from "../types.js";
@@ -54,7 +59,7 @@ interface VariableMutationOptions {
54
59
  defaultValue?: string[];
55
60
  sourceSchema?: string;
56
61
  sourceField?: string;
57
- filter?: string[];
62
+ filter?: ParsedExpression[];
58
63
  dateMode?: string;
59
64
  dateDynamicOption?: string;
60
65
  dateSpecific?: string;
@@ -78,7 +83,7 @@ interface WidgetMutationOptions {
78
83
  layoutFile?: string;
79
84
  field?: string[];
80
85
  sort?: string[];
81
- filter?: string[];
86
+ filter?: ParsedExpression[];
82
87
  widgetReadOnly?: boolean;
83
88
  hideAddButton?: boolean;
84
89
  hideDeleteButton?: boolean;
@@ -189,6 +194,46 @@ function parseUuidOrSlugSelector(input: string): NormalizedUuidOrSlug {
189
194
  }
190
195
  }
191
196
 
197
+ const COMPARATOR_TO_DISPLAY_OPERATOR: Record<ExpressionComparator, string> = {
198
+ eq: "EQUAL",
199
+ neq: "NOT_EQUAL",
200
+ gt: "GREATER",
201
+ gte: "GREATER_OR_EQUAL",
202
+ lt: "LESS",
203
+ lte: "LESS_OR_EQUAL",
204
+ contains: "CONTAINS",
205
+ in: "ANY_OF",
206
+ is_empty: "IS_EMPTY",
207
+ is_not_empty: "IS_NOT_EMPTY",
208
+ };
209
+
210
+ function toDisplayFilter(parsed: ParsedExpression): Record<string, unknown> {
211
+ const values =
212
+ parsed.value === null
213
+ ? []
214
+ : Array.isArray(parsed.value)
215
+ ? [...parsed.value]
216
+ : [parsed.value];
217
+ return {
218
+ origin: { fieldId: parsed.field },
219
+ comparisonOperator: COMPARATOR_TO_DISPLAY_OPERATOR[parsed.comparator],
220
+ values,
221
+ };
222
+ }
223
+
224
+ function parseFilterExpression(input: string): ParsedExpression {
225
+ try {
226
+ return parseExpression(input);
227
+ } catch (error) {
228
+ const message = error instanceof Error ? error.message : "Invalid filter expression.";
229
+ throw new InvalidArgumentError(message);
230
+ }
231
+ }
232
+
233
+ function collectFilters(value: string, previous: ParsedExpression[] = []): ParsedExpression[] {
234
+ return [...previous, parseFilterExpression(value)];
235
+ }
236
+
192
237
  function parseBooleanFlag(input: string): boolean {
193
238
  const normalized = input.trim().toLowerCase();
194
239
  if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
@@ -278,7 +323,7 @@ function buildVariableBody(options: VariableMutationOptions): Record<string, unk
278
323
  };
279
324
  }
280
325
  if (options.filter && options.filter.length > 0) {
281
- body.filters = options.filter.map((entry) => ({ raw: entry }));
326
+ body.filters = options.filter.map(toDisplayFilter);
282
327
  }
283
328
  if (
284
329
  options.dateMode ||
@@ -348,15 +393,19 @@ function buildWidgetBody(options: WidgetMutationOptions): Record<string, unknown
348
393
  };
349
394
  }
350
395
 
351
- if (options.field?.length || options.sort?.length || options.filter?.length) {
396
+ if (options.field && options.field.length > 0) {
352
397
  body.view = {
353
398
  ...(typeof body.view === "object" && body.view ? body.view as Record<string, unknown> : {}),
354
399
  fields: options.field,
355
400
  };
401
+ }
402
+ const queryMerge: Record<string, unknown> = {};
403
+ if (options.sort && options.sort.length > 0) queryMerge.sorts = options.sort;
404
+ if (options.filter && options.filter.length > 0) queryMerge.filters = options.filter.map(toDisplayFilter);
405
+ if (Object.keys(queryMerge).length > 0) {
356
406
  body.query = {
357
407
  ...(typeof body.query === "object" && body.query ? body.query as Record<string, unknown> : {}),
358
- sorts: options.sort,
359
- filters: options.filter?.map((entry) => ({ raw: entry })),
408
+ ...queryMerge,
360
409
  };
361
410
  }
362
411
 
@@ -624,7 +673,7 @@ export function registerDisplaysCommands(
624
673
  variables.command("create").description("Create variable").argument("<display>", "display uuid or slug", parseUuidOrSlugSelector)
625
674
  .option("--type <type>").option("--name <name>").option("--allow-multiple").option("--static")
626
675
  .option("--default <value>").option("--default-value <value>", "repeatable default value", (v, p: string[] = []) => [...p, v], [])
627
- .option("--source-schema <id>").option("--source-field <id>").option("--filter <expr>", "repeatable filter", (v, p: string[] = []) => [...p, v], [])
676
+ .option("--source-schema <id>").option("--source-field <id>").option("--filter <expr>", "repeatable filter", collectFilters, [])
628
677
  .option("--date-mode <mode>").option("--date-dynamic-option <option>").option("--date-specific <iso>").option("--date-range-start <iso>").option("--date-range-end <iso>")
629
678
  .option("--raw <json>").option("--raw-file <path>")
630
679
  .action(async function action(this: Command, display: NormalizedUuidOrSlug, commandOptions: VariableMutationOptions) {
@@ -636,7 +685,7 @@ export function registerDisplaysCommands(
636
685
  variables.command("update").description("Update variable").argument("<display>", "display uuid or slug", parseUuidOrSlugSelector).argument("<variable>", "variable id")
637
686
  .option("--type <type>").option("--name <name>").option("--allow-multiple").option("--static")
638
687
  .option("--default <value>").option("--default-value <value>", "repeatable default value", (v, p: string[] = []) => [...p, v], [])
639
- .option("--source-schema <id>").option("--source-field <id>").option("--filter <expr>", "repeatable filter", (v, p: string[] = []) => [...p, v], [])
688
+ .option("--source-schema <id>").option("--source-field <id>").option("--filter <expr>", "repeatable filter", collectFilters, [])
640
689
  .option("--date-mode <mode>").option("--date-dynamic-option <option>").option("--date-specific <iso>").option("--date-range-start <iso>").option("--date-range-end <iso>")
641
690
  .option("--raw <json>").option("--raw-file <path>")
642
691
  .action(async function action(this: Command, display: NormalizedUuidOrSlug, variableId: string, commandOptions: VariableMutationOptions) {
@@ -668,7 +717,7 @@ export function registerDisplaysCommands(
668
717
  .option("--tab <tabId>").option("--slot <slotId>").option("--layout <json>").option("--layout-file <path>")
669
718
  .option("--field <field>", "repeatable field", (v, p: string[] = []) => [...p, v], [])
670
719
  .option("--sort <sort>", "repeatable sort", (v, p: string[] = []) => [...p, v], [])
671
- .option("--filter <expr>", "repeatable filter", (v, p: string[] = []) => [...p, v], [])
720
+ .option("--filter <expr>", "repeatable filter", collectFilters, [])
672
721
  .option("--widget-read-only").option("--hide-add-button").option("--hide-delete-button").option("--hide-sort-button").option("--hide-filter-button").option("--hide-see-button")
673
722
  .option("--calc <type>").option("--calc-field <uuid>").option("--kpi-config <json>")
674
723
  .option("--chart-type <type>").option("--x-axis-field <uuid>").option("--x-axis-date-format <granularity>").option("--x-axis-time-series")
@@ -686,7 +735,7 @@ export function registerDisplaysCommands(
686
735
  .option("--tab <tabId>").option("--slot <slotId>").option("--layout <json>").option("--layout-file <path>")
687
736
  .option("--field <field>", "repeatable field", (v, p: string[] = []) => [...p, v], [])
688
737
  .option("--sort <sort>", "repeatable sort", (v, p: string[] = []) => [...p, v], [])
689
- .option("--filter <expr>", "repeatable filter", (v, p: string[] = []) => [...p, v], [])
738
+ .option("--filter <expr>", "repeatable filter", collectFilters, [])
690
739
  .option("--widget-read-only").option("--hide-add-button").option("--hide-delete-button").option("--hide-sort-button").option("--hide-filter-button").option("--hide-see-button")
691
740
  .option("--calc <type>").option("--calc-field <uuid>").option("--kpi-config <json>")
692
741
  .option("--chart-type <type>").option("--x-axis-field <uuid>").option("--x-axis-date-format <granularity>").option("--x-axis-time-series")
@@ -1518,30 +1518,41 @@ const specificExamples: Record<string, readonly CommandExample[]> = {
1518
1518
  },
1519
1519
  {
1520
1520
  description:
1521
- "Add query filters that match the full comparisonOperator contract (requires --raw because --filter only accepts free-form expressions).",
1521
+ "Add query filters with --filter. The CLI parses the expression and emits the structured comparisonOperator payload the API expects.",
1522
1522
  command:
1523
- `zinkee --json displays widgets update 0f6f062f-1478-4df2-96a1-c495d037d260 11111111-1111-4111-a111-111111111111 --raw '{"query":{"filters":[{"origin":{"schemaId":"contacts","fieldId":"status"},"comparisonOperator":"EQUAL","values":["2"]},{"origin":{"schemaId":"contacts","fieldId":"amount"},"comparisonOperator":"GREATER_OR_EQUAL","values":["500"]}]}}'`,
1523
+ `zinkee --json displays widgets update 0f6f062f-1478-4df2-96a1-c495d037d260 11111111-1111-4111-a111-111111111111 --filter "status:eq:2" --filter "amount:gte:500"`,
1524
1524
  raw: `{
1525
1525
  "query": {
1526
1526
  "filters": [
1527
1527
  {
1528
- "origin": {
1529
- "schemaId": "contacts",
1530
- "fieldId": "status"
1531
- },
1528
+ "origin": { "fieldId": "status" },
1532
1529
  "comparisonOperator": "EQUAL",
1533
1530
  "values": ["2"]
1534
1531
  },
1535
1532
  {
1536
- "origin": {
1537
- "schemaId": "contacts",
1538
- "fieldId": "amount"
1539
- },
1533
+ "origin": { "fieldId": "amount" },
1540
1534
  "comparisonOperator": "GREATER_OR_EQUAL",
1541
1535
  "values": ["500"]
1542
1536
  }
1543
1537
  ]
1544
1538
  }
1539
+ }`,
1540
+ },
1541
+ {
1542
+ description:
1543
+ "Fall back to --raw for comparators the parser does not cover yet (e.g. NOT_CONTAINS, RANGE).",
1544
+ command:
1545
+ `zinkee --json displays widgets update 0f6f062f-1478-4df2-96a1-c495d037d260 11111111-1111-4111-a111-111111111111 --raw '{"query":{"filters":[{"origin":{"fieldId":"due-date"},"comparisonOperator":"RANGE","values":["2025-01-01","2025-12-31"]}]}}'`,
1546
+ raw: `{
1547
+ "query": {
1548
+ "filters": [
1549
+ {
1550
+ "origin": { "fieldId": "due-date" },
1551
+ "comparisonOperator": "RANGE",
1552
+ "values": ["2025-01-01", "2025-12-31"]
1553
+ }
1554
+ ]
1555
+ }
1545
1556
  }`,
1546
1557
  },
1547
1558
  {