Skip to content

Commit 1ffd503

Browse files
authored
feat(themes): add create and update subcommands (#93)
1 parent c99a0e3 commit 1ffd503

3 files changed

Lines changed: 304 additions & 9 deletions

File tree

cmd/themes.go

Lines changed: 131 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,64 @@
11
package cmd
22

33
import (
4+
"encoding/json"
45
"fmt"
6+
"os"
57
"strconv"
68

79
"github.com/loops-so/cli/internal/config"
810
"github.com/loops-so/loops-go"
911
"github.com/spf13/cobra"
1012
)
1113

14+
// themeFieldParams holds the fields shared by `themes create` and
15+
// `themes update`. Name is empty when the flag is unset; Styles is nil unless
16+
// --styles-file is provided.
17+
type themeFieldParams struct {
18+
Name string
19+
Styles *loops.ThemeStyles
20+
}
21+
22+
func addThemeFieldFlags(cmd *cobra.Command) {
23+
cmd.Flags().StringP("name", "n", "", "Theme name")
24+
cmd.Flags().String("styles-file", "", "Path to a JSON file with theme styles")
25+
}
26+
27+
func themeFieldParamsFromCmd(cmd *cobra.Command) (themeFieldParams, error) {
28+
var p themeFieldParams
29+
if cmd.Flags().Changed("name") {
30+
p.Name, _ = cmd.Flags().GetString("name")
31+
}
32+
if cmd.Flags().Changed("styles-file") {
33+
path, _ := cmd.Flags().GetString("styles-file")
34+
if path == "" {
35+
return p, fmt.Errorf("--styles-file requires a value")
36+
}
37+
data, err := os.ReadFile(path)
38+
if err != nil {
39+
return p, fmt.Errorf("read --styles-file: %w", err)
40+
}
41+
var styles loops.ThemeStyles
42+
if err := json.Unmarshal(data, &styles); err != nil {
43+
return p, fmt.Errorf("parse --styles-file: %w", err)
44+
}
45+
p.Styles = &styles
46+
}
47+
return p, nil
48+
}
49+
1250
func runThemesGet(cfg *config.Config, id string) (*loops.Theme, error) {
1351
return newAPIClient(cfg).GetTheme(id)
1452
}
1553

54+
func runThemesCreate(cfg *config.Config, req loops.CreateThemeRequest) (*loops.Theme, error) {
55+
return newAPIClient(cfg).CreateTheme(req)
56+
}
57+
58+
func runThemesUpdate(cfg *config.Config, id string, req loops.UpdateThemeRequest) (*loops.Theme, error) {
59+
return newAPIClient(cfg).UpdateTheme(id, req)
60+
}
61+
1662
func runThemesList(cfg *config.Config, params loops.PaginationParams) ([]loops.Theme, error) {
1763
client := newAPIClient(cfg)
1864
if params.Cursor != "" {
@@ -106,21 +152,88 @@ var themesGetCmd = &cobra.Command{
106152
return printJSON(cmd.OutOrStdout(), th)
107153
}
108154

109-
t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE")
110-
t.Row("themeId", th.ID)
111-
t.Row("name", th.Name)
112-
t.Row("isDefault", strconv.FormatBool(th.IsDefault))
113-
t.Row("createdAt", th.CreatedAt)
114-
t.Row("updatedAt", th.UpdatedAt)
115-
if err := t.Render(); err != nil {
155+
return printTheme(cmd, th)
156+
},
157+
}
158+
159+
var themesCreateCmd = &cobra.Command{
160+
Use: "create",
161+
Short: "Create a theme",
162+
RunE: func(cmd *cobra.Command, args []string) error {
163+
params, err := themeFieldParamsFromCmd(cmd)
164+
if err != nil {
165+
return err
166+
}
167+
168+
cfg, err := loadConfig()
169+
if err != nil {
170+
return err
171+
}
172+
173+
th, err := runThemesCreate(cfg, loops.CreateThemeRequest{
174+
Name: params.Name,
175+
Styles: params.Styles,
176+
})
177+
if err != nil {
116178
return err
117179
}
118180

119-
fmt.Fprintln(cmd.OutOrStdout())
120-
return printThemeStyles(cmd, th.Styles)
181+
if isJSONOutput() {
182+
return printJSON(cmd.OutOrStdout(), th)
183+
}
184+
185+
fmt.Fprintf(cmd.OutOrStdout(), "Created. (id: %s)\n\n", th.ID)
186+
return printTheme(cmd, th)
187+
},
188+
}
189+
190+
var themesUpdateCmd = &cobra.Command{
191+
Use: "update <id>",
192+
Short: "Update a theme",
193+
Args: cobra.ExactArgs(1),
194+
RunE: func(cmd *cobra.Command, args []string) error {
195+
params, err := themeFieldParamsFromCmd(cmd)
196+
if err != nil {
197+
return err
198+
}
199+
200+
cfg, err := loadConfig()
201+
if err != nil {
202+
return err
203+
}
204+
205+
th, err := runThemesUpdate(cfg, args[0], loops.UpdateThemeRequest{
206+
Name: params.Name,
207+
Styles: params.Styles,
208+
})
209+
if err != nil {
210+
return err
211+
}
212+
213+
if isJSONOutput() {
214+
return printJSON(cmd.OutOrStdout(), th)
215+
}
216+
217+
fmt.Fprintf(cmd.OutOrStdout(), "Updated. (id: %s)\n\n", th.ID)
218+
return printTheme(cmd, th)
121219
},
122220
}
123221

222+
func printTheme(cmd *cobra.Command, th *loops.Theme) error {
223+
t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE")
224+
t.Row("themeId", th.ID)
225+
t.Row("name", th.Name)
226+
t.Row("isDefault", strconv.FormatBool(th.IsDefault))
227+
t.Row("createdAt", th.CreatedAt)
228+
t.Row("updatedAt", th.UpdatedAt)
229+
if err := t.Render(); err != nil {
230+
return err
231+
}
232+
233+
fmt.Fprintln(cmd.OutOrStdout())
234+
return printThemeStyles(cmd, th.Styles)
235+
}
236+
124237
func printThemeStyles(cmd *cobra.Command, s loops.ThemeStyles) error {
125238
t := newStyledTable(cmd.OutOrStdout(), "STYLE", "VALUE")
126239
for _, row := range themeStyleRows(s) {
@@ -189,5 +302,14 @@ func init() {
189302
addPickFlag(themesListCmd)
190303
themesCmd.AddCommand(themesListCmd)
191304
themesCmd.AddCommand(themesGetCmd)
305+
306+
addThemeFieldFlags(themesCreateCmd)
307+
themesCreateCmd.MarkFlagRequired("name")
308+
themesCmd.AddCommand(themesCreateCmd)
309+
310+
addThemeFieldFlags(themesUpdateCmd)
311+
themesUpdateCmd.MarkFlagsOneRequired("name", "styles-file")
312+
themesCmd.AddCommand(themesUpdateCmd)
313+
192314
rootCmd.AddCommand(themesCmd)
193315
}

cmd/themes_create_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"testing"
7+
8+
"github.com/loops-so/loops-go"
9+
)
10+
11+
func TestRunThemesCreate(t *testing.T) {
12+
body := `{
13+
"id": "theme_new",
14+
"name": "Brand",
15+
"isDefault": false,
16+
"createdAt": "2026-04-20T10:00:00Z",
17+
"updatedAt": "2026-04-20T10:00:00Z",
18+
"styles": {"backgroundColor": "#ffffff", "borderWidth": 2}
19+
}`
20+
21+
t.Run("returns theme on success", func(t *testing.T) {
22+
serveJSON(t, http.StatusCreated, body)
23+
th, err := runThemesCreate(cfg(t), loops.CreateThemeRequest{Name: "Brand"})
24+
if err != nil {
25+
t.Fatalf("unexpected error: %v", err)
26+
}
27+
if th.ID != "theme_new" {
28+
t.Errorf("ID = %q, want theme_new", th.ID)
29+
}
30+
if th.Name != "Brand" {
31+
t.Errorf("Name = %q, want Brand", th.Name)
32+
}
33+
})
34+
35+
t.Run("returns error on non-2xx response", func(t *testing.T) {
36+
serveJSON(t, http.StatusBadRequest, `{"success":false,"message":"name is required"}`)
37+
_, err := runThemesCreate(cfg(t), loops.CreateThemeRequest{})
38+
if err == nil {
39+
t.Fatal("expected error, got nil")
40+
}
41+
})
42+
43+
t.Run("sends name and styles", func(t *testing.T) {
44+
got := serveJSONCapture(t, http.StatusCreated, body)
45+
_, err := runThemesCreate(cfg(t), loops.CreateThemeRequest{
46+
Name: "Brand",
47+
Styles: &loops.ThemeStyles{
48+
BackgroundColor: "#ffffff",
49+
BorderWidth: 2,
50+
},
51+
})
52+
if err != nil {
53+
t.Fatalf("unexpected error: %v", err)
54+
}
55+
56+
var sent map[string]any
57+
if err := json.Unmarshal(got.Body, &sent); err != nil {
58+
t.Fatalf("decode request body: %v\nraw: %s", err, got.Body)
59+
}
60+
if sent["name"] != "Brand" {
61+
t.Errorf("name = %v, want Brand", sent["name"])
62+
}
63+
styles, ok := sent["styles"].(map[string]any)
64+
if !ok {
65+
t.Fatalf("styles not an object: %v", sent["styles"])
66+
}
67+
if styles["backgroundColor"] != "#ffffff" {
68+
t.Errorf("styles.backgroundColor = %v, want #ffffff", styles["backgroundColor"])
69+
}
70+
if styles["borderWidth"] != float64(2) {
71+
t.Errorf("styles.borderWidth = %v, want 2", styles["borderWidth"])
72+
}
73+
})
74+
75+
t.Run("omits styles when nil", func(t *testing.T) {
76+
got := serveJSONCapture(t, http.StatusCreated, body)
77+
_, err := runThemesCreate(cfg(t), loops.CreateThemeRequest{Name: "Brand"})
78+
if err != nil {
79+
t.Fatalf("unexpected error: %v", err)
80+
}
81+
82+
var sent map[string]any
83+
if err := json.Unmarshal(got.Body, &sent); err != nil {
84+
t.Fatalf("decode request body: %v\nraw: %s", err, got.Body)
85+
}
86+
if _, present := sent["styles"]; present {
87+
t.Errorf("styles should be omitted, got %v", sent["styles"])
88+
}
89+
})
90+
}

cmd/themes_update_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"testing"
7+
8+
"github.com/loops-so/loops-go"
9+
)
10+
11+
func TestRunThemesUpdate(t *testing.T) {
12+
body := `{
13+
"id": "theme_abc123",
14+
"name": "Renamed",
15+
"isDefault": false,
16+
"createdAt": "2026-04-01T10:00:00Z",
17+
"updatedAt": "2026-04-25T10:00:00Z",
18+
"styles": {"backgroundColor": "#000000"}
19+
}`
20+
21+
t.Run("returns theme on success", func(t *testing.T) {
22+
serveJSON(t, http.StatusOK, body)
23+
th, err := runThemesUpdate(cfg(t), "theme_abc123", loops.UpdateThemeRequest{Name: "Renamed"})
24+
if err != nil {
25+
t.Fatalf("unexpected error: %v", err)
26+
}
27+
if th.ID != "theme_abc123" {
28+
t.Errorf("ID = %q, want theme_abc123", th.ID)
29+
}
30+
if th.Name != "Renamed" {
31+
t.Errorf("Name = %q, want Renamed", th.Name)
32+
}
33+
})
34+
35+
t.Run("returns error on non-2xx response", func(t *testing.T) {
36+
serveJSON(t, http.StatusNotFound, `{"success":false,"message":"theme not found"}`)
37+
_, err := runThemesUpdate(cfg(t), "theme_missing", loops.UpdateThemeRequest{Name: "Renamed"})
38+
if err == nil {
39+
t.Fatal("expected error, got nil")
40+
}
41+
})
42+
43+
t.Run("sends name only when styles unset", func(t *testing.T) {
44+
got := serveJSONCapture(t, http.StatusOK, body)
45+
_, err := runThemesUpdate(cfg(t), "theme_abc123", loops.UpdateThemeRequest{Name: "Renamed"})
46+
if err != nil {
47+
t.Fatalf("unexpected error: %v", err)
48+
}
49+
50+
var sent map[string]any
51+
if err := json.Unmarshal(got.Body, &sent); err != nil {
52+
t.Fatalf("decode request body: %v\nraw: %s", err, got.Body)
53+
}
54+
if sent["name"] != "Renamed" {
55+
t.Errorf("name = %v, want Renamed", sent["name"])
56+
}
57+
if _, present := sent["styles"]; present {
58+
t.Errorf("styles should be omitted, got %v", sent["styles"])
59+
}
60+
})
61+
62+
t.Run("sends styles under styles key", func(t *testing.T) {
63+
got := serveJSONCapture(t, http.StatusOK, body)
64+
_, err := runThemesUpdate(cfg(t), "theme_abc123", loops.UpdateThemeRequest{
65+
Styles: &loops.ThemeStyles{BackgroundColor: "#000000"},
66+
})
67+
if err != nil {
68+
t.Fatalf("unexpected error: %v", err)
69+
}
70+
71+
var sent map[string]any
72+
if err := json.Unmarshal(got.Body, &sent); err != nil {
73+
t.Fatalf("decode request body: %v\nraw: %s", err, got.Body)
74+
}
75+
styles, ok := sent["styles"].(map[string]any)
76+
if !ok {
77+
t.Fatalf("styles not an object: %v", sent["styles"])
78+
}
79+
if styles["backgroundColor"] != "#000000" {
80+
t.Errorf("styles.backgroundColor = %v, want #000000", styles["backgroundColor"])
81+
}
82+
})
83+
}

0 commit comments

Comments
 (0)