feat(view-preset): shared view presets with fork-on-edit semantics
Lands the view-preset / server-persisted personal-layer feature that has been running in production, plus the hotfix for "the same preset shows different filter configs to different users" (物流问题-已出轨迹 on tblgsJTXhqwiDpsjeL5 / viwyszJmLQNOLhA9sh5). Root cause of the divergence: - usePersonalViewSync implicitly flushed any overlay edit by a view|update user INTO the shared preset row (last-writer-wins). Overlays are stale snapshots seeded at apply time, so one user's seed silently erased colleagues' filter changes (2026-09-17 02:55 the preset lost the 流转角色=售后 condition), and active overlays never converged because preset updates do not propagate. Fix (semantics: presets are shared definitions, edits are personal): - fork-on-edit: the first user edit strips the seed markers from the overlay (toolbar switches to the personal config); edits only ever write the user's personal row - shared presets change ONLY through the new explicit, confirmed "Update to current config" row action (canManage users) - un-edited preset overlays auto-follow redefinitions: applyPreset stamps a presetVersion (preset.lastModifiedTime); when the preset list refetches with a newer row the overlay re-seeds. Edited overlays carry no markers and are never clobbered. Legacy rows without a version re-seed exactly once - exits strip both seed markers from the persisted personal row Also lands the pending WIP shipped with the deployed build: select-choice visibility constraint, personal-view server sync + proxy, grid async records scope/request tracking, CLI resource display, preset i18n (10 locales), view_preset migrations, docs/skill updates, .zcode gitignore. Verified end-to-end on the live instance (headless chromium, real session): apply -> edit flips toolbar to Personal with ZERO shared-row PUTs; explicit update PUTs the shared row; API-side redefinition auto-reseeds an un-edited overlay on the next list refetch.
This commit is contained in:
Vendored
+230
-29
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.baseResourceCommands = void 0;
|
||||
exports.presetConfigFromOptions = exports.baseResourceCommands = void 0;
|
||||
/**
|
||||
* `inkreach-teable base|table|field|record|view` — resource CRUD commands.
|
||||
*
|
||||
@@ -9,9 +9,36 @@ exports.baseResourceCommands = void 0;
|
||||
const commander_1 = require("commander");
|
||||
const ctx_1 = require("../lib/ctx");
|
||||
const output_1 = require("../lib/output");
|
||||
const client_1 = require("../lib/client");
|
||||
const field_normalize_1 = require("../lib/field-normalize");
|
||||
const field_display_1 = require("../lib/field-display");
|
||||
/**
|
||||
* Fetch a field by ID or display name. A direct ID hit needs no extra call;
|
||||
* a 400/404 miss (the API rejects non-ID paths) falls back to resolving the
|
||||
* arg against the table's field list. Unresolvable input rethrows the
|
||||
* original error so the user sees the underlying cause.
|
||||
*/
|
||||
async function getFieldFlexible(client, tableId, fieldIdOrName) {
|
||||
try {
|
||||
return (await client.getField(tableId, fieldIdOrName));
|
||||
}
|
||||
catch (err) {
|
||||
if (err instanceof client_1.ApiError && (err.status === 400 || err.status === 404)) {
|
||||
const list = (await client.listFields(tableId));
|
||||
const hit = (0, field_normalize_1.findFieldByNameOrId)(list, fieldIdOrName);
|
||||
if (hit)
|
||||
return (await client.getField(tableId, hit.id));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
/** fieldId → display-name map of a table, to humanize visibilityFilter references. */
|
||||
async function fieldNameMap(client, tableId) {
|
||||
const list = (await client.listFields(tableId));
|
||||
return Object.fromEntries(list.map((f) => [f.id, f.name]));
|
||||
}
|
||||
function baseResourceCommands() {
|
||||
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()];
|
||||
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd(), presetCmd()];
|
||||
}
|
||||
exports.baseResourceCommands = baseResourceCommands;
|
||||
// ===================== base =====================
|
||||
@@ -162,7 +189,9 @@ function fieldCmd() {
|
||||
cmd
|
||||
.command('list <tableId>')
|
||||
.description('List fields in a table')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
.option('--with-options', 'Add an OPTIONS summary column and expand select choices with their visibility conditions')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const opts = o;
|
||||
const tableId = args[0];
|
||||
const list = (await ctx.client.listFields(tableId));
|
||||
if (ctx.json)
|
||||
@@ -172,28 +201,60 @@ function fieldCmd() {
|
||||
process.stdout.write('No fields found.\n');
|
||||
return;
|
||||
}
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'type', header: 'TYPE' },
|
||||
], list.map((f) => ({
|
||||
id: (0, output_1.truncate)(f.id),
|
||||
name: (0, output_1.truncate)(f.name, 30),
|
||||
type: f.type,
|
||||
})));
|
||||
if (opts.withOptions) {
|
||||
const nameMap = Object.fromEntries(list.map((f) => [String(f.id), String(f.name)]));
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'type', header: 'TYPE' },
|
||||
{ key: 'options', header: 'OPTIONS' },
|
||||
], list.map((f) => ({
|
||||
id: (0, output_1.truncate)(f.id),
|
||||
name: (0, output_1.truncate)(f.name, 30),
|
||||
type: f.type,
|
||||
options: (0, field_display_1.summarizeFieldOptions)(f),
|
||||
})));
|
||||
const selectFields = list.filter((f) => Array.isArray(f.options?.choices));
|
||||
for (const f of selectFields) {
|
||||
const rows = (0, field_display_1.choiceRows)(f, nameMap);
|
||||
const conditional = rows.filter((r) => r.when !== '(always)').length;
|
||||
process.stdout.write(`\n◇ ${f.name} (${f.id}) — ${rows.length} choices · ${conditional} conditional\n`);
|
||||
for (const r of rows) {
|
||||
const color = r.color ? ` [${r.color}]` : '';
|
||||
process.stdout.write(` ${r.name}${color}\n`);
|
||||
if (r.when !== '(always)') {
|
||||
process.stdout.write(` when: ${r.when}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'type', header: 'TYPE' },
|
||||
], list.map((f) => ({
|
||||
id: (0, output_1.truncate)(f.id),
|
||||
name: (0, output_1.truncate)(f.name, 30),
|
||||
type: f.type,
|
||||
})));
|
||||
}
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('get <tableId> <fieldId>')
|
||||
.description('Get field details (full options incl. choice visibilityFilter)')
|
||||
.command('get <tableId> <fieldIdOrName>')
|
||||
.description('Get field details by id or name (full options; select choices rendered with visibility conditions)')
|
||||
.action((0, ctx_1.run)(async (ctx, args) => {
|
||||
const [tableId, fieldId] = args;
|
||||
const field = await ctx.client.getField(tableId, fieldId);
|
||||
const [tableId, fieldIdOrName] = args;
|
||||
const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(field);
|
||||
else {
|
||||
const f = field;
|
||||
(0, output_1.printKv)(Object.entries(f).map(([k, v]) => [k, v]));
|
||||
const choices = field.options?.choices;
|
||||
const names = Array.isArray(choices)
|
||||
? await fieldNameMap(ctx.client, tableId)
|
||||
: undefined;
|
||||
process.stdout.write((0, field_display_1.formatFieldDetail)(field, names) + '\n');
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
@@ -242,27 +303,28 @@ function fieldCmd() {
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.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)')
|
||||
.command('update <tableId> <fieldIdOrName>')
|
||||
.description('Update a field (by id or name). --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 (written via convert API)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const [tableId, fieldId] = args;
|
||||
const [tableId, fieldIdOrName] = args;
|
||||
const opts = o;
|
||||
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 });
|
||||
const current = (await getFieldFlexible(ctx.client, tableId, fieldIdOrName));
|
||||
const field = await ctx.client.updateField(tableId, current.id, { name: opts.name });
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(field);
|
||||
else
|
||||
process.stdout.write(`✓ Field ${fieldId} renamed\n`);
|
||||
process.stdout.write(`✓ Field ${current.id} 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 current = (await getFieldFlexible(ctx.client, tableId, fieldIdOrName));
|
||||
const body = {
|
||||
type: current.type,
|
||||
options: JSON.parse(opts.options),
|
||||
@@ -270,18 +332,19 @@ function fieldCmd() {
|
||||
if (opts.name)
|
||||
body.name = opts.name;
|
||||
(0, field_normalize_1.normalizeFieldRo)(body);
|
||||
const field = await ctx.client.convertField(tableId, fieldId, body);
|
||||
const field = await ctx.client.convertField(tableId, current.id, body);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(field);
|
||||
else
|
||||
process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`);
|
||||
process.stdout.write(`✓ Field ${current.id} options updated (via convert)\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('visible-choices <tableId> <recordId> <fieldId>')
|
||||
.command('visible-choices <tableId> <recordId> <fieldIdOrName>')
|
||||
.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);
|
||||
const [tableId, recordId, fieldIdOrName] = args;
|
||||
const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName);
|
||||
const res = await ctx.client.getVisibleChoices(tableId, recordId, String(field.id));
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(res);
|
||||
else {
|
||||
@@ -536,3 +599,141 @@ function viewCmd() {
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
// ===================== view preset =====================
|
||||
/** Parse a --filter/--sort/... JSON option; empty/absent → undefined. */
|
||||
function parseJsonOption(value, optionName) {
|
||||
if (value == null || value === '')
|
||||
return undefined;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
catch (err) {
|
||||
throw new Error(`Invalid JSON for ${optionName}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
/** Collect preset config keys from options; absent keys stay out of the config. */
|
||||
function presetConfigFromOptions(opts) {
|
||||
const config = {};
|
||||
const entries = [
|
||||
['filter', '--filter'],
|
||||
['sort', '--sort'],
|
||||
['group', '--group'],
|
||||
['options', '--options'],
|
||||
['columnMeta', '--column-meta'],
|
||||
];
|
||||
for (const [key, optionName] of entries) {
|
||||
const parsed = parseJsonOption(opts[key], optionName);
|
||||
if (parsed !== undefined)
|
||||
config[key] = parsed;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
exports.presetConfigFromOptions = presetConfigFromOptions;
|
||||
function presetCmd() {
|
||||
const cmd = new commander_1.Command('preset');
|
||||
cmd.description('Manage preset view configurations (named filter/sort/group bundles, shared with all collaborators)');
|
||||
cmd
|
||||
.command('list <tableId>')
|
||||
.description('List view presets of a table (pass --view-id to scope to one view)')
|
||||
.option('--view-id <id>', 'Only presets bound to this view')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const tableId = args[0];
|
||||
const opts = o;
|
||||
const list = (await ctx.client.listViewPresets(tableId, opts.viewId));
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(list);
|
||||
else {
|
||||
if (!list.length) {
|
||||
process.stdout.write('No view presets found.\n');
|
||||
return;
|
||||
}
|
||||
(0, output_1.printTable)([
|
||||
{ key: 'id', header: 'ID' },
|
||||
{ key: 'name', header: 'NAME' },
|
||||
{ key: 'config', header: 'CONFIG' },
|
||||
], list.map((p) => {
|
||||
const config = (p.config ?? {});
|
||||
const keys = Object.keys(config).filter((k) => config[k] != null);
|
||||
return {
|
||||
id: (0, output_1.truncate)(p.id),
|
||||
name: (0, output_1.truncate)(p.name, 30),
|
||||
config: keys.join(',') || '(empty)',
|
||||
};
|
||||
}));
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('create')
|
||||
.description('Create a view preset bound to one view (keys not passed stay unset — they keep the view values on apply)')
|
||||
.requiredOption('--table-id <id>', 'Table id')
|
||||
.requiredOption('--view-id <id>', 'View id the preset belongs to')
|
||||
.requiredOption('--name <name>', 'Preset name')
|
||||
.option('--description <text>', 'Preset description')
|
||||
.option('--filter <json>', 'Filter config, e.g. \'{"conjunction":"and","filterSet":[{"fieldId":"fldX","operator":"is","value":"Pending"}]}\'')
|
||||
.option('--sort <json>', 'Sort config, e.g. \'{"sortObjs":[{"fieldId":"fldX","order":"asc"}]}\'')
|
||||
.option('--group <json>', 'Group config (JSON array of group items)')
|
||||
.option('--options <json>', 'View options (JSON object)')
|
||||
.option('--column-meta <json>', 'Column meta (field visibility/order, JSON object)')
|
||||
.action((0, ctx_1.run)(async (ctx, _args, o) => {
|
||||
const opts = o;
|
||||
const body = {
|
||||
name: opts.name,
|
||||
viewId: opts.viewId,
|
||||
config: presetConfigFromOptions(opts),
|
||||
};
|
||||
if (opts.description)
|
||||
body.description = opts.description;
|
||||
const preset = (await ctx.client.createViewPreset(opts.tableId, body));
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(preset);
|
||||
else {
|
||||
(0, output_1.printKv)([
|
||||
['id', preset.id],
|
||||
['name', preset.name],
|
||||
['order', preset.order],
|
||||
]);
|
||||
}
|
||||
}));
|
||||
cmd
|
||||
.command('update <presetId>')
|
||||
.description('Update a view preset (rename and/or replace config keys)')
|
||||
.requiredOption('--table-id <id>', 'Table id')
|
||||
.option('--name <name>', 'New preset name')
|
||||
.option('--description <text>', 'New preset description')
|
||||
.option('--filter <json>', 'Replace filter config (JSON)')
|
||||
.option('--sort <json>', 'Replace sort config (JSON)')
|
||||
.option('--group <json>', 'Replace group config (JSON)')
|
||||
.option('--options <json>', 'Replace view options (JSON object)')
|
||||
.option('--column-meta <json>', 'Replace column meta (JSON object)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const presetId = args[0];
|
||||
const opts = o;
|
||||
const body = {};
|
||||
if (opts.name)
|
||||
body.name = opts.name;
|
||||
if (opts.description !== undefined)
|
||||
body.description = opts.description;
|
||||
const config = presetConfigFromOptions(opts);
|
||||
if (Object.keys(config).length)
|
||||
body.config = config;
|
||||
await ctx.client.updateViewPreset(opts.tableId, presetId, body);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)({ ok: true, id: presetId });
|
||||
else
|
||||
process.stdout.write(`✓ Preset ${presetId} updated\n`);
|
||||
}));
|
||||
cmd
|
||||
.command('delete <presetId>')
|
||||
.description('Delete a view preset')
|
||||
.requiredOption('--table-id <id>', 'Table id')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const presetId = args[0];
|
||||
const opts = o;
|
||||
await ctx.client.deleteViewPreset(opts.tableId, presetId);
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)({ ok: true, id: presetId });
|
||||
else
|
||||
process.stdout.write(`✓ Preset ${presetId} deleted\n`);
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
|
||||
Vendored
+14
-6
@@ -182,9 +182,7 @@ function subscriptionCommand() {
|
||||
}
|
||||
}));
|
||||
const getCmd = cmd.command('get <subscriptionId>').description('Show full subscription config');
|
||||
getCmd
|
||||
.option('--base-id <id>', 'Base id (overrides default)')
|
||||
.action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
getCmd.option('--base-id <id>', 'Base id (overrides default)').action((0, ctx_1.run)(async (ctx, args, o) => {
|
||||
const id = args[0];
|
||||
const opts = o;
|
||||
const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId);
|
||||
@@ -234,7 +232,11 @@ function subscriptionCommand() {
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(vo);
|
||||
else
|
||||
(0, output_1.printKv)([['id', vo.id], ['name', vo.name], ['created', '✓']]);
|
||||
(0, output_1.printKv)([
|
||||
['id', vo.id],
|
||||
['name', vo.name],
|
||||
['created', '✓'],
|
||||
]);
|
||||
}));
|
||||
const updateCmd = cmd
|
||||
.command('update <subscriptionId>')
|
||||
@@ -251,7 +253,10 @@ function subscriptionCommand() {
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(vo);
|
||||
else
|
||||
(0, output_1.printKv)([['id', vo.id], ['updated', '✓']]);
|
||||
(0, output_1.printKv)([
|
||||
['id', vo.id],
|
||||
['updated', '✓'],
|
||||
]);
|
||||
}));
|
||||
cmd
|
||||
.command('delete <subscriptionId>')
|
||||
@@ -308,7 +313,10 @@ function subscriptionCommand() {
|
||||
if (ctx.json)
|
||||
(0, output_1.printJson)(result ?? { id });
|
||||
else
|
||||
(0, output_1.printKv)([['id', id], ['sent', '✓']]);
|
||||
(0, output_1.printKv)([
|
||||
['id', id],
|
||||
['sent', '✓'],
|
||||
]);
|
||||
}));
|
||||
return cmd;
|
||||
}
|
||||
|
||||
Vendored
+15
@@ -154,6 +154,21 @@ class Client {
|
||||
deleteView(viewId) {
|
||||
return this.call('DELETE', `/api/view/${viewId}`);
|
||||
}
|
||||
// ---- view preset (self-built contract) ----
|
||||
// Preset view configurations: VIEW-scoped named bundles of
|
||||
// filter/sort/group(/options/columnMeta) shared with all collaborators.
|
||||
listViewPresets(tableId, viewId) {
|
||||
return this.call('GET', `/api/table/${tableId}/view-preset${viewId ? `?viewId=${encodeURIComponent(viewId)}` : ''}`);
|
||||
}
|
||||
createViewPreset(tableId, body) {
|
||||
return this.call('POST', `/api/table/${tableId}/view-preset`, body);
|
||||
}
|
||||
updateViewPreset(tableId, presetId, body) {
|
||||
return this.call('PUT', `/api/table/${tableId}/view-preset/${presetId}`, body);
|
||||
}
|
||||
deleteViewPreset(tableId, presetId) {
|
||||
return this.call('DELETE', `/api/table/${tableId}/view-preset/${presetId}`);
|
||||
}
|
||||
// ---- workflow (self-built contract) ----
|
||||
listWorkflows(baseId) {
|
||||
return this.call('GET', `/api/base/${baseId}/workflow`);
|
||||
|
||||
Vendored
+136
@@ -0,0 +1,136 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Human-mode display helpers for field options — make select-choice
|
||||
* constraints (choices, colors, per-choice visibilityFilter) readable
|
||||
* without `--json` post-processing.
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.formatFieldDetail = exports.choiceRows = exports.describeChoiceCondition = exports.summarizeFieldOptions = void 0;
|
||||
const CONDITION_MAX_LEN = 90;
|
||||
/** Summarize a field's options for the `field list --with-options` column. */
|
||||
function summarizeFieldOptions(field) {
|
||||
const options = field.options;
|
||||
const choices = options?.choices;
|
||||
if (Array.isArray(choices) && choices.length) {
|
||||
const conditional = choices.filter((c) => c && typeof c === 'object' && c.visibilityFilter).length;
|
||||
return conditional
|
||||
? `${choices.length} choices · ${conditional} conditional`
|
||||
: `${choices.length} choices`;
|
||||
}
|
||||
if (!options || typeof options !== 'object')
|
||||
return '—';
|
||||
const type = field.type;
|
||||
if (type === 'rating') {
|
||||
return [
|
||||
`max=${options.max ?? 5}`,
|
||||
typeof options.icon === 'string' ? `icon=${options.icon}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
const formatting = options.formatting;
|
||||
if (type === 'number' && formatting) {
|
||||
return `precision=${formatting.precision ?? 0}`;
|
||||
}
|
||||
if ((type === 'date' || type === 'createdTime' || type === 'lastModifiedTime') && formatting) {
|
||||
return [formatting.date, formatting.time && formatting.time !== 'None' ? formatting.time : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
if (typeof options.expression === 'string') {
|
||||
const e = options.expression;
|
||||
return `expr: ${e.length > 40 ? e.slice(0, 39) + '…' : e}`;
|
||||
}
|
||||
return '—';
|
||||
}
|
||||
exports.summarizeFieldOptions = summarizeFieldOptions;
|
||||
function formatValue(value) {
|
||||
if (value === null || value === undefined)
|
||||
return '';
|
||||
if (Array.isArray(value))
|
||||
return value.map((v) => String(v)).join('|');
|
||||
if (typeof value === 'object')
|
||||
return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
/** One-line human summary of a choice's visibilityFilter; `(always)` when absent. */
|
||||
function describeChoiceCondition(vf, fieldNames) {
|
||||
if (!vf || typeof vf !== 'object')
|
||||
return '(always)';
|
||||
const { conjunction, filterSet } = vf;
|
||||
if (!Array.isArray(filterSet) || !filterSet.length)
|
||||
return '(always)';
|
||||
const joiner = conjunction === 'or' ? ' OR ' : ' AND ';
|
||||
const rules = filterSet.map(({ fieldId, operator, value }) => {
|
||||
const label = (fieldId && fieldNames?.[fieldId]) || fieldId || '?';
|
||||
const v = formatValue(value);
|
||||
return v ? `${label} ${operator ?? '?'} ${v}` : `${label} ${operator ?? '?'}`;
|
||||
});
|
||||
let text = '';
|
||||
let kept = 0;
|
||||
for (const rule of rules) {
|
||||
const candidate = text ? text + joiner + rule : rule;
|
||||
if (candidate.length > CONDITION_MAX_LEN && kept > 0)
|
||||
break;
|
||||
text = candidate;
|
||||
kept++;
|
||||
}
|
||||
const omitted = rules.length - kept;
|
||||
return omitted > 0 ? `${text}… +${omitted} more` : text;
|
||||
}
|
||||
exports.describeChoiceCondition = describeChoiceCondition;
|
||||
/** Choices of a select field as display rows; `[]` when the field has none. */
|
||||
function choiceRows(field, fieldNames) {
|
||||
const choices = field.options?.choices;
|
||||
if (!Array.isArray(choices))
|
||||
return [];
|
||||
return choices
|
||||
.filter((c) => !!c && typeof c === 'object')
|
||||
.map((c) => ({
|
||||
name: String(c.name ?? ''),
|
||||
color: typeof c.color === 'string' ? c.color : '',
|
||||
when: describeChoiceCondition(c.visibilityFilter, fieldNames),
|
||||
}));
|
||||
}
|
||||
exports.choiceRows = choiceRows;
|
||||
function kvLine(key, value, keyWidth) {
|
||||
const val = typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value ?? '');
|
||||
return `${key.padEnd(keyWidth)} ${val}`;
|
||||
}
|
||||
/** Full human-mode rendering for `field get`: kv lines, with select choices
|
||||
* expanded into a per-choice condition listing instead of one JSON blob. */
|
||||
function formatFieldDetail(field, fieldNames) {
|
||||
const lines = [];
|
||||
const options = field.options;
|
||||
const choices = options?.choices;
|
||||
const restEntries = Object.entries({ ...field });
|
||||
const keyWidth = Math.max(...restEntries.map(([k]) => k.length), 'defaultValue'.length);
|
||||
for (const [k, v] of restEntries) {
|
||||
if (k === 'options')
|
||||
continue;
|
||||
lines.push(kvLine(k, v, keyWidth));
|
||||
}
|
||||
if (Array.isArray(choices) && choices.length) {
|
||||
const conditional = choices.filter((c) => c && typeof c === 'object' && c.visibilityFilter).length;
|
||||
lines.push('');
|
||||
lines.push(`choices (${choices.length} · ${conditional} conditional):`);
|
||||
for (const row of choiceRows(field, fieldNames)) {
|
||||
const color = row.color ? ` [${row.color}]` : '';
|
||||
lines.push(` ${row.name}${color}`);
|
||||
lines.push(` when: ${row.when}`);
|
||||
}
|
||||
const otherOpts = Object.fromEntries(Object.entries(options ?? {}).filter(([k]) => k !== 'choices'));
|
||||
if (Object.keys(otherOpts).length) {
|
||||
lines.push('');
|
||||
lines.push(kvLine('options (other)', otherOpts, keyWidth));
|
||||
}
|
||||
}
|
||||
else if (options && Object.keys(options).length) {
|
||||
lines.push(kvLine('options', options, keyWidth));
|
||||
}
|
||||
else {
|
||||
lines.push(kvLine('options', undefined, keyWidth));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
exports.formatFieldDetail = formatFieldDetail;
|
||||
Reference in New Issue
Block a user