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
141 changes: 117 additions & 24 deletions shortcuts/base/base_dashboard_execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,32 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) {
}
})

t.Run("text block json readback diagnostics", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_text",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"block_id": "blk_text",
"name": "文本",
"type": "text",
"data_config": map[string]interface{}{
"text": "\"图表说明\"",
},
},
},
})
if err := runShortcut(t, BaseDashboardBlockGet, []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_text"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"dashboard_text_json_readback"`) || !strings.Contains(got, `"text_plain": "图表说明"`) {
t.Fatalf("stdout=%s", got)
}
})

t.Run("with user-id-type", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Expand All @@ -270,35 +296,70 @@ func TestBaseDashboardBlockExecuteGet(t *testing.T) {

// TestBaseDashboardBlockExecuteGetData tests the +dashboard-block-get-data command.
func TestBaseDashboardBlockExecuteGetData(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/dashboards/blocks/blk_chart/data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"dimensions": []interface{}{
map[string]interface{}{"field_name": "文本", "alias": "dim_text"},
},
"measures": []interface{}{
map[string]interface{}{"field_name": "Bitable_Dashboard_Count", "aggregation": "count_all", "alias": "me_count"},
t.Run("with measure values", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/dashboards/blocks/blk_chart/data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"dimensions": []interface{}{
map[string]interface{}{"field_name": "文本", "alias": "dim_text"},
},
"measures": []interface{}{
map[string]interface{}{"field_name": "Bitable_Dashboard_Count", "aggregation": "count_all", "alias": "me_count"},
},
"main_data": []interface{}{
map[string]interface{}{
"dim_text": map[string]interface{}{"value": "A"},
"me_count": map[string]interface{}{"value": 3},
},
},
},
"main_data": []interface{}{
map[string]interface{}{
"dim_text": map[string]interface{}{"value": "A"},
"me_count": map[string]interface{}{"value": 3},
},
})
if err := runShortcut(t, BaseDashboardBlockGetData, []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_chart"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"dimensions"`) || !strings.Contains(got, `"main_data"`) || !strings.Contains(got, `"dim_text"`) {
t.Fatalf("stdout=%s", got)
}
if strings.Contains(got, `"_diagnostics"`) {
t.Fatalf("unexpected diagnostics in stdout=%s", got)
}
})

t.Run("diagnoses rows without measure values", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/dashboards/blocks/blk_empty_measure/data",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"dimensions": []interface{}{
map[string]interface{}{"field_name": "任务名称", "alias": "dim_task"},
},
"measures": []interface{}{
map[string]interface{}{"field_name": "处理时长", "aggregation": "sum", "alias": "me_duration"},
},
"main_data": []interface{}{
map[string]interface{}{"dim_task": map[string]interface{}{"value": "A"}},
map[string]interface{}{"dim_task": map[string]interface{}{"value": "B"}},
},
},
},
},
})
if err := runShortcut(t, BaseDashboardBlockGetData, []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_empty_measure"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"_diagnostics"`) || !strings.Contains(got, `"empty_measure_values"`) || !strings.Contains(got, `"me_duration"`) {
t.Fatalf("stdout=%s", got)
}
})
if err := runShortcut(t, BaseDashboardBlockGetData, []string{"+dashboard-block-get-data", "--base-token", "app_x", "--block-id", "blk_chart"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"dimensions"`) || !strings.Contains(got, `"main_data"`) || !strings.Contains(got, `"dim_text"`) {
t.Fatalf("stdout=%s", got)
}
}

// TestBaseDashboardBlockExecuteCreate tests the +dashboard-block-create command.
Expand Down Expand Up @@ -756,6 +817,38 @@ func TestBaseDashboardBlockExecuteUpdate_TextType(t *testing.T) {
if !strings.Contains(got, `"updated": true`) || !strings.Contains(got, "新内容") {
t.Fatalf("stdout=%s", got)
}
if strings.Contains(got, `"dashboard_text_json_readback"`) {
t.Fatalf("unexpected text diagnostics in stdout=%s", got)
}
})

t.Run("diagnoses json encoded text readback", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_text",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"block_id": "blk_text",
"name": "文本",
"type": "text",
"data_config": map[string]interface{}{
"text": "\"# 新内容\"",
},
},
},
})
args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_text",
"--data-config", `{"text":"# 新内容"}`,
}
if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"dashboard_text_json_readback"`) || !strings.Contains(got, `"text_plain": "# 新内容"`) {
t.Fatalf("stdout=%s", got)
}
})

t.Run("update without type skips strict validation", func(t *testing.T) {
Expand Down
23 changes: 23 additions & 0 deletions shortcuts/base/base_execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1277,6 +1277,29 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})

t.Run("list json shorthand alias", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_json_alias"},
"data": []interface{}{[]interface{}{"Charlie"}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_json_alias"`) || strings.Contains(got, "| Name |") {
t.Fatalf("stdout=%s", got)
}
})

t.Run("list markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Expand Down
16 changes: 16 additions & 0 deletions shortcuts/base/base_shortcuts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,22 @@ func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
}
}

func TestBaseRecordListJSONAliasIsHidden(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseRecordList.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
flag := cmd.Flags().Lookup("json")
if flag == nil {
t.Fatal("flag --json missing")
}
if !flag.Hidden {
t.Fatal("flag --json must be hidden")
}
if strings.Contains(cmd.Flags().FlagUsages(), "--json") {
t.Fatalf("help should not include hidden --json:\n%s", cmd.Flags().FlagUsages())
}
}

func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
Expand Down
121 changes: 121 additions & 0 deletions shortcuts/base/dashboard_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package base

import (
"context"
"encoding/json"
"strings"

"github.com/larksuite/cli/shortcuts/common"
Expand Down Expand Up @@ -257,6 +258,7 @@ func executeDashboardBlockGet(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
attachDashboardTextBlockDiagnostics(data)
runtime.Out(map[string]interface{}{"block": data}, nil)
return nil
}
Expand All @@ -267,10 +269,80 @@ func executeDashboardBlockGetData(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
attachDashboardBlockDataDiagnostics(data)
runtime.Out(data, nil)
return nil
}

func attachDashboardBlockDataDiagnostics(data map[string]interface{}) {
measureAliases := dashboardBlockMeasureAliases(data["measures"])
if len(measureAliases) == 0 {
return
}
rows, ok := data["main_data"].([]interface{})
if !ok || len(rows) == 0 {
return
}
for _, rawRow := range rows {
row, ok := rawRow.(map[string]interface{})
if !ok {
continue
}
if dashboardBlockRowHasMeasureValue(row, measureAliases) {
return
}
}
data["_diagnostics"] = []interface{}{
map[string]interface{}{
"type": "empty_measure_values",
"message": "chart data defines measures, but main_data rows contain no computed measure values",
"hint": "Recreate or update the block data_config with a computable numeric measure or count_all, then rerun +dashboard-block-get-data before declaring the chart complete.",
"measure_aliases": measureAliases,
"main_data_rows": len(rows),
},
}
}
Comment on lines +282 to +304

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Emit empty_measure_values for zero-row results too.

Line 283 returns before adding diagnostics when main_data is empty, so a block with declared measures but zero result rows still looks like a clean success. That misses the exact “empty chart” case this change is trying to surface. Please emit the same diagnostic when measures exist and main_data is [], and add the matching regression case in TestBaseDashboardBlockExecuteGetData.

Proposed fix
 func attachDashboardBlockDataDiagnostics(data map[string]interface{}) {
 	measureAliases := dashboardBlockMeasureAliases(data["measures"])
 	if len(measureAliases) == 0 {
 		return
 	}
 	rows, ok := data["main_data"].([]interface{})
-	if !ok || len(rows) == 0 {
+	if !ok {
 		return
 	}
+	if len(rows) == 0 {
+		appendDashboardDiagnostic(data, map[string]interface{}{
+			"type":            "empty_measure_values",
+			"message":         "chart data defines measures, but main_data rows contain no computed measure values",
+			"hint":            "Recreate or update the block data_config with a computable numeric measure or count_all, then rerun +dashboard-block-get-data before declaring the chart complete.",
+			"measure_aliases": measureAliases,
+			"main_data_rows":  0,
+		})
+		return
+	}
 	for _, rawRow := range rows {
 		row, ok := rawRow.(map[string]interface{})
 		if !ok {
 			continue
 		}
 		if dashboardBlockRowHasMeasureValue(row, measureAliases) {
 			return
 		}
 	}
-	data["_diagnostics"] = []interface{}{
-		map[string]interface{}{
-			"type":            "empty_measure_values",
-			"message":         "chart data defines measures, but main_data rows contain no computed measure values",
-			"hint":            "Recreate or update the block data_config with a computable numeric measure or count_all, then rerun +dashboard-block-get-data before declaring the chart complete.",
-			"measure_aliases": measureAliases,
-			"main_data_rows":  len(rows),
-		},
-	}
+	appendDashboardDiagnostic(data, map[string]interface{}{
+		"type":            "empty_measure_values",
+		"message":         "chart data defines measures, but main_data rows contain no computed measure values",
+		"hint":            "Recreate or update the block data_config with a computable numeric measure or count_all, then rerun +dashboard-block-get-data before declaring the chart complete.",
+		"measure_aliases": measureAliases,
+		"main_data_rows":  len(rows),
+	})
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
rows, ok := data["main_data"].([]interface{})
if !ok || len(rows) == 0 {
return
}
for _, rawRow := range rows {
row, ok := rawRow.(map[string]interface{})
if !ok {
continue
}
if dashboardBlockRowHasMeasureValue(row, measureAliases) {
return
}
}
data["_diagnostics"] = []interface{}{
map[string]interface{}{
"type": "empty_measure_values",
"message": "chart data defines measures, but main_data rows contain no computed measure values",
"hint": "Recreate or update the block data_config with a computable numeric measure or count_all, then rerun +dashboard-block-get-data before declaring the chart complete.",
"measure_aliases": measureAliases,
"main_data_rows": len(rows),
},
}
}
rows, ok := data["main_data"].([]interface{})
if !ok {
return
}
if len(rows) == 0 {
appendDashboardDiagnostic(data, map[string]interface{}{
"type": "empty_measure_values",
"message": "chart data defines measures, but main_data rows contain no computed measure values",
"hint": "Recreate or update the block data_config with a computable numeric measure or count_all, then rerun +dashboard-block-get-data before declaring the chart complete.",
"measure_aliases": measureAliases,
"main_data_rows": 0,
})
return
}
for _, rawRow := range rows {
row, ok := rawRow.(map[string]interface{})
if !ok {
continue
}
if dashboardBlockRowHasMeasureValue(row, measureAliases) {
return
}
}
appendDashboardDiagnostic(data, map[string]interface{}{
"type": "empty_measure_values",
"message": "chart data defines measures, but main_data rows contain no computed measure values",
"hint": "Recreate or update the block data_config with a computable numeric measure or count_all, then rerun +dashboard-block-get-data before declaring the chart complete.",
"measure_aliases": measureAliases,
"main_data_rows": len(rows),
})
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/base/dashboard_ops.go` around lines 282 - 304, The empty-measure
diagnostic in dashboard block data handling is skipped when main_data has zero
rows, so the zero-result chart case is treated as success. Update
dashboardBlockRowHasMeasureValue-related logic in dashboard_ops.go to emit the
same empty_measure_values diagnostic whenever measures are declared and
main_data is empty, not just when rows exist but lack computed values, and add a
regression test in TestBaseDashboardBlockExecuteGetData covering the zero-row
case.


func dashboardBlockMeasureAliases(raw interface{}) []string {
measures, ok := raw.([]interface{})
if !ok {
return nil
}
aliases := make([]string, 0, len(measures))
for _, rawMeasure := range measures {
measure, ok := rawMeasure.(map[string]interface{})
if !ok {
continue
}
alias, ok := measure["alias"].(string)
if !ok {
continue
}
alias = strings.TrimSpace(alias)
if alias != "" {
aliases = append(aliases, alias)
}
}
return aliases
}

func dashboardBlockRowHasMeasureValue(row map[string]interface{}, aliases []string) bool {
for _, alias := range aliases {
value, ok := row[alias]
if !ok || value == nil {
continue
}
valueMap, ok := value.(map[string]interface{})
if !ok {
return true
}
if nestedValue, exists := valueMap["value"]; exists && nestedValue != nil {
return true
}
}
return false
}

// executeDashboardBlockCreate creates a new dashboard block.
func executeDashboardBlockCreate(runtime *common.RuntimeContext) error {
pc := newParseCtx(runtime)
Expand Down Expand Up @@ -325,10 +397,59 @@ func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error {
if err != nil {
return err
}
attachDashboardTextBlockDiagnostics(data)
runtime.Out(map[string]interface{}{"block": data, "updated": true}, nil)
return nil
}

func attachDashboardTextBlockDiagnostics(block map[string]interface{}) {
if !isDashboardTextBlock(block) {
return
}
cfg, ok := block["data_config"].(map[string]interface{})
if !ok {
return
}
rawText, ok := cfg["text"].(string)
if !ok || strings.TrimSpace(rawText) == "" {
return
}
plainText, ok := decodeDashboardTextReadback(rawText)
if !ok || plainText == rawText {
return
}
appendDashboardDiagnostic(block, map[string]interface{}{
"type": "dashboard_text_json_readback",
"message": "text block data_config.text is returned as a JSON-encoded string",
"hint": "When verifying text block content, compare text_plain with the target text; keep data_config.text unchanged for raw API compatibility.",
"text_plain": plainText,
})
}

func isDashboardTextBlock(block map[string]interface{}) bool {
blockType, _ := block["type"].(string)
if blockType == "" {
blockType, _ = block["block_type"].(string)
}
return strings.EqualFold(strings.TrimSpace(blockType), "text")
}

func decodeDashboardTextReadback(raw string) (string, bool) {
var decoded string
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
return "", false
}
return decoded, true
}

func appendDashboardDiagnostic(target map[string]interface{}, diagnostic map[string]interface{}) {
if existing, ok := target["_diagnostics"].([]interface{}); ok {
target["_diagnostics"] = append(existing, diagnostic)
return
}
target["_diagnostics"] = []interface{}{diagnostic}
}

// executeDashboardBlockDelete deletes a dashboard block by ID.
func executeDashboardBlockDelete(runtime *common.RuntimeContext) error {
_, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), nil, nil)
Expand Down
5 changes: 5 additions & 0 deletions shortcuts/base/record_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var BaseRecordList = common.Shortcut{
{Name: "limit", Type: "int", Default: "100", Desc: "pagination size, range 1-200"},
pageSizeLimitAliasFlag(),
recordReadFormatFlag(),
recordReadJSONOutputAliasFlag(),
},
Tips: []string{
"Example: lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
Expand Down Expand Up @@ -88,3 +89,7 @@ func recordReadFormatFlag() common.Flag {
Desc: "output format: markdown (default) | json",
}
}

func recordReadJSONOutputAliasFlag() common.Flag {
return common.Flag{Name: "json", Type: "bool", Desc: "hidden shorthand for --format json", Hidden: true}
}
Loading
Loading