Add task priority enum and rename priority to rank
The existing integer priority field represents positional ordering within a state, not semantic importance. Rename it to rank and introduce a new priority field with enum values URGENT, HIGH, MEDIUM and LOW across the entire stack. Rank is now scoped to (state, priority) so tasks are ordered within each priority group. A generated priority_rank column combines both fields into a single sortable integer for cursor pagination. Dragging a task across priority groups updates its priority automatically based on the drop position neighbors. The backend first moves the task to the new group then repositions it at the target rank. The migration defaults existing rows to MEDIUM priority and backfills ranks per (state, priority) group. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -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 */}
|
||||
<div className="py-5 px-6 bg-subtle">
|
||||
<Label>{__("Properties")}</Label>
|
||||
<PropertyRow
|
||||
label={__("Priority")}
|
||||
error={formState.errors.priority?.message}
|
||||
>
|
||||
<Controller
|
||||
name="priority"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<Option value="URGENT">
|
||||
<span className="flex items-center gap-2">
|
||||
<PriorityLevel level="URGENT" />
|
||||
{__("Urgent")}
|
||||
</span>
|
||||
</Option>
|
||||
<Option value="HIGH">
|
||||
<span className="flex items-center gap-2">
|
||||
<PriorityLevel level="HIGH" />
|
||||
{__("High")}
|
||||
</span>
|
||||
</Option>
|
||||
<Option value="MEDIUM">
|
||||
<span className="flex items-center gap-2">
|
||||
<PriorityLevel level="MEDIUM" />
|
||||
{__("Medium")}
|
||||
</span>
|
||||
</Option>
|
||||
<Option value="LOW">
|
||||
<span className="flex items-center gap-2">
|
||||
<PriorityLevel level="LOW" />
|
||||
{__("Low")}
|
||||
</span>
|
||||
</Option>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</PropertyRow>
|
||||
<PropertyRow
|
||||
label={__("Assigned to")}
|
||||
error={formState.errors.assignedToId?.message}
|
||||
|
||||
@@ -43,7 +43,10 @@ import {
|
||||
import { Link, useLocation, useParams } from "react-router";
|
||||
|
||||
import type { TaskFormDialogFragment$key } from "#/__generated__/core/TaskFormDialogFragment.graphql";
|
||||
import type { TaskFormDialogUpdateMutation } from "#/__generated__/core/TaskFormDialogUpdateMutation.graphql";
|
||||
import type {
|
||||
TaskFormDialogUpdateMutation,
|
||||
TaskPriority,
|
||||
} from "#/__generated__/core/TaskFormDialogUpdateMutation.graphql";
|
||||
import type { TasksCard_task$key } from "#/__generated__/core/TasksCard_task.graphql";
|
||||
import type { TasksCard_TaskRowFragment$key } from "#/__generated__/core/TasksCard_TaskRowFragment.graphql";
|
||||
import type { TasksCardDeleteMutation } from "#/__generated__/core/TasksCardDeleteMutation.graphql";
|
||||
@@ -53,11 +56,35 @@ import type {
|
||||
} from "#/__generated__/core/TasksCardOrganizationFragment.graphql";
|
||||
import type { TasksCardOrganizationQuery } from "#/__generated__/core/TasksCardOrganizationQuery.graphql";
|
||||
import TaskFormDialog, {
|
||||
taskPriorities,
|
||||
taskUpdateMutation,
|
||||
} from "#/components/tasks/TaskFormDialog";
|
||||
import { updateStoreCounter } from "#/hooks/useMutationWithIncrement";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
function resolveDropPriority(
|
||||
dragged: TaskPriority,
|
||||
above?: TaskPriority,
|
||||
below?: TaskPriority,
|
||||
): TaskPriority | undefined {
|
||||
// If any neighbor shares the dragged priority, keep it.
|
||||
if (above === dragged || below === dragged) return undefined;
|
||||
|
||||
// At edges, take the single neighbor's priority.
|
||||
if (!above && below) return below !== dragged ? below : undefined;
|
||||
if (!below && above) return above !== dragged ? above : undefined;
|
||||
|
||||
// Both neighbors differ — pick the one closest to dragged.
|
||||
if (above && below) {
|
||||
const di = taskPriorities.indexOf(dragged);
|
||||
const dAbove = Math.abs(taskPriorities.indexOf(above) - di);
|
||||
const dBelow = Math.abs(taskPriorities.indexOf(below) - di);
|
||||
return dAbove <= dBelow ? above : below;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
tasks: TasksCardOrganizationFragment$data["tasks"]["edges"];
|
||||
connectionId: string;
|
||||
@@ -70,6 +97,7 @@ const taskInlineFragment = graphql`
|
||||
id
|
||||
state
|
||||
priority
|
||||
rank
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -82,7 +110,7 @@ const organizationTasksFragment = graphql`
|
||||
@refetchable(queryName: "TasksCardOrganizationQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 500 }
|
||||
order: { type: "TaskOrder", defaultValue: { field: PRIORITY, direction: ASC } }
|
||||
order: { type: "TaskOrder", defaultValue: { field: PRIORITY_RANK, direction: ASC } }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
@@ -110,7 +138,7 @@ const organizationTasksFragment = graphql`
|
||||
|
||||
type OrganizationTasksCardProps = {
|
||||
organizationRef: TasksCardOrganizationFragment$key;
|
||||
header?: (params: { connectionId: string; canCreateTask: boolean }) => 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 })}
|
||||
<TasksCard
|
||||
tasks={data.tasks.edges}
|
||||
connectionId={data.tasks.__id}
|
||||
@@ -132,12 +164,13 @@ export function OrganizationTasksCard({ organizationRef, header }: OrganizationT
|
||||
);
|
||||
}
|
||||
|
||||
const updatePriorityMutation = graphql`
|
||||
mutation TasksCardUpdatePriorityMutation($input: UpdateTaskInput!) {
|
||||
const updateRankMutation = graphql`
|
||||
mutation TasksCardUpdateRankMutation($input: UpdateTaskInput!) {
|
||||
updateTask(input: $input) {
|
||||
task {
|
||||
id
|
||||
priority
|
||||
rank
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,7 +184,7 @@ export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) {
|
||||
const { toast } = useToast();
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||
const [previewOrder, setPreviewOrder] = useState<string[] | null>(null);
|
||||
const [updatePriority] = useMutation<TaskFormDialogUpdateMutation>(updatePriorityMutation);
|
||||
const [updateRank] = useMutation<TaskFormDialogUpdateMutation>(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) {
|
||||
<TaskFormDialog
|
||||
task={props.fKey as TaskFormDialogFragment$key}
|
||||
ref={dialogRef}
|
||||
onCompleted={props.onStateChange}
|
||||
/>
|
||||
<div
|
||||
className={`flex items-center justify-between py-3 px-6 ${className}`}
|
||||
@@ -466,7 +515,7 @@ function TaskRow(props: TaskRowProps) {
|
||||
>
|
||||
<div className="flex gap-2 items-start">
|
||||
<div className="flex items-center gap-2 pt-[2px]">
|
||||
<PriorityLevel level={1} />
|
||||
<PriorityLevel level={task.priority} />
|
||||
<button
|
||||
onClick={() => void onToggle()}
|
||||
className="cursor-pointer -m-1 p-1 disabled:opacity-60"
|
||||
|
||||
@@ -29,7 +29,7 @@ const tasksQuery = graphql`
|
||||
... on Measure {
|
||||
id
|
||||
canCreateTask: permission(action: "core:task:create")
|
||||
tasks(first: 100, orderBy: { field: PRIORITY, direction: ASC })
|
||||
tasks(first: 100, orderBy: { field: PRIORITY_RANK, direction: ASC })
|
||||
@connection(key: "Measure__tasks")
|
||||
@required(action: THROW) {
|
||||
__id
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function TasksPage({ queryRef }: Props) {
|
||||
<div className="space-y-6">
|
||||
<OrganizationTasksCard
|
||||
organizationRef={query.organization as TasksCardOrganizationFragment$key}
|
||||
header={({ connectionId, canCreateTask }) => (
|
||||
header={({ connectionId, canCreateTask, refetch }) => (
|
||||
<PageHeader
|
||||
title={__("Tasks")}
|
||||
description={__(
|
||||
@@ -54,7 +54,7 @@ export default function TasksPage({ queryRef }: Props) {
|
||||
)}
|
||||
>
|
||||
{canCreateTask && (
|
||||
<TaskFormDialog connection={connectionId}>
|
||||
<TaskFormDialog connection={connectionId} onCompleted={refetch}>
|
||||
<Button icon={IconPlusLarge}>{__("New task")}</Button>
|
||||
</TaskFormDialog>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,8 +24,26 @@ export default {
|
||||
|
||||
type Story = StoryObj<typeof PriorityLevel>;
|
||||
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div className="w-max flex items-center justify-center text-txt-danger">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M7 1.75v5.25M7 10.5h.005"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const bars = level === "HIGH" ? 3 : level === "MEDIUM" ? 2 : 1;
|
||||
|
||||
return (
|
||||
<div className="w-max p-[2px] flex gap-[2px] items-end">
|
||||
<div
|
||||
className={clsx(
|
||||
"h-1 w-[3px] bg-txt-quaternary rounded",
|
||||
level >= 1 ? "bg-txt-secondary" : "bg-txt-quaternary",
|
||||
"h-1 w-[3px] rounded",
|
||||
bars >= 1 ? "bg-txt-secondary" : "bg-txt-quaternary",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
"h-2 w-[3px] bg-txt-quaternary rounded",
|
||||
level >= 2 ? "bg-txt-secondary" : "bg-txt-quaternary",
|
||||
"h-2 w-[3px] rounded",
|
||||
bars >= 2 ? "bg-txt-secondary" : "bg-txt-quaternary",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={clsx(
|
||||
"h-3 w-[3px] bg-txt-quaternary rounded",
|
||||
level >= 3 ? "bg-txt-secondary" : "bg-txt-quaternary",
|
||||
"h-3 w-[3px] rounded",
|
||||
bars >= 3 ? "bg-txt-secondary" : "bg-txt-quaternary",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
36
pkg/coredata/migrations/20260330T120000Z.sql
Normal file
36
pkg/coredata/migrations/20260330T120000Z.sql
Normal file
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
72
pkg/coredata/task_priority.go
Normal file
72
pkg/coredata/task_priority.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user