Skip to content
Merged
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
140 changes: 131 additions & 9 deletions cmd/themes.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,64 @@
package cmd

import (
"encoding/json"
"fmt"
"os"
"strconv"

"github.com/loops-so/cli/internal/config"
"github.com/loops-so/loops-go"
"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 != "" {
Expand Down Expand Up @@ -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 <id>",
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) {
Expand Down Expand Up @@ -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)
}
90 changes: 90 additions & 0 deletions cmd/themes_create_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
})
}
83 changes: 83 additions & 0 deletions cmd/themes_update_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
})
}