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
+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