-
Notifications
You must be signed in to change notification settings - Fork 39
feat: add blocks preview command to open blocks in Block Kit Builder #620
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6091f50
6d3f066
55afeba
d19e65d
d97944d
0a95868
739406d
519ff67
2f8d29a
39e2cd7
279794b
53b6ee1
85309a5
989cfbc
46fa7a3
252308d
2102c7b
006f027
0b0b4d3
fe16f49
87fa6d3
1ffef7c
4e51a7a
dd90080
2525043
53c1129
ffa22a7
f2c7c6f
345fe94
64bbc8e
4503762
5874f18
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| // Copyright 2022-2026 Salesforce, Inc. | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package blocks | ||
|
|
||
| import ( | ||
| "github.com/slackapi/slack-cli/internal/shared" | ||
| "github.com/slackapi/slack-cli/internal/style" | ||
| "github.com/slackapi/slack-cli/internal/useragent" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| // aiAgentFunc is a package variable so it can be stubbed in tests. | ||
| var aiAgentFunc = useragent.GetAIAgent | ||
|
|
||
| func NewCommand(clients *shared.ClientFactory) *cobra.Command { | ||
| cmd := &cobra.Command{ | ||
| Use: "blocks <subcommand> [flags]", | ||
| Short: "Build with Block Kit", | ||
| Long: "Build layouts using Block Kit and iterate on designs with Block Kit Builder.", | ||
| Hidden: aiAgentFunc() == nil, | ||
| Example: style.ExampleCommandsf([]style.ExampleCommand{ | ||
| { | ||
| Meaning: "Preview blocks in Block Kit Builder", | ||
| Command: "blocks preview --blocks '[{\"type\":\"divider\"}]'", | ||
| }, | ||
| }), | ||
| Args: cobra.NoArgs, | ||
| RunE: func(cmd *cobra.Command, args []string) error { | ||
| return cmd.Help() | ||
| }, | ||
| } | ||
|
|
||
| cmd.AddCommand(NewPreviewCommand(clients)) | ||
|
|
||
| return cmd | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| // Copyright 2022-2026 Salesforce, Inc. | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package blocks | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/slackapi/slack-cli/internal/shared" | ||
| "github.com/slackapi/slack-cli/internal/useragent" | ||
| "github.com/slackapi/slack-cli/test/testutil" | ||
| "github.com/spf13/cobra" | ||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| func Test_Blocks_Command(t *testing.T) { | ||
| testutil.TableTestCommand(t, testutil.CommandTests{ | ||
| "prints help without a subcommand": { | ||
| Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { | ||
| }, | ||
| ExpectedOutputs: []string{ | ||
| "Build layouts using Block Kit", | ||
| "preview", | ||
| }, | ||
| }, | ||
| }, func(cf *shared.ClientFactory) *cobra.Command { | ||
| return NewCommand(cf) | ||
| }) | ||
| } | ||
|
|
||
| func Test_Blocks_Command_Hidden(t *testing.T) { | ||
| tests := map[string]struct { | ||
| aiAgent *useragent.AIAgent | ||
| expectedHidden bool | ||
| }{ | ||
| "hidden when no AI coding tool is detected": { | ||
| aiAgent: nil, | ||
| expectedHidden: true, | ||
| }, | ||
| "visible when an AI coding tool is detected": { | ||
| aiAgent: &useragent.AIAgent{Name: "claude-code"}, | ||
| expectedHidden: false, | ||
| }, | ||
| } | ||
| for name, tc := range tests { | ||
| t.Run(name, func(t *testing.T) { | ||
| restore := stubAIAgent(tc.aiAgent) | ||
| defer restore() | ||
|
|
||
| clientsMock := shared.NewClientsMock() | ||
| clients := shared.NewClientFactory(clientsMock.MockClientFactory()) | ||
| cmd := NewCommand(clients) | ||
| assert.Equal(t, tc.expectedHidden, cmd.Hidden) | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,216 @@ | ||||||||||
| // Copyright 2022-2026 Salesforce, Inc. | ||||||||||
| // | ||||||||||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||||||||||
| // you may not use this file except in compliance with the License. | ||||||||||
| // You may obtain a copy of the License at | ||||||||||
| // | ||||||||||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||||||||||
| // | ||||||||||
| // Unless required by applicable law or agreed to in writing, software | ||||||||||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||||||||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||||||||
| // See the License for the specific language governing permissions and | ||||||||||
| // limitations under the License. | ||||||||||
|
|
||||||||||
| package blocks | ||||||||||
|
|
||||||||||
| import ( | ||||||||||
| "bytes" | ||||||||||
| "context" | ||||||||||
| "encoding/json" | ||||||||||
| "fmt" | ||||||||||
| "net/url" | ||||||||||
| "strings" | ||||||||||
|
|
||||||||||
| "github.com/slackapi/slack-cli/internal/goutils" | ||||||||||
| "github.com/slackapi/slack-cli/internal/shared" | ||||||||||
| "github.com/slackapi/slack-cli/internal/shared/types" | ||||||||||
| "github.com/slackapi/slack-cli/internal/slackerror" | ||||||||||
| "github.com/slackapi/slack-cli/internal/slacktrace" | ||||||||||
| "github.com/slackapi/slack-cli/internal/style" | ||||||||||
| "github.com/spf13/cobra" | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| func NewPreviewCommand(clients *shared.ClientFactory) *cobra.Command { | ||||||||||
| var blocksFlag string | ||||||||||
| cmd := &cobra.Command{ | ||||||||||
| Use: "preview [flags]", | ||||||||||
| Short: "Preview blocks in Block Kit Builder", | ||||||||||
| Long: strings.Join([]string{ | ||||||||||
| "Preview a set of Block Kit blocks with Block Kit Builder in a web browser.", | ||||||||||
| "", | ||||||||||
| "Provide blocks with the --blocks flag.", | ||||||||||
| "The input is a JSON array of blocks or a JSON object with a \"blocks\" array.", | ||||||||||
| "Pass - to --blocks, or omit all flags, to read from standard input.", | ||||||||||
| }, "\n"), | ||||||||||
| Example: style.ExampleCommandsf([]style.ExampleCommand{ | ||||||||||
| { | ||||||||||
| Meaning: "Preview blocks passed as a flag value", | ||||||||||
| Command: "blocks preview --blocks '[{\"type\":\"divider\"}]'", | ||||||||||
| }, | ||||||||||
| { | ||||||||||
| Meaning: "Preview blocks read from a file", | ||||||||||
| Command: "blocks preview < blocks.json", | ||||||||||
| }, | ||||||||||
| { | ||||||||||
| Meaning: "Preview blocks read from a redirect and scoped to a team", | ||||||||||
| Command: "blocks preview --team T0123456 --blocks - < blocks.json", | ||||||||||
| }, | ||||||||||
|
Comment on lines
+55
to
+58
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 praise: Thanks for including this as an example! In ongoing iteration this might be a preferred pattern for me to use. |
||||||||||
| }), | ||||||||||
| Args: cobra.NoArgs, | ||||||||||
| RunE: func(cmd *cobra.Command, args []string) error { | ||||||||||
| return previewCommandRunE(clients, cmd, blocksFlag, cmd.Flags().Changed("blocks")) | ||||||||||
| }, | ||||||||||
| } | ||||||||||
| cmd.Flags().StringVar(&blocksFlag, "blocks", "", "blocks to preview as a JSON array or object\n (use - to read from standard input)") | ||||||||||
| return cmd | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func previewCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, blocksFlag string, blocksFlagChanged bool) error { | ||||||||||
| ctx := cmd.Context() | ||||||||||
| clients.IO.PrintTrace(ctx, slacktrace.BlocksPreviewStart) | ||||||||||
|
|
||||||||||
| blocksInput, err := resolveBlocksInput(clients, blocksFlag, blocksFlagChanged) | ||||||||||
| if err != nil { | ||||||||||
| return err | ||||||||||
| } | ||||||||||
|
|
||||||||||
| blocksJSON, err := normalizeBlocksPayload(blocksInput) | ||||||||||
| if err != nil { | ||||||||||
| return err | ||||||||||
| } | ||||||||||
|
|
||||||||||
| auth, err := resolveTeamAuth(ctx, clients) | ||||||||||
| if err != nil { | ||||||||||
| return err | ||||||||||
| } | ||||||||||
|
|
||||||||||
| builderURL, err := buildBlockKitBuilderURL(clients.API().Host(), teamOrEnterpriseID(auth), blocksJSON) | ||||||||||
| if err != nil { | ||||||||||
| return err | ||||||||||
| } | ||||||||||
|
|
||||||||||
| clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ | ||||||||||
| Emoji: "bricks", | ||||||||||
| Text: "Blocks Preview", | ||||||||||
| Secondary: []string{ | ||||||||||
| builderURL, | ||||||||||
| }, | ||||||||||
| })) | ||||||||||
| clients.Browser().OpenURL(builderURL) | ||||||||||
|
|
||||||||||
| clients.IO.PrintTrace(ctx, slacktrace.BlocksPreviewSuccess, builderURL) | ||||||||||
| return nil | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func resolveTeamAuth(ctx context.Context, clients *shared.ClientFactory) (*types.SlackAuth, error) { | ||||||||||
| if clients.Config.TeamFlag == "" { | ||||||||||
| return nil, nil | ||||||||||
| } | ||||||||||
| auths, err := clients.Auth().Auths(ctx) | ||||||||||
| if err != nil { | ||||||||||
| return nil, err | ||||||||||
| } | ||||||||||
| for i, auth := range auths { | ||||||||||
| if clients.Config.TeamFlag == auth.TeamID || clients.Config.TeamFlag == auth.TeamDomain { | ||||||||||
| return &auths[i], nil | ||||||||||
| } | ||||||||||
| } | ||||||||||
| return nil, slackerror.New(slackerror.ErrTeamNotFound) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func resolveBlocksInput(clients *shared.ClientFactory, flagValue string, flagChanged bool) (string, error) { | ||||||||||
| switch { | ||||||||||
| case flagChanged && flagValue == "-": | ||||||||||
| if clients.IO.IsStdinTTY() { | ||||||||||
| return "", slackerror.New(slackerror.ErrMissingInput). | ||||||||||
| WithMessage("No blocks were provided on standard input"). | ||||||||||
| WithRemediation("Redirect blocks into the command with \"<\", e.g. %s", style.Commandf("blocks preview < blocks.json", false)) | ||||||||||
| } | ||||||||||
| return readStdinBlocks(clients) | ||||||||||
|
Comment on lines
+124
to
+130
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
⚙️ question: Would an omitted
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So this was and pattern I considered originally but it creates weird edge cases with flags, like how do we properly handle Maybe if we support no flags with the following then we don't support flags with this type of pattern 🤔 |
||||||||||
| case flagChanged: | ||||||||||
| input := strings.TrimSpace(flagValue) | ||||||||||
| if input == "" { | ||||||||||
| return "", missingBlocksError() | ||||||||||
| } | ||||||||||
| return input, nil | ||||||||||
| case !clients.IO.IsStdinTTY(): | ||||||||||
| return readStdinBlocks(clients) | ||||||||||
| default: | ||||||||||
| return "", missingBlocksError() | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func readStdinBlocks(clients *shared.ClientFactory) (string, error) { | ||||||||||
| input, err := clients.IO.ReadInAll() | ||||||||||
| if err != nil { | ||||||||||
| return "", slackerror.Wrap(err, slackerror.ErrMissingInput) | ||||||||||
| } | ||||||||||
| if input == "" { | ||||||||||
| return "", missingBlocksError() | ||||||||||
| } | ||||||||||
| return input, nil | ||||||||||
| } | ||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚂 suggestion: We can perhaps separate the input itself and mixing errors from command details with moving this function to: |
||||||||||
|
|
||||||||||
| func missingBlocksError() error { | ||||||||||
| return slackerror.New(slackerror.ErrMissingInput). | ||||||||||
| WithMessage("No blocks were provided"). | ||||||||||
| WithRemediation("Provide blocks with the %s flag, or pass %s to read from standard input", style.Highlight("--blocks"), style.Highlight("--blocks -")) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func normalizeBlocksPayload(input string) (string, error) { | ||||||||||
| compacted, err := goutils.CompactJSON([]byte(input)) | ||||||||||
| if err != nil { | ||||||||||
| return "", slackerror.JSONUnmarshalError(err, []byte(input)) | ||||||||||
| } | ||||||||||
| trimmed := bytes.TrimSpace(compacted) | ||||||||||
| if len(trimmed) == 0 { | ||||||||||
| return "", slackerror.New(slackerror.ErrInvalidBlocks) | ||||||||||
| } | ||||||||||
|
|
||||||||||
| switch trimmed[0] { | ||||||||||
| case '[': | ||||||||||
| return fmt.Sprintf(`{"blocks":%s}`, trimmed), nil | ||||||||||
| case '{': | ||||||||||
| var object map[string]json.RawMessage | ||||||||||
| if err := json.Unmarshal(trimmed, &object); err != nil { | ||||||||||
| return "", slackerror.New(slackerror.ErrInvalidBlocks) | ||||||||||
| } | ||||||||||
| blocks, ok := object["blocks"] | ||||||||||
| if !ok || len(bytes.TrimSpace(blocks)) == 0 || bytes.TrimSpace(blocks)[0] != '[' { | ||||||||||
| return "", slackerror.New(slackerror.ErrInvalidBlocks) | ||||||||||
| } | ||||||||||
| return string(trimmed), nil | ||||||||||
| default: | ||||||||||
| return "", slackerror.New(slackerror.ErrInvalidBlocks) | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func teamOrEnterpriseID(auth *types.SlackAuth) string { | ||||||||||
| if auth == nil { | ||||||||||
| return "" | ||||||||||
| } | ||||||||||
| if auth.IsEnterpriseInstall { | ||||||||||
| return auth.EnterpriseID | ||||||||||
| } | ||||||||||
| return auth.TeamID | ||||||||||
| } | ||||||||||
|
|
||||||||||
| func buildBlockKitBuilderURL(apiHost string, teamOrEnterpriseID string, blocksJSON string) (string, error) { | ||||||||||
| parsed, err := url.Parse(apiHost) | ||||||||||
| if err != nil { | ||||||||||
| return "", slackerror.Wrap(err, slackerror.ErrInvalidArguments) | ||||||||||
| } | ||||||||||
| if parsed.Host == "" { | ||||||||||
| return "", slackerror.New(slackerror.ErrInvalidArguments). | ||||||||||
| WithMessage("The API host %q is not a valid URL", apiHost) | ||||||||||
| } | ||||||||||
| parsed.Host = "app." + parsed.Host | ||||||||||
| if teamOrEnterpriseID != "" { | ||||||||||
| parsed.Path = fmt.Sprintf("/block-kit-builder/%s/builder", teamOrEnterpriseID) | ||||||||||
| } else { | ||||||||||
| parsed.Path = "/block-kit-builder" | ||||||||||
| } | ||||||||||
| parsed.Fragment = blocksJSON | ||||||||||
| return parsed.String(), nil | ||||||||||
| } | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🪓 thought: I'd be alright making this command visible to standard use either now or later? This command seems so useful to casual block kit building IMHO!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My aim here was to make it available only to AI tools for now since it would let us iterate on the command and introduce breaking changes until we decide to make it visible to everyone