Skip to content
Closed
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
211 changes: 211 additions & 0 deletions shortcuts/base/base_dashboard_execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,217 @@ func TestNormalizeDataConfigSortOrder(t *testing.T) {
})
}

func TestValidateNPSDataConfig(t *testing.T) {
valid := map[string]interface{}{
"table_name": "Survey",
"group_by": []interface{}{
map[string]interface{}{"field_name": "Score", "mode": "integrated"},
},
"category_range": []interface{}{float64(0), float64(6), float64(8), float64(10)},
}
if problems := validateBlockDataConfig("nps", valid); len(problems) != 0 {
t.Fatalf("valid nps config got problems: %v", problems)
}
Comment thread
wanghm25 marked this conversation as resolved.
if problems := validateBlockDataConfig(" NpS ", cloneMap(valid)); len(problems) != 0 {
t.Fatalf("normalized nps type got problems: %v", problems)
}

withoutCountAll := cloneMap(valid)
if problems := validateBlockDataConfig("nps", withoutCountAll); len(problems) != 0 {
t.Fatalf("nps should allow omitted count_all: %v", problems)
}

withCountAll := cloneMap(valid)
withCountAll["count_all"] = true
if problems := validateBlockDataConfig("nps", withCountAll); len(problems) != 0 {
t.Fatalf("nps should allow count_all true: %v", problems)
}

invalid := cloneMap(valid)
invalid["series"] = []interface{}{map[string]interface{}{"field_name": "Score", "rollup": "SUM"}}
invalid["count_all"] = false
invalid["group_by"] = []interface{}{
map[string]interface{}{"field_name": "Score", "mode": "enumerated", "sort": map[string]interface{}{"type": "group"}},
}
problems := validateBlockDataConfig("nps", invalid)
for _, want := range []string{"不支持 series", "只能为 true", "只能为 integrated", "不支持 sort"} {
if !containsProblem(problems, want) {
t.Fatalf("problems=%v, want containing %q", problems, want)
}
}

for _, tc := range []struct {
field string
value interface{}
}{
{field: "sort", value: map[string]interface{}{"type": "group", "order": "asc"}},
{field: "limit_size", value: float64(10)},
{field: "number_format", value: map[string]interface{}{"formatName": "digital"}},
{field: "text", value: "NPS"},
} {
t.Run("reject top-level "+tc.field, func(t *testing.T) {
cfg := cloneMap(valid)
cfg[tc.field] = tc.value
problems := validateBlockDataConfig("nps", cfg)
if !containsProblem(problems, "nps 不支持 "+tc.field) {
t.Fatalf("problems=%v, want containing %q", problems, "nps 不支持 "+tc.field)
}
})
}

for _, tc := range []struct {
name string
mut func(map[string]interface{})
want string
}{
{
name: "missing table_name",
mut: func(cfg map[string]interface{}) {
delete(cfg, "table_name")
},
want: "缺少必填字段 table_name",
},
{
name: "invalid group_by shape",
mut: func(cfg map[string]interface{}) {
cfg["group_by"] = map[string]interface{}{"field_name": "Score", "mode": "integrated"}
},
want: "nps.group_by 必须是长度为 1 的数组",
},
{
name: "missing group_by field_name",
mut: func(cfg map[string]interface{}) {
cfg["group_by"] = []interface{}{map[string]interface{}{"mode": "integrated"}}
},
want: "nps.group_by[0].field_name 不能为空",
},
{
name: "missing group_by mode",
mut: func(cfg map[string]interface{}) {
cfg["group_by"] = []interface{}{map[string]interface{}{"field_name": "Score"}}
},
want: "nps.group_by[0].mode 只能为 integrated",
},
{
name: "empty group_by mode",
mut: func(cfg map[string]interface{}) {
cfg["group_by"] = []interface{}{map[string]interface{}{"field_name": "Score", "mode": ""}}
},
want: "nps.group_by[0].mode 只能为 integrated",
},
{
name: "non-string group_by mode",
mut: func(cfg map[string]interface{}) {
cfg["group_by"] = []interface{}{map[string]interface{}{"field_name": "Score", "mode": true}}
},
want: "nps.group_by[0].mode 只能为 integrated",
},
{
name: "invalid category_range",
mut: func(cfg map[string]interface{}) {
cfg["category_range"] = []interface{}{float64(0), float64(6), float64(10)}
},
want: "nps.category_range 必须是长度为 4 的数组",
},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := cloneMap(valid)
tc.mut(cfg)
problems := validateBlockDataConfig("nps", cfg)
if !containsProblem(problems, tc.want) {
t.Fatalf("problems=%v, want containing %q", problems, tc.want)
}
})
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestValidateNonNPSRejectsCategoryRange(t *testing.T) {
for _, tc := range []struct {
name string
blockType string
cfg map[string]interface{}
}{
{
name: "statistics",
blockType: "statistics",
cfg: map[string]interface{}{
"table_name": "T",
"count_all": true,
"category_range": []interface{}{float64(0), float64(6), float64(8), float64(10)},
},
},
{
name: "ordinary chart",
blockType: "column",
cfg: map[string]interface{}{
"table_name": "T",
"count_all": true,
"group_by": []interface{}{map[string]interface{}{"field_name": "Score"}},
"category_range": []interface{}{float64(0), float64(6), float64(8), float64(10)},
},
},
{
name: "text",
blockType: "text",
cfg: map[string]interface{}{
"text": "Summary",
"category_range": []interface{}{float64(0), float64(6), float64(8), float64(10)},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
problems := validateBlockDataConfig(tc.blockType, tc.cfg)
if !containsProblem(problems, "category_range 仅支持 nps 类型组件") {
t.Fatalf("problems=%v, want category_range rejection", problems)
}
})
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestBaseDashboardBlockCreate_NPSRequiresDataConfig(t *testing.T) {
for _, tc := range []struct {
name string
blockType string
}{
{name: "nps", blockType: "nps"},
{name: "padded nps", blockType: " NpS "},
{name: "padded text", blockType: " text "},
} {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseDashboardBlockCreate, []string{
"+dashboard-block-create",
"--base-token", "app_x",
"--dashboard-id", "dsh_1",
"--name", "Block",
"--type", tc.blockType,
}, factory, stdout)
if err == nil {
t.Fatalf("expected validation error for missing data_config, got nil (stdout=%s)", stdout.String())
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category=%q subtype=%q, want validation/invalid_argument", ve.Category, ve.Subtype)
}
if ve.Param != "--data-config" {
t.Fatalf("param=%q, want --data-config", ve.Param)
}
})
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func containsProblem(problems []string, want string) bool {
for _, p := range problems {
if strings.Contains(p, want) {
return true
}
}
return false
}

// ── Text Block Tests ────────────────────────────────────────────────

// TestBaseDashboardBlockExecuteCreate_TextType tests creating text blocks with markdown content.
Expand Down
1 change: 1 addition & 0 deletions shortcuts/base/base_shortcuts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,7 @@ func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
shortcut: BaseDashboardBlockCreate,
wantTips: []string{
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
`--type nps --data-config '{"table_name":"Survey","group_by":[{"field_name":"Score","mode":"integrated"}],"category_range":[0,6,8,10]}'`,
`--type text --data-config '{"text":"# Sales Dashboard"}'`,
"+table-list and +field-list",
"not table_id or field_id",
Expand Down
69 changes: 64 additions & 5 deletions shortcuts/base/block_data_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,17 @@ func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} {
// app_list_block_data_config.go and never enters this dashboard path.
func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []string {
blockType = strings.ToLower(strings.TrimSpace(blockType))
if _, hasNumberFormat := cfg["number_format"]; hasNumberFormat && blockType != "statistics" {
return []string{"number_format 仅支持 statistics 类型组件"}
}
switch {
case isTextBlockType(blockType):
return validateTextDataConfig(blockType, cfg)
return append(validateNonNPSDataConfig(cfg), validateTextDataConfig(blockType, cfg)...)
case blockType == "nps":
return validateNPSDataConfig(cfg)
default:
problems := validateChartDataConfig(cfg)
problems := validateNonNPSDataConfig(cfg)
if _, hasNumberFormat := cfg["number_format"]; hasNumberFormat && blockType != "statistics" {
return append(problems, "number_format 仅支持 statistics 类型组件")
}
problems = append(problems, validateChartDataConfig(cfg)...)
if matchesBlockType(blockType, []string{"statistics"}) {
if rawNumberFormat, hasNumberFormat := cfg["number_format"]; hasNumberFormat {
problems = append(problems, validateNumberFormat(rawNumberFormat)...)
Expand All @@ -141,6 +144,13 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str
}
}

func validateNonNPSDataConfig(cfg map[string]interface{}) []string {
if _, hasRange := cfg["category_range"]; hasRange {
return []string{"category_range 仅支持 nps 类型组件"}
}
return nil
}

// validateTextDataConfig validates the text data_config shape.
func validateTextDataConfig(blockType string, cfg map[string]interface{}) []string {
var problems []string
Expand Down Expand Up @@ -256,6 +266,55 @@ func validateNumberFormat(raw interface{}) []string {
return problems
}

func validateNPSDataConfig(cfg map[string]interface{}) []string {
var errs []string
if tn, _ := cfg["table_name"].(string); strings.TrimSpace(tn) == "" {
errs = append(errs, "缺少必填字段 table_name")
}
for _, field := range []string{"sort", "limit_size", "number_format", "text"} {
if _, hasField := cfg[field]; hasField {
errs = append(errs, fmt.Sprintf("nps 不支持 %s", field))
}
}
if _, hasSeries := cfg["series"]; hasSeries {
errs = append(errs, "nps 不支持 series;请省略 series,服务端会使用 count_all:true")
}
if v, hasCountAll := cfg["count_all"]; hasCountAll {
if b, ok := v.(bool); !ok || !b {
errs = append(errs, "nps.count_all 出现时只能为 true")
}
}
gb, ok := cfg["group_by"].([]interface{})
if !ok || len(gb) != 1 {
errs = append(errs, "nps.group_by 必须是长度为 1 的数组")
} else {
m, ok := gb[0].(map[string]interface{})
if !ok {
errs = append(errs, "nps.group_by[0] 必须是对象")
} else {
fn, _ := m["field_name"].(string)
if strings.TrimSpace(fn) == "" {
errs = append(errs, "nps.group_by[0].field_name 不能为空")
}
mode, modeOK := m["mode"].(string)
if !modeOK || strings.TrimSpace(mode) != "integrated" {
errs = append(errs, "nps.group_by[0].mode 只能为 integrated")
}
if _, hasSort := m["sort"]; hasSort {
errs = append(errs, "nps.group_by[0] 不支持 sort")
}
}
}
if cr, hasRange := cfg["category_range"]; hasRange {
arr, ok := cr.([]interface{})
if !ok || len(arr) != 4 {
errs = append(errs, "nps.category_range 必须是长度为 4 的数组")
}
}
errs = append(errs, validateBlockFilter(cfg, "filter", false)...)
return errs
}

// ── BaseApp chart data_config (multi-datasource) ─────────────────────
//
// BaseApp page charts differ from dashboard charts by supporting multiple
Expand Down
14 changes: 10 additions & 4 deletions shortcuts/base/dashboard_block_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
baseTokenFlag(true),
dashboardIDFlag(true),
{Name: "name", Desc: "block name", Required: true},
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read lark-base-dashboard-block-config.md before creating.", Required: true},
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|nps(NPS 图)|text(文本). Read lark-base-dashboard-block-config.md before creating.", Required: true},
Comment thread
wanghm25 marked this conversation as resolved.
{Name: "data-config", Desc: "data_config JSON object; read lark-base-dashboard-block-config.md for the SSOT"},
{Name: "position", Desc: `optional. component position+size in 12-col grid, JSON {"x","y","w","h"}; all four keys required and numeric (position is submitted whole, so a partial object cannot express a complete placement). Advisory bounds x/y>=0, 1<=w<=12 and x+w<=12, h>=1 — coordinate VALUES are not validated locally and pass through as given; the server auto-arranges out-of-range or overlapping positions. Omit for server auto-layout`},
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
Expand All @@ -33,6 +33,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
Tips: []string{
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Revenue" --type statistics --data-config '{"table_name":"Orders","series":[{"field_name":"Amount","rollup":"SUM"}],"number_format":{"formatName":"dollar_rounded","precision":2}}'`,
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Satisfaction NPS" --type nps --data-config '{"table_name":"Survey","group_by":[{"field_name":"Score","mode":"integrated"}],"category_range":[0,6,8,10]}'`,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Dashboard Note" --type text --data-config '{"text":"# Sales Dashboard"}'`,
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}' --position '{"x":0,"y":0,"w":6,"h":4}'`,
"Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.",
Expand All @@ -50,9 +51,14 @@ var BaseDashboardBlockCreate = common.Shortcut{
}
raw := strings.TrimSpace(runtime.Str("data-config"))
if raw == "" {
// text 类型必须提供 data-config(含 text 内容)
if !runtime.Bool("no-validate") && strings.EqualFold(strings.TrimSpace(runtime.Str("type")), "text") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "text 类型组件必须提供 data-config,包含必填字段 text").WithParam("--data-config")
if !runtime.Bool("no-validate") {
blockType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
switch blockType {
case "text":
return errs.NewValidationError(errs.SubtypeInvalidArgument, "text 类型组件必须提供 data-config,包含必填字段 text").WithParam("--data-config")
case "nps":
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nps 类型组件必须提供 data-config,包含必填字段 table_name 与 group_by").WithParam("--data-config")
}
}
return nil
}
Expand Down
Loading
Loading