diff --git a/apps/console/src/components/tasks/TaskFormDialog.tsx b/apps/console/src/components/tasks/TaskFormDialog.tsx index e9a0b4262..94ef72def 100644 --- a/apps/console/src/components/tasks/TaskFormDialog.tsx +++ b/apps/console/src/components/tasks/TaskFormDialog.tsx @@ -23,7 +23,10 @@ import { DurationPicker, Input, Label, + Option, + PriorityLevel, PropertyRow, + Select, Textarea, useDialogRef, } from "@probo/ui"; @@ -47,6 +50,7 @@ const taskFragment = graphql` id description name + priority timeEstimate deadline assignedTo { @@ -87,9 +91,12 @@ export const taskUpdateMutation = graphql` } `; +export const taskPriorities = ["URGENT", "HIGH", "MEDIUM", "LOW"] as const; + const createTaskSchema = z.object({ name: z.string().min(1), description: z.string().optional().nullable(), + priority: z.enum(taskPriorities), timeEstimate: z.string().optional().nullable(), assignedToId: z.string().optional().nullable(), measureId: z.preprocess( @@ -102,6 +109,7 @@ const createTaskSchema = z.object({ const updateTaskSchema = z.object({ name: z.string().min(1), description: z.string().optional().nullable(), + priority: z.enum(taskPriorities), timeEstimate: z.string().optional().nullable(), assignedToId: z.preprocess( val => (val === "" || val == null ? null : val), @@ -120,10 +128,11 @@ type Props = { connection?: string; ref?: DialogRef; measureId?: string; + onCompleted?: () => void; }; export default function TaskFormDialog(props: Props) { - const { children, connection, ref, task: taskKey, measureId } = props; + const { children, connection, ref, task: taskKey, measureId, onCompleted } = props; const { __ } = useTranslate(); const newRef = useDialogRef(); const dialogRef = ref ?? newRef; @@ -145,6 +154,7 @@ export default function TaskFormDialog(props: Props) { defaultValues: { name: task?.name ?? "", description: task?.description ?? "", + priority: task?.priority ?? "MEDIUM", timeEstimate: task?.timeEstimate ?? "", assignedToId: task?.assignedTo?.id ?? "", measureId: task?.measure?.id ?? measureId ?? "", @@ -160,12 +170,16 @@ export default function TaskFormDialog(props: Props) { taskId: task.id, name: data.name, description: data.description || null, + priority: data.priority, timeEstimate: data.timeEstimate || null, deadline: formatDatetime(data.deadline) ?? null, assignedToId: data.assignedToId ?? null, measureId: data.measureId || null, }, }, + onCompleted: (_response, errors) => { + if (!errors) onCompleted?.(); + }, }); } else { await mutate({ @@ -174,6 +188,7 @@ export default function TaskFormDialog(props: Props) { organizationId, name: data.name, description: data.description || null, + priority: data.priority, timeEstimate: data.timeEstimate || null, deadline: formatDatetime(data.deadline) ?? null, assignedToId: data.assignedToId || null, @@ -182,8 +197,11 @@ export default function TaskFormDialog(props: Props) { connections: [connection!], }, onCompleted: (_response, errors) => { - if (!errors && data.measureId) { - updateStoreCounter(relayEnv, data.measureId, "tasks(first:0)", 1); + if (!errors) { + if (data.measureId) { + updateStoreCounter(relayEnv, data.measureId, "tasks(first:0)", 1); + } + onCompleted?.(); } }, }); @@ -224,6 +242,46 @@ export default function TaskFormDialog(props: Props) { {/* Properties form */}
+ + ( + + )} + /> + ReactNode; + header?: (params: { connectionId: string; canCreateTask: boolean; refetch: () => void }) => ReactNode; }; export function OrganizationTasksCard({ organizationRef, header }: OrganizationTasksCardProps) { @@ -119,9 +147,13 @@ export function OrganizationTasksCard({ organizationRef, header }: OrganizationT TasksCardOrganizationFragment$key >(organizationTasksFragment, organizationRef); + const handleRefetch = () => { + refetch({}, { fetchPolicy: "store-and-network" }); + }; + return ( <> - {header?.({ connectionId: data.tasks.__id, canCreateTask: data.canCreateTask })} + {header?.({ connectionId: data.tasks.__id, canCreateTask: data.canCreateTask, refetch: handleRefetch })} (null); const [previewOrder, setPreviewOrder] = useState(null); - const [updatePriority] = useMutation(updatePriorityMutation); + const [updateRank] = useMutation(updateRankMutation); const handleStateChange = () => { if (refetch) { @@ -207,18 +240,32 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) { const newIdx = previewOrder.indexOf(draggedId); const originalIds = filteredTasks.map(({ node }) => readTask(node).id); const originalIdx = originalIds.indexOf(draggedId); + if (originalIdx === -1) { + setDraggedId(null); + setPreviewOrder(null); + return; + } let targetOriginalIdx = newIdx; if (targetOriginalIdx >= originalIdx) targetOriginalIdx++; if (targetOriginalIdx >= filteredTasks.length) targetOriginalIdx = filteredTasks.length - 1; - const targetPriority = readTask(filteredTasks[targetOriginalIdx].node).priority; + const targetTask = readTask(filteredTasks[targetOriginalIdx].node); + const draggedTask = readTask(filteredTasks[originalIdx].node); + + // Determine target priority from neighbors at the drop position. + const aboveId = newIdx > 0 ? previewOrder[newIdx - 1] : null; + const belowId = newIdx < previewOrder.length - 1 ? previewOrder[newIdx + 1] : null; + const aboveTask = aboveId ? readTask(filteredTasks[originalIds.indexOf(aboveId)].node) : null; + const belowTask = belowId ? readTask(filteredTasks[originalIds.indexOf(belowId)].node) : null; + const targetPriority = resolveDropPriority(draggedTask.priority, aboveTask?.priority, belowTask?.priority); setDraggedId(null); - updatePriority({ + updateRank({ variables: { input: { taskId: draggedId, - priority: targetPriority, + rank: targetTask.rank, + ...(targetPriority && { priority: targetPriority }), }, }, onCompleted: (_, errors) => { @@ -351,6 +398,7 @@ const fragment = graphql` id name state + priority description timeEstimate deadline @@ -452,6 +500,7 @@ function TaskRow(props: TaskRowProps) {
- + )} diff --git a/e2e/console/rbac_test.go b/e2e/console/rbac_test.go index 02fbbdb46..a5b76f7d6 100644 --- a/e2e/console/rbac_test.go +++ b/e2e/console/rbac_test.go @@ -631,7 +631,7 @@ func TestRBAC(t *testing.T) { client: owner, query: createTaskMutation, variables: func() map[string]any { - return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "measureId": measureID, "name": factory.SafeName("Task")}} + return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "measureId": measureID, "name": factory.SafeName("Task"), "priority": "MEDIUM"}} }, shouldAllow: true, }, @@ -641,7 +641,7 @@ func TestRBAC(t *testing.T) { client: admin, query: createTaskMutation, variables: func() map[string]any { - return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "measureId": measureID, "name": factory.SafeName("Task")}} + return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "measureId": measureID, "name": factory.SafeName("Task"), "priority": "MEDIUM"}} }, shouldAllow: true, }, @@ -651,7 +651,7 @@ func TestRBAC(t *testing.T) { client: viewer, query: createTaskMutation, variables: func() map[string]any { - return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "measureId": measureID, "name": factory.SafeName("Task")}} + return map[string]any{"input": map[string]any{"organizationId": owner.GetOrganizationID().String(), "measureId": measureID, "name": factory.SafeName("Task"), "priority": "MEDIUM"}} }, shouldAllow: false, }, diff --git a/e2e/console/task_test.go b/e2e/console/task_test.go index 8749823e8..26153cf59 100644 --- a/e2e/console/task_test.go +++ b/e2e/console/task_test.go @@ -58,6 +58,7 @@ func TestTask_Create(t *testing.T) { "measureId": measureID, "name": "Owner Task", "description": "Created by owner", + "priority": "MEDIUM", }, }, &result) require.NoError(t, err) @@ -106,6 +107,7 @@ func TestTask_CreateWithoutMeasure(t *testing.T) { "organizationId": owner.GetOrganizationID().String(), "name": "Task without measure", "description": "Created without a measure", + "priority": "HIGH", }, }, &result) require.NoError(t, err) @@ -252,7 +254,8 @@ func TestTask_RequiredFields(t *testing.T) { { name: "missing organizationId", input: map[string]any{ - "name": "Test Task", + "name": "Test Task", + "priority": "MEDIUM", }, skipOrganization: true, wantErrorContains: "organizationId", @@ -261,9 +264,18 @@ func TestTask_RequiredFields(t *testing.T) { name: "missing name", input: map[string]any{ "organizationId": "placeholder", + "priority": "MEDIUM", }, wantErrorContains: "name", }, + { + name: "missing priority", + input: map[string]any{ + "organizationId": "placeholder", + "name": "Test Task", + }, + wantErrorContains: "priority", + }, } for _, tt := range tests { @@ -770,6 +782,7 @@ func TestTask_OmittableDeadline(t *testing.T) { "organizationId": owner.GetOrganizationID().String(), "measureId": measureID, "name": "Deadline Test Task", + "priority": "MEDIUM", "deadline": "2025-12-31T00:00:00Z", }, }, &createResult) diff --git a/e2e/internal/factory/factory.go b/e2e/internal/factory/factory.go index 376664af0..ef1db4251 100644 --- a/e2e/internal/factory/factory.go +++ b/e2e/internal/factory/factory.go @@ -339,6 +339,7 @@ func CreateTask(c *testutil.Client, measureID *string, attrs ...Attrs) string { input := map[string]any{ "organizationId": c.GetOrganizationID().String(), "name": a.getString("name", SafeName("Task")), + "priority": a.getString("priority", "MEDIUM"), } if measureID != nil { input["measureId"] = *measureID diff --git a/packages/ui/src/Atoms/PriorityLevel/PriorityLevel.stories.tsx b/packages/ui/src/Atoms/PriorityLevel/PriorityLevel.stories.tsx index dc612a428..e977869aa 100644 --- a/packages/ui/src/Atoms/PriorityLevel/PriorityLevel.stories.tsx +++ b/packages/ui/src/Atoms/PriorityLevel/PriorityLevel.stories.tsx @@ -24,8 +24,26 @@ export default { type Story = StoryObj; -export const Default: Story = { +export const Low: Story = { args: { - level: 1, + level: "LOW", + }, +}; + +export const Medium: Story = { + args: { + level: "MEDIUM", + }, +}; + +export const High: Story = { + args: { + level: "HIGH", + }, +}; + +export const Urgent: Story = { + args: { + level: "URGENT", }, }; diff --git a/packages/ui/src/Atoms/PriorityLevel/PriorityLevel.tsx b/packages/ui/src/Atoms/PriorityLevel/PriorityLevel.tsx index ce9c13d5c..9a3f41cf3 100644 --- a/packages/ui/src/Atoms/PriorityLevel/PriorityLevel.tsx +++ b/packages/ui/src/Atoms/PriorityLevel/PriorityLevel.tsx @@ -15,28 +15,46 @@ import { clsx } from "clsx"; type Props = { - level: number; + level: "LOW" | "MEDIUM" | "HIGH" | "URGENT"; }; export function PriorityLevel({ level }: Props) { + if (level === "URGENT") { + return ( +
+ + + +
+ ); + } + + const bars = level === "HIGH" ? 3 : level === "MEDIUM" ? 2 : 1; + return (
= 1 ? "bg-txt-secondary" : "bg-txt-quaternary", + "h-1 w-[3px] rounded", + bars >= 1 ? "bg-txt-secondary" : "bg-txt-quaternary", )} />
= 2 ? "bg-txt-secondary" : "bg-txt-quaternary", + "h-2 w-[3px] rounded", + bars >= 2 ? "bg-txt-secondary" : "bg-txt-quaternary", )} />
= 3 ? "bg-txt-secondary" : "bg-txt-quaternary", + "h-3 w-[3px] rounded", + bars >= 3 ? "bg-txt-secondary" : "bg-txt-quaternary", )} />
diff --git a/pkg/coredata/migrations/20260330T120000Z.sql b/pkg/coredata/migrations/20260330T120000Z.sql new file mode 100644 index 000000000..7a53ba206 --- /dev/null +++ b/pkg/coredata/migrations/20260330T120000Z.sql @@ -0,0 +1,36 @@ +-- Rename priority to rank +ALTER TABLE tasks RENAME COLUMN priority TO rank; + +ALTER TABLE tasks DROP CONSTRAINT tasks_organization_id_state_priority_key; + +-- Add task priority enum +CREATE TYPE task_priority AS ENUM ('URGENT', 'HIGH', 'MEDIUM', 'LOW'); + +ALTER TABLE tasks ADD COLUMN priority task_priority NOT NULL DEFAULT 'MEDIUM'::task_priority; + +ALTER TABLE tasks ALTER COLUMN priority DROP DEFAULT; + +-- Rank is now scoped to (state, priority) — backfill ranks per group +WITH ranked AS ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY organization_id, state, priority + ORDER BY rank + ) AS new_rank + FROM tasks +) +UPDATE tasks SET rank = ranked.new_rank FROM ranked WHERE tasks.id = ranked.id; + +ALTER TABLE tasks +ADD CONSTRAINT tasks_organization_id_state_priority_rank_key + UNIQUE (organization_id, state, priority, rank) + DEFERRABLE INITIALLY DEFERRED; + +-- Computed column for composite ordering (priority level then rank) +ALTER TABLE tasks ADD COLUMN priority_rank int GENERATED ALWAYS AS ( + (CASE priority + WHEN 'URGENT' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'MEDIUM' THEN 3 + WHEN 'LOW' THEN 4 + END) * 1000000 + rank +) STORED; diff --git a/pkg/coredata/task.go b/pkg/coredata/task.go index 93606770f..e43547a30 100644 --- a/pkg/coredata/task.go +++ b/pkg/coredata/task.go @@ -37,13 +37,17 @@ type ( Name string `db:"name"` Description *string `db:"description"` State TaskState `db:"state"` + Priority TaskPriority `db:"priority"` ReferenceID string `db:"reference_id"` TimeEstimate *time.Duration `db:"time_estimate"` AssignedToID *gid.GID `db:"assigned_to_profile_id"` Deadline *time.Time `db:"deadline"` - Priority int `db:"priority"` + Rank int `db:"rank"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` + + // ordering only + PriorityRank int `db:"priority_rank"` } Tasks []*Task @@ -51,8 +55,8 @@ type ( func (t Task) CursorKey(orderBy TaskOrderField) page.CursorKey { switch orderBy { - case TaskOrderFieldPriority: - return page.NewCursorKey(t.ID, t.Priority) + case TaskOrderFieldPriorityRank: + return page.NewCursorKey(t.ID, t.PriorityRank) case TaskOrderFieldCreatedAt: return page.NewCursorKey(t.ID, t.CreatedAt) } @@ -88,11 +92,13 @@ SELECT name, description, state, + priority, reference_id, time_estimate, assigned_to_profile_id, deadline, - priority, + rank, + priority_rank, created_at, updated_at FROM @@ -141,11 +147,13 @@ SELECT name, description, state, + priority, reference_id, time_estimate, assigned_to_profile_id, deadline, - priority, + rank, + priority_rank, created_at, updated_at FROM @@ -181,10 +189,10 @@ func (t *Task) Insert( scope Scoper, ) error { q := ` -WITH next_priority AS ( - SELECT COALESCE(MAX(priority), 0) + 1 AS value +WITH next_rank AS ( + SELECT COALESCE(MAX(rank), 0) + 1 AS value FROM tasks - WHERE organization_id = @organization_id AND state = @state + WHERE organization_id = @organization_id AND state = @state AND priority = @priority ) INSERT INTO tasks ( @@ -196,10 +204,11 @@ INSERT INTO description, reference_id, state, + priority, time_estimate, assigned_to_profile_id, deadline, - priority, + rank, created_at, updated_at ) @@ -212,14 +221,15 @@ VALUES ( @description, @reference_id, @state, + @priority, @time_estimate, @assigned_to_profile_id, @deadline, - (SELECT value FROM next_priority), + (SELECT value FROM next_rank), @created_at, @updated_at ) -RETURNING priority; +RETURNING rank, priority_rank; ` args := pgx.StrictNamedArgs{ @@ -231,6 +241,7 @@ RETURNING priority; "description": t.Description, "reference_id": t.ReferenceID, "state": t.State, + "priority": t.Priority, "time_estimate": t.TimeEstimate, "assigned_to_profile_id": t.AssignedToID, "deadline": t.Deadline, @@ -238,7 +249,7 @@ RETURNING priority; "updated_at": t.UpdatedAt, } - err := conn.QueryRow(ctx, q, args).Scan(&t.Priority) + err := conn.QueryRow(ctx, q, args).Scan(&t.Rank, &t.PriorityRank) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) { @@ -258,10 +269,10 @@ func (t *Task) Upsert( scope Scoper, ) error { q := ` -WITH next_priority AS ( - SELECT COALESCE(MAX(priority), 0) + 1 AS value +WITH next_rank AS ( + SELECT COALESCE(MAX(rank), 0) + 1 AS value FROM tasks - WHERE organization_id = @organization_id AND state = @state + WHERE organization_id = @organization_id AND state = @state AND priority = @priority ) INSERT INTO tasks ( @@ -273,10 +284,11 @@ INSERT INTO description, reference_id, state, + priority, time_estimate, assigned_to_profile_id, deadline, - priority, + rank, created_at, updated_at ) @@ -289,10 +301,11 @@ VALUES ( @description, @reference_id, @state, + @priority, @time_estimate, @assigned_to_profile_id, @deadline, - (SELECT value FROM next_priority), + (SELECT value FROM next_rank), @created_at, @updated_at ) @@ -309,10 +322,12 @@ RETURNING description, reference_id, state, + priority, time_estimate, assigned_to_profile_id, deadline, - priority, + rank, + priority_rank, created_at, updated_at ` @@ -326,6 +341,7 @@ RETURNING "description": t.Description, "reference_id": t.ReferenceID, "state": t.State, + "priority": t.Priority, "time_estimate": t.TimeEstimate, "assigned_to_profile_id": t.AssignedToID, "deadline": t.Deadline, @@ -394,11 +410,13 @@ func (t *Tasks) LoadByOrganizationID( name, description, state, + priority, reference_id, time_estimate, assigned_to_profile_id, deadline, - priority, + rank, + priority_rank, created_at, updated_at FROM @@ -476,11 +494,13 @@ SELECT name, description, state, + priority, reference_id, time_estimate, assigned_to_profile_id, deadline, - priority, + rank, + priority_rank, created_at, updated_at FROM @@ -523,6 +543,7 @@ SET description = @description, state = @state, priority = @priority, + rank = @rank, time_estimate = @time_estimate, updated_at = @updated_at, assigned_to_profile_id = @assigned_to_profile_id, @@ -538,6 +559,7 @@ WHERE %s "description": t.Description, "state": t.State, "priority": t.Priority, + "rank": t.Rank, "time_estimate": t.TimeEstimate, "updated_at": t.UpdatedAt, "assigned_to_profile_id": t.AssignedToID, @@ -550,17 +572,18 @@ WHERE %s return err } -func (t *Task) NextPriorityForState( +func (t *Task) NextRankForStatePriority( ctx context.Context, conn pg.Conn, scope Scoper, ) error { q := ` -SELECT COALESCE(MAX(priority), 0) + 1 +SELECT COALESCE(MAX(rank), 0) + 1 FROM tasks WHERE organization_id = @organization_id AND state = @state + AND priority = @priority AND id != @id AND %s; ` @@ -570,24 +593,25 @@ WHERE "id": t.ID, "organization_id": t.OrganizationID, "state": t.State, + "priority": t.Priority, } maps.Copy(args, scope.SQLArguments()) rows, err := conn.Query(ctx, q, args) if err != nil { - return fmt.Errorf("cannot get next priority: %w", err) + return fmt.Errorf("cannot get next rank: %w", err) } - priority, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]) + rank, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]) if err != nil { - return fmt.Errorf("cannot get next priority: %w", err) + return fmt.Errorf("cannot get next rank: %w", err) } - t.Priority = priority + t.Rank = rank return nil } -func (t *Task) UpdatePriority( +func (t *Task) UpdateRank( ctx context.Context, conn pg.Conn, scope Scoper, @@ -595,18 +619,18 @@ func (t *Task) UpdatePriority( q := ` WITH old AS ( SELECT - priority AS old_priority + rank AS old_rank FROM tasks - WHERE %s AND id = @id AND organization_id = @organization_id AND state = @state + WHERE %s AND id = @id AND organization_id = @organization_id AND state = @state AND priority = @priority ) UPDATE tasks SET - priority = CASE - WHEN id = @id THEN @new_priority - ELSE priority + CASE - WHEN @new_priority < old.old_priority THEN 1 - WHEN @new_priority > old.old_priority THEN -1 + rank = CASE + WHEN id = @id THEN @new_rank + ELSE rank + CASE + WHEN @new_rank < old.old_rank THEN 1 + WHEN @new_rank > old.old_rank THEN -1 END END, updated_at = @updated_at @@ -614,9 +638,10 @@ FROM old WHERE %s AND organization_id = @organization_id AND state = @state + AND priority = @priority AND ( id = @id - OR (priority BETWEEN LEAST(old.old_priority, @new_priority) AND GREATEST(old.old_priority, @new_priority)) + OR (rank BETWEEN LEAST(old.old_rank, @new_rank) AND GREATEST(old.old_rank, @new_rank)) ); ` @@ -625,16 +650,17 @@ WHERE %s args := pgx.StrictNamedArgs{ "id": t.ID, - "new_priority": t.Priority, + "new_rank": t.Rank, "organization_id": t.OrganizationID, "state": t.State, + "priority": t.Priority, "updated_at": t.UpdatedAt, } maps.Copy(args, scope.SQLArguments()) _, err := conn.Exec(ctx, q, args) if err != nil { - return fmt.Errorf("cannot update task priority: %w", err) + return fmt.Errorf("cannot update task rank: %w", err) } return nil diff --git a/pkg/coredata/task_order_field.go b/pkg/coredata/task_order_field.go index cd4ce49e9..c82e7816f 100644 --- a/pkg/coredata/task_order_field.go +++ b/pkg/coredata/task_order_field.go @@ -21,14 +21,14 @@ type ( ) const ( - TaskOrderFieldPriority TaskOrderField = "PRIORITY" - TaskOrderFieldCreatedAt TaskOrderField = "CREATED_AT" + TaskOrderFieldPriorityRank TaskOrderField = "PRIORITY_RANK" // ordering only + TaskOrderFieldCreatedAt TaskOrderField = "CREATED_AT" ) func (p TaskOrderField) Column() string { switch p { - case TaskOrderFieldPriority: - return "priority" + case TaskOrderFieldPriorityRank: + return "priority_rank" case TaskOrderFieldCreatedAt: return "created_at" } @@ -37,7 +37,7 @@ func (p TaskOrderField) Column() string { func (p TaskOrderField) IsValid() bool { switch p { - case TaskOrderFieldPriority, TaskOrderFieldCreatedAt: + case TaskOrderFieldPriorityRank, TaskOrderFieldCreatedAt: return true } return false diff --git a/pkg/coredata/task_priority.go b/pkg/coredata/task_priority.go new file mode 100644 index 000000000..ae0ff11e7 --- /dev/null +++ b/pkg/coredata/task_priority.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package coredata + +import ( + "database/sql/driver" + "fmt" +) + +type TaskPriority string + +const ( + TaskPriorityUrgent TaskPriority = "URGENT" + TaskPriorityHigh TaskPriority = "HIGH" + TaskPriorityMedium TaskPriority = "MEDIUM" + TaskPriorityLow TaskPriority = "LOW" +) + +func TaskPriorities() []TaskPriority { + return []TaskPriority{ + TaskPriorityUrgent, + TaskPriorityHigh, + TaskPriorityMedium, + TaskPriorityLow, + } +} + +func (tp TaskPriority) String() string { + return string(tp) +} + +func (tp *TaskPriority) Scan(value any) error { + var s string + switch v := value.(type) { + case string: + s = v + case []byte: + s = string(v) + default: + return fmt.Errorf("unsupported type for TaskPriority: %T", value) + } + + switch s { + case "URGENT": + *tp = TaskPriorityUrgent + case "HIGH": + *tp = TaskPriorityHigh + case "MEDIUM": + *tp = TaskPriorityMedium + case "LOW": + *tp = TaskPriorityLow + default: + return fmt.Errorf("invalid TaskPriority value: %q", s) + } + return nil +} + +func (tp TaskPriority) Value() (driver.Value, error) { + return tp.String(), nil +} diff --git a/pkg/probo/measure_service.go b/pkg/probo/measure_service.go index f7968dfef..2b4467635 100644 --- a/pkg/probo/measure_service.go +++ b/pkg/probo/measure_service.go @@ -411,6 +411,7 @@ func (s MeasureService) Import( Description: &taskDescription, ReferenceID: req.Measures[i].Tasks[j].ReferenceID, State: coredata.TaskStateTodo, + Priority: coredata.TaskPriorityMedium, CreatedAt: now, UpdatedAt: now, } diff --git a/pkg/probo/task_service.go b/pkg/probo/task_service.go index 283a1deef..8b600e04f 100644 --- a/pkg/probo/task_service.go +++ b/pkg/probo/task_service.go @@ -37,6 +37,7 @@ type ( MeasureID *gid.GID Name string Description *string + Priority coredata.TaskPriority TimeEstimate *time.Duration AssignedToID *gid.GID Deadline *time.Time @@ -47,11 +48,12 @@ type ( Name *string Description **string State *coredata.TaskState + Priority *coredata.TaskPriority TimeEstimate **time.Duration Deadline **time.Time AssignedToID **gid.GID MeasureID **gid.GID - Priority *int + Rank *int } ) @@ -62,6 +64,7 @@ func (ctr *CreateTaskRequest) Validate() error { v.Check(ctr.MeasureID, "measure_id", validator.GID(coredata.MeasureEntityType)) v.Check(ctr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength)) v.Check(ctr.Description, "description", validator.SafeText(ContentMaxLength)) + v.Check(ctr.Priority, "priority", validator.Required(), validator.OneOfSlice(coredata.TaskPriorities())) v.Check(ctr.TimeEstimate, "time_estimate", validator.RangeDuration(0, 1000*time.Hour)) v.Check(ctr.AssignedToID, "assigned_to_id", validator.GID(coredata.MembershipProfileEntityType)) @@ -74,10 +77,12 @@ func (utr *UpdateTaskRequest) Validate() error { v.Check(utr.TaskID, "task_id", validator.Required(), validator.GID(coredata.TaskEntityType)) v.Check(utr.Name, "name", validator.SafeTextNoNewLine(TitleMaxLength)) v.Check(utr.Description, "description", validator.SafeText(ContentMaxLength)) + v.Check(utr.Priority, "priority", validator.OneOfSlice(coredata.TaskPriorities())) 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.MembershipProfileEntityType)) v.Check(utr.MeasureID, "measure_id", validator.GID(coredata.MeasureEntityType)) + v.Check(utr.Rank, "rank", validator.Min(1)) return v.Error() } @@ -104,6 +109,7 @@ func (s TaskService) Create( MeasureID: req.MeasureID, Name: req.Name, Description: req.Description, + Priority: req.Priority, TimeEstimate: req.TimeEstimate, AssignedToID: req.AssignedToID, Deadline: req.Deadline, @@ -275,6 +281,7 @@ func (s TaskService) Update( } oldState := task.State + oldPriority := task.Priority if req.Name != nil { task.Name = *req.Name @@ -320,16 +327,19 @@ func (s TaskService) Update( } } - task.UpdatedAt = time.Now() - if req.Priority != nil { task.Priority = *req.Priority - if err := task.UpdatePriority(ctx, conn, s.svc.scope); err != nil { - return fmt.Errorf("cannot update task priority: %w", err) - } - } else if task.State != oldState { - if err := task.NextPriorityForState(ctx, conn, s.svc.scope); err != nil { - return fmt.Errorf("cannot get next priority: %w", err) + } + + task.UpdatedAt = time.Now() + + targetRank := req.Rank + priorityChanged := task.Priority != oldPriority + stateChanged := task.State != oldState + + if priorityChanged || stateChanged { + if err := task.NextRankForStatePriority(ctx, conn, s.svc.scope); err != nil { + return fmt.Errorf("cannot get next rank: %w", err) } } @@ -337,6 +347,13 @@ func (s TaskService) Update( return fmt.Errorf("cannot update task: %w", err) } + if targetRank != nil { + task.Rank = *targetRank + if err := task.UpdateRank(ctx, conn, s.svc.scope); err != nil { + return fmt.Errorf("cannot update task rank: %w", err) + } + } + return nil }, ) diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index db05229c8..08e8aec4e 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -69,6 +69,26 @@ enum TaskState @goModel(model: "go.probo.inc/probo/pkg/coredata.TaskState") { DONE @goEnum(value: "go.probo.inc/probo/pkg/coredata.TaskStateDone") } +enum TaskPriority + @goModel(model: "go.probo.inc/probo/pkg/coredata.TaskPriority") { + URGENT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TaskPriorityUrgent" + ) + HIGH + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TaskPriorityHigh" + ) + MEDIUM + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TaskPriorityMedium" + ) + LOW + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.TaskPriorityLow" + ) +} + enum EvidenceState @goModel(model: "go.probo.inc/probo/pkg/coredata.EvidenceState") { FULFILLED @@ -507,7 +527,7 @@ enum MeasureOrderField enum TaskOrderField @goModel(model: "go.probo.inc/probo/pkg/coredata.TaskOrderField") { - PRIORITY + PRIORITY_RANK CREATED_AT } @@ -2404,7 +2424,8 @@ type Task implements Node { name: String! description: String state: TaskState! - priority: Int! + priority: TaskPriority! + rank: Int! timeEstimate: Duration deadline: Datetime assignedTo: Profile @goField(forceResolver: true) @@ -4274,6 +4295,7 @@ input CreateTaskInput { measureId: ID name: String! description: String + priority: TaskPriority! timeEstimate: Duration assignedToId: ID deadline: Datetime @@ -4284,7 +4306,8 @@ input UpdateTaskInput { name: String description: String @goField(omittable: true) state: TaskState - priority: Int + priority: TaskPriority + rank: Int timeEstimate: Duration @goField(omittable: true) deadline: Datetime @goField(omittable: true) assignedToId: ID @goField(omittable: true) diff --git a/pkg/server/api/console/v1/types/task.go b/pkg/server/api/console/v1/types/task.go index 4aa91d63c..3965df689 100644 --- a/pkg/server/api/console/v1/types/task.go +++ b/pkg/server/api/console/v1/types/task.go @@ -71,6 +71,7 @@ func NewTask(t *coredata.Task) *Task { Description: t.Description, State: t.State, Priority: t.Priority, + Rank: t.Rank, TimeEstimate: t.TimeEstimate, Deadline: t.Deadline, CreatedAt: t.CreatedAt, diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index eca97c6a7..990e3086e 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -4061,6 +4061,7 @@ func (r *mutationResolver) CreateTask(ctx context.Context, input types.CreateTas OrganizationID: input.OrganizationID, Name: input.Name, Description: input.Description, + Priority: input.Priority, TimeEstimate: input.TimeEstimate, AssignedToID: input.AssignedToID, Deadline: input.Deadline, @@ -4099,6 +4100,7 @@ func (r *mutationResolver) UpdateTask(ctx context.Context, input types.UpdateTas Description: gqlutils.UnwrapOmittable(input.Description), State: input.State, Priority: input.Priority, + Rank: input.Rank, TimeEstimate: gqlutils.UnwrapOmittable(input.TimeEstimate), Deadline: gqlutils.UnwrapOmittable(input.Deadline), AssignedToID: gqlutils.UnwrapOmittable(input.AssignedToID), diff --git a/pkg/server/api/mcp/v1/schema.resolvers.go b/pkg/server/api/mcp/v1/schema.resolvers.go index d6c8ffa1d..9a37d49db 100644 --- a/pkg/server/api/mcp/v1/schema.resolvers.go +++ b/pkg/server/api/mcp/v1/schema.resolvers.go @@ -1896,6 +1896,7 @@ func (r *Resolver) UpdateTaskTool(ctx context.Context, req *mcp.CallToolRequest, Description: UnwrapOmittable(input.Description), State: input.State, Priority: input.Priority, + Rank: input.Rank, TimeEstimate: UnwrapOmittable(input.TimeEstimate), Deadline: UnwrapOmittable(input.Deadline), AssignedToID: UnwrapOmittable(input.AssignedToID), diff --git a/pkg/server/api/mcp/v1/specification.yaml b/pkg/server/api/mcp/v1/specification.yaml index 3bb5aa685..a3b25c49d 100644 --- a/pkg/server/api/mcp/v1/specification.yaml +++ b/pkg/server/api/mcp/v1/specification.yaml @@ -4621,7 +4621,7 @@ components: TaskOrderField: type: string enum: - - PRIORITY + - PRIORITY_RANK - CREATED_AT go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.TaskOrderField @@ -4638,6 +4638,15 @@ components: $ref: "#/components/schemas/OrderDirection" description: Task order direction + TaskPriority: + type: string + enum: + - URGENT + - HIGH + - MEDIUM + - LOW + go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.TaskPriority + Task: type: object required: @@ -4646,6 +4655,7 @@ components: - name - state - priority + - rank - created_at - updated_at properties: @@ -4676,8 +4686,11 @@ components: $ref: "#/components/schemas/TaskState" description: Task state priority: + $ref: "#/components/schemas/TaskPriority" + description: Task priority level + rank: type: integer - description: Task priority within state + description: Task rank within state time_estimate: anyOf: - $ref: "#/components/schemas/Duration" @@ -4830,8 +4843,15 @@ components: description: No state description: Task state priority: + anyOf: + - $ref: "#/components/schemas/TaskPriority" + description: Task priority level + - type: "null" + description: No priority + description: Task priority level + rank: type: integer - description: Task priority within state + description: Task rank within state time_estimate: anyOf: - $ref: "#/components/schemas/Duration" diff --git a/pkg/server/api/mcp/v1/types/task.go b/pkg/server/api/mcp/v1/types/task.go index c1828489e..c14b33efc 100644 --- a/pkg/server/api/mcp/v1/types/task.go +++ b/pkg/server/api/mcp/v1/types/task.go @@ -26,6 +26,7 @@ func NewTask(t *coredata.Task) *Task { Description: t.Description, State: t.State, Priority: t.Priority, + Rank: t.Rank, TimeEstimate: t.TimeEstimate, CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,