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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user