From 10b52db8b2b0e76f824a0dff01d681cc4e2c0f00 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Sun, 16 Aug 2026 05:31:05 +0000 Subject: [PATCH] fix(field): enforce select-visibility guard on the V2 convert path + repair inkreach-cli field routes - FieldOpenApiV2Service.convertField now calls the shared V1 validator (assertSelectVisibilityConfigValid) before executing the update command. Previously only the V1 path validated self-reference / dependency cycles, so invalid configs could be persisted through V2 (verified live: a self-referencing choice was accepted with 200). - V1 assertSelectVisibilityConfigValid private -> public (shared guard). - Unit tests: 3 new cases in field-open-api-v2.service.spec.ts (guard invoked with exact args; rejects before executing when validator throws; called with raw type/options for non-select conversions). 63/63 pass; tsc build config 0 errors. - inkreach-cli: field get/update/delete targeted non-existent /api/field/:id routes; now use /api/table/:tableId/field/:fieldId. update --options goes through the convert API (the PATCH update API strips options by design). New: convertField + getVisibleChoices client methods, field visible-choices command (evaluates choice visibility for a record). --- dist/commands/resources.js | 63 ++++++++++++++++++++++++--------- dist/lib/client.js | 24 +++++++++---- src/commands/resources.ts | 71 ++++++++++++++++++++++++++++++-------- src/lib/client.ts | 31 +++++++++++++---- 4 files changed, 146 insertions(+), 43 deletions(-) diff --git a/dist/commands/resources.js b/dist/commands/resources.js index 93e7264..5dc66d6 100644 --- a/dist/commands/resources.js +++ b/dist/commands/resources.js @@ -171,11 +171,11 @@ function fieldCmd() { } })); cmd - .command('get ') - .description('Get field details') + .command('get ') + .description('Get field details (full options incl. choice visibilityFilter)') .action((0, ctx_1.run)(async (ctx, args) => { - const fieldId = args[0]; - const field = await ctx.client.getField(fieldId); + const [tableId, fieldId] = args; + const field = await ctx.client.getField(tableId, fieldId); if (ctx.json) (0, output_1.printJson)(field); else { @@ -208,30 +208,61 @@ function fieldCmd() { } })); cmd - .command('update ') - .description('Update a field') + .command('update ') + .description('Update a field. --name renames (PATCH); --options rewrites options via the convert API (the update API does not accept options)') .option('--name ', 'New field name') - .option('--options ', 'New field options JSON') + .option('--options ', 'New field options JSON (written via convert API)') .action((0, ctx_1.run)(async (ctx, args, o) => { - const fieldId = args[0]; + const [tableId, fieldId] = args; const opts = o; - const body = {}; + if (!opts.name && !opts.options) { + throw new Error('Nothing to update: pass --name and/or --options'); + } + if (opts.name && !opts.options) { + const field = await ctx.client.updateField(tableId, fieldId, { name: opts.name }); + if (ctx.json) + (0, output_1.printJson)(field); + else + process.stdout.write(`✓ Field ${fieldId} renamed\n`); + return; + } + // Options (choices / visibilityFilter / ...) must go through convert. + // Fetch the current field to reuse its type; merge name if given. + const current = (await ctx.client.getField(tableId, fieldId)); + const body = { + type: current.type, + options: JSON.parse(opts.options), + }; if (opts.name) body.name = opts.name; - if (opts.options) - body.options = JSON.parse(opts.options); - const field = await ctx.client.updateField(fieldId, body); + const field = await ctx.client.convertField(tableId, fieldId, body); if (ctx.json) (0, output_1.printJson)(field); else - process.stdout.write(`✓ Field ${fieldId} updated\n`); + process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`); })); cmd - .command('delete ') + .command('visible-choices ') + .description('Evaluate select-choice visibility for a record (respects choice visibilityFilter dependencies)') + .action((0, ctx_1.run)(async (ctx, args) => { + const [tableId, recordId, fieldId] = args; + const res = await ctx.client.getVisibleChoices(tableId, recordId, fieldId); + if (ctx.json) + (0, output_1.printJson)(res); + else { + (0, output_1.printTable)([ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'NAME' }, + { key: 'color', header: 'COLOR' }, + ], res.visibleChoices.map((c) => ({ ...c, color: c.color ?? '' }))); + } + })); + cmd + .command('delete ') .description('Delete a field') .action((0, ctx_1.run)(async (ctx, args) => { - const fieldId = args[0]; - await ctx.client.deleteField(fieldId); + const [tableId, fieldId] = args; + await ctx.client.deleteField(tableId, fieldId); if (ctx.json) (0, output_1.printJson)({ ok: true, id: fieldId }); else diff --git a/dist/lib/client.js b/dist/lib/client.js index 77cfbde..f942677 100644 --- a/dist/lib/client.js +++ b/dist/lib/client.js @@ -67,17 +67,29 @@ class Client { listFields(tableId) { return this.call('GET', `/api/table/${tableId}/field`); } - getField(fieldId) { - return this.call('GET', `/api/field/${fieldId}`); + getField(tableId, fieldId) { + return this.call('GET', `/api/table/${tableId}/field/${fieldId}`); } createField(tableId, body) { return this.call('POST', `/api/table/${tableId}/field`, body); } - updateField(fieldId, body) { - return this.call('PUT', `/api/field/${fieldId}`, body); + /** Rename/describe. NOTE: the update API accepts name/description only — + * options (incl. select choice visibilityFilter) must go through convertField. */ + updateField(tableId, fieldId, body) { + return this.call('PATCH', `/api/table/${tableId}/field/${fieldId}`, body); } - deleteField(fieldId) { - return this.call('DELETE', `/api/field/${fieldId}`); + /** PUT convert: the ONLY API path that persists field options (choices, + * visibilityFilter dependency config, etc.). Requires the field's current type. */ + convertField(tableId, fieldId, body) { + return this.call('PUT', `/api/table/${tableId}/field/${fieldId}/convert`, body); + } + deleteField(tableId, fieldId) { + return this.call('DELETE', `/api/table/${tableId}/field/${fieldId}`); + } + /** Evaluate select-choice visibility dependency for one record+field. + * GET /api/table/:tableId/select-visibility/:recordId/:fieldId */ + getVisibleChoices(tableId, recordId, fieldId) { + return this.call('GET', `/api/table/${tableId}/select-visibility/${recordId}/${fieldId}`); } // ---- record ---- getRecords(tableId, query) { diff --git a/src/commands/resources.ts b/src/commands/resources.ts index c0f34d2..47523c1 100644 --- a/src/commands/resources.ts +++ b/src/commands/resources.ts @@ -202,12 +202,12 @@ function fieldCmd(): Command { ); cmd - .command('get ') - .description('Get field details') + .command('get ') + .description('Get field details (full options incl. choice visibilityFilter)') .action( run(async (ctx, args) => { - const fieldId = args[0] as string; - const field = await ctx.client.getField(fieldId); + const [tableId, fieldId] = args as [string, string]; + const field = await ctx.client.getField(tableId, fieldId); if (ctx.json) printJson(field); else { const f = field as Record; @@ -245,30 +245,71 @@ function fieldCmd(): Command { ); cmd - .command('update ') - .description('Update a field') + .command('update ') + .description( + 'Update a field. --name renames (PATCH); --options rewrites options via the convert API (the update API does not accept options)' + ) .option('--name ', 'New field name') - .option('--options ', 'New field options JSON') + .option('--options ', 'New field options JSON (written via convert API)') .action( run(async (ctx, args, o) => { - const fieldId = args[0] as string; + const [tableId, fieldId] = args as [string, string]; const opts = o as { name?: string; options?: string }; - const body: Record = {}; + if (!opts.name && !opts.options) { + throw new Error('Nothing to update: pass --name and/or --options'); + } + if (opts.name && !opts.options) { + const field = await ctx.client.updateField(tableId, fieldId, { name: opts.name }); + if (ctx.json) printJson(field); + else process.stdout.write(`✓ Field ${fieldId} renamed\n`); + return; + } + // Options (choices / visibilityFilter / ...) must go through convert. + // Fetch the current field to reuse its type; merge name if given. + const current = (await ctx.client.getField(tableId, fieldId)) as { + type: string; + }; + const body: Record = { + type: current.type, + options: JSON.parse(opts.options as string), + }; if (opts.name) body.name = opts.name; - if (opts.options) body.options = JSON.parse(opts.options); - const field = await ctx.client.updateField(fieldId, body); + const field = await ctx.client.convertField(tableId, fieldId, body); if (ctx.json) printJson(field); - else process.stdout.write(`✓ Field ${fieldId} updated\n`); + else process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`); }) ); cmd - .command('delete ') + .command('visible-choices ') + .description( + 'Evaluate select-choice visibility for a record (respects choice visibilityFilter dependencies)' + ) + .action( + run(async (ctx, args) => { + const [tableId, recordId, fieldId] = args as [string, string, string]; + const res = await ctx.client.getVisibleChoices(tableId, recordId, fieldId); + if (ctx.json) printJson(res); + else { + printTable( + [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'NAME' }, + { key: 'color', header: 'COLOR' }, + ], + res.visibleChoices.map((c) => ({ ...c, color: c.color ?? '' })) + ); + } + }) + ); + + cmd + .command('delete ') .description('Delete a field') .action( run(async (ctx, args) => { - const fieldId = args[0] as string; - await ctx.client.deleteField(fieldId); + const [tableId, fieldId] = args as [string, string]; + await ctx.client.deleteField(tableId, fieldId); if (ctx.json) printJson({ ok: true, id: fieldId }); else process.stdout.write(`✓ Field ${fieldId} deleted\n`); }) diff --git a/src/lib/client.ts b/src/lib/client.ts index 0805feb..ea6cf16 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -77,17 +77,36 @@ export class Client { listFields(tableId: string) { return this.call('GET', `/api/table/${tableId}/field`); } - getField(fieldId: string) { - return this.call('GET', `/api/field/${fieldId}`); + getField(tableId: string, fieldId: string) { + return this.call('GET', `/api/table/${tableId}/field/${fieldId}`); } createField(tableId: string, body: unknown) { return this.call('POST', `/api/table/${tableId}/field`, body); } - updateField(fieldId: string, body: unknown) { - return this.call('PUT', `/api/field/${fieldId}`, body); + /** Rename/describe. NOTE: the update API accepts name/description only — + * options (incl. select choice visibilityFilter) must go through convertField. */ + updateField(tableId: string, fieldId: string, body: unknown) { + return this.call('PATCH', `/api/table/${tableId}/field/${fieldId}`, body); } - deleteField(fieldId: string) { - return this.call<{ id: string }>('DELETE', `/api/field/${fieldId}`); + /** PUT convert: the ONLY API path that persists field options (choices, + * visibilityFilter dependency config, etc.). Requires the field's current type. */ + convertField(tableId: string, fieldId: string, body: unknown) { + return this.call( + 'PUT', + `/api/table/${tableId}/field/${fieldId}/convert`, + body + ); + } + deleteField(tableId: string, fieldId: string) { + return this.call<{ id: string }>('DELETE', `/api/table/${tableId}/field/${fieldId}`); + } + /** Evaluate select-choice visibility dependency for one record+field. + * GET /api/table/:tableId/select-visibility/:recordId/:fieldId */ + getVisibleChoices(tableId: string, recordId: string, fieldId: string) { + return this.call<{ visibleChoices: Array<{ id: string; name: string; color: string }> }>( + 'GET', + `/api/table/${tableId}/select-visibility/${recordId}/${fieldId}` + ); } // ---- record ----