diff --git a/.mockery.yaml b/.mockery.yaml index b8ba0d35..8c8e0044 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -6,3 +6,4 @@ packages: Client: Config: Factory: + TimeEntryDefaults: diff --git a/Makefile b/Makefile index 4a0995a6..b4b227ee 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ dist: deps-install dist/darwin dist/linux dist/windows ## build all cli versions dist-internal: mkdir -p dist/$(goos) - GOOS=$(goos) GOARCH=$(goarch) go build -o dist/$(goos)/clockify-cli $(MAIN_PKG) + CGO_ENABLED=0 GOOS=$(goos) GOARCH=$(goarch) go build -o dist/$(goos)/clockify-cli $(MAIN_PKG) dist/darwin: make dist-internal goos=darwin goarch=amd64 diff --git a/internal/consoletest/test.go b/internal/consoletest/test.go index 06cd5bb1..0e939229 100644 --- a/internal/consoletest/test.go +++ b/internal/consoletest/test.go @@ -17,7 +17,7 @@ type ExpectConsole interface { ExpectEOF() ExpectString(string) Send(string) - SendLine(string) + SendLine(...string) } type console struct { @@ -43,9 +43,15 @@ func (c *console) Send(s string) { } } -func (c *console) SendLine(s string) { - if _, err := c.c.SendLine(s); err != nil { - c.t.Errorf("failed to SendLine %v", err) +func (c *console) SendLine(s ...string) { + if len(s) == 0 { + s = []string{""} + } + + for i := range s { + if _, err := c.c.SendLine(s[i]); err != nil { + c.t.Errorf("failed to SendLine %v", err) + } } } diff --git a/internal/mocks/gen.go b/internal/mocks/gen.go index a13e5dd7..3e292299 100644 --- a/internal/mocks/gen.go +++ b/internal/mocks/gen.go @@ -2,6 +2,7 @@ package mocks import ( "github.com/lucassabreu/clockify-cli/api" + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util/defaults" "github.com/lucassabreu/clockify-cli/pkg/cmdutil" ) @@ -16,3 +17,7 @@ type Config interface { type Client interface { api.Client } + +type TimeEntryDefaults interface { + defaults.TimeEntryDefaults +} diff --git a/internal/mocks/mock_Config.go b/internal/mocks/mock_Config.go index 068c785b..f9516ab5 100644 --- a/internal/mocks/mock_Config.go +++ b/internal/mocks/mock_Config.go @@ -395,6 +395,51 @@ func (_c *MockConfig_InteractivePageSize_Call) RunAndReturn(run func() int) *Moc return _c } +// IsAllowArchivedTags provides a mock function with given fields: +func (_m *MockConfig) IsAllowArchivedTags() bool { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for IsAllowArchivedTags") + } + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// MockConfig_IsAllowArchivedTags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsAllowArchivedTags' +type MockConfig_IsAllowArchivedTags_Call struct { + *mock.Call +} + +// IsAllowArchivedTags is a helper method to define mock.On call +func (_e *MockConfig_Expecter) IsAllowArchivedTags() *MockConfig_IsAllowArchivedTags_Call { + return &MockConfig_IsAllowArchivedTags_Call{Call: _e.mock.On("IsAllowArchivedTags")} +} + +func (_c *MockConfig_IsAllowArchivedTags_Call) Run(run func()) *MockConfig_IsAllowArchivedTags_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockConfig_IsAllowArchivedTags_Call) Return(_a0 bool) *MockConfig_IsAllowArchivedTags_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockConfig_IsAllowArchivedTags_Call) RunAndReturn(run func() bool) *MockConfig_IsAllowArchivedTags_Call { + _c.Call.Return(run) + return _c +} + // IsAllowNameForID provides a mock function with given fields: func (_m *MockConfig) IsAllowNameForID() bool { ret := _m.Called() diff --git a/internal/mocks/mock_Factory.go b/internal/mocks/mock_Factory.go index 4ba42369..b0403206 100644 --- a/internal/mocks/mock_Factory.go +++ b/internal/mocks/mock_Factory.go @@ -6,6 +6,8 @@ import ( api "github.com/lucassabreu/clockify-cli/api" cmdutil "github.com/lucassabreu/clockify-cli/pkg/cmdutil" + defaults "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util/defaults" + dto "github.com/lucassabreu/clockify-cli/api/dto" mock "github.com/stretchr/testify/mock" @@ -295,6 +297,53 @@ func (_c *MockFactory_GetWorkspaceID_Call) RunAndReturn(run func() (string, erro return _c } +// TimeEntryDefaults provides a mock function with given fields: +func (_m *MockFactory) TimeEntryDefaults() defaults.TimeEntryDefaults { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for TimeEntryDefaults") + } + + var r0 defaults.TimeEntryDefaults + if rf, ok := ret.Get(0).(func() defaults.TimeEntryDefaults); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(defaults.TimeEntryDefaults) + } + } + + return r0 +} + +// MockFactory_TimeEntryDefaults_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TimeEntryDefaults' +type MockFactory_TimeEntryDefaults_Call struct { + *mock.Call +} + +// TimeEntryDefaults is a helper method to define mock.On call +func (_e *MockFactory_Expecter) TimeEntryDefaults() *MockFactory_TimeEntryDefaults_Call { + return &MockFactory_TimeEntryDefaults_Call{Call: _e.mock.On("TimeEntryDefaults")} +} + +func (_c *MockFactory_TimeEntryDefaults_Call) Run(run func()) *MockFactory_TimeEntryDefaults_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockFactory_TimeEntryDefaults_Call) Return(_a0 defaults.TimeEntryDefaults) *MockFactory_TimeEntryDefaults_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockFactory_TimeEntryDefaults_Call) RunAndReturn(run func() defaults.TimeEntryDefaults) *MockFactory_TimeEntryDefaults_Call { + _c.Call.Return(run) + return _c +} + // UI provides a mock function with given fields: func (_m *MockFactory) UI() ui.UI { ret := _m.Called() diff --git a/internal/mocks/mock_TimeEntryDefaults.go b/internal/mocks/mock_TimeEntryDefaults.go new file mode 100644 index 00000000..af04be08 --- /dev/null +++ b/internal/mocks/mock_TimeEntryDefaults.go @@ -0,0 +1,136 @@ +// Code generated by mockery v2.40.3. DO NOT EDIT. + +package mocks + +import ( + defaults "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util/defaults" + mock "github.com/stretchr/testify/mock" +) + +// MockTimeEntryDefaults is an autogenerated mock type for the TimeEntryDefaults type +type MockTimeEntryDefaults struct { + mock.Mock +} + +type MockTimeEntryDefaults_Expecter struct { + mock *mock.Mock +} + +func (_m *MockTimeEntryDefaults) EXPECT() *MockTimeEntryDefaults_Expecter { + return &MockTimeEntryDefaults_Expecter{mock: &_m.Mock} +} + +// Read provides a mock function with given fields: +func (_m *MockTimeEntryDefaults) Read() (defaults.DefaultTimeEntry, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Read") + } + + var r0 defaults.DefaultTimeEntry + var r1 error + if rf, ok := ret.Get(0).(func() (defaults.DefaultTimeEntry, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() defaults.DefaultTimeEntry); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(defaults.DefaultTimeEntry) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockTimeEntryDefaults_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read' +type MockTimeEntryDefaults_Read_Call struct { + *mock.Call +} + +// Read is a helper method to define mock.On call +func (_e *MockTimeEntryDefaults_Expecter) Read() *MockTimeEntryDefaults_Read_Call { + return &MockTimeEntryDefaults_Read_Call{Call: _e.mock.On("Read")} +} + +func (_c *MockTimeEntryDefaults_Read_Call) Run(run func()) *MockTimeEntryDefaults_Read_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockTimeEntryDefaults_Read_Call) Return(_a0 defaults.DefaultTimeEntry, _a1 error) *MockTimeEntryDefaults_Read_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockTimeEntryDefaults_Read_Call) RunAndReturn(run func() (defaults.DefaultTimeEntry, error)) *MockTimeEntryDefaults_Read_Call { + _c.Call.Return(run) + return _c +} + +// Write provides a mock function with given fields: _a0 +func (_m *MockTimeEntryDefaults) Write(_a0 defaults.DefaultTimeEntry) error { + ret := _m.Called(_a0) + + if len(ret) == 0 { + panic("no return value specified for Write") + } + + var r0 error + if rf, ok := ret.Get(0).(func(defaults.DefaultTimeEntry) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockTimeEntryDefaults_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write' +type MockTimeEntryDefaults_Write_Call struct { + *mock.Call +} + +// Write is a helper method to define mock.On call +// - _a0 defaults.DefaultTimeEntry +func (_e *MockTimeEntryDefaults_Expecter) Write(_a0 interface{}) *MockTimeEntryDefaults_Write_Call { + return &MockTimeEntryDefaults_Write_Call{Call: _e.mock.On("Write", _a0)} +} + +func (_c *MockTimeEntryDefaults_Write_Call) Run(run func(_a0 defaults.DefaultTimeEntry)) *MockTimeEntryDefaults_Write_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(defaults.DefaultTimeEntry)) + }) + return _c +} + +func (_c *MockTimeEntryDefaults_Write_Call) Return(_a0 error) *MockTimeEntryDefaults_Write_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockTimeEntryDefaults_Write_Call) RunAndReturn(run func(defaults.DefaultTimeEntry) error) *MockTimeEntryDefaults_Write_Call { + _c.Call.Return(run) + return _c +} + +// NewMockTimeEntryDefaults creates a new instance of MockTimeEntryDefaults. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockTimeEntryDefaults(t interface { + mock.TestingT + Cleanup(func()) +}) *MockTimeEntryDefaults { + mock := &MockTimeEntryDefaults{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/mocks/simple_config.go b/internal/mocks/simple_config.go index 5ed8a1ef..c3df5aa4 100644 --- a/internal/mocks/simple_config.go +++ b/internal/mocks/simple_config.go @@ -114,6 +114,11 @@ func (d *SimpleConfig) IsInteractive() bool { return d.Interactive } +// IsAllowArchivedTags defines if archived tags should be suggested +func (s *SimpleConfig) IsAllowArchivedTags() bool { + return s.AllowArchivedTags +} + func (d *SimpleConfig) GetWorkWeekdays() []string { return d.WorkweekDays } diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go index f2df3f54..6091b2cf 100644 --- a/pkg/cmd/config/config.go +++ b/pkg/cmd/config/config.go @@ -36,9 +36,13 @@ var validParameters = cmdcompl.ValidArgsMap{ cmdutil.CONF_LOG_LEVEL: "how much logs should be shown values: " + "none , error , info and debug", cmdutil.CONF_ALLOW_ARCHIVED_TAGS: "should allow and suggest archived tags", + cmdutil.CONF_INTERACTIVE_PAGE_SIZE: "how many entries should be listed " + + "when prompting options", cmdutil.CONF_LANGUAGE: "which language to use for number " + "formatting", cmdutil.CONF_TIMEZONE: "which timezone to use to input/output time", + cmdutil.CONF_TIME_ENTRY_DEFAULTS: "should load defaults for time " + + "entries from current folder", } // NewCmdConfig represents the config command diff --git a/pkg/cmd/config/init/init.go b/pkg/cmd/config/init/init.go index 15250988..9ce1d926 100644 --- a/pkg/cmd/config/init/init.go +++ b/pkg/cmd/config/init/init.go @@ -111,6 +111,18 @@ func NewCmdInit(f cmdutil.Factory) *cobra.Command { "Should suggest and allow creating time entries "+ "with archived tags?", ), + updateFlag(i, config, cmdutil.CONF_TIME_ENTRY_DEFAULTS, + "Look for default parameters for time entries per folder?", + ui.WithConfirmHelp( + "This will set the default parameters of a time "+ + "entry when using `clockify-cli in` and "+ + "`clockify-cli manual` to the closest "+ + ".clockify-defaults.yaml file looking up the "+ + "current folder you were running the commands.\n"+ + "For more information and examples go to "+ + "https://clockify-cli.netlify.app/", + ), + ), setLanguage(i, config), setTimezone(i, config), ); err != nil { @@ -268,15 +280,13 @@ func updateInt(ui ui.UI, config cmdutil.Config, param, desc string, func updateFlag( ui ui.UI, config cmdutil.Config, param, description string, -) func() error { + opts ...ui.ConfirmOption) func() error { return func() (err error) { - b := config.GetBool(param) - if b, err = ui.Confirm(description, b); err != nil { + if b, err = ui.Confirm(description, b, opts...); err != nil { return } config.SetBool(param, b) return } - } diff --git a/pkg/cmd/config/init/init_test.go b/pkg/cmd/config/init/init_test.go index ce80e964..49bd4eec 100644 --- a/pkg/cmd/config/init/init_test.go +++ b/pkg/cmd/config/init/init_test.go @@ -20,7 +20,12 @@ import ( ) func setStringFn(config *mocks.MockConfig, name, value string) *mock.Call { - r := "" + return setStringDefaultFn(config, name, "", value) +} + +func setStringDefaultFn( + config *mocks.MockConfig, name, first, value string) *mock.Call { + r := first config.On("GetString", name). Return(func(string) string { v := r @@ -108,6 +113,8 @@ func TestInitCmd(t *testing.T) { setBoolFn(config, cmdutil.CONF_ALLOW_ARCHIVED_TAGS, true, false) + setBoolFn(config, cmdutil.CONF_TIME_ENTRY_DEFAULTS, false, true) + config.EXPECT().Language().Return(language.English) config.EXPECT().SetLanguage(language.German) @@ -210,6 +217,13 @@ func TestInitCmd(t *testing.T) { c.SendLine("n") c.ExpectString("No") + c.ExpectString( + "Look for default parameters for time entries per folder?") + c.SendLine("?") + c.ExpectString("https://") + c.SendLine("y") + c.ExpectString("Yes") + c.ExpectString("preferred language") c.Send("e") c.Send(string(terminal.KeyTab)) diff --git a/pkg/cmd/config/set/set_test.go b/pkg/cmd/config/set/set_test.go index 09866124..67db8892 100644 --- a/pkg/cmd/config/set/set_test.go +++ b/pkg/cmd/config/set/set_test.go @@ -15,9 +15,9 @@ import ( func TestSetCmdArgs(t *testing.T) { tt := map[string][]string{ - "zero": []string{}, - "one": []string{"param"}, - "three": []string{"param", "value", "other value"}, + "zero": {}, + "one": {"param"}, + "three": {"param", "value", "other value"}, } for name := range tt { diff --git a/pkg/cmd/time-entry/defaults/defaults.go b/pkg/cmd/time-entry/defaults/defaults.go new file mode 100644 index 00000000..3d0929ba --- /dev/null +++ b/pkg/cmd/time-entry/defaults/defaults.go @@ -0,0 +1,28 @@ +package defaults + +import ( + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/defaults/set" + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/defaults/show" + "github.com/lucassabreu/clockify-cli/pkg/cmdutil" + outd "github.com/lucassabreu/clockify-cli/pkg/output/defaults" + "github.com/spf13/cobra" +) + +// NewCmdDefaults creates commands to manage default parameters when creating +// time entries +func NewCmdDefaults(f cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "defaults", + Aliases: []string{"def"}, + Short: "Manages the default parameters for time entries " + + "in the current folder", + Args: cobra.ExactArgs(0), + } + + cmd.AddCommand( + set.NewCmdSet(f, outd.Report), + show.NewCmdShow(f, outd.Report), + ) + + return cmd +} diff --git a/pkg/cmd/time-entry/defaults/set/set.go b/pkg/cmd/time-entry/defaults/set/set.go new file mode 100644 index 00000000..20279282 --- /dev/null +++ b/pkg/cmd/time-entry/defaults/set/set.go @@ -0,0 +1,353 @@ +package set + +import ( + "io" + + "github.com/lucassabreu/clockify-cli/api" + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util/defaults" + "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" + "github.com/lucassabreu/clockify-cli/pkg/cmdcomplutil" + "github.com/lucassabreu/clockify-cli/pkg/cmdutil" + outd "github.com/lucassabreu/clockify-cli/pkg/output/defaults" + "github.com/lucassabreu/clockify-cli/pkg/search" + "github.com/lucassabreu/clockify-cli/pkg/ui" + "github.com/lucassabreu/clockify-cli/pkg/uiutil" + "github.com/lucassabreu/clockify-cli/strhlp" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// NewCmdSet sets the default parameters for time entries in the current folder +func NewCmdSet( + f cmdutil.Factory, + report func(outd.OutputFlags, io.Writer, defaults.DefaultTimeEntry) error, +) *cobra.Command { + if report == nil { + panic(errors.New("report parameter should not be nil")) + } + + short := "Sets the default parameters for the current folder" + of := outd.OutputFlags{} + cmd := &cobra.Command{ + Use: "set", + Short: short, + Long: short + "\n" + + "The parameters will be saved in the current working directory " + + "in the file " + defaults.DEFAULT_FILENAME + ".yaml", + Example: "", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + if err := cmdutil.XorFlagSet( + cmd.Flags(), "billable", "not-billable"); err != nil { + return err + } + + d, err := f.TimeEntryDefaults().Read() + if err != nil && err != defaults.DefaultsFileNotFoundErr { + return err + } + + n, changed := readFlags(d, cmd.Flags()) + + var w string + if w, err = f.GetWorkspaceID(); err != nil { + return err + } + + c, err := f.Client() + if err != nil { + return err + } + + if changed { + if n.TaskID != "" && n.ProjectID == "" { + return errors.New("can't set task without project") + } + + if f.Config().IsAllowNameForID() { + if n, err = updateIDsByNames( + c, n, f.Config(), w); err != nil { + return err + } + } else { + if err = checkIDs(c, w, n); err != nil { + return err + } + } + } + + if f.Config().IsInteractive() { + if n, err = ask(n, w, f.Config(), c, f.UI()); err != nil { + return err + } + } + + if err = f.TimeEntryDefaults().Write(n); err != nil { + return err + } + + return report(of, cmd.OutOrStdout(), n) + }, + } + + cmd.Flags().StringVarP(&of.Format, + "format", "f", outd.FORMAT_YAML, "output format") + _ = cmdcompl.AddFixedSuggestionsToFlag(cmd, "format", + cmdcompl.ValidArgsSlide{outd.FORMAT_YAML, outd.FORMAT_JSON}) + + cmd.Flags().BoolP("billable", "b", false, + "time entry should be billable by default") + cmd.Flags().BoolP("not-billable", "n", false, + "time entry should not be billable by default") + cmd.Flags().String("task", "", "default task") + _ = cmdcompl.AddSuggestionsToFlag(cmd, "task", + cmdcomplutil.NewTaskAutoComplete(f, true)) + + cmd.Flags().StringSliceP("tag", "T", []string{}, + "add tags be used by default") + _ = cmdcompl.AddSuggestionsToFlag(cmd, "tag", + cmdcomplutil.NewTagAutoComplete(f)) + + cmd.Flags().StringP("project", "p", "", "project to used by default") + _ = cmdcompl.AddSuggestionsToFlag(cmd, "project", + cmdcomplutil.NewProjectAutoComplete(f, f.Config())) + + return cmd +} + +func readFlags( + d defaults.DefaultTimeEntry, + f *pflag.FlagSet, +) (defaults.DefaultTimeEntry, bool) { + changed := false + if f.Changed("project") { + d.ProjectID, _ = f.GetString("project") + changed = true + } + + if f.Changed("task") { + d.TaskID, _ = f.GetString("task") + changed = true + } + + if f.Changed("tag") { + d.TagIDs, _ = f.GetStringSlice("tag") + d.TagIDs = strhlp.Unique(d.TagIDs) + changed = true + } + + if f.Changed("billable") { + b := true + d.Billable = &b + changed = true + } else if f.Changed("not-billable") { + b := false + d.Billable = &b + changed = true + } + + return d, changed +} + +func checkIDs(c api.Client, w string, d defaults.DefaultTimeEntry) error { + if d.ProjectID != "" { + p, err := c.GetProject(api.GetProjectParam{ + Workspace: w, + ProjectID: d.ProjectID, + Hydrate: d.TaskID != "", + }) + + if err != nil { + return err + } + + if d.TaskID != "" { + found := false + for i := range p.Tasks { + if p.Tasks[i].ID == d.TaskID { + found = true + break + } + } + + if !found { + return errors.New( + "can't find task with ID \"" + d.TaskID + + "\" on project \"" + d.ProjectID + "\"") + } + } + } else if d.TaskID != "" { + return errors.New("task can't be set without a project") + } + + tags, err := c.GetTags(api.GetTagsParam{ + Workspace: w, + Archived: &archived, + PaginationParam: api.AllPages(), + }) + if err != nil { + return err + } + + ids := make([]string, len(tags)) + for i := range tags { + ids[i] = tags[i].ID + } + + for _, id := range d.TagIDs { + if !strhlp.InSlice(id, ids) { + return errors.Errorf("can't find tag with ID \"%s\"", id) + } + } + + return nil +} + +var archived = false + +func updateIDsByNames( + c api.Client, d defaults.DefaultTimeEntry, + cnf cmdutil.Config, w string) ( + defaults.DefaultTimeEntry, + error, +) { + var err error + if d.ProjectID != "" { + d.ProjectID, err = search.GetProjectByName(c, cnf, + w, d.ProjectID, "") + if err != nil { + d.ProjectID = "" + d.TaskID = "" + if !cnf.IsInteractive() { + return d, err + } + } + } + + if d.TaskID != "" { + d.TaskID, err = search.GetTaskByName(c, api.GetTasksParam{ + Workspace: w, + ProjectID: d.ProjectID, + Active: true, + }, d.TaskID) + if err != nil && !cnf.IsInteractive() { + return d, err + } + } + + if len(d.TagIDs) > 0 { + d.TagIDs, err = search.GetTagsByName( + c, w, !cnf.IsAllowArchivedTags(), d.TagIDs) + if err != nil && !cnf.IsInteractive() { + return d, err + } + } + + return d, nil +} + +func ask( + d defaults.DefaultTimeEntry, + w string, + cnf cmdutil.Config, + c api.Client, + ui ui.UI, +) ( + defaults.DefaultTimeEntry, + error, +) { + ui.SetPageSize(uint(cnf.InteractivePageSize())) + + ps, err := c.GetProjects(api.GetProjectsParam{ + Workspace: w, + Archived: &archived, + PaginationParam: api.AllPages(), + }) + if err != nil { + return d, err + } + + p, err := uiutil.AskProject(uiutil.AskProjectParam{ + UI: ui, + ProjectID: d.ProjectID, + Projects: ps, + }) + if err != nil { + return d, err + } + if p != nil { + d.ProjectID = p.ID + } else { + d.ProjectID = "" + } + + if d.ProjectID != "" { + ts, err := c.GetTasks(api.GetTasksParam{ + Workspace: w, + ProjectID: d.ProjectID, + Active: true, + PaginationParam: api.AllPages(), + }) + if err != nil { + return d, err + } + + t, err := uiutil.AskTask(uiutil.AskTaskParam{ + UI: ui, + TaskID: d.TaskID, + Tasks: ts, + }) + if err != nil { + return d, err + } + if t != nil { + d.TaskID = t.ID + } else { + d.TaskID = "" + } + } else { + d.TaskID = "" + } + + var archived *bool + if !cnf.IsAllowArchivedTags() { + b := false + archived = &b + } + + tags, err := c.GetTags(api.GetTagsParam{ + Workspace: w, + Archived: archived, + PaginationParam: api.AllPages(), + }) + if err != nil { + return d, err + } + + tags, err = uiutil.AskTags(uiutil.AskTagsParam{ + UI: ui, + TagIDs: d.TagIDs, + Tags: tags, + }) + if err != nil { + return d, err + } + d.TagIDs = make([]string, len(tags)) + for i := range tags { + d.TagIDs[i] = tags[i].ID + } + + b := false + if d.Billable != nil { + b = *d.Billable + } + + b, err = ui.Confirm("Should be billable?", b) + if err != nil { + return d, err + } + d.Billable = &b + + return d, err +} diff --git a/pkg/cmd/time-entry/defaults/set/set_test.go b/pkg/cmd/time-entry/defaults/set/set_test.go new file mode 100644 index 00000000..41dc8be4 --- /dev/null +++ b/pkg/cmd/time-entry/defaults/set/set_test.go @@ -0,0 +1,686 @@ +package set_test + +import ( + "errors" + "io" + "testing" + + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/lucassabreu/clockify-cli/api" + "github.com/lucassabreu/clockify-cli/api/dto" + "github.com/lucassabreu/clockify-cli/internal/consoletest" + "github.com/lucassabreu/clockify-cli/internal/mocks" + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/defaults/set" + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util/defaults" + "github.com/lucassabreu/clockify-cli/pkg/cmdutil" + . "github.com/lucassabreu/clockify-cli/pkg/output/defaults" + "github.com/lucassabreu/clockify-cli/pkg/ui" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +var bTrue = true +var bFalse = false + +func TestNewCmdSet_ShouldAskInfo_WhenInteractive(t *testing.T) { + consoletest.RunTestConsole(t, + func(out consoletest.FileWriter, in consoletest.FileReader) error { + f := mocks.NewMockFactory(t) + + f.EXPECT().UI().Return(ui.NewUI(in, out, out)) + + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return(defaults.DefaultTimeEntry{}, nil) + ted.On("Write", mock.Anything).Return(nil) + f.EXPECT().TimeEntryDefaults().Return(ted) + + f.EXPECT().GetWorkspaceID().Return("w", nil) + + c := mocks.NewMockClient(t) + + c.EXPECT().GetProjects(api.GetProjectsParam{ + Workspace: "w", + PaginationParam: api.AllPages(), + }). + Return([]dto.Project{}, nil) + + c.EXPECT().GetProjects(api.GetProjectsParam{ + Workspace: "w", + Archived: &bFalse, + PaginationParam: api.AllPages(), + }). + Return([]dto.Project{ + {ID: "p1", Name: "first"}, + {ID: "p2", Name: "second", + ClientID: "c", ClientName: "Myself"}, + {ID: "p3", Name: "third"}, + }, nil) + + c.EXPECT().GetTasks(api.GetTasksParam{ + Workspace: "w", + ProjectID: "p3", + Active: true, + PaginationParam: api.AllPages(), + }). + Return([]dto.Task{ + {ID: "t", Name: "first"}, + {ID: "t2", Name: "second"}, + {ID: "t3", Name: "third"}, + }, nil) + + c.EXPECT().GetTags(api.GetTagsParam{ + Workspace: "w", + Archived: &bFalse, + PaginationParam: api.AllPages(), + }). + Return([]dto.Tag{ + {ID: "tg1", Name: "first"}, + {ID: "tg2", Name: "second"}, + {ID: "tg3", Name: "third"}, + }, nil) + + f.EXPECT().Client().Return(c, nil) + + f.EXPECT().Config().Return(&mocks.SimpleConfig{ + AllowNameForID: true, + Interactive: true, + InteractivePageSizeNumber: 7, + }) + + var d defaults.DefaultTimeEntry + cmd := set.NewCmdSet(f, func(_ OutputFlags, _ io.Writer, + dte defaults.DefaultTimeEntry) error { + d = dte + return nil + }) + + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{ + "-p=not found", + "--task=nf", + "-T=nf", + "--not-billable", + }) + _, err := cmd.ExecuteC() + + if !assert.NoError(t, err) { + return err + } + + assert.Equal(t, "p3", d.ProjectID) + assert.Equal(t, "t2", d.TaskID) + assert.Equal(t, []string{"tg1", "tg2", "tg3"}, d.TagIDs) + assert.Equal(t, &bTrue, d.Billable) + + return err + }, + func(c consoletest.ExpectConsole) { + c.ExpectString("Choose your project:") + c.ExpectString("> No Project") + c.ExpectString("first | Without Client") + c.ExpectString("second | Client: Myself") + c.ExpectString("third | Without Client") + + c.Send("without") + c.SendLine(string(terminal.KeyArrowDown)) + + c.ExpectString("Choose your task:") + c.ExpectString("> No Task") + c.ExpectString("first") + c.ExpectString("second") + c.ExpectString("third") + c.SendLine("sec") + + c.ExpectString("Choose your tags:") + c.ExpectString("first") + c.ExpectString("second") + c.ExpectString("third") + c.SendLine(string(terminal.KeyArrowRight)) + + c.ExpectString("Should be billable?") + c.SendLine("y") + + c.ExpectEOF() + }, + ) +} + +func runCmd(f cmdutil.Factory, args []string) ( + d defaults.DefaultTimeEntry, reported bool, err error) { + + cmd := set.NewCmdSet(f, func(_ OutputFlags, _ io.Writer, + dte defaults.DefaultTimeEntry) error { + reported = true + d = dte + return nil + }) + + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs(args) + _, err = cmd.ExecuteC() + + return d, reported, err +} + +func TestNewCmdSet_ShouldFail_WhenInvalidArgs(t *testing.T) { + tts := []struct { + name string + args []string + err string + factory func(t *testing.T) cmdutil.Factory + }{ + { + name: "can't be not billable and billable", + args: []string{"--billable", "--not-billable"}, + err: ".*flags can't be used together.*", + factory: func(*testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + f.EXPECT().Config().Return(&mocks.SimpleConfig{}) + return f + }, + }, + { + name: "can't read file", + err: "failed", + factory: func(*testing.T) cmdutil.Factory { + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, + errors.New("failed"), + ) + + f := mocks.NewMockFactory(t) + f.EXPECT().Config().Return(&mocks.SimpleConfig{}) + f.EXPECT().TimeEntryDefaults().Return(ted) + + return f + }, + }, + { + name: "failed to get client", + args: []string{"--project", "p1"}, + err: "failed", + factory: func(*testing.T) cmdutil.Factory { + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, + defaults.DefaultsFileNotFoundErr, + ) + + f := mocks.NewMockFactory(t) + f.EXPECT().Config().Return(&mocks.SimpleConfig{}) + f.EXPECT().TimeEntryDefaults().Return(ted) + f.EXPECT().GetWorkspaceID().Return("w", nil) + f.EXPECT().Client().Return( + mocks.NewMockClient(t), + errors.New("failed"), + ) + + return f + }, + }, + { + name: "can't get workspace", + err: "failed", + factory: func(*testing.T) cmdutil.Factory { + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, + defaults.DefaultsFileNotFoundErr, + ) + + f := mocks.NewMockFactory(t) + f.EXPECT().Config().Return(&mocks.SimpleConfig{}) + f.EXPECT().TimeEntryDefaults().Return(ted) + + f.EXPECT().GetWorkspaceID().Return("", errors.New("failed")) + + return f + }, + }, + { + name: "can't get project", + err: "failed", + args: []string{"--project", "p"}, + factory: func(*testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, + defaults.DefaultsFileNotFoundErr, + ) + + f.EXPECT().TimeEntryDefaults().Return(ted) + f.EXPECT().Config().Return(&mocks.SimpleConfig{}) + f.EXPECT().GetWorkspaceID().Return("w", nil) + f.EXPECT().Config().Return(&mocks.SimpleConfig{ + AllowNameForID: false, + }) + + cl := mocks.NewMockClient(t) + cl.EXPECT().GetProject(api.GetProjectParam{ + Workspace: "w", + ProjectID: "p", + Hydrate: false, + }).Return(nil, errors.New("failed")) + + f.EXPECT().Client().Return(cl, nil) + + return f + }, + }, + { + name: "can't find task", + err: `can't find task with ID "tk" on project "p"`, + args: []string{ + "--project", "p", + "--task=tk", + }, + factory: func(*testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, + defaults.DefaultsFileNotFoundErr, + ) + + f.EXPECT().TimeEntryDefaults().Return(ted) + + f.EXPECT().GetWorkspaceID().Return("w", nil) + + f.EXPECT().Config().Return(&mocks.SimpleConfig{ + AllowNameForID: false, + }) + + cl := mocks.NewMockClient(t) + cl.EXPECT().GetProject(api.GetProjectParam{ + Workspace: "w", + ProjectID: "p", + Hydrate: true, + }).Return(&dto.Project{ID: "p", Name: "project"}, nil) + + f.EXPECT().Client().Return(cl, nil) + + return f + }, + }, + { + name: "can't find tag", + err: "can't find tag with ID \"tg\"", + args: []string{ + "--project", "p", + "-T", "tg", + }, + factory: func(*testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, + defaults.DefaultsFileNotFoundErr, + ) + + f.EXPECT().TimeEntryDefaults().Return(ted) + + f.EXPECT().GetWorkspaceID().Return("w", nil) + + f.EXPECT().Config().Return(&mocks.SimpleConfig{ + AllowNameForID: false, + }) + + cl := mocks.NewMockClient(t) + cl.EXPECT().GetProject(api.GetProjectParam{ + Workspace: "w", + ProjectID: "p", + Hydrate: false, + }).Return(&dto.Project{ID: "p", Name: "project"}, nil) + + cl.EXPECT().GetTags(api.GetTagsParam{ + Workspace: "w", + Archived: &bFalse, + PaginationParam: api.AllPages(), + }).Return([]dto.Tag{{ID: "not that"}}, nil) + + f.EXPECT().Client().Return(cl, nil) + + return f + }, + }, + { + name: "can't look for tag", + err: "failed", + args: []string{ + "-T", "tg", + }, + factory: func(*testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, + defaults.DefaultsFileNotFoundErr, + ) + + f.EXPECT().TimeEntryDefaults().Return(ted) + + f.EXPECT().GetWorkspaceID().Return("w", nil) + + f.EXPECT().Config().Return(&mocks.SimpleConfig{ + AllowNameForID: false, + }) + + cl := mocks.NewMockClient(t) + + cl.EXPECT().GetTags(api.GetTagsParam{ + Workspace: "w", + Archived: &bFalse, + PaginationParam: api.AllPages(), + }).Return(nil, errors.New("failed")) + + f.EXPECT().Client().Return(cl, nil) + + return f + }, + }, + { + name: "can't find project by name", + err: "No project with id or name containing 'p' was found", + args: []string{"--project", "p"}, + factory: func(*testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, + defaults.DefaultsFileNotFoundErr, + ) + + f.EXPECT().TimeEntryDefaults().Return(ted) + + f.EXPECT().GetWorkspaceID().Return("w", nil) + + f.EXPECT().Config().Return(&mocks.SimpleConfig{ + AllowNameForID: true, + }) + + cl := mocks.NewMockClient(t) + cl.EXPECT().GetProjects(mock.Anything). + Return([]dto.Project{}, nil) + + f.EXPECT().Client().Return(cl, nil) + + return f + }, + }, + { + name: "can't find task by name", + err: "No task with id or name containing 'task' was found", + args: []string{"--project", "project", "--task=task"}, + factory: func(*testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, + defaults.DefaultsFileNotFoundErr, + ) + + f.EXPECT().TimeEntryDefaults().Return(ted) + + f.EXPECT().GetWorkspaceID().Return("w", nil) + + f.EXPECT().Config().Return(&mocks.SimpleConfig{ + AllowNameForID: true, + }) + + cl := mocks.NewMockClient(t) + cl.EXPECT().GetProjects(mock.Anything). + Return([]dto.Project{{ID: "p", Name: "project"}}, nil) + + cl.EXPECT().GetTasks(api.GetTasksParam{ + Workspace: "w", + ProjectID: "p", + Active: true, + PaginationParam: api.AllPages(), + }). + Return([]dto.Task{{ID: "tk", Name: "other"}}, nil) + + f.EXPECT().Client().Return(cl, nil) + + return f + }, + }, + { + name: "can't find tag by name", + err: "No tag with id or name containing 'tag' was found", + args: []string{ + "--project", "project", + "--task=task", + "-T=tag", + }, + factory: func(*testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, + defaults.DefaultsFileNotFoundErr, + ) + + f.EXPECT().TimeEntryDefaults().Return(ted) + + f.EXPECT().GetWorkspaceID().Return("w", nil) + + f.EXPECT().Config().Return(&mocks.SimpleConfig{ + AllowNameForID: true, + }) + + cl := mocks.NewMockClient(t) + cl.EXPECT().GetProjects(mock.Anything). + Return([]dto.Project{{ID: "p", Name: "project"}}, nil) + + cl.EXPECT().GetTasks(api.GetTasksParam{ + Workspace: "w", + ProjectID: "p", + Active: true, + PaginationParam: api.AllPages(), + }). + Return([]dto.Task{{ID: "tk", Name: "task"}}, nil) + + cl.EXPECT().GetTags(api.GetTagsParam{ + Workspace: "w", + Archived: &bFalse, + PaginationParam: api.AllPages(), + }). + Return([]dto.Tag{{ID: "tg", Name: "other"}}, nil) + + f.EXPECT().Client().Return(cl, nil) + + return f + }, + }, + { + name: "can't set task without project", + err: "can't set task without project", + args: []string{"--task=task"}, + factory: func(*testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + f.EXPECT().Config().Return(&mocks.SimpleConfig{}) + + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, + defaults.DefaultsFileNotFoundErr, + ) + + f.EXPECT().TimeEntryDefaults().Return(ted) + + f.EXPECT().GetWorkspaceID().Return("w", nil) + + f.EXPECT().Client(). + Return(mocks.NewMockClient(t), nil) + + return f + }, + }, + { + name: "can't find tag by name (no project)", + err: "No tag with id or name containing 'tag2' was found", + args: []string{"-T=tag", "-T=tag2"}, + factory: func(*testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, + defaults.DefaultsFileNotFoundErr, + ) + + f.EXPECT().TimeEntryDefaults().Return(ted) + + f.EXPECT().GetWorkspaceID().Return("w", nil) + + f.EXPECT().Config().Return(&mocks.SimpleConfig{ + AllowNameForID: true, + }) + + cl := mocks.NewMockClient(t) + + cl.EXPECT().GetTags(api.GetTagsParam{ + Workspace: "w", + Archived: &bFalse, + PaginationParam: api.AllPages(), + }). + Return([]dto.Tag{{ID: "tag", Name: "other"}}, nil) + + f.EXPECT().Client().Return(cl, nil) + + return f + }, + }, + } + + for i := range tts { + tt := &tts[i] + t.Run(tt.name, func(t *testing.T) { + _, called, err := runCmd(tt.factory(t), tt.args) + if !assert.Error(t, err, "should have failed") { + return + } + assert.False(t, called) + assert.Regexp(t, tt.err, err) + }) + } +} + +func TestNewCmdSet_ShouldUpdateDefaultsFile_OnlyByFlags(t *testing.T) { + tts := []struct { + name string + args []string + current defaults.DefaultTimeEntry + expected defaults.DefaultTimeEntry + }{ + { + name: "no arguments, no changes", + args: []string{}, + current: defaults.DefaultTimeEntry{ + ProjectID: "p1"}, + expected: defaults.DefaultTimeEntry{ + ProjectID: "p1"}, + }, + { + name: "all arguments", + args: []string{ + "-p=p2", + "--task=t2", + "-T=tg1", "-T=tg2", + "--billable", + }, + expected: defaults.DefaultTimeEntry{ + ProjectID: "p2", + TaskID: "t2", + Billable: &bTrue, + TagIDs: []string{"tg1", "tg2"}, + }, + }, + { + name: "not billable", + args: []string{"--not-billable"}, + current: defaults.DefaultTimeEntry{ + ProjectID: "p2", + TaskID: "t2", + Billable: &bTrue, + TagIDs: []string{"tg1", "tg2"}, + }, + expected: defaults.DefaultTimeEntry{ + ProjectID: "p2", + TaskID: "t2", + Billable: &bFalse, + TagIDs: []string{"tg1", "tg2"}, + }, + }, + } + + for i := range tts { + tt := &tts[i] + t.Run(tt.name, func(t *testing.T) { + + f := mocks.NewMockFactory(t) + + c := mocks.NewMockClient(t) + f.EXPECT().Client(). + Return(c, nil) + + f.EXPECT().Config().Return(&mocks.SimpleConfig{}) + + if len(tt.args) != 0 { + f.EXPECT().Config().Return(&mocks.SimpleConfig{ + AllowNameForID: false, + Interactive: false, + }) + + tasks := make([]dto.Task, 0) + + if tt.expected.TaskID != "" { + tasks = append(tasks, dto.Task{ + ID: tt.expected.TaskID, + }) + } + + if tt.expected.ProjectID != "" { + c.On("GetProject", mock.Anything).Return(&dto.Project{ + ID: tt.expected.ProjectID, + Tasks: tasks, + }, nil) + } + + if len(tt.expected.TagIDs) != 0 { + tags := make([]dto.Tag, len(tt.expected.TagIDs)) + + for i := range tt.expected.TagIDs { + tags[i] = dto.Tag{ID: tt.expected.TagIDs[i]} + } + + c.On("GetTags", mock.Anything).Return(tags, nil) + } + } + + f.EXPECT().GetWorkspaceID().Return("w", nil) + + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return(tt.current, nil) + ted.EXPECT().Write(tt.expected).Return(nil) + f.EXPECT().TimeEntryDefaults().Return(ted) + + result, called, err := runCmd(f, tt.args) + + assert.NoError(t, err, "should not have failed") + assert.True(t, called) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/cmd/time-entry/defaults/show/show.go b/pkg/cmd/time-entry/defaults/show/show.go new file mode 100644 index 00000000..d4c33c71 --- /dev/null +++ b/pkg/cmd/time-entry/defaults/show/show.go @@ -0,0 +1,38 @@ +package show + +import ( + "io" + + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util/defaults" + "github.com/lucassabreu/clockify-cli/pkg/cmdcompl" + "github.com/lucassabreu/clockify-cli/pkg/cmdutil" + outd "github.com/lucassabreu/clockify-cli/pkg/output/defaults" + "github.com/spf13/cobra" +) + +// NewCmdShow prints the default options for the current folder +func NewCmdShow( + f cmdutil.Factory, + report func(outd.OutputFlags, io.Writer, defaults.DefaultTimeEntry) error, +) *cobra.Command { + of := outd.OutputFlags{} + cmd := &cobra.Command{ + Use: "show", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + d, err := f.TimeEntryDefaults().Read() + if err != nil { + return err + } + + return report(of, cmd.OutOrStdout(), d) + }, + } + + cmd.Flags().StringVarP(&of.Format, + "format", "f", outd.FORMAT_YAML, "output format") + _ = cmdcompl.AddFixedSuggestionsToFlag(cmd, "format", + cmdcompl.ValidArgsSlide{outd.FORMAT_YAML, outd.FORMAT_JSON}) + + return cmd +} diff --git a/pkg/cmd/time-entry/defaults/show/show_test.go b/pkg/cmd/time-entry/defaults/show/show_test.go new file mode 100644 index 00000000..0eca25e7 --- /dev/null +++ b/pkg/cmd/time-entry/defaults/show/show_test.go @@ -0,0 +1,81 @@ +package show_test + +import ( + "bytes" + "testing" + + outd "github.com/lucassabreu/clockify-cli/pkg/output/defaults" + + "github.com/MakeNowJust/heredoc" + "github.com/lucassabreu/clockify-cli/internal/mocks" + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/defaults/show" + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util/defaults" + "github.com/stretchr/testify/assert" +) + +var bFalse = false +var bTrue = true + +func TestNewCmdShow_ShouldPrintDefaults(t *testing.T) { + ft := func(name string, + dte defaults.DefaultTimeEntry, + args []string, expected string) { + t.Helper() + t.Run(name, func(t *testing.T) { + f := mocks.NewMockFactory(t) + + ted := mocks.NewMockTimeEntryDefaults(t) + f.EXPECT().TimeEntryDefaults().Return(ted) + ted.EXPECT().Read().Return(dte, nil) + + cmd := show.NewCmdShow(f, outd.Report) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + cmd.SetArgs(args) + + out := bytes.NewBufferString("") + + cmd.SetOut(out) + cmd.SetErr(out) + + _, err := cmd.ExecuteC() + + if !assert.NoError(t, err) { + return + } + + assert.Equal(t, expected, out.String()) + }) + } + + dte := defaults.DefaultTimeEntry{ + ProjectID: "p", + Billable: &bFalse, + TagIDs: []string{"t1"}, + } + + ft("as json", dte, []string{"--format=json"}, + `{"project":"p","billable":false,"tags":["t1"]}`) + + ft("as yaml", dte, []string{"--format=yaml"}, heredoc.Doc(` + project: p + billable: false + tags: [t1] + `)) + + dte = defaults.DefaultTimeEntry{ + ProjectID: "p", + TaskID: "t", + Billable: &bTrue, + } + + ft("as json", dte, []string{"--format=json"}, + `{"project":"p","task":"t","billable":true}`) + + ft("as yaml", dte, []string{"--format=yaml"}, heredoc.Doc(` + project: p + task: t + billable: true + `)) +} diff --git a/pkg/cmd/time-entry/in/in.go b/pkg/cmd/time-entry/in/in.go index 66213ca3..b00d0c57 100644 --- a/pkg/cmd/time-entry/in/in.go +++ b/pkg/cmd/time-entry/in/in.go @@ -107,6 +107,11 @@ func NewCmdIn( return err } + tei, err = util.FromDefaults(f)(tei) + if err != nil { + return err + } + c, err := f.Client() if err != nil { return err diff --git a/pkg/cmd/time-entry/in/in_test.go b/pkg/cmd/time-entry/in/in_test.go index 2efb37d0..9845c92a 100644 --- a/pkg/cmd/time-entry/in/in_test.go +++ b/pkg/cmd/time-entry/in/in_test.go @@ -12,6 +12,7 @@ import ( "github.com/lucassabreu/clockify-cli/internal/mocks" "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/in" "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util" + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util/defaults" "github.com/lucassabreu/clockify-cli/pkg/cmdutil" "github.com/lucassabreu/clockify-cli/pkg/timehlp" "github.com/stretchr/testify/assert" @@ -19,11 +20,20 @@ import ( var w = dto.Workspace{ID: "w"} +func newTEDNotFound(t *testing.T) defaults.TimeEntryDefaults { + ted := mocks.NewMockTimeEntryDefaults(t) + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, defaults.DefaultsFileNotFoundErr) + + return ted +} + func TestNewCmdIn_ShouldBeBothBillableAndNotBillable(t *testing.T) { f := mocks.NewMockFactory(t) f.EXPECT().GetUserID().Return("u", nil) f.EXPECT().GetWorkspaceID().Return(w.ID, nil) + f.EXPECT().TimeEntryDefaults().Return(newTEDNotFound(t)) f.EXPECT().Config().Return(&mocks.SimpleConfig{}) @@ -57,9 +67,10 @@ func TestNewCmdIn_ShouldBeBothBillableAndNotBillable(t *testing.T) { t.Fatal("should've failed") } +var bTrue = true +var bFalse = false + func TestNewCmdIn_ShouldNotSetBillable_WhenNotAsked(t *testing.T) { - bTrue := true - bFalse := false tts := []struct { name string @@ -104,6 +115,7 @@ func TestNewCmdIn_ShouldNotSetBillable_WhenNotAsked(t *testing.T) { f.EXPECT().GetUserID().Return("u", nil) f.EXPECT().GetWorkspace().Return(w, nil) f.EXPECT().GetWorkspaceID().Return(w.ID, nil) + f.EXPECT().TimeEntryDefaults().Return(newTEDNotFound(t)) f.EXPECT().Config().Return(&mocks.SimpleConfig{ AllowNameForID: true, @@ -258,6 +270,7 @@ func TestNewCmdIn_ShouldLookupProject_WithAndWithoutClient(t *testing.T) { f.EXPECT().GetUserID().Return("u", nil) f.EXPECT().GetWorkspaceID().Return(w.ID, nil) + f.EXPECT().TimeEntryDefaults().Return(newTEDNotFound(t)) f.EXPECT().Config().Return(&mocks.SimpleConfig{ AllowNameForID: true, @@ -331,5 +344,123 @@ func TestNewCmdIn_ShouldLookupProject_WithAndWithoutClient(t *testing.T) { t.Fatalf("err: %s", err) }) } +} + +func TestNewCmdIn_ShouldUseDefaults(t *testing.T) { + ft := func(name string, d *defaults.DefaultTimeEntry, + args []string, p *dto.Project, exp api.CreateTimeEntryParam) { + t.Run(name, func(t *testing.T) { + f := mocks.NewMockFactory(t) + + f.EXPECT().Config().Return(&mocks.SimpleConfig{}) + f.EXPECT().GetWorkspaceID().Return("w", nil) + f.EXPECT().GetWorkspace().Return(w, nil) + f.EXPECT().GetUserID().Return("u", nil) + + c := mocks.NewMockClient(t) + f.EXPECT().Client().Return(c, nil) + + if p != nil { + c.EXPECT().GetProject(api.GetProjectParam{ + Workspace: w.ID, + ProjectID: p.ID, + }).Return(p, nil) + } + + c.EXPECT().GetTimeEntryInProgress(api.GetTimeEntryInProgressParam{ + Workspace: w.ID, + UserID: "u", + }). + Return(nil, nil) + + st := timehlp.Now() + c.EXPECT().Out(api.OutParam{ + Workspace: w.ID, + UserID: "u", + End: st, + }).Return(api.ErrorNotFound) + + c.EXPECT().CreateTimeEntry(exp). + Return(dto.TimeEntryImpl{ID: "te"}, nil) + + ted := mocks.NewMockTimeEntryDefaults(t) + f.EXPECT().TimeEntryDefaults().Return(ted) + if d == nil { + ted.EXPECT().Read().Return( + defaults.DefaultTimeEntry{}, + defaults.DefaultsFileNotFoundErr, + ) + } else { + ted.EXPECT().Read().Return(*d, nil) + } + + called := false + cmd := in.NewCmdIn(f, func( + _ dto.TimeEntryImpl, _ io.Writer, _ util.OutputFlags) error { + called = true + return nil + }) + + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + out := bytes.NewBufferString("") + cmd.SetOut(out) + cmd.SetErr(out) + + cmd.SetArgs(args) + _, err := cmd.ExecuteC() + + if !assert.NoError(t, err) { + return + } + + assert.True(t, called) + }) + } + + ft("only defaults", + &defaults.DefaultTimeEntry{ + ProjectID: "p1", + TaskID: "t", + Billable: &bTrue, + TagIDs: []string{"t1", "t2"}, + }, + []string{}, + &dto.Project{ID: "p1"}, + api.CreateTimeEntryParam{ + Workspace: w.ID, + Start: timehlp.Now(), + ProjectID: "p1", + TaskID: "t", + Billable: &bTrue, + TagIDs: []string{"t1", "t2"}, + }, + ) + ft("flags over defaults", + &defaults.DefaultTimeEntry{ + ProjectID: "p1", + TaskID: "t", + TagIDs: []string{"t1", "t2"}, + }, + []string{"-T", "tag", "-p", "p2"}, + &dto.Project{ID: "p2"}, + api.CreateTimeEntryParam{ + Workspace: w.ID, + Start: timehlp.Now(), + ProjectID: "p2", + TagIDs: []string{"tag"}, + }, + ) + + ft("no defaults", + &defaults.DefaultTimeEntry{}, + []string{}, + nil, + api.CreateTimeEntryParam{ + Workspace: w.ID, + Start: timehlp.Now(), + }, + ) } diff --git a/pkg/cmd/time-entry/manual/manual.go b/pkg/cmd/time-entry/manual/manual.go index f69d079b..0350edfe 100644 --- a/pkg/cmd/time-entry/manual/manual.go +++ b/pkg/cmd/time-entry/manual/manual.go @@ -52,6 +52,11 @@ func NewCmdManual(f cmdutil.Factory) *cobra.Command { return err } + tei, err = util.FromDefaults(f)(tei) + if err != nil { + return err + } + c, err := f.Client() if err != nil { return err diff --git a/pkg/cmd/time-entry/report/util/report.go b/pkg/cmd/time-entry/report/util/report.go index 2ae34c50..83914681 100644 --- a/pkg/cmd/time-entry/report/util/report.go +++ b/pkg/cmd/time-entry/report/util/report.go @@ -142,7 +142,7 @@ func ReportWithRange( if len(rf.TagIDs) > 0 && f.Config().IsAllowNameForID() { if rf.TagIDs, err = search.GetTagsByName( - c, workspace, rf.TagIDs); err != nil { + c, workspace, true, rf.TagIDs); err != nil { return err } } diff --git a/pkg/cmd/time-entry/report/util/reportwithrange_test.go b/pkg/cmd/time-entry/report/util/reportwithrange_test.go index 0bbc74eb..644e44f9 100644 --- a/pkg/cmd/time-entry/report/util/reportwithrange_test.go +++ b/pkg/cmd/time-entry/report/util/reportwithrange_test.go @@ -21,6 +21,8 @@ func newDate(s string) time.Time { return date } +var bFalse = false + func TestReportWithRange(t *testing.T) { date := newDate("2006-01-02") first := time.Date( @@ -432,6 +434,7 @@ func TestReportWithRange(t *testing.T) { tag := dto.Tag{ID: "t1", Name: "Client"} c.On("GetTags", api.GetTagsParam{ Workspace: "w", + Archived: &bFalse, PaginationParam: api.AllPages(), }).Return([]dto.Tag{tag}, nil) @@ -541,7 +544,7 @@ func TestReportWithRange(t *testing.T) { `), }, { - name: "projects form a client", + name: "projects from a client", flags: func(t *testing.T) util.ReportFlags { rf := util.NewReportFlags() rf.Quiet = true diff --git a/pkg/cmd/time-entry/timeentry.go b/pkg/cmd/time-entry/timeentry.go index ba5d7ef1..3aab90b2 100644 --- a/pkg/cmd/time-entry/timeentry.go +++ b/pkg/cmd/time-entry/timeentry.go @@ -5,6 +5,7 @@ import ( "github.com/lucassabreu/clockify-cli/api/dto" "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/clone" + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/defaults" del "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/delete" "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/edit" em "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/edit-multipple" @@ -50,6 +51,8 @@ func NewCmdTimeEntry(f cmdutil.Factory) (cmds []*cobra.Command) { show.NewCmdShow(f), report.NewCmdReport(f), + + defaults.NewCmdDefaults(f), ) cmds = append(cmds, invoiced.NewCmdInvoiced(f)...) diff --git a/pkg/cmd/time-entry/util/defaults/defaults.go b/pkg/cmd/time-entry/util/defaults/defaults.go new file mode 100644 index 00000000..1b40bfd0 --- /dev/null +++ b/pkg/cmd/time-entry/util/defaults/defaults.go @@ -0,0 +1,159 @@ +package defaults + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "gopkg.in/yaml.v3" +) + +// ScanError wraps errors from scanning for the defaults file +type ScanError struct { + Err error +} + +// Error shows error message +func (s *ScanError) Error() string { + return s.Unwrap().Error() +} + +// Unwrap gives access to the error chain +func (s *ScanError) Unwrap() error { + return s.Err +} + +// DefaultsFileNotFoundErr is returned when the scan can't find any files +var DefaultsFileNotFoundErr = errors.New("defaults file not found") + +const DEFAULT_FILENAME = ".clockify-defaults.json" + +// DefaultTimeEntry has the default properties for the working directory +type DefaultTimeEntry struct { + ProjectID string `json:"project,omitempty" yaml:"project,omitempty"` + TaskID string `json:"task,omitempty" yaml:"task,omitempty"` + Billable *bool `json:"billable,omitempty" yaml:"billable,omitempty"` + TagIDs []string `json:"tags,omitempty" yaml:"tags,omitempty,flow"` +} + +// ScanParam sets how ScanForDefaults should look for defaults +type ScanParam struct { + Dir string + Filename string +} + +// TimeEntryDefaults is a manager for the default time entry parameters on a +// folder +type TimeEntryDefaults interface { + // Read scan the directory informed and its parents for the defaults + // file + Read() (DefaultTimeEntry, error) + // Write persists the default values to the folder + Write(DefaultTimeEntry) error +} + +// NewTimeEntryDefaults creates a new instance of TimeEntryDefaults +func NewTimeEntryDefaults(p ScanParam) TimeEntryDefaults { + return &timeEntryDefaults{ + ScanParam: p, + } +} + +type timeEntryDefaults struct { + ScanParam + DefaultTimeEntry +} + +// FailedToOpenErr error returned when failing to open file without an explicit +// error +var FailedToOpenErr = errors.New("failed to open file") + +// Write persists the default values to the folder +func (t *timeEntryDefaults) Write(d DefaultTimeEntry) error { + println(filepath.Join(t.Dir, t.Filename)) + f, err := os.Create(filepath.Join(t.Dir, t.Filename)) + if err != nil { + return err + } + + if f == nil { + return FailedToOpenErr + + } + + defer f.Close() + + if strings.HasSuffix(f.Name(), "json") { + return json.NewEncoder(f).Encode(d) + } + + return yaml.NewEncoder(f).Encode(d) +} + +// Read scan the directory informed and its parents for the defaults +// file +func (t *timeEntryDefaults) Read() (DefaultTimeEntry, error) { + if t.ScanParam.Filename == "" { + t.ScanParam.Filename = DEFAULT_FILENAME + } + + p := t.ScanParam + dir := filepath.FromSlash(p.Dir) + d := DefaultTimeEntry{} + for { + f, err := getFile(filepath.Join(dir, p.Filename)) + if err != nil { + return d, &ScanError{ + Err: errors.Wrap( + err, "failed to open defaults file"), + } + } + + if f == nil { + nDir := filepath.Dir(dir) + if nDir == dir { + return d, DefaultsFileNotFoundErr + } + + dir = nDir + continue + } + + if f == nil { + return d, FailedToOpenErr + + } + defer f.Close() + + if strings.HasSuffix(f.Name(), "json") { + err = json.NewDecoder(f).Decode(&d) + } else { + err = yaml.NewDecoder(f).Decode(&d) + } + + if err != nil { + return d, errors.WithStack(&ScanError{ + Err: errors.Wrap( + err, "failed to decode defaults file"), + }) + } + + return d, nil + } +} + +func getFile(filename string) (*os.File, error) { + stat, err := os.Stat(filepath.Join(filename)) + if err != nil || stat.IsDir() { + return nil, nil + } + + f, err := os.Open(filename) + if os.IsNotExist(err) { + return nil, nil + } + + return f, err +} diff --git a/pkg/cmd/time-entry/util/defaults/defaults_test.go b/pkg/cmd/time-entry/util/defaults/defaults_test.go new file mode 100644 index 00000000..01d66002 --- /dev/null +++ b/pkg/cmd/time-entry/util/defaults/defaults_test.go @@ -0,0 +1,243 @@ +package defaults_test + +import ( + "os" + "path" + "path/filepath" + "testing" + "time" + + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util/defaults" + "github.com/stretchr/testify/assert" +) + +func TestWriteDefaults(t *testing.T) { + tts := []struct { + filename string + d defaults.DefaultTimeEntry + }{ + { + filename: "y_empty.yml", + d: defaults.DefaultTimeEntry{}, + }, + { + filename: "j_empty.json", + d: defaults.DefaultTimeEntry{}, + }, + { + filename: "j_complete.json", + d: defaults.DefaultTimeEntry{ + ProjectID: "p", + TaskID: "t", + TagIDs: []string{"t1", "t2"}, + }, + }, + { + filename: "y_complete.yaml", + d: defaults.DefaultTimeEntry{ + ProjectID: "p", + TaskID: "t", + TagIDs: []string{"t1", "t2"}, + }, + }, + } + + dir := t.TempDir() + for i := range tts { + tt := &tts[i] + t.Run(tt.filename, func(t *testing.T) { + timeout(t, 5*time.Second, func() { + ted := defaults.NewTimeEntryDefaults(defaults.ScanParam{ + Dir: dir, + Filename: tt.filename, + }) + err := ted.Write(tt.d) + if !assert.NoError(t, err, "failed to write") { + return + } + + ted = defaults.NewTimeEntryDefaults(defaults.ScanParam{ + Dir: dir, + Filename: tt.filename, + }) + r, err := ted.Read() + + assert.NoError(t, err) + assert.Equal(t, tt.d, r) + }) + }) + } +} + +func TestWriteDefaults_ShouldFail_WhenPermAreMissing(t *testing.T) { + dir := t.TempDir() + _ = os.Chmod(dir, 0444) + timeout(t, 5*time.Second, func() { + ted := defaults.NewTimeEntryDefaults(defaults.ScanParam{ + Dir: dir, + Filename: "fail", + }) + err := ted.Write(defaults.DefaultTimeEntry{}) + assert.Error(t, err) + }) +} + +func timeout(t *testing.T, d time.Duration, f func()) { + done := make(chan bool) + defer close(done) + + go func() { + f() + done <- true + }() + + select { + case <-done: + case <-time.After(d): + t.Error("timeout " + d.String()) + } +} + +func TestScanForDefaults_ShouldFail(t *testing.T) { + wd, _ := os.Getwd() + + dir := t.TempDir() + f, _ := os.OpenFile( + filepath.Join(dir, "not-open.yaml"), os.O_CREATE, os.ModePerm) + _ = f.Chmod(0000) + _ = f.Close() + + tts := []struct { + dir string + filename string + err interface{} + }{ + { + dir: wd, + filename: "not-found", + err: defaults.DefaultsFileNotFoundErr, + }, + { + dir: filepath.Join(wd, "test_data", "test_cur"), + filename: "not-right.json", + err: "invalid character", + }, + { + dir: dir, + filename: "not-open.yaml", + err: "permission denied", + }, + { + dir: filepath.Join(wd, "test_data", "test_empty", "dir.yaml"), + filename: "dir", + err: defaults.DefaultsFileNotFoundErr, + }, + } + + for i := range tts { + tt := &tts[i] + t.Run(tt.filename, func(t *testing.T) { + timeout(t, 5*time.Second, func() { + ted := defaults.NewTimeEntryDefaults(defaults.ScanParam{ + Dir: tt.dir, + Filename: tt.filename, + }) + d, err := ted.Read() + + assert.Equal(t, d, defaults.DefaultTimeEntry{}) + assert.Error(t, err) + switch v := tt.err.(type) { + case error: + assert.ErrorIs(t, err, v) + case string: + assert.Regexp(t, v, err) + } + }) + }) + } +} + +func TestScanForDefaults_ShouldLookUpperDirs(t *testing.T) { + wd, _ := os.Getwd() + tts := []struct { + name string + param defaults.ScanParam + expected defaults.DefaultTimeEntry + }{ + { + name: "test_cur", + param: defaults.ScanParam{ + Dir: "./test_data/test_cur", + Filename: ".clockify-defaults.yaml", + }, + expected: defaults.DefaultTimeEntry{ + ProjectID: "p", + TaskID: "t", + TagIDs: []string{"t1", "t2"}, + }, + }, + { + name: "test_cur, filename as defaults", + param: defaults.ScanParam{ + Dir: "./test_data/test_cur", + Filename: "defaults.json", + }, + expected: defaults.DefaultTimeEntry{ + ProjectID: "P", + TaskID: "T", + }, + }, + { + name: "down again", + param: defaults.ScanParam{ + Dir: "./test_data/test_cur/down/again", + Filename: ".clockify-defaults.yaml", + }, + expected: defaults.DefaultTimeEntry{ + ProjectID: "p", + TaskID: "t", + TagIDs: []string{"t1", "t2"}, + }, + }, + { + name: "down path, filename as defaults", + param: defaults.ScanParam{ + Dir: "./test_data/test_cur/down/again", + Filename: "defaults.json", + }, + expected: defaults.DefaultTimeEntry{ + ProjectID: "P", + TaskID: "T", + }, + }, + { + name: "test_incompl", + param: defaults.ScanParam{ + Dir: "./test_data/test_incompl", + Filename: ".clockify-defaults.yaml", + }, + expected: defaults.DefaultTimeEntry{ + ProjectID: "p", + }, + }, + { + name: "test_empty", + param: defaults.ScanParam{ + Dir: "./test_data/test_empty/down/here", + }, + expected: defaults.DefaultTimeEntry{}, + }, + } + + for i := range tts { + tt := &tts[i] + t.Run(tt.name, func(t *testing.T) { + timeout(t, 1*time.Second, func() { + tt.param.Dir = path.Join(wd, tt.param.Dir) + ted := defaults.NewTimeEntryDefaults(tt.param) + d, _ := ted.Read() + assert.Equal(t, tt.expected, d) + }) + }) + } +} diff --git a/pkg/cmd/time-entry/util/defaults/test_data/test_cur/.clockify-defaults.json/.gitkeep b/pkg/cmd/time-entry/util/defaults/test_data/test_cur/.clockify-defaults.json/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/pkg/cmd/time-entry/util/defaults/test_data/test_cur/.clockify-defaults.yaml b/pkg/cmd/time-entry/util/defaults/test_data/test_cur/.clockify-defaults.yaml new file mode 100644 index 00000000..d9646f8d --- /dev/null +++ b/pkg/cmd/time-entry/util/defaults/test_data/test_cur/.clockify-defaults.yaml @@ -0,0 +1,7 @@ +workspace: "w" +project: "p" +task: "t" +description: "d" +tags: + - "t1" + - "t2" diff --git a/pkg/cmd/time-entry/util/defaults/test_data/test_cur/defaults.json b/pkg/cmd/time-entry/util/defaults/test_data/test_cur/defaults.json new file mode 100644 index 00000000..5a57b24b --- /dev/null +++ b/pkg/cmd/time-entry/util/defaults/test_data/test_cur/defaults.json @@ -0,0 +1,6 @@ +{ + "description": "D", + "project": "P", + "task": "T", + "workspace": "W" +} diff --git a/pkg/cmd/time-entry/util/defaults/test_data/test_cur/down/again/.gitkeep b/pkg/cmd/time-entry/util/defaults/test_data/test_cur/down/again/.gitkeep new file mode 100644 index 00000000..50cb283c --- /dev/null +++ b/pkg/cmd/time-entry/util/defaults/test_data/test_cur/down/again/.gitkeep @@ -0,0 +1,7 @@ +workspace: "w" +projectId: "p" +task: "t" +description: "d" +tags: + - "t1" + - "t2" diff --git a/pkg/cmd/time-entry/util/defaults/test_data/test_cur/not-right.json b/pkg/cmd/time-entry/util/defaults/test_data/test_cur/not-right.json new file mode 100644 index 00000000..d9646f8d --- /dev/null +++ b/pkg/cmd/time-entry/util/defaults/test_data/test_cur/not-right.json @@ -0,0 +1,7 @@ +workspace: "w" +project: "p" +task: "t" +description: "d" +tags: + - "t1" + - "t2" diff --git a/pkg/cmd/time-entry/util/defaults/test_data/test_empty/dir.yaml/.gitkeep b/pkg/cmd/time-entry/util/defaults/test_data/test_empty/dir.yaml/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/pkg/cmd/time-entry/util/defaults/test_data/test_empty/down/here/.gitkeep b/pkg/cmd/time-entry/util/defaults/test_data/test_empty/down/here/.gitkeep new file mode 100644 index 00000000..50cb283c --- /dev/null +++ b/pkg/cmd/time-entry/util/defaults/test_data/test_empty/down/here/.gitkeep @@ -0,0 +1,7 @@ +workspace: "w" +projectId: "p" +task: "t" +description: "d" +tags: + - "t1" + - "t2" diff --git a/pkg/cmd/time-entry/util/defaults/test_data/test_incompl/.clockify-defaults.yaml b/pkg/cmd/time-entry/util/defaults/test_data/test_incompl/.clockify-defaults.yaml new file mode 100644 index 00000000..c9686181 --- /dev/null +++ b/pkg/cmd/time-entry/util/defaults/test_data/test_incompl/.clockify-defaults.yaml @@ -0,0 +1,2 @@ +workspace: "w" +project: "p" diff --git a/pkg/cmd/time-entry/util/from-defaults.go b/pkg/cmd/time-entry/util/from-defaults.go new file mode 100644 index 00000000..e0757d7f --- /dev/null +++ b/pkg/cmd/time-entry/util/from-defaults.go @@ -0,0 +1,23 @@ +package util + +import ( + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util/defaults" + "github.com/lucassabreu/clockify-cli/pkg/cmdutil" +) + +// FromDefaults starts a TimeEntryDTO with the current defaults +func FromDefaults(f cmdutil.Factory) Step { + return func(ted TimeEntryDTO) (TimeEntryDTO, error) { + d, err := f.TimeEntryDefaults().Read() + if err != nil && err != defaults.DefaultsFileNotFoundErr { + return ted, err + } + + ted.ProjectID = d.ProjectID + ted.TaskID = d.TaskID + ted.TagIDs = d.TagIDs + ted.Billable = d.Billable + + return ted, nil + } +} diff --git a/pkg/cmd/time-entry/util/interactive.go b/pkg/cmd/time-entry/util/interactive.go index a8603dc6..bf8846f0 100644 --- a/pkg/cmd/time-entry/util/interactive.go +++ b/pkg/cmd/time-entry/util/interactive.go @@ -5,13 +5,13 @@ import ( "fmt" "strings" "time" - "unicode/utf8" "github.com/lucassabreu/clockify-cli/api" "github.com/lucassabreu/clockify-cli/api/dto" "github.com/lucassabreu/clockify-cli/pkg/cmdutil" "github.com/lucassabreu/clockify-cli/pkg/timehlp" "github.com/lucassabreu/clockify-cli/pkg/ui" + "github.com/lucassabreu/clockify-cli/pkg/uiutil" ) // GetDatesInteractiveFn will ask the user the start and end times of the entry @@ -73,7 +73,7 @@ func GetPropsInteractiveFn( c, f.UI(), dc, - f.Config().GetBool(cmdutil.CONF_ALLOW_ARCHIVED_TAGS), + f.Config().IsAllowArchivedTags(), ) } } @@ -111,8 +111,6 @@ func askTimeEntryPropsInteractive( return te, err } -const noProject = "No Project" - func getProjectID( projectID string, w dto.Workspace, c api.Client, ui ui.UI, ) (string, error) { @@ -127,62 +125,20 @@ func getProjectID( return "", err } - projectsString := make([]string, len(projects)) - found := -1 - projectNameSize := 0 - - for i := range projects { - projectsString[i] = projects[i].ID + " - " + projects[i].Name - if c := utf8.RuneCountInString(projectsString[i]); projectNameSize < c { - projectNameSize = c - } - - if found == -1 && projects[i].ID == projectID { - projectID = projectsString[i] - found = i - } - } - - format := fmt.Sprintf("%%-%ds| %%s", projectNameSize+1) - - for i := range projects { - client := "Without Client" - if projects[i].ClientID != "" { - client = "Client: " + projects[i].ClientName + - " (" + projects[i].ClientID + ")" - } - - projectsString[i] = fmt.Sprintf( - format, - projectsString[i], - client, - ) - } - - if found == -1 { - if projectID != "" { - fmt.Printf("Project '%s' informed was not found.\n", projectID) - projectID = "" - } - } else { - projectID = projectsString[found] - } + p, err := uiutil.AskProject(uiutil.AskProjectParam{ + UI: ui, + ProjectID: projectID, + Projects: projects, + Force: w.Settings.ForceProjects, + }) - if !w.Settings.ForceProjects { - projectsString = append([]string{noProject}, projectsString...) + if p != nil { + return p.ID, err } - projectID, err = ui.AskFromOptions("Choose your project:", - projectsString, projectID) - if err != nil || projectID == noProject || projectID == "" { - return "", err - } - - return strings.TrimSpace(projectID[0:strings.Index(projectID, " - ")]), nil + return "", err } -const noTask = "No Task" - func getTaskID( taskID, projectID string, w dto.Workspace, c api.Client, ui ui.UI, ) (string, error) { @@ -210,37 +166,18 @@ func getTaskID( return "", nil } - tasksString := make([]string, len(tasks)) - found := -1 - - for i := range tasks { - tasksString[i] = tasks[i].ID + " - " + tasks[i].Name - - if found == -1 && tasks[i].ID == taskID { - taskID = tasksString[i] - found = i - } - } - - if found == -1 { - if taskID != "" { - fmt.Printf("Task '%s' informed was not found.\n", taskID) - taskID = "" - } - } else { - taskID = tasksString[found] - } - - if !w.Settings.ForceTasks { - tasksString = append([]string{noTask}, tasksString...) - } + t, err := uiutil.AskTask(uiutil.AskTaskParam{ + UI: ui, + TaskID: taskID, + Tasks: tasks, + Force: w.Settings.ForceTasks, + }) - taskID, err = ui.AskFromOptions("Choose your task:", tasksString, taskID) - if err != nil || taskID == noTask || taskID == "" { - return "", err + if t != nil { + return t.ID, err } - return strings.TrimSpace(taskID[0:strings.Index(taskID, " - ")]), nil + return "", err } func getDescription( @@ -301,21 +238,21 @@ func getTagIDs( } } - var newTags []string - if newTags, err = ui.AskManyFromOptions("Choose your tags:", - tagsString, current, func(s []string) error { - if w.Settings.ForceTags && len(s) == 0 { - return errors.New("at least one tag should be selected") - } + ts, err := uiutil.AskTags(uiutil.AskTagsParam{ + UI: ui, + TagIDs: tagIDs, + Tags: tags, + Force: w.Settings.ForceTags, + }) - return nil - }); err != nil { - return nil, nil + if err != nil || len(ts) == 0 { + return nil, err } - for i, t := range newTags { - newTags[i] = strings.TrimSpace(t[0:strings.Index(t, " - ")]) + newTags := make([]string, len(ts)) + for i := range ts { + newTags[i] = ts[i].ID } - return newTags, nil + return newTags, err } diff --git a/pkg/cmd/time-entry/util/interactive_test.go b/pkg/cmd/time-entry/util/interactive_test.go index 153d7b57..158133db 100644 --- a/pkg/cmd/time-entry/util/interactive_test.go +++ b/pkg/cmd/time-entry/util/interactive_test.go @@ -9,6 +9,7 @@ import ( "github.com/lucassabreu/clockify-cli/internal/mocks" "github.com/lucassabreu/clockify-cli/pkg/timehlp" "github.com/lucassabreu/clockify-cli/pkg/ui" + "github.com/lucassabreu/clockify-cli/pkg/uiutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -95,7 +96,7 @@ func TestGetPropsInteractive_ShouldAskValues(t *testing.T) { return err }, func(c consoletest.ExpectConsole) { c.ExpectString("Choose your project:") - c.ExpectString(noProject) + c.ExpectString(uiutil.NoProject) c.ExpectString("1 - First | Without Client") c.ExpectString("2 - Second | Client: Client One (1)") c.ExpectString("3 - Third | Client: Client Two (2)") @@ -106,10 +107,10 @@ func TestGetPropsInteractive_ShouldAskValues(t *testing.T) { c.ExpectString("3 - Third | Client: Client Two (2)") c.Send(string(terminal.KeyArrowDown)) - c.SendLine("") + c.SendLine() c.ExpectString("Choose your task:") - c.ExpectString(noTask) + c.ExpectString(uiutil.NoTask) c.ExpectString("t1 - First") c.ExpectString("t2 - Second") c.ExpectString("t3 - Third") @@ -127,7 +128,7 @@ func TestGetPropsInteractive_ShouldAskValues(t *testing.T) { c.Send("end") c.Send(string(terminal.KeyArrowRight)) - c.SendLine("") + c.SendLine() c.ExpectEOF() }) @@ -186,7 +187,7 @@ func TestGetPropsInteractive_ShouldAllowEmptyValues(t *testing.T) { return err }, func(c consoletest.ExpectConsole) { c.ExpectString("Choose your project:") - c.ExpectString(noProject) + c.ExpectString(uiutil.NoProject) c.ExpectString("1 - First | Without Client") c.SendLine("") @@ -267,7 +268,7 @@ func TestGetPropsInteractive_ShouldUseInputAsSelected(t *testing.T) { )(input) assert.NoError(t, err) - assert.Equal(t, input, output) + assert.Equal(t, output, input) return err }, func(c consoletest.ExpectConsole) { diff --git a/pkg/cmd/time-entry/util/name-for-id.go b/pkg/cmd/time-entry/util/name-for-id.go index dbef21d8..b3470bfc 100644 --- a/pkg/cmd/time-entry/util/name-for-id.go +++ b/pkg/cmd/time-entry/util/name-for-id.go @@ -66,7 +66,7 @@ func lookupTags(c api.Client) Step { } var err error - te.TagIDs, err = search.GetTagsByName(c, te.Workspace, te.TagIDs) + te.TagIDs, err = search.GetTagsByName(c, te.Workspace, true, te.TagIDs) return te, err } diff --git a/pkg/cmd/time-entry/util/util_test.go b/pkg/cmd/time-entry/util/util_test.go index bbcf0ea4..59159381 100644 --- a/pkg/cmd/time-entry/util/util_test.go +++ b/pkg/cmd/time-entry/util/util_test.go @@ -599,6 +599,7 @@ func TestGetAllowNameForIDsFn_ShouldLookupEntityIDs_WhenFilled(t *testing.T) { c.EXPECT().GetTags(api.GetTagsParam{ Workspace: te.Workspace, + Archived: &bFalse, PaginationParam: api.AllPages(), }). Return([]dto.Tag{ @@ -700,6 +701,7 @@ func TestGetAllowNameForIDsFn_ShouldFail_WhenEntitiesNotFound(t *testing.T) { c.EXPECT().GetTags(api.GetTagsParam{ Workspace: te.Workspace, + Archived: &bFalse, PaginationParam: api.AllPages(), }). Return([]dto.Tag{{ID: "tg_id_1", Name: "t1"}}, nil) diff --git a/pkg/cmdutil/config.go b/pkg/cmdutil/config.go index c2f06830..0af38987 100644 --- a/pkg/cmdutil/config.go +++ b/pkg/cmdutil/config.go @@ -31,6 +31,7 @@ const ( CONF_INTERACTIVE_PAGE_SIZE = "interactive-page-size" CONF_LANGUAGE = "lang" CONF_TIMEZONE = "time-zone" + CONF_TIME_ENTRY_DEFAULTS = "time-entry-defaults" ) const ( @@ -75,6 +76,8 @@ type Config interface { // IsSearchProjectWithClientsName defines if the project name for ID should // include the client's name IsSearchProjectWithClientsName() bool + // IsAllowArchivedTags defines if archived tags should be suggested + IsAllowArchivedTags() bool // Language what is the language to used when printing numbers Language() language.Tag @@ -114,6 +117,11 @@ func (c *config) IsSearchProjectWithClientsName() bool { return c.GetBool(CONF_SEARCH_PROJECTS_WITH_CLIENT_NAME) } +// IsAllowArchivedTags defines if archived tags should be suggested +func (c *config) IsAllowArchivedTags() bool { + return c.GetBool(CONF_ALLOW_ARCHIVED_TAGS) +} + func (c *config) InteractivePageSize() int { i := c.GetInt(CONF_INTERACTIVE_PAGE_SIZE) if i <= 0 { diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index 21aec575..c28a0854 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -6,6 +6,7 @@ import ( "github.com/lucassabreu/clockify-cli/api" "github.com/lucassabreu/clockify-cli/api/dto" + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util/defaults" "github.com/lucassabreu/clockify-cli/pkg/ui" ) @@ -20,6 +21,8 @@ type Factory interface { Client() (api.Client, error) // UI builds a control to prompt information from the user UI() ui.UI + // TimeEntryDefaults manages the default properties of a time entry + TimeEntryDefaults() defaults.TimeEntryDefaults // GetUserID returns the current user id GetUserID() (string, error) @@ -32,15 +35,21 @@ type Factory interface { type factory struct { version func() Version - config func() Config - client func() (api.Client, error) - ui func() ui.UI + config func() Config + client func() (api.Client, error) + ui func() ui.UI + timeEntryDefaults func() defaults.TimeEntryDefaults getUserID func() (string, error) getWorkspaceID func() (string, error) getWorkspace func() (dto.Workspace, error) } +// TimeEntryDefaults manages the default properties of a time entry +func (f *factory) TimeEntryDefaults() defaults.TimeEntryDefaults { + return f.timeEntryDefaults() +} + func (f *factory) Version() Version { return f.version() } @@ -69,10 +78,12 @@ func (f *factory) GetWorkspace() (dto.Workspace, error) { return f.getWorkspace() } +// NewFactory creates a new instance of Factory func NewFactory(v Version) Factory { f := &factory{ - version: func() Version { return v }, - config: configFunc(), + version: func() Version { return v }, + config: configFunc(), + timeEntryDefaults: getTED(), } f.ui = getUi(f) @@ -219,3 +230,19 @@ func getUi(f Factory) func() ui.UI { } } + +func getTED() func() defaults.TimeEntryDefaults { + var ted defaults.TimeEntryDefaults + return func() defaults.TimeEntryDefaults { + if ted != nil { + return ted + } + + wd, _ := os.Getwd() + ted = defaults.NewTimeEntryDefaults(defaults.ScanParam{ + Dir: wd, + }) + + return ted + } +} diff --git a/pkg/output/defaults/default.go b/pkg/output/defaults/default.go new file mode 100644 index 00000000..7bb8039a --- /dev/null +++ b/pkg/output/defaults/default.go @@ -0,0 +1,35 @@ +package defaults + +import ( + "encoding/json" + "errors" + "io" + + "github.com/lucassabreu/clockify-cli/pkg/cmd/time-entry/util/defaults" + "gopkg.in/yaml.v3" +) + +const ( + FORMAT_JSON = "json" + FORMAT_YAML = "yaml" +) + +type OutputFlags struct { + Format string +} + +// Report prints a DefaultTimeEntry using user's flags +func Report(of OutputFlags, out io.Writer, v defaults.DefaultTimeEntry) error { + var b []byte + switch of.Format { + case FORMAT_JSON: + b, _ = json.Marshal(v) + case FORMAT_YAML: + b, _ = yaml.Marshal(v) + default: + return errors.New("invalid format") + } + + _, err := out.Write(b) + return err +} diff --git a/pkg/search/tag.go b/pkg/search/tag.go index 6ecc4f9c..7e35699c 100644 --- a/pkg/search/tag.go +++ b/pkg/search/tag.go @@ -9,14 +9,21 @@ import ( func GetTagsByName( c api.Client, workspace string, + onlyActive bool, tags []string, ) ([]string, error) { if len(tags) == 0 { return tags, nil } + var b *bool + if onlyActive { + f := false + b = &f + } ts, err := c.GetTags(api.GetTagsParam{ Workspace: workspace, + Archived: b, PaginationParam: api.AllPages(), }) if err != nil { diff --git a/pkg/ui/ui.go b/pkg/ui/ui.go index 74634e66..6cf89a86 100644 --- a/pkg/ui/ui.go +++ b/pkg/ui/ui.go @@ -60,7 +60,7 @@ type UI interface { m string, o, d []string, validade func([]string) error, ) ([]string, error) // Confirm interactively ask the user a yes/no question - Confirm(m string, d bool) (bool, error) + Confirm(m string, d bool, opts ...ConfirmOption) (bool, error) } type ui struct { @@ -295,15 +295,26 @@ func (u *ui) AskManyFromOptions( ) } +// ConfirmOption as extra options to customize a Confirm input +type ConfirmOption func(*survey.Confirm) + +// WithConfirmHelp add help to input question +func WithConfirmHelp(help string) ConfirmOption { + return func(i *survey.Confirm) { + i.Help = help + } +} + // Confirm interactively ask the user a yes/no question -func (u *ui) Confirm(message string, d bool) (bool, error) { +func (u *ui) Confirm(message string, d bool, opts ...ConfirmOption) (bool, error) { + c := &survey.Confirm{ + Message: message, + Default: d, + } + for _, o := range opts { + o(c) + } + v := false - return v, survey.AskOne( - &survey.Confirm{ - Message: message, - Default: d, - }, - &v, - u.options..., - ) + return v, survey.AskOne(c, &v, u.options...) } diff --git a/pkg/uiutil/ask-project.go b/pkg/uiutil/ask-project.go new file mode 100644 index 00000000..06e142ba --- /dev/null +++ b/pkg/uiutil/ask-project.go @@ -0,0 +1,90 @@ +package uiutil + +import ( + "fmt" + "strings" + "unicode/utf8" + + "github.com/lucassabreu/clockify-cli/api/dto" + "github.com/lucassabreu/clockify-cli/pkg/ui" + "github.com/pkg/errors" +) + +// AskProjectParam informs what options to display while asking for a project +type AskProjectParam struct { + UI ui.UI + ProjectID string + Projects []dto.Project + Force bool + Message string +} + +// NoProject is the text shown to not select a project +const NoProject = "No Project" + +// AskProject asks the user for a project from options +func AskProject(p AskProjectParam) (*dto.Project, error) { + if p.UI == nil { + return nil, errors.New("UI must be informed") + } + + if p.Message == "" { + p.Message = "Choose your project:" + } + + c, list := projectsToList(p.ProjectID, p.Projects) + p.ProjectID = c + + if !p.Force { + list = append([]string{NoProject}, list...) + } + + id, err := p.UI.AskFromOptions(p.Message, list, p.ProjectID) + if err != nil || id == NoProject || id == "" { + return nil, err + } + + id = strings.TrimSpace(id[0:strings.Index(id, " - ")]) + for i := range p.Projects { + if p.Projects[i].ID == id { + return &p.Projects[i], nil + } + } + + return nil, errors.New(`project with id "` + id + `" not found`) +} + +func projectsToList( + projectID string, projects []dto.Project) (string, []string) { + list := make([]string, len(projects)) + found := -1 + nameSize := 0 + + for i := range projects { + list[i] = projects[i].ID + " - " + projects[i].Name + if c := utf8.RuneCountInString(list[i]); nameSize < c { + nameSize = c + } + + if found == -1 && projects[i].ID == projectID { + found = i + } + } + + format := fmt.Sprintf("%%-%ds| %%s", nameSize+1) + for i := range projects { + client := "Without Client" + if projects[i].ClientID != "" { + client = "Client: " + projects[i].ClientName + + " (" + projects[i].ClientID + ")" + } + + list[i] = fmt.Sprintf(format, list[i], client) + } + + if found == -1 { + return "", list + } + + return list[found], list +} diff --git a/pkg/uiutil/ask-project_test.go b/pkg/uiutil/ask-project_test.go new file mode 100644 index 00000000..23dddb95 --- /dev/null +++ b/pkg/uiutil/ask-project_test.go @@ -0,0 +1,134 @@ +package uiutil_test + +import ( + "testing" + + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/lucassabreu/clockify-cli/api/dto" + "github.com/lucassabreu/clockify-cli/internal/consoletest" + "github.com/lucassabreu/clockify-cli/pkg/ui" + "github.com/lucassabreu/clockify-cli/pkg/uiutil" + "github.com/stretchr/testify/assert" +) + +func TestAskProjectShouldFail(t *testing.T) { + tts := []struct { + name string + param uiutil.AskProjectParam + err string + }{ + { + name: "no ui", + param: uiutil.AskProjectParam{}, + err: "UI must be informed", + }, + } + + for _, tt := range tts { + t.Run(tt.name, func(t *testing.T) { + _, err := uiutil.AskProject(tt.param) + if !assert.Error(t, err) { + return + } + + assert.Regexp(t, tt.err, err.Error()) + }) + } +} + +var ps = []dto.Project{ + {ID: "p1", Name: "Project One"}, + {ID: "p2", Name: "Project Two", ClientID: "c1", ClientName: "Client One"}, + {ID: "p3", Name: "Project Tree"}, + {ID: "p4", Name: "Project Four"}, + {ID: "p5", Name: "Project Five"}, + {ID: "p6", Name: "Project Six"}, +} + +func TestAskProjectIsRequired(t *testing.T) { + consoletest.RunTestConsole(t, + func(out consoletest.FileWriter, in consoletest.FileReader) error { + ui := ui.NewUI(in, out, out) + ui.SetPageSize(10) + + p, err := uiutil.AskProject(uiutil.AskProjectParam{ + UI: ui, + ProjectID: "p2", + Force: true, + Projects: ps, + }) + + assert.Equal(t, &ps[3], p) + return err + }, + func(c consoletest.ExpectConsole) { + c.ExpectString("project:") + c.ExpectString("> p2") + c.ExpectString("Client One") + + c.Send("four") + c.ExpectString("four") + c.ExpectString("> p4") + c.SendLine() + + c.ExpectEOF() + }, + ) +} + +func TestAskProjectIsntRequired(t *testing.T) { + consoletest.RunTestConsole(t, + func(out consoletest.FileWriter, in consoletest.FileReader) error { + ui := ui.NewUI(in, out, out) + + p, err := uiutil.AskProject(uiutil.AskProjectParam{ + UI: ui, + Message: "Which project?", + ProjectID: "p2", + Force: false, + Projects: ps, + }) + + assert.Nil(t, p) + + return err + }, + func(c consoletest.ExpectConsole) { + c.ExpectString("project?") + c.ExpectString("No Project") + c.Send(string(terminal.KeyArrowUp)) + c.Send(string(terminal.KeyArrowUp)) + + c.SendLine() + + c.ExpectEOF() + }, + ) +} + +func TestAskProjectNoneSelected(t *testing.T) { + consoletest.RunTestConsole(t, + func(out consoletest.FileWriter, in consoletest.FileReader) error { + ui := ui.NewUI(in, out, out) + + p, err := uiutil.AskProject(uiutil.AskProjectParam{ + UI: ui, + Message: "Which project?", + ProjectID: "", + Force: false, + Projects: ps, + }) + + assert.Nil(t, p) + + return err + }, + func(c consoletest.ExpectConsole) { + c.ExpectString("project?") + + c.SendLine() + + c.ExpectEOF() + }, + ) +} diff --git a/pkg/uiutil/ask-tags.go b/pkg/uiutil/ask-tags.go new file mode 100644 index 00000000..e7e0e969 --- /dev/null +++ b/pkg/uiutil/ask-tags.go @@ -0,0 +1,86 @@ +package uiutil + +import ( + "strings" + + "github.com/lucassabreu/clockify-cli/api/dto" + "github.com/lucassabreu/clockify-cli/pkg/ui" + "github.com/pkg/errors" +) + +// AskTagsParam informs what options to display while asking for a tag +type AskTagsParam struct { + UI ui.UI + TagIDs []string + Tags []dto.Tag + Message string + Force bool +} + +// AskTags asks the user for a tag from options +func AskTags(p AskTagsParam) ([]dto.Tag, error) { + if p.UI == nil { + return nil, errors.New("UI must be informed") + } + + if p.Message == "" { + p.Message = "Choose your tags:" + } + + s, list := tagsToList(p.TagIDs, p.Tags) + v := func(s []string) error { return nil } + if p.Force { + v = func(s []string) error { + if len(s) == 0 { + return errors.New("at least one tag should be selected") + } + return nil + } + } + + ids, err := p.UI.AskManyFromOptions(p.Message, list, s, v) + if err != nil || len(ids) == 0 { + return []dto.Tag{}, err + } + + return listToTags("tag", ids, p.Tags) +} +func tagsToList( + d []string, options []dto.Tag) (strD []string, strOpts []string) { + strOpts = make([]string, len(options)) + for i := range options { + strOpts[i] = options[i].ID + " - " + options[i].Name + } + + strD = make([]string, len(d)) + for i := range d { + for _, o := range strOpts { + if strings.HasPrefix(o, d[i]) { + strD[i] = o + } + } + } + return +} + +func listToTags( + name string, ids []string, entities []dto.Tag) ([]dto.Tag, error) { + selected := make([]dto.Tag, len(ids)) + for i, t := range ids { + found := false + t = strings.TrimSpace(t[0:strings.Index(t, " - ")]) + for j := range entities { + if entities[j].ID == t { + selected[i] = entities[j] + found = true + } + } + + if !found { + return []dto.Tag{}, errors.New( + name + ` with id "` + t + `" not found`) + } + } + + return selected, nil +} diff --git a/pkg/uiutil/ask-tags_test.go b/pkg/uiutil/ask-tags_test.go new file mode 100644 index 00000000..98de65b4 --- /dev/null +++ b/pkg/uiutil/ask-tags_test.go @@ -0,0 +1,198 @@ +package uiutil_test + +import ( + "testing" + + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/lucassabreu/clockify-cli/api/dto" + "github.com/lucassabreu/clockify-cli/internal/consoletest" + "github.com/lucassabreu/clockify-cli/pkg/ui" + "github.com/lucassabreu/clockify-cli/pkg/uiutil" + "github.com/stretchr/testify/assert" +) + +func TestAskTagsShouldFail(t *testing.T) { + tts := []struct { + name string + param uiutil.AskTagsParam + err string + }{ + { + name: "no ui", + param: uiutil.AskTagsParam{}, + err: "UI must be informed", + }, + } + + for _, tt := range tts { + t.Run(tt.name, func(t *testing.T) { + _, err := uiutil.AskTags(tt.param) + if !assert.Error(t, err) { + return + } + + assert.Regexp(t, tt.err, err.Error()) + }) + } +} + +var tags = []dto.Tag{ + {ID: "t1", Name: "Tag One"}, + {ID: "t2", Name: "Tag Two"}, + {ID: "t3", Name: "Tag Tree"}, + {ID: "t4", Name: "Tag Four"}, + {ID: "t5", Name: "Tag Five"}, + {ID: "t6", Name: "Tag Six"}, +} + +func TestAskTags(t *testing.T) { + consoletest.RunTestConsole(t, + func(out consoletest.FileWriter, in consoletest.FileReader) error { + ui := ui.NewUI(in, out, out) + ui.SetPageSize(10) + + ts, err := uiutil.AskTags(uiutil.AskTagsParam{ + UI: ui, + TagIDs: []string{"t2", "t4"}, + Force: true, + Tags: tags, + }) + + if !assert.Equal( + t, + []dto.Tag{ + {ID: "t1", Name: "Tag One"}, + {ID: "t2", Name: "Tag Two"}, + {ID: "t5", Name: "Tag Five"}, + {ID: "t6", Name: "Tag Six"}, + }, + ts, + ) { + return nil + } + + return err + }, + func(c consoletest.ExpectConsole) { + c.ExpectString("tags:") + c.ExpectString("[x]") + c.ExpectString("[x]") + + c.Send("one ") + c.Send("four ") + + c.Send("f") + c.Send(string(terminal.KeyArrowDown)) + c.Send(" ") + + c.Send(string(terminal.KeyArrowDown)) + c.Send(string(terminal.KeyArrowDown)) + c.Send(string(terminal.KeyArrowDown)) + c.Send(string(terminal.KeyArrowDown)) + + c.SendLine(" ") + + c.ExpectEOF() + }, + ) +} + +func TestAskTagsIsRequired(t *testing.T) { + consoletest.RunTestConsole(t, + func(out consoletest.FileWriter, in consoletest.FileReader) error { + ui := ui.NewUI(in, out, out) + + ts, err := uiutil.AskTags(uiutil.AskTagsParam{ + UI: ui, + Message: "Which tags?", + TagIDs: []string{"t2"}, + Force: true, + Tags: tags, + }) + + assert.Equal( + t, + []dto.Tag{ + {ID: "t1", Name: "Tag One"}, + }, + ts, + ) + + return err + }, + func(c consoletest.ExpectConsole) { + c.ExpectString("tags?") + c.ExpectString("[x]") + + c.SendLine(string(terminal.KeyArrowLeft)) + + c.ExpectString("at least one") + c.Send(string(terminal.KeyArrowLeft)) + + c.SendLine(" ") + + c.ExpectEOF() + }, + ) +} + +func TestAskTagsIsntRequired(t *testing.T) { + consoletest.RunTestConsole(t, + func(out consoletest.FileWriter, in consoletest.FileReader) error { + ui := ui.NewUI(in, out, out) + + ts, err := uiutil.AskTags(uiutil.AskTagsParam{ + UI: ui, + Message: "Which tags?", + TagIDs: []string{"t2"}, + Force: false, + Tags: tags, + }) + + assert.Equal( + t, + []dto.Tag{}, + ts, + ) + + return err + }, + func(c consoletest.ExpectConsole) { + c.ExpectString("tags?") + c.ExpectString("[x]") + + c.SendLine(string(terminal.KeyArrowLeft)) + + c.ExpectEOF() + }, + ) +} + +func TestAskTagsNoneSelected(t *testing.T) { + consoletest.RunTestConsole(t, + func(out consoletest.FileWriter, in consoletest.FileReader) error { + ui := ui.NewUI(in, out, out) + + ts, err := uiutil.AskTags(uiutil.AskTagsParam{ + UI: ui, + Message: "Which tags?", + TagIDs: []string{}, + Force: false, + Tags: tags, + }) + + assert.Equal( + t, + []dto.Tag{}, + ts, + ) + + return err + }, + func(c consoletest.ExpectConsole) { + c.ExpectString("tags?") + c.SendLine() + c.ExpectEOF() + }, + ) +} diff --git a/pkg/uiutil/ask-task.go b/pkg/uiutil/ask-task.go new file mode 100644 index 00000000..31779397 --- /dev/null +++ b/pkg/uiutil/ask-task.go @@ -0,0 +1,69 @@ +package uiutil + +import ( + "strings" + + "github.com/lucassabreu/clockify-cli/api/dto" + "github.com/lucassabreu/clockify-cli/pkg/ui" + "github.com/pkg/errors" +) + +// AskTaskParam informs what options to display while asking for a task +type AskTaskParam struct { + UI ui.UI + TaskID string + Tasks []dto.Task + Force bool + Message string +} + +// NoTask is the text used for no selection +const NoTask = "No Task" + +// AskTask asks the user for a task from options +func AskTask(p AskTaskParam) (*dto.Task, error) { + if p.UI == nil { + return nil, errors.New("UI must be informed") + } + + if p.Tasks == nil || len(p.Tasks) == 0 { + return nil, nil + } + if p.Message == "" { + p.Message = "Choose your task:" + } + + list := make([]string, len(p.Tasks)) + found := -1 + + for i := range p.Tasks { + list[i] = p.Tasks[i].ID + " - " + p.Tasks[i].Name + if found == -1 && p.Tasks[i].ID == p.TaskID { + found = i + } + } + + if found == -1 { + p.TaskID = "" + } else { + p.TaskID = list[found] + } + + if !p.Force { + list = append([]string{NoTask}, list...) + } + + id, err := p.UI.AskFromOptions(p.Message, list, p.TaskID) + if err != nil || id == NoTask || id == "" { + return nil, err + } + + id = strings.TrimSpace(id[0:strings.Index(id, " - ")]) + for i := range p.Tasks { + if p.Tasks[i].ID == id { + return &p.Tasks[i], nil + } + } + + return nil, errors.New(`task with id "` + id + `" not found`) +} diff --git a/pkg/uiutil/ask-task_test.go b/pkg/uiutil/ask-task_test.go new file mode 100644 index 00000000..c163bc03 --- /dev/null +++ b/pkg/uiutil/ask-task_test.go @@ -0,0 +1,134 @@ +package uiutil_test + +import ( + "testing" + + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/lucassabreu/clockify-cli/api/dto" + "github.com/lucassabreu/clockify-cli/internal/consoletest" + "github.com/lucassabreu/clockify-cli/pkg/ui" + "github.com/lucassabreu/clockify-cli/pkg/uiutil" + "github.com/stretchr/testify/assert" +) + +func TestAskTaskShouldFail(t *testing.T) { + tts := []struct { + name string + param uiutil.AskTaskParam + err string + }{ + { + name: "no ui", + param: uiutil.AskTaskParam{}, + err: "UI must be informed", + }, + } + + for _, tt := range tts { + t.Run(tt.name, func(t *testing.T) { + _, err := uiutil.AskTask(tt.param) + if !assert.Error(t, err) { + return + } + + assert.Regexp(t, tt.err, err.Error()) + }) + } +} + +var tks = []dto.Task{ + {ID: "t1", Name: "Task One"}, + {ID: "t2", Name: "Task Two"}, + {ID: "t3", Name: "Task Tree"}, + {ID: "t4", Name: "Task Four"}, + {ID: "t5", Name: "Task Five"}, + {ID: "t6", Name: "Task Six"}, +} + +func TestAskTaskIsRequired(t *testing.T) { + consoletest.RunTestConsole(t, + func(out consoletest.FileWriter, in consoletest.FileReader) error { + ui := ui.NewUI(in, out, out) + ui.SetPageSize(10) + + p, err := uiutil.AskTask(uiutil.AskTaskParam{ + UI: ui, + TaskID: "t2", + Force: true, + Tasks: tks, + }) + + assert.Equal(t, &tks[3], p) + return err + }, + func(c consoletest.ExpectConsole) { + c.ExpectString("task:") + c.ExpectString("> t2") + + c.Send("four") + c.ExpectString("four") + c.ExpectString("> t4") + c.SendLine() + + c.ExpectEOF() + }, + ) +} + +func TestAskTaskIsntRequired(t *testing.T) { + consoletest.RunTestConsole(t, + func(out consoletest.FileWriter, in consoletest.FileReader) error { + ui := ui.NewUI(in, out, out) + + p, err := uiutil.AskTask(uiutil.AskTaskParam{ + UI: ui, + Message: "Which task?", + TaskID: "t2", + Force: false, + Tasks: tks, + }) + + assert.Nil(t, p) + + return err + }, + func(c consoletest.ExpectConsole) { + c.ExpectString("task?") + c.ExpectString("No Task") + c.Send(string(terminal.KeyArrowUp)) + c.Send(string(terminal.KeyArrowUp)) + + c.SendLine() + + c.ExpectEOF() + }, + ) +} + +func TestAskTaskNoneSelected(t *testing.T) { + consoletest.RunTestConsole(t, + func(out consoletest.FileWriter, in consoletest.FileReader) error { + ui := ui.NewUI(in, out, out) + + p, err := uiutil.AskTask(uiutil.AskTaskParam{ + UI: ui, + Message: "Which task?", + TaskID: "", + Force: false, + Tasks: tks, + }) + + assert.Nil(t, p) + + return err + }, + func(c consoletest.ExpectConsole) { + c.ExpectString("task?") + c.ExpectString("No Task") + + c.SendLine() + + c.ExpectEOF() + }, + ) +}