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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user