Add task drag-and-drop reordering
Order tasks by priority (ASC) by default. Enable drag-and-drop on the TODO and DONE tabs using native HTML5 drag events, following the compliance external URLs pattern. The "All" tab remains read-only since priority is scoped per state. Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -33,8 +33,6 @@ const taskFragment = graphql`
|
|||||||
id
|
id
|
||||||
description
|
description
|
||||||
name
|
name
|
||||||
# eslint-disable-next-line relay/unused-fields
|
|
||||||
state
|
|
||||||
timeEstimate
|
timeEstimate
|
||||||
deadline
|
deadline
|
||||||
assignedTo {
|
assignedTo {
|
||||||
@@ -52,9 +50,11 @@ const taskCreateMutation = graphql`
|
|||||||
$connections: [ID!]!
|
$connections: [ID!]!
|
||||||
) {
|
) {
|
||||||
createTask(input: $input) {
|
createTask(input: $input) {
|
||||||
taskEdge @prependEdge(connections: $connections) {
|
taskEdge @appendEdge(connections: $connections) {
|
||||||
node {
|
node {
|
||||||
...TaskFormDialogFragment
|
...TaskFormDialogFragment
|
||||||
|
...TasksCard_task
|
||||||
|
...TasksCard_TaskRowFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,8 @@ export const taskUpdateMutation = graphql`
|
|||||||
updateTask(input: $input) {
|
updateTask(input: $input) {
|
||||||
task {
|
task {
|
||||||
...TaskFormDialogFragment
|
...TaskFormDialogFragment
|
||||||
|
...TasksCard_task
|
||||||
|
...TasksCard_TaskRowFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { formatDate, formatDuration, promisifyMutation } from "@probo/helpers";
|
import { formatDate, formatDuration, formatError, promisifyMutation } from "@probo/helpers";
|
||||||
import { usePageTitle } from "@probo/hooks";
|
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
@@ -16,22 +15,29 @@ import {
|
|||||||
TaskStateIcon,
|
TaskStateIcon,
|
||||||
useConfirm,
|
useConfirm,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { Fragment } from "react";
|
import { Fragment, type ReactNode, useState, useTransition } from "react";
|
||||||
import {
|
import {
|
||||||
graphql,
|
graphql,
|
||||||
|
readInlineData,
|
||||||
useFragment,
|
useFragment,
|
||||||
useMutation,
|
useMutation,
|
||||||
|
useRefetchableFragment,
|
||||||
useRelayEnvironment,
|
useRelayEnvironment,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { Link, useLocation, useParams } from "react-router";
|
import { Link, useLocation, useParams } from "react-router";
|
||||||
|
|
||||||
import type { MeasureTasksTabQuery$data } from "#/__generated__/core/MeasureTasksTabQuery.graphql";
|
|
||||||
import type { TaskFormDialogFragment$key } from "#/__generated__/core/TaskFormDialogFragment.graphql";
|
import type { TaskFormDialogFragment$key } from "#/__generated__/core/TaskFormDialogFragment.graphql";
|
||||||
import type { TaskFormDialogUpdateMutation } from "#/__generated__/core/TaskFormDialogUpdateMutation.graphql";
|
import type { TaskFormDialogUpdateMutation } 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 { TasksCard_TaskRowFragment$key } from "#/__generated__/core/TasksCard_TaskRowFragment.graphql";
|
||||||
import type { TasksCardDeleteMutation } from "#/__generated__/core/TasksCardDeleteMutation.graphql";
|
import type { TasksCardDeleteMutation } from "#/__generated__/core/TasksCardDeleteMutation.graphql";
|
||||||
import type { TasksPageFragment$data } from "#/__generated__/core/TasksPageFragment.graphql";
|
import type {
|
||||||
|
TasksCardOrganizationFragment$data,
|
||||||
|
TasksCardOrganizationFragment$key,
|
||||||
|
} from "#/__generated__/core/TasksCardOrganizationFragment.graphql";
|
||||||
|
import type { TasksCardOrganizationQuery } from "#/__generated__/core/TasksCardOrganizationQuery.graphql";
|
||||||
import TaskFormDialog, {
|
import TaskFormDialog, {
|
||||||
taskUpdateMutation,
|
taskUpdateMutation,
|
||||||
} from "#/components/tasks/TaskFormDialog";
|
} from "#/components/tasks/TaskFormDialog";
|
||||||
@@ -39,18 +45,107 @@ import { updateStoreCounter } from "#/hooks/useMutationWithIncrement";
|
|||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
tasks:
|
tasks: TasksCardOrganizationFragment$data["tasks"]["edges"];
|
||||||
| TasksPageFragment$data["tasks"]["edges"]
|
|
||||||
| Extract<
|
|
||||||
MeasureTasksTabQuery$data["node"],
|
|
||||||
{ __typename: "Measure" }
|
|
||||||
>["tasks"]["edges"];
|
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
|
canReorder?: boolean;
|
||||||
|
refetch?: (vars: Record<string, never>, options?: { fetchPolicy?: "store-and-network" | "network-only" }) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TasksCard({ tasks, connectionId }: Props) {
|
const taskInlineFragment = graphql`
|
||||||
|
fragment TasksCard_task on Task @inline {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
priority
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function readTask(key: TasksCard_task$key) {
|
||||||
|
return readInlineData(taskInlineFragment, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationTasksFragment = graphql`
|
||||||
|
fragment TasksCardOrganizationFragment on Organization
|
||||||
|
@refetchable(queryName: "TasksCardOrganizationQuery")
|
||||||
|
@argumentDefinitions(
|
||||||
|
first: { type: "Int", defaultValue: 500 }
|
||||||
|
order: { type: "TaskOrder", defaultValue: { field: PRIORITY, direction: ASC } }
|
||||||
|
after: { type: "CursorKey", defaultValue: null }
|
||||||
|
before: { type: "CursorKey", defaultValue: null }
|
||||||
|
last: { type: "Int", defaultValue: null }
|
||||||
|
) {
|
||||||
|
canCreateTask: permission(action: "core:task:create")
|
||||||
|
canUpdateTask: permission(action: "core:task:update")
|
||||||
|
tasks(
|
||||||
|
first: $first
|
||||||
|
after: $after
|
||||||
|
last: $last
|
||||||
|
before: $before
|
||||||
|
orderBy: $order
|
||||||
|
) @connection(key: "TasksCardOrganization_tasks") @required(action: THROW) {
|
||||||
|
__id
|
||||||
|
edges @required(action: THROW) {
|
||||||
|
node {
|
||||||
|
...TasksCard_task
|
||||||
|
...TaskFormDialogFragment
|
||||||
|
...TasksCard_TaskRowFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type OrganizationTasksCardProps = {
|
||||||
|
organizationRef: TasksCardOrganizationFragment$key;
|
||||||
|
header?: (params: { connectionId: string; canCreateTask: boolean }) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrganizationTasksCard({ organizationRef, header }: OrganizationTasksCardProps) {
|
||||||
|
const [data, refetch] = useRefetchableFragment<
|
||||||
|
TasksCardOrganizationQuery,
|
||||||
|
TasksCardOrganizationFragment$key
|
||||||
|
>(organizationTasksFragment, organizationRef);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header?.({ connectionId: data.tasks.__id, canCreateTask: data.canCreateTask })}
|
||||||
|
<TasksCard
|
||||||
|
tasks={data.tasks.edges}
|
||||||
|
connectionId={data.tasks.__id}
|
||||||
|
canReorder={data.canUpdateTask}
|
||||||
|
refetch={refetch}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePriorityMutation = graphql`
|
||||||
|
mutation TasksCardUpdatePriorityMutation($input: UpdateTaskInput!) {
|
||||||
|
updateTask(input: $input) {
|
||||||
|
task {
|
||||||
|
id
|
||||||
|
priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function TasksCard({ tasks, connectionId, canReorder, refetch }: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const hash = useLocation().hash.replace("#", "");
|
const hash = useLocation().hash.replace("#", "");
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
|
const [previewOrder, setPreviewOrder] = useState<string[] | null>(null);
|
||||||
|
const [updatePriority] = useMutation<TaskFormDialogUpdateMutation>(updatePriorityMutation);
|
||||||
|
|
||||||
|
const handleStateChange = () => {
|
||||||
|
if (refetch) {
|
||||||
|
startTransition(() => {
|
||||||
|
refetch({}, { fetchPolicy: "store-and-network" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const hashes = [
|
const hashes = [
|
||||||
{ hash: "", label: __("To do"), state: "TODO" },
|
{ hash: "", label: __("To do"), state: "TODO" },
|
||||||
@@ -59,14 +154,99 @@ export function TasksCard({ tasks, connectionId }: Props) {
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const tasksPerHash = new Map([
|
const tasksPerHash = new Map([
|
||||||
["", tasks?.filter(({ node }) => node.state === "TODO")],
|
["", tasks?.filter(({ node }) => readTask(node).state === "TODO")],
|
||||||
["done", tasks?.filter(({ node }) => node.state === "DONE")],
|
["done", tasks?.filter(({ node }) => readTask(node).state === "DONE")],
|
||||||
["all", tasks],
|
["all", tasks],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const filteredTasks = tasksPerHash.get(hash) ?? [];
|
const filteredTasks = tasksPerHash.get(hash) ?? [];
|
||||||
|
const canDrag = !!canReorder && hash !== "all";
|
||||||
|
|
||||||
usePageTitle(__("Tasks"));
|
const handleDragOver = (e: React.DragEvent, hoveredId: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedId === null || hoveredId === draggedId) return;
|
||||||
|
const ids = filteredTasks.map(({ node }) => readTask(node).id);
|
||||||
|
const fromIdx = ids.indexOf(draggedId);
|
||||||
|
if (fromIdx === -1) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const midY = rect.top + rect.height / 2;
|
||||||
|
const insertBefore = e.clientY < midY;
|
||||||
|
const hoverIdx = ids.indexOf(hoveredId);
|
||||||
|
let targetIdx = insertBefore ? hoverIdx : hoverIdx + 1;
|
||||||
|
if (targetIdx > fromIdx) targetIdx--;
|
||||||
|
if (targetIdx === fromIdx) {
|
||||||
|
setPreviewOrder(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reordered = [...ids];
|
||||||
|
reordered.splice(fromIdx, 1);
|
||||||
|
reordered.splice(targetIdx, 0, draggedId);
|
||||||
|
setPreviewOrder(reordered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = () => {
|
||||||
|
if (draggedId === null || previewOrder === null) {
|
||||||
|
setDraggedId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newIdx = previewOrder.indexOf(draggedId);
|
||||||
|
const originalIds = filteredTasks.map(({ node }) => readTask(node).id);
|
||||||
|
const originalIdx = originalIds.indexOf(draggedId);
|
||||||
|
let targetOriginalIdx = newIdx;
|
||||||
|
if (targetOriginalIdx >= originalIdx) targetOriginalIdx++;
|
||||||
|
if (targetOriginalIdx >= filteredTasks.length) targetOriginalIdx = filteredTasks.length - 1;
|
||||||
|
const targetPriority = readTask(filteredTasks[targetOriginalIdx].node).priority;
|
||||||
|
|
||||||
|
setDraggedId(null);
|
||||||
|
|
||||||
|
updatePriority({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
taskId: draggedId,
|
||||||
|
priority: targetPriority,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted: (_, errors) => {
|
||||||
|
if (errors?.length) {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: formatError(
|
||||||
|
__("Failed to reorder task."),
|
||||||
|
errors,
|
||||||
|
),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (refetch) {
|
||||||
|
startTransition(() => {
|
||||||
|
refetch(
|
||||||
|
{},
|
||||||
|
{ fetchPolicy: errors?.length ? "network-only" : "store-and-network" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: __("Failed to reorder task."),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayTasks = (() => {
|
||||||
|
if (!previewOrder) return filteredTasks;
|
||||||
|
const byId = new Map(filteredTasks.map(edge => [readTask(edge.node).id, edge]));
|
||||||
|
const currentIdSet = new Set(byId.keys());
|
||||||
|
const previewIdSet = new Set(previewOrder);
|
||||||
|
if (currentIdSet.size !== previewIdSet.size || [...currentIdSet].some(id => !previewIdSet.has(id))) {
|
||||||
|
return filteredTasks;
|
||||||
|
}
|
||||||
|
return previewOrder.map(id => byId.get(id)!);
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -98,26 +278,43 @@ export function TasksCard({ tasks, connectionId }: Props) {
|
|||||||
<TaskStateIcon state={h.state!} />
|
<TaskStateIcon state={h.state!} />
|
||||||
{h.label}
|
{h.label}
|
||||||
</h2>
|
</h2>
|
||||||
{tasksPerHash.get(h.hash)?.map(({ node: task }) => (
|
{tasksPerHash.get(h.hash)?.map(({ node }) => (
|
||||||
<TaskRow
|
<TaskRow
|
||||||
key={task.id}
|
key={readTask(node).id}
|
||||||
fKey={task}
|
fKey={node}
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
|
onStateChange={handleStateChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))
|
))
|
||||||
// Todo and Done tab simply list todos
|
// Todo and Done tab simply list todos
|
||||||
: filteredTasks?.map(({ node: task }) => (
|
: displayTasks.map(({ node }) => {
|
||||||
<TaskRow
|
const task = readTask(node);
|
||||||
key={task.id}
|
return (
|
||||||
fKey={task}
|
<TaskRow
|
||||||
connectionId={connectionId}
|
key={task.id}
|
||||||
/>
|
fKey={node}
|
||||||
))}
|
connectionId={connectionId}
|
||||||
|
canDrag={canDrag}
|
||||||
|
isDragging={draggedId === task.id}
|
||||||
|
isGhost={previewOrder !== null && draggedId === task.id}
|
||||||
|
onDragStart={() => setDraggedId(task.id)}
|
||||||
|
onDragOver={e => handleDragOver(e, task.id)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragEnd={() => setDraggedId(null)}
|
||||||
|
onStateChange={handleStateChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
{canDrag && filteredTasks.length > 1 && (
|
||||||
|
<p className="text-sm text-txt-tertiary">
|
||||||
|
{__("Drag and drop to reorder tasks")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -125,6 +322,14 @@ export function TasksCard({ tasks, connectionId }: Props) {
|
|||||||
type TaskRowProps = {
|
type TaskRowProps = {
|
||||||
fKey: TasksCard_TaskRowFragment$key | TaskFormDialogFragment$key;
|
fKey: TasksCard_TaskRowFragment$key | TaskFormDialogFragment$key;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
|
canDrag?: boolean;
|
||||||
|
isDragging?: boolean;
|
||||||
|
isGhost?: boolean;
|
||||||
|
onDragStart?: () => void;
|
||||||
|
onDragOver?: (e: React.DragEvent) => void;
|
||||||
|
onDrop?: () => void;
|
||||||
|
onDragEnd?: () => void;
|
||||||
|
onStateChange?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fragment = graphql`
|
const fragment = graphql`
|
||||||
@@ -175,6 +380,8 @@ function TaskRow(props: TaskRowProps) {
|
|||||||
);
|
);
|
||||||
const [updateTask, isUpdating] = useMutation<TaskFormDialogUpdateMutation>(taskUpdateMutation);
|
const [updateTask, isUpdating] = useMutation<TaskFormDialogUpdateMutation>(taskUpdateMutation);
|
||||||
|
|
||||||
|
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||||
|
|
||||||
const onToggle = async () => {
|
const onToggle = async () => {
|
||||||
await promisifyMutation(updateTask)({
|
await promisifyMutation(updateTask)({
|
||||||
variables: {
|
variables: {
|
||||||
@@ -184,6 +391,7 @@ function TaskRow(props: TaskRowProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
props.onStateChange?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDelete = () => {
|
const onDelete = () => {
|
||||||
@@ -211,13 +419,37 @@ function TaskRow(props: TaskRowProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canDrag = props.canDrag;
|
||||||
|
const isDragging = props.isDragging;
|
||||||
|
const isGhost = props.isGhost;
|
||||||
|
|
||||||
|
const className = [
|
||||||
|
"transition-all duration-150",
|
||||||
|
canDrag && isDragging && !isGhost && "opacity-40 cursor-grabbing",
|
||||||
|
canDrag && !isDragging && !isMouseDown && "cursor-grab",
|
||||||
|
canDrag && !isDragging && isMouseDown && "cursor-grabbing",
|
||||||
|
isGhost && "opacity-50 bg-primary-50",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TaskFormDialog
|
<TaskFormDialog
|
||||||
task={props.fKey as TaskFormDialogFragment$key}
|
task={props.fKey as TaskFormDialogFragment$key}
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between py-3 px-6">
|
<div
|
||||||
|
className={`flex items-center justify-between py-3 px-6 ${className}`}
|
||||||
|
draggable={canDrag}
|
||||||
|
onDragStart={canDrag ? props.onDragStart : undefined}
|
||||||
|
onDragOver={canDrag ? props.onDragOver : undefined}
|
||||||
|
onDrop={canDrag ? props.onDrop : undefined}
|
||||||
|
onDragEnd={canDrag ? props.onDragEnd : undefined}
|
||||||
|
onMouseDown={canDrag ? () => setIsMouseDown(true) : undefined}
|
||||||
|
onMouseUp={canDrag ? () => setIsMouseDown(false) : undefined}
|
||||||
|
onMouseLeave={canDrag ? () => setIsMouseDown(false) : undefined}
|
||||||
|
>
|
||||||
<div className="flex gap-2 items-start">
|
<div className="flex gap-2 items-start">
|
||||||
<div className="flex items-center gap-2 pt-[2px]">
|
<div className="flex items-center gap-2 pt-[2px]">
|
||||||
<PriorityLevel level={1} />
|
<PriorityLevel level={1} />
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { graphql } from "relay-runtime";
|
|
||||||
|
|
||||||
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
|
||||||
|
|
||||||
export const tasksQuery = graphql`
|
|
||||||
query TaskGraphQuery($organizationId: ID!) {
|
|
||||||
organization: node(id: $organizationId) {
|
|
||||||
... on Organization {
|
|
||||||
id
|
|
||||||
...TasksPageFragment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
@@ -15,15 +15,13 @@ const tasksQuery = graphql`
|
|||||||
... on Measure {
|
... on Measure {
|
||||||
id
|
id
|
||||||
canCreateTask: permission(action: "core:task:create")
|
canCreateTask: permission(action: "core:task:create")
|
||||||
tasks(first: 100)
|
tasks(first: 100, orderBy: { field: PRIORITY, direction: ASC })
|
||||||
@connection(key: "Measure__tasks")
|
@connection(key: "Measure__tasks")
|
||||||
@required(action: THROW) {
|
@required(action: THROW) {
|
||||||
__id
|
__id
|
||||||
edges @required(action: THROW) {
|
edges @required(action: THROW) {
|
||||||
node {
|
node {
|
||||||
id
|
...TasksCard_task
|
||||||
# eslint-disable-next-line relay/unused-fields
|
|
||||||
state
|
|
||||||
...TaskFormDialogFragment
|
...TaskFormDialogFragment
|
||||||
...TasksCard_TaskRowFragment
|
...TasksCard_TaskRowFragment
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +1,52 @@
|
|||||||
import { usePageTitle } from "@probo/hooks";
|
import { usePageTitle } from "@probo/hooks";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Button, IconPlusLarge, PageHeader } from "@probo/ui";
|
import { Button, IconPlusLarge, PageHeader } from "@probo/ui";
|
||||||
import {
|
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||||
type PreloadedQuery,
|
|
||||||
usePreloadedQuery,
|
|
||||||
useRefetchableFragment,
|
|
||||||
} from "react-relay";
|
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
|
|
||||||
import type { TaskGraphQuery } from "#/__generated__/core/TaskGraphQuery.graphql";
|
import type { TasksCardOrganizationFragment$key } from "#/__generated__/core/TasksCardOrganizationFragment.graphql";
|
||||||
import type { TasksPageFragment$key } from "#/__generated__/core/TasksPageFragment.graphql";
|
import type { TasksPageQuery } from "#/__generated__/core/TasksPageQuery.graphql";
|
||||||
import type { TasksPageFragment_query } from "#/__generated__/core/TasksPageFragment_query.graphql";
|
|
||||||
import TaskFormDialog from "#/components/tasks/TaskFormDialog";
|
import TaskFormDialog from "#/components/tasks/TaskFormDialog";
|
||||||
import { TasksCard } from "#/components/tasks/TasksCard";
|
import { OrganizationTasksCard } from "#/components/tasks/TasksCard";
|
||||||
import { tasksQuery } from "#/hooks/graph/TaskGraph";
|
|
||||||
|
|
||||||
const tasksFragment = graphql`
|
export const tasksPageQuery = graphql`
|
||||||
fragment TasksPageFragment on Organization
|
query TasksPageQuery($organizationId: ID!) {
|
||||||
@refetchable(queryName: "TasksPageFragment_query")
|
organization: node(id: $organizationId) {
|
||||||
@argumentDefinitions(
|
... on Organization {
|
||||||
first: { type: "Int", defaultValue: 500 }
|
...TasksCardOrganizationFragment
|
||||||
order: { type: "TaskOrder", defaultValue: null }
|
|
||||||
after: { type: "CursorKey", defaultValue: null }
|
|
||||||
before: { type: "CursorKey", defaultValue: null }
|
|
||||||
last: { type: "Int", defaultValue: null }
|
|
||||||
) {
|
|
||||||
canCreateTask: permission(action: "core:task:create")
|
|
||||||
tasks(
|
|
||||||
first: $first
|
|
||||||
after: $after
|
|
||||||
last: $last
|
|
||||||
before: $before
|
|
||||||
orderBy: $order
|
|
||||||
) @connection(key: "TasksPageFragment_tasks") @required(action: THROW) {
|
|
||||||
__id
|
|
||||||
edges @required(action: THROW) {
|
|
||||||
# eslint-disable-next-line relay/unused-fields
|
|
||||||
node {
|
|
||||||
# eslint-disable-next-line relay/unused-fields
|
|
||||||
id
|
|
||||||
# eslint-disable-next-line relay/unused-fields
|
|
||||||
state
|
|
||||||
...TaskFormDialogFragment
|
|
||||||
...TasksCard_TaskRowFragment
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
queryRef: PreloadedQuery<TaskGraphQuery>;
|
queryRef: PreloadedQuery<TasksPageQuery>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TasksPage({ queryRef }: Props) {
|
export default function TasksPage({ queryRef }: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const query = usePreloadedQuery(tasksQuery, queryRef);
|
const query = usePreloadedQuery(tasksPageQuery, queryRef);
|
||||||
const [data] = useRefetchableFragment<TasksPageFragment_query, TasksPageFragment$key>(
|
|
||||||
tasksFragment,
|
|
||||||
query.organization as TasksPageFragment$key,
|
|
||||||
);
|
|
||||||
const connectionId = data.tasks.__id;
|
|
||||||
usePageTitle(__("Tasks"));
|
usePageTitle(__("Tasks"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<OrganizationTasksCard
|
||||||
title={__("Tasks")}
|
organizationRef={query.organization as TasksCardOrganizationFragment$key}
|
||||||
description={__(
|
header={({ connectionId, canCreateTask }) => (
|
||||||
"Track your assigned compliance tasks and keep progress on track.",
|
<PageHeader
|
||||||
|
title={__("Tasks")}
|
||||||
|
description={__(
|
||||||
|
"Track your assigned compliance tasks and keep progress on track.",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{canCreateTask && (
|
||||||
|
<TaskFormDialog connection={connectionId}>
|
||||||
|
<Button icon={IconPlusLarge}>{__("New task")}</Button>
|
||||||
|
</TaskFormDialog>
|
||||||
|
)}
|
||||||
|
</PageHeader>
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
{data.canCreateTask && (
|
|
||||||
<TaskFormDialog connection={connectionId}>
|
|
||||||
<Button icon={IconPlusLarge}>{__("New task")}</Button>
|
|
||||||
</TaskFormDialog>
|
|
||||||
)}
|
|
||||||
</PageHeader>
|
|
||||||
<TasksCard connectionId={connectionId} tasks={data.tasks.edges} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,17 @@ import {
|
|||||||
} from "@probo/routes";
|
} from "@probo/routes";
|
||||||
import { loadQuery } from "react-relay";
|
import { loadQuery } from "react-relay";
|
||||||
|
|
||||||
import type { TaskGraphQuery } from "#/__generated__/core/TaskGraphQuery.graphql";
|
import type { TasksPageQuery } from "#/__generated__/core/TasksPageQuery.graphql";
|
||||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||||
import { coreEnvironment } from "#/environments";
|
import { coreEnvironment } from "#/environments";
|
||||||
import { tasksQuery } from "#/hooks/graph/TaskGraph";
|
import { tasksPageQuery } from "#/pages/organizations/tasks/TasksPage";
|
||||||
|
|
||||||
export const taskRoutes = [
|
export const taskRoutes = [
|
||||||
{
|
{
|
||||||
path: "tasks",
|
path: "tasks",
|
||||||
Fallback: PageSkeleton,
|
Fallback: PageSkeleton,
|
||||||
loader: loaderFromQueryLoader(({ organizationId }) =>
|
loader: loaderFromQueryLoader(({ organizationId }) =>
|
||||||
loadQuery<TaskGraphQuery>(coreEnvironment, tasksQuery, {
|
loadQuery<TasksPageQuery>(coreEnvironment, tasksPageQuery, {
|
||||||
organizationId,
|
organizationId,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user