Skip to content

Commit 35b01c4

Browse files
Thomas StrombergThomas Stromberg
authored andcommitted
Slack dashboard async rendering
1 parent 3a2778a commit 35b01c4

File tree

4 files changed

+112
-134
lines changed

4 files changed

+112
-134
lines changed

pkg/home/ui.go

Lines changed: 33 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -13,34 +13,49 @@ import (
1313

1414
// BuildBlocks creates Slack Block Kit UI for the home dashboard.
1515
// Design matches dashboard at https://ready-to-review.dev - modern minimal with indigo accents.
16-
func BuildBlocks(dashboard *Dashboard, primaryOrg string) []slack.Block {
16+
func BuildBlocks(dashboard *Dashboard) []slack.Block {
1717
var blocks []slack.Block
1818

19-
// Header - gradient-inspired title
19+
// Header
2020
blocks = append(blocks,
2121
slack.NewHeaderBlock(
2222
slack.NewTextBlockObject("plain_text", "🚀 Ready to Review", true, false),
2323
),
24+
// Refresh button
25+
slack.NewActionBlock(
26+
"refresh_actions",
27+
slack.NewButtonBlockElement(
28+
"refresh_dashboard",
29+
"refresh",
30+
slack.NewTextBlockObject("plain_text", "🔄 Refresh Dashboard", true, false),
31+
).WithStyle("primary"),
32+
),
33+
slack.NewDividerBlock(),
2434
)
2535

26-
c := dashboard.Counts()
36+
// PR sections
37+
blocks = append(blocks, BuildPRSections(dashboard.IncomingPRs, dashboard.OutgoingPRs)...)
38+
39+
// Organizations section
40+
blocks = append(blocks, slack.NewDividerBlock())
2741

28-
// Status overview - quick summary
29-
emoji := "✨"
30-
status := "All clear"
31-
if c.IncomingBlocked > 0 || c.OutgoingBlocked > 0 {
32-
emoji = "⚡"
33-
status = "Action needed"
42+
var orgLines []string
43+
for _, org := range dashboard.WorkspaceOrgs {
44+
// URL-escape org name to prevent injection
45+
esc := url.PathEscape(org)
46+
orgLine := fmt.Sprintf("• <%s|%s> [<%s|config>, <%s|dashboard>]",
47+
fmt.Sprintf("https://github.com/%s", esc),
48+
org,
49+
fmt.Sprintf("https://github.com/%s/.github/blob/main/.codeGROOVE/slack.yaml", esc),
50+
fmt.Sprintf("https://%s.ready-to-review.dev", esc),
51+
)
52+
orgLines = append(orgLines, orgLine)
3453
}
3554

3655
blocks = append(blocks,
3756
slack.NewSectionBlock(
3857
slack.NewTextBlockObject("mrkdwn",
39-
fmt.Sprintf("%s *%s* • %d incoming • %d outgoing",
40-
emoji,
41-
status,
42-
c.IncomingTotal,
43-
c.OutgoingTotal),
58+
"*Organizations*\n"+strings.Join(orgLines, "\n"),
4459
false,
4560
false,
4661
),
@@ -49,50 +64,12 @@ func BuildBlocks(dashboard *Dashboard, primaryOrg string) []slack.Block {
4964
),
5065
)
5166

52-
// Organization monitoring + last updated
53-
links := make([]string, 0, len(dashboard.WorkspaceOrgs))
54-
for _, org := range dashboard.WorkspaceOrgs {
55-
// URL-escape org name to prevent injection
56-
esc := url.PathEscape(org)
57-
links = append(links, fmt.Sprintf("<%s|%s>",
58-
fmt.Sprintf("https://github.com/%s/.codeGROOVE/blob/main/slack.yaml", esc),
59-
org))
60-
}
67+
// Updated timestamp
6168
now := time.Now().Format("Jan 2, 3:04pm MST")
62-
context := fmt.Sprintf("Monitoring: %s • Updated: %s",
63-
strings.Join(links, ", "),
64-
now)
65-
66-
blocks = append(blocks,
67-
slack.NewContextBlock("",
68-
slack.NewTextBlockObject("mrkdwn", context, false, false),
69-
),
70-
// Refresh button
71-
slack.NewActionBlock(
72-
"refresh_actions",
73-
slack.NewButtonBlockElement(
74-
"refresh_dashboard",
75-
"refresh",
76-
slack.NewTextBlockObject("plain_text", "🔄 Refresh Dashboard", true, false),
77-
).WithStyle("primary"),
78-
),
79-
slack.NewDividerBlock(),
80-
)
81-
82-
// Use the same clean report format for PR sections
83-
blocks = append(blocks, BuildPRSections(dashboard.IncomingPRs, dashboard.OutgoingPRs)...)
84-
85-
// Footer - full dashboard link
86-
// URL-escape org name to prevent injection
87-
esc := url.PathEscape(primaryOrg)
8869
blocks = append(blocks,
89-
slack.NewDividerBlock(),
9070
slack.NewContextBlock("",
9171
slack.NewTextBlockObject("mrkdwn",
92-
fmt.Sprintf("📊 <%s|View full dashboard at %s.ready-to-review.dev>",
93-
fmt.Sprintf("https://%s.ready-to-review.dev", esc),
94-
primaryOrg,
95-
),
72+
fmt.Sprintf("Updated: %s", now),
9673
false,
9774
false,
9875
),
@@ -246,9 +223,9 @@ func BuildPRSections(incoming, outgoing []PR) []slack.Block {
246223
}
247224

248225
// BuildBlocksWithDebug creates Slack Block Kit UI with debug information about user mapping.
249-
func BuildBlocksWithDebug(dashboard *Dashboard, primaryOrg string, mapping *usermapping.ReverseMapping) []slack.Block {
226+
func BuildBlocksWithDebug(dashboard *Dashboard, mapping *usermapping.ReverseMapping) []slack.Block {
250227
// Build standard blocks first
251-
blocks := BuildBlocks(dashboard, primaryOrg)
228+
blocks := BuildBlocks(dashboard)
252229

253230
// Add debug section if mapping info is available
254231
if mapping != nil {

pkg/home/ui_test.go

Lines changed: 50 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,9 @@ import (
1313
//nolint:gocognit,maintidx // Comprehensive test with many test cases - complexity acceptable
1414
func TestBuildBlocks(t *testing.T) {
1515
tests := []struct {
16-
name string
17-
dashboard *Dashboard
18-
primaryOrg string
19-
validate func(t *testing.T, blocks []slack.Block)
16+
name string
17+
dashboard *Dashboard
18+
validate func(t *testing.T, blocks []slack.Block)
2019
}{
2120
{
2221
name: "empty dashboard",
@@ -25,7 +24,6 @@ func TestBuildBlocks(t *testing.T) {
2524
IncomingPRs: []PR{},
2625
OutgoingPRs: []PR{},
2726
},
28-
primaryOrg: "test-org",
2927
validate: func(t *testing.T, blocks []slack.Block) {
3028
t.Helper()
3129
if len(blocks) == 0 {
@@ -45,49 +43,60 @@ func TestBuildBlocks(t *testing.T) {
4543
t.Error("expected header block with 'Ready to Review'")
4644
}
4745

48-
// Should have "All clear" status
49-
foundStatus := false
46+
// Verify we don't have any PR section blocks (empty dashboard)
47+
hasPRSections := false
5048
for _, block := range blocks {
5149
if sb, ok := block.(*slack.SectionBlock); ok {
52-
if sb.Text != nil && strings.Contains(sb.Text.Text, "All clear") {
53-
foundStatus = true
50+
if sb.Text != nil && (strings.Contains(sb.Text.Text, "Incoming") || strings.Contains(sb.Text.Text, "Outgoing")) {
51+
hasPRSections = true
5452
}
5553
}
5654
}
57-
if !foundStatus {
58-
t.Error("expected 'All clear' status for empty dashboard")
55+
if hasPRSections {
56+
t.Error("expected no PR sections for empty dashboard")
5957
}
6058

61-
// With new format, empty dashboards don't show "No incoming PRs" message
62-
// They just show header/status/refresh with no PR sections
63-
// Verify we don't have any PR section blocks
64-
hasPRSections := false
59+
// Should have Organizations section
60+
foundOrgs := false
6561
for _, block := range blocks {
6662
if sb, ok := block.(*slack.SectionBlock); ok {
67-
if sb.Text != nil && (strings.Contains(sb.Text.Text, "Incoming") || strings.Contains(sb.Text.Text, "Outgoing")) {
68-
hasPRSections = true
63+
if sb.Text != nil && strings.Contains(sb.Text.Text, "Organizations") {
64+
foundOrgs = true
6965
}
7066
}
7167
}
72-
if hasPRSections {
73-
t.Error("expected no PR sections for empty dashboard")
68+
if !foundOrgs {
69+
t.Error("expected Organizations section")
7470
}
7571

76-
// Should have dashboard link
72+
// Should have dashboard link in Organizations section
7773
foundLink := false
74+
for _, block := range blocks {
75+
if sb, ok := block.(*slack.SectionBlock); ok {
76+
if sb.Text != nil && strings.Contains(sb.Text.Text, "ready-to-review.dev") {
77+
foundLink = true
78+
}
79+
}
80+
}
81+
if !foundLink {
82+
t.Error("expected dashboard link in Organizations section")
83+
}
84+
85+
// Should have Updated timestamp
86+
foundTimestamp := false
7887
for _, block := range blocks {
7988
if cb, ok := block.(*slack.ContextBlock); ok {
8089
for _, elem := range cb.ContextElements.Elements {
8190
if txt, ok := elem.(*slack.TextBlockObject); ok {
82-
if strings.Contains(txt.Text, "ready-to-review.dev") {
83-
foundLink = true
91+
if strings.Contains(txt.Text, "Updated:") {
92+
foundTimestamp = true
8493
}
8594
}
8695
}
8796
}
8897
}
89-
if !foundLink {
90-
t.Error("expected dashboard link in footer")
98+
if !foundTimestamp {
99+
t.Error("expected Updated timestamp")
91100
}
92101
},
93102
},
@@ -110,23 +119,9 @@ func TestBuildBlocks(t *testing.T) {
110119
},
111120
OutgoingPRs: []PR{},
112121
},
113-
primaryOrg: "test-org",
114122
validate: func(t *testing.T, blocks []slack.Block) {
115123
t.Helper()
116-
// Should have "Action needed" status
117-
foundActionNeeded := false
118-
for _, block := range blocks {
119-
if sb, ok := block.(*slack.SectionBlock); ok {
120-
if sb.Text != nil && strings.Contains(sb.Text.Text, "Action needed") {
121-
foundActionNeeded = true
122-
}
123-
}
124-
}
125-
if !foundActionNeeded {
126-
t.Error("expected 'Action needed' status with blocked PRs")
127-
}
128-
129-
// Should show "1 blocked on you" in section header (new format)
124+
// Should show "1 blocked on you" in section header
130125
foundBlocked := false
131126
for _, block := range blocks {
132127
if sb, ok := block.(*slack.SectionBlock); ok {
@@ -139,7 +134,7 @@ func TestBuildBlocks(t *testing.T) {
139134
t.Error("expected 'blocked on you' message in header")
140135
}
141136

142-
// Should have PR with large red square (incoming blocked indicator)
137+
// Should have PR with red circle (incoming blocked indicator)
143138
foundBlockedPR := false
144139
for _, block := range blocks {
145140
if sb, ok := block.(*slack.SectionBlock); ok {
@@ -171,7 +166,6 @@ func TestBuildBlocks(t *testing.T) {
171166
},
172167
},
173168
},
174-
primaryOrg: "test-org",
175169
validate: func(t *testing.T, blocks []slack.Block) {
176170
t.Helper()
177171
// Should show outgoing PR section with "blocked on you" (new format)
@@ -212,38 +206,35 @@ func TestBuildBlocks(t *testing.T) {
212206
IncomingPRs: []PR{},
213207
OutgoingPRs: []PR{},
214208
},
215-
primaryOrg: "org1",
216209
validate: func(t *testing.T, blocks []slack.Block) {
217210
t.Helper()
218-
// Should list all orgs in monitoring section
211+
// Should list all orgs in Organizations section
219212
foundOrgs := 0
220213
for _, block := range blocks {
221-
if cb, ok := block.(*slack.ContextBlock); ok {
222-
for _, elem := range cb.ContextElements.Elements {
223-
if txt, ok := elem.(*slack.TextBlockObject); ok {
224-
if strings.Contains(txt.Text, "org1") {
225-
foundOrgs++
226-
}
227-
if strings.Contains(txt.Text, "org2") {
228-
foundOrgs++
229-
}
230-
if strings.Contains(txt.Text, "org3") {
231-
foundOrgs++
232-
}
214+
if sb, ok := block.(*slack.SectionBlock); ok {
215+
if sb.Text != nil && strings.Contains(sb.Text.Text, "Organizations") {
216+
if strings.Contains(sb.Text.Text, "org1") {
217+
foundOrgs++
218+
}
219+
if strings.Contains(sb.Text.Text, "org2") {
220+
foundOrgs++
221+
}
222+
if strings.Contains(sb.Text.Text, "org3") {
223+
foundOrgs++
233224
}
234225
}
235226
}
236227
}
237228
if foundOrgs < 3 {
238-
t.Errorf("expected all 3 orgs in monitoring section, found %d", foundOrgs)
229+
t.Errorf("expected all 3 orgs in Organizations section, found %d", foundOrgs)
239230
}
240231
},
241232
},
242233
}
243234

244235
for _, tt := range tests {
245236
t.Run(tt.name, func(t *testing.T) {
246-
blocks := BuildBlocks(tt.dashboard, tt.primaryOrg)
237+
blocks := BuildBlocks(tt.dashboard)
247238
tt.validate(t, blocks)
248239
})
249240
}
@@ -257,7 +248,7 @@ func TestBuildBlocks_RefreshButton(t *testing.T) {
257248
OutgoingPRs: []PR{},
258249
}
259250

260-
blocks := BuildBlocks(dashboard, "test-org")
251+
blocks := BuildBlocks(dashboard)
261252

262253
// Should have action block with refresh button
263254
foundRefresh := false
@@ -294,7 +285,7 @@ func TestBuildBlocks_DividersBetweenSections(t *testing.T) {
294285
},
295286
}
296287

297-
blocks := BuildBlocks(dashboard, "test-org")
288+
blocks := BuildBlocks(dashboard)
298289

299290
// Count dividers
300291
dividerCount := 0

pkg/slack/home_handler.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,8 @@ func (h *HomeHandler) tryHandleAppHomeOpened(ctx context.Context, teamID, slackU
139139
// Add workspace orgs to dashboard for UI display
140140
dashboard.WorkspaceOrgs = workspaceOrgs
141141

142-
// Build Block Kit UI - use first org as primary, include debug info
143-
blocks := home.BuildBlocksWithDebug(dashboard, workspaceOrgs[0], mapping)
142+
// Build Block Kit UI with debug info
143+
blocks := home.BuildBlocksWithDebug(dashboard, mapping)
144144

145145
// Publish to Slack
146146
if err := slackClient.PublishHomeView(ctx, slackUserID, blocks); err != nil {
@@ -187,7 +187,7 @@ func (*HomeHandler) publishPlaceholderHome(ctx context.Context, slackClient *Cli
187187
IncomingPRs: nil,
188188
OutgoingPRs: nil,
189189
WorkspaceOrgs: []string{"your-org"},
190-
}, "your-org", mapping)
190+
}, mapping)
191191

192192
return slackClient.PublishHomeView(ctx, slackUserID, blocks)
193193
}

0 commit comments

Comments
 (0)