Skip to content

Commit 0fd0d04

Browse files
feat: support visible_rule for form questions
Form questions can now carry a visible_rule (display condition) so a question shows only when earlier questions match the rule. The rule shares the exact same structure as the view filter, so extract that structure into a single shared reference (lark-base-filter-condition.md) that both view-set-filter and visible_rule point to. - create/update shortcuts: document visible_rule in --questions help and transcribe the questions body (including visible_rule) into dry-run output - skill refs: add visible_rule sections to form-questions create/update, note it is only needed when the user asks for a display condition, and that form-questions-list returns it verbatim - tests: pin flag help, verbatim visible_rule passthrough on create/update/list, and add dry-run E2E coverage
1 parent 37d490a commit 0fd0d04

11 files changed

Lines changed: 429 additions & 157 deletions

shortcuts/base/base_form_execute_test.go

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package base
55

66
import (
7+
"encoding/json"
78
"strings"
89
"testing"
910

@@ -250,17 +251,23 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
250251
"total": 2,
251252
"questions": []interface{}{
252253
map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil},
253-
map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil},
254+
map[string]interface{}{"id": "q_002", "title": "发票抬头", "required": false, "description": nil,
255+
"visible_rule": map[string]interface{}{"logic": "and", "conditions": []interface{}{[]interface{}{"q_001", "==", "是"}}}},
254256
},
255257
},
256258
},
257259
})
258260
if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil {
259261
t.Fatalf("err=%v", err)
260262
}
261-
if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
263+
got := stdout.String()
264+
if !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
262265
t.Fatalf("stdout=%s", got)
263266
}
267+
// The list output must forward visible_rule verbatim so agents can read existing display conditions.
268+
if !strings.Contains(got, `"visible_rule"`) {
269+
t.Fatalf("visible_rule missing from list output: %s", got)
270+
}
264271
}
265272

266273
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
@@ -296,11 +303,49 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
296303
t.Fatalf("expected error for invalid questions JSON")
297304
}
298305
})
306+
307+
t.Run("visible_rule passthrough", func(t *testing.T) {
308+
factory, stdout, reg := newExecuteFactory(t)
309+
stub := &httpmock.Stub{
310+
Method: "POST",
311+
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
312+
Body: map[string]interface{}{
313+
"code": 0,
314+
"data": map[string]interface{}{
315+
"questions": []interface{}{
316+
map[string]interface{}{"id": "q_new1", "title": "发票抬头"},
317+
},
318+
},
319+
},
320+
}
321+
reg.Register(stub)
322+
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
323+
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`}
324+
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
325+
t.Fatalf("err=%v", err)
326+
}
327+
var body struct {
328+
Questions []map[string]interface{} `json:"questions"`
329+
}
330+
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
331+
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
332+
}
333+
if len(body.Questions) != 1 {
334+
t.Fatalf("questions=%#v", body.Questions)
335+
}
336+
rule, ok := body.Questions[0]["visible_rule"].(map[string]interface{})
337+
if !ok {
338+
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
339+
}
340+
if rule["logic"] != "and" {
341+
t.Fatalf("visible_rule logic not preserved: %#v", rule)
342+
}
343+
})
299344
}
300345

301346
func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
302347
factory, stdout, reg := newExecuteFactory(t)
303-
reg.Register(&httpmock.Stub{
348+
stub := &httpmock.Stub{
304349
Method: "PATCH",
305350
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
306351
Body: map[string]interface{}{
@@ -311,15 +356,29 @@ func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
311356
},
312357
},
313358
},
314-
})
359+
}
360+
reg.Register(stub)
315361
args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
316-
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`}
362+
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true,"visible_rule":{"logic":"and","conditions":[["q_002","==","是"]]}}]`}
317363
if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil {
318364
t.Fatalf("err=%v", err)
319365
}
320366
if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) {
321367
t.Fatalf("stdout=%s", got)
322368
}
369+
// visible_rule must be forwarded verbatim to the API (transcribe faithfully).
370+
var body struct {
371+
Questions []map[string]interface{} `json:"questions"`
372+
}
373+
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
374+
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
375+
}
376+
if len(body.Questions) != 1 {
377+
t.Fatalf("questions=%#v", body.Questions)
378+
}
379+
if _, ok := body.Questions[0]["visible_rule"].(map[string]interface{}); !ok {
380+
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
381+
}
323382
}
324383

325384
func TestBaseFormQuestionsExecuteDelete(t *testing.T) {

shortcuts/base/base_form_questions_create.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,21 @@ var BaseFormQuestionsCreate = common.Shortcut{
2525
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
2626
{Name: "table-id", Desc: "table ID", Required: true},
2727
{Name: "form-id", Desc: "form ID", Required: true},
28-
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
28+
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
2929
},
3030
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
31-
return common.NewDryRunAPI().
31+
api := common.NewDryRunAPI().
3232
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
3333
Set("base_token", runtime.Str("base-token")).
3434
Set("table_id", runtime.Str("table-id")).
3535
Set("form_id", runtime.Str("form-id"))
36+
// Transcribe the questions body verbatim so the preview shows exactly
37+
// what would be sent (including optional fields like visible_rule).
38+
var questions []interface{}
39+
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
40+
api.Body(map[string]interface{}{"questions": questions})
41+
}
42+
return api
3643
},
3744
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
3845
baseToken := runtime.Str("base-token")

shortcuts/base/base_form_questions_update.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,21 @@ var BaseFormQuestionsUpdate = common.Shortcut{
2525
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
2626
{Name: "table-id", Desc: "table ID", Required: true},
2727
{Name: "form-id", Desc: "form ID", Required: true},
28-
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
28+
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, pass null to clear). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
2929
},
3030
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
31-
return common.NewDryRunAPI().
31+
api := common.NewDryRunAPI().
3232
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
3333
Set("base_token", runtime.Str("base-token")).
3434
Set("table_id", runtime.Str("table-id")).
3535
Set("form_id", runtime.Str("form-id"))
36+
// Transcribe the questions body verbatim so the preview shows exactly
37+
// what would be sent (including optional fields like visible_rule).
38+
var questions []interface{}
39+
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
40+
api.Body(map[string]interface{}{"questions": questions})
41+
}
42+
return api
3643
},
3744
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
3845
baseToken := runtime.Str("base-token")

shortcuts/base/base_shortcuts_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,20 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
743743
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
744744
},
745745
},
746+
{
747+
name: "form question create visible_rule",
748+
shortcut: BaseFormQuestionsCreate,
749+
wantHelp: []string{
750+
`"visible_rule"(display condition; same shape as view filter`,
751+
},
752+
},
753+
{
754+
name: "form question update visible_rule",
755+
shortcut: BaseFormQuestionsUpdate,
756+
wantHelp: []string{
757+
`"visible_rule"(display condition; same shape as view filter`,
758+
},
759+
},
746760
{
747761
name: "record search json",
748762
shortcut: BaseRecordSearch,

skills/lark-base/SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,12 @@ metadata:
5757
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md)[lark-base-cell-value.md](references/lark-base-cell-value.md) |
5858
| 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 |
5959
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 |
60-
| 管理视图 | `+view-*` | `+view-set-filter`[lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);其余配置先 get 现状,再按返回结构更新 |
60+
| 管理视图 | `+view-*` | `+view-set-filter`[lark-base-view-set-filter.md](references/lark-base-view-set-filter.md)(filter 条件结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md);其余配置先 get 现状,再按返回结构更新 |
6161
| 一次性聚合统计 | `+data-query` | 必读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 和入口 [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md);完整 DSL 再读 [lark-base-data-query.md](references/lark-base-data-query.md) |
6262
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
6363
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
6464
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
65-
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` |[lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) |
65+
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` |[lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
6666
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail`[lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 |
6767
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config`[dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
6868
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 |
@@ -151,6 +151,7 @@ metadata:
151151
- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充
152152
- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释
153153
- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON
154+
- [lark-base-filter-condition.md](references/lark-base-filter-condition.md):filter / 表单 `visible_rule` 条件结构公共协议 SSOT
154155
- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON
155156
- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议
156157
- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md):workflow 入口与 steps JSON SSOT
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# Base Filter 条件结构(公共协议)
2+
3+
两者 JSON 结构完全一致,仅「条件引用的对象」不同:视图 `filter` 引用**数据表字段**,表单 `visible_rule` 引用**同一表单内的其他题目**
4+
5+
## 1. 顶层结构
6+
7+
- 必须是 JSON 对象。
8+
- 顶层结构是 `{logic?, conditions?}`
9+
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`
10+
- `conditions` 默认空数组。
11+
- 每条条件写成 tuple:`[field, operator, value?]`
12+
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]``[field, "non_empty"]`
13+
14+
```json
15+
{
16+
"logic": "and",
17+
"conditions": [
18+
["状态", "intersects", ["Doing"]],
19+
["负责人", "intersects", [{ "id": "ou_xxx" }]],
20+
["截止时间", "empty"]
21+
]
22+
}
23+
```
24+
25+
清空写法:
26+
27+
```json
28+
{
29+
"conditions": []
30+
}
31+
```
32+
33+
## 2. operator
34+
35+
可用 operator:
36+
- `==`
37+
- `!=`
38+
- `>`
39+
- `>=`
40+
- `<`
41+
- `<=`
42+
- `intersects`
43+
- `disjoint`
44+
- `empty`
45+
- `non_empty`
46+
47+
## 3. value 写法
48+
49+
value 类型取决于条件引用对象(字段 / 题目)的类型。
50+
51+
### `text`
52+
53+
用字符串:
54+
55+
```json
56+
["标题", "intersects", "发布"]
57+
```
58+
59+
### `location`
60+
61+
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
62+
63+
```json
64+
["位置", "intersects", "深圳"]
65+
```
66+
67+
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
68+
69+
### `number` / `auto_number`
70+
71+
用数字:
72+
73+
```json
74+
["工时", ">=", 3.5]
75+
```
76+
77+
### `select`
78+
79+
用选项名数组:
80+
81+
```json
82+
["状态", "intersects", ["Doing", "Blocked"]]
83+
```
84+
85+
### `user` / `created_by` / `updated_by`
86+
87+
用对象数组:
88+
89+
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id:`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`
90+
91+
```json
92+
["负责人", "intersects", [{ "id": "ou_xxx" }]]
93+
```
94+
95+
### `group_chat`
96+
97+
用对象数组:
98+
99+
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`
100+
101+
```json
102+
["负责群", "intersects", [{ "id": "oc_xxx" }]]
103+
```
104+
105+
### `link`
106+
107+
用记录 id 对象数组:
108+
109+
```json
110+
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
111+
```
112+
113+
### `checkbox`
114+
115+
用布尔值:
116+
117+
```json
118+
["完成", "==", true]
119+
```
120+
121+
### `datetime` / `created_at` / `updated_at`
122+
123+
用相对时间关键字或 `ExactDate(...)`
124+
125+
```json
126+
["截止时间", "==", "ExactDate(2026-01-01)"]
127+
```
128+
129+
```json
130+
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
131+
```
132+
133+
```json
134+
["截止时间", "==", "Today"]
135+
```
136+
137+
可用关键字:
138+
- `Today`
139+
- `Yesterday`
140+
- `Tomorrow`
141+
142+
### `formula` / `lookup`
143+
144+
- 筛选值类型由字段计算结果类型动态决定。
145+
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
146+
- 如果报错,再按错误提示把 `value` 改成对应类型。
147+
148+
字符串示例:
149+
150+
```json
151+
["风险说明", "intersects", "高风险"]
152+
```
153+
154+
数字示例:
155+
156+
```json
157+
["汇总分", ">=", 80]
158+
```
159+
160+
## 4. 易错点
161+
162+
- 不要再写旧对象风格:`{"field_name":...,"operator":...}`
163+
- `user` / `group_chat` / `link` 不要写成单个标量。
164+
- `empty` / `non_empty` 不要硬塞无意义的 value。
165+
- 日期条件稳定写法用 `ExactDate(...)``Today` / `Yesterday` / `Tomorrow`
166+
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前配置或字段定义,或根据错误提示修正类型。
167+
168+
## 5. 参考
169+
- [lookup-field-guide.md](lookup-field-guide.md)

0 commit comments

Comments
 (0)