diff --git a/projects/task.go b/projects/task.go index 64e6412..d29c35d 100644 --- a/projects/task.go +++ b/projects/task.go @@ -223,19 +223,55 @@ func (t TaskAttachments) IsZero() bool { return len(t.Files) == 0 && len(t.PendingFiles) == 0 } -// TaskWorkflowStage represents the workflow stage associated with a task. This -// is used when creating or updating a task to set the workflow stage of the -// task. +// TaskWorkflowStage reports which stage of a workflow a task sits in. It is the +// response shape, carried by Task.WorkflowStages; the request side is +// TaskWorkflows, whose fields differ. type TaskWorkflowStage struct { // WorkflowID is the unique identifier of the workflow associated with the // task. WorkflowID *int64 `json:"workflowId"` // StageID is the unique identifier of the workflow stage associated with the - // task. + // task. A task in the workflow's backlog reports 0. StageID *int64 `json:"stageId"` } +// TaskWorkflows places a task in a stage of a workflow while creating or +// updating it, saving the follow-up request WorkflowStageTaskMove would cost. +// +// It reaches the endpoint as a "workflows" object beside "task", and both +// identifiers are needed: a stage without its workflow is ignored. The endpoints +// treat it differently, and neither says so in the response. +// +// On create, the task joins every workflow attached to its project, in the +// backlog. This moves it out of the backlog of the one workflow it names -- +// silently doing nothing at all if that workflow is not attached to the project, +// so the workflow has to be one of the project's own. +// +// On update, the task must already be in the named workflow or the endpoint +// answers 404. Unlike the routes behind WorkflowStageTaskMove, neither endpoint +// asks for permission to edit the workflow; the task's own edit permission is +// what is checked. +type TaskWorkflows struct { + // WorkflowID is the identifier of the workflow that owns the stage. Required + // alongside StageID. + WorkflowID *int64 `json:"workflowId,omitempty"` + + // StageID is the identifier of the stage to place the task in. Zero, or unset, + // leaves the task in the backlog. + StageID *int64 `json:"stageId,omitempty"` + + // PositionAfterTaskID is the identifier of the task after which this one is + // placed within the stage. Unset appends it to the end. + PositionAfterTaskID *int64 `json:"positionAfterTask,omitempty"` +} + +// IsZero reports whether no workflow placement was requested, so that the field +// is omitted from the request instead of being sent as an empty object. +func (t TaskWorkflows) IsZero() bool { + return t.WorkflowID == nil && t.StageID == nil && t.PositionAfterTaskID == nil +} + // TaskUpdateRequestPath contains the path parameters for creating a // task. type TaskCreateRequestPath struct { @@ -297,6 +333,9 @@ type TaskCreateRequest struct { // exist in the project, files uploaded with PendingFileCreate, or both. Attachments TaskAttachments `json:"-"` + // Workflows places the task in a stage of one of its project's workflows. + Workflows TaskWorkflows `json:"-"` + // ChangeFollowers is the list of users, teams or clients/companies that will // receive notifications when the task is updated. ChangeFollowers UserGroups `json:"changeFollowers,omitzero"` @@ -330,11 +369,13 @@ func (t TaskCreateRequest) HTTPRequest(ctx context.Context, server string) (*htt Options TaskOptions `json:"taskOptions"` Predecessors []TaskPredecessor `json:"predecessors,omitempty"` Attachments TaskAttachments `json:"attachments,omitzero"` + Workflows TaskWorkflows `json:"workflows,omitzero"` }{ Task: t, Options: t.Options, Predecessors: t.Predecessors, Attachments: t.Attachments, + Workflows: t.Workflows, } var body bytes.Buffer @@ -455,6 +496,9 @@ type TaskUpdateRequest struct { // additive, so files already attached to the task are left alone. Attachments TaskAttachments `json:"-"` + // Workflows moves the task to a stage of a workflow it is already in. + Workflows TaskWorkflows `json:"-"` + // ChangeFollowers is the list of users, teams or clients/companies that will // receive notifications when the task is updated. ChangeFollowers *UserGroups `json:"changeFollowers,omitempty"` @@ -487,11 +531,13 @@ func (t TaskUpdateRequest) HTTPRequest(ctx context.Context, server string) (*htt Options TaskOptions `json:"taskOptions"` Predecessors []TaskPredecessor `json:"predecessors,omitempty"` Attachments TaskAttachments `json:"attachments,omitzero"` + Workflows TaskWorkflows `json:"workflows,omitzero"` }{ Task: t, Options: t.Options, Predecessors: t.Predecessors, Attachments: t.Attachments, + Workflows: t.Workflows, } var body bytes.Buffer diff --git a/projects/task_test.go b/projects/task_test.go index 7cf53b6..cb25f27 100644 --- a/projects/task_test.go +++ b/projects/task_test.go @@ -700,3 +700,92 @@ func TestTaskSubTaskIDsAreDecoded(t *testing.T) { t.Errorf("unexpected subtask IDs: %v", resp.Task.SubTaskIDs) } } + +// TestTaskWorkflowsRequestGeneration pins where the workflows block lands and +// that it disappears when unset. It rides beside "task" rather than inside it, +// and an empty object is not inert on every endpoint, so sending one on an +// unrelated update would be a change of meaning rather than a wasted field. +func TestTaskWorkflowsRequestGeneration(t *testing.T) { + placement := projects.TaskWorkflows{ + WorkflowID: new(int64(123)), + StageID: new(int64(456)), + PositionAfterTaskID: new(int64(789)), + } + + tests := []struct { + name string + request twapi.HTTPRequester + want *projects.TaskWorkflows + }{{ + name: "create without a placement", + request: projects.NewTaskCreateRequest(888, "test"), + }, { + name: "create with a placement", + request: func() projects.TaskCreateRequest { + req := projects.NewTaskCreateRequest(888, "test") + req.Workflows = placement + return req + }(), + want: &placement, + }, { + name: "update without a placement", + request: projects.NewTaskUpdateRequest(12345), + }, { + name: "update with a placement", + request: func() projects.TaskUpdateRequest { + req := projects.NewTaskUpdateRequest(12345) + req.Workflows = placement + return req + }(), + want: &placement, + }} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + httpReq, err := tt.request.HTTPRequest(context.Background(), "https://test.teamwork.com") + if err != nil { + t.Fatalf("unexpected error creating HTTP request: %s", err) + } + body, err := io.ReadAll(httpReq.Body) + if err != nil { + t.Fatalf("failed to read request body: %s", err) + } + + var payload struct { + Task map[string]any `json:"task"` + Workflows *projects.TaskWorkflows `json:"workflows"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to decode request body %q: %s", body, err) + } + + // A "workflows" key inside "task" is ignored by the endpoint, so the + // sibling position is the whole point. + if _, ok := payload.Task["workflows"]; ok { + t.Errorf("workflows must sit beside task, not inside it (body %q)", body) + } + + if tt.want == nil { + if payload.Workflows != nil { + t.Errorf("expected workflows to be omitted but got %+v (body %q)", *payload.Workflows, body) + } + return + } + if payload.Workflows == nil { + t.Fatalf("expected workflows to reach the wire (body %q)", body) + } + for _, field := range []struct { + name string + got, want *int64 + }{ + {"workflowId", payload.Workflows.WorkflowID, tt.want.WorkflowID}, + {"stageId", payload.Workflows.StageID, tt.want.StageID}, + {"positionAfterTask", payload.Workflows.PositionAfterTaskID, tt.want.PositionAfterTaskID}, + } { + if field.got == nil || *field.got != *field.want { + t.Errorf("expected %s %d but got %v (body %q)", field.name, *field.want, field.got, body) + } + } + }) + } +}