diff --git a/CHANGELOG.md b/CHANGELOG.md index d96d7dfd..76542927 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- new flag `--client` to filter projects by client when managing time entries + ## [v0.46.0] - 2023-12-06 ### Added diff --git a/pkg/cmd/project/edit/edit.go b/pkg/cmd/project/edit/edit.go index 3e920d03..4da85109 100644 --- a/pkg/cmd/project/edit/edit.go +++ b/pkg/cmd/project/edit/edit.go @@ -126,11 +126,6 @@ func NewCmdEdit( return err } - if ids, err = search.GetProjectsByName( - c, w, ids); err != nil { - return err - } - if client != nil && *client != "" { if *client, err = search.GetClientByName( c, w, *client); err != nil { diff --git a/pkg/cmd/task/delete/delete.go b/pkg/cmd/task/delete/delete.go index ab9bea79..0647478a 100644 --- a/pkg/cmd/task/delete/delete.go +++ b/pkg/cmd/task/delete/delete.go @@ -70,7 +70,7 @@ func NewCmdDelete( if f.Config().IsAllowNameForID() { if project, err = search.GetProjectByName( - c, w, project); err != nil { + c, w, project, ""); err != nil { return err } diff --git a/pkg/cmd/task/done/done.go b/pkg/cmd/task/done/done.go index ee960118..eec5468c 100644 --- a/pkg/cmd/task/done/done.go +++ b/pkg/cmd/task/done/done.go @@ -88,7 +88,7 @@ func NewCmdDone( if f.Config().IsAllowNameForID() { if project, err = search.GetProjectByName( - c, workspace, project); err != nil { + c, workspace, project, ""); err != nil { return err } diff --git a/pkg/cmd/task/list/list.go b/pkg/cmd/task/list/list.go index b51b3ce3..78c3a76b 100644 --- a/pkg/cmd/task/list/list.go +++ b/pkg/cmd/task/list/list.go @@ -63,7 +63,7 @@ func NewCmdList( if f.Config().IsAllowNameForID() && p.ProjectID != "" { if p.ProjectID, err = search.GetProjectByName( - c, workspace, p.ProjectID); err != nil { + c, workspace, p.ProjectID, ""); err != nil { return err } } diff --git a/pkg/cmd/task/quick-add/quick-add.go b/pkg/cmd/task/quick-add/quick-add.go index d6989021..c41f5a77 100644 --- a/pkg/cmd/task/quick-add/quick-add.go +++ b/pkg/cmd/task/quick-add/quick-add.go @@ -61,7 +61,7 @@ func NewCmdQuickAdd( p, _ := cmd.Flags().GetString("project") if f.Config().IsAllowNameForID() { - if p, err = search.GetProjectByName(c, w, p); err != nil { + if p, err = search.GetProjectByName(c, w, p, ""); err != nil { return err } } diff --git a/pkg/cmd/task/util/read-flags.go b/pkg/cmd/task/util/read-flags.go index fd3f8f7e..7a002e2b 100644 --- a/pkg/cmd/task/util/read-flags.go +++ b/pkg/cmd/task/util/read-flags.go @@ -79,7 +79,7 @@ func TaskReadFlags(cmd *cobra.Command, f cmdutil.Factory) (p FlagsDTO, err error } if p.ProjectID, err = search.GetProjectByName( - c, p.Workspace, p.ProjectID); err != nil { + c, p.Workspace, p.ProjectID, ""); err != nil { return p, err } diff --git a/pkg/cmd/time-entry/in/in_test.go b/pkg/cmd/time-entry/in/in_test.go index eb65ebfe..cc08298f 100644 --- a/pkg/cmd/time-entry/in/in_test.go +++ b/pkg/cmd/time-entry/in/in_test.go @@ -2,6 +2,7 @@ package in_test import ( "bytes" + "errors" "io" "testing" "time" @@ -150,6 +151,157 @@ func TestNewCmdIn_ShouldNotSetBillable_WhenNotAsked(t *testing.T) { return } + t.Fatalf("err: %s", err) + }) + } +} + +func TestNewCmdIn_ShouldLookupProject_WithAndWithoutClient(t *testing.T) { + defaultStart := timehlp.Today().Add(8 * time.Hour) + + projects := []dto.Project{ + {ID: "p1", Name: "first", ClientID: "c1", ClientName: "other"}, + {ID: "p2", Name: "second", ClientID: "c2", ClientName: "me"}, + {ID: "p3", Name: "second", ClientID: "c3", ClientName: "clockify"}, + {ID: "p4", Name: "third"}, + {ID: "p5", Name: "notonclient", ClientID: "c3", ClientName: "clockify"}, + } + + tts := []struct { + name string + args []string + param api.CreateTimeEntryParam + err error + }{ + { + name: "only project", + args: []string{"-s=08:00", "-p=first"}, + param: api.CreateTimeEntryParam{ + Workspace: w.ID, + Start: defaultStart, + ProjectID: projects[0].ID, + }, + }, + { + name: "project and client", + args: []string{"-s=08:00", "-p=second", "-c=me"}, + param: api.CreateTimeEntryParam{ + Workspace: w.ID, + Start: defaultStart, + ProjectID: projects[1].ID, + }, + }, + { + name: "project and other client", + args: []string{"-s=08:00", "-p=second", "-c=clockify"}, + param: api.CreateTimeEntryParam{ + Workspace: w.ID, + Start: defaultStart, + ProjectID: projects[2].ID, + }, + }, + { + name: "project without client", + args: []string{"-s=08:00", "-p=third"}, + param: api.CreateTimeEntryParam{ + Workspace: w.ID, + Start: defaultStart, + ProjectID: projects[3].ID, + }, + }, + { + name: "project does not exist", + args: []string{"-s=08:00", "-p=notfound"}, + err: errors.New( + "No project with id or name containing 'notfound' " + + "was found"), + }, + { + name: "project does not exist in this client", + args: []string{"-s=08:00", "-p=notonclient", "-c=me"}, + err: errors.New( + "No project with id or name containing 'notonclient' " + + "was found for client 'me'"), + }, + } + + for i := range tts { + tt := &tts[i] + + t.Run(tt.name, func(t *testing.T) { + f := mocks.NewMockFactory(t) + + f.EXPECT().GetUserID().Return("u", nil) + f.EXPECT().GetWorkspaceID().Return(w.ID, nil) + + f.EXPECT().Config().Return(&mocks.SimpleConfig{ + AllowNameForID: true, + }) + + c := mocks.NewMockClient(t) + f.EXPECT().Client().Return(c, nil) + + c.EXPECT().GetProjects(api.GetProjectsParam{ + Workspace: w.ID, + PaginationParam: api.AllPages(), + }). + Return(projects, nil) + + c.EXPECT().GetTimeEntryInProgress(api.GetTimeEntryInProgressParam{ + Workspace: w.ID, + UserID: "u", + }). + Return(nil, nil) + + if tt.err == nil { + c.EXPECT().GetProject(api.GetProjectParam{ + Workspace: w.ID, + ProjectID: tt.param.ProjectID, + }). + Return(&dto.Project{ID: tt.param.ProjectID}, nil) + + f.EXPECT().GetWorkspace().Return(w, nil) + + c.EXPECT().Out(api.OutParam{ + Workspace: w.ID, + UserID: "u", + End: tt.param.Start, + }).Return(api.ErrorNotFound) + + c.EXPECT().CreateTimeEntry(tt.param). + Return(dto.TimeEntryImpl{ID: "te"}, 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(append(tt.args, "-q")) + _, err := cmd.ExecuteC() + + if tt.err != nil { + assert.EqualError(t, err, tt.err.Error()) + return + } + + t.Cleanup(func() { + assert.True(t, called) + }) + + if assert.NoError(t, err) { + return + } + t.Fatalf("err: %s", err) }) } diff --git a/pkg/cmd/time-entry/report/util/report.go b/pkg/cmd/time-entry/report/util/report.go index f48b53ff..aaa3f712 100644 --- a/pkg/cmd/time-entry/report/util/report.go +++ b/pkg/cmd/time-entry/report/util/report.go @@ -1,6 +1,7 @@ package util import ( + "errors" "io" "sort" "time" @@ -31,6 +32,7 @@ type ReportFlags struct { NotBillable bool Description string + Client string Project string TagIDs []string } @@ -41,6 +43,12 @@ func (rf ReportFlags) Check() error { return err } + if rf.Client != "" && rf.Project == "" { + return cmdutil.FlagErrorWrap(errors.New( + "flag 'client' can't be used without flag 'project'", + )) + } + return cmdutil.XorFlag(map[string]bool{ "billable": rf.Billable, "not-billable": rf.NotBillable, @@ -69,6 +77,10 @@ func AddReportFlags( "Will filter time entries using this project") _ = cmdcompl.AddSuggestionsToFlag(cmd, "project", cmdcomplutil.NewProjectAutoComplete(f)) + cmd.Flags().StringVarP(&rf.Client, "client", "c", "", + "Will filter projects from this client") + _ = cmdcompl.AddSuggestionsToFlag(cmd, "project", + cmdcomplutil.NewProjectAutoComplete(f)) cmd.Flags().StringSliceVarP(&rf.TagIDs, "tag", "T", []string{}, "Will filter time entries using these tags") _ = cmdcompl.AddSuggestionsToFlag(cmd, "tag", @@ -102,7 +114,7 @@ func ReportWithRange( if rf.Project != "" && f.Config().IsAllowNameForID() { if rf.Project, err = search.GetProjectByName( - c, workspace, rf.Project); err != nil { + c, workspace, rf.Project, rf.Client); err != nil { return err } } diff --git a/pkg/cmd/time-entry/report/util/report_flag_test.go b/pkg/cmd/time-entry/report/util/report_flag_test.go index 9c6f660f..01473210 100644 --- a/pkg/cmd/time-entry/report/util/report_flag_test.go +++ b/pkg/cmd/time-entry/report/util/report_flag_test.go @@ -7,15 +7,16 @@ import ( "github.com/stretchr/testify/assert" ) -func TestReportFlagsChecks(t *testing.T) { +func TestReportBillableFlagsChecks(t *testing.T) { rf := util.NewReportFlags() rf.Billable = true rf.NotBillable = true err := rf.Check() - assert.Error(t, err) - assert.Regexp(t, - "can't be used together.*billable.*not-billable", err.Error()) + if assert.Error(t, err) { + assert.Regexp(t, + "can't be used together.*billable.*not-billable", err.Error()) + } rf.Billable = false rf.NotBillable = true @@ -27,3 +28,25 @@ func TestReportFlagsChecks(t *testing.T) { assert.NoError(t, rf.Check()) } + +func TestReportProjectFlagsChecks(t *testing.T) { + rf := util.NewReportFlags() + rf.Client = "me" + rf.Project = "" + + err := rf.Check() + if assert.Error(t, err) { + assert.Equal(t, + "flag 'client' can't be used without flag 'project'", err.Error()) + } + + rf.Client = "" + rf.Project = "mine" + + assert.NoError(t, rf.Check()) + + rf.Client = "me" + rf.Project = "mine" + + assert.NoError(t, rf.Check()) +} diff --git a/pkg/cmd/time-entry/report/util/reportwithrange_test.go b/pkg/cmd/time-entry/report/util/reportwithrange_test.go index c92fca2f..1a5493c8 100644 --- a/pkg/cmd/time-entry/report/util/reportwithrange_test.go +++ b/pkg/cmd/time-entry/report/util/reportwithrange_test.go @@ -135,6 +135,71 @@ func TestReportWithRange(t *testing.T) { }, err: "No project.*wrong' was found", }, + { + name: "invalid client", + factory: func(t *testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + f.On("GetUserID").Return("u", nil) + f.On("GetWorkspaceID").Return("w", nil) + + cf := mocks.NewMockConfig(t) + f.On("Config").Return(cf) + cf.On("IsAllowNameForID").Return(true) + + c := mocks.NewMockClient(t) + f.On("Client").Return(c, nil) + + c.On("GetProjects", api.GetProjectsParam{ + Workspace: "w", + PaginationParam: api.AllPages(), + }).Return([]dto.Project{{Name: "right"}}, nil) + + return f + }, + flags: func(t *testing.T) util.ReportFlags { + rf := util.NewReportFlags() + rf.Client = "right" + rf.Project = "wrong" + return rf + }, + err: "No client.*right' was found", + }, + { + name: "invalid project for client", + factory: func(t *testing.T) cmdutil.Factory { + f := mocks.NewMockFactory(t) + f.On("GetUserID").Return("u", nil) + f.On("GetWorkspaceID").Return("w", nil) + + cf := mocks.NewMockConfig(t) + f.On("Config").Return(cf) + cf.On("IsAllowNameForID").Return(true) + + c := mocks.NewMockClient(t) + f.On("Client").Return(c, nil) + + c.On("GetProjects", api.GetProjectsParam{ + Workspace: "w", + PaginationParam: api.AllPages(), + }). + Return( + []dto.Project{{ + Name: "right", + ClientName: "right", + ClientID: "r1", + }}, + nil) + + return f + }, + flags: func(t *testing.T) util.ReportFlags { + rf := util.NewReportFlags() + rf.Client = "right" + rf.Project = "wrong" + return rf + }, + err: "No project.*wrong' was found for client 'right'", + }, { name: "range http error", factory: func(t *testing.T) cmdutil.Factory { @@ -152,7 +217,21 @@ func TestReportWithRange(t *testing.T) { c.On("GetProjects", api.GetProjectsParam{ Workspace: "w", PaginationParam: api.AllPages(), - }).Return([]dto.Project{{ID: "p", Name: "right"}}, nil) + }). + Return([]dto.Project{ + { + ID: "p", + Name: "right", + ClientName: "right", + ClientID: "c1", + }, + { + ID: "p", + Name: "right", + ClientName: "wrong", + ClientID: "c2", + }, + }, nil) c.On("LogRange", api.LogRangeParam{ Workspace: "w", @@ -169,6 +248,7 @@ func TestReportWithRange(t *testing.T) { flags: func(t *testing.T) util.ReportFlags { rf := util.NewReportFlags() rf.Project = "right" + rf.Client = "right" return rf }, err: "http error", diff --git a/pkg/cmd/time-entry/util/fill-with-flags.go b/pkg/cmd/time-entry/util/fill-with-flags.go index 37c03ce6..285c8a59 100644 --- a/pkg/cmd/time-entry/util/fill-with-flags.go +++ b/pkg/cmd/time-entry/util/fill-with-flags.go @@ -29,6 +29,14 @@ func FillTimeEntryWithFlags(flags flagSet) Step { dto.TaskID = "" } dto.ProjectID = p + + if flags.Changed("client") { + c, _ := flags.GetString("client") + if c != dto.Client { + dto.TaskID = "" + } + dto.Client = c + } } if flags.Changed("description") { diff --git a/pkg/cmd/time-entry/util/flags.go b/pkg/cmd/time-entry/util/flags.go index beba4002..85a13918 100644 --- a/pkg/cmd/time-entry/util/flags.go +++ b/pkg/cmd/time-entry/util/flags.go @@ -29,6 +29,10 @@ func AddTimeEntryFlags( cmd.Flags().BoolP("allow-incomplete", "A", false, "allow creation of incomplete time entries to be edited later") + cmd.Flags().StringP("client", "c", "", "client of the project to use for time entry") + _ = cmdcompl.AddSuggestionsToFlag(cmd, "client", + cmdcomplutil.NewClientAutoComplete(f)) + cmd.Flags().StringP("project", "p", "", "project to use for time entry") _ = cmdcompl.AddSuggestionsToFlag(cmd, "project", cmdcomplutil.NewProjectAutoComplete(f)) diff --git a/pkg/cmd/time-entry/util/name-for-id.go b/pkg/cmd/time-entry/util/name-for-id.go index 4b7e8652..05e0407e 100644 --- a/pkg/cmd/time-entry/util/name-for-id.go +++ b/pkg/cmd/time-entry/util/name-for-id.go @@ -34,7 +34,7 @@ func lookupProject(c api.Client) Step { var err error te.ProjectID, err = search.GetProjectByName( - c, te.Workspace, te.ProjectID) + c, te.Workspace, te.ProjectID, te.Client) return te, err } diff --git a/pkg/cmd/time-entry/util/util.go b/pkg/cmd/time-entry/util/util.go index 8681c444..e5df3f08 100644 --- a/pkg/cmd/time-entry/util/util.go +++ b/pkg/cmd/time-entry/util/util.go @@ -15,6 +15,7 @@ type TimeEntryDTO struct { Workspace string UserID string ProjectID string + Client string TaskID string Description string Start time.Time diff --git a/pkg/search/errors.go b/pkg/search/errors.go index 83970563..bd54e36b 100644 --- a/pkg/search/errors.go +++ b/pkg/search/errors.go @@ -1,12 +1,37 @@ package search +import ( + "sort" + + "github.com/lucassabreu/clockify-cli/strhlp" +) + // ErrNotFound represents a fail to identify a entity by its name or id type ErrNotFound struct { EntityName string Reference string + Filters map[string]string } func (e ErrNotFound) Error() string { + sufix := "" + if len(e.Filters) > 0 { + sufix = " for " + keys := make([]string, len(e.Filters)) + i := 0 + for k := range e.Filters { + keys[i] = k + i++ + } + + sort.Strings(keys) + for i := range keys { + keys[i] = keys[i] + " '" + e.Filters[keys[i]] + "'" + } + + sufix = sufix + strhlp.ListForHumans(keys) + } + return "No " + e.EntityName + " with id or name containing '" + - e.Reference + "' was found" + e.Reference + "' was found" + sufix } diff --git a/pkg/search/project.go b/pkg/search/project.go index 827811e2..cd63fa77 100644 --- a/pkg/search/project.go +++ b/pkg/search/project.go @@ -2,41 +2,104 @@ package search import ( "github.com/lucassabreu/clockify-cli/api" + "github.com/lucassabreu/clockify-cli/api/dto" "github.com/pkg/errors" "golang.org/x/sync/errgroup" ) func GetProjectByName( c api.Client, - workspace, + workspace string, project string, + client string, ) (string, error) { - id, err := findByName(project, "project", func() ([]named, error) { - ps, err := c.GetProjects(api.GetProjectsParam{ - Workspace: workspace, - PaginationParam: api.AllPages(), - }) - if err != nil { - return []named{}, err - } + ps, err := c.GetProjects(api.GetProjectsParam{ + Workspace: workspace, + PaginationParam: api.AllPages(), + }) + if err != nil { + return "", err + } + if ps, err = filterClientProjects(ps, client); err != nil { + return "", err + } + + id, err := findByName(project, "project", func() ([]named, error) { ns := make([]named, len(ps)) - for i := 0; i < len(ns); i++ { + for i := 0; i < len(ps); i++ { ns[i] = ps[i] } return ns, nil }) - if errors.Is(err, ErrEmptyReference) { - return id, errors.New( - "no project with id or name containing \"" + - project + "\" was not found") + var eNotFound ErrNotFound + if errors.As(err, &eNotFound) { + if client == "" { + return id, err + } + + return id, ErrNotFound{ + EntityName: eNotFound.EntityName, + Reference: eNotFound.Reference, + Filters: map[string]string{ + "client": client, + }, + } } return id, err } +type namedStruct struct { + ID string + Name string +} + +func (c namedStruct) GetID() string { + return c.ID +} + +func (c namedStruct) GetName() string { + return c.Name +} + +func filterClientProjects( + ps []dto.Project, + client string, +) ([]dto.Project, error) { + if client == "" { + return ps, nil + } + + clients := make([]named, len(ps)) + for i := 0; i < len(ps); i++ { + clients[i] = namedStruct{ + ID: ps[i].ClientID, + Name: ps[i].ClientName, + } + } + + id, err := findByName(client, "client", + func() ([]named, error) { return clients, nil }) + + if err != nil { + return ps, err + } + + fPs := make([]dto.Project, 0) + for i := 0; i < len(ps); i++ { + if ps[i].ClientID != id { + continue + } + + fPs = append(fPs, ps[i]) + } + + return fPs, nil +} + // GetProjectsByName will try to find projects containing the string on its // name or id that matches the value func GetProjectsByName(