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
132 changes: 126 additions & 6 deletions cmd/components.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"os"

"github.com/loops-so/cli/internal/config"
"github.com/loops-so/loops-go"
Expand All @@ -12,6 +13,47 @@ func runComponentsGet(cfg *config.Config, id string) (*loops.Component, error) {
return newAPIClient(cfg).GetComponent(id)
}

func runComponentsCreate(cfg *config.Config, req loops.CreateComponentRequest) (*loops.Component, error) {
return newAPIClient(cfg).CreateComponent(req)
}

func runComponentsUpdate(cfg *config.Config, id string, req loops.UpdateComponentRequest) (*loops.UpdateComponentResult, error) {
return newAPIClient(cfg).UpdateComponent(id, req)
}

// lmxFromCmd resolves the LMX body from either --lmx (inline) or --lmx-file
// (raw file contents). The two flags are mutually exclusive. The second return
// value reports whether either flag was set.
func lmxFromCmd(cmd *cobra.Command) (string, bool, error) {
if cmd.Flags().Changed("lmx") {
lmx, _ := cmd.Flags().GetString("lmx")
return lmx, true, nil
}
if cmd.Flags().Changed("lmx-file") {
path, _ := cmd.Flags().GetString("lmx-file")
data, err := os.ReadFile(path)
if err != nil {
return "", false, fmt.Errorf("read --lmx-file: %w", err)
}
return string(data), true, nil
}
return "", false, nil
}

// printComponent renders a component as a field table followed by its
// highlighted LMX body, matching the `components get` output.
func printComponent(cmd *cobra.Command, c *loops.Component) error {
t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE")
t.Row("componentId", c.ID)
t.Row("name", c.Name)
if err := t.Render(); err != nil {
return err
}

fmt.Fprintln(cmd.OutOrStdout())
return renderLMX(cmd.OutOrStdout(), c.LMX)
}

func runComponentsList(cfg *config.Config, params loops.PaginationParams) ([]loops.Component, error) {
client := newAPIClient(cfg)
if params.Cursor != "" {
Expand Down Expand Up @@ -100,15 +142,77 @@ var componentsGetCmd = &cobra.Command{
return printJSON(cmd.OutOrStdout(), c)
}

t := newStyledTable(cmd.OutOrStdout(), "FIELD", "VALUE")
t.Row("componentId", c.ID)
t.Row("name", c.Name)
if err := t.Render(); err != nil {
return printComponent(cmd, c)
},
}

var componentsCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a component",
RunE: func(cmd *cobra.Command, args []string) error {
name, _ := cmd.Flags().GetString("name")

lmx, _, err := lmxFromCmd(cmd)
if err != nil {
return err
}

cfg, err := loadConfig()
if err != nil {
return err
}

c, err := runComponentsCreate(cfg, loops.CreateComponentRequest{
Name: name,
LMX: lmx,
})
if err != nil {
return err
}

if isJSONOutput() {
return printJSON(cmd.OutOrStdout(), c)
}

fmt.Fprintf(cmd.OutOrStdout(), "Created. (componentId: %s)\n\n", c.ID)
return printComponent(cmd, c)
},
}

var componentsUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a component",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
lmx, lmxSet, err := lmxFromCmd(cmd)
if err != nil {
return err
}

req := loops.UpdateComponentRequest{}
if cmd.Flags().Changed("name") {
req.Name, _ = cmd.Flags().GetString("name")
}
if lmxSet {
req.LMX = lmx
}

cfg, err := loadConfig()
if err != nil {
return err
}

fmt.Fprintln(cmd.OutOrStdout())
return renderLMX(cmd.OutOrStdout(), c.LMX)
result, err := runComponentsUpdate(cfg, args[0], req)
if err != nil {
return err
}

if isJSONOutput() {
return printJSON(cmd.OutOrStdout(), result)
}

fmt.Fprintf(cmd.OutOrStdout(), "Updated. (componentId: %s, affectedEmailCount: %d)\n\n", result.ID, result.AffectedEmailCount)
return printComponent(cmd, &result.Component)
},
}

Expand All @@ -117,5 +221,21 @@ func init() {
addPickFlag(componentsListCmd)
componentsCmd.AddCommand(componentsListCmd)
componentsCmd.AddCommand(componentsGetCmd)

componentsCreateCmd.Flags().String("name", "", "Component name")
componentsCreateCmd.Flags().String("lmx", "", "LMX markup (inline)")
componentsCreateCmd.Flags().String("lmx-file", "", "Path to a file containing LMX markup")
componentsCreateCmd.MarkFlagRequired("name")
componentsCreateCmd.MarkFlagsMutuallyExclusive("lmx", "lmx-file")
componentsCreateCmd.MarkFlagsOneRequired("lmx", "lmx-file")
componentsCmd.AddCommand(componentsCreateCmd)

componentsUpdateCmd.Flags().String("name", "", "Component name")
componentsUpdateCmd.Flags().String("lmx", "", "LMX markup (inline)")
componentsUpdateCmd.Flags().String("lmx-file", "", "Path to a file containing LMX markup")
componentsUpdateCmd.MarkFlagsMutuallyExclusive("lmx", "lmx-file")
componentsUpdateCmd.MarkFlagsOneRequired("name", "lmx", "lmx-file")
componentsCmd.AddCommand(componentsUpdateCmd)

rootCmd.AddCommand(componentsCmd)
}
145 changes: 145 additions & 0 deletions cmd/components_create_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package cmd

import (
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"testing"

"github.com/loops-so/loops-go"
"github.com/spf13/cobra"
)

func TestRunComponentsCreate(t *testing.T) {
body := `{
"id": "cmp_new",
"name": "Footer",
"lmx": "<Paragraph>Hi</Paragraph>"
}`

t.Run("returns component on success", func(t *testing.T) {
serveJSON(t, http.StatusCreated, body)
c, err := runComponentsCreate(cfg(t), loops.CreateComponentRequest{Name: "Footer", LMX: "<Paragraph>Hi</Paragraph>"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if c.ID != "cmp_new" {
t.Errorf("ID = %q, want cmp_new", c.ID)
}
if c.Name != "Footer" {
t.Errorf("Name = %q, want Footer", c.Name)
}
})

t.Run("returns error on non-2xx response", func(t *testing.T) {
serveJSON(t, http.StatusBadRequest, `{"success":false,"message":"name is required"}`)
_, err := runComponentsCreate(cfg(t), loops.CreateComponentRequest{})
if err == nil {
t.Fatal("expected error, got nil")
}
})

t.Run("sends name and lmx in request body", func(t *testing.T) {
got := serveJSONCapture(t, http.StatusCreated, body)
_, err := runComponentsCreate(cfg(t), loops.CreateComponentRequest{
Name: "Footer",
LMX: "<Paragraph>Hi</Paragraph>",
})
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"] != "Footer" {
t.Errorf("name = %v, want Footer", sent["name"])
}
if sent["lmx"] != "<Paragraph>Hi</Paragraph>" {
t.Errorf("lmx = %v", sent["lmx"])
}
})
}

func TestComponentsCreateCmdLmxFile(t *testing.T) {
body := `{"id":"cmp_new","name":"Footer","lmx":"<Paragraph>From file</Paragraph>"}`
got := serveJSONCapture(t, http.StatusCreated, body)

dir := t.TempDir()
path := filepath.Join(dir, "footer.lmx")
if err := os.WriteFile(path, []byte("<Paragraph>From file</Paragraph>"), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}

saved := outputFormat
outputFormat = "text"
t.Cleanup(func() { outputFormat = saved })

cmd := *componentsCreateCmd
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := cmd.ParseFlags([]string{"--name", "Footer", "--lmx-file", path}); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
if err := cmd.RunE(&cmd, []string{}); err != nil {
t.Fatalf("RunE: %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["lmx"] != "<Paragraph>From file</Paragraph>" {
t.Errorf("lmx = %v, want file contents", sent["lmx"])
}
if sent["name"] != "Footer" {
t.Errorf("name = %v, want Footer", sent["name"])
}
}

// newLmxTestCmd returns a fresh command carrying its own flag set so subtests
// do not alias the shared package-level command's flags.
func newLmxTestCmd() *cobra.Command {
cmd := &cobra.Command{}
cmd.Flags().String("name", "", "")
cmd.Flags().String("lmx", "", "")
cmd.Flags().String("lmx-file", "", "")
return cmd
}

func TestLmxFromCmd(t *testing.T) {
t.Run("inline lmx", func(t *testing.T) {
cmd := newLmxTestCmd()
cmd.ParseFlags([]string{"--lmx", "<Paragraph>Inline</Paragraph>"})
lmx, set, err := lmxFromCmd(cmd)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !set || lmx != "<Paragraph>Inline</Paragraph>" {
t.Errorf("lmx=%q set=%v", lmx, set)
}
})

t.Run("missing lmx-file returns error", func(t *testing.T) {
cmd := newLmxTestCmd()
cmd.ParseFlags([]string{"--lmx-file", "/does/not/exist.lmx"})
if _, _, err := lmxFromCmd(cmd); err == nil {
t.Fatal("expected error for missing file, got nil")
}
})

t.Run("neither flag set", func(t *testing.T) {
cmd := newLmxTestCmd()
cmd.ParseFlags([]string{"--name", "x"})
lmx, set, err := lmxFromCmd(cmd)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if set || lmx != "" {
t.Errorf("expected unset, got lmx=%q set=%v", lmx, set)
}
})
}
Loading