Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions shortcuts/base/base_form_execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
t.Run("create questions", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
Body: map[string]interface{}{
Expand All @@ -277,7 +277,8 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
},
},
},
})
}
reg.Register(createStub)
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
"--questions", `[{"type":"text","title":"您的姓名","required":true}]`}
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
Expand All @@ -288,6 +289,69 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
}
})

t.Run("attachment defaults to all file types", 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/forms/vew_form1/questions",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{"id": "q_file", "title": "请上传PDF简历", "required": true},
},
},
},
}
reg.Register(createStub)
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
"--questions", `[{"type":"attachment","title":"请上传PDF简历","required":true}]`}
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
body := decodeCapturedJSONBody(t, createStub)
questions, _ := body["questions"].([]interface{})
if len(questions) != 1 {
t.Fatalf("questions=%#v", body["questions"])
}
question, _ := questions[0].(map[string]interface{})
attachment, _ := question["attachment"].(map[string]interface{})
fileTypes, _ := attachment["file_types"].([]interface{})
if len(fileTypes) != 1 || fileTypes[0] != "all" {
t.Fatalf("attachment file_types=%#v, body=%s", attachment["file_types"], string(createStub.CapturedBody))
}
})

t.Run("attachment preserves explicit file types", 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/forms/vew_form1/questions",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"questions": []interface{}{
map[string]interface{}{"id": "q_image", "title": "请上传图片", "required": false},
},
},
},
}
reg.Register(createStub)
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
"--questions", `[{"type":"attachment","title":"请上传图片","attachment":{"file_types":["image"]}}]`}
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
body := decodeCapturedJSONBody(t, createStub)
questions, _ := body["questions"].([]interface{})
question, _ := questions[0].(map[string]interface{})
attachment, _ := question["attachment"].(map[string]interface{})
fileTypes, _ := attachment["file_types"].([]interface{})
if len(fileTypes) != 1 || fileTypes[0] != "image" {
t.Fatalf("attachment file_types=%#v, body=%s", attachment["file_types"], string(createStub.CapturedBody))
}
})

t.Run("invalid questions json", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
Expand Down
38 changes: 34 additions & 4 deletions shortcuts/base/base_form_questions_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,27 @@ var BaseFormQuestionsCreate = common.Shortcut{
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
{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},
{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}),"attachment"({"file_types":["all"]},attachment only). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
dr := common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
Set("base_token", runtime.Str("base-token")).
Set("table_id", runtime.Str("table-id")).
Set("form_id", runtime.Str("form-id"))
if questions, err := parseFormQuestions(runtime.Str("questions")); err == nil {
dr.Body(map[string]interface{}{"questions": questions})
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
tableId := runtime.Str("table-id")
formId := runtime.Str("form-id")
questionsJSON := runtime.Str("questions")

var questions []interface{}
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
questions, err := parseFormQuestions(questionsJSON)
if err != nil {
return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
}

Expand Down Expand Up @@ -71,3 +75,29 @@ var BaseFormQuestionsCreate = common.Shortcut{
return nil
},
}

func parseFormQuestions(questionsJSON string) ([]interface{}, error) {
var questions []interface{}
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
return nil, err
}
normalizeFormQuestionAttachments(questions)
return questions, nil
}

func normalizeFormQuestionAttachments(questions []interface{}) {
for _, question := range questions {
q, ok := question.(map[string]interface{})
if !ok || q["type"] != "attachment" {
continue
}
attachment, ok := q["attachment"].(map[string]interface{})
if !ok {
q["attachment"] = map[string]interface{}{"file_types": []interface{}{"all"}}
continue
}
if fileTypes, ok := attachment["file_types"]; !ok || fileTypes == nil {
attachment["file_types"] = []interface{}{"all"}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ lark-cli base +form-questions-create \
--table-id <table_id> \
--form-id <form_id> \
--questions '[{"type":"number","title":"服务评分","style":{"type":"rating","icon":"star","min":1,"max":5}}]'

# 添加附件题(默认允许上传所有文件类型)
lark-cli base +form-questions-create \
--base-token <base_token> \
--table-id <table_id> \
--form-id <form_id> \
--questions '[{"type":"attachment","title":"请上传PDF简历","required":true}]'

# 添加带描述的问题(纯文本)
lark-cli base +form-questions-create \
Expand Down Expand Up @@ -78,6 +85,7 @@ lark-cli base +form-questions-create \
| `multiple` | 否 | 是否多选(`select`/`user` 类型有效,bool) |
| `options` | 否 | 选项列表(仅 `select` 有效):`[{"name":"选项1","hue":"Blue"}]`,hue 可选:`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Purple`/`Gray` |
| `style` | 否 | 字段样式配置(见下方说明) |
| `attachment` | 否 | 附件题配置(仅 `attachment` 有效):`{"file_types":["all"]}`。未提供时 CLI 默认补为 `["all"]`,与飞书 UI 新建附件题一致 |

### `style` 字段说明

Expand Down
48 changes: 48 additions & 0 deletions tests/cli_e2e/base/base_form_questions_dryrun_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import (
"context"
"testing"
"time"

clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)

func TestBase_FormQuestionsCreateDryRunAttachmentFileTypes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "base_form_questions_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "base_form_questions_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)

result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-questions-create",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--form-id", "vew_form1",
"--questions", `[{"type":"attachment","title":"请上传PDF简历","required":true}]`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

if got := gjson.Get(result.Stdout, "api.0.method").String(); got != "POST" {
t.Fatalf("api[0].method=%q, want POST\nstdout:\n%s", got, result.Stdout)
}
if got := gjson.Get(result.Stdout, "api.0.url").String(); got != "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions" {
t.Fatalf("api[0].url=%q\nstdout:\n%s", got, result.Stdout)
}
if got := gjson.Get(result.Stdout, "api.0.body.questions.0.attachment.file_types.0").String(); got != "all" {
t.Fatalf("attachment file_types[0]=%q, want all\nstdout:\n%s", got, result.Stdout)
}
}
Loading