From 5a4bed17692ba2a97807611626291f1efdee18b2 Mon Sep 17 00:00:00 2001 From: Nate Meyer <672246+notnmeyer@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:00:26 -0700 Subject: [PATCH] feat(themes): add create and update subcommands --- cmd/themes.go | 140 +++++++++++++++++++++++++++++++++++--- cmd/themes_create_test.go | 90 ++++++++++++++++++++++++ cmd/themes_update_test.go | 83 ++++++++++++++++++++++ 3 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 cmd/themes_create_test.go create mode 100644 cmd/themes_update_test.go diff --git a/cmd/themes.go b/cmd/themes.go index 8c6f71d..4e36e3f 100644 --- a/cmd/themes.go +++ b/cmd/themes.go @@ -1,7 +1,9 @@ package cmd import ( + "encoding/json" "fmt" + "os" "strconv" "github.com/loops-so/cli/internal/config" @@ -9,10 +11,54 @@ import ( "github.com/spf13/cobra" ) +// themeFieldParams holds the fields shared by `themes create` and +// `themes update`. Name is empty when the flag is unset; Styles is nil unless +// --styles-file is provided. +type themeFieldParams struct { + Name string + Styles *loops.ThemeStyles +} + +func addThemeFieldFlags(cmd *cobra.Command) { + cmd.Flags().StringP("name", "n", "", "Theme name") + cmd.Flags().String("styles-file", "", "Path to a JSON file with theme styles") +} + +func themeFieldParamsFromCmd(cmd *cobra.Command) (themeFieldParams, error) { + var p themeFieldParams + if cmd.Flags().Changed("name") { + p.Name, _ = cmd.Flags().GetString("name") + } + if cmd.Flags().Changed("styles-file") { + path, _ := cmd.Flags().GetString("styles-file") + if path == "" { + return p, fmt.Errorf("--styles-file requires a value") + } + data, err := os.ReadFile(path) + if err != nil { + return p, fmt.Errorf("read --styles-file: %w", err) + } + var styles loops.ThemeStyles + if err := json.Unmarshal(data, &styles); err != nil { + return p, fmt.Errorf("parse --styles-file: %w", err) + } + p.Styles = &styles + } + return p, nil +} + func runThemesGet(cfg *config.Config, id string) (*loops.Theme, error) { return newAPIClient(cfg).GetTheme(id) } +func runThemesCreate(cfg *config.Config, req loops.CreateThemeRequest) (*loops.Theme, error) { + return newAPIClient(cfg).CreateTheme(req) +} + +func runThemesUpdate(cfg *config.Config, id string, req loops.UpdateThemeRequest) (*loops.Theme, error) { + return newAPIClient(cfg).UpdateTheme(id, req) +} + func runThemesList(cfg *config.Config, params loops.PaginationParams) ([]loops.Theme, error) { client := newAPIClient(cfg) if params.Cursor != "" { @@ -106,21 +152,88 @@ var themesGetCmd = &cobra.Command{ return printJSON(cmd.OutOrStdout(), th) } - t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") - t.Row("themeId", th.ID) - t.Row("name", th.Name) - t.Row("isDefault", strconv.FormatBool(th.IsDefault)) - t.Row("createdAt", th.CreatedAt) - t.Row("updatedAt", th.UpdatedAt) - if err := t.Render(); err != nil { + return printTheme(cmd, th) + }, +} + +var themesCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a theme", + RunE: func(cmd *cobra.Command, args []string) error { + params, err := themeFieldParamsFromCmd(cmd) + if err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + th, err := runThemesCreate(cfg, loops.CreateThemeRequest{ + Name: params.Name, + Styles: params.Styles, + }) + if err != nil { return err } - fmt.Fprintln(cmd.OutOrStdout()) - return printThemeStyles(cmd, th.Styles) + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), th) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Created. (id: %s)\n\n", th.ID) + return printTheme(cmd, th) + }, +} + +var themesUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a theme", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + params, err := themeFieldParamsFromCmd(cmd) + if err != nil { + return err + } + + cfg, err := loadConfig() + if err != nil { + return err + } + + th, err := runThemesUpdate(cfg, args[0], loops.UpdateThemeRequest{ + Name: params.Name, + Styles: params.Styles, + }) + if err != nil { + return err + } + + if isJSONOutput() { + return printJSON(cmd.OutOrStdout(), th) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Updated. (id: %s)\n\n", th.ID) + return printTheme(cmd, th) }, } +func printTheme(cmd *cobra.Command, th *loops.Theme) error { + t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE") + t.Row("themeId", th.ID) + t.Row("name", th.Name) + t.Row("isDefault", strconv.FormatBool(th.IsDefault)) + t.Row("createdAt", th.CreatedAt) + t.Row("updatedAt", th.UpdatedAt) + if err := t.Render(); err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout()) + return printThemeStyles(cmd, th.Styles) +} + func printThemeStyles(cmd *cobra.Command, s loops.ThemeStyles) error { t := newStyledTable(cmd.OutOrStdout(), "STYLE", "VALUE") for _, row := range themeStyleRows(s) { @@ -189,5 +302,14 @@ func init() { addPickFlag(themesListCmd) themesCmd.AddCommand(themesListCmd) themesCmd.AddCommand(themesGetCmd) + + addThemeFieldFlags(themesCreateCmd) + themesCreateCmd.MarkFlagRequired("name") + themesCmd.AddCommand(themesCreateCmd) + + addThemeFieldFlags(themesUpdateCmd) + themesUpdateCmd.MarkFlagsOneRequired("name", "styles-file") + themesCmd.AddCommand(themesUpdateCmd) + rootCmd.AddCommand(themesCmd) } diff --git a/cmd/themes_create_test.go b/cmd/themes_create_test.go new file mode 100644 index 0000000..1d58422 --- /dev/null +++ b/cmd/themes_create_test.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/loops-so/loops-go" +) + +func TestRunThemesCreate(t *testing.T) { + body := `{ + "id": "theme_new", + "name": "Brand", + "isDefault": false, + "createdAt": "2026-04-20T10:00:00Z", + "updatedAt": "2026-04-20T10:00:00Z", + "styles": {"backgroundColor": "#ffffff", "borderWidth": 2} + }` + + t.Run("returns theme on success", func(t *testing.T) { + serveJSON(t, http.StatusCreated, body) + th, err := runThemesCreate(cfg(t), loops.CreateThemeRequest{Name: "Brand"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if th.ID != "theme_new" { + t.Errorf("ID = %q, want theme_new", th.ID) + } + if th.Name != "Brand" { + t.Errorf("Name = %q, want Brand", th.Name) + } + }) + + t.Run("returns error on non-2xx response", func(t *testing.T) { + serveJSON(t, http.StatusBadRequest, `{"success":false,"message":"name is required"}`) + _, err := runThemesCreate(cfg(t), loops.CreateThemeRequest{}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("sends name and styles", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusCreated, body) + _, err := runThemesCreate(cfg(t), loops.CreateThemeRequest{ + Name: "Brand", + Styles: &loops.ThemeStyles{ + BackgroundColor: "#ffffff", + BorderWidth: 2, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode request body: %v\nraw: %s", err, got.Body) + } + if sent["name"] != "Brand" { + t.Errorf("name = %v, want Brand", sent["name"]) + } + styles, ok := sent["styles"].(map[string]any) + if !ok { + t.Fatalf("styles not an object: %v", sent["styles"]) + } + if styles["backgroundColor"] != "#ffffff" { + t.Errorf("styles.backgroundColor = %v, want #ffffff", styles["backgroundColor"]) + } + if styles["borderWidth"] != float64(2) { + t.Errorf("styles.borderWidth = %v, want 2", styles["borderWidth"]) + } + }) + + t.Run("omits styles when nil", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusCreated, body) + _, err := runThemesCreate(cfg(t), loops.CreateThemeRequest{Name: "Brand"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode request body: %v\nraw: %s", err, got.Body) + } + if _, present := sent["styles"]; present { + t.Errorf("styles should be omitted, got %v", sent["styles"]) + } + }) +} diff --git a/cmd/themes_update_test.go b/cmd/themes_update_test.go new file mode 100644 index 0000000..bfd9823 --- /dev/null +++ b/cmd/themes_update_test.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/loops-so/loops-go" +) + +func TestRunThemesUpdate(t *testing.T) { + body := `{ + "id": "theme_abc123", + "name": "Renamed", + "isDefault": false, + "createdAt": "2026-04-01T10:00:00Z", + "updatedAt": "2026-04-25T10:00:00Z", + "styles": {"backgroundColor": "#000000"} + }` + + t.Run("returns theme on success", func(t *testing.T) { + serveJSON(t, http.StatusOK, body) + th, err := runThemesUpdate(cfg(t), "theme_abc123", loops.UpdateThemeRequest{Name: "Renamed"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if th.ID != "theme_abc123" { + t.Errorf("ID = %q, want theme_abc123", th.ID) + } + if th.Name != "Renamed" { + t.Errorf("Name = %q, want Renamed", th.Name) + } + }) + + t.Run("returns error on non-2xx response", func(t *testing.T) { + serveJSON(t, http.StatusNotFound, `{"success":false,"message":"theme not found"}`) + _, err := runThemesUpdate(cfg(t), "theme_missing", loops.UpdateThemeRequest{Name: "Renamed"}) + if err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("sends name only when styles unset", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, body) + _, err := runThemesUpdate(cfg(t), "theme_abc123", loops.UpdateThemeRequest{Name: "Renamed"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode request body: %v\nraw: %s", err, got.Body) + } + if sent["name"] != "Renamed" { + t.Errorf("name = %v, want Renamed", sent["name"]) + } + if _, present := sent["styles"]; present { + t.Errorf("styles should be omitted, got %v", sent["styles"]) + } + }) + + t.Run("sends styles under styles key", func(t *testing.T) { + got := serveJSONCapture(t, http.StatusOK, body) + _, err := runThemesUpdate(cfg(t), "theme_abc123", loops.UpdateThemeRequest{ + Styles: &loops.ThemeStyles{BackgroundColor: "#000000"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sent map[string]any + if err := json.Unmarshal(got.Body, &sent); err != nil { + t.Fatalf("decode request body: %v\nraw: %s", err, got.Body) + } + styles, ok := sent["styles"].(map[string]any) + if !ok { + t.Fatalf("styles not an object: %v", sent["styles"]) + } + if styles["backgroundColor"] != "#000000" { + t.Errorf("styles.backgroundColor = %v, want #000000", styles["backgroundColor"]) + } + }) +}