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).
This commit is contained in:
Vendored
+49
-18
@@ -171,11 +171,11 @@ function fieldCmd() {
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('get <fieldId>')
|
||||
.description('Get field details')
|
||||
.command('get <tableId> <fieldId>')
|
||||
.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 <fieldId>')
|
||||
.description('Update a field')
|
||||
.command('update <tableId> <fieldId>')
|
||||
.description('Update a field. --name renames (PATCH); --options rewrites options via the convert API (the update API does not accept options)')
|
||||
.option('--name <name>', 'New field name')
|
||||
.option('--options <json>', 'New field options JSON')
|
||||
.option('--options <json>', '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)
|
||||
body.name = opts.name;
|
||||
if (opts.options)
|
||||
body.options = JSON.parse(opts.options);
|
||||
const field = await ctx.client.updateField(fieldId, 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} updated\n`);
|
||||
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;
|
||||
const field = await ctx.client.convertField(tableId, fieldId, body);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(field);
|
||||
else
|
||||
process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('delete <fieldId>')
|
||||
.command('visible-choices <tableId> <recordId> <fieldId>')
|
||||
.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 <tableId> <fieldId>')
|
||||
.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
|
||||
|
||||
Vendored
+18
-6
@@ -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) {
|
||||
|
||||
+57
-16
@@ -202,12 +202,12 @@ function fieldCmd(): Command {
|
||||
);
|
||||
|
||||
cmd
|
||||
.command('get <fieldId>')
|
||||
.description('Get field details')
|
||||
.command('get <tableId> <fieldId>')
|
||||
.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<string, unknown>;
|
||||
@@ -245,30 +245,71 @@ function fieldCmd(): Command {
|
||||
);
|
||||
|
||||
cmd
|
||||
.command('update <fieldId>')
|
||||
.description('Update a field')
|
||||
.command('update <tableId> <fieldId>')
|
||||
.description(
|
||||
'Update a field. --name renames (PATCH); --options rewrites options via the convert API (the update API does not accept options)'
|
||||
)
|
||||
.option('--name <name>', 'New field name')
|
||||
.option('--options <json>', 'New field options JSON')
|
||||
.option('--options <json>', '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<string, unknown> = {};
|
||||
if (opts.name) body.name = opts.name;
|
||||
if (opts.options) body.options = JSON.parse(opts.options);
|
||||
const field = await ctx.client.updateField(fieldId, 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) printJson(field);
|
||||
else process.stdout.write(`✓ Field ${fieldId} updated\n`);
|
||||
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<string, unknown> = {
|
||||
type: current.type,
|
||||
options: JSON.parse(opts.options as string),
|
||||
};
|
||||
if (opts.name) body.name = opts.name;
|
||||
const field = await ctx.client.convertField(tableId, fieldId, body);
|
||||
if (ctx.json) printJson(field);
|
||||
else process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`);
|
||||
})
|
||||
);
|
||||
|
||||
cmd
|
||||
.command('delete <fieldId>')
|
||||
.command('visible-choices <tableId> <recordId> <fieldId>')
|
||||
.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 <tableId> <fieldId>')
|
||||
.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`);
|
||||
})
|
||||
|
||||
+25
-6
@@ -77,17 +77,36 @@ export class Client {
|
||||
listFields(tableId: string) {
|
||||
return this.call<unknown[]>('GET', `/api/table/${tableId}/field`);
|
||||
}
|
||||
getField(fieldId: string) {
|
||||
return this.call<unknown>('GET', `/api/field/${fieldId}`);
|
||||
getField(tableId: string, fieldId: string) {
|
||||
return this.call<unknown>('GET', `/api/table/${tableId}/field/${fieldId}`);
|
||||
}
|
||||
createField(tableId: string, body: unknown) {
|
||||
return this.call<unknown>('POST', `/api/table/${tableId}/field`, body);
|
||||
}
|
||||
updateField(fieldId: string, body: unknown) {
|
||||
return this.call<unknown>('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<unknown>('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<unknown>(
|
||||
'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 ----
|
||||
|
||||
Reference in New Issue
Block a user