From 9bdf2517ef85b88a08a6c4287bef7ca099c11b0e Mon Sep 17 00:00:00 2001 From: wanghaomin Date: Fri, 7 Aug 2026 08:05:13 +0000 Subject: [PATCH] feat(base): support button field workflow binding Normalize friendly button field JSON for +field-create, +field-update, and +base-create --fields, add button-specific validation and readback guidance, and cover the workflow-first flow with dry-run and live E2E tests. Co-authored-by: TRAE CLI --- shortcuts/base/base_execute_test.go | 180 ++++++++++++++++++ shortcuts/base/field_create.go | 1 + shortcuts/base/field_ops.go | 138 +++++++++++++- shortcuts/base/field_update.go | 1 + shortcuts/base/helpers_test.go | 138 ++++++++++++++ shortcuts/base/table_ops.go | 22 ++- .../references/lark-base-field-create.md | 33 +++- .../references/lark-base-field-json.md | 41 +++- .../references/lark-base-field-update.md | 31 ++- .../base/base_button_field_workflow_test.go | 163 ++++++++++++++++ tests/cli_e2e/base/base_create_dryrun_test.go | 34 ++++ tests/cli_e2e/base/base_field_dryrun_test.go | 33 ++++ .../base/base_field_update_dryrun_test.go | 24 +++ 13 files changed, 825 insertions(+), 14 deletions(-) create mode 100644 tests/cli_e2e/base/base_button_field_workflow_test.go diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 9c853c8090..cca00a1933 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -857,6 +857,83 @@ func TestBaseFieldExecuteUpdate(t *testing.T) { } } +func TestBaseFieldExecuteUpdateButtonUsesLowLevelTriggerPayload(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "fld_x", + "name": "同步到 CRM", + "type": buttonFieldLowLevelType, + "fieldUIType": buttonFieldUIType, + "property": map[string]interface{}{ + "button": map[string]interface{}{"title": "同步到 CRM", "color": 0}, + "trigger": map[string]interface{}{ + "type": buttonTriggerAutomation, + "config": map[string]interface{}{"id": "wkf_x"}, + }, + }, + }, + }, + } + reg.Register(stub) + + jsonBody := `{"name":"同步到 CRM","type":"button","button":{"title":"同步到 CRM","color":0},"trigger":{"type":"automation","workflow_id":"wkf_x"}}` + if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + + body := decodeCapturedJSONBody(t, stub) + if body["name"] != "同步到 CRM" { + t.Fatalf("request body=%#v", body) + } + if body["type"] != float64(buttonFieldLowLevelType) { + t.Fatalf("request body.type=%#v, want %d", body["type"], buttonFieldLowLevelType) + } + if body["fieldUIType"] != buttonFieldUIType { + t.Fatalf("request body.fieldUIType=%#v, want %q", body["fieldUIType"], buttonFieldUIType) + } + if _, ok := body["button"]; ok { + t.Fatalf("request body must not keep top-level button: %#v", body) + } + if _, ok := body["trigger"]; ok { + t.Fatalf("request body must not keep top-level trigger: %#v", body) + } + + property, ok := body["property"].(map[string]interface{}) + if !ok { + t.Fatalf("request body.property=%#v", body["property"]) + } + button, ok := property["button"].(map[string]interface{}) + if !ok { + t.Fatalf("request body.property.button=%#v", property["button"]) + } + trigger, ok := property["trigger"].(map[string]interface{}) + if !ok { + t.Fatalf("request body.property.trigger=%#v", property["trigger"]) + } + config, ok := trigger["config"].(map[string]interface{}) + if !ok { + t.Fatalf("request body.property.trigger.config=%#v", trigger["config"]) + } + if common.GetString(button, "title") != "同步到 CRM" || button["color"] != float64(0) { + t.Fatalf("request body.property.button=%#v", button) + } + if trigger["type"] != float64(buttonTriggerAutomation) || common.GetString(config, "id") != "wkf_x" { + t.Fatalf("request body.property.trigger=%#v", trigger) + } + + got := stdout.String() + for _, want := range []string{`"updated": true`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} { + if !strings.Contains(got, want) { + t.Fatalf("stdout missing %q:\n%s", want, got) + } + } +} + func TestFieldUpdateResultAlwaysRecommendsReadback(t *testing.T) { tests := []struct { name string @@ -888,6 +965,32 @@ func TestFieldUpdateResultAlwaysRecommendsReadback(t *testing.T) { submitted: map[string]interface{}{"name": "Amount"}, hintContains: []string{"unknown or uncommon field type", "+field-get"}, }, + { + name: "button field still recommends readback", + field: map[string]interface{}{ + "type": buttonFieldLowLevelType, + "fieldUIType": buttonFieldUIType, + "property": map[string]interface{}{ + "button": map[string]interface{}{"title": "同步到 CRM", "color": 0}, + "trigger": map[string]interface{}{ + "type": buttonTriggerAutomation, + "config": map[string]interface{}{"id": "wkf_x"}, + }, + }, + }, + submitted: map[string]interface{}{ + "type": buttonFieldLowLevelType, + "fieldUIType": buttonFieldUIType, + "property": map[string]interface{}{ + "button": map[string]interface{}{"title": "同步到 CRM", "color": 0}, + "trigger": map[string]interface{}{ + "type": buttonTriggerAutomation, + "config": map[string]interface{}{"id": "wkf_x"}, + }, + }, + }, + hintContains: []string{"generated field update", "+field-get"}, + }, } for _, tc := range tests { @@ -1332,6 +1435,83 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { } }) + t.Run("create button field normalizes to low-level payload", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + createStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "fld_btn", + "name": "同步到 CRM", + "type": buttonFieldLowLevelType, + "fieldUIType": buttonFieldUIType, + "property": map[string]interface{}{ + "button": map[string]interface{}{"title": "同步到 CRM", "color": 0}, + "trigger": map[string]interface{}{ + "type": buttonTriggerAutomation, + "config": map[string]interface{}{"id": "wkf_x"}, + }, + }, + }, + }, + } + reg.Register(createStub) + + jsonBody := `{"name":"同步到 CRM","type":"button","button":{"title":"同步到 CRM","color":0},"trigger":{"type":"automation","workflow_id":"wkf_x"}}` + if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", jsonBody}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + + body := decodeCapturedJSONBody(t, createStub) + if body["name"] != "同步到 CRM" { + t.Fatalf("request body=%#v", body) + } + if body["type"] != float64(buttonFieldLowLevelType) { + t.Fatalf("request body.type=%#v, want %d", body["type"], buttonFieldLowLevelType) + } + if body["fieldUIType"] != buttonFieldUIType { + t.Fatalf("request body.fieldUIType=%#v, want %q", body["fieldUIType"], buttonFieldUIType) + } + if _, ok := body["button"]; ok { + t.Fatalf("request body must not keep top-level button: %#v", body) + } + if _, ok := body["trigger"]; ok { + t.Fatalf("request body must not keep top-level trigger: %#v", body) + } + + property, ok := body["property"].(map[string]interface{}) + if !ok { + t.Fatalf("request body.property=%#v", body["property"]) + } + button, ok := property["button"].(map[string]interface{}) + if !ok { + t.Fatalf("request body.property.button=%#v", property["button"]) + } + trigger, ok := property["trigger"].(map[string]interface{}) + if !ok { + t.Fatalf("request body.property.trigger=%#v", property["trigger"]) + } + config, ok := trigger["config"].(map[string]interface{}) + if !ok { + t.Fatalf("request body.property.trigger.config=%#v", trigger["config"]) + } + if common.GetString(button, "title") != "同步到 CRM" || button["color"] != float64(0) { + t.Fatalf("request body.property.button=%#v", button) + } + if trigger["type"] != float64(buttonTriggerAutomation) || common.GetString(config, "id") != "wkf_x" { + t.Fatalf("request body.property.trigger=%#v", trigger) + } + + got := stdout.String() + for _, want := range []string{`"created": true`, `"fld_btn"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} { + if !strings.Contains(got, want) { + t.Fatalf("stdout missing %q:\n%s", want, got) + } + } + }) + t.Run("create array sequentially", func(t *testing.T) { oldDelay := fieldCreateBatchDelay fieldCreateBatchDelay = 0 diff --git a/shortcuts/base/field_create.go b/shortcuts/base/field_create.go index 121117627e..b8f44e2cc5 100644 --- a/shortcuts/base/field_create.go +++ b/shortcuts/base/field_create.go @@ -25,6 +25,7 @@ var BaseFieldCreate = common.Shortcut{ Tips: []string{ `Example text: lark-cli base +field-create --base-token --table-id --json '{"name":"Status","type":"text"}'`, `Example select: lark-cli base +field-create --base-token --table-id --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}'`, + `Example button: create the workflow first, then bind it with lark-cli base +field-create --base-token --table-id --json '{"name":"Sync to CRM","type":"button","button":{"title":"Sync to CRM","color":0},"trigger":{"type":"automation","workflow_id":"wkf_xxx"}}'`, "Agent hint: use the lark-base skill's field-create guide for usage and limits.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index 3198183781..78025af397 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -14,6 +14,13 @@ import ( var fieldCreateBatchDelay = time.Second +const ( + buttonFieldFriendlyType = "button" + buttonFieldLowLevelType = 3001 + buttonFieldUIType = "Button" + buttonTriggerAutomation = 1 +) + func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { offset := runtime.Int("offset") if offset < 0 { @@ -52,7 +59,7 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { pc := newParseCtx(runtime) - body, err := parseJSONObject(pc, runtime.Str("json"), "json") + body, err := parseFieldJSONObject(pc, runtime.Str("json")) if err != nil { return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err)) } @@ -90,8 +97,7 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext) } func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) { - pc := newParseCtx(runtime) - return parseJSONObject(pc, runtime.Str("json"), "json") + return parseFieldJSONObject(newParseCtx(runtime), runtime.Str("json")) } func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command string, body map[string]interface{}) error { @@ -188,6 +194,13 @@ func parseFieldCreateBodies(pc *parseCtx, raw string) ([]map[string]interface{}, if len(bodies) == 0 { return nil, baseFlagErrorf("--json must contain at least one field JSON object") } + for i, body := range bodies { + normalized, normalizeErr := normalizeFieldBody(body) + if normalizeErr != nil { + return nil, baseValidationErrorf("--json item %d: %s", i+1, normalizeErr.Error()) + } + bodies[i] = normalized + } return bodies, nil } @@ -195,7 +208,7 @@ func executeFieldUpdate(runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) baseToken := runtime.Str("base-token") tableIDValue := baseTableID(runtime) - body, err := parseJSONObject(pc, runtime.Str("json"), "json") + body, err := parseFieldJSONObject(pc, runtime.Str("json")) if err != nil { return err } @@ -232,7 +245,7 @@ func fieldCreateBatchResult(result map[string]interface{}, submitted []map[strin func fieldUpdateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} { returnedType := normalizeFieldType(fieldResultType(result["field"])) - submittedType := normalizeFieldType(common.GetString(submitted, "type")) + submittedType := normalizeFieldType(fieldResultType(submitted)) readbackRecommended, reason := fieldUpdateReadbackRecommendation(returnedType, submittedType) return attachFieldReadbackRecommendation(result, readbackRecommended, reason) } @@ -264,14 +277,14 @@ func attachFieldReadbackRecommendation(result map[string]interface{}, readbackRe } func fieldWriteReadbackRecommendation(submitted map[string]interface{}, operation string) (bool, string) { - fieldType := normalizeFieldType(common.GetString(submitted, "type")) + fieldType := normalizeFieldType(fieldResultType(submitted)) return fieldTypeReadbackRecommendation(fieldType, operation) } func fieldTypeReadbackRecommendation(fieldType, operation string) (bool, string) { fieldType = normalizeFieldType(fieldType) switch fieldType { - case "formula", "lookup", "auto_number", "link": + case "formula", "lookup", "auto_number", "link", buttonFieldFriendlyType: return true, fmt.Sprintf("computed, linked, or generated field %s should be verified with +field-get before declaring completion", operation) case "text", "number", "select", "datetime", "checkbox", "user", "group_chat", "attachment", "location": return false, fmt.Sprintf("simple field %s returned successfully; use +field-get only when extra properties or explicit verification are needed", operation) @@ -289,6 +302,9 @@ func fieldResultType(value interface{}) string { if !ok { return "" } + if looksLikeButtonField(field) { + return buttonFieldFriendlyType + } if fieldType := strings.ToLower(strings.TrimSpace(common.GetString(field, "type"))); fieldType != "" { return fieldType } @@ -296,9 +312,117 @@ func fieldResultType(value interface{}) string { if !ok { return "" } + if looksLikeButtonField(nested) { + return buttonFieldFriendlyType + } return strings.ToLower(strings.TrimSpace(common.GetString(nested, "type"))) } +func parseFieldJSONObject(pc *parseCtx, raw string) (map[string]interface{}, error) { + body, err := parseJSONObject(pc, raw, "json") + if err != nil { + return nil, err + } + return normalizeFieldBody(body) +} + +func normalizeFieldBody(body map[string]interface{}) (map[string]interface{}, error) { + cloned := cloneMap(body) + if normalizeFieldType(common.GetString(cloned, "type")) != buttonFieldFriendlyType { + return cloned, nil + } + return normalizeButtonFieldBody(cloned) +} + +func normalizeButtonFieldBody(body map[string]interface{}) (map[string]interface{}, error) { + name := strings.TrimSpace(common.GetString(body, "name")) + if name == "" { + return nil, baseValidationErrorf("button field requires non-empty string \"name\"") + } + + button, ok := body["button"].(map[string]interface{}) + if !ok { + return nil, baseValidationErrorf("button field requires object \"button\"") + } + button = cloneMap(button) + title := strings.TrimSpace(common.GetString(button, "title")) + if title == "" { + title = name + } + button["title"] = title + if _, exists := button["color"]; !exists { + button["color"] = 0 + } + + trigger, ok := body["trigger"].(map[string]interface{}) + if !ok { + return nil, baseValidationErrorf("button field requires object \"trigger\"") + } + trigger = cloneMap(trigger) + triggerType := normalizeFieldType(common.GetString(trigger, "type")) + if triggerType == "" { + triggerType = "automation" + } + if triggerType != "automation" { + return nil, baseValidationErrorf("button field only supports trigger.type %q", "automation") + } + workflowID := strings.TrimSpace(common.GetString(trigger, "workflow_id")) + if workflowID == "" { + config, ok := trigger["config"].(map[string]interface{}) + if ok { + workflowID = strings.TrimSpace(common.GetString(config, "id")) + } + } + if workflowID == "" { + return nil, baseValidationErrorf("button field requires non-empty string \"trigger.workflow_id\"") + } + + normalized := cloneMap(body) + normalized["type"] = buttonFieldLowLevelType + normalized["fieldUIType"] = buttonFieldUIType + delete(normalized, "trigger") + delete(normalized, "button") + + property, _ := normalized["property"].(map[string]interface{}) + property = cloneMap(property) + if property == nil { + property = map[string]interface{}{} + } + property["button"] = button + property["trigger"] = map[string]interface{}{ + "type": buttonTriggerAutomation, + "config": map[string]interface{}{"id": workflowID}, + } + normalized["property"] = property + return normalized, nil +} + +func looksLikeButtonField(field map[string]interface{}) bool { + if strings.EqualFold(strings.TrimSpace(common.GetString(field, "fieldUIType")), buttonFieldUIType) { + return true + } + if fieldType, ok := field["type"].(int); ok && fieldType == buttonFieldLowLevelType { + return true + } + if fieldType, ok := common.GetFloatOK(field, "type"); ok && int(fieldType) == buttonFieldLowLevelType { + return true + } + property, _ := field["property"].(map[string]interface{}) + if property == nil { + return false + } + _, hasButton := property["button"].(map[string]interface{}) + trigger, hasTrigger := property["trigger"].(map[string]interface{}) + if !hasButton || !hasTrigger { + return false + } + config, ok := trigger["config"].(map[string]interface{}) + if !ok { + return false + } + return strings.TrimSpace(common.GetString(config, "id")) != "" +} + func executeFieldDelete(runtime *common.RuntimeContext) error { baseToken := runtime.Str("base-token") tableIDValue := baseTableID(runtime) diff --git a/shortcuts/base/field_update.go b/shortcuts/base/field_update.go index 71aaba7b64..1b7fc7cc6f 100644 --- a/shortcuts/base/field_update.go +++ b/shortcuts/base/field_update.go @@ -28,6 +28,7 @@ var BaseFieldUpdate = common.Shortcut{ `Example text: lark-cli base +field-update --base-token --table-id --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`, `Example select: lark-cli base +field-update --base-token --table-id --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`, `Example auto_number update: lark-cli base +field-update --base-token --table-id --field-id "编号" --json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' --yes`, + `Example button rebind: create the workflow first, then run lark-cli base +field-update --base-token --table-id --field-id "Sync to CRM" --json '{"name":"Sync to CRM","type":"button","button":{"title":"Sync to CRM","color":0},"trigger":{"type":"automation","workflow_id":"wkf_xxx"}}' --yes`, "Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.", `When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers; just submit the target field definition and do not add extra low-level parameters.`, "Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.", diff --git a/shortcuts/base/helpers_test.go b/shortcuts/base/helpers_test.go index 0f1a838d24..bdce3cc51d 100644 --- a/shortcuts/base/helpers_test.go +++ b/shortcuts/base/helpers_test.go @@ -173,6 +173,144 @@ func TestBuildTableFieldBodies(t *testing.T) { } } +func TestNormalizeButtonFieldBody(t *testing.T) { + body, err := normalizeFieldBody(map[string]interface{}{ + "name": "同步到 CRM", + "type": "button", + "button": map[string]interface{}{ + "title": "同步到 CRM", + "color": float64(2), + }, + "trigger": map[string]interface{}{ + "type": "automation", + "workflow_id": "wkf_sync", + }, + }) + if err != nil { + t.Fatalf("err=%v", err) + } + if got := body["type"]; got != buttonFieldLowLevelType { + t.Fatalf("type=%v, want %d", got, buttonFieldLowLevelType) + } + if got := body["fieldUIType"]; got != buttonFieldUIType { + t.Fatalf("fieldUIType=%v, want %q", got, buttonFieldUIType) + } + property, _ := body["property"].(map[string]interface{}) + if property == nil { + t.Fatalf("property missing: %#v", body) + } + button, _ := property["button"].(map[string]interface{}) + if button == nil || button["title"] != "同步到 CRM" || toInt(button["color"]) != 2 { + t.Fatalf("button=%#v", button) + } + trigger, _ := property["trigger"].(map[string]interface{}) + if trigger == nil || toInt(trigger["type"]) != buttonTriggerAutomation { + t.Fatalf("trigger=%#v", trigger) + } + config, _ := trigger["config"].(map[string]interface{}) + if config == nil || config["id"] != "wkf_sync" { + t.Fatalf("config=%#v", config) + } + if _, exists := body["button"]; exists { + t.Fatalf("friendly button payload should be normalized away: %#v", body) + } + if _, exists := body["trigger"]; exists { + t.Fatalf("friendly trigger payload should be normalized away: %#v", body) + } +} + +func TestNormalizeButtonFieldBodyDefaultsAndErrors(t *testing.T) { + body, err := normalizeFieldBody(map[string]interface{}{ + "name": "同步到 CRM", + "type": "button", + "button": map[string]interface{}{}, + "trigger": map[string]interface{}{ + "config": map[string]interface{}{"id": "wkf_sync"}, + }, + }) + if err != nil { + t.Fatalf("err=%v", err) + } + property, _ := body["property"].(map[string]interface{}) + button, _ := property["button"].(map[string]interface{}) + if button["title"] != "同步到 CRM" || toInt(button["color"]) != 0 { + t.Fatalf("button defaults=%#v", button) + } + trigger, _ := property["trigger"].(map[string]interface{}) + if toInt(trigger["type"]) != buttonTriggerAutomation { + t.Fatalf("trigger default=%#v", trigger) + } + + tests := []struct { + name string + body map[string]interface{} + want string + }{ + { + name: "missing button", + body: map[string]interface{}{ + "name": "同步到 CRM", + "type": "button", + "trigger": map[string]interface{}{"workflow_id": "wkf_sync"}, + }, + want: `requires object "button"`, + }, + { + name: "missing workflow_id", + body: map[string]interface{}{ + "name": "同步到 CRM", + "type": "button", + "button": map[string]interface{}{}, + "trigger": map[string]interface{}{"type": "automation"}, + }, + want: `requires non-empty string "trigger.workflow_id"`, + }, + { + name: "unsupported trigger type", + body: map[string]interface{}{ + "name": "同步到 CRM", + "type": "button", + "button": map[string]interface{}{}, + "trigger": map[string]interface{}{"type": "url", "workflow_id": "wkf_sync"}, + }, + want: `only supports trigger.type "automation"`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := normalizeFieldBody(tc.body) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err=%v, want substring %q", err, tc.want) + } + }) + } +} + +func TestFieldResultTypeRecognizesButtonField(t *testing.T) { + if got := fieldResultType(map[string]interface{}{ + "type": float64(buttonFieldLowLevelType), + "fieldUIType": buttonFieldUIType, + "property": map[string]interface{}{ + "button": map[string]interface{}{"title": "同步到 CRM", "color": float64(0)}, + "trigger": map[string]interface{}{"type": float64(buttonTriggerAutomation), "config": map[string]interface{}{"id": "wkf_sync"}}, + }, + }); got != buttonFieldFriendlyType { + t.Fatalf("fieldResultType(button)=%q", got) + } + if got := fieldResultType(map[string]interface{}{ + "field": map[string]interface{}{ + "type": float64(buttonFieldLowLevelType), + "fieldUIType": buttonFieldUIType, + "property": map[string]interface{}{ + "button": map[string]interface{}{"title": "同步到 CRM", "color": float64(0)}, + "trigger": map[string]interface{}{"type": float64(buttonTriggerAutomation), "config": map[string]interface{}{"id": "wkf_sync"}}, + }, + }, + }); got != buttonFieldFriendlyType { + t.Fatalf("fieldResultType(nested button)=%q", got) + } +} + func TestBaseV3Helpers(t *testing.T) { if baseV3Path("/bases/", "app_1", "/tables/", "tbl_1") != "/open-apis/base/v3/bases/app_1/tables/tbl_1" { t.Fatalf("baseV3Path mismatch") diff --git a/shortcuts/base/table_ops.go b/shortcuts/base/table_ops.go index 586bc57287..20433fb44e 100644 --- a/shortcuts/base/table_ops.go +++ b/shortcuts/base/table_ops.go @@ -5,6 +5,7 @@ package base import ( "context" + "fmt" "strings" "github.com/larksuite/cli/shortcuts/common" @@ -144,9 +145,15 @@ func buildTableCreateBody(runtime *common.RuntimeContext, pc *parseCtx, tableNam return nil, err } for idx, item := range fieldItems { - if _, ok := item.(map[string]interface{}); !ok { + fieldBody, ok := item.(map[string]interface{}) + if !ok { return nil, baseValidationErrorf("--fields item %d must be an object", idx+1) } + normalized, normalizeErr := normalizeFieldBody(fieldBody) + if normalizeErr != nil { + return nil, baseValidationErrorf("--fields item %d: %s", idx+1, normalizeErr.Error()) + } + fieldItems[idx] = normalized } if len(fieldItems) > 0 { body["fields"] = fieldItems @@ -164,6 +171,19 @@ func dryRunTableCreateBody(runtime *common.RuntimeContext, tableName string) map body["fields"] = "" return body } + for idx, item := range fieldItems { + fieldBody, ok := item.(map[string]interface{}) + if !ok { + body["fields"] = "" + return body + } + normalized, normalizeErr := normalizeFieldBody(fieldBody) + if normalizeErr != nil { + body["fields"] = fmt.Sprintf("", idx+1, normalizeErr.Error()) + return body + } + fieldItems[idx] = normalized + } body["fields"] = fieldItems return body } diff --git a/skills/lark-base/references/lark-base-field-create.md b/skills/lark-base/references/lark-base-field-create.md index 4771458984..93e4273f79 100644 --- a/skills/lark-base/references/lark-base-field-create.md +++ b/skills/lark-base/references/lark-base-field-create.md @@ -29,6 +29,15 @@ lark-cli base +field-create \ --base-token \ --table-id \ --json '{"name":"负责人","type":"user","multiple":false,"default_value":[{"$slot":"current_user"}],"description":"用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"}' + +lark-cli base +workflow-create \ + --base-token \ + --json '' + +lark-cli base +field-create \ + --base-token \ + --table-id \ + --json '{"name":"同步到 CRM","type":"button","button":{"title":"同步到 CRM","color":0},"trigger":{"type":"automation","workflow_id":"wkf_xxx"}}' ``` ## 参数 @@ -57,6 +66,7 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields - `link`:必须有 `link_table`,可选 `bidirectional`、`bidirectional_link_field_name`。 - `formula`:必须有 `expression`;先读 formula guide,再创建。 - `lookup`:必须有 `from`、`select`、`where`;先读 lookup guide,再创建。 + - `button`:必须有 `button` 和 `trigger`;其中 `trigger.type` 只支持 `automation`,并且必须先创建 Workflow 再写入 `trigger.workflow_id`。 **正确(base +field-create)** @@ -84,22 +94,41 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields } ``` +**按钮字段示例** + +```json +{ + "name": "同步到 CRM", + "type": "button", + "button": { + "title": "同步到 CRM", + "color": 0 + }, + "trigger": { + "type": "automation", + "workflow_id": "wkf_xxx" + } +} +``` + ## 返回重点 - 返回 `field` 和 `created: true`。 - 如果返回 `field_get_recommended:false` 且 `next_step:"done"`,表示本次是简单字段创建,通常不需要立刻执行 `+field-get`。 -- 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按 `verification_hint` 读回字段;`formula`、`lookup`、`link`、`auto_number` 等计算、关联或生成型字段更适合读回确认服务端最终结构。 +- 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按 `verification_hint` 读回字段;`formula`、`lookup`、`link`、`auto_number`、`button` 等计算、关联或生成型字段更适合读回确认服务端最终结构。 ## 工作流 1. formula / lookup 字段必须先阅读对应指南;没读之前不要直接创建。 -2. 创建简单字段时,优先相信命令返回;只有用户要求精确核对额外属性,或返回建议读回时,才继续执行 `+field-get`。 +2. 如果创建 `button` 字段,先创建带按钮触发器的 Workflow,再把它的 `workflow_id` 写入字段 JSON。 +3. 创建简单字段时,优先相信命令返回;只有用户要求精确核对额外属性,或返回建议读回时,才继续执行 `+field-get`。 ## 坑点 - ⚠️ 这是写入操作,执行前必须确认。 - ⚠️ 当 `type` 是 `formula` 或 `lookup` 时,先读对应 guide,再创建。 +- ⚠️ 当 `type` 是 `button` 时,必须先创建 Workflow;不要猜测 `workflow_id`,也不要直接手写底层 `type:3001` 结构。 - ⚠️ 不要把“每次创建后都 `+field-get`”当作固定流程;按返回里的 `field_get_recommended` 和 `next_step` 决定是否读回。 ## 参考 diff --git a/skills/lark-base/references/lark-base-field-json.md b/skills/lark-base/references/lark-base-field-json.md index 3050d68330..f511613df0 100644 --- a/skills/lark-base/references/lark-base-field-json.md +++ b/skills/lark-base/references/lark-base-field-json.md @@ -39,6 +39,7 @@ | `formula` | `type` `name` `expression` | 无 | | `lookup` | `type` `name` `from` `select` `where` | `aggregate` | | `auto_number` | `type` `name` | `style.rules` | +| `button` | `type` `name` `button` `trigger` | `button.title` `button.color` | | `attachment` / `location` / `checkbox` | `type` `name` | 无 | 所有类型都可额外传 `description`;上表的“常见补充字段”只列类型特有配置。 @@ -493,7 +494,39 @@ } ``` -### 3.12 attachment / location / checkbox +### 3.12 button + +按钮字段;用于在单元格内展示一个按钮,并绑定到自动化 Workflow。使用前先创建一个带按钮触发器的 Workflow,再把它的 `workflow_id` 绑定到字段上。 + +支持字段:`button`、`trigger` + +默认值 / 约束: +- `button` 必填,结构是 `{title, color?}` +- `button.title` 默认回退到字段 `name` +- `button.color` 默认 `0` +- `trigger` 必填,结构是 `{type, workflow_id}` +- `trigger.type` 当前只支持 `automation`;省略时默认 `automation` +- `trigger.workflow_id` 必填;也可兼容传 `trigger.config.id`,但 SSOT 推荐始终写 `workflow_id` +- CLI 会把这个 friendly JSON 归一化成底层 OpenAPI payload:`type:3001`、`fieldUIType:\"Button\"`、`property.button`、`property.trigger.type:1`、`property.trigger.config.id` + +推荐写法: + +```json +{ + "type": "button", + "name": "同步到 CRM", + "button": { + "title": "同步到 CRM", + "color": 0 + }, + "trigger": { + "type": "automation", + "workflow_id": "wkf_xxx" + } +} +``` + +### 3.13 attachment / location / checkbox ```json { "type": "attachment", "name": "附件" } @@ -513,10 +546,13 @@ - `+field-create`:按目标字段配置直接构造 `--json`。 - `+field-update`:使用同样的 JSON 结构,但语义是 `PUT`;建议先 `+field-get`,再按目标完整状态提交,并带 `--yes`。当 `type` 是 `auto_number` 时,更新编号规则本身就会把新规则应用到已有编号,无需额外参数,也不要在 JSON 里塞额外的底层实现参数。 +- `type=button` 时,先创建 Workflow,再创建或更新按钮字段绑定 `workflow_id`。按钮字段属于生成/联动型字段,创建或更新成功后都建议执行 `+field-get` 确认服务端最终结构。 ## 5. 暂不支持字段 -Object(对象字段)、Button(按钮字段)、Stage(流程字段)暂时都没有被 CLI 支持。这些字段会展示为 `not_support` 字段并被保护:不允许修改,不允许读取内容。 +Object(对象字段)和 Stage(流程字段)暂时都没有被 CLI 支持。这些字段会展示为 `not_support` 字段并被保护:不允许修改,不允许读取内容。 + +Button(按钮字段)已经支持,按本文档的 friendly JSON 写法创建或更新即可;不要手写底层 `type:3001` / `fieldUIType:"Button"` 结构,除非是在调试服务端原始返回。 遇到暂不支持的字段类型时,直接说明 Base CLI 当前不支持并停止;不要猜测未注册的字段 JSON、service 或 schema,也不要用其他字段类型冒充目标能力。 @@ -526,4 +562,5 @@ Object(对象字段)、Button(按钮字段)、Stage(流程字段)暂 - `number` 的精度、货币、进度、评分配置都放在 `style` 下,不要写顶层 `precision`。 - `datetime` 是手动日期字段;系统时间请改用 `created_at` / `updated_at`。 - `formula` / `lookup` 没读 guide 前不要直接写。 +- `button` 必须先有 Workflow;不要把未创建的 `workflow_id` 写进字段 JSON,也不要把 `button` / `trigger` 同时再塞进底层 `property`。 - 只有 `text`、`number`、静态 `select`、`datetime`、`user` 支持 `default_value`;清空统一传 `"default_value": null`。其他字段类型不要配置默认值。 diff --git a/skills/lark-base/references/lark-base-field-update.md b/skills/lark-base/references/lark-base-field-update.md index dee392290f..0ad6b05a2d 100644 --- a/skills/lark-base/references/lark-base-field-update.md +++ b/skills/lark-base/references/lark-base-field-update.md @@ -27,6 +27,13 @@ lark-cli base +field-update \ --field-id \ --json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' \ --yes + +lark-cli base +field-update \ + --base-token \ + --table-id \ + --field-id \ + --json '{"name":"同步到 CRM","type":"button","button":{"title":"同步到 CRM","color":0},"trigger":{"type":"automation","workflow_id":"wkf_xxx"}}' \ + --yes ``` ## 参数 @@ -62,6 +69,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id - 不能把非 `link` 字段改成 `link`,也不能把 `link` 改成非 `link`。 - 现有 `link` 字段的 `bidirectional` 不能改。 - `auto_number` 更新的 `style.rules` 支持 `text`、`created_time`、`incremental_number`。 +- `button` 更新时:使用与创建相同的 friendly JSON 结构;必须保留 `button` 和 `trigger`,并把目标 Workflow 写在 `trigger.workflow_id`。如果只是重绑 Workflow,也仍然要按 PUT 语义提交完整按钮字段定义。 **推荐更新示例** @@ -90,13 +98,30 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id } ``` +**按钮字段重绑示例** + +```json +{ + "name": "同步到 CRM", + "type": "button", + "button": { + "title": "同步到 CRM", + "color": 0 + }, + "trigger": { + "type": "automation", + "workflow_id": "wkf_xxx" + } +} +``` + ## 返回重点 - 返回 `field` 和 `updated: true`。 - `updated:true` 只表示更新请求成功,不表示字段结构、已有记录值或下游能力已经完成验证。`+field-update` 无法知道更新前的字段类型,因此成功响应会推荐执行 `+field-get`;若发生类型转换,还要抽样读取记录值。 - 如果响应中的 `field.type` 与提交的 `type` 不一致,必须把它当作待核验的类型不匹配;不能返回完成态,也不能只根据其中任一类型推断更新成功。 - 如果 API 报告本次更新没有产生任何变更(no-op),命令会如实返回该错误;这通常说明目标字段已是期望状态,不要机械重试同一份 `+field-update`。需要确认当前字段完整状态时执行 `+field-get`。 -- 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按提示读回字段;`auto_number` 更新后还应抽样读记录值确认编号已按新规则生成。 +- 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按提示读回字段;`auto_number` 更新后还应抽样读记录值确认编号已按新规则生成,`button` 更新后还应确认服务端最终返回的按钮和 trigger 绑定关系。 ## 工作流 @@ -104,7 +129,8 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id 1. 建议先用 `+field-get` 拉现状,再做最小化修改。 2. `formula/lookup` 类型更新前先阅读对应指南。 3. 如果更新 `auto_number`,理解为“更新编号规则,同时把新规则应用到已有编号”;执行后按返回提示读回字段并在必要时抽样记录值。 -4. 如果这次更新会改变字段 `type` 先按下方“字段类型变更规则”判断能否执行。如果不修改 `type`,大多数场景都相对安全。 +4. 如果更新 `button`,先确保目标 Workflow 已创建,再按完整按钮字段定义提交;不要只传新的 `workflow_id` 片段。 +5. 如果这次更新会改变字段 `type` 先按下方“字段类型变更规则”判断能否执行。如果不修改 `type`,大多数场景都相对安全。 ## 字段类型变更规则 @@ -180,6 +206,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id - ⚠️ 这是全量字段属性更新语义,不是 patch。 - ⚠️ 这是高风险写入操作,执行时必须带 `--yes`。 - ⚠️ 当 `type` 是 `formula` 或 `lookup` 时,先阅读对应指南再执行。 +- ⚠️ 当 `type` 是 `button` 时,必须先创建目标 Workflow;不要猜 `workflow_id`,也不要把顶层 `button`/`trigger` 改写成底层 `property` 结构。 ## 参考 diff --git a/tests/cli_e2e/base/base_button_field_workflow_test.go b/tests/cli_e2e/base/base_button_field_workflow_test.go new file mode 100644 index 0000000000..960ffa1c8b --- /dev/null +++ b/tests/cli_e2e/base/base_button_field_workflow_test.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBaseButtonFieldWorkflow(t *testing.T) { + clie2e.SkipWithoutTenantAccessToken(t) + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + t.Cleanup(cancel) + + baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-button-field-"+clie2e.GenerateSuffix()) + tableName := "Leads " + clie2e.GenerateSuffix() + tableID, _, _ := createTableWithRetry( + t, + parentT, + ctx, + baseToken, + tableName, + `[{"name":"Lead Name","type":"text"}]`, + `{"name":"Main","type":"grid"}`, + ) + + workflowBody := func(title string) string { + return fmt.Sprintf(`{"client_token":"%s","title":"%s","steps":[{"id":"step_button_trigger","type":"ButtonTrigger","title":"Click sync button","next":"step_delay","data":{"button_type":"buttonField","table_name":"%s"}},{"id":"step_delay","type":"Delay","title":"Wait briefly","next":null,"data":{"duration":1}}]}`, clie2e.GenerateSuffix(), title, tableName) + } + + createWorkflow := func(title string) string { + t.Helper() + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+workflow-create", + "--base-token", baseToken, + "--json", workflowBody(title), + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + workflowID := gjson.Get(result.Stdout, "data.workflow_id").String() + require.NotEmpty(t, workflowID, "stdout:\n%s", result.Stdout) + return workflowID + } + + getWorkflow := func(workflowID string) gjson.Result { + t.Helper() + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+workflow-get", + "--base-token", baseToken, + "--workflow-id", workflowID, + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + return gjson.Parse(result.Stdout) + } + + getField := func(fieldID string) gjson.Result { + t.Helper() + var payload gjson.Result + err := clie2e.WaitForCondition(ctx, clie2e.WaitOptions{ + Timeout: 20 * time.Second, + Interval: time.Second, + }, func() (bool, error) { + result, runErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+field-get", + "--base-token", baseToken, + "--table-id", tableID, + "--field-id", fieldID, + }, + DefaultAs: "bot", + }) + if runErr != nil { + return false, runErr + } + if result.ExitCode != 0 { + return false, nil + } + payload = gjson.Parse(result.Stdout) + return payload.Get("status").Bool(), nil + }) + require.NoError(t, err) + return payload + } + + firstWorkflowID := createWorkflow("Sync to CRM A") + firstWorkflow := getWorkflow(firstWorkflowID) + require.Equal(t, firstWorkflowID, firstWorkflow.Get("data.workflow_id").String(), firstWorkflow.Raw) + require.Equal(t, tableName, firstWorkflow.Get("data.steps.0.data.table_name").String(), firstWorkflow.Raw) + require.Equal(t, "buttonField", firstWorkflow.Get("data.steps.0.data.button_type").String(), firstWorkflow.Raw) + + createFieldResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+field-create", + "--base-token", baseToken, + "--table-id", tableID, + "--json", fmt.Sprintf(`{"name":"Sync to CRM","type":"button","button":{"title":"Sync to CRM","color":0},"trigger":{"type":"automation","workflow_id":"%s"}}`, firstWorkflowID), + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + createFieldResult.AssertExitCode(t, 0) + createFieldResult.AssertStdoutStatus(t, true) + require.True(t, gjson.Get(createFieldResult.Stdout, "data.field_get_recommended").Bool(), createFieldResult.Stdout) + require.Equal(t, "field_get", gjson.Get(createFieldResult.Stdout, "data.next_step").String(), createFieldResult.Stdout) + + fieldID := gjson.Get(createFieldResult.Stdout, "data.field.id").String() + if fieldID == "" { + fieldID = gjson.Get(createFieldResult.Stdout, "data.field.field_id").String() + } + require.NotEmpty(t, fieldID, "stdout:\n%s", createFieldResult.Stdout) + + fieldPayload := getField(fieldID) + require.Equal(t, fieldID, fieldPayload.Get("data.field.id").String(), fieldPayload.Raw) + require.Equal(t, "Button", fieldPayload.Get("data.field.fieldUIType").String(), fieldPayload.Raw) + require.Equal(t, "Sync to CRM", fieldPayload.Get("data.field.property.button.title").String(), fieldPayload.Raw) + require.Equal(t, int64(0), fieldPayload.Get("data.field.property.button.color").Int(), fieldPayload.Raw) + require.Equal(t, int64(1), fieldPayload.Get("data.field.property.trigger.type").Int(), fieldPayload.Raw) + require.Equal(t, firstWorkflowID, fieldPayload.Get("data.field.property.trigger.config.id").String(), fieldPayload.Raw) + + secondWorkflowID := createWorkflow("Sync to CRM B") + secondWorkflow := getWorkflow(secondWorkflowID) + require.Equal(t, secondWorkflowID, secondWorkflow.Get("data.workflow_id").String(), secondWorkflow.Raw) + require.Equal(t, tableName, secondWorkflow.Get("data.steps.0.data.table_name").String(), secondWorkflow.Raw) + + updateFieldResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+field-update", + "--base-token", baseToken, + "--table-id", tableID, + "--field-id", fieldID, + "--json", fmt.Sprintf(`{"name":"Sync to CRM","type":"button","button":{"title":"Sync to CRM","color":0},"trigger":{"type":"automation","workflow_id":"%s"}}`, secondWorkflowID), + "--yes", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + updateFieldResult.AssertExitCode(t, 0) + updateFieldResult.AssertStdoutStatus(t, true) + require.True(t, gjson.Get(updateFieldResult.Stdout, "data.field_get_recommended").Bool(), updateFieldResult.Stdout) + require.Equal(t, "field_get", gjson.Get(updateFieldResult.Stdout, "data.next_step").String(), updateFieldResult.Stdout) + + updatedFieldPayload := getField(fieldID) + require.Equal(t, "Button", updatedFieldPayload.Get("data.field.fieldUIType").String(), updatedFieldPayload.Raw) + require.Equal(t, secondWorkflowID, updatedFieldPayload.Get("data.field.property.trigger.config.id").String(), updatedFieldPayload.Raw) +} diff --git a/tests/cli_e2e/base/base_create_dryrun_test.go b/tests/cli_e2e/base/base_create_dryrun_test.go index 539a3576a4..fa46566172 100644 --- a/tests/cli_e2e/base/base_create_dryrun_test.go +++ b/tests/cli_e2e/base/base_create_dryrun_test.go @@ -109,3 +109,37 @@ func TestBaseCreateDryRunFieldsOnlyUsesDefaultTableName(t *testing.T) { require.Equal(t, "Table 1", clie2e.DryRunGet(out, "api.2.body.name").String(), out) require.Equal(t, "Title", clie2e.DryRunGet(out, "api.2.body.fields.0.name").String(), out) } + +func TestBaseCreateDryRunFieldsButtonNormalizesTriggerPayload(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+base-create", + "--name", "Project Tracker", + "--table-name", "Tasks", + "--fields", `[{"name":"同步到 CRM","type":"button","button":{"title":"同步到 CRM","color":0},"trigger":{"type":"automation","workflow_id":"wkf_x"}}]`, + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", clie2e.DryRunGet(out, "api.2.url").String(), out) + require.Equal(t, "POST", clie2e.DryRunGet(out, "api.2.method").String(), out) + require.Equal(t, "Tasks", clie2e.DryRunGet(out, "api.2.body.name").String(), out) + require.Equal(t, "同步到 CRM", clie2e.DryRunGet(out, "api.2.body.fields.0.name").String(), out) + require.Equal(t, int64(3001), clie2e.DryRunGet(out, "api.2.body.fields.0.type").Int(), out) + require.Equal(t, "Button", clie2e.DryRunGet(out, "api.2.body.fields.0.fieldUIType").String(), out) + require.Equal(t, "同步到 CRM", clie2e.DryRunGet(out, "api.2.body.fields.0.property.button.title").String(), out) + require.Equal(t, int64(0), clie2e.DryRunGet(out, "api.2.body.fields.0.property.button.color").Int(), out) + require.Equal(t, int64(1), clie2e.DryRunGet(out, "api.2.body.fields.0.property.trigger.type").Int(), out) + require.Equal(t, "wkf_x", clie2e.DryRunGet(out, "api.2.body.fields.0.property.trigger.config.id").String(), out) + require.False(t, clie2e.DryRunGet(out, "api.2.body.fields.0.button").Exists(), out) + require.False(t, clie2e.DryRunGet(out, "api.2.body.fields.0.trigger").Exists(), out) +} diff --git a/tests/cli_e2e/base/base_field_dryrun_test.go b/tests/cli_e2e/base/base_field_dryrun_test.go index a98f3fc09b..c6ee95fe76 100644 --- a/tests/cli_e2e/base/base_field_dryrun_test.go +++ b/tests/cli_e2e/base/base_field_dryrun_test.go @@ -42,3 +42,36 @@ func TestBaseFieldCreateDryRunArrayCompat(t *testing.T) { require.Equal(t, "B", clie2e.DryRunGet(out, "api.1.body.name").String(), out) require.Equal(t, "text", clie2e.DryRunGet(out, "api.1.body.type").String(), out) } + +func TestBaseFieldCreateDryRunButtonNormalizesTriggerPayload(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+field-create", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--json", `{"name":"同步到 CRM","type":"button","button":{"title":"同步到 CRM","color":0},"trigger":{"type":"automation","workflow_id":"wkf_x"}}`, + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", clie2e.DryRunGet(out, "api.0.url").String(), out) + require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out) + require.Equal(t, "同步到 CRM", clie2e.DryRunGet(out, "api.0.body.name").String(), out) + require.Equal(t, int64(3001), clie2e.DryRunGet(out, "api.0.body.type").Int(), out) + require.Equal(t, "Button", clie2e.DryRunGet(out, "api.0.body.fieldUIType").String(), out) + require.Equal(t, "同步到 CRM", clie2e.DryRunGet(out, "api.0.body.property.button.title").String(), out) + require.Equal(t, int64(0), clie2e.DryRunGet(out, "api.0.body.property.button.color").Int(), out) + require.Equal(t, int64(1), clie2e.DryRunGet(out, "api.0.body.property.trigger.type").Int(), out) + require.Equal(t, "wkf_x", clie2e.DryRunGet(out, "api.0.body.property.trigger.config.id").String(), out) + require.False(t, clie2e.DryRunGet(out, "api.0.body.button").Exists(), out) + require.False(t, clie2e.DryRunGet(out, "api.0.body.trigger").Exists(), out) +} diff --git a/tests/cli_e2e/base/base_field_update_dryrun_test.go b/tests/cli_e2e/base/base_field_update_dryrun_test.go index 6d31cf7792..ddecbe0275 100644 --- a/tests/cli_e2e/base/base_field_update_dryrun_test.go +++ b/tests/cli_e2e/base/base_field_update_dryrun_test.go @@ -50,3 +50,27 @@ func TestBaseFieldUpdateDryRunAllowsRatingMaxAboveLimit(t *testing.T) { require.Equal(t, "rating", gjson.Get(out, "data.api.0.body.style.type").String(), out) require.Equal(t, int64(20), gjson.Get(out, "data.api.0.body.style.max").Int(), out) } + +func TestBaseFieldUpdateButtonDryRunNormalizesTriggerPayload(t *testing.T) { + result := runBaseDryRun(t, 0, + "base", "+field-update", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--field-id", "fld_x", + "--json", `{"name":"同步到 CRM","type":"button","button":{"title":"同步到 CRM","color":0},"trigger":{"type":"automation","workflow_id":"wkf_x"}}`, + "--yes", + ) + + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", gjson.Get(out, "data.api.0.url").String(), out) + require.Equal(t, "PUT", gjson.Get(out, "data.api.0.method").String(), out) + require.Equal(t, "同步到 CRM", gjson.Get(out, "data.api.0.body.name").String(), out) + require.Equal(t, int64(3001), gjson.Get(out, "data.api.0.body.type").Int(), out) + require.Equal(t, "Button", gjson.Get(out, "data.api.0.body.fieldUIType").String(), out) + require.Equal(t, "同步到 CRM", gjson.Get(out, "data.api.0.body.property.button.title").String(), out) + require.Equal(t, int64(0), gjson.Get(out, "data.api.0.body.property.button.color").Int(), out) + require.Equal(t, int64(1), gjson.Get(out, "data.api.0.body.property.trigger.type").Int(), out) + require.Equal(t, "wkf_x", gjson.Get(out, "data.api.0.body.property.trigger.config.id").String(), out) + require.False(t, gjson.Get(out, "data.api.0.body.button").Exists(), out) + require.False(t, gjson.Get(out, "data.api.0.body.trigger").Exists(), out) +}