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
3 changes: 2 additions & 1 deletion cmd/campaigns.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,14 +224,15 @@ var campaignsListCmd = &cobra.Command{
return nil
}

headers := []string{"ID", "MESSAGE ID", "NAME", "STATUS", "SCHEDULING", "UPDATED"}
headers := []string{"ID", "MESSAGE ID", "NAME", "STATUS", "GROUP", "SCHEDULING", "UPDATED"}
rows := make([][]string, 0, len(campaigns))
for _, c := range campaigns {
rows = append(rows, []string{
c.ID,
deref(c.EmailMessageID),
c.Name,
c.Status,
deref(c.CampaignGroupID),
formatCampaignScheduling(c.Scheduling),
c.UpdatedAt,
})
Expand Down
33 changes: 30 additions & 3 deletions cmd/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import (
)

// groupCmdSet binds the four SDK methods that back a *Group resource
// (campaign-groups or transactional-groups) to the labels used in CLI text.
// Both group types share the same Group/CreateGroupRequest/UpdateGroupRequest
// shapes, so the command tree is built once and dispatched twice.
// (campaign-groups, transactional-groups, or workflow-groups) to the labels
// used in CLI text. All group types share the same
// Group/CreateGroupRequest/UpdateGroupRequest shapes, so the command tree is
// built once and dispatched per resource.
type groupCmdSet struct {
use string // top-level command name, e.g. "campaign-groups"
idLabel string // table label for the id, e.g. "campaignGroupId"
Expand Down Expand Up @@ -57,6 +58,22 @@ func runTransactionalGroupsUpdate(cfg *config.Config, id string, req loops.Updat
return newAPIClient(cfg).UpdateTransactionalGroup(id, req)
}

func runWorkflowGroupsList(cfg *config.Config, params loops.PaginationParams) ([]loops.Group, error) {
return paginateGroups(params, newAPIClient(cfg).ListWorkflowGroups)
}

func runWorkflowGroupsGet(cfg *config.Config, id string) (*loops.Group, error) {
return newAPIClient(cfg).GetWorkflowGroup(id)
}

func runWorkflowGroupsCreate(cfg *config.Config, req loops.CreateGroupRequest) (*loops.Group, error) {
return newAPIClient(cfg).CreateWorkflowGroup(req)
}

func runWorkflowGroupsUpdate(cfg *config.Config, id string, req loops.UpdateGroupRequest) (*loops.Group, error) {
return newAPIClient(cfg).UpdateWorkflowGroup(id, req)
}

// paginateGroups runs single-page fetch when a cursor is set, otherwise walks
// every page via loops.Paginate.
func paginateGroups(params loops.PaginationParams, list func(loops.PaginationParams) ([]loops.Group, *loops.Pagination, error)) ([]loops.Group, error) {
Expand Down Expand Up @@ -243,4 +260,14 @@ func init() {
runCreate: runTransactionalGroupsCreate,
runUpdate: runTransactionalGroupsUpdate,
}))

rootCmd.AddCommand(newGroupsCmd(groupCmdSet{
use: "workflow-groups",
idLabel: "workflowGroupId",
singular: "workflow group",
runList: runWorkflowGroupsList,
runGet: runWorkflowGroupsGet,
runCreate: runWorkflowGroupsCreate,
runUpdate: runWorkflowGroupsUpdate,
}))
}
49 changes: 49 additions & 0 deletions cmd/groups_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,52 @@ func TestRunTransactionalGroupsEndpoints(t *testing.T) {
}
})
}

func TestRunWorkflowGroupsEndpoints(t *testing.T) {
t.Run("list hits /workflow-groups", func(t *testing.T) {
got := serveJSONCapture(t, http.StatusOK, `{"pagination":{"nextCursor":""},"data":[]}`)
if _, err := runWorkflowGroupsList(cfg(t), loops.PaginationParams{}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Path != "/workflow-groups" {
t.Errorf("Path = %q", got.Path)
}
})

t.Run("get hits /workflow-groups/:id", func(t *testing.T) {
got := serveJSONCapture(t, http.StatusOK, sampleGroupBody)
if _, err := runWorkflowGroupsGet(cfg(t), "grp_abc"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Path != "/workflow-groups/grp_abc" {
t.Errorf("Path = %q", got.Path)
}
})

t.Run("create hits /workflow-groups", func(t *testing.T) {
got := serveJSONCapture(t, http.StatusCreated, sampleGroupBody)
if _, err := runWorkflowGroupsCreate(cfg(t), loops.CreateGroupRequest{Name: "Onboarding"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Path != "/workflow-groups" || got.Method != http.MethodPost {
t.Errorf("Path/Method = %q/%q", got.Path, got.Method)
}
})

t.Run("update hits /workflow-groups/:id", func(t *testing.T) {
got := serveJSONCapture(t, http.StatusOK, sampleGroupBody)
if _, err := runWorkflowGroupsUpdate(cfg(t), "grp_abc", loops.UpdateGroupRequest{Name: "Renamed"}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Path != "/workflow-groups/grp_abc" || got.Method != http.MethodPost {
t.Errorf("Path/Method = %q/%q", got.Path, got.Method)
}
})

t.Run("returns error on api failure", func(t *testing.T) {
serveJSON(t, http.StatusUnauthorized, `{"error":"unauthorized"}`)
if _, err := runWorkflowGroupsList(cfg(t), loops.PaginationParams{}); err == nil {
t.Fatal("expected error, got nil")
}
})
}
3 changes: 2 additions & 1 deletion cmd/transactional.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,15 @@ var transactionalListCmd = &cobra.Command{
return nil
}

headers := []string{"ID", "NAME", "DRAFT MSG", "PUBLISHED MSG", "UPDATED"}
headers := []string{"ID", "NAME", "DRAFT MSG", "PUBLISHED MSG", "GROUP", "UPDATED"}
rows := make([][]string, 0, len(emails))
for _, e := range emails {
rows = append(rows, []string{
e.ID,
e.Name,
deref(e.DraftEmailMessageID),
deref(e.PublishedEmailMessageID),
deref(e.TransactionalGroupID),
e.UpdatedAt,
})
}
Expand Down
21 changes: 14 additions & 7 deletions cmd/workflows.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,10 @@ var workflowsListCmd = &cobra.Command{
return nil
}

headers := []string{"ID", "NAME", "CREATED", "UPDATED"}
headers := []string{"ID", "NAME", "GROUP", "CREATED", "UPDATED"}
rows := make([][]string, 0, len(workflows))
for _, w := range workflows {
rows = append(rows, []string{w.ID, w.Name, w.CreatedAt, w.UpdatedAt})
rows = append(rows, []string{w.ID, w.Name, w.WorkflowGroupID, w.CreatedAt, w.UpdatedAt})
}

if isPicking(cmd) {
Expand Down Expand Up @@ -152,6 +152,7 @@ func printSimplifiedWorkflow(cmd *cobra.Command, w *loops.SimplifiedWorkflow) er
t.Row("status", w.Status)
t.Row("workflowRevisionId", deref(w.WorkflowRevisionID))
t.Row("mailingListId", deref(w.MailingListID))
t.Row("workflowGroupId", w.WorkflowGroupID)
t.Row("rootNodeId", deref(w.RootNodeID))
if err := t.Render(); err != nil {
return err
Expand Down Expand Up @@ -584,16 +585,18 @@ var workflowsCreateCmd = &cobra.Command{

name, _ := cmd.Flags().GetString("name")
description, _ := cmd.Flags().GetString("description")
groupID, _ := cmd.Flags().GetString("workflow-group-id")

mailingListID, _, err := readNullableFlag(cmd, "mailing-list-id")
if err != nil {
return err
}

w, err := runWorkflowsCreate(cfg, loops.CreateWorkflowRequest{
Name: name,
Description: description,
MailingListID: mailingListID,
Name: name,
Description: description,
MailingListID: mailingListID,
WorkflowGroupID: groupID,
})
if err != nil {
return err
Expand All @@ -610,7 +613,7 @@ var workflowsCreateCmd = &cobra.Command{

var workflowsUpdateCmd = &cobra.Command{
Use: "update <id>",
Short: "Update a workflow's name and/or description",
Short: "Update a workflow's name, description, and/or group",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
Expand All @@ -620,11 +623,13 @@ var workflowsUpdateCmd = &cobra.Command{

name, _ := cmd.Flags().GetString("name")
description, _ := cmd.Flags().GetString("description")
groupID, _ := cmd.Flags().GetString("workflow-group-id")

w, err := runWorkflowsUpdate(cfg, args[0], loops.UpdateWorkflowPropertiesRequest{
ExpectedRevisionID: readExpectedRevisionID(cmd),
Name: name,
Description: description,
WorkflowGroupID: groupID,
})
if err != nil {
return err
Expand Down Expand Up @@ -995,6 +1000,7 @@ func init() {
workflowsCreateCmd.Flags().StringP("name", "n", "", "Workflow name")
workflowsCreateCmd.Flags().StringP("description", "d", "", "Workflow description")
workflowsCreateCmd.Flags().String("mailing-list-id", "", `Mailing list ID. Pass "null" to clear.`)
workflowsCreateCmd.Flags().String("workflow-group-id", "", "Workflow group ID to create the workflow in (defaults to the Unsorted group)")
workflowsCreateCmd.MarkFlagRequired("name")
workflowsCmd.AddCommand(workflowsCreateCmd)

Expand All @@ -1004,8 +1010,9 @@ func init() {

workflowsUpdateCmd.Flags().StringP("name", "n", "", "Workflow name")
workflowsUpdateCmd.Flags().StringP("description", "d", "", "Workflow description")
workflowsUpdateCmd.Flags().String("workflow-group-id", "", "Move the workflow to this workflow group")
workflowsUpdateCmd.Flags().String("expected-revision-id", "", "Expected workflow revision ID (optimistic concurrency)")
workflowsUpdateCmd.MarkFlagsOneRequired("name", "description")
workflowsUpdateCmd.MarkFlagsOneRequired("name", "description", "workflow-group-id")
workflowsCmd.AddCommand(workflowsUpdateCmd)

workflowsChangeMailingListCmd.Flags().String("mailing-list-id", "", `Mailing list ID. Pass "null" to clear.`)
Expand Down
56 changes: 53 additions & 3 deletions cmd/workflows_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func TestRunWorkflowsCreate(t *testing.T) {
"status": "Draft",
"workflowRevisionId": "rev_1",
"mailingListId": "ml_1",
"workflowGroupId": "wg_1",
"rootNodeId": null,
"nodes": {}
}`
Expand All @@ -24,16 +25,20 @@ func TestRunWorkflowsCreate(t *testing.T) {
cap := serveJSONCapture(t, http.StatusOK, body)
ml := "ml_1"
w, err := runWorkflowsCreate(cfg(t), loops.CreateWorkflowRequest{
Name: "Onboarding",
Description: "New user series",
MailingListID: &ml,
Name: "Onboarding",
Description: "New user series",
MailingListID: &ml,
WorkflowGroupID: "wg_1",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if w.ID != "wf_new" {
t.Errorf("ID = %q, want wf_new", w.ID)
}
if w.WorkflowGroupID != "wg_1" {
t.Errorf("WorkflowGroupID = %q, want wg_1", w.WorkflowGroupID)
}

if cap.Method != http.MethodPost {
t.Errorf("Method = %q, want POST", cap.Method)
Expand All @@ -55,6 +60,24 @@ func TestRunWorkflowsCreate(t *testing.T) {
if sent["mailingListId"] != "ml_1" {
t.Errorf("mailingListId = %v, want ml_1", sent["mailingListId"])
}
if sent["workflowGroupId"] != "wg_1" {
t.Errorf("workflowGroupId = %v, want wg_1", sent["workflowGroupId"])
}
})

t.Run("omits workflowGroupId when empty", func(t *testing.T) {
cap := serveJSONCapture(t, http.StatusOK, body)
_, err := runWorkflowsCreate(cfg(t), loops.CreateWorkflowRequest{Name: "Onboarding"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var sent map[string]any
if err := json.Unmarshal(cap.Body, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if _, ok := sent["workflowGroupId"]; ok {
t.Errorf("workflowGroupId should be omitted when empty, got %v", sent)
}
})

t.Run("returns error on non-200 response", func(t *testing.T) {
Expand All @@ -73,6 +96,7 @@ func TestRunWorkflowsUpdate(t *testing.T) {
"status": "Draft",
"workflowRevisionId": "rev_2",
"mailingListId": null,
"workflowGroupId": "wg_2",
"rootNodeId": null,
"nodes": {}
}`
Expand Down Expand Up @@ -104,6 +128,32 @@ func TestRunWorkflowsUpdate(t *testing.T) {
if sent["name"] != "Renamed" {
t.Errorf("name = %v, want Renamed", sent["name"])
}
if _, ok := sent["workflowGroupId"]; ok {
t.Errorf("workflowGroupId should be omitted when empty, got %v", sent)
}
})

t.Run("sends workflowGroupId when set", func(t *testing.T) {
cap := serveJSONCapture(t, http.StatusOK, body)
w, err := runWorkflowsUpdate(cfg(t), "wf_1", loops.UpdateWorkflowPropertiesRequest{
WorkflowGroupID: "wg_2",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if w.WorkflowGroupID != "wg_2" {
t.Errorf("WorkflowGroupID = %q, want wg_2", w.WorkflowGroupID)
}
var sent map[string]any
if err := json.Unmarshal(cap.Body, &sent); err != nil {
t.Fatalf("decode body: %v", err)
}
if sent["workflowGroupId"] != "wg_2" {
t.Errorf("workflowGroupId = %v, want wg_2", sent["workflowGroupId"])
}
if _, ok := sent["name"]; ok {
t.Errorf("name should be omitted when empty, got %v", sent)
}
})

t.Run("sends null expectedRevisionId when unset", func(t *testing.T) {
Expand Down
4 changes: 4 additions & 0 deletions cmd/workflows_get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func TestRunWorkflowsGet(t *testing.T) {
"description": "New user welcome series",
"emoji": "👋",
"mailingListId": null,
"workflowGroupId": "wg_1",
"rootNodeId": "node_root",
"nodes": {
"node_root": {
Expand Down Expand Up @@ -41,6 +42,9 @@ func TestRunWorkflowsGet(t *testing.T) {
if w.Name != "Welcome" {
t.Errorf("Name = %q, want Welcome", w.Name)
}
if w.WorkflowGroupID != "wg_1" {
t.Errorf("WorkflowGroupID = %q, want wg_1", w.WorkflowGroupID)
}
if w.MailingListID != nil {
t.Errorf("MailingListID = %v, want nil", w.MailingListID)
}
Expand Down
7 changes: 5 additions & 2 deletions cmd/workflows_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ func TestRunWorkflowsList(t *testing.T) {
body := `{
"pagination":{"nextCursor":""},
"data":[
{"id":"wf_1","name":"Welcome","createdAt":"2026-04-01","updatedAt":"2026-04-02"},
{"id":"wf_2","name":"Onboarding","createdAt":"2026-05-01","updatedAt":"2026-05-02"}
{"id":"wf_1","name":"Welcome","workflowGroupId":"wg_1","createdAt":"2026-04-01","updatedAt":"2026-04-02"},
{"id":"wf_2","name":"Onboarding","workflowGroupId":"wg_2","createdAt":"2026-05-01","updatedAt":"2026-05-02"}
]
}`
cap := serveJSONCapture(t, http.StatusOK, body)
Expand All @@ -28,6 +28,9 @@ func TestRunWorkflowsList(t *testing.T) {
if workflows[0].ID != "wf_1" {
t.Errorf("ID = %q, want wf_1", workflows[0].ID)
}
if workflows[0].WorkflowGroupID != "wg_1" {
t.Errorf("WorkflowGroupID = %q, want wg_1", workflows[0].WorkflowGroupID)
}
if workflows[1].Name != "Onboarding" {
t.Errorf("Name = %q, want Onboarding", workflows[1].Name)
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/term v0.2.2
github.com/loops-so/loops-go v0.7.1
github.com/loops-so/loops-go v0.8.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/zalando/go-keyring v0.2.8
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,8 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/loops-so/loops-go v0.7.1 h1:rv6rWH5P7cd+PZiZXnO+5fTVJkXSiDiV5O2hQwuWXRs=
github.com/loops-so/loops-go v0.7.1/go.mod h1:BDzBhAn/4e2QSKXrpXufIpSuH8xUPv9oa+hazH01ejE=
github.com/loops-so/loops-go v0.8.0 h1:1k8pQDmixxny8BvSB78Td/0ugZOslAsrAfUE7KRYMF4=
github.com/loops-so/loops-go v0.8.0/go.mod h1:BDzBhAn/4e2QSKXrpXufIpSuH8xUPv9oa+hazH01ejE=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
Expand Down
Loading