From 00aadb9cc4123da06992098ff19b25da53d46819 Mon Sep 17 00:00:00 2001 From: Lucas dos Santos Abreu Date: Thu, 8 Jul 2021 21:15:58 -0300 Subject: [PATCH] (release): v0.18.0 * (fix): none project option and incomplete time entry * (fix): remove no-closing * (feat): validating time entries before creation * (ref): deduplicating edit and newEntry * (release): v0.18.0 --- CHANGELOG.md | 24 ++++++++++++ api/client.go | 19 +++++++++ cmd/clone.go | 11 +++++- cmd/common.go | 104 +++++++++++++++++++++++++++++++++++--------------- cmd/config.go | 22 +++++------ cmd/edit.go | 55 ++++++++++++-------------- cmd/in.go | 16 +++++++- cmd/manual.go | 12 +++++- cmd/root.go | 18 ++++----- 9 files changed, 196 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 653253bb..b40d0fd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [v0.18.0] - 2021-07-08 + +### Added + +- commands `in`, `clone` and `manual` will show a new "None" option on the projects list on the + interactive mode if the workspace allows time entries without projects. +- config `allow-incomplete` allows the user to set if they want to create "incomplete time entries" + or to validated then before creation. Flag `--allow-incomplete` and environment variable + `CLOCKIFY_ALLOW_INCOMPLETE` can be used for the same purpose. by default time entries will be + validated. + +### Changed + +- commands `in` and `clone` when creating an "open" time entry will not validate if the workspace + requires a project or not, allowing the creation of open incomplete/invalid time entries, similar + to the browser application. +- `newEntry` function changed to `manageEntry` and will allow a callback to deal with the filled and + validated time entry instead of always creating a new one, that way same code that were duplicated + between it and the `edit` command can be united. + +### Fixed + +- `no-closing` configuration was removed, because was not used anymore. + ## [v0.17.2] - 2021-06-17 ### Fixed diff --git a/api/client.go b/api/client.go index 795cfbb2..62ad3744 100644 --- a/api/client.go +++ b/api/client.go @@ -86,6 +86,25 @@ func (c *Client) GetWorkspaces(f GetWorkspaces) ([]dto.Workspace, error) { return ws, nil } +type GetWorkspace struct { + ID string +} + +func (c *Client) GetWorkspace(p GetWorkspace) (dto.Workspace, error) { + ws, err := c.GetWorkspaces(GetWorkspaces{}) + if err != nil { + return dto.Workspace{}, err + } + + for _, w := range ws { + if w.ID == p.ID { + return w, nil + } + } + + return dto.Workspace{}, dto.Error{Message: "not found", Code: 404} +} + // WorkspaceUsersParam params to query workspace users type WorkspaceUsersParam struct { Workspace string diff --git a/cmd/clone.go b/cmd/clone.go index f61e342f..20d44776 100644 --- a/cmd/clone.go +++ b/cmd/clone.go @@ -76,7 +76,16 @@ var cloneCmd = &cobra.Command{ format, _ := cmd.Flags().GetString("format") noClosing, _ := cmd.Flags().GetBool("no-closing") asJSON, _ := cmd.Flags().GetBool("json") - return newEntry(c, tec, viper.GetBool(INTERACTIVE), viper.GetBool(ALLOW_PROJECT_NAME), !noClosing, format, asJSON) + return manageEntry( + c, + tec, + createTimeEntry(c), + viper.GetBool(INTERACTIVE), + viper.GetBool(ALLOW_PROJECT_NAME), + !noClosing, + format, asJSON, + !viper.GetBool(ALLOW_INCOMPLETE), + ) }), } diff --git a/cmd/common.go b/cmd/common.go index 127b70ce..5a9efadf 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -125,15 +125,12 @@ func getProjectByNameOrId(c *api.Client, workspace, project string) (string, err return "", stackedErrors.Errorf("No project with id or name containing: %s", project) } -func confirmEntryInteractively(c *api.Client, te dto.TimeEntryImpl) (dto.TimeEntryImpl, error) { +func confirmEntryInteractively(c *api.Client, te dto.TimeEntryImpl, w dto.Workspace) (dto.TimeEntryImpl, error) { var err error - te.ProjectID, err = getProjectID(te.ProjectID, te.WorkspaceID, c) + te.ProjectID, err = getProjectID(te.ProjectID, w, c) if err != nil { return te, err } - if te.ProjectID == "" { - return te, errors.New("project must be informed") - } te.Description = getDescription(te.Description) @@ -163,6 +160,23 @@ func confirmEntryInteractively(c *api.Client, te dto.TimeEntryImpl) (dto.TimeEnt return te, nil } +func validateTimeEntry(te dto.TimeEntryImpl, w dto.Workspace) error { + + if w.Settings.ForceProjects && te.ProjectID == "" { + return errors.New("workspace requires project") + } + + if w.Settings.ForceDescription && strings.TrimSpace(te.Description) == "" { + return errors.New("workspace requires description") + } + + if w.Settings.ForceTags && len(te.TagIDs) == 0 { + return errors.New("workspace requires at least one tag") + } + + return nil +} + func printTimeEntryImpl(c *api.Client, tei dto.TimeEntryImpl, asJSON bool, format string) error { fte, err := c.ConvertIntoFullTimeEntry(tei) if err != nil { @@ -184,7 +198,17 @@ func printTimeEntryImpl(c *api.Client, tei dto.TimeEntryImpl, asJSON bool, forma return reportFn(&fte, os.Stdout) } -func newEntry(c *api.Client, te dto.TimeEntryImpl, interactive, allowProjectByName, autoClose bool, format string, asJSON bool) error { +func manageEntry( + c *api.Client, + te dto.TimeEntryImpl, + callback func(dto.TimeEntryImpl) (dto.TimeEntryImpl, error), + interactive, + allowProjectByName, + autoClose bool, + format string, + asJSON bool, + validate bool, +) error { var err error if allowProjectByName && te.ProjectID != "" { @@ -194,37 +218,33 @@ func newEntry(c *api.Client, te dto.TimeEntryImpl, interactive, allowProjectByNa } } - if interactive { - te, err = confirmEntryInteractively(c, te) + if interactive || validate { + w, err := c.GetWorkspace(api.GetWorkspace{ID: te.WorkspaceID}) if err != nil { return err } - } else if te.ProjectID == "" { - return errors.New("project must be informed") + + if interactive { + te, err = confirmEntryInteractively(c, te, w) + if err != nil { + return err + } + } + + if validate { + if err = validateTimeEntry(te, w); err != nil { + return err + } + } } if autoClose { - err = c.Out(api.OutParam{ - Workspace: te.WorkspaceID, - End: te.TimeInterval.Start, - }) - - if err != nil { + if err = c.Out(api.OutParam{Workspace: te.WorkspaceID, End: te.TimeInterval.Start}); err != nil { return err } } - tei, err := c.CreateTimeEntry(api.CreateTimeEntryParam{ - Workspace: te.WorkspaceID, - Billable: te.Billable, - Start: te.TimeInterval.Start, - End: te.TimeInterval.End, - ProjectID: te.ProjectID, - Description: te.Description, - TagIDs: te.TagIDs, - TaskID: te.TaskID, - }) - + tei, err := callback(te) if err != nil { return err } @@ -232,9 +252,26 @@ func newEntry(c *api.Client, te dto.TimeEntryImpl, interactive, allowProjectByNa return printTimeEntryImpl(c, tei, asJSON, format) } -func getProjectID(projectID string, workspace string, c *api.Client) (string, error) { +func createTimeEntry(c *api.Client) func(dto.TimeEntryImpl) (dto.TimeEntryImpl, error) { + return func(te dto.TimeEntryImpl) (dto.TimeEntryImpl, error) { + return c.CreateTimeEntry(api.CreateTimeEntryParam{ + Workspace: te.WorkspaceID, + Billable: te.Billable, + Start: te.TimeInterval.Start, + End: te.TimeInterval.End, + ProjectID: te.ProjectID, + Description: te.Description, + TagIDs: te.TagIDs, + TaskID: te.TaskID, + }) + } +} + +const noProject = "No Project" + +func getProjectID(projectID string, w dto.Workspace, c *api.Client) (string, error) { projects, err := c.GetProjects(api.GetProjectsParam{ - Workspace: workspace, + Workspace: w.ID, PaginationParam: api.PaginationParam{AllPages: true}, }) @@ -282,8 +319,13 @@ func getProjectID(projectID string, workspace string, c *api.Client) (string, er projectID = projectsString[found] } - if projectID, err = ui.AskFromOptions("Choose your project:", projectsString, projectID); err != nil || projectID == "" { - return "", nil + if !w.Settings.ForceProjects { + projectsString = append([]string{noProject}, projectsString...) + } + + 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 diff --git a/cmd/config.go b/cmd/config.go index 90aea603..734211b6 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -36,11 +36,11 @@ import ( const ( WORKWEEK_DAYS = "workweek-days" INTERACTIVE = "interactive" - NO_CLOSING = "no-closing" ALLOW_PROJECT_NAME = "allow-project-name" USER_ID = "user.id" WORKSPACE = "workspace" TOKEN = "token" + ALLOW_INCOMPLETE = "allow-incomplete" ) var configValidArgs = completion.ValigsArgsMap{ @@ -48,9 +48,9 @@ var configValidArgs = completion.ValigsArgsMap{ WORKSPACE: "workspace to be used", USER_ID: "user id from the token", ALLOW_PROJECT_NAME: "should allow use of project when id is asked", - NO_CLOSING: "should not close any active time entry", INTERACTIVE: "show interactive mode", WORKWEEK_DAYS: "days of the week were your expected to work (use comma to set multiple)", + ALLOW_INCOMPLETE: "should allow starting time entries with missing required values", } var weekdays []string @@ -205,15 +205,6 @@ func configInit(cmd *cobra.Command, args []string) error { } viper.Set(ALLOW_PROJECT_NAME, allowProjectName) - autoClose := !viper.GetBool(NO_CLOSING) - if autoClose, err = ui.Confirm( - `Should auto-close previous/current time entry before opening a new one?`, - autoClose, - ); err != nil { - return err - } - viper.Set(NO_CLOSING, !autoClose) - interactive := viper.GetBool(INTERACTIVE) if interactive, err = ui.Confirm( `Should use "Interactive Mode" by default?`, @@ -233,6 +224,15 @@ func configInit(cmd *cobra.Command, args []string) error { } viper.Set(WORKWEEK_DAYS, workweekDays) + allowIncomplete := viper.GetBool(ALLOW_INCOMPLETE) + if allowIncomplete, err = ui.Confirm( + `Should allow starting time entries with incomplete data?`, + allowIncomplete, + ); err != nil { + return err + } + viper.Set(ALLOW_INCOMPLETE, allowIncomplete) + return configSaveFile() } diff --git a/cmd/edit.go b/cmd/edit.go index 6c7c5e3f..7fb789a0 100644 --- a/cmd/edit.go +++ b/cmd/edit.go @@ -18,6 +18,7 @@ import ( "time" "github.com/lucassabreu/clockify-cli/api" + "github.com/lucassabreu/clockify-cli/api/dto" "github.com/lucassabreu/clockify-cli/cmd/completion" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -32,7 +33,6 @@ var editCmd = &cobra.Command{ Short: `Edit a time entry, use id "current" to apply to time entry in progress`, RunE: withClockifyClient(func(cmd *cobra.Command, args []string, c *api.Client) error { var err error - interactive := viper.GetBool(INTERACTIVE) userID, err := getUserId(c) if err != nil { @@ -52,12 +52,6 @@ var editCmd = &cobra.Command{ if cmd.Flags().Changed("project") { tei.ProjectID, _ = cmd.Flags().GetString("project") - if viper.GetBool(ALLOW_PROJECT_NAME) && tei.ProjectID != "" { - tei.ProjectID, err = getProjectByNameOrId(c, tei.WorkspaceID, tei.ProjectID) - if err != nil && !interactive { - return err - } - } } if cmd.Flags().Changed("description") { @@ -95,32 +89,31 @@ var editCmd = &cobra.Command{ tei.TimeInterval.End = &v } - if interactive { - tei, err = confirmEntryInteractively(c, tei) - if err != nil { - return err - } - } - - tei, err = c.UpdateTimeEntry(api.UpdateTimeEntryParam{ - Workspace: tei.WorkspaceID, - TimeEntryID: tei.ID, - Description: tei.Description, - Start: tei.TimeInterval.Start, - End: tei.TimeInterval.End, - Billable: tei.Billable, - ProjectID: tei.ProjectID, - TaskID: tei.TaskID, - TagIDs: tei.TagIDs, - }) - - if err != nil { - return err - } - format, _ := cmd.Flags().GetString("format") asJSON, _ := cmd.Flags().GetBool("json") - return printTimeEntryImpl(c, tei, asJSON, format) + return manageEntry( + c, + tei, + func(tei dto.TimeEntryImpl) (dto.TimeEntryImpl, error) { + return c.UpdateTimeEntry(api.UpdateTimeEntryParam{ + Workspace: tei.WorkspaceID, + TimeEntryID: tei.ID, + Description: tei.Description, + Start: tei.TimeInterval.Start, + End: tei.TimeInterval.End, + Billable: tei.Billable, + ProjectID: tei.ProjectID, + TaskID: tei.TaskID, + TagIDs: tei.TagIDs, + }) + }, + viper.GetBool(INTERACTIVE), + viper.GetBool(ALLOW_PROJECT_NAME), + false, + format, + asJSON, + !viper.GetBool(ALLOW_INCOMPLETE), + ) }), } diff --git a/cmd/in.go b/cmd/in.go index 2356d7f4..9ccec33a 100644 --- a/cmd/in.go +++ b/cmd/in.go @@ -78,7 +78,17 @@ var inCmd = &cobra.Command{ format, _ := cmd.Flags().GetString("format") asJSON, _ := cmd.Flags().GetBool("json") - return newEntry(c, tei, viper.GetBool(INTERACTIVE), viper.GetBool(ALLOW_PROJECT_NAME), true, format, asJSON) + return manageEntry( + c, + tei, + createTimeEntry(c), + viper.GetBool(INTERACTIVE), + viper.GetBool(ALLOW_PROJECT_NAME), + true, + format, + asJSON, + !viper.GetBool(ALLOW_INCOMPLETE), + ) }), } @@ -91,6 +101,10 @@ func addTimeEntryFlags(cmd *cobra.Command) { cmd.Flags().StringVar(&whenString, "when", time.Now().Format(fullTimeFormat), "when the entry should be started, if not informed will use current time") cmd.Flags().StringVar(&whenToCloseString, "when-to-close", "", "when the entry should be closed, if not informed will let it open") + + cmd.Flags().BoolP(ALLOW_INCOMPLETE, "", false, "allow creation of incomplete time entries to be edited later (defaults to env $"+ENV_PREFIX+"_ALLOW_INCOMPLETE)") + _ = viper.BindPFlag(ALLOW_INCOMPLETE, cmd.Flags().Lookup(ALLOW_INCOMPLETE)) + _ = viper.BindEnv(ALLOW_INCOMPLETE, ENV_PREFIX+"_ALLOW_INCOMPLETE") } func init() { diff --git a/cmd/manual.go b/cmd/manual.go index dcf17333..75f34bc1 100644 --- a/cmd/manual.go +++ b/cmd/manual.go @@ -67,7 +67,17 @@ var manualCmd = &cobra.Command{ format, _ := cmd.Flags().GetString("format") asJSON, _ := cmd.Flags().GetBool("json") - return newEntry(c, tei, viper.GetBool(INTERACTIVE), viper.GetBool(ALLOW_PROJECT_NAME), false, format, asJSON) + return manageEntry( + c, + tei, + createTimeEntry(c), + viper.GetBool(INTERACTIVE), + viper.GetBool(ALLOW_PROJECT_NAME), + false, + format, + asJSON, + true, + ) }), } diff --git a/cmd/root.go b/cmd/root.go index 098b45b6..2ba3da97 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -59,12 +59,12 @@ func Execute(v, c, d string) { } const USER_ID_FLAG = "user-id" +const ENV_PREFIX = "CLOCKIFY" func init() { cobra.OnInitialize(initConfig) - envPrefix := "CLOCKIFY" - viper.SetEnvPrefix(envPrefix) + viper.SetEnvPrefix(ENV_PREFIX) // Here you will define your flags and configuration settings. // Cobra supports persistent flags, which, if defined here, @@ -72,28 +72,28 @@ func init() { rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.clockify-cli.yaml)") rootCmd.PersistentFlags().StringP(TOKEN, "t", "", - "clockify's token (defaults to env $"+envPrefix+"_TOKEN)\n"+ + "clockify's token (defaults to env $"+ENV_PREFIX+"_TOKEN)\n"+ "\tCan be generated here: https://clockify.me/user/settings#generateApiKeyBtn", ) _ = viper.BindPFlag(TOKEN, rootCmd.PersistentFlags().Lookup(TOKEN)) - rootCmd.PersistentFlags().StringP(WORKSPACE, "w", "", "workspace to be used (defaults to env $"+envPrefix+"_WORKSPACE)") + rootCmd.PersistentFlags().StringP(WORKSPACE, "w", "", "workspace to be used (defaults to env $"+ENV_PREFIX+"_WORKSPACE)") _ = viper.BindPFlag(WORKSPACE, rootCmd.PersistentFlags().Lookup(WORKSPACE)) _ = completion.AddSuggestionsToFlag(rootCmd, WORKSPACE, suggestWithClientAPI(suggestWorkspaces)) - rootCmd.PersistentFlags().StringP(USER_ID_FLAG, "u", "", "user id from the token (defaults to env $"+envPrefix+"_USER_ID)") + rootCmd.PersistentFlags().StringP(USER_ID_FLAG, "u", "", "user id from the token (defaults to env $"+ENV_PREFIX+"_USER_ID)") _ = viper.BindPFlag(USER_ID, rootCmd.PersistentFlags().Lookup(USER_ID_FLAG)) _ = completion.AddSuggestionsToFlag(rootCmd, USER_ID, suggestWithClientAPI(suggestUsers)) - rootCmd.PersistentFlags().Bool("debug", false, "show debug log (defaults to env $"+envPrefix+"_DEBUG)") + rootCmd.PersistentFlags().Bool("debug", false, "show debug log (defaults to env $"+ENV_PREFIX+"_DEBUG)") _ = viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug")) - rootCmd.PersistentFlags().BoolP(INTERACTIVE, "i", false, "show interactive log (defaults to env $"+envPrefix+"_INTERACTIVE)") + rootCmd.PersistentFlags().BoolP(INTERACTIVE, "i", false, "show interactive log (defaults to env $"+ENV_PREFIX+"_INTERACTIVE)") _ = viper.BindPFlag(INTERACTIVE, rootCmd.PersistentFlags().Lookup(INTERACTIVE)) - rootCmd.PersistentFlags().BoolP(ALLOW_PROJECT_NAME, "", false, "allow use of project name when id is asked (defaults to env $"+envPrefix+"_ALLOW_PROJECT_NAME)") + rootCmd.PersistentFlags().BoolP(ALLOW_PROJECT_NAME, "", false, "allow use of project name when id is asked (defaults to env $"+ENV_PREFIX+"_ALLOW_PROJECT_NAME)") _ = viper.BindPFlag(ALLOW_PROJECT_NAME, rootCmd.PersistentFlags().Lookup(ALLOW_PROJECT_NAME)) - _ = viper.BindEnv(ALLOW_PROJECT_NAME, envPrefix+"_ALLOW_PROJECT_NAME") + _ = viper.BindEnv(ALLOW_PROJECT_NAME, ENV_PREFIX+"_ALLOW_PROJECT_NAME") _ = rootCmd.MarkFlagRequired(TOKEN)