Fix relay connection handling for evidence lists

Implements proper connection identifiers for evidence lists with correct
pagination support. Removes custom types in favor of generated ones, replaces
useMemo with useCallback for connection IDs, and increases the evidence limit
from 10 to 50 items. Ensures UI updates correctly when adding new evidence
to tasks.

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-03-01 18:53:05 +01:00
parent 5646286fc9
commit 436151b0fe
2 changed files with 268 additions and 209 deletions

View File

@@ -4,7 +4,7 @@ import {
useState, useState,
useRef, useRef,
DragEvent, DragEvent,
useMemo, useCallback,
} from "react"; } from "react";
import { useParams, useNavigate } from "react-router"; import { useParams, useNavigate } from "react-router";
import { import {
@@ -49,10 +49,7 @@ import type { ControlOverviewPageQuery as ControlOverviewPageQueryType } from ".
import type { ControlOverviewPageUpdateTaskStateMutation as ControlOverviewPageUpdateTaskStateMutationType } from "./__generated__/ControlOverviewPageUpdateTaskStateMutation.graphql"; import type { ControlOverviewPageUpdateTaskStateMutation as ControlOverviewPageUpdateTaskStateMutationType } from "./__generated__/ControlOverviewPageUpdateTaskStateMutation.graphql";
import type { ControlOverviewPageCreateTaskMutation as ControlOverviewPageCreateTaskMutationType } from "./__generated__/ControlOverviewPageCreateTaskMutation.graphql"; import type { ControlOverviewPageCreateTaskMutation as ControlOverviewPageCreateTaskMutationType } from "./__generated__/ControlOverviewPageCreateTaskMutation.graphql";
import type { ControlOverviewPageDeleteTaskMutation as ControlOverviewPageDeleteTaskMutationType } from "./__generated__/ControlOverviewPageDeleteTaskMutation.graphql"; import type { ControlOverviewPageDeleteTaskMutation as ControlOverviewPageDeleteTaskMutationType } from "./__generated__/ControlOverviewPageDeleteTaskMutation.graphql";
import type { import type { ControlOverviewPageUploadEvidenceMutation as ControlOverviewPageUploadEvidenceMutationType } from "./__generated__/ControlOverviewPageUploadEvidenceMutation.graphql";
ControlOverviewPageUploadEvidenceMutation as ControlOverviewPageUploadEvidenceMutationType,
EvidenceState,
} from "./__generated__/ControlOverviewPageUploadEvidenceMutation.graphql";
const controlOverviewPageQuery = graphql` const controlOverviewPageQuery = graphql`
query ControlOverviewPageQuery($controlId: ID!) { query ControlOverviewPageQuery($controlId: ID!) {
@@ -64,13 +61,16 @@ const controlOverviewPageQuery = graphql`
state state
category category
tasks(first: 100) @connection(key: "ControlOverviewPage_tasks") { tasks(first: 100) @connection(key: "ControlOverviewPage_tasks") {
__id
edges { edges {
node { node {
id id
name name
description description
state state
evidences(first: 10) { evidences(first: 50)
@connection(key: "ControlOverviewPage_evidences") {
__id
edges { edges {
node { node {
id id
@@ -152,29 +152,6 @@ const uploadEvidenceMutation = graphql`
} }
`; `;
// Use the Relay-generated types with extensions for fields that might not be in the generated types yet
type EvidenceNode = {
id: string;
name: string;
fileUrl: string;
mimeType: string;
size: number;
state: EvidenceState;
createdAt: string;
};
type TaskNode = {
id: string;
name: string;
description: string;
state: string;
evidences?: {
edges: Array<{
node: EvidenceNode | null;
} | null>;
};
};
function ControlOverviewPageContent({ function ControlOverviewPageContent({
queryRef, queryRef,
}: { }: {
@@ -199,9 +176,6 @@ function ControlOverviewPageContent({
useMutation<ControlOverviewPageUploadEvidenceMutationType>( useMutation<ControlOverviewPageUploadEvidenceMutationType>(
uploadEvidenceMutation uploadEvidenceMutation
); );
const control = data.control;
const tasks =
control?.tasks?.edges.map((edge) => edge?.node as TaskNode) ?? [];
const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false); const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false);
const [newTaskName, setNewTaskName] = useState(""); const [newTaskName, setNewTaskName] = useState("");
@@ -232,13 +206,19 @@ function ControlOverviewPageContent({
string | null string | null
>(null); >(null);
// Get the connection ID for Relay const tasks = data.control.tasks?.edges.map((edge) => edge.node) || [];
const connectionId = useMemo(() => {
// The connection key is defined in the GraphQL query as "ControlOverviewPage_tasks" const getEvidenceConnectionId = useCallback(
return control?.id ? `client:${control.id}:tasks{"first":100}` : null; (taskId: string) => {
}, [control?.id]); const task = tasks.find((t) => t.id === taskId);
if (task?.evidences?.__id) {
return task.evidences.__id;
}
return null;
},
[tasks]
);
// Add global drag event handlers to detect when a file is being dragged
useEffect(() => { useEffect(() => {
const handleDragEnter = (e: globalThis.DragEvent) => { const handleDragEnter = (e: globalThis.DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -289,14 +269,12 @@ function ControlOverviewPageContent({
}, },
optimisticResponse: { optimisticResponse: {
updateTaskState: { updateTaskState: {
taskEdge: { task: {
node: {
id: taskId, id: taskId,
state: newState, state: newState,
}, },
}, },
}, },
},
onCompleted: () => { onCompleted: () => {
toast({ toast({
title: "Task updated", title: "Task updated",
@@ -325,7 +303,7 @@ function ControlOverviewPageContent({
return; return;
} }
if (!control?.id) { if (!data.control.id) {
toast({ toast({
title: "Error creating task", title: "Error creating task",
description: "Control ID is missing", description: "Control ID is missing",
@@ -336,9 +314,9 @@ function ControlOverviewPageContent({
createTask({ createTask({
variables: { variables: {
connections: [], connections: [`${data.control.tasks?.__id}`],
input: { input: {
controlId: control.id, controlId: data.control.id,
name: newTaskName, name: newTaskName,
description: newTaskDescription, description: newTaskDescription,
}, },
@@ -372,7 +350,7 @@ function ControlOverviewPageContent({
deleteTask({ deleteTask({
variables: { variables: {
connections: connectionId ? [connectionId] : [], connections: [`${data.control.tasks?.__id}`],
input: { input: {
taskId: taskToDelete.id, taskId: taskToDelete.id,
}, },
@@ -413,6 +391,9 @@ function ControlOverviewPageContent({
const file = fileInputRef.current.files[0]; const file = fileInputRef.current.files[0];
// Get the evidence connection ID for this task
const evidenceConnectionId = getEvidenceConnectionId(taskForEvidence.id);
uploadEvidence({ uploadEvidence({
variables: { variables: {
input: { input: {
@@ -420,7 +401,7 @@ function ControlOverviewPageContent({
name: evidenceName || file.name, name: evidenceName || file.name,
file: null, file: null,
}, },
connections: connectionId ? [connectionId] : [], connections: evidenceConnectionId ? [evidenceConnectionId] : [],
}, },
uploadables: { uploadables: {
"input.file": file, "input.file": file,
@@ -479,6 +460,9 @@ function ControlOverviewPageContent({
description: `Uploading ${file.name}...`, description: `Uploading ${file.name}...`,
}); });
// Get the evidence connection ID for this task
const evidenceConnectionId = getEvidenceConnectionId(taskId);
uploadEvidence({ uploadEvidence({
variables: { variables: {
input: { input: {
@@ -486,7 +470,7 @@ function ControlOverviewPageContent({
name: file.name, name: file.name,
file: null, file: null,
}, },
connections: connectionId ? [connectionId] : [], connections: evidenceConnectionId ? [evidenceConnectionId] : [],
}, },
uploadables: { uploadables: {
"input.file": file, "input.file": file,
@@ -555,12 +539,12 @@ function ControlOverviewPageContent({
return ( return (
<> <>
<Helmet> <Helmet>
<title>{control?.name || "Control"} - Probo</title> <title>{data.control.name || "Control"} - Probo</title>
</Helmet> </Helmet>
<div className="min-h-screen bg-white p-6 space-y-6"> <div className="min-h-screen bg-white p-6 space-y-6">
<div className="space-y-4 mb-8"> <div className="space-y-4 mb-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">{control?.name}</h1> <h1 className="text-2xl font-semibold">{data.control.name}</h1>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleEditControl}> <Button variant="outline" size="sm" onClick={handleEditControl}>
Edit Control Edit Control
@@ -576,7 +560,7 @@ function ControlOverviewPageContent({
</div> </div>
</div> </div>
</div> </div>
<p className="text-gray-600 max-w-3xl">{control?.description}</p> <p className="text-gray-600 max-w-3xl">{data.control.description}</p>
</div> </div>
<Card className="bg-gray-50 border border-gray-200"> <Card className="bg-gray-50 border border-gray-200">
@@ -587,14 +571,14 @@ function ControlOverviewPageContent({
> >
<div <div
className={`w-2 h-2 rounded-full ${ className={`w-2 h-2 rounded-full ${
control?.state === "IMPLEMENTED" data.control.state === "IMPLEMENTED"
? "bg-green-500" ? "bg-green-500"
: "bg-gray-300" : "bg-gray-300"
}`} }`}
/> />
</div> </div>
<span className="text-sm text-gray-700"> <span className="text-sm text-gray-700">
{control?.state === "IMPLEMENTED" {data.control.state === "IMPLEMENTED"
? "Validated" ? "Validated"
: "Not validated"} : "Not validated"}
</span> </span>
@@ -818,9 +802,9 @@ function ControlOverviewPageContent({
{expandedEvidenceTaskId === task.id && ( {expandedEvidenceTaskId === task.id && (
<div className="bg-white border-t border-gray-200 p-3 space-y-2"> <div className="bg-white border-t border-gray-200 p-3 space-y-2">
{task.evidences.edges.map( {task.evidences.edges.map((edge) => {
(edge: { node: EvidenceNode | null } | null) => { if (!edge) return null;
const evidence = edge?.node; const evidence = edge.node;
if (!evidence) return null; if (!evidence) return null;
return ( return (
@@ -835,9 +819,7 @@ function ControlOverviewPageContent({
{evidence.name} {evidence.name}
</div> </div>
<div className="text-xs text-gray-500 flex items-center gap-2"> <div className="text-xs text-gray-500 flex items-center gap-2">
<span> <span>{formatFileSize(evidence.size)}</span>
{formatFileSize(evidence.size)}
</span>
<span>•</span> <span>•</span>
<span> <span>
{formatDate(evidence.createdAt)} {formatDate(evidence.createdAt)}
@@ -870,8 +852,7 @@ function ControlOverviewPageContent({
</div> </div>
</div> </div>
); );
} })}
)}
</div> </div>
)} )}
</> </>

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<ba62806712d6eae0a97e09a9db5c5841>> * @generated SignedSource<<2ce0834e0bd8b3d2937b34ebf91f9a69>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -23,10 +23,12 @@ export type ControlOverviewPageQuery$data = {
readonly name?: string; readonly name?: string;
readonly state?: ControlState; readonly state?: ControlState;
readonly tasks?: { readonly tasks?: {
readonly __id: string;
readonly edges: ReadonlyArray<{ readonly edges: ReadonlyArray<{
readonly node: { readonly node: {
readonly description: string; readonly description: string;
readonly evidences: { readonly evidences: {
readonly __id: string;
readonly edges: ReadonlyArray<{ readonly edges: ReadonlyArray<{
readonly node: { readonly node: {
readonly createdAt: string; readonly createdAt: string;
@@ -108,41 +110,51 @@ v7 = {
"name": "__typename", "name": "__typename",
"storageKey": null "storageKey": null
}, },
v8 = [ v8 = {
{
"alias": null, "alias": null,
"args": null, "args": null,
"concreteType": "TaskEdge", "kind": "ScalarField",
"kind": "LinkedField", "name": "cursor",
"name": "edges", "storageKey": null
"plural": true, },
"selections": [ v9 = {
{
"alias": null, "alias": null,
"args": null, "args": null,
"concreteType": "Task", "concreteType": "PageInfo",
"kind": "LinkedField", "kind": "LinkedField",
"name": "node", "name": "pageInfo",
"plural": false, "plural": false,
"selections": [ "selections": [
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
{ {
"alias": null, "alias": null,
"args": [ "args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{ {
"kind": "Literal", "alias": null,
"name": "first", "args": null,
"value": 10 "kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
} }
], ],
"concreteType": "EvidenceConnection", "storageKey": null
"kind": "LinkedField", },
"name": "evidences", v10 = {
"plural": false, "kind": "ClientExtension",
"selections": [ "selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
},
v11 = [
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -188,62 +200,31 @@ v8 = [
"kind": "ScalarField", "kind": "ScalarField",
"name": "createdAt", "name": "createdAt",
"storageKey": null "storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "evidences(first:10)"
}, },
(v7/*: any*/) (v7/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
{ (v8/*: any*/)
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
], ],
"storageKey": null "storageKey": null
}, },
{ (v9/*: any*/),
"alias": null, (v10/*: any*/)
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
}
],
"storageKey": null
}
], ],
v9 = [ v12 = [
{ {
"kind": "Literal", "kind": "Literal",
"name": "first", "name": "first",
"value": 100 "value": 100
} }
],
v13 = [
{
"kind": "Literal",
"name": "first",
"value": 50
}
]; ];
return { return {
"fragment": { "fragment": {
@@ -275,7 +256,48 @@ return {
"kind": "LinkedField", "kind": "LinkedField",
"name": "__ControlOverviewPage_tasks_connection", "name": "__ControlOverviewPage_tasks_connection",
"plural": false, "plural": false,
"selections": (v8/*: any*/), "selections": [
{
"alias": null,
"args": null,
"concreteType": "TaskEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Task",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
{
"alias": "evidences",
"args": null,
"concreteType": "EvidenceConnection",
"kind": "LinkedField",
"name": "__ControlOverviewPage_evidences_connection",
"plural": false,
"selections": (v11/*: any*/),
"storageKey": null
},
(v7/*: any*/)
],
"storageKey": null
},
(v8/*: any*/)
],
"storageKey": null
},
(v9/*: any*/),
(v10/*: any*/)
],
"storageKey": null "storageKey": null
} }
], ],
@@ -314,17 +336,67 @@ return {
(v6/*: any*/), (v6/*: any*/),
{ {
"alias": null, "alias": null,
"args": (v9/*: any*/), "args": (v12/*: any*/),
"concreteType": "TaskConnection", "concreteType": "TaskConnection",
"kind": "LinkedField", "kind": "LinkedField",
"name": "tasks", "name": "tasks",
"plural": false, "plural": false,
"selections": (v8/*: any*/), "selections": [
{
"alias": null,
"args": null,
"concreteType": "TaskEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Task",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
{
"alias": null,
"args": (v13/*: any*/),
"concreteType": "EvidenceConnection",
"kind": "LinkedField",
"name": "evidences",
"plural": false,
"selections": (v11/*: any*/),
"storageKey": "evidences(first:50)"
},
{
"alias": null,
"args": (v13/*: any*/),
"filters": null,
"handle": "connection",
"key": "ControlOverviewPage_evidences",
"kind": "LinkedHandle",
"name": "evidences"
},
(v7/*: any*/)
],
"storageKey": null
},
(v8/*: any*/)
],
"storageKey": null
},
(v9/*: any*/),
(v10/*: any*/)
],
"storageKey": "tasks(first:100)" "storageKey": "tasks(first:100)"
}, },
{ {
"alias": null, "alias": null,
"args": (v9/*: any*/), "args": (v12/*: any*/),
"filters": null, "filters": null,
"handle": "connection", "handle": "connection",
"key": "ControlOverviewPage_tasks", "key": "ControlOverviewPage_tasks",
@@ -341,10 +413,16 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "ae3ac6c53719fa3d9c1953ff40c75005", "cacheID": "9738657b3d99bf6d85fd5333972e68f1",
"id": null, "id": null,
"metadata": { "metadata": {
"connection": [ "connection": [
{
"count": null,
"cursor": null,
"direction": "forward",
"path": null
},
{ {
"count": null, "count": null,
"cursor": null, "cursor": null,
@@ -358,11 +436,11 @@ return {
}, },
"name": "ControlOverviewPageQuery", "name": "ControlOverviewPageQuery",
"operationKind": "query", "operationKind": "query",
"text": "query ControlOverviewPageQuery(\n $controlId: ID!\n) {\n control: node(id: $controlId) {\n __typename\n id\n ... on Control {\n name\n description\n state\n category\n tasks(first: 100) {\n edges {\n node {\n id\n name\n description\n state\n evidences(first: 10) {\n edges {\n node {\n id\n fileUrl\n mimeType\n size\n state\n createdAt\n }\n }\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n" "text": "query ControlOverviewPageQuery(\n $controlId: ID!\n) {\n control: node(id: $controlId) {\n __typename\n id\n ... on Control {\n name\n description\n state\n category\n tasks(first: 100) {\n edges {\n node {\n id\n name\n description\n state\n evidences(first: 50) {\n edges {\n node {\n id\n fileUrl\n mimeType\n size\n state\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n }\n }\n}\n"
} }
}; };
})(); })();
(node as any).hash = "ad8c4a4980229230ca295c4cb9a2c836"; (node as any).hash = "5c2829a1e6bc18d96db9e608fbfc4d6f";
export default node; export default node;