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:
@@ -44,20 +44,7 @@ inkreach-teable table create --name "<name>" [--base-id <id>] \
|
||||
| `--fields` | no | Array of field objects (see below) |
|
||||
| `--description` | no | Optional description |
|
||||
|
||||
**`--fields` format**: JSON array of `{name, type, [options], [description]}` objects. Allowed types and aliases:
|
||||
|
||||
| Shorthand | Full type | Shorthand | Full type |
|
||||
|-----------|-----------|-----------|-----------|
|
||||
| `text`, `str` | `singleLineText` | `long` | `longText` |
|
||||
| `num`, `int` | `number` | `check`, `bool` | `checkbox` |
|
||||
| `sel`, `select` | `singleSelect` | `multi`, `tags` | `multipleSelect` |
|
||||
| `date` | `date` | `rate`, `star` | `rating` |
|
||||
| `user`, `person` | `user` | `file`, `attach` | `attachment` |
|
||||
| `link`, `rel` | `link` | `rollup` | `rollup` |
|
||||
| `formula` | `formula` | `auto` | `autoNumber` |
|
||||
| `created` | `createdTime` | `modified` | `lastModifiedTime` |
|
||||
| `createdBy` | `createdBy` | `modifiedBy` | `lastModifiedBy` |
|
||||
| `button` | `button` | `condRollup` | `conditionalRollup` |
|
||||
**`--fields` format**: JSON array of `{name, type, [options], [description]}` objects. Types accept full values or aliases (`sel` → `singleSelect`); per-type option shapes and inference rules: [field-types.md](../api-reference/field-types.md).
|
||||
|
||||
**Compact shorthand** (in `--fields` only): `"name:type"` string format:
|
||||
|
||||
@@ -103,35 +90,10 @@ inkreach-teable field create --table-id <tid> --name "<name>" \
|
||||
|------|----------|-------|
|
||||
| `--table-id` | yes | Target table |
|
||||
| `--name` | yes | Field display name |
|
||||
| `--type` | yes | Full type name or alias (see [field-types.md](../api-reference/field-types.md)) |
|
||||
| `--options` | depends on type | JSON object; see type-specific shapes below |
|
||||
| `--type` | no | Full type name or alias; **omitted → inferred** from `--options` shape or `--name` pattern (rules: [field-types.md § Smart Inference](../api-reference/field-types.md)) |
|
||||
| `--options` | depends on type | JSON object; select `choices` accept plain strings — see [field-types.md § Option Shapes](../api-reference/field-types.md) |
|
||||
| `--description` | no | Optional |
|
||||
|
||||
**Type inference from name**: when `--type` is omitted, the CLI infers from the field name:
|
||||
|
||||
| Name pattern | Inferred type | Name pattern | Inferred type |
|
||||
|-------------|---------------|-------------|---------------|
|
||||
| `status`, `priority`, `stage`, `category`, `type`, `level` | `singleSelect` | `tag`, `label` | `multipleSelect` |
|
||||
| `description`, `notes`, `comment`, `content`, `body` | `longText` | `count`, `amount`, `price`, `quantity`, `total`, `age` | `number` |
|
||||
| `rating`, `score`, `stars` | `rating` | `is*`, `has*`, `active`, `enabled`, `done`, `completed` | `checkbox` |
|
||||
| `assignee`, `owner`, `user` | `user` | `attachment`, `file`, `image`, `photo`, `document` | `attachment` |
|
||||
| `email` | `singleLineText` | `url`, `link` (without table context) | `singleLineText` |
|
||||
| `created time` / `created` | `createdTime` | `last modified` / `updated` | `lastModifiedTime` |
|
||||
| `created by`, `author` | `createdBy` | `modified by`, `updated by` | `lastModifiedBy` |
|
||||
|
||||
**Options by type** (most common):
|
||||
|
||||
| Type | Required options | Shape |
|
||||
|------|-----------------|-------|
|
||||
| `singleSelect` / `multipleSelect` | `choices` | `{"choices":["A","B","C"]}` or `{"choices":[{"name":"A","color":"blue"}]}` — colors auto-assigned if omitted |
|
||||
| `number` | none (defaults integer) | `{"formatting":{"precision":2}}` for decimal |
|
||||
| `rating` | none (defaults max=5, star, yellow) | `{"max":5,"icon":"star","color":"yellowBright"}` |
|
||||
| `date` | none | `{"formatting":{"date":"YYYY-MM-DD","time":"None","timeZone":"UTC"}}` |
|
||||
| `link` | `foreignTableId` / `foreignTableName` | `{"foreignTableId":"tblXXX","relationship":"manyOne"}` |
|
||||
| `formula` | `expression` | `{"expression":"{fieldId}+{otherFieldId}"}` |
|
||||
| `rollup` | via `--lookup-options` | See [field-types.md](../api-reference/field-types.md) |
|
||||
| `button` | `workflow` | `{"label":"Click","color":"blue","workflow":{"id":"wflXXX"}}` |
|
||||
|
||||
**Name resolution**: for `link` fields, use `foreignTableName` instead of `foreignTableId` — the CLI resolves it automatically:
|
||||
|
||||
```bash
|
||||
@@ -142,7 +104,7 @@ inkreach-teable field create --table-id tblXXX --name "Project" --type link \
|
||||
### Update & Delete
|
||||
|
||||
```bash
|
||||
inkreach-teable field update <tableId> <fieldId> [--name "<n>"] [--options '<json>']
|
||||
inkreach-teable field update <tableId> <fieldIdOrName> [--name "<n>"] [--options '<json>']
|
||||
inkreach-teable field delete <tableId> <fieldId>
|
||||
```
|
||||
|
||||
@@ -153,6 +115,27 @@ inkreach-teable field delete <tableId> <fieldId>
|
||||
| `--name` + `--options` | Both applied via one convert call |
|
||||
| `field delete` | Irreversible — all cell data in that field is lost |
|
||||
|
||||
### Inspecting fields & constraints
|
||||
|
||||
`field get` and `field visible-choices` accept **fieldId or field name** (the CLI resolves names automatically). Human mode renders select choices with their conditions — no `--json` post-processing needed:
|
||||
|
||||
```bash
|
||||
# One-field overview: which fields carry conditional choices
|
||||
inkreach-teable field list tblXXX --with-options
|
||||
# ID NAME TYPE OPTIONS
|
||||
# ... 处理反馈 singleSelect 21 choices · 19 conditional
|
||||
# followed by per-choice listings: name [color] + "when: <condition summary>"
|
||||
|
||||
# Single field by name — choices expanded with human-readable conditions
|
||||
inkreach-teable field get tblXXX 处理反馈
|
||||
# choices (21 · 19 conditional):
|
||||
# 待工厂补发 [orange]
|
||||
# when: 问题类型 is 物流问题-超时未揽收 OR 问题类型 is 物流问题-丢件 …
|
||||
|
||||
# What a specific record may currently select
|
||||
inkreach-teable field visible-choices tblXXX recXXX 处理反馈
|
||||
```
|
||||
|
||||
### Select-choice dependency (visibilityFilter) — 2026-08
|
||||
|
||||
Single/multiple-select choices may carry a `visibilityFilter` making them visible only when another select field matches:
|
||||
@@ -162,9 +145,10 @@ Single/multiple-select choices may carry a `visibilityFilter` making them visibl
|
||||
"filterSet":[{"fieldId":"fldDeviceType","operator":"is","value":"choPrinter"}]}}]}
|
||||
```
|
||||
|
||||
- **Read config**: `inkreach-teable field get <tableId> <fieldId>` → `options.choices[].visibilityFilter`
|
||||
- **Read config**: `field get <tableId> <fieldIdOrName>` → `options.choices[].visibilityFilter` (rendered human-readable in default mode; raw JSON with `--json`)
|
||||
- **Survey a whole table**: `field list <tableId> --with-options` shows a constraint count per field plus per-choice conditions
|
||||
- **Write config**: `field update --options` (convert API). Backend rejects self-reference (`Self-reference in visibilityFilter is not allowed`) and dependency cycles (`Dependency cycle detected…`) with 400 — enforced on both V1 and V2 paths since fix `26ed1f732`
|
||||
- **Evaluate for a record**: `inkreach-teable field visible-choices <tableId> <recordId> <fieldId>` → choices currently selectable
|
||||
- **Evaluate for a record**: `field visible-choices <tableId> <recordId> <fieldIdOrName>` → choices currently selectable
|
||||
- **Write-time enforcement**: writing a record value that fails its visibility condition → 400 `Option "X" does not meet the current row conditions and cannot be selected.`
|
||||
|
||||
---
|
||||
@@ -346,6 +330,42 @@ Deleting a view does NOT delete the table or its data — only the view configur
|
||||
|
||||
---
|
||||
|
||||
## View Presets
|
||||
|
||||
Presets are **view-scoped, named bundles of view configuration** (filter/sort/group, optionally options/columnMeta), stored server-side and shared with all collaborators — each view owns its own preset list. Users apply them from the toolbar preset dropdown (labeled by current state: 公共 / 个人 / active preset name; the old "个人" toggle button was merged into this dropdown). Applying seeds the user's personal-view overlay (persisted server-side per user per view), so operators can work in different presets simultaneously without touching the collaborative view. Managing presets requires table-level view-update permission.
|
||||
|
||||
### Typical use
|
||||
|
||||
One-click screening configs for operators, e.g. "pending orders" vs "in-progress orders" on an order table, without granting view-edit permission.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
# preset screening pending orders (bound to one view)
|
||||
inkreach-teable preset create --table-id tblXxx --view-id viwXxx --name "待处理订单" \
|
||||
--filter '{"conjunction":"and","filterSet":[{"fieldId":"fldStatus","operator":"is","value":"待处理"}]}'
|
||||
|
||||
# preset for in-progress orders, newest first
|
||||
inkreach-teable preset create --table-id tblXxx --view-id viwXxx --name "正在处理的订单" \
|
||||
--filter '{"conjunction":"and","filterSet":[{"fieldId":"fldStatus","operator":"is","value":"处理中"}]}' \
|
||||
--sort '{"sortObjs":[{"fieldId":"fldCreated","order":"desc"}]}'
|
||||
|
||||
inkreach-teable preset list tblXxx --view-id viwXxx
|
||||
inkreach-teable preset update <presetId> --table-id tblXxx --name "新名字" # or replace --filter/--sort/...
|
||||
inkreach-teable preset delete <presetId> --table-id tblXxx
|
||||
```
|
||||
|
||||
### Semantics
|
||||
|
||||
- Config keys you do NOT pass stay **unset**: on apply, the preset keeps the view's current value for those keys (a filter-only preset does not touch the sort).
|
||||
- Keys you DO pass **replace** that aspect on apply.
|
||||
- `--column-meta` snapshots field visibility/order — avoid unless needed: fields added later stay hidden while that preset is active (stale snapshot).
|
||||
- Deleting a preset does not touch views or records; users applying it fall back to the public config on next switch.
|
||||
- REST equivalents: `GET/POST /api/table/:tableId/view-preset` (list accepts `?viewId=`; create requires `viewId`), `PUT/DELETE /api/table/:tableId/view-preset/:presetId` (writes need view-update permission).
|
||||
- Per-user personal config: `GET/PUT/DELETE /api/table/:tableId/view-preset/personal?viewId=` (view-read permission; PUT body `{config, enabled}`) — this is what the UI "个人配置" entry persists.
|
||||
|
||||
---
|
||||
|
||||
## Value Formats Quick Reference
|
||||
|
||||
| Field Type | Value format | Notes |
|
||||
|
||||
Reference in New Issue
Block a user