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:
yeuimu
2026-09-17 04:07:30 +00:00
parent 1df830adc4
commit 035384131c
15 changed files with 1309 additions and 258 deletions
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { presetConfigFromOptions } from './resources';
describe('presetConfigFromOptions', () => {
it('parses provided JSON keys and drops absent ones', () => {
const config = presetConfigFromOptions({
tableId: 'tblX',
name: 'n',
filter: '{"conjunction":"and","filterSet":[]}',
sort: '{"sortObjs":[{"fieldId":"fldA","order":"asc"}]}',
});
expect(config).toEqual({
filter: { conjunction: 'and', filterSet: [] },
sort: { sortObjs: [{ fieldId: 'fldA', order: 'asc' }] },
});
});
it('returns an empty config when nothing is provided', () => {
expect(presetConfigFromOptions({ tableId: 'tblX', name: 'n' })).toEqual({});
});
it('throws a descriptive error on invalid JSON', () => {
expect(() =>
presetConfigFromOptions({ tableId: 'tblX', name: 'n', filter: '{oops' })
).toThrowError(/Invalid JSON for --filter/);
});
});
+298 -35
View File
@@ -4,8 +4,9 @@
* These endpoints are compatible with official teable open-api.
*/
import { Command } from 'commander';
import { run, requireBaseId } from '../lib/ctx';
import { run, requireBaseId, type Ctx } from '../lib/ctx';
import { printJson, printTable, printKv, truncate } from '../lib/output';
import { Client, ApiError } from '../lib/client';
import {
normalizeFieldRo,
normalizeFieldRoArray,
@@ -13,12 +14,43 @@ import {
resolveFieldType,
inferFieldType,
findTableByNameOrId,
findFieldByNameOrId,
type ITableLike,
type IFieldLike,
} from '../lib/field-normalize';
import { formatFieldDetail, summarizeFieldOptions, choiceRows } from '../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: Client,
tableId: string,
fieldIdOrName: string
): Promise<Record<string, unknown>> {
try {
return (await client.getField(tableId, fieldIdOrName)) as Record<string, unknown>;
} catch (err) {
if (err instanceof ApiError && (err.status === 400 || err.status === 404)) {
const list = (await client.listFields(tableId)) as unknown as IFieldLike[];
const hit = findFieldByNameOrId(list, fieldIdOrName);
if (hit) return (await client.getField(tableId, hit.id)) as Record<string, unknown>;
}
throw err;
}
}
/** fieldId → display-name map of a table, to humanize visibilityFilter references. */
async function fieldNameMap(client: Client, tableId: string): Promise<Record<string, string>> {
const list = (await client.listFields(tableId)) as Array<{ id: string; name: string }>;
return Object.fromEntries(list.map((f) => [f.id, f.name]));
}
export function baseResourceCommands(): Command[] {
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd()];
return [baseCmd(), tableCmd(), fieldCmd(), recordCmd(), viewCmd(), presetCmd()];
}
// ===================== base =====================
@@ -141,7 +173,11 @@ function tableCmd(): Command {
const tables = await ctx.client.listTables(baseId).catch(() => []);
for (const f of fields) {
const options = f?.options as Record<string, unknown> | undefined;
if (options && typeof options.foreignTableName === 'string' && !options.foreignTableId) {
if (
options &&
typeof options.foreignTableName === 'string' &&
!options.foreignTableId
) {
const t = findTableByNameOrId(
(tables as unknown as ITableLike[]) || [],
options.foreignTableName
@@ -200,8 +236,13 @@ function fieldCmd(): Command {
cmd
.command('list <tableId>')
.description('List fields in a table')
.option(
'--with-options',
'Add an OPTIONS summary column and expand select choices with their visibility conditions'
)
.action(
run(async (ctx, args) => {
run(async (ctx, args, o) => {
const opts = o as { withOptions?: boolean };
const tableId = args[0] as string;
const list = (await ctx.client.listFields(tableId)) as Array<Record<string, unknown>>;
if (ctx.json) printJson(list);
@@ -210,33 +251,73 @@ function fieldCmd(): Command {
process.stdout.write('No fields found.\n');
return;
}
printTable(
[
{ key: 'id', header: 'ID' },
{ key: 'name', header: 'NAME' },
{ key: 'type', header: 'TYPE' },
],
list.map((f) => ({
id: truncate(f.id),
name: truncate(f.name, 30),
type: f.type,
}))
);
if (opts.withOptions) {
const nameMap = Object.fromEntries(list.map((f) => [String(f.id), String(f.name)]));
printTable(
[
{ key: 'id', header: 'ID' },
{ key: 'name', header: 'NAME' },
{ key: 'type', header: 'TYPE' },
{ key: 'options', header: 'OPTIONS' },
],
list.map((f) => ({
id: truncate(f.id),
name: truncate(f.name, 30),
type: f.type,
options: summarizeFieldOptions(f),
}))
);
const selectFields = list.filter((f) =>
Array.isArray((f.options as Record<string, unknown> | undefined)?.choices)
);
for (const f of selectFields) {
const rows = 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 {
printTable(
[
{ key: 'id', header: 'ID' },
{ key: 'name', header: 'NAME' },
{ key: 'type', header: 'TYPE' },
],
list.map((f) => ({
id: truncate(f.id),
name: 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(
run(async (ctx, args) => {
const [tableId, fieldId] = args as [string, string];
const field = await ctx.client.getField(tableId, fieldId);
const [tableId, fieldIdOrName] = args as [string, string];
const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName);
if (ctx.json) printJson(field);
else {
const f = field as Record<string, unknown>;
printKv(Object.entries(f).map(([k, v]) => [k, v]));
const choices = (field.options as Record<string, unknown> | undefined)?.choices;
const names = Array.isArray(choices)
? await fieldNameMap(ctx.client, tableId)
: undefined;
process.stdout.write(formatFieldDetail(field, names) + '\n');
}
})
);
@@ -250,9 +331,15 @@ function fieldCmd(): Command {
'--type <type>',
'Field type — full value (singleLineText, singleSelect, link…) or alias (text, sel, num…). Omitted: inferred from --options shape / --name'
)
.option('--options <json>', 'Field options JSON (type-specific; select choices accept plain strings)')
.option(
'--options <json>',
'Field options JSON (type-specific; select choices accept plain strings)'
)
.option('--description <d>', 'Field description')
.option('--lookup-options <json>', 'Lookup options JSON (names like linkFieldName/lookupFieldName are resolved to IDs)')
.option(
'--lookup-options <json>',
'Lookup options JSON (names like linkFieldName/lookupFieldName are resolved to IDs)'
)
.option('--is-lookup', 'Mark the field as a lookup field')
.action(
run(async (ctx, _args, o) => {
@@ -295,28 +382,32 @@ function fieldCmd(): Command {
);
cmd
.command('update <tableId> <fieldId>')
.command('update <tableId> <fieldIdOrName>')
.description(
'Update a field. --name renames (PATCH); --options rewrites options via the convert API (the update API does not accept options)'
'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(
run(async (ctx, args, o) => {
const [tableId, fieldId] = args as [string, string];
const [tableId, fieldIdOrName] = args as [string, string];
const opts = o as { name?: string; options?: string };
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)) as {
id: string;
};
const field = await ctx.client.updateField(tableId, current.id, { name: opts.name });
if (ctx.json) printJson(field);
else process.stdout.write(`✓ Field ${fieldId} renamed\n`);
else 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)) as {
const current = (await getFieldFlexible(ctx.client, tableId, fieldIdOrName)) as {
id: string;
type: string;
};
const body: Record<string, unknown> = {
@@ -325,21 +416,22 @@ function fieldCmd(): Command {
};
if (opts.name) body.name = opts.name;
normalizeFieldRo(body);
const field = await ctx.client.convertField(tableId, fieldId, body);
const field = await ctx.client.convertField(tableId, current.id, body);
if (ctx.json) printJson(field);
else process.stdout.write(`✓ Field ${fieldId} options updated (via convert)\n`);
else 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(
run(async (ctx, args) => {
const [tableId, recordId, fieldId] = args as [string, string, string];
const res = await ctx.client.getVisibleChoices(tableId, recordId, fieldId);
const [tableId, recordId, fieldIdOrName] = args as [string, string, string];
const field = await getFieldFlexible(ctx.client, tableId, fieldIdOrName);
const res = await ctx.client.getVisibleChoices(tableId, recordId, String(field.id));
if (ctx.json) printJson(res);
else {
printTable(
@@ -634,3 +726,174 @@ function viewCmd(): Command {
return cmd;
}
// ===================== view preset =====================
/** Parse a --filter/--sort/... JSON option; empty/absent → undefined. */
function parseJsonOption(value: string | undefined, optionName: string): unknown {
if (value == null || value === '') return undefined;
try {
return JSON.parse(value);
} catch (err) {
throw new Error(`Invalid JSON for ${optionName}: ${(err as Error).message}`);
}
}
interface IPresetOptions {
tableId: string;
viewId?: string;
name?: string;
description?: string;
filter?: string;
sort?: string;
group?: string;
options?: string;
columnMeta?: string;
}
/** Collect preset config keys from options; absent keys stay out of the config. */
export function presetConfigFromOptions(opts: IPresetOptions): Record<string, unknown> {
const config: Record<string, unknown> = {};
const entries: Array<[keyof IPresetOptions, string]> = [
['filter', '--filter'],
['sort', '--sort'],
['group', '--group'],
['options', '--options'],
['columnMeta', '--column-meta'],
];
for (const [key, optionName] of entries) {
const parsed = parseJsonOption(opts[key] as string | undefined, optionName);
if (parsed !== undefined) config[key] = parsed;
}
return config;
}
function presetCmd(): Command {
const cmd = new 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(
run(async (ctx, args, o) => {
const tableId = args[0] as string;
const opts = o as unknown as IPresetOptions;
const list = (await ctx.client.listViewPresets(tableId, opts.viewId)) as Array<
Record<string, unknown>
>;
if (ctx.json) printJson(list);
else {
if (!list.length) {
process.stdout.write('No view presets found.\n');
return;
}
printTable(
[
{ key: 'id', header: 'ID' },
{ key: 'name', header: 'NAME' },
{ key: 'config', header: 'CONFIG' },
],
list.map((p) => {
const config = (p.config ?? {}) as Record<string, unknown>;
const keys = Object.keys(config).filter((k) => config[k] != null);
return {
id: truncate(p.id),
name: 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(
run(async (ctx, _args, o) => {
const opts = o as unknown as IPresetOptions;
const body: Record<string, unknown> = {
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)) as Record<
string,
unknown
>;
if (ctx.json) printJson(preset);
else {
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(
run(async (ctx, args, o) => {
const presetId = args[0] as string;
const opts = o as unknown as IPresetOptions;
const body: Record<string, unknown> = {};
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) 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(
run(async (ctx, args, o) => {
const presetId = args[0] as string;
const opts = o as { tableId: string };
await ctx.client.deleteViewPreset(opts.tableId, presetId);
if (ctx.json) printJson({ ok: true, id: presetId });
else process.stdout.write(`✓ Preset ${presetId} deleted\n`);
})
);
return cmd;
}
+19
View File
@@ -166,6 +166,25 @@ export class Client {
return this.call<{ id: string }>('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: string, viewId?: string) {
return this.call<unknown[]>(
'GET',
`/api/table/${tableId}/view-preset${viewId ? `?viewId=${encodeURIComponent(viewId)}` : ''}`
);
}
createViewPreset(tableId: string, body: unknown) {
return this.call<unknown>('POST', `/api/table/${tableId}/view-preset`, body);
}
updateViewPreset(tableId: string, presetId: string, body: unknown) {
return this.call<unknown>('PUT', `/api/table/${tableId}/view-preset/${presetId}`, body);
}
deleteViewPreset(tableId: string, presetId: string) {
return this.call<void>('DELETE', `/api/table/${tableId}/view-preset/${presetId}`);
}
// ---- workflow (self-built contract) ----
listWorkflows(baseId: string) {
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow`);
+194
View File
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest';
import {
summarizeFieldOptions,
describeChoiceCondition,
choiceRows,
formatFieldDetail,
} from './field-display';
describe('summarizeFieldOptions (field list --with-options column)', () => {
it('counts select choices without conditions', () => {
const field = {
type: 'singleSelect',
options: { choices: [{ name: 'A' }, { name: 'B' }, { name: 'C' }] },
};
expect(summarizeFieldOptions(field)).toBe('3 choices');
});
it('counts conditional choices (visibilityFilter)', () => {
const field = {
type: 'singleSelect',
options: {
choices: [
{ name: 'A' },
{ name: 'B', visibilityFilter: { conjunction: 'and', filterSet: [] } },
{ name: 'C', visibilityFilter: { conjunction: 'and', filterSet: [] } },
],
},
};
expect(summarizeFieldOptions(field)).toBe('3 choices · 2 conditional');
});
it('summarizes rating options', () => {
expect(summarizeFieldOptions({ type: 'rating', options: { max: 5, icon: 'star' } })).toBe(
'max=5 icon=star'
);
});
it('summarizes number precision', () => {
expect(
summarizeFieldOptions({
type: 'number',
options: { formatting: { precision: 2 } },
})
).toBe('precision=2');
});
it('summarizes date formatting', () => {
expect(
summarizeFieldOptions({
type: 'date',
options: { formatting: { date: 'YYYY-MM-DD', time: 'HH:mm' } },
})
).toBe('YYYY-MM-DD HH:mm');
});
it('returns dash when no recognizable options', () => {
expect(summarizeFieldOptions({ type: 'singleLineText' })).toBe('—');
expect(summarizeFieldOptions({ type: 'checkbox', options: {} })).toBe('—');
});
});
describe('describeChoiceCondition (visibilityFilter summary)', () => {
it('returns (always) when no filter', () => {
expect(describeChoiceCondition(undefined)).toBe('(always)');
expect(describeChoiceCondition(null)).toBe('(always)');
});
it('renders a single is rule with resolved field name', () => {
const vf = {
conjunction: 'and',
filterSet: [{ fieldId: 'fldA', operator: 'is', value: '物流问题-丢件' }],
};
expect(describeChoiceCondition(vf, { fldA: '问题类型' })).toBe('问题类型 is 物流问题-丢件');
});
it('falls back to fieldId when no name map', () => {
const vf = {
conjunction: 'and',
filterSet: [{ fieldId: 'fldA', operator: 'is', value: 'X' }],
};
expect(describeChoiceCondition(vf)).toBe('fldA is X');
});
it('joins rules with AND / OR and array values with |', () => {
const vfOr = {
conjunction: 'or',
filterSet: [
{ fieldId: 'fldA', operator: 'is', value: ['A1', 'A2'] },
{ fieldId: 'fldA', operator: 'is', value: 'B' },
],
};
expect(describeChoiceCondition(vfOr, { fldA: '问题类型' })).toBe(
'问题类型 is A1|A2 OR 问题类型 is B'
);
const vfAnd = {
conjunction: 'and',
filterSet: [
{ fieldId: 'fldA', operator: 'isNot', value: 'X' },
{ fieldId: 'fldB', operator: 'isNotEmpty', value: null },
],
};
expect(describeChoiceCondition(vfAnd, { fldA: '甲', fldB: '乙' })).toBe(
'甲 isNot X AND 乙 isNotEmpty'
);
});
it('truncates long rule lists with +N more', () => {
const vf = {
conjunction: 'or',
filterSet: Array.from({ length: 6 }, (_, i) => ({
fieldId: 'fldA',
operator: 'is',
value: `value-number-${i}`,
})),
};
const out = describeChoiceCondition(vf, { fldA: 'F' });
// 4 rules fit the budget → the marker must report exactly the 2 omitted.
expect(out).toMatch(/\+2 more$/);
expect(out).not.toContain('value-number-4');
expect(out).not.toContain('value-number-5');
});
});
describe('choiceRows (select field → display rows)', () => {
it('maps choices to name/color/when rows', () => {
const field = {
type: 'singleSelect',
options: {
choices: [
{ name: 'A', color: 'red' },
{
name: 'B',
visibilityFilter: {
conjunction: 'and',
filterSet: [{ fieldId: 'fldX', operator: 'is', value: 'V' }],
},
},
],
},
};
const rows = choiceRows(field, { fldX: 'X' });
expect(rows).toEqual([
{ name: 'A', color: 'red', when: '(always)' },
{ name: 'B', color: '', when: 'X is V' },
]);
});
it('returns [] for non-select fields', () => {
expect(choiceRows({ type: 'number', options: {} })).toEqual([]);
expect(choiceRows({ type: 'singleSelect', options: {} })).toEqual([]);
});
});
describe('formatFieldDetail (field get human mode)', () => {
it('expands select choices into lines with conditions', () => {
const field = {
id: 'fld1',
name: '处理反馈',
type: 'singleSelect',
unique: false,
options: {
defaultValue: 'A',
choices: [
{ name: 'A', color: 'blue' },
{
name: 'B',
visibilityFilter: {
conjunction: 'and',
filterSet: [{ fieldId: 'fldT', operator: 'is', value: '丢件' }],
},
},
],
},
};
const out = formatFieldDetail(field, { fldT: '问题类型' });
expect(out).toContain('处理反馈');
expect(out).toMatch(/choices \(2 · 1 conditional\)/);
expect(out).toContain('(always)');
expect(out).toContain('问题类型 is 丢件');
expect(out).toContain('defaultValue'); // non-choice options still visible
});
it('keeps non-select options as a JSON line', () => {
const field = {
id: 'fld2',
name: 'Score',
type: 'rating',
options: { max: 5, icon: 'star' },
};
const out = formatFieldDetail(field);
expect(out).toContain('max');
expect(out).toContain('"max":5');
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* Human-mode display helpers for field options — make select-choice
* constraints (choices, colors, per-choice visibilityFilter) readable
* without `--json` post-processing.
*/
const CONDITION_MAX_LEN = 90;
/** Summarize a field's options for the `field list --with-options` column. */
export function summarizeFieldOptions(field: Record<string, unknown>): string {
const options = field.options as Record<string, unknown> | undefined;
const choices = options?.choices;
if (Array.isArray(choices) && choices.length) {
const conditional = choices.filter(
(c) => c && typeof c === 'object' && (c as { visibilityFilter?: unknown }).visibilityFilter
).length;
return conditional
? `${choices.length} choices · ${conditional} conditional`
: `${choices.length} choices`;
}
if (!options || typeof options !== 'object') return '—';
const type = field.type as string | undefined;
if (type === 'rating') {
return [
`max=${options.max ?? 5}`,
typeof options.icon === 'string' ? `icon=${options.icon}` : '',
]
.filter(Boolean)
.join(' ');
}
const formatting = options.formatting as Record<string, unknown> | undefined;
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 '—';
}
interface IFilterItem {
fieldId?: string;
operator?: string;
value?: unknown;
}
function formatValue(value: unknown): string {
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. */
export function describeChoiceCondition(vf: unknown, fieldNames?: Record<string, string>): string {
if (!vf || typeof vf !== 'object') return '(always)';
const { conjunction, filterSet } = vf as { conjunction?: string; filterSet?: IFilterItem[] };
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;
}
export interface IChoiceRow {
name: string;
color: string;
when: string;
}
/** Choices of a select field as display rows; `[]` when the field has none. */
export function choiceRows(
field: Record<string, unknown>,
fieldNames?: Record<string, string>
): IChoiceRow[] {
const choices = (field.options as Record<string, unknown> | undefined)?.choices;
if (!Array.isArray(choices)) return [];
return choices
.filter((c): c is Record<string, unknown> => !!c && typeof c === 'object')
.map((c) => ({
name: String(c.name ?? ''),
color: typeof c.color === 'string' ? c.color : '',
when: describeChoiceCondition(c.visibilityFilter, fieldNames),
}));
}
function kvLine(key: string, value: unknown, keyWidth: number): string {
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. */
export function formatFieldDetail(
field: Record<string, unknown>,
fieldNames?: Record<string, string>
): string {
const lines: string[] = [];
const options = field.options as Record<string, unknown> | undefined;
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 as { visibilityFilter?: unknown }).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');
}