diff --git a/environment/handler/env.go b/environment/handler/env.go index 0b2af11..53a4b43 100644 --- a/environment/handler/env.go +++ b/environment/handler/env.go @@ -2,18 +2,23 @@ package handler import ( + "bytes" "fmt" + "sort" + "text/tabwriter" "gofr.dev/pkg/gofr" ) +const padding = 2 + // Handler is responsible for managing environment-related operations. type Handler struct { - envSvc EnvAdder + envSvc EnvironmentService } // New creates a new Handler with the given EnvAdder service. -func New(envSvc EnvAdder) *Handler { +func New(envSvc EnvironmentService) *Handler { return &Handler{envSvc: envSvc} } @@ -34,3 +39,35 @@ func (h *Handler) Add(ctx *gofr.Context) (any, error) { return fmt.Sprintf("%d environments added", n), nil } + +func (h *Handler) List(ctx *gofr.Context) (any, error) { + envs, err := h.envSvc.List(ctx) + if err != nil { + return nil, err + } + + sort.Slice(envs, func(i, j int) bool { return envs[i].ID < envs[j].ID }) + + b := bytes.NewBuffer([]byte{}) + + // Print a table of all the environments in the application + writer := tabwriter.NewWriter(b, 0, 0, padding, ' ', tabwriter.Debug) + + // Print table headers + fmt.Fprintln(writer, "Name\tLevel\tCreatedAt\tUpdatedAt") + + // Print rows for each environment + for _, env := range envs { + fmt.Fprintf(writer, "%s\t%d\t%s\t%s\n", + env.Name, + env.Level, + env.CreatedAt, + env.UpdatedAt, + ) + } + + // Flush the writer to output the table + writer.Flush() + + return b.String(), nil +} diff --git a/environment/handler/interface.go b/environment/handler/interface.go index bb958c5..58762e7 100644 --- a/environment/handler/interface.go +++ b/environment/handler/interface.go @@ -1,7 +1,12 @@ package handler -import "gofr.dev/pkg/gofr" +import ( + "gofr.dev/pkg/gofr" -type EnvAdder interface { + "zop.dev/cli/zop/environment/service" +) + +type EnvironmentService interface { Add(ctx *gofr.Context) (int, error) + List(ctx *gofr.Context) ([]service.Environment, error) } diff --git a/environment/service/env.go b/environment/service/env.go index 9ff8d9a..f3dd68e 100644 --- a/environment/service/env.go +++ b/environment/service/env.go @@ -7,25 +7,33 @@ import ( "io" "net/http" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" "gofr.dev/pkg/gofr" + + "zop.dev/cli/zop/utils" ) +const listTitle = "Select the application where you want to add the environment!" + var ( // ErrUnableToRenderApps is returned when the application list cannot be rendered. ErrUnableToRenderApps = errors.New("unable to render the list of applications") + // ErrConnectingZopAPI is returned when there is an error connecting to the Zop API. ErrConnectingZopAPI = errors.New("unable to connect to Zop API") + // ErrorAddingEnv is returned when there is an error adding an environment. ErrorAddingEnv = errors.New("unable to add environment") + // ErrNoApplicationSelected is returned when no application is selected. ErrNoApplicationSelected = errors.New("no application selected") + + // ErrorFetchingEnvironments is returned when there is an error fetching environments for a given application. + ErrorFetchingEnvironments = errors.New("unable to fetch environments") ) // Service represents the application service that handles application and environment operations. type Service struct { - appGet ApplicationGetter // appGet is responsible for fetching the list of applications. + appGet ApplicationGetter } // New creates a new Service instance with the provided ApplicationGetter. @@ -41,8 +49,8 @@ func (s *Service) Add(ctx *gofr.Context) (int, error) { return 0, err } - ctx.Out.Println("Selected application: ", app.name) - ctx.Out.Println("Please provide names of environments to be added...") + ctx.Out.Println("Selected application: ", app.Name) + ctx.Out.Println("Please provide names of environment to be added...") var ( input string @@ -55,14 +63,13 @@ func (s *Service) Add(ctx *gofr.Context) (int, error) { _, _ = fmt.Scanf("%s", &input) - err = postEnvironment(ctx, &Environment{Name: input, Level: level, ApplicationID: int64(app.id)}) + err = postEnvironment(ctx, &Environment{Name: input, Level: level, ApplicationID: int64(app.ID)}) if err != nil { return level, err } level++ - // Ask the user if they want to add more environments. ctx.Out.Print("Do you wish to add more? (y/n) ") _, _ = fmt.Scanf("%s", &input) @@ -75,42 +82,60 @@ func (s *Service) Add(ctx *gofr.Context) (int, error) { return level, nil } +func (s *Service) List(ctx *gofr.Context) ([]Environment, error) { + app, err := s.getSelectedApplication(ctx) + if err != nil { + return nil, err + } + + resp, err := ctx.GetHTTPService("api-service"). + Get(ctx, fmt.Sprintf("applications/%d/environments", app.ID), nil) + if err != nil { + ctx.Logger.Errorf("unable to connect to Zop API! %v", err) + + return nil, ErrConnectingZopAPI + } + + var data struct { + Envs []Environment `json:"data"` + } + + err = getResponse(resp, &data) + if err != nil { + ctx.Logger.Errorf("unable to fetch environments, could not unmarshall response %v", err) + + return nil, ErrorFetchingEnvironments + } + + return data.Envs, nil +} + // getSelectedApplication renders a list of applications for the user to select from. // It returns the selected application or an error if no selection is made. -func (s *Service) getSelectedApplication(ctx *gofr.Context) (*item, error) { +func (s *Service) getSelectedApplication(ctx *gofr.Context) (*utils.Item, error) { apps, err := s.appGet.List(ctx) if err != nil { return nil, err } - // Prepare a list of items for the user to select from. - items := make([]list.Item, 0) + items := make([]*utils.Item, 0) + for _, app := range apps { - items = append(items, &item{app.ID, app.Name}) + items = append(items, &utils.Item{ID: app.ID, Name: app.Name}) } - // Initialize the list component for application selection. - l := list.New(items, itemDelegate{}, listWidth, listHeight) - l.Title = "Select the application where you want to add the environment!" - l.SetShowStatusBar(false) - l.SetFilteringEnabled(true) - l.Styles.PaginationStyle = paginationStyle - l.Styles.HelpStyle = helpStyle - l.SetShowStatusBar(false) - - m := model{list: l} + choice, err := utils.RenderList(listTitle, items) + if err != nil { + ctx.Logger.Errorf("unable to render the list of applications! %v", err) - // Render the list using the bubbletea program. - if _, er := tea.NewProgram(&m).Run(); er != nil { - ctx.Logger.Errorf("unable to render the list of applications! %v", er) return nil, ErrUnableToRenderApps } - if m.choice == nil { + if choice == nil { return nil, ErrNoApplicationSelected } - return m.choice, nil + return choice, nil } // postEnvironment sends a POST request to the API to add the provided environment to the application. @@ -124,6 +149,7 @@ func postEnvironment(ctx *gofr.Context, env *Environment) error { }) if err != nil { ctx.Logger.Errorf("unable to connect to Zop API! %v", err) + return ErrConnectingZopAPI } diff --git a/environment/service/mock_interface.go b/environment/service/mock_interface.go deleted file mode 100644 index 9fd52b4..0000000 --- a/environment/service/mock_interface.go +++ /dev/null @@ -1,57 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: interface.go -// -// Generated by this command: -// -// mockgen -source=interface.go -destination=mock_interface.go -package=service -// - -// Package service is a generated GoMock package. -package service - -import ( - reflect "reflect" - - gomock "go.uber.org/mock/gomock" - gofr "gofr.dev/pkg/gofr" - service "zop.dev/cli/zop/application/service" -) - -// MockApplicationGetter is a mock of ApplicationGetter interface. -type MockApplicationGetter struct { - ctrl *gomock.Controller - recorder *MockApplicationGetterMockRecorder - isgomock struct{} -} - -// MockApplicationGetterMockRecorder is the mock recorder for MockApplicationGetter. -type MockApplicationGetterMockRecorder struct { - mock *MockApplicationGetter -} - -// NewMockApplicationGetter creates a new mock instance. -func NewMockApplicationGetter(ctrl *gomock.Controller) *MockApplicationGetter { - mock := &MockApplicationGetter{ctrl: ctrl} - mock.recorder = &MockApplicationGetterMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockApplicationGetter) EXPECT() *MockApplicationGetterMockRecorder { - return m.recorder -} - -// GetApplications mocks base method. -func (m *MockApplicationGetter) GetApplications(ctx *gofr.Context) ([]service.Application, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetApplications", ctx) - ret0, _ := ret[0].([]service.Application) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetApplications indicates an expected call of GetApplications. -func (mr *MockApplicationGetterMockRecorder) GetApplications(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetApplications", reflect.TypeOf((*MockApplicationGetter)(nil).GetApplications), ctx) -} diff --git a/environment/service/models.go b/environment/service/models.go index 41010ad..4fd23d0 100644 --- a/environment/service/models.go +++ b/environment/service/models.go @@ -15,4 +15,10 @@ type Environment struct { // Name is the name of the environment. Name string `json:"name"` + + // CreatedAt is the timestamp of when the environment was created. + CreatedAt string `json:"createdAt"` + + // UpdatedAt is the timestamp of when the environment was last updated. + UpdatedAt string `json:"updatedAt"` } diff --git a/main.go b/main.go index fc90b54..c468079 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,6 @@ import ( _ "github.com/mattn/go-sqlite3" "gofr.dev/pkg/gofr" "gofr.dev/pkg/gofr/service" - _ "modernc.org/sqlite" applicationHandler "zop.dev/cli/zop/application/handler" applicationSvc "zop.dev/cli/zop/application/service" @@ -65,6 +64,7 @@ func main() { envH := envHandler.New(envSvc) app.SubCommand("environment add", envH.Add) + app.SubCommand("environment list", envH.List) app.Run() } diff --git a/environment/service/app_selector.go b/utils/list.go similarity index 63% rename from environment/service/app_selector.go rename to utils/list.go index ea9fb5f..fa58938 100644 --- a/environment/service/app_selector.go +++ b/utils/list.go @@ -1,8 +1,4 @@ -// Package service provides functionalities for interacting with applications and environments. -// It supports selecting an application and adding environments to it by communicating with an external API. -// it gives users a text-based user interface (TUI) for displaying and selecting items -// using the Charmbracelet bubbletea and list packages. -package service +package utils import ( "fmt" @@ -15,39 +11,36 @@ import ( ) const ( - // listWidth defines the width of the list. - listWidth = 20 - // listHeight defines the height of the list. - listHeight = 14 - // listPaddingLeft defines the left padding of the list items. - listPaddingLeft = 2 - // paginationPadding defines the padding for pagination controls. + listPaddingLeft = 2 paginationPadding = 4 + listWidth = 20 + listHeight = 14 ) //nolint:gochecknoglobals //required TUI styles for displaying the list var ( // itemStyle defines the default style for list items. itemStyle = lipgloss.NewStyle().PaddingLeft(listPaddingLeft) + // selectedItemStyle defines the style for the selected list item. - selectedItemStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")) + selectedItemStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#06b6d4")) + // paginationStyle defines the style for pagination controls. paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(paginationPadding) - // helpStyle defines the style for the help text. - helpStyle = list.DefaultStyles().HelpStyle + + titleStyle = lipgloss.NewStyle().Background(lipgloss.Color("#0891b2")).Foreground(lipgloss.Color("#ffffff")) ) -// item represents a single item in the list. -type item struct { - id int // ID is the unique identifier for the item. - name string // Name is the display name of the item. +// Item represents a single item in the list. +type Item struct { + ID int // ID is the unique identifier for the item. + Name string // Name is the display name of the item. + Data any } // FilterValue returns the value to be used for filtering list items. // In this case, it's the name of the item. -func (i *item) FilterValue() string { - return i.name -} +func (i *Item) FilterValue() string { return i.Name } // itemDelegate is a struct responsible for rendering and interacting with list items. type itemDelegate struct{} @@ -58,21 +51,19 @@ func (itemDelegate) Height() int { return 1 } // Spacing returns the spacing between items (always 0). func (itemDelegate) Spacing() int { return 0 } -// Update is used to handle updates to the item model. It doesn't do anything in this case. -func (itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { - return nil -} +// Update returns the command to update the list. (always nil). +func (itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } -// Render renders a single list item, applying the selected item style if it's the currently selected item. +// Render renders the list items with the selected item highlighted. // //nolint:gocritic //required for rendering list items and implementing ItemDelegate interface func (itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i, ok := listItem.(*item) + i, ok := listItem.(*Item) if !ok { return } - str := fmt.Sprintf("%3d. %s", index+1, i.name) + str := fmt.Sprintf("%3d. %s", index+1, i.Name) fn := itemStyle.Render if index == m.Index() { @@ -86,7 +77,7 @@ func (itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.I // model represents the state of the TUI interface, including the list and selected item. type model struct { - choice *item // choice is the selected item. + choice *Item // choice is the selected item. quitting bool // quitting indicates if the application is quitting. list list.Model // list holds the list of items displayed in the TUI. } @@ -107,11 +98,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch keypress := msg.String(); keypress { case "q", "ctrl+c": - m.quitting = true // Set quitting to true when 'q' or 'ctrl+c' is pressed. + m.quitting = true return m, tea.Quit case "enter": - i, ok := m.list.SelectedItem().(*item) + i, ok := m.list.SelectedItem().(*Item) if ok { m.choice = i } @@ -130,3 +121,27 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *model) View() string { return "\n" + m.list.View() } + +func RenderList(title string, items []*Item) (*Item, error) { + listItems := make([]list.Item, 0) + + for i := range items { + listItems = append(listItems, items[i]) + } + + l := list.New(listItems, itemDelegate{}, listWidth, listHeight) + l.Title = title + l.Styles.Title = titleStyle + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.Styles.PaginationStyle = paginationStyle + l.SetShowStatusBar(false) + + m := model{list: l} + + if _, er := tea.NewProgram(&m, tea.WithAltScreen()).Run(); er != nil { + return nil, er + } + + return m.choice, nil +}