From 3c2be844e4f7a2de7b112f962998c50bd196f2ae Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Thu, 15 Jan 2026 18:06:56 +0100 Subject: [PATCH] Remove mandatory task from measure Signed-off-by: Sacha Al Himdani --- .../components/form/MeasureSelectField.tsx | 25 ++++++++-- .../src/components/tasks/TaskFormDialog.tsx | 20 +++++--- .../TaskFormDialogUpdateMutation.graphql.ts | 3 +- e2e/console/task_test.go | 49 +++++++++++++++++++ e2e/internal/factory/factory.go | 14 ++++-- pkg/probo/task_service.go | 14 ++++++ pkg/server/api/console/v1/schema.graphql | 1 + pkg/server/api/console/v1/schema/schema.go | 10 +++- pkg/server/api/console/v1/types/types.go | 1 + pkg/server/api/console/v1/v1_resolver.go | 7 ++- pkg/server/api/mcp/v1/schema.resolvers.go | 2 + pkg/server/api/mcp/v1/specification.yaml | 16 ++++++ pkg/server/api/mcp/v1/types/types.go | 6 ++- 13 files changed, 151 insertions(+), 17 deletions(-) diff --git a/apps/console/src/components/form/MeasureSelectField.tsx b/apps/console/src/components/form/MeasureSelectField.tsx index 60a7b9c18..fcac751be 100644 --- a/apps/console/src/components/form/MeasureSelectField.tsx +++ b/apps/console/src/components/form/MeasureSelectField.tsx @@ -11,12 +11,14 @@ type Props; export function MeasureSelectField({ organizationId, control, disabled, + optional, ...props }: Props) { return ( @@ -29,6 +31,7 @@ export function MeasureSelectField @@ -36,10 +39,10 @@ export function MeasureSelectField( - props: Pick, "organizationId" | "control" | "name" | "disabled"> + props: Pick, "organizationId" | "control" | "name" | "disabled" | "optional"> ) { const { __ } = useTranslate(); - const { name, organizationId, control, disabled } = props; + const { name, organizationId, control, disabled, optional } = props; const { data } = usePaginatedMeasures(organizationId); const [search, setSearch] = useState(""); const measures = useMemo(() => { @@ -54,20 +57,36 @@ function MeasureSelectWithQuery( ); }, [data?.measures.edges, search]); + const allMeasures = useMemo(() => { + return data?.measures.edges?.map((edge) => edge.node) ?? []; + }, [data?.measures.edges]); + return (
{ + const selectedMeasure = field.value ? allMeasures?.find((m) => m.id === field.value) : null; + return ( + {optional && ( + { + field.onChange(null); + setSearch(""); + }} + > + {__("None")} + + )} {measures?.map((m) => ( (val === "" || val == null ? undefined : val), - z.string({ required_error: "Measure is required" }).min(1, "Measure is required") + (val) => (val === "" || val == null ? null : val), + z.string().nullable().optional() ), deadline: z.string().optional().nullable(), }); @@ -85,8 +85,14 @@ const updateTaskSchema = z.object({ name: z.string().min(1), description: z.string().optional().nullable(), timeEstimate: z.string().optional().nullable(), - assignedToId: z.string().optional().nullable(), - measureId: z.string().optional(), + assignedToId: z.preprocess( + (val) => (val === "" || val == null ? null : val), + z.string().nullable().optional() + ), + measureId: z.preprocess( + (val) => (val === "" || val == null ? null : val), + z.string().nullable().optional() + ), deadline: z.string().optional().nullable(), }); @@ -135,6 +141,7 @@ export default function TaskFormDialog(props: Props) { timeEstimate: data.timeEstimate || null, deadline: formatDatetime(data.deadline) ?? null, assignedToId: data.assignedToId ?? null, + measureId: data.measureId || null, }, }, }); @@ -148,7 +155,7 @@ export default function TaskFormDialog(props: Props) { timeEstimate: data.timeEstimate || null, deadline: formatDatetime(data.deadline) ?? null, assignedToId: data.assignedToId || null, - measureId: data.measureId, + measureId: data.measureId || null, }, connections: [props.connection!], }, @@ -162,7 +169,7 @@ export default function TaskFormDialog(props: Props) { } dialogRef.current?.close(); }); - const showMeasure = !props.measureId && !isUpdating; + const showMeasure = !props.measureId; return ( )} diff --git a/apps/console/src/components/tasks/__generated__/TaskFormDialogUpdateMutation.graphql.ts b/apps/console/src/components/tasks/__generated__/TaskFormDialogUpdateMutation.graphql.ts index c260c3a14..57e8bba2b 100644 --- a/apps/console/src/components/tasks/__generated__/TaskFormDialogUpdateMutation.graphql.ts +++ b/apps/console/src/components/tasks/__generated__/TaskFormDialogUpdateMutation.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<1d9eed2918f5f58de18ef4ea5f0058b7>> + * @generated SignedSource<<8efad26dafbead83a13b59703edc7493>> * @lightSyntaxTransform * @nogrep */ @@ -15,6 +15,7 @@ export type UpdateTaskInput = { assignedToId?: string | null | undefined; deadline?: any | null | undefined; description?: string | null | undefined; + measureId?: string | null | undefined; name?: string | null | undefined; state?: TaskState | null | undefined; taskId: string; diff --git a/e2e/console/task_test.go b/e2e/console/task_test.go index 95e84313a..5e8a03cb5 100644 --- a/e2e/console/task_test.go +++ b/e2e/console/task_test.go @@ -67,6 +67,55 @@ func TestTask_Create(t *testing.T) { assert.Equal(t, "Owner Task", task.Name) } +func TestTask_CreateWithoutMeasure(t *testing.T) { + t.Parallel() + owner := testutil.NewClient(t, testutil.RoleOwner) + + query := ` + mutation CreateTask($input: CreateTaskInput!) { + createTask(input: $input) { + taskEdge { + node { + id + name + measure { + id + } + } + } + } + } + ` + + var result struct { + CreateTask struct { + TaskEdge struct { + Node struct { + ID string `json:"id"` + Name string `json:"name"` + Measure *struct { + ID string `json:"id"` + } `json:"measure"` + } `json:"node"` + } `json:"taskEdge"` + } `json:"createTask"` + } + + err := owner.Execute(query, map[string]any{ + "input": map[string]any{ + "organizationId": owner.GetOrganizationID().String(), + "name": "Task without measure", + "description": "Created without a measure", + }, + }, &result) + require.NoError(t, err) + + task := result.CreateTask.TaskEdge.Node + assert.NotEmpty(t, task.ID) + assert.Equal(t, "Task without measure", task.Name) + assert.Nil(t, task.Measure) +} + func TestTask_Update(t *testing.T) { t.Parallel() owner := testutil.NewClient(t, testutil.RoleOwner) diff --git a/e2e/internal/factory/factory.go b/e2e/internal/factory/factory.go index bfe6fb8ed..2c995cc48 100644 --- a/e2e/internal/factory/factory.go +++ b/e2e/internal/factory/factory.go @@ -279,7 +279,7 @@ func CreateMeasure(c *testutil.Client, attrs ...Attrs) string { return result.CreateMeasure.MeasureEdge.Node.ID } -func CreateTask(c *testutil.Client, measureID string, attrs ...Attrs) string { +func CreateTask(c *testutil.Client, measureID *string, attrs ...Attrs) string { c.T.Helper() var a Attrs @@ -299,9 +299,11 @@ func CreateTask(c *testutil.Client, measureID string, attrs ...Attrs) string { input := map[string]any{ "organizationId": c.GetOrganizationID().String(), - "measureId": measureID, "name": a.getString("name", SafeName("Task")), } + if measureID != nil { + input["measureId"] = *measureID + } if desc := a.getStringPtr("description"); desc != nil { input["description"] = *desc } @@ -530,12 +532,16 @@ func (b *MeasureBuilder) Create() string { type TaskBuilder struct { client *testutil.Client - measureID string + measureID *string attrs Attrs } func NewTask(c *testutil.Client, measureID string) *TaskBuilder { - return &TaskBuilder{client: c, measureID: measureID, attrs: Attrs{}} + return &TaskBuilder{client: c, measureID: &measureID, attrs: Attrs{}} +} + +func NewTaskWithoutMeasure(c *testutil.Client) *TaskBuilder { + return &TaskBuilder{client: c, measureID: nil, attrs: Attrs{}} } func (b *TaskBuilder) WithName(name string) *TaskBuilder { diff --git a/pkg/probo/task_service.go b/pkg/probo/task_service.go index a19570f87..46da68eed 100644 --- a/pkg/probo/task_service.go +++ b/pkg/probo/task_service.go @@ -50,6 +50,7 @@ type ( TimeEstimate **time.Duration Deadline **time.Time AssignedToID **gid.GID + MeasureID **gid.GID } ) @@ -75,6 +76,7 @@ func (utr *UpdateTaskRequest) Validate() error { v.Check(utr.TimeEstimate, "time_estimate", validator.RangeDuration(0, 1000*time.Hour)) v.Check(utr.State, "state", validator.OneOfSlice(coredata.TaskStates())) v.Check(utr.AssignedToID, "assigned_to_id", validator.GID(coredata.PeopleEntityType)) + v.Check(utr.MeasureID, "measure_id", validator.GID(coredata.MeasureEntityType)) return v.Error() } @@ -275,6 +277,18 @@ func (s TaskService) Update( } } + if req.MeasureID != nil { + if *req.MeasureID == nil { + task.MeasureID = nil + } else { + measure := &coredata.Measure{} + if err := measure.LoadByID(ctx, conn, s.svc.scope, **req.MeasureID); err != nil { + return fmt.Errorf("cannot load measure: %w", err) + } + task.MeasureID = *req.MeasureID + } + } + task.UpdatedAt = time.Now() if err := task.Update(ctx, conn, s.svc.scope); err != nil { diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index cad584e3d..289b40c70 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -3725,6 +3725,7 @@ input UpdateTaskInput { timeEstimate: Duration @goField(omittable: true) deadline: Datetime @goField(omittable: true) assignedToId: ID @goField(omittable: true) + measureId: ID @goField(omittable: true) } input DeleteTaskInput { diff --git a/pkg/server/api/console/v1/schema/schema.go b/pkg/server/api/console/v1/schema/schema.go index 29cf8aa6f..a1369b395 100644 --- a/pkg/server/api/console/v1/schema/schema.go +++ b/pkg/server/api/console/v1/schema/schema.go @@ -13945,6 +13945,7 @@ input UpdateTaskInput { timeEstimate: Duration @goField(omittable: true) deadline: Datetime @goField(omittable: true) assignedToId: ID @goField(omittable: true) + measureId: ID @goField(omittable: true) } input DeleteTaskInput { @@ -68042,7 +68043,7 @@ func (ec *executionContext) unmarshalInputUpdateTaskInput(ctx context.Context, o asMap[k] = v } - fieldsInOrder := [...]string{"taskId", "name", "description", "state", "timeEstimate", "deadline", "assignedToId"} + fieldsInOrder := [...]string{"taskId", "name", "description", "state", "timeEstimate", "deadline", "assignedToId", "measureId"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -68098,6 +68099,13 @@ func (ec *executionContext) unmarshalInputUpdateTaskInput(ctx context.Context, o return it, err } it.AssignedToID = graphql.OmittableOf(data) + case "measureId": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("measureId")) + data, err := ec.unmarshalOID2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋgidᚐGID(ctx, v) + if err != nil { + return it, err + } + it.MeasureID = graphql.OmittableOf(data) } } diff --git a/pkg/server/api/console/v1/types/types.go b/pkg/server/api/console/v1/types/types.go index 16e51aafa..0b79864a8 100644 --- a/pkg/server/api/console/v1/types/types.go +++ b/pkg/server/api/console/v1/types/types.go @@ -2307,6 +2307,7 @@ type UpdateTaskInput struct { TimeEstimate graphql.Omittable[*time.Duration] `json:"timeEstimate,omitempty"` Deadline graphql.Omittable[*time.Time] `json:"deadline,omitempty"` AssignedToID graphql.Omittable[*gid.GID] `json:"assignedToId,omitempty"` + MeasureID graphql.Omittable[*gid.GID] `json:"measureId,omitempty"` } type UpdateTaskPayload struct { diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index 19f903af2..5d4a38ce9 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -2790,7 +2790,7 @@ func (r *mutationResolver) DeleteControlSnapshotMapping(ctx context.Context, inp func (r *mutationResolver) CreateTask(ctx context.Context, input types.CreateTaskInput) (*types.CreateTaskPayload, error) { r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateTask) - prb := r.ProboService(ctx, input.MeasureID.TenantID()) + prb := r.ProboService(ctx, input.OrganizationID.TenantID()) task, err := prb.Tasks.Create(ctx, probo.CreateTaskRequest{ MeasureID: input.MeasureID, @@ -2828,6 +2828,7 @@ func (r *mutationResolver) UpdateTask(ctx context.Context, input types.UpdateTas TimeEstimate: UnwrapOmittable(input.TimeEstimate), Deadline: UnwrapOmittable(input.Deadline), AssignedToID: UnwrapOmittable(input.AssignedToID), + MeasureID: UnwrapOmittable(input.MeasureID), }) if err != nil { panic(fmt.Errorf("cannot update task: %w", err)) @@ -6798,6 +6799,10 @@ func (r *taskResolver) Measure(ctx context.Context, obj *types.Task) (*types.Mea panic(fmt.Errorf("cannot get task: %w", err)) } + if task.MeasureID == nil { + return nil, nil + } + measure, err := prb.Measures.Get(ctx, *task.MeasureID) if err != nil { var errNotFound *coredata.ErrMeasureNotFound diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index 88c3fd065..e861245a8 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -1379,6 +1379,8 @@ func (r *Resolver) UpdateTaskTool(ctx context.Context, req *mcp.CallToolRequest, State: input.State, TimeEstimate: UnwrapOmittable(input.TimeEstimate), Deadline: UnwrapOmittable(input.Deadline), + AssignedToID: UnwrapOmittable(input.AssignedToID), + MeasureID: UnwrapOmittable(input.MeasureID), }, ) if err != nil { diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 6c7ab8bc4..26a46dd4b 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -3076,6 +3076,22 @@ components: description: No deadline description: Deadline go.probo.inc/mcpgen/omittable: true + assigned_to_id: + anyOf: + - $ref: "#/components/schemas/GID" + description: Assigned to person ID + - type: "null" + description: Not assigned + description: Assigned to person ID + go.probo.inc/mcpgen/omittable: true + measure_id: + anyOf: + - $ref: "#/components/schemas/GID" + description: Measure ID + - type: "null" + description: No measure + description: Measure ID + go.probo.inc/mcpgen/omittable: true UpdateTaskOutput: type: object diff --git a/pkg/server/api/mcp/v1/types/types.go b/pkg/server/api/mcp/v1/types/types.go index 806b49cd5..c3a5ebf54 100644 --- a/pkg/server/api/mcp/v1/types/types.go +++ b/pkg/server/api/mcp/v1/types/types.go @@ -169,7 +169,7 @@ var ( UpdatePeopleToolOutputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["people"],"properties":{"people":{"type":"object","required":["id","organization_id","full_name","primary_email_address","additional_email_addresses","kind","created_at","updated_at"],"properties":{"additional_email_addresses":{"type":"array","description":"Additional email addresses","items":{"type":"string","format":"string"}},"contract_end_date":{"description":"Contract end date","format":"date-time"},"contract_start_date":{"description":"Contract start date","format":"date-time"},"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"full_name":{"type":"string","description":"Full name"},"id":{"type":"string","format":"string"},"kind":{"type":"string","enum":["EMPLOYEE","CONTRACTOR","SERVICE_ACCOUNT"]},"organization_id":{"type":"string","format":"string"},"position":{"description":"Position"},"primary_email_address":{"type":"string","format":"string"},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}}}`) UpdateRiskToolInputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["id"],"properties":{"category":{"type":"string","description":"Risk category"},"description":{"description":"Risk description"},"id":{"type":"string","format":"string"},"inherent_impact":{"type":"integer","description":"Inherent impact"},"inherent_likelihood":{"type":"integer","description":"Inherent likelihood"},"name":{"type":"string","description":"Risk name"},"note":{"type":"string","description":"Risk note"},"owner_id":{"description":"Owner ID","anyOf":[{"type":"string","format":"string"},{"type":"null"}]},"residual_impact":{"type":"integer","description":"Residual impact"},"residual_likelihood":{"type":"integer","description":"Residual likelihood"},"treatment":{"type":"string","enum":["MITIGATED","ACCEPTED","AVOIDED","TRANSFERRED"]}}}`) UpdateRiskToolOutputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["risk"],"properties":{"risk":{"type":"object","required":["id","organization_id","name","category","treatment","inherent_likelihood","inherent_impact","inherent_risk_score","residual_likelihood","residual_impact","residual_risk_score","note","created_at","updated_at"],"properties":{"category":{"type":"string","description":"Risk category"},"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"description":{"description":"Risk description"},"id":{"type":"string","format":"string"},"inherent_impact":{"type":"integer","description":"Inherent impact"},"inherent_likelihood":{"type":"integer","description":"Inherent likelihood"},"inherent_risk_score":{"type":"integer","description":"Inherent risk score"},"name":{"type":"string","description":"Risk name"},"note":{"type":"string","description":"Risk note"},"organization_id":{"type":"string","format":"string"},"owner_id":{"anyOf":[{"type":"string","format":"string"},{"type":"null","description":"No owner"}]},"residual_impact":{"type":"integer","description":"Residual impact"},"residual_likelihood":{"type":"integer","description":"Residual likelihood"},"residual_risk_score":{"type":"integer","description":"Residual risk score"},"snapshot_id":{"description":"Snapshot ID","anyOf":[{"type":"string","format":"string"},{"type":"null","description":"No snapshot"}]},"treatment":{"type":"string","enum":["MITIGATED","ACCEPTED","AVOIDED","TRANSFERRED"]},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}}}`) - UpdateTaskToolInputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["id"],"properties":{"deadline":{"description":"Deadline","anyOf":[{"type":"string","description":"Deadline","format":"date-time"},{"type":"null","description":"No deadline"}]},"description":{"description":"Task description"},"id":{"type":"string","format":"string"},"name":{"type":"string","description":"Task name"},"state":{"description":"Task state","anyOf":[{"type":"string","enum":["TODO","DONE"]},{"type":"null","description":"No state"}]},"time_estimate":{"description":"Time estimate","anyOf":[{"type":"string","description":"A duration"},{"type":"null","description":"No time estimate"}]}}}`) + UpdateTaskToolInputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["id"],"properties":{"assigned_to_id":{"description":"Assigned to person ID","anyOf":[{"type":"string","format":"string"},{"type":"null","description":"Not assigned"}]},"deadline":{"description":"Deadline","anyOf":[{"type":"string","description":"Deadline","format":"date-time"},{"type":"null","description":"No deadline"}]},"description":{"description":"Task description"},"id":{"type":"string","format":"string"},"measure_id":{"description":"Measure ID","anyOf":[{"type":"string","format":"string"},{"type":"null","description":"No measure"}]},"name":{"type":"string","description":"Task name"},"state":{"description":"Task state","anyOf":[{"type":"string","enum":["TODO","DONE"]},{"type":"null","description":"No state"}]},"time_estimate":{"description":"Time estimate","anyOf":[{"type":"string","description":"A duration"},{"type":"null","description":"No time estimate"}]}}}`) UpdateTaskToolOutputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["task"],"properties":{"task":{"type":"object","required":["id","organization_id","name","state","created_at","updated_at"],"properties":{"assigned_to_id":{"description":"Assigned to person ID","anyOf":[{"type":"string","format":"string"},{"type":"null","description":"Not assigned"}]},"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"deadline":{"description":"Deadline","anyOf":[{"type":"string","description":"Deadline","format":"date-time"},{"type":"null","description":"No deadline"}]},"description":{"description":"Task description","anyOf":[{"type":"string","description":"Task description"},{"type":"null","description":"No description"}]},"id":{"type":"string","format":"string"},"measure_id":{"description":"Measure ID","anyOf":[{"type":"string","format":"string"},{"type":"null","description":"No measure"}]},"name":{"type":"string","description":"Task name"},"organization_id":{"type":"string","format":"string"},"state":{"type":"string","enum":["TODO","DONE"]},"time_estimate":{"description":"Time estimate","anyOf":[{"type":"string","description":"A duration"},{"type":"null","description":"No time estimate"}]},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}}}`) UpdateVendorToolInputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["id"],"properties":{"description":{"type":"string","description":"Vendor description"},"id":{"type":"string","format":"string"},"name":{"type":"string","description":"Vendor name"}}}`) UpdateVendorToolOutputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["vendor"],"properties":{"vendor":{"type":"object","required":["id","name","organization_id","created_at","updated_at"],"properties":{"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"description":{"description":"Vendor description"},"id":{"type":"string","format":"string"},"name":{"type":"string","description":"Vendor name"},"organization_id":{"type":"string","format":"string"},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}}}`) @@ -2081,12 +2081,16 @@ type UpdateRiskOutput struct { // UpdateTaskInput represents the schema type UpdateTaskInput struct { + // Assigned to person ID + AssignedToID mcp.Omittable[*gid.GID] `json:"assigned_to_id,omitempty"` // Deadline Deadline mcp.Omittable[*time.Time] `json:"deadline,omitempty"` // Task description Description mcp.Omittable[*string] `json:"description,omitempty"` // Task ID ID gid.GID `json:"id"` + // Measure ID + MeasureID mcp.Omittable[*gid.GID] `json:"measure_id,omitempty"` // Task name Name *string `json:"name,omitempty"` // Task state