From 3693a345861e381f1c086b4e6024dd092e6e054d Mon Sep 17 00:00:00 2001 From: VictoriaBeilstenEdmands Date: Tue, 23 Sep 2025 13:32:49 +0100 Subject: [PATCH 1/2] feat(frontend): use fragments for Template pages --- .../src/routes/SingleTemplatePage.tsx | 8 +- .../src/routes/TemplatesListPage.tsx | 4 +- .../lib/components/RenderSubmittedMessage.tsx | 102 +++++++++++ .../lib/components/SubmissionForm.tsx | 57 +++++-- .../lib/components/SubmittedMessagesList.tsx | 158 +----------------- .../lib/components/SubscribeAndRender.tsx | 73 ++++++++ .../lib/components}/TemplateCard.tsx | 36 ++-- .../lib/components/TemplatesList.tsx | 75 --------- .../lib/graphql/TemplatesListQuery.ts | 15 -- .../lib/graphql/workflowTemplateFragment.ts | 13 -- .../{components => views}/TemplateView.tsx | 98 ++++------- .../TemplateViewRetrigger.tsx | 18 +- .../lib/views/TemplatesListView.tsx | 114 +++++++++++++ frontend/workflows-lib/lib/main.ts | 1 - .../tests/components/TemplateList.test.tsx | 6 +- 15 files changed, 407 insertions(+), 371 deletions(-) create mode 100644 frontend/relay-workflows-lib/lib/components/RenderSubmittedMessage.tsx create mode 100644 frontend/relay-workflows-lib/lib/components/SubscribeAndRender.tsx rename frontend/{workflows-lib/lib/components/template => relay-workflows-lib/lib/components}/TemplateCard.tsx (71%) delete mode 100644 frontend/relay-workflows-lib/lib/components/TemplatesList.tsx delete mode 100644 frontend/relay-workflows-lib/lib/graphql/TemplatesListQuery.ts delete mode 100644 frontend/relay-workflows-lib/lib/graphql/workflowTemplateFragment.ts rename frontend/relay-workflows-lib/lib/{components => views}/TemplateView.tsx (50%) rename frontend/relay-workflows-lib/lib/{components => views}/TemplateViewRetrigger.tsx (64%) create mode 100644 frontend/relay-workflows-lib/lib/views/TemplatesListView.tsx diff --git a/frontend/dashboard/src/routes/SingleTemplatePage.tsx b/frontend/dashboard/src/routes/SingleTemplatePage.tsx index d4df6be78..20fec9c65 100644 --- a/frontend/dashboard/src/routes/SingleTemplatePage.tsx +++ b/frontend/dashboard/src/routes/SingleTemplatePage.tsx @@ -2,10 +2,10 @@ import { Suspense } from "react"; import { useParams, Link } from "react-router-dom"; import { Container, Box, Typography } from "@mui/material"; import { Breadcrumbs, visitToText } from "@diamondlightsource/sci-react-ui"; -import TemplateView from "relay-workflows-lib/lib/components/TemplateView"; -import TemplateViewRetrigger from "relay-workflows-lib/lib/components/TemplateViewRetrigger"; -import { WorkflowsErrorBoundary, WorkflowsNavbar } from "workflows-lib"; +import { WorkflowsNavbar, WorkflowsErrorBoundary } from "workflows-lib"; import { parseVisitAndTemplate } from "workflows-lib/lib/utils/commonUtils"; +import TemplateViewRetrigger from "relay-workflows-lib/lib/views/TemplateViewRetrigger"; +import TemplateView from "relay-workflows-lib/lib/views/TemplateView"; const SingleTemplatePage: React.FC = () => { const { templateName, prepopulate } = useParams<{ @@ -40,7 +40,7 @@ const SingleTemplatePage: React.FC = () => { )} {templateName && ( - + Loading...}> {workflowName ? ( { return ( @@ -14,7 +14,7 @@ const TemplatesListPage: React.FC = () => { - + diff --git a/frontend/relay-workflows-lib/lib/components/RenderSubmittedMessage.tsx b/frontend/relay-workflows-lib/lib/components/RenderSubmittedMessage.tsx new file mode 100644 index 000000000..5edd3c921 --- /dev/null +++ b/frontend/relay-workflows-lib/lib/components/RenderSubmittedMessage.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { Link } from "react-router-dom"; +import { + SubmissionGraphQLErrorMessage, + SubmissionNetworkErrorMessage, + SubmissionSuccessMessage, + WorkflowStatus, +} from "workflows-lib/lib/types"; +import { getWorkflowStatusIcon } from "workflows-lib/lib/components/common/StatusIcons"; +import { graphql } from "relay-runtime"; +import { useFragment } from "react-relay"; +import { RenderSubmittedMessageFragment$key } from "./__generated__/RenderSubmittedMessageFragment.graphql"; + +const RenderSubmittedMessageFragment = graphql` + fragment RenderSubmittedMessageFragment on Workflow { + status { + __typename + } + } +`; + +interface RenderSubmittedMessagePropsList { + result: + | SubmissionGraphQLErrorMessage + | SubmissionNetworkErrorMessage + | SubmissionSuccessMessage; + index: number; + fragmentRef?: RenderSubmittedMessageFragment$key | null; +} + +export const RenderSubmittedMessage: React.FC< + RenderSubmittedMessagePropsList +> = ({ result, index, fragmentRef }) => { + const data = useFragment(RenderSubmittedMessageFragment, fragmentRef); + switch (result.type) { + case "success": + return ( + {}} + > + + + Successfully submitted{" "} + {result.message} + + {data + ? getWorkflowStatusIcon(data.status?.__typename as WorkflowStatus) + : getWorkflowStatusIcon("Unknown")} + + + ); + + case "networkError": + return ( + + }> + + Submission error type {result.error.name} + + + + + Submission error message {result.error.message} + + + + ); + case "graphQLError": + default: + return ( + + }> + + Submission error type GraphQL + + + + {result.errors.map((e, j) => { + return ( + + Error {j} {e.message} + + ); + })} + + + ); + } +}; diff --git a/frontend/relay-workflows-lib/lib/components/SubmissionForm.tsx b/frontend/relay-workflows-lib/lib/components/SubmissionForm.tsx index 1d48f6a79..66206c780 100644 --- a/frontend/relay-workflows-lib/lib/components/SubmissionForm.tsx +++ b/frontend/relay-workflows-lib/lib/components/SubmissionForm.tsx @@ -1,31 +1,56 @@ import { useFragment } from "react-relay"; -import { - JSONObject, - SubmissionForm as SubmissionFormBase, - Visit, -} from "workflows-lib"; +import { SubmissionForm as SubmissionFormBase, Visit } from "workflows-lib"; import { JsonSchema, UISchemaElement } from "@jsonforms/core"; -import { workflowTemplateFragment$key } from "../graphql/__generated__/workflowTemplateFragment.graphql"; -import { workflowTemplateFragment } from "../graphql/workflowTemplateFragment"; +import { graphql } from "react-relay"; +import { SubmissionFormFragment$key } from "./__generated__/SubmissionFormFragment.graphql"; +import { SubmissionFormParametersFragment$key } from "./__generated__/SubmissionFormParametersFragment.graphql"; -const SubmissionForm = (props: { - template: workflowTemplateFragment$key; - prepopulatedParameters?: JSONObject; +export const SubmissionFormFragment = graphql` + fragment SubmissionFormFragment on WorkflowTemplate { + name + maintainer + title + description + arguments + uiSchema + repository + } +`; + +export const SubmissionFormParametersFragment = graphql` + fragment SubmissionFormParametersFragment on Workflow { + parameters + } +`; + +const SubmissionForm = ({ + template, + prepopulatedParameters, + visit, + onSubmit, +}: { + template: SubmissionFormFragment$key; + prepopulatedParameters?: SubmissionFormParametersFragment$key; visit?: Visit; onSubmit: (visit: Visit, parameters: object) => void; }) => { - const data = useFragment(workflowTemplateFragment, props.template); + const data = useFragment(SubmissionFormFragment, template); + const parameterData = useFragment( + SubmissionFormParametersFragment, + prepopulatedParameters, + ); + return ( ); }; diff --git a/frontend/relay-workflows-lib/lib/components/SubmittedMessagesList.tsx b/frontend/relay-workflows-lib/lib/components/SubmittedMessagesList.tsx index 1d7b6562b..2702d0e9a 100644 --- a/frontend/relay-workflows-lib/lib/components/SubmittedMessagesList.tsx +++ b/frontend/relay-workflows-lib/lib/components/SubmittedMessagesList.tsx @@ -1,156 +1,9 @@ -import React, { useMemo, useState } from "react"; -import { useSubscription } from "react-relay"; -import { GraphQLSubscriptionConfig } from "relay-runtime"; -import { - Accordion, - AccordionSummary, - AccordionDetails, - Box, - Divider, - Paper, - Typography, -} from "@mui/material"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import { Link } from "react-router-dom"; -import { - SubmissionData, - SubmissionGraphQLErrorMessage, - SubmissionNetworkErrorMessage, - SubmissionSuccessMessage, - WorkflowStatus, -} from "workflows-lib/lib/types"; -import { - workflowRelaySubscription$data, - workflowRelaySubscription as WorkflowRelaySubscriptionType, -} from "../graphql/__generated__/workflowRelaySubscription.graphql"; -import { workflowRelaySubscription } from "../graphql/workflowRelaySubscription"; -import { getWorkflowStatusIcon } from "workflows-lib/lib/components/common/StatusIcons"; -import { Visit } from "@diamondlightsource/sci-react-ui"; +import React from "react"; -interface RenderSubmittedMessagePropsList { - result: - | SubmissionGraphQLErrorMessage - | SubmissionNetworkErrorMessage - | SubmissionSuccessMessage; - index: number; - workflowData: workflowRelaySubscription$data | null; -} - -const RenderSubmittedMessage: React.FC = ({ - result, - index, - workflowData, -}) => { - switch (result.type) { - case "success": - return ( - {}} - > - - - Successfully submitted{" "} - {result.message} - - {workflowData - ? getWorkflowStatusIcon( - workflowData.workflow.status?.__typename as WorkflowStatus, - ) - : getWorkflowStatusIcon("Unknown")} - - - ); - - case "networkError": - return ( - - }> - - Submission error type {result.error.name} - - - - - Submission error message {result.error.message} - - - - ); - case "graphQLError": - default: - return ( - - }> - - Submission error type GraphQL - - - - {result.errors.map((e, j) => { - return ( - - Error {j} {e.message} - - ); - })} - - - ); - } -}; - -interface SubscribeAndRenderPropsList { - result: - | SubmissionGraphQLErrorMessage - | SubmissionNetworkErrorMessage - | SubmissionSuccessMessage; - visit: Visit; - workflowName: string; - index: number; -} - -const SubscribeAndRender: React.FC = ({ - result, - visit, - workflowName, - index, -}) => { - const [workflowData, setWorkflowData] = - useState(null); - - const subscriptionData: GraphQLSubscriptionConfig = - useMemo( - () => ({ - subscription: workflowRelaySubscription, - variables: { visit, name: workflowName }, - onNext: (response) => { - setWorkflowData(response ?? null); - }, - onError: (error: unknown) => { - console.error("Subscription error:", error); - }, - onCompleted: () => { - console.log("completed"); - }, - }), - [visit, workflowName], - ); - useSubscription(subscriptionData); - - return ( - - ); -}; +import { Box, Divider, Paper, Typography } from "@mui/material"; +import { SubmissionData } from "workflows-lib/lib/types"; +import { RenderSubmittedMessage } from "./RenderSubmittedMessage"; +import { SubscribeAndRender } from "./SubscribeAndRender"; interface SubmittedMessagesListProps { submittedData: SubmissionData[]; @@ -195,7 +48,6 @@ const SubmittedMessagesList: React.FC = ({ ), )} diff --git a/frontend/relay-workflows-lib/lib/components/SubscribeAndRender.tsx b/frontend/relay-workflows-lib/lib/components/SubscribeAndRender.tsx new file mode 100644 index 000000000..186d3a034 --- /dev/null +++ b/frontend/relay-workflows-lib/lib/components/SubscribeAndRender.tsx @@ -0,0 +1,73 @@ +import React, { useMemo, useState } from "react"; +import { useSubscription } from "react-relay"; +import { GraphQLSubscriptionConfig } from "relay-runtime"; +import { + SubmissionGraphQLErrorMessage, + SubmissionNetworkErrorMessage, + SubmissionSuccessMessage, +} from "workflows-lib/lib/types"; +import { Visit } from "@diamondlightsource/sci-react-ui"; +import { RenderSubmittedMessage } from "./RenderSubmittedMessage"; +import { graphql, GraphQLTaggedNode } from "react-relay"; +import { + SubscribeAndRenderSubscription$data, + SubscribeAndRenderSubscription as SubscribeAndRenderSubscriptionType, +} from "./__generated__/SubscribeAndRenderSubscription.graphql"; + +export const SubscribeAndRenderSubscription: GraphQLTaggedNode = graphql` + subscription SubscribeAndRenderSubscription( + $visit: VisitInput! + $name: String! + ) { + workflow(visit: $visit, name: $name) { + ...RenderSubmittedMessageFragment + } + } +`; + +interface SubscribeAndRenderPropsList { + result: + | SubmissionGraphQLErrorMessage + | SubmissionNetworkErrorMessage + | SubmissionSuccessMessage; + visit: Visit; + workflowName: string; + index: number; +} + +export const SubscribeAndRender: React.FC = ({ + result, + visit, + workflowName, + index, +}) => { + const [workflowData, setWorkflowData] = + useState(null); + + const subscriptionData: GraphQLSubscriptionConfig = + useMemo( + () => ({ + subscription: SubscribeAndRenderSubscription, + variables: { visit, name: workflowName }, + onNext: (response) => { + setWorkflowData(response ?? null); + }, + onError: (error: unknown) => { + console.error("Subscription error:", error); + }, + onCompleted: () => { + console.log("completed"); + }, + }), + [visit, workflowName], + ); + useSubscription(subscriptionData); + + return ( + + ); +}; diff --git a/frontend/workflows-lib/lib/components/template/TemplateCard.tsx b/frontend/relay-workflows-lib/lib/components/TemplateCard.tsx similarity index 71% rename from frontend/workflows-lib/lib/components/template/TemplateCard.tsx rename to frontend/relay-workflows-lib/lib/components/TemplateCard.tsx index f6db28727..0991da948 100644 --- a/frontend/workflows-lib/lib/components/template/TemplateCard.tsx +++ b/frontend/relay-workflows-lib/lib/components/TemplateCard.tsx @@ -2,19 +2,30 @@ import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import CardActionArea from "@mui/material/CardActionArea"; import Typography from "@mui/material/Typography"; -import { Template } from "../../types"; import React from "react"; import { Container, Box, Stack } from "@mui/material"; import { useLocation, useNavigate } from "react-router-dom"; +import { graphql, useFragment } from "react-relay"; +import type { TemplateCard_template$key } from "./__generated__/TemplateCard_template.graphql"; + +const templateCardFragment = graphql` + fragment TemplateCard_template on WorkflowTemplate { + name + title + description + maintainer + repository + } +`; export interface TemplateCardProps { - template: Template; + template: TemplateCard_template$key; } export const TemplateCard: React.FC = ({ template }) => { + const data = useFragment(templateCardFragment, template); const location = useLocation(); const navigate = useNavigate(); - const reroute = (templateName: string) => { const path = location.pathname.split("/")[1]; (navigate(`/${path}/${templateName}`) as Promise).catch( @@ -23,14 +34,13 @@ export const TemplateCard: React.FC = ({ template }) => { }, ); }; - return ( { - reroute(template.name); + reroute(data.name); }} > @@ -41,10 +51,10 @@ export const TemplateCard: React.FC = ({ template }) => { alignItems="center" > - {template.title ?? template.name} + {data.title ?? data.name} - Maintainer: {template.maintainer} + Maintainer: {data.maintainer} = ({ template }) => { justifyContent="space-between" alignItems="center" > - {template.name} - {template.repository && ( + {data.name} + {data.repository && ( - Repository: {template.repository} + Repository: {data.repository} )} - {template.description && ( - - {template.description} - + {data.description && ( + {data.description} )} diff --git a/frontend/relay-workflows-lib/lib/components/TemplatesList.tsx b/frontend/relay-workflows-lib/lib/components/TemplatesList.tsx deleted file mode 100644 index 33bb60685..000000000 --- a/frontend/relay-workflows-lib/lib/components/TemplatesList.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { ChangeEvent, useMemo, useState } from "react"; -import { useLazyLoadQuery } from "react-relay/hooks"; -import { Box, Pagination } from "@mui/material"; -import { TemplateCard } from "workflows-lib/lib/components/template/TemplateCard"; -import { templatesListQuery } from "../graphql/TemplatesListQuery"; -import { TemplatesListQuery as TemplatesListQueryType } from "../graphql/__generated__/TemplatesListQuery.graphql"; -import { useClientSidePagination } from "../utils"; -import TemplateSearchField from "workflows-lib/lib/components/template/TemplateSearchField"; - -export default function TemplatesList() { - const data = useLazyLoadQuery(templatesListQuery, {}); - const [search, setSearch] = useState(""); - - const filteredTemplates = useMemo(() => { - const upperSearch = search.toUpperCase(); - const allTemplates = data.workflowTemplates.nodes; - - if (!search) return allTemplates; - - return allTemplates.filter( - (template) => - template.title?.toUpperCase().includes(upperSearch) || - template.name.toUpperCase().includes(upperSearch) || - template.description?.toUpperCase().includes(upperSearch), - ); - }, [search, data]); - - const handleSearch = (search: string) => { - setSearch(search); - }; - - const { - pageNumber, - setPageNumber, - totalPages, - paginatedItems: paginatedPosts, - } = useClientSidePagination(filteredTemplates, 10); - - const handlePageChange = (_event: ChangeEvent, page: number) => { - setPageNumber(page); - }; - - return ( - <> - - - {paginatedPosts.map((template) => ( - - ))} - - - - - - ); -} diff --git a/frontend/relay-workflows-lib/lib/graphql/TemplatesListQuery.ts b/frontend/relay-workflows-lib/lib/graphql/TemplatesListQuery.ts deleted file mode 100644 index 5a14cba2d..000000000 --- a/frontend/relay-workflows-lib/lib/graphql/TemplatesListQuery.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { graphql } from "relay-runtime"; - -export const templatesListQuery = graphql` - query TemplatesListQuery { - workflowTemplates { - nodes { - name - description - title - maintainer - repository - } - } - } -`; diff --git a/frontend/relay-workflows-lib/lib/graphql/workflowTemplateFragment.ts b/frontend/relay-workflows-lib/lib/graphql/workflowTemplateFragment.ts deleted file mode 100644 index 28db243c3..000000000 --- a/frontend/relay-workflows-lib/lib/graphql/workflowTemplateFragment.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { graphql } from "react-relay"; - -export const workflowTemplateFragment = graphql` - fragment workflowTemplateFragment on WorkflowTemplate { - name - maintainer - title - description - arguments - uiSchema - repository - } -`; diff --git a/frontend/relay-workflows-lib/lib/components/TemplateView.tsx b/frontend/relay-workflows-lib/lib/views/TemplateView.tsx similarity index 50% rename from frontend/relay-workflows-lib/lib/components/TemplateView.tsx rename to frontend/relay-workflows-lib/lib/views/TemplateView.tsx index 6f6c330a6..165c82192 100644 --- a/frontend/relay-workflows-lib/lib/components/TemplateView.tsx +++ b/frontend/relay-workflows-lib/lib/views/TemplateView.tsx @@ -2,23 +2,24 @@ import { useState } from "react"; import { useLazyLoadQuery, useMutation } from "react-relay/hooks"; import { graphql } from "relay-runtime"; import { Box } from "@mui/material"; -import { JSONObject, SubmissionData, Visit } from "workflows-lib"; +import { SubmissionData, Visit } from "workflows-lib"; import { visitToText } from "@diamondlightsource/sci-react-ui"; -import SubmissionForm from "./SubmissionForm"; -import { TemplateViewQuery as TemplateViewQueryType } from "./__generated__/TemplateViewQuery.graphql"; -import { TemplateViewMutation as TemplateViewMutationType } from "./__generated__/TemplateViewMutation.graphql"; +import SubmissionForm from "../components/SubmissionForm"; +import SubmittedMessagesList from "workflows-lib/lib/components/workflow/SubmittedMessagesList"; import { visitTextToVisit } from "workflows-lib/lib/utils/commonUtils"; -import SubmittedMessagesList from "./SubmittedMessagesList"; +import { TemplateViewMutation } from "./__generated__/TemplateViewMutation.graphql"; +import { TemplateViewQuery as TemplateViewQueryType } from "./__generated__/TemplateViewQuery.graphql"; +import { SubmissionFormParametersFragment$key } from "../components/__generated__/SubmissionFormParametersFragment.graphql"; -const templateViewQuery = graphql` +const TemplateViewQuery = graphql` query TemplateViewQuery($templateName: String!) { workflowTemplate(name: $templateName) { - ...workflowTemplateFragment + ...SubmissionFormFragment } } `; -const templateViewMutation = graphql` +const mutation = graphql` mutation TemplateViewMutation( $templateName: String! $visit: VisitInput! @@ -41,51 +42,36 @@ export default function TemplateView({ }: { templateName: string; visit?: Visit; - prepopulatedParameters?: JSONObject; + prepopulatedParameters?: SubmissionFormParametersFragment$key; }) { - const data = useLazyLoadQuery(templateViewQuery, { - templateName, - }); - const storedVisit = visitTextToVisit( localStorage.getItem("instrumentSessionID") ?? "", ); - const [submissionData, setSubmissionData] = useState([]); - - const [commitMutation] = - useMutation(templateViewMutation); - + const [commitMutation] = useMutation(mutation); + const templateData = useLazyLoadQuery( + TemplateViewQuery, + { templateName }, + { fetchPolicy: "store-or-network" }, + ); function submitWorkflow(visit: Visit, parameters: object) { commitMutation({ - variables: { - templateName: templateName, - visit: visit, - parameters: parameters, - }, + variables: { templateName, visit, parameters }, onCompleted: (response, errors) => { if (errors?.length) { - console.error("GraphQL errors:", errors); setSubmissionData((prev) => [ - { - submissionResult: { - type: "graphQLError", - errors: errors, - }, - visit: visit, - }, + { submissionResult: { type: "graphQLError", errors }, visit }, ...prev, ]); } else { const submittedName = response.submitWorkflowTemplate.name; - console.log("Successfully submitted:", submittedName); setSubmissionData((prev) => [ { submissionResult: { type: "success", message: `${visitToText(visit)}/${submittedName}`, }, - visit: visit, + visit, workflowName: submittedName, }, ...prev, @@ -94,45 +80,25 @@ export default function TemplateView({ } }, onError: (err) => { - console.error("Submission failed:", err); setSubmissionData((prev) => [ - { - submissionResult: { - type: "networkError", - error: err, - }, - visit: visit, - }, + { submissionResult: { type: "networkError", error: err }, visit }, ...prev, ]); }, }); } + return ( - <> - {templateName ? ( - - - - - - - ) : ( - <>No Template Name provided - )} - + + + + + + ); } diff --git a/frontend/relay-workflows-lib/lib/components/TemplateViewRetrigger.tsx b/frontend/relay-workflows-lib/lib/views/TemplateViewRetrigger.tsx similarity index 64% rename from frontend/relay-workflows-lib/lib/components/TemplateViewRetrigger.tsx rename to frontend/relay-workflows-lib/lib/views/TemplateViewRetrigger.tsx index 8d78e9472..10b6dc57c 100644 --- a/frontend/relay-workflows-lib/lib/components/TemplateViewRetrigger.tsx +++ b/frontend/relay-workflows-lib/lib/views/TemplateViewRetrigger.tsx @@ -1,16 +1,16 @@ import { useLazyLoadQuery } from "react-relay/hooks"; import { graphql } from "relay-runtime"; -import { JSONObject, Visit } from "workflows-lib"; +import { Visit } from "workflows-lib"; import { TemplateViewRetriggerQuery as TemplateViewRetriggerQueryType } from "./__generated__/TemplateViewRetriggerQuery.graphql"; import TemplateView from "./TemplateView"; -const templateViewRetriggerQuery = graphql` +const TemplateViewRetriggerQuery = graphql` query TemplateViewRetriggerQuery( $visit: VisitInput! - $workflowname: String! + $workflowName: String! ) { - workflow(visit: $visit, name: $workflowname) { - parameters + workflow(visit: $visit, name: $workflowName) { + ...SubmissionFormParametersFragment } } `; @@ -25,20 +25,18 @@ export default function TemplateViewWithRetrigger({ visit: Visit; }) { const retriggerData = useLazyLoadQuery( - templateViewRetriggerQuery, + TemplateViewRetriggerQuery, { visit, - workflowname: workflowName, + workflowName: workflowName, }, ); - const prepopulatedParameters = retriggerData.workflow - .parameters as JSONObject; return ( ); } diff --git a/frontend/relay-workflows-lib/lib/views/TemplatesListView.tsx b/frontend/relay-workflows-lib/lib/views/TemplatesListView.tsx new file mode 100644 index 000000000..10bbe1796 --- /dev/null +++ b/frontend/relay-workflows-lib/lib/views/TemplatesListView.tsx @@ -0,0 +1,114 @@ +import { ChangeEvent, useMemo, useState } from "react"; +import TemplateCard from "../components/TemplateCard"; +import { graphql, useLazyLoadQuery, useFragment } from "react-relay/hooks"; +import { Box, Pagination } from "@mui/material"; +import type { TemplatesListViewQuery_templateSearch$key } from "./__generated__/TemplatesListViewQuery_templateSearch.graphql"; +import { useClientSidePagination } from "../utils/coreUtils"; +import TemplateSearchField from "workflows-lib/lib/components/template/TemplateSearchField"; +import { TemplatesListViewQuery as TemplatesListViewQueryType } from "./__generated__/TemplatesListViewQuery.graphql"; + +const templateSearchFragment = graphql` + fragment TemplatesListViewQuery_templateSearch on WorkflowTemplate { + name + title + description + } +`; + +export const TemplatesListViewQuery = graphql` + query TemplatesListViewQuery { + workflowTemplates { + nodes { + ...TemplateCard_template + ...TemplatesListViewQuery_templateSearch + } + } + } +`; + +export default function TemplatesListView() { + const data = useLazyLoadQuery( + TemplatesListViewQuery, + {}, + ); + const [search, setSearch] = useState(""); + + const templatesWithSearchData = data.workflowTemplates.nodes.map( + (template) => + template + ? { + original: template, + searchData: useFragment( + templateSearchFragment, + template as TemplatesListViewQuery_templateSearch$key, + ), + } + : null, + ); + + const filteredTemplates = useMemo(() => { + const upperSearch = search.toUpperCase(); + if (!search) + return templatesWithSearchData.map((t) => t?.original).filter(Boolean); + + return templatesWithSearchData + .filter((t) => { + if (!t) return false; + const { searchData } = t; + return ( + searchData.title?.toUpperCase().includes(upperSearch) || + searchData.name.toUpperCase().includes(upperSearch) || + searchData.description?.toUpperCase().includes(upperSearch) + ); + }) + .map((t) => t?.original); + }, [search, data]); + + const handleSearch = (search: string) => { + setSearch(search); + }; + + const { + pageNumber, + setPageNumber, + totalPages, + paginatedItems: paginatedPosts, + } = useClientSidePagination(filteredTemplates, 10); + + const handlePageChange = (_event: ChangeEvent, page: number) => { + setPageNumber(page); + }; + + return ( + <> + + + {paginatedPosts.map((template, i) => + template ? : null, + )} + + + + + + ); +} diff --git a/frontend/workflows-lib/lib/main.ts b/frontend/workflows-lib/lib/main.ts index 63e97f823..b327d8962 100644 --- a/frontend/workflows-lib/lib/main.ts +++ b/frontend/workflows-lib/lib/main.ts @@ -2,7 +2,6 @@ export { default as WorkflowAccordion } from "./components/workflow/WorkflowAcco export { default as TasksFlow } from "./components/workflow/TasksFlow"; export { default as TasksTable } from "./components/workflow/TasksTable"; export { default as SubmissionForm } from "./components/template/SubmissionForm"; -export { default as TemplateCard } from "./components/template/TemplateCard"; export { default as WorkflowsErrorBoundary } from "./components/workflow/WorkflowsErrorBoundary"; export { default as WorkflowsNavbar } from "./components/workflow/WorkflowsNavbar"; export { default as PaginationControls } from "./components/common/PaginationControls"; diff --git a/frontend/workflows-lib/tests/components/TemplateList.test.tsx b/frontend/workflows-lib/tests/components/TemplateList.test.tsx index c6612b0c1..265123bd4 100644 --- a/frontend/workflows-lib/tests/components/TemplateList.test.tsx +++ b/frontend/workflows-lib/tests/components/TemplateList.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import "@testing-library/jest-dom"; import TemplatesList from "relay-workflows-lib/lib/components/TemplatesList"; -import { TemplateCardProps } from "../../lib/components/template/TemplateCard"; +import { TemplateCardProps } from "relay-workflows-lib/lib/components/TemplateCard"; import { Box } from "@mui/material"; import userEvent from "@testing-library/user-event"; import templateListResponse from "dashboard/src/mocks/responses/templates/templateListResponse.json"; @@ -17,7 +17,9 @@ vi.mock("relay-runtime", () => ({ })); vi.mock("workflows-lib/lib/components/template/TemplateCard", () => ({ - TemplateCard: (props: TemplateCardProps) => {props.template.name}, + TemplateCard: (props: TemplateCardProps) => ( + {(props as any).template.name} + ), })); vi.mock("react-relay/hooks", () => ({ From b182b42f8a62956a60e03002a97c08e8b6265f19 Mon Sep 17 00:00:00 2001 From: VictoriaBeilstenEdmands Date: Fri, 3 Oct 2025 15:13:51 +0100 Subject: [PATCH 2/2] feat(frontend): add fragments to workflows pages --- frontend/dashboard/src/mocks/handlers.ts | 77 +- .../templates/conditionalStepsRetrigger.json | 3 + .../templates/e02Mib2xRetriggerResponse.json | 285 ++++++ .../templates/fallbackRetriggerResponse.json | 7 + .../responses/templates/templateResponses.tsx | 20 +- .../templateViewRetriggerResponse.ts | 25 + .../templates/workflowsListViewTemplates.ts | 5 + .../SingleWorkflowViewQueryResponse.ts | 97 ++ .../WorkflowsListViewQueryResponse.ts | 924 ++++++++++++++++++ .../src/routes/SingleWorkflowPage.tsx | 2 +- .../src/routes/WorkflowsListPage.tsx | 57 +- .../lib/components/BaseWorkflowRelay.tsx | 52 +- .../lib/components/LiveSingleWorkflowView.tsx | 41 - .../lib/components/LiveWorkflowRelay.tsx | 67 -- .../lib/components/QueryWorkflowRelay.tsx | 29 - .../lib/components/SingleWorkflowView.tsx | 31 - .../lib/components/SubmissionForm.tsx | 8 +- .../lib/components}/TasksFlow.tsx | 16 +- .../lib/components/TemplateCard.tsx | 6 +- .../lib/components/WorkflowInfo.tsx | 52 +- .../components/WorkflowListFilterDrawer.tsx | 21 +- .../lib/components/WorkflowRelay.tsx | 42 +- .../lib/components/Workflows.tsx | 77 -- .../lib/components/WorkflowsContent.tsx | 143 +-- .../lib/graphql/WorkflowTasksFragment.ts | 71 ++ frontend/relay-workflows-lib/lib/main.ts | 5 +- .../RetriggerWorkflow.tsx | 1 + .../LiveWorkflowRelay.tsx | 85 ++ .../SubscribeAndRender.tsx | 2 +- frontend/relay-workflows-lib/lib/utils.ts | 67 -- .../lib/utils/coreUtils.ts | 135 +++ .../useServerSidePagination.ts | 0 .../lib/utils/useTemplateMatchesSearch.ts | 19 + .../workflowRelayUtils.ts | 29 +- .../BaseSingleWorkflowView.tsx | 45 +- .../lib/views/LiveSingleWorkflowView.tsx | 80 ++ .../lib/views/SingleWorkflowView.tsx | 55 ++ .../lib/views/TemplatesListView.tsx | 83 +- .../lib/views/WorkflowsListView.tsx | 155 +++ frontend/relay-workflows-lib/package.json | 2 + .../workflow}/SubmittedMessagesList.tsx | 4 +- frontend/workflows-lib/lib/main.ts | 1 - .../stories/TasksFlow.stories.tsx | 65 -- .../stories/WorkflowAccordion.stories.tsx | 12 +- .../tests/components/TasksDynamic.test.tsx | 113 --- .../tests/components/TasksFlow.test.tsx | 197 ---- .../tests/components/TasksTable.test.tsx | 51 - .../tests/components/TemplateList.test.tsx | 89 -- .../components/WorkflowAccordion.test.tsx | 15 +- .../WorkflowsListFilterDrawer.test.tsx | 75 -- frontend/yarn.lock | 21 +- 51 files changed, 2343 insertions(+), 1221 deletions(-) create mode 100644 frontend/dashboard/src/mocks/responses/templates/conditionalStepsRetrigger.json create mode 100644 frontend/dashboard/src/mocks/responses/templates/e02Mib2xRetriggerResponse.json create mode 100644 frontend/dashboard/src/mocks/responses/templates/fallbackRetriggerResponse.json create mode 100644 frontend/dashboard/src/mocks/responses/templates/templateViewRetriggerResponse.ts create mode 100644 frontend/dashboard/src/mocks/responses/templates/workflowsListViewTemplates.ts create mode 100644 frontend/dashboard/src/mocks/responses/workflows/SingleWorkflowViewQueryResponse.ts create mode 100644 frontend/dashboard/src/mocks/responses/workflows/WorkflowsListViewQueryResponse.ts delete mode 100644 frontend/relay-workflows-lib/lib/components/LiveSingleWorkflowView.tsx delete mode 100644 frontend/relay-workflows-lib/lib/components/LiveWorkflowRelay.tsx delete mode 100644 frontend/relay-workflows-lib/lib/components/QueryWorkflowRelay.tsx delete mode 100644 frontend/relay-workflows-lib/lib/components/SingleWorkflowView.tsx rename frontend/{workflows-lib/lib/components/workflow => relay-workflows-lib/lib/components}/TasksFlow.tsx (91%) delete mode 100644 frontend/relay-workflows-lib/lib/components/Workflows.tsx create mode 100644 frontend/relay-workflows-lib/lib/graphql/WorkflowTasksFragment.ts rename frontend/relay-workflows-lib/lib/{components => query-components}/RetriggerWorkflow.tsx (99%) create mode 100644 frontend/relay-workflows-lib/lib/subscription-components/LiveWorkflowRelay.tsx rename frontend/relay-workflows-lib/lib/{components => subscription-components}/SubscribeAndRender.tsx (96%) delete mode 100644 frontend/relay-workflows-lib/lib/utils.ts create mode 100644 frontend/relay-workflows-lib/lib/utils/coreUtils.ts rename frontend/relay-workflows-lib/lib/{components => utils}/useServerSidePagination.ts (100%) create mode 100644 frontend/relay-workflows-lib/lib/utils/useTemplateMatchesSearch.ts rename frontend/relay-workflows-lib/lib/{components => utils}/workflowRelayUtils.ts (70%) rename frontend/relay-workflows-lib/lib/{components => views}/BaseSingleWorkflowView.tsx (76%) create mode 100644 frontend/relay-workflows-lib/lib/views/LiveSingleWorkflowView.tsx create mode 100644 frontend/relay-workflows-lib/lib/views/SingleWorkflowView.tsx create mode 100644 frontend/relay-workflows-lib/lib/views/WorkflowsListView.tsx rename frontend/{relay-workflows-lib/lib/components => workflows-lib/lib/components/workflow}/SubmittedMessagesList.tsx (87%) delete mode 100644 frontend/workflows-lib/stories/TasksFlow.stories.tsx delete mode 100644 frontend/workflows-lib/tests/components/TasksDynamic.test.tsx delete mode 100644 frontend/workflows-lib/tests/components/TasksFlow.test.tsx delete mode 100644 frontend/workflows-lib/tests/components/TasksTable.test.tsx delete mode 100644 frontend/workflows-lib/tests/components/TemplateList.test.tsx delete mode 100644 frontend/workflows-lib/tests/components/WorkflowsListFilterDrawer.test.tsx diff --git a/frontend/dashboard/src/mocks/handlers.ts b/frontend/dashboard/src/mocks/handlers.ts index 737b37883..5fb198721 100644 --- a/frontend/dashboard/src/mocks/handlers.ts +++ b/frontend/dashboard/src/mocks/handlers.ts @@ -2,28 +2,32 @@ import { graphql, HttpResponse } from "msw"; import { RetriggerWorkflowQuery$data, RetriggerWorkflowQuery$variables, -} from "relay-workflows-lib/lib/components/__generated__/RetriggerWorkflowQuery.graphql"; +} from "relay-workflows-lib/lib/query-components/__generated__/RetriggerWorkflowQuery.graphql"; import { TemplateViewMutation$data, TemplateViewMutation$variables, -} from "relay-workflows-lib/lib/components/__generated__/TemplateViewMutation.graphql"; +} from "relay-workflows-lib/lib/views/__generated__/TemplateViewMutation.graphql"; import { TemplateViewQuery$data, TemplateViewQuery$variables, -} from "relay-workflows-lib/lib/components/__generated__/TemplateViewQuery.graphql"; +} from "relay-workflows-lib/lib/views/__generated__/TemplateViewQuery.graphql"; import { workflowsQuery$data, workflowsQuery$variables, } from "relay-workflows-lib/lib/graphql/__generated__/workflowsQuery.graphql"; import { - TemplatesListQuery$data, - TemplatesListQuery$variables, -} from "relay-workflows-lib/lib/graphql/__generated__/TemplatesListQuery.graphql"; + TemplatesListViewQuery$data, + TemplatesListViewQuery$variables, +} from "relay-workflows-lib/lib/views/__generated__/TemplatesListViewQuery.graphql"; import templateListResponse from "./responses/templates/templateListResponse.json"; import { templateViewResponse, templateFallbackResponse, } from "./responses/templates/templateResponses"; +import { + templateRetriggerResponse, + templateFallbackRetriggerResponse, +} from "./responses/templates/templateViewRetriggerResponse"; import workflowsListResponse from "./responses/workflows/workflowsListResponse.json"; import { workflowRelayQuery$data, @@ -33,6 +37,25 @@ import { defaultWorkflowResponse, workflowRelayMockResponses, } from "./responses/workflows/workflowResponses"; +import { + WorkflowsListViewTemplatesQuery$data, + WorkflowsListViewTemplatesQuery$variables, +} from "relay-workflows-lib/lib/views/__generated__/WorkflowsListViewTemplatesQuery.graphql"; +import { workflowsListViewTemplatesResponse } from "./responses/templates/workflowsListViewTemplates"; +import { workflowsListViewQueryResponse } from "./responses/workflows/WorkflowsListViewQueryResponse"; +import { + WorkflowsListViewQuery$data, + WorkflowsListViewQuery$variables, +} from "relay-workflows-lib/lib/views/__generated__/WorkflowsListViewQuery.graphql"; +import { + SingleWorkflowViewQuery$data, + SingleWorkflowViewQuery$variables, +} from "relay-workflows-lib/lib/views/__generated__/SingleWorkflowViewQuery.graphql"; +import { singleWorkflowViewQueryResponse } from "./responses/workflows/SingleWorkflowViewQueryResponse"; +import { + TemplateViewRetriggerQuery$data, + TemplateViewRetriggerQuery$variables, +} from "relay-workflows-lib/lib/views/__generated__/TemplateViewRetriggerQuery.graphql"; const api = graphql.link("https://workflows.diamond.ac.uk/graphql"); @@ -55,6 +78,32 @@ export const handlers = [ }, ), + api.query< + WorkflowsListViewTemplatesQuery$data, + WorkflowsListViewTemplatesQuery$variables + >("WorkflowsListViewTemplatesQuery", () => { + return HttpResponse.json({ + data: workflowsListViewTemplatesResponse as unknown as WorkflowsListViewTemplatesQuery$data, + }); + }), + + api.query( + "WorkflowsListViewQuery", + () => { + return HttpResponse.json({ + data: workflowsListViewQueryResponse as unknown as WorkflowsListViewQuery$data, + }); + }, + ), + api.query( + "SingleWorkflowViewQuery", + () => { + return HttpResponse.json({ + data: singleWorkflowViewQueryResponse as unknown as SingleWorkflowViewQuery$data, + }); + }, + ), + api.query( "RetriggerWorkflowQuery", ({ variables }) => { @@ -68,15 +117,25 @@ export const handlers = [ }, ), - api.query( - "TemplatesListQuery", + api.query( + "TemplatesListViewQuery", () => { return HttpResponse.json({ - data: templateListResponse, + data: templateListResponse as unknown as TemplatesListViewQuery$data, }); }, ), + api.query< + TemplateViewRetriggerQuery$data, + TemplateViewRetriggerQuery$variables + >("TemplateViewRetriggerQuery", ({ variables }) => { + const response = + templateRetriggerResponse[variables.workflowName] ?? + templateFallbackRetriggerResponse; + return HttpResponse.json({ data: response }); + }), + api.query( "TemplateViewQuery", ({ variables }) => { diff --git a/frontend/dashboard/src/mocks/responses/templates/conditionalStepsRetrigger.json b/frontend/dashboard/src/mocks/responses/templates/conditionalStepsRetrigger.json new file mode 100644 index 000000000..4b448a37f --- /dev/null +++ b/frontend/dashboard/src/mocks/responses/templates/conditionalStepsRetrigger.json @@ -0,0 +1,3 @@ +{ + "parameters": {} +} diff --git a/frontend/dashboard/src/mocks/responses/templates/e02Mib2xRetriggerResponse.json b/frontend/dashboard/src/mocks/responses/templates/e02Mib2xRetriggerResponse.json new file mode 100644 index 000000000..dd00b28c3 --- /dev/null +++ b/frontend/dashboard/src/mocks/responses/templates/e02Mib2xRetriggerResponse.json @@ -0,0 +1,285 @@ +{ + "name": "e02-mib2x", + "maintainer": "imaging-e02-group", + "title": "ePSIC mib conversion", + "description": "Convert MIB file to hdf5/hspy files\n", + "arguments": { + "properties": { + "DEBUG": { + "default": 1, + "type": "integer" + }, + "Scan_X": { + "default": 256, + "minimum": 0, + "type": "integer" + }, + "Scan_Y": { + "default": 256, + "minimum": 0, + "type": "integer" + }, + "bin_nav_factor": { + "default": 4, + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "bin_sig_factor": { + "default": 4, + "maximum": 8, + "minimum": 0, + "type": "integer" + }, + "create_json": { + "default": false, + "type": "boolean" + }, + "iBF": { + "default": true, + "type": "boolean" + }, + "memory": { + "default": "16Gi", + "pattern": "^[0-9]+[GMK]i$", + "type": "string" + }, + "mib_path": { + "type": "string" + }, + "nprocs": { + "default": 8, + "minimum": 0, + "type": "integer" + }, + "ptycho_config": { + "default": "", + "type": "string" + }, + "ptycho_template": { + "default": "", + "type": "string" + }, + "reshape_option": { + "default": "--auto-reshape", + "oneOf": [ + { + "const": "--auto-reshape", + "title": "Auto" + }, + { + "const": "--no-reshaping", + "title": "None" + }, + { + "const": "--use-fly-back", + "title": "Fly-back" + }, + { + "const": "--known-shape", + "title": "By known shape" + } + ], + "type": "string" + } + }, + "required": [ + "DEBUG", + "Scan_X", + "Scan_Y", + "bin_nav_factor", + "bin_sig_factor", + "create_json", + "iBF", + "memory", + "mib_path", + "nprocs", + "ptycho_config", + "ptycho_template", + "reshape_option" + ], + "type": "object" + }, + "uiSchema": { + "type": "VerticalLayout", + "elements": [ + { + "type": "Group", + "label": "Input Files", + "elements": [ + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Control", + "scope": "#/properties/mib_path", + "label": "Path of MIB file", + "options": null, + "rule": null + }, + { + "type": "Control", + "scope": "#/properties/iBF", + "label": "Generate integrated bright-field image", + "options": null, + "rule": null + } + ], + "options": null, + "rule": null + } + ], + "options": null, + "rule": null + }, + { + "type": "VerticalLayout", + "elements": [ + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Group", + "label": "Reshape Options", + "elements": [ + { + "type": "Control", + "scope": "#/properties/reshape_option", + "label": "Reshape methods", + "options": null, + "rule": null + }, + { + "type": "Control", + "scope": "#/properties/Scan_X", + "label": "Dimension in x (row)", + "options": null, + "rule": null + }, + { + "type": "Control", + "scope": "#/properties/Scan_Y", + "label": "Dimension in y (column)", + "options": null, + "rule": null + } + ], + "options": null, + "rule": null + }, + { + "type": "Group", + "label": "Pixel Binning", + "elements": [ + { + "type": "Control", + "scope": "#/properties/bin_sig_factor", + "label": "Signal binning factor (detector)", + "options": null, + "rule": null + }, + { + "type": "Control", + "scope": "#/properties/bin_nav_factor", + "label": "Navigation binning factor", + "options": null, + "rule": null + } + ], + "options": null, + "rule": null + } + ], + "options": null, + "rule": null + }, + { + "type": "HorizontalLayout", + "elements": [ + { + "type": "Group", + "label": "Configs for PtyREX", + "elements": [ + { + "type": "Control", + "scope": "#/properties/create_json", + "label": "Create PtyREX config", + "options": null, + "rule": null + }, + { + "type": "Control", + "scope": "#/properties/ptycho_config", + "label": "The name of the generated config", + "options": null, + "rule": { + "condition": { + "schema": { + "const": false + }, + "scope": "#/properties/create_json" + }, + "effect": "HIDE" + } + }, + { + "type": "Control", + "scope": "#/properties/ptycho_template", + "label": "Path of the PtyREX template config", + "options": null, + "rule": { + "condition": { + "schema": { + "const": false + }, + "scope": "#/properties/create_json" + }, + "effect": "HIDE" + } + } + ], + "options": null, + "rule": null + }, + { + "type": "Group", + "label": "Resource and Debug Settings", + "elements": [ + { + "type": "Control", + "scope": "#/properties/nprocs", + "options": null, + "rule": null + }, + { + "type": "Control", + "scope": "#/properties/memory", + "options": null, + "rule": null + }, + { + "type": "Control", + "scope": "#/properties/DEBUG", + "options": null, + "rule": null + } + ], + "options": null, + "rule": null + } + ], + "options": null, + "rule": null + } + ], + "options": null, + "rule": null + } + ], + "options": { + "formWidth": "80%" + }, + "rule": null + }, + "repository": null +} diff --git a/frontend/dashboard/src/mocks/responses/templates/fallbackRetriggerResponse.json b/frontend/dashboard/src/mocks/responses/templates/fallbackRetriggerResponse.json new file mode 100644 index 000000000..935b28258 --- /dev/null +++ b/frontend/dashboard/src/mocks/responses/templates/fallbackRetriggerResponse.json @@ -0,0 +1,7 @@ +{ + "parameters": { + "number": 5, + "start": 1, + "stop": 6 + } +} diff --git a/frontend/dashboard/src/mocks/responses/templates/templateResponses.tsx b/frontend/dashboard/src/mocks/responses/templates/templateResponses.tsx index 8e4807e97..22d3779eb 100644 --- a/frontend/dashboard/src/mocks/responses/templates/templateResponses.tsx +++ b/frontend/dashboard/src/mocks/responses/templates/templateResponses.tsx @@ -5,36 +5,34 @@ import fallbackTemplate from "./fallbackTemplate.json"; import notebookTemplate from "./notebookTemplate.json"; import ptychoTomoJobTemplate from "./ptychoTomoJobTemplate.json"; import sinSimulateTemplate from "./sinSimulateArtifactTemplate.json"; -import { TemplateViewQuery$data } from "relay-workflows-lib/lib/components/__generated__/TemplateViewQuery.graphql"; -import type { workflowTemplateFragment$key } from "relay-workflows-lib/lib/graphql/__generated__/workflowTemplateFragment.graphql"; +import { TemplateViewQuery$data } from "relay-workflows-lib/lib/views/__generated__/TemplateViewQuery.graphql"; +import { SubmissionFormFragment$key } from "relay-workflows-lib/lib/components/__generated__/SubmissionFormFragment.graphql"; export const templateViewResponse: Record = { "conditional-steps": { workflowTemplate: - conditionalStepsTemplate as unknown as workflowTemplateFragment$key, + conditionalStepsTemplate as unknown as SubmissionFormFragment$key, }, "e02-mib2x": { - workflowTemplate: - e02Mib2xTemplate as unknown as workflowTemplateFragment$key, + workflowTemplate: e02Mib2xTemplate as unknown as SubmissionFormFragment$key, }, "httomo-cor-sweep": { workflowTemplate: - httomoCorSweepTemplate as unknown as workflowTemplateFragment$key, + httomoCorSweepTemplate as unknown as SubmissionFormFragment$key, }, notebook: { - workflowTemplate: - notebookTemplate as unknown as workflowTemplateFragment$key, + workflowTemplate: notebookTemplate as unknown as SubmissionFormFragment$key, }, "ptycho-tomo-job": { workflowTemplate: - ptychoTomoJobTemplate as unknown as workflowTemplateFragment$key, + ptychoTomoJobTemplate as unknown as SubmissionFormFragment$key, }, "sin-simulate-artifact": { workflowTemplate: - sinSimulateTemplate as unknown as workflowTemplateFragment$key, + sinSimulateTemplate as unknown as SubmissionFormFragment$key, }, }; export const templateFallbackResponse: TemplateViewQuery$data = { - workflowTemplate: fallbackTemplate as unknown as workflowTemplateFragment$key, + workflowTemplate: fallbackTemplate as unknown as SubmissionFormFragment$key, }; diff --git a/frontend/dashboard/src/mocks/responses/templates/templateViewRetriggerResponse.ts b/frontend/dashboard/src/mocks/responses/templates/templateViewRetriggerResponse.ts new file mode 100644 index 000000000..3edb4d3e0 --- /dev/null +++ b/frontend/dashboard/src/mocks/responses/templates/templateViewRetriggerResponse.ts @@ -0,0 +1,25 @@ +import fallbackRetriggerResponse from "./fallbackRetriggerResponse.json"; +import e02Mib2xRetriggerResponse from "./e02Mib2xRetriggerResponse.json"; +import conditionalStepsRetrigger from "./conditionalStepsRetrigger.json"; +import { SubmissionFormParametersFragment$key } from "relay-workflows-lib/lib/components/__generated__/SubmissionFormParametersFragment.graphql"; +import { TemplateViewRetriggerQuery$data } from "relay-workflows-lib/lib/views/__generated__/TemplateViewRetriggerQuery.graphql"; + +export const templateRetriggerResponse: Record< + string, + TemplateViewRetriggerQuery$data +> = { + "mg-template-for-conditional-steps-first": { + workflow: + conditionalStepsRetrigger as unknown as SubmissionFormParametersFragment$key, + }, + "e02-mib2x": { + workflow: + e02Mib2xRetriggerResponse as unknown as SubmissionFormParametersFragment$key, + }, +}; + +export const templateFallbackRetriggerResponse: TemplateViewRetriggerQuery$data = + { + workflow: + fallbackRetriggerResponse as unknown as SubmissionFormParametersFragment$key, + }; diff --git a/frontend/dashboard/src/mocks/responses/templates/workflowsListViewTemplates.ts b/frontend/dashboard/src/mocks/responses/templates/workflowsListViewTemplates.ts new file mode 100644 index 000000000..d056f25a6 --- /dev/null +++ b/frontend/dashboard/src/mocks/responses/templates/workflowsListViewTemplates.ts @@ -0,0 +1,5 @@ +export const workflowsListViewTemplatesResponse = { + workflowTemplates: { + nodes: [{ name: "Template A" }, { name: "Template B" }], + }, +}; diff --git a/frontend/dashboard/src/mocks/responses/workflows/SingleWorkflowViewQueryResponse.ts b/frontend/dashboard/src/mocks/responses/workflows/SingleWorkflowViewQueryResponse.ts new file mode 100644 index 000000000..cee036336 --- /dev/null +++ b/frontend/dashboard/src/mocks/responses/workflows/SingleWorkflowViewQueryResponse.ts @@ -0,0 +1,97 @@ +export const singleWorkflowViewQueryResponse = { + workflow: { + name: "conditional-steps-first", + visit: { + proposalCode: "mg", + proposalNumber: 36964, + number: 1, + }, + templateRef: "conditional-steps", + parameters: {}, + status: { + __typename: "WorkflowSucceededStatus", + startTime: "2025-08-22T10:35:22+00:00", + endTime: "2025-08-22T10:35:43+00:00", + message: null, + tasks: [ + { + id: "conditional-steps-first-2863409095", + name: "less-than-5", + status: "SUCCEEDED", + message: null, + depends: ["conditional-steps-first-3687959097"], + dependencies: [], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "conditional-steps-first-3687959097", + name: "pick-a-number", + status: "SUCCEEDED", + message: null, + depends: ["conditional-steps-first"], + dependencies: [ + "conditional-steps-first-567981434", + "conditional-steps-first-3590043386", + "conditional-steps-first-1223470002", + "conditional-steps-first-2863409095", + ], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "conditional-steps-first", + name: "conditional-steps-first", + status: "SUCCEEDED", + message: null, + depends: [], + dependencies: ["conditional-steps-first-3687959097"], + stepType: "DAG", + artifacts: [], + }, + { + id: "conditional-steps-first-567981434", + name: "complex-condition", + status: "SKIPPED", + message: null, + depends: ["conditional-steps-first-3687959097"], + dependencies: [], + stepType: "Skipped", + artifacts: [], + }, + { + id: "conditional-steps-first-1223470002", + name: "greater-than-5", + status: "SKIPPED", + message: null, + depends: ["conditional-steps-first-3687959097"], + dependencies: [], + stepType: "Skipped", + artifacts: [], + }, + { + id: "conditional-steps-first-3590043386", + name: "even", + status: "SKIPPED", + message: null, + depends: ["conditional-steps-first-3687959097"], + dependencies: [], + stepType: "Skipped", + artifacts: [], + }, + ], + }, + }, +}; diff --git a/frontend/dashboard/src/mocks/responses/workflows/WorkflowsListViewQueryResponse.ts b/frontend/dashboard/src/mocks/responses/workflows/WorkflowsListViewQueryResponse.ts new file mode 100644 index 000000000..4463a6734 --- /dev/null +++ b/frontend/dashboard/src/mocks/responses/workflows/WorkflowsListViewQueryResponse.ts @@ -0,0 +1,924 @@ +export const workflowsListViewQueryResponse = { + workflows: { + pageInfo: { + hasNextPage: true, + endCursor: "MTA", + }, + nodes: [ + { + name: "conditional-steps-first", + visit: { + proposalCode: "mg", + proposalNumber: 36964, + number: 1, + }, + templateRef: "conditional-steps", + parameters: {}, + status: { + __typename: "WorkflowSucceededStatus", + startTime: "2025-08-22T10:35:22+00:00", + endTime: "2025-08-22T10:35:43+00:00", + message: null, + tasks: [ + { + id: "conditional-steps-first-2863409095", + name: "less-than-5", + status: "SUCCEEDED", + depends: ["conditional-steps-first-3687959097"], + dependencies: [], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "conditional-steps-first-3687959097", + name: "pick-a-number", + status: "SUCCEEDED", + depends: ["conditional-steps-first"], + dependencies: [ + "conditional-steps-first-567981434", + "conditional-steps-first-3590043386", + "conditional-steps-first-1223470002", + "conditional-steps-first-2863409095", + ], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "conditional-steps-first", + name: "conditional-steps-first", + status: "SUCCEEDED", + depends: [], + dependencies: ["conditional-steps-first-3687959097"], + stepType: "DAG", + artifacts: [], + }, + { + id: "conditional-steps-first-567981434", + name: "complex-condition", + status: "SKIPPED", + depends: ["conditional-steps-first-3687959097"], + dependencies: [], + stepType: "Skipped", + artifacts: [], + }, + { + id: "conditional-steps-first-1223470002", + name: "greater-than-5", + status: "SKIPPED", + depends: ["conditional-steps-first-3687959097"], + dependencies: [], + stepType: "Skipped", + artifacts: [], + }, + { + id: "conditional-steps-first-3590043386", + name: "even", + status: "SKIPPED", + depends: ["conditional-steps-first-3687959097"], + dependencies: [], + stepType: "Skipped", + artifacts: [], + }, + ], + }, + message: null, + }, + { + name: "conditional-steps-second", + visit: { + proposalCode: "mg", + proposalNumber: 36964, + number: 1, + }, + templateRef: "conditional-steps", + parameters: {}, + status: { + __typename: "WorkflowSucceededStatus", + startTime: "2025-08-05T17:06:00+00:00", + endTime: "2025-08-05T17:06:20+00:00", + message: null, + tasks: [ + { + id: "conditional-steps-second-1851624235", + name: "pick-a-number", + status: "SUCCEEDED", + depends: ["conditional-steps-second"], + dependencies: [ + "conditional-steps-second-757537572", + "conditional-steps-second-2546591724", + "conditional-steps-second-3106395740", + "conditional-steps-second-2942740785", + ], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "conditional-steps-second-2546591724", + name: "even", + status: "SKIPPED", + depends: ["conditional-steps-second-1851624235"], + dependencies: [], + stepType: "Skipped", + artifacts: [], + }, + { + id: "conditional-steps-second-2942740785", + name: "less-than-5", + status: "SUCCEEDED", + depends: ["conditional-steps-second-1851624235"], + dependencies: [], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "conditional-steps-second", + name: "conditional-steps-second", + status: "SUCCEEDED", + depends: [], + dependencies: ["conditional-steps-second-1851624235"], + stepType: "DAG", + artifacts: [], + }, + { + id: "conditional-steps-second-3106395740", + name: "greater-than-5", + status: "SKIPPED", + depends: ["conditional-steps-second-1851624235"], + dependencies: [], + stepType: "Skipped", + artifacts: [], + }, + { + id: "conditional-steps-second-757537572", + name: "complex-condition", + status: "SKIPPED", + depends: ["conditional-steps-second-1851624235"], + dependencies: [], + stepType: "Skipped", + artifacts: [], + }, + ], + }, + message: null, + }, + { + name: "conditional-steps-third", + visit: { + proposalCode: "mg", + proposalNumber: 36964, + number: 1, + }, + templateRef: "conditional-steps", + parameters: {}, + status: { + __typename: "WorkflowSucceededStatus", + startTime: "2025-08-19T09:45:55+00:00", + endTime: "2025-08-19T09:46:26+00:00", + message: null, + tasks: [ + { + id: "conditional-steps-third-1746857061", + name: "greater-than-5", + status: "SKIPPED", + depends: ["conditional-steps-third-3177968324"], + dependencies: [], + stepType: "Skipped", + artifacts: [], + }, + { + id: "conditional-steps-third-3903486903", + name: "complex-condition", + status: "SKIPPED", + depends: ["conditional-steps-third-3177968324"], + dependencies: [], + stepType: "Skipped", + artifacts: [], + }, + { + id: "conditional-steps-third", + name: "conditional-steps-third", + status: "SUCCEEDED", + depends: [], + dependencies: ["conditional-steps-third-3177968324"], + stepType: "DAG", + artifacts: [], + }, + { + id: "conditional-steps-third-3320442045", + name: "even", + status: "SKIPPED", + depends: ["conditional-steps-third-3177968324"], + dependencies: [], + stepType: "Skipped", + artifacts: [], + }, + { + id: "conditional-steps-third-3545401330", + name: "less-than-5", + status: "SUCCEEDED", + depends: ["conditional-steps-third-3177968324"], + dependencies: [], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "conditional-steps-third-3177968324", + name: "pick-a-number", + status: "SUCCEEDED", + depends: ["conditional-steps-third"], + dependencies: [ + "conditional-steps-third-3903486903", + "conditional-steps-third-3320442045", + "conditional-steps-third-1746857061", + "conditional-steps-third-3545401330", + ], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + ], + }, + message: null, + }, + { + name: "mount-tmpdir-first", + visit: { + proposalCode: "mg", + proposalNumber: 36964, + number: 1, + }, + templateRef: "mount-tmpdir", + parameters: {}, + status: { + __typename: "WorkflowSucceededStatus", + startTime: "2025-08-19T09:49:41+00:00", + endTime: "2025-08-19T09:50:12+00:00", + message: null, + tasks: [ + { + id: "mount-tmpdir-first-2996578215", + name: "read-shared-file", + status: "SUCCEEDED", + depends: ["mount-tmpdir-first-1553540662"], + dependencies: [], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "mount-tmpdir-first-1553540662", + name: "say-hello", + status: "SUCCEEDED", + depends: ["mount-tmpdir-first"], + dependencies: ["mount-tmpdir-first-2996578215"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "mount-tmpdir-first", + name: "mount-tmpdir-first", + status: "SUCCEEDED", + depends: [], + dependencies: ["mount-tmpdir-first-1553540662"], + stepType: "DAG", + artifacts: [], + }, + ], + }, + message: null, + }, + { + name: "notebook-first", + visit: { + proposalCode: "mg", + proposalNumber: 36964, + number: 1, + }, + templateRef: "notebook", + parameters: {}, + status: { + __typename: "WorkflowSucceededStatus", + startTime: "2025-08-19T11:14:17+00:00", + endTime: "2025-08-19T11:16:47+00:00", + message: null, + tasks: [ + { + id: "notebook-first-1461433281", + name: "files", + status: "SUCCEEDED", + depends: ["notebook-first"], + dependencies: ["notebook-first-1474563389"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "notebook-first", + name: "notebook-first", + status: "SUCCEEDED", + depends: [], + dependencies: ["notebook-first-1461433281"], + stepType: "DAG", + artifacts: [], + }, + { + id: "notebook-first-1474563389", + name: "convert", + status: "SUCCEEDED", + depends: ["notebook-first-1461433281"], + dependencies: [], + stepType: "Pod", + artifacts: [ + { + name: "notebook.html", + url: "", + mimeType: "text/html", + }, + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + ], + }, + message: null, + }, + { + name: "notebook-second", + visit: { + proposalCode: "mg", + proposalNumber: 36964, + number: 1, + }, + templateRef: "notebook", + parameters: {}, + status: { + __typename: "WorkflowSucceededStatus", + startTime: "2025-08-21T18:12:48+00:00", + endTime: "2025-08-21T18:14:50+00:00", + message: null, + tasks: [ + { + id: "notebook-second", + name: "notebook-second", + status: "SUCCEEDED", + depends: [], + dependencies: ["notebook-second-3790061711"], + stepType: "DAG", + artifacts: [], + }, + { + id: "notebook-second-3790061711", + name: "files", + status: "SUCCEEDED", + depends: ["notebook-second"], + dependencies: ["notebook-second-3005300287"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "notebook-second-3005300287", + name: "convert", + status: "SUCCEEDED", + depends: ["notebook-second-3790061711"], + dependencies: [], + stepType: "Pod", + artifacts: [ + { + name: "notebook.html", + url: "", + mimeType: "text/html", + }, + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + ], + }, + message: null, + }, + { + name: "ptycho-tomo-job-first", + visit: { + proposalCode: "mg", + proposalNumber: 36964, + number: 1, + }, + templateRef: "ptycho-tomo-job", + parameters: {}, + status: { + __typename: "WorkflowSucceededStatus", + startTime: "2025-08-22T10:27:18+00:00", + endTime: "2025-08-22T10:28:12+00:00", + message: null, + tasks: [ + { + id: "ptycho-tomo-job-first-2462835579", + name: "ptycho-recons-2", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-first-2539189859"], + dependencies: ["ptycho-tomo-job-first-670375061"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-first-620042204", + name: "post-processing-1", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-first-2479613198"], + dependencies: ["ptycho-tomo-job-first-1048220977"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-first-2479613198", + name: "ptycho-recons-1", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-first-2539189859"], + dependencies: ["ptycho-tomo-job-first-620042204"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-first-3788201106", + name: "tomography", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-first-1048220977"], + dependencies: [], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-first", + name: "ptycho-tomo-job-first", + status: "SUCCEEDED", + depends: [], + dependencies: ["ptycho-tomo-job-first-2539189859"], + stepType: "DAG", + artifacts: [], + }, + { + id: "ptycho-tomo-job-first-1048220977", + name: "align", + status: "SUCCEEDED", + depends: [ + "ptycho-tomo-job-first-620042204", + "ptycho-tomo-job-first-670375061", + ], + dependencies: ["ptycho-tomo-job-first-3788201106"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-first-2539189859", + name: "create-mask", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-first"], + dependencies: [ + "ptycho-tomo-job-first-2462835579", + "ptycho-tomo-job-first-2479613198", + ], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-first-670375061", + name: "post-processing-2", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-first-2462835579"], + dependencies: ["ptycho-tomo-job-first-1048220977"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + ], + }, + message: null, + }, + { + name: "ptycho-tomo-job-second", + visit: { + proposalCode: "mg", + proposalNumber: 36964, + number: 1, + }, + templateRef: "ptycho-tomo-job", + parameters: {}, + status: { + __typename: "WorkflowSucceededStatus", + startTime: "2025-08-22T10:27:22+00:00", + endTime: "2025-08-22T10:28:15+00:00", + message: null, + tasks: [ + { + id: "ptycho-tomo-job-second-3248405604", + name: "post-processing-2", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-second-1860025362"], + dependencies: ["ptycho-tomo-job-second-229827508"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-second-229827508", + name: "align", + status: "SUCCEEDED", + depends: [ + "ptycho-tomo-job-second-3248405604", + "ptycho-tomo-job-second-3298738461", + ], + dependencies: ["ptycho-tomo-job-second-4255670669"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-second-4255670669", + name: "tomography", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-second-229827508"], + dependencies: [], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-second-3199919166", + name: "create-mask", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-second"], + dependencies: [ + "ptycho-tomo-job-second-1843247743", + "ptycho-tomo-job-second-1860025362", + ], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-second-3298738461", + name: "post-processing-1", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-second-1843247743"], + dependencies: ["ptycho-tomo-job-second-229827508"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-second", + name: "ptycho-tomo-job-second", + status: "SUCCEEDED", + depends: [], + dependencies: ["ptycho-tomo-job-second-3199919166"], + stepType: "DAG", + artifacts: [], + }, + { + id: "ptycho-tomo-job-second-1860025362", + name: "ptycho-recons-2", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-second-3199919166"], + dependencies: ["ptycho-tomo-job-second-3248405604"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-second-1843247743", + name: "ptycho-recons-1", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-second-3199919166"], + dependencies: ["ptycho-tomo-job-second-3298738461"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + ], + }, + message: null, + }, + { + name: "ptycho-tomo-job-third", + visit: { + proposalCode: "mg", + proposalNumber: 36964, + number: 1, + }, + templateRef: "ptycho-tomo-job", + parameters: {}, + status: { + __typename: "WorkflowSucceededStatus", + startTime: "2025-08-22T10:18:00+00:00", + endTime: "2025-08-22T10:18:52+00:00", + message: null, + tasks: [ + { + id: "ptycho-tomo-job-third", + name: "ptycho-tomo-job-third", + status: "SUCCEEDED", + depends: [], + dependencies: ["ptycho-tomo-job-third-590507507"], + stepType: "DAG", + artifacts: [], + }, + { + id: "ptycho-tomo-job-third-3090693345", + name: "align", + status: "SUCCEEDED", + depends: [ + "ptycho-tomo-job-third-1406505733", + "ptycho-tomo-job-third-1356172876", + ], + dependencies: ["ptycho-tomo-job-third-1763893730"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-third-1406505733", + name: "post-processing-2", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-third-2991331627"], + dependencies: ["ptycho-tomo-job-third-3090693345"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-third-590507507", + name: "create-mask", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-third"], + dependencies: [ + "ptycho-tomo-job-third-2991331627", + "ptycho-tomo-job-third-3008109246", + ], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-third-1763893730", + name: "tomography", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-third-3090693345"], + dependencies: [], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-third-1356172876", + name: "post-processing-1", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-third-3008109246"], + dependencies: ["ptycho-tomo-job-third-3090693345"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-third-2991331627", + name: "ptycho-recons-2", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-third-590507507"], + dependencies: ["ptycho-tomo-job-third-1406505733"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + { + id: "ptycho-tomo-job-third-3008109246", + name: "ptycho-recons-1", + status: "SUCCEEDED", + depends: ["ptycho-tomo-job-third-590507507"], + dependencies: ["ptycho-tomo-job-third-1356172876"], + stepType: "Pod", + artifacts: [ + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + ], + }, + message: null, + }, + { + name: "sin-simulate-artifact-first", + visit: { + proposalCode: "mg", + proposalNumber: 36964, + number: 1, + }, + templateRef: "sin-simulate-artifact", + parameters: { + stop: "10", + step: "1", + start: "0", + }, + status: { + __typename: "WorkflowSucceededStatus", + startTime: "2025-08-19T09:47:26+00:00", + endTime: "2025-08-19T09:47:58+00:00", + message: null, + tasks: [ + { + id: "sin-simulate-artifact-first", + name: "sin-simulate-artifact-first", + status: "SUCCEEDED", + depends: [], + dependencies: [], + stepType: "Pod", + artifacts: [ + { + name: "figure.png", + url: "", + mimeType: "image/png", + }, + { + name: "main.log", + url: "", + mimeType: "text/plain", + }, + ], + }, + ], + }, + message: null, + }, + ], + }, +}; diff --git a/frontend/dashboard/src/routes/SingleWorkflowPage.tsx b/frontend/dashboard/src/routes/SingleWorkflowPage.tsx index 12410dc8e..c438d6a66 100644 --- a/frontend/dashboard/src/routes/SingleWorkflowPage.tsx +++ b/frontend/dashboard/src/routes/SingleWorkflowPage.tsx @@ -3,7 +3,7 @@ import { useParams, Link, useSearchParams } from "react-router-dom"; import { Suspense, useMemo } from "react"; import "react-resizable/css/styles.css"; import { Breadcrumbs } from "@diamondlightsource/sci-react-ui"; -import SingleWorkflowView from "relay-workflows-lib/lib/components/SingleWorkflowView"; +import SingleWorkflowView from "relay-workflows-lib/lib/views/SingleWorkflowView"; import { WorkflowsErrorBoundary, WorkflowsNavbar } from "workflows-lib"; import { visitTextToVisit } from "workflows-lib/lib/utils/commonUtils"; diff --git a/frontend/dashboard/src/routes/WorkflowsListPage.tsx b/frontend/dashboard/src/routes/WorkflowsListPage.tsx index 9d59b9d7e..6b1bad3b0 100644 --- a/frontend/dashboard/src/routes/WorkflowsListPage.tsx +++ b/frontend/dashboard/src/routes/WorkflowsListPage.tsx @@ -1,20 +1,9 @@ -import { Suspense, useEffect, useState } from "react"; +import { useEffect } from "react"; import { Link, useParams, useNavigate } from "react-router-dom"; -import { Container, Box, Stack } from "@mui/material"; -import { - VisitInput, - Breadcrumbs, - visitToText, -} from "@diamondlightsource/sci-react-ui"; -import Workflows from "relay-workflows-lib/lib/components/Workflows"; -import WorkflowListFilterDrawer from "relay-workflows-lib/lib/components/WorkflowListFilterDrawer"; -import { - WorkflowQueryFilter, - WorkflowsErrorBoundary, - WorkflowsNavbar, -} from "workflows-lib"; -import { WorkflowListFilterDisplay } from "relay-workflows-lib/lib/components/WorkflowListFilterDrawer"; -import { useVisitInput, ScrollRestorer } from "./utils"; +import { Container, Box } from "@mui/material"; +import { Breadcrumbs } from "@diamondlightsource/sci-react-ui"; +import { WorkflowsNavbar } from "workflows-lib"; +import { WorkflowsListView } from "relay-workflows-lib"; const WorkflowsListPage: React.FC = () => { const { visitid } = useParams<{ visitid?: string }>(); @@ -41,11 +30,6 @@ const WorkflowsListPage: React.FC = () => { } }, [visitid, instrumentSessionID, navigate]); - const { visit, handleVisitSubmit } = useVisitInput(instrumentSessionID); - const [workflowQueryFilter, setWorkflowQueryFilter] = useState< - WorkflowQueryFilter | undefined - >(undefined); - return ( <> { - - - - - - { - setWorkflowQueryFilter(newFilters); - }} - /> - - - {workflowQueryFilter && ( - - )} - - - - {visit && ( - - - - - - - )} - + diff --git a/frontend/relay-workflows-lib/lib/components/BaseWorkflowRelay.tsx b/frontend/relay-workflows-lib/lib/components/BaseWorkflowRelay.tsx index c7c413701..a166c3d13 100644 --- a/frontend/relay-workflows-lib/lib/components/BaseWorkflowRelay.tsx +++ b/frontend/relay-workflows-lib/lib/components/BaseWorkflowRelay.tsx @@ -2,22 +2,36 @@ import React from "react"; import { ResizableBox } from "react-resizable"; import { Box } from "@mui/material"; import { visitToText } from "@diamondlightsource/sci-react-ui"; -import { - TasksFlow, - WorkflowAccordion, - type WorkflowStatus, -} from "workflows-lib"; -import RetriggerWorkflow from "./RetriggerWorkflow"; -import { useFetchedTasks, useSelectedTaskIds } from "./workflowRelayUtils"; -import { workflowRelaySubscription$data } from "../graphql/__generated__/workflowRelaySubscription.graphql"; +import { WorkflowAccordion, type WorkflowStatus } from "workflows-lib"; +import RetriggerWorkflow from "../query-components/RetriggerWorkflow"; +import { useSelectedTaskIds } from "../utils/workflowRelayUtils"; import { useParams, useNavigate } from "react-router-dom"; +import { graphql } from "relay-runtime"; +import { useFragment } from "react-relay"; +import { BaseWorkflowRelayFragment$key } from "./__generated__/BaseWorkflowRelayFragment.graphql"; +import TasksFlow from "./TasksFlow"; + +export const BaseWorkflowRelayFragment = graphql` + fragment BaseWorkflowRelayFragment on Workflow { + name + visit { + proposalCode + proposalNumber + number + } + status { + __typename + } + ...WorkflowTasksFragment + } +`; interface BaseWorkflowRelayProps { workflowLink?: boolean; filledTaskId?: string | null; expanded?: boolean; onChange?: () => void; - data: workflowRelaySubscription$data; + fragmentRef: BaseWorkflowRelayFragment$key; } export default function BaseWorkflowRelay({ @@ -25,14 +39,14 @@ export default function BaseWorkflowRelay({ filledTaskId, expanded, onChange, - data, + fragmentRef, }: BaseWorkflowRelayProps) { const { workflowName: workflowNameURL } = useParams<{ workflowName: string; }>(); const navigate = useNavigate(); - const statusText = data.workflow.status?.__typename ?? "Unknown"; - const fetchedTasks = useFetchedTasks(data); + const data = useFragment(BaseWorkflowRelayFragment, fragmentRef); + const statusText = data.status?.__typename ?? "Unknown"; const [selectedTaskIds, setSelectedTaskIds] = useSelectedTaskIds(); const onNavigate = React.useCallback( @@ -48,10 +62,8 @@ export default function BaseWorkflowRelay({ } else { updatedTaskIds = [taskId]; } - if (workflowNameURL !== data.workflow.name) { - void navigate( - `/workflows/${visitToText(data.workflow.visit)}/${data.workflow.name}`, - ); + if (workflowNameURL !== data.name) { + void navigate(`/workflows/${visitToText(data.visit)}/${data.name}`); } setSelectedTaskIds(updatedTaskIds); }, @@ -75,8 +87,8 @@ export default function BaseWorkflowRelay({ > (null); - - const subscriptionData: GraphQLSubscriptionConfig = - useMemo( - () => ({ - subscription: workflowRelaySubscription, - variables: { visit, name: workflowName }, - onNext: (res) => { - setWorkflowData(res ?? null); - }, - onError: (error: unknown) => { - console.error("Subscription error:", error); - }, - onCompleted: () => { - console.log("completed"); - }, - }), - [visit, workflowName], - ); - - useSubscription(subscriptionData); - - return ; -} diff --git a/frontend/relay-workflows-lib/lib/components/LiveWorkflowRelay.tsx b/frontend/relay-workflows-lib/lib/components/LiveWorkflowRelay.tsx deleted file mode 100644 index 007b3f3ee..000000000 --- a/frontend/relay-workflows-lib/lib/components/LiveWorkflowRelay.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { requestSubscription, useRelayEnvironment } from "react-relay"; -import { workflowRelaySubscription } from "../graphql/workflowRelaySubscription"; -import { - workflowRelaySubscription$data, - workflowRelaySubscription as WorkflowRelaySubscriptionType, -} from "../graphql/__generated__/workflowRelaySubscription.graphql"; -import BaseWorkflowRelay from "./BaseWorkflowRelay"; -import { WorkflowRelayProps } from "./WorkflowRelay"; -import { isFinished } from "../utils"; - -interface LiveWorkflowRelayProps extends WorkflowRelayProps { - onNull: () => void; -} - -export default function LiveWorkflowRelay(props: LiveWorkflowRelayProps) { - const [workflowData, setWorkflowData] = - useState(null); - - const subscriptionRef = useRef<{ dispose: () => void } | null>(null); - const environment = useRelayEnvironment(); - useEffect(() => { - const subscription = requestSubscription( - environment, - { - subscription: workflowRelaySubscription, - variables: { - visit: props.data.workflow.visit, - name: props.data.workflow.name, - }, - onNext: (response?: workflowRelaySubscription$data | null) => { - if (response) { - setWorkflowData(response); - - if (isFinished(response)) { - console.log("Workflow finished, unsubscribing."); - subscriptionRef.current?.dispose(); - } - } else { - props.onNull(); - } - }, - onError: (error: unknown) => { - console.error("Subscription error:", error); - }, - onCompleted: () => { - console.log("completed"); - }, - }, - ); - - subscriptionRef.current = subscription; - - return () => { - subscriptionRef.current?.dispose(); - }; - }, [ - props.data.workflow.visit, - props.data.workflow.name, - props.onNull, - environment, - ]); - - return workflowData ? ( - - ) : null; -} diff --git a/frontend/relay-workflows-lib/lib/components/QueryWorkflowRelay.tsx b/frontend/relay-workflows-lib/lib/components/QueryWorkflowRelay.tsx deleted file mode 100644 index b0bddd28f..000000000 --- a/frontend/relay-workflows-lib/lib/components/QueryWorkflowRelay.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; -import { Visit } from "workflows-lib"; -import { workflowRelayQuery } from "../graphql/workflowRelayQuery"; -import { workflowRelayQuery as WorkflowRelayQueryType } from "../graphql/__generated__/workflowRelayQuery.graphql"; -import { useLazyLoadQuery } from "react-relay"; -import WorkflowRelay from "./WorkflowRelay"; - -export interface WorkflowRelayProps { - visit: Visit; - workflowName: string; - workflowLink?: boolean; - filledTaskName?: string | null; - expanded?: boolean; - onChange?: () => void; -} - -const QueryWorkflowRelay: React.FC = (props) => { - const queryData = useLazyLoadQuery( - workflowRelayQuery, - { - visit: props.visit, - name: props.workflowName, - }, - ); - - return ; -}; - -export default QueryWorkflowRelay; diff --git a/frontend/relay-workflows-lib/lib/components/SingleWorkflowView.tsx b/frontend/relay-workflows-lib/lib/components/SingleWorkflowView.tsx deleted file mode 100644 index d49e814a1..000000000 --- a/frontend/relay-workflows-lib/lib/components/SingleWorkflowView.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Visit } from "workflows-lib"; -import LiveSingleWorkflowView from "./LiveSingleWorkflowView"; -import { useLazyLoadQuery } from "react-relay"; -import { workflowRelayQuery } from "../graphql/workflowRelayQuery"; -import { workflowRelayQuery as WorkflowRelayQueryType } from "../graphql/__generated__/workflowRelayQuery.graphql"; -import { isFinished } from "../utils"; -import BaseSingleWorkflowView from "./BaseSingleWorkflowView"; - -export interface SingleWorkflowViewProps { - visit: Visit; - workflowName: string; - taskIds?: string[]; -} - -export default function SingleWorkflowView(props: SingleWorkflowViewProps) { - const queryData = useLazyLoadQuery( - workflowRelayQuery, - { - visit: props.visit, - name: props.workflowName, - }, - ); - - const finished = isFinished(queryData); - - return finished ? ( - - ) : ( - - ); -} diff --git a/frontend/relay-workflows-lib/lib/components/SubmissionForm.tsx b/frontend/relay-workflows-lib/lib/components/SubmissionForm.tsx index 66206c780..e16c08fa1 100644 --- a/frontend/relay-workflows-lib/lib/components/SubmissionForm.tsx +++ b/frontend/relay-workflows-lib/lib/components/SubmissionForm.tsx @@ -1,5 +1,9 @@ import { useFragment } from "react-relay"; -import { SubmissionForm as SubmissionFormBase, Visit } from "workflows-lib"; +import { + JSONObject, + SubmissionForm as SubmissionFormBase, + Visit, +} from "workflows-lib"; import { JsonSchema, UISchemaElement } from "@jsonforms/core"; import { graphql } from "react-relay"; import { SubmissionFormFragment$key } from "./__generated__/SubmissionFormFragment.graphql"; @@ -49,7 +53,7 @@ const SubmissionForm = ({ parametersSchema={data.arguments as JsonSchema} parametersUISchema={data.uiSchema as UISchemaElement} visit={visit} - prepopulatedParameters={parameterData?.parameters} + prepopulatedParameters={parameterData?.parameters as JSONObject} onSubmit={onSubmit} /> ); diff --git a/frontend/workflows-lib/lib/components/workflow/TasksFlow.tsx b/frontend/relay-workflows-lib/lib/components/TasksFlow.tsx similarity index 91% rename from frontend/workflows-lib/lib/components/workflow/TasksFlow.tsx rename to frontend/relay-workflows-lib/lib/components/TasksFlow.tsx index 381396ae2..e5e77bd03 100644 --- a/frontend/workflows-lib/lib/components/workflow/TasksFlow.tsx +++ b/frontend/relay-workflows-lib/lib/components/TasksFlow.tsx @@ -15,22 +15,25 @@ import { } from "@xyflow/react"; import type { Node } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import TaskFlowNode, { TaskFlowNodeData } from "./TasksFlowNode"; -import TasksTable from "./TasksTable"; +import TaskFlowNode, { + TaskFlowNodeData, +} from "workflows-lib/lib/components/workflow/TasksFlowNode"; +import TasksTable from "workflows-lib/lib/components/workflow/TasksTable"; import { addHighlightsAndFills, applyDagreLayout, buildTaskTree, generateNodesAndEdges, usePersistentViewport, -} from "../../utils/tasksFlowUtils"; -import { Task } from "../../types"; +} from "workflows-lib/lib/utils/tasksFlowUtils"; +import { useFetchedTasks } from "relay-workflows-lib/lib/utils/workflowRelayUtils"; +import { WorkflowTasksFragment$key } from "relay-workflows-lib/lib/graphql/__generated__/WorkflowTasksFragment.graphql"; const defaultViewport = { x: 0, y: 0, zoom: 1.5 }; interface TasksFlowProps { workflowName: string; - tasks: Task[]; + tasksRef?: WorkflowTasksFragment$key | null; onNavigate: (path: string, e?: React.MouseEvent) => void; highlightedTaskIds?: string[]; filledTaskId?: string | null; @@ -39,12 +42,13 @@ interface TasksFlowProps { const TasksFlow: React.FC = ({ workflowName, - tasks, + tasksRef, onNavigate, highlightedTaskIds, filledTaskId, isDynamic, }) => { + const tasks = useFetchedTasks(tasksRef ?? null); const reactFlowInstance = useRef(null); const containerRef = useRef(null); const [isOverflow, setIsOverflow] = useState(false); diff --git a/frontend/relay-workflows-lib/lib/components/TemplateCard.tsx b/frontend/relay-workflows-lib/lib/components/TemplateCard.tsx index 0991da948..9c1a1b478 100644 --- a/frontend/relay-workflows-lib/lib/components/TemplateCard.tsx +++ b/frontend/relay-workflows-lib/lib/components/TemplateCard.tsx @@ -6,10 +6,10 @@ import React from "react"; import { Container, Box, Stack } from "@mui/material"; import { useLocation, useNavigate } from "react-router-dom"; import { graphql, useFragment } from "react-relay"; -import type { TemplateCard_template$key } from "./__generated__/TemplateCard_template.graphql"; +import type { TemplateCardFragment$key } from "./__generated__/TemplateCardFragment.graphql"; const templateCardFragment = graphql` - fragment TemplateCard_template on WorkflowTemplate { + fragment TemplateCardFragment on WorkflowTemplate { name title description @@ -19,7 +19,7 @@ const templateCardFragment = graphql` `; export interface TemplateCardProps { - template: TemplateCard_template$key; + template: TemplateCardFragment$key; } export const TemplateCard: React.FC = ({ template }) => { diff --git a/frontend/relay-workflows-lib/lib/components/WorkflowInfo.tsx b/frontend/relay-workflows-lib/lib/components/WorkflowInfo.tsx index 8947dc3e4..557603dc2 100644 --- a/frontend/relay-workflows-lib/lib/components/WorkflowInfo.tsx +++ b/frontend/relay-workflows-lib/lib/components/WorkflowInfo.tsx @@ -4,10 +4,58 @@ import { AccordionSummary, Typography, } from "@mui/material"; -import { workflowRelayQuery$data } from "../graphql/__generated__/workflowRelayQuery.graphql"; import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; +import { graphql } from "relay-runtime"; +import { useFragment } from "react-relay"; +import { WorkflowInfoFragment$key } from "./__generated__/WorkflowInfoFragment.graphql"; -export default function WorkflowInfo({ workflow }: workflowRelayQuery$data) { +const WorkflowInfoFragment = graphql` + fragment WorkflowInfoFragment on Workflow { + templateRef + parameters + status { + __typename + ... on WorkflowPendingStatus { + message + } + ... on WorkflowRunningStatus { + message + tasks { + name + message + } + } + ... on WorkflowSucceededStatus { + message + tasks { + name + message + } + } + ... on WorkflowFailedStatus { + message + tasks { + name + message + } + } + ... on WorkflowErroredStatus { + message + tasks { + name + message + } + } + } + } +`; + +interface WorkflowInfoProps { + fragmentRef: WorkflowInfoFragment$key; +} + +export default function WorkflowInfo({ fragmentRef }: WorkflowInfoProps) { + const workflow = useFragment(WorkflowInfoFragment, fragmentRef); return ( }> diff --git a/frontend/relay-workflows-lib/lib/components/WorkflowListFilterDrawer.tsx b/frontend/relay-workflows-lib/lib/components/WorkflowListFilterDrawer.tsx index 2e43b6e5c..9f80b96f9 100644 --- a/frontend/relay-workflows-lib/lib/components/WorkflowListFilterDrawer.tsx +++ b/frontend/relay-workflows-lib/lib/components/WorkflowListFilterDrawer.tsx @@ -13,11 +13,19 @@ import ClearIcon from "@mui/icons-material/Clear"; import CloseIcon from "@mui/icons-material/Close"; import { useState } from "react"; import { WorkflowQueryFilter, WorkflowStatusBool } from "workflows-lib"; -import { useLazyLoadQuery } from "react-relay/hooks"; -import { templatesListQuery } from "relay-workflows-lib/lib/graphql/TemplatesListQuery.ts"; -import { TemplatesListQuery as TemplatesListQueryType } from "relay-workflows-lib/lib/graphql/__generated__/TemplatesListQuery.graphql"; +import { graphql, useFragment } from "react-relay/hooks"; +import { WorkflowListFilterDrawerFragment$key } from "./__generated__/WorkflowListFilterDrawerFragment.graphql"; + +const WorkflowListFilterDrawerFragment = graphql` + fragment WorkflowListFilterDrawerFragment on WorkflowTemplateConnection { + nodes { + name + } + } +`; interface WorkflowListFilterDrawerProps { + data: WorkflowListFilterDrawerFragment$key; onApplyFilters: (filters: WorkflowQueryFilter) => void; } @@ -67,6 +75,7 @@ export function WorkflowListFilterDisplay({ } function WorkflowListFilterDrawer({ + data, onApplyFilters, }: WorkflowListFilterDrawerProps) { const [open, setOpen] = useState(false); @@ -77,10 +86,8 @@ function WorkflowListFilterDrawer({ template?: boolean; }>({}); const [status, setStatus] = useState<{ label: string; value: string }[]>([]); - const data = useLazyLoadQuery(templatesListQuery, {}); - const templateOptions = data.workflowTemplates.nodes.map( - (templateNode) => templateNode.name, - ); + const { nodes } = useFragment(WorkflowListFilterDrawerFragment, data); + const templateOptions = nodes.map((templateNode) => templateNode.name); const toggleDrawer = (newOpen: boolean) => () => { setOpen(newOpen); diff --git a/frontend/relay-workflows-lib/lib/components/WorkflowRelay.tsx b/frontend/relay-workflows-lib/lib/components/WorkflowRelay.tsx index 18a08c6ae..0c9088e9e 100644 --- a/frontend/relay-workflows-lib/lib/components/WorkflowRelay.tsx +++ b/frontend/relay-workflows-lib/lib/components/WorkflowRelay.tsx @@ -1,10 +1,28 @@ import React, { useState } from "react"; -import LiveWorkflowRelay from "./LiveWorkflowRelay"; -import { workflowRelayQuery$data } from "../graphql/__generated__/workflowRelayQuery.graphql"; +import LiveWorkflowRelay from "../subscription-components/LiveWorkflowRelay"; import BaseWorkflowRelay from "./BaseWorkflowRelay"; -import { isFinished } from "../utils"; +import { finishedStatuses } from "../utils/coreUtils"; +import { graphql } from "relay-runtime"; +import { useFragment } from "react-relay"; +import { WorkflowRelayFragment$key } from "./__generated__/WorkflowRelayFragment.graphql"; + +export const WorkflowRelayFragment = graphql` + fragment WorkflowRelayFragment on Workflow { + ...BaseWorkflowRelayFragment + status { + __typename + } + visit { + proposalCode + proposalNumber + number + } + name + } +`; + export interface WorkflowRelayProps { - data: workflowRelayQuery$data; + fragmentRef: WorkflowRelayFragment$key; workflowLink?: boolean; filledTaskId?: string | null; expanded?: boolean; @@ -12,16 +30,24 @@ export interface WorkflowRelayProps { } const WorkflowRelay: React.FC = (props) => { - const finished = isFinished(props.data); + const data = useFragment(WorkflowRelayFragment, props.fragmentRef); + const finished = + data.status?.__typename && finishedStatuses.has(data.status.__typename); const [isNull, setIsNull] = useState(false); - const onNull = () => { + const onNullSubscriptionData = () => { setIsNull(true); }; return finished || isNull ? ( - + ) : ( - + ); }; diff --git a/frontend/relay-workflows-lib/lib/components/Workflows.tsx b/frontend/relay-workflows-lib/lib/components/Workflows.tsx deleted file mode 100644 index f8d060335..000000000 --- a/frontend/relay-workflows-lib/lib/components/Workflows.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useQueryLoader } from "react-relay/hooks"; -import { Visit, WorkflowQueryFilter } from "workflows-lib"; -import { workflowsQuery as WorkflowsQueryType } from "../graphql/__generated__/workflowsQuery.graphql"; -import WorkflowsContent from "./WorkflowsContent"; -import { useServerSidePagination } from "./useServerSidePagination"; -import { workflowsQuery } from "../graphql/workflowsQuery"; - -export default function Workflows({ - visit, - filter, -}: { - visit: Visit; - filter?: WorkflowQueryFilter; -}) { - const { - cursor, - currentPage, - totalPages, - selectedLimit, - goToPage, - changeLimit, - updatePageInfo, - } = useServerSidePagination(); - - const [queryReference, loadQuery] = - useQueryLoader(workflowsQuery); - - const [isPaginated, setIsPaginated] = useState(false); - const lastPage = useRef(currentPage); - const lastLimit = useRef(selectedLimit); - - const load = useCallback(() => { - loadQuery( - { visit, limit: selectedLimit, cursor, filter }, - { fetchPolicy: "store-and-network" }, - ); - }, [visit, selectedLimit, cursor, filter, loadQuery]); - - useEffect(() => { - load(); - const interval = setInterval(load, 5000); - return () => { - clearInterval(interval); - }; - }, [load]); - - useEffect(() => { - if ( - currentPage !== lastPage.current || - selectedLimit !== lastLimit.current - ) { - setIsPaginated(true); - lastPage.current = currentPage; - lastLimit.current = selectedLimit; - } - }, [currentPage, selectedLimit]); - - if (!queryReference) return
Loading workflows
; - - return ( - { - goToPage(page, endCursor, hasNextPage); - }} - onLimitChange={changeLimit} - updatePageInfo={updatePageInfo} - isPaginated={isPaginated} - setIsPaginated={setIsPaginated} - visit={visit} - /> - ); -} diff --git a/frontend/relay-workflows-lib/lib/components/WorkflowsContent.tsx b/frontend/relay-workflows-lib/lib/components/WorkflowsContent.tsx index c9a28360e..187b694ee 100644 --- a/frontend/relay-workflows-lib/lib/components/WorkflowsContent.tsx +++ b/frontend/relay-workflows-lib/lib/components/WorkflowsContent.tsx @@ -1,15 +1,32 @@ -import { useEffect, useRef, useState, useMemo } from "react"; -import { PreloadedQuery, usePreloadedQuery } from "react-relay"; -import { Box, Button, FormControlLabel, Switch, useTheme } from "@mui/material"; -import { Visit } from "@diamondlightsource/sci-react-ui"; +import { useEffect, useRef, useState } from "react"; +import { + graphql, + PreloadedQuery, + useFragment, + usePreloadedQuery, +} from "react-relay"; +import { Box } from "@mui/material"; import { PaginationControls } from "workflows-lib"; -import { workflowsQuery } from "../graphql/workflowsQuery"; -import { updateWorkflowsState } from "../utils"; -import { workflowsQuery as WorkflowsQueryType } from "../graphql/__generated__/workflowsQuery.graphql"; -import QueryWorkflowRelay from "./QueryWorkflowRelay"; +import { WorkflowsContentFragment$key } from "./__generated__/WorkflowsContentFragment.graphql"; +import WorkflowRelay from "./WorkflowRelay"; +import { WorkflowsListViewQuery as WorkflowsListViewQueryType } from "../views/__generated__/WorkflowsListViewQuery.graphql"; +import { WorkflowsListViewQuery } from "../views/WorkflowsListView"; + +export const WorkflowsContentFragment = graphql` + fragment WorkflowsContentFragment on WorkflowConnection { + pageInfo { + hasNextPage + endCursor + } + nodes { + ...WorkflowRelayFragment + name + } + } +`; interface WorkflowsContentProps { - queryReference: PreloadedQuery; + queryReference: PreloadedQuery; currentPage: number; totalPages: number; selectedLimit: number; @@ -22,7 +39,6 @@ interface WorkflowsContentProps { updatePageInfo: (hasNextPage: boolean, endCursor: string | null) => void; isPaginated: boolean; setIsPaginated: (b: boolean) => void; - visit: Visit; } export default function WorkflowsContent({ @@ -35,64 +51,35 @@ export default function WorkflowsContent({ updatePageInfo, isPaginated, setIsPaginated, - visit, }: WorkflowsContentProps) { - const theme = useTheme(); - const data = usePreloadedQuery(workflowsQuery, queryReference); - const pageInfo = data.workflows.pageInfo; - const fetchedWorkflows = useMemo(() => { - return data.workflows.nodes.map((wf: { readonly name: string }) => wf.name); - }, [data.workflows.nodes]); + const queryData = usePreloadedQuery(WorkflowsListViewQuery, queryReference); + const data = useFragment( + WorkflowsContentFragment, + queryData.workflows, + ); + const pageInfo = data.pageInfo; + const fetchedWorkflows = data.nodes; const prevFetchedRef = useRef([]); - const [visibleWorkflows, setVisibleWorkflows] = - useState(fetchedWorkflows); - const [newWorkflows, setNewWorkflows] = useState([]); const [expandedWorkflows, setExpandedWorkflows] = useState>( new Set(), ); - const [liveUpdate, setLiveUpdate] = useState(false); + useEffect(() => { updatePageInfo(pageInfo.hasNextPage, pageInfo.endCursor ?? null); }, [pageInfo, updatePageInfo]); useEffect(() => { - const fetchedChanged = prevFetchedRef.current !== fetchedWorkflows; - - if ((isPaginated || liveUpdate) && fetchedChanged) { - setVisibleWorkflows(fetchedWorkflows); - setNewWorkflows([]); - setExpandedWorkflows(new Set()); - prevFetchedRef.current = fetchedWorkflows; - - if (isPaginated) { - setTimeout(() => { - setIsPaginated(false); - }, 0); - } - } else if (fetchedChanged) { - updateWorkflowsState( - fetchedWorkflows, - visibleWorkflows, - newWorkflows, - setNewWorkflows, - ); - prevFetchedRef.current = fetchedWorkflows; + const currentNames = fetchedWorkflows.map((wf) => wf.name); + const prevNames = prevFetchedRef.current; + const fetchedChanged = + JSON.stringify(currentNames) !== JSON.stringify(prevNames); + if (fetchedChanged && isPaginated) { + setTimeout(() => { + setIsPaginated(false); + }, 0); } - }, [ - isPaginated, - fetchedWorkflows, - newWorkflows, - visibleWorkflows, - liveUpdate, - setIsPaginated, - ]); - const handleShowNewWorkflows = () => { - const combined = [...new Set([...newWorkflows, ...visibleWorkflows])]; - const trimmed = combined.slice(0, selectedLimit); - setVisibleWorkflows(trimmed); - setNewWorkflows([]); - }; + }, [isPaginated, fetchedWorkflows, setIsPaginated]); const handleToggleExpanded = (name: string) => { setExpandedWorkflows((prev) => { @@ -106,51 +93,19 @@ export default function WorkflowsContent({ }); }; - const toggleLiveUpdate = () => { - setLiveUpdate((prev) => !prev); - }; - return ( - - } - label="Live Update" - sx={{ position: "right" }} - value={liveUpdate} - onChange={toggleLiveUpdate} - /> - - - {!liveUpdate && currentPage === 1 && newWorkflows.length > 0 && ( - - )} - - {visibleWorkflows.map((n) => ( - ( + { - handleToggleExpanded(n); + handleToggleExpanded(node.name); }} /> ))} diff --git a/frontend/relay-workflows-lib/lib/graphql/WorkflowTasksFragment.ts b/frontend/relay-workflows-lib/lib/graphql/WorkflowTasksFragment.ts new file mode 100644 index 000000000..484166235 --- /dev/null +++ b/frontend/relay-workflows-lib/lib/graphql/WorkflowTasksFragment.ts @@ -0,0 +1,71 @@ +import { graphql } from "react-relay"; + +export const WorkflowTasksFragment = graphql` + fragment WorkflowTasksFragment on Workflow { + name + visit { + proposalCode + proposalNumber + number + } + status { + __typename + ... on WorkflowRunningStatus { + tasks { + id + name + status + depends + stepType + artifacts { + name + url + mimeType + } + } + } + ... on WorkflowSucceededStatus { + tasks { + id + name + status + depends + stepType + artifacts { + name + url + mimeType + } + } + } + ... on WorkflowFailedStatus { + tasks { + id + name + status + depends + stepType + artifacts { + name + url + mimeType + } + } + } + ... on WorkflowErroredStatus { + tasks { + id + name + status + depends + stepType + artifacts { + name + url + mimeType + } + } + } + } + } +`; diff --git a/frontend/relay-workflows-lib/lib/main.ts b/frontend/relay-workflows-lib/lib/main.ts index 4ca23b9aa..6eff95d22 100644 --- a/frontend/relay-workflows-lib/lib/main.ts +++ b/frontend/relay-workflows-lib/lib/main.ts @@ -1,5 +1,6 @@ export { default as Workflow } from "./components/WorkflowRelay"; export { default as Submission } from "./components/SubmissionForm"; -export { default as RetriggerWorkflow } from "./components/RetriggerWorkflow"; -export { default as SubmittedMessagesList } from "./components/SubmittedMessagesList"; +export { default as RetriggerWorkflow } from "./query-components/RetriggerWorkflow"; +export { default as SubmittedMessagesList } from "workflows-lib/lib/components/workflow/SubmittedMessagesList"; export { default as WorkflowListFilterDrawer } from "./components/WorkflowListFilterDrawer"; +export { default as WorkflowsListView } from "./views/WorkflowsListView"; diff --git a/frontend/relay-workflows-lib/lib/components/RetriggerWorkflow.tsx b/frontend/relay-workflows-lib/lib/query-components/RetriggerWorkflow.tsx similarity index 99% rename from frontend/relay-workflows-lib/lib/components/RetriggerWorkflow.tsx rename to frontend/relay-workflows-lib/lib/query-components/RetriggerWorkflow.tsx index e905433bb..5991485db 100644 --- a/frontend/relay-workflows-lib/lib/components/RetriggerWorkflow.tsx +++ b/frontend/relay-workflows-lib/lib/query-components/RetriggerWorkflow.tsx @@ -7,6 +7,7 @@ import { NavLink } from "react-router-dom"; import { RetriggerWorkflowQuery as RetriggerWorkflowQueryType } from "./__generated__/RetriggerWorkflowQuery.graphql"; import { Visit, visitToText } from "@diamondlightsource/sci-react-ui"; import WorkflowsErrorBoundary from "workflows-lib/lib/components/workflow/WorkflowsErrorBoundary"; + const retriggerWorkflowQuery = graphql` query RetriggerWorkflowQuery($visit: VisitInput!, $workflowname: String!) { workflow(visit: $visit, name: $workflowname) { diff --git a/frontend/relay-workflows-lib/lib/subscription-components/LiveWorkflowRelay.tsx b/frontend/relay-workflows-lib/lib/subscription-components/LiveWorkflowRelay.tsx new file mode 100644 index 000000000..b62084111 --- /dev/null +++ b/frontend/relay-workflows-lib/lib/subscription-components/LiveWorkflowRelay.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef, useState } from "react"; +import { requestSubscription, useRelayEnvironment } from "react-relay"; +import BaseWorkflowRelay from "../components/BaseWorkflowRelay"; +import { WorkflowRelayProps } from "../components/WorkflowRelay"; +import { isFinished } from "../utils/coreUtils"; +import { graphql } from "react-relay"; +import { BaseWorkflowRelayFragment$key } from "../components/__generated__/BaseWorkflowRelayFragment.graphql"; +import { Visit } from "workflows-lib"; +import { + LiveWorkflowRelaySubscription$data, + LiveWorkflowRelaySubscription as LiveWorkflowRelaySubscriptionType, +} from "./__generated__/LiveWorkflowRelaySubscription.graphql"; + +export const LiveWorkflowRelaySubscription = graphql` + subscription LiveWorkflowRelaySubscription( + $visit: VisitInput! + $name: String! + ) { + workflow(visit: $visit, name: $name) { + status { + __typename + } + ...BaseWorkflowRelayFragment + } + } +`; + +interface LiveWorkflowRelayProps extends WorkflowRelayProps { + onNullSubscriptionData: () => void; + baseFragmentRef: BaseWorkflowRelayFragment$key; + workflowName: string; + visit: Visit; +} + +export default function LiveWorkflowRelay(props: LiveWorkflowRelayProps) { + const [workflowFragmentRef, setWorkflowFragmentRef] = + useState(null); + + const environment = useRelayEnvironment(); + const subscriptionRef = useRef<{ dispose: () => void } | null>(null); + + useEffect(() => { + const subscription = requestSubscription( + environment, + { + subscription: LiveWorkflowRelaySubscription, + variables: { + visit: props.visit, + name: props.workflowName, + }, + onNext: (response?: LiveWorkflowRelaySubscription$data | null) => { + if (response?.workflow) { + setWorkflowFragmentRef( + response.workflow as unknown as BaseWorkflowRelayFragment$key, + ); + + if (isFinished(response)) { + console.log("Workflow finished, unsubscribing."); + subscriptionRef.current?.dispose(); + } + } else { + props.onNullSubscriptionData(); + setWorkflowFragmentRef(null); + } + }, + onError: (error: unknown) => { + console.error("Subscription error:", error); + }, + onCompleted: () => { + console.log("Subscription completed"); + }, + }, + ); + + subscriptionRef.current = subscription; + + return () => { + subscriptionRef.current?.dispose(); + }; + }, [props, environment]); + + return workflowFragmentRef ? ( + + ) : null; +} diff --git a/frontend/relay-workflows-lib/lib/components/SubscribeAndRender.tsx b/frontend/relay-workflows-lib/lib/subscription-components/SubscribeAndRender.tsx similarity index 96% rename from frontend/relay-workflows-lib/lib/components/SubscribeAndRender.tsx rename to frontend/relay-workflows-lib/lib/subscription-components/SubscribeAndRender.tsx index 186d3a034..ae0e8a977 100644 --- a/frontend/relay-workflows-lib/lib/components/SubscribeAndRender.tsx +++ b/frontend/relay-workflows-lib/lib/subscription-components/SubscribeAndRender.tsx @@ -7,7 +7,7 @@ import { SubmissionSuccessMessage, } from "workflows-lib/lib/types"; import { Visit } from "@diamondlightsource/sci-react-ui"; -import { RenderSubmittedMessage } from "./RenderSubmittedMessage"; +import { RenderSubmittedMessage } from "../components/RenderSubmittedMessage"; import { graphql, GraphQLTaggedNode } from "react-relay"; import { SubscribeAndRenderSubscription$data, diff --git a/frontend/relay-workflows-lib/lib/utils.ts b/frontend/relay-workflows-lib/lib/utils.ts deleted file mode 100644 index af6fb0b7e..000000000 --- a/frontend/relay-workflows-lib/lib/utils.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { useState } from "react"; -import type { workflowRelayQuery$data } from "./graphql/__generated__/workflowRelayQuery.graphql"; -import { workflowRelaySubscription$data } from "./graphql/__generated__/workflowRelaySubscription.graphql"; -type WorkflowStatusType = NonNullable< - workflowRelayQuery$data["workflow"]["status"] ->; - -export const isWorkflowWithTasks = (status: WorkflowStatusType) => { - return ( - status.__typename === "WorkflowErroredStatus" || - status.__typename === "WorkflowFailedStatus" || - status.__typename === "WorkflowRunningStatus" || - status.__typename === "WorkflowSucceededStatus" - ); -}; - -export function useClientSidePagination( - items: readonly T[] | T[], - perPage: number = 10, -) { - const [pageNumber, setPageNumber] = useState(1); - - const totalPages = Math.ceil(items.length / perPage); - const startIndex = (pageNumber - 1) * perPage; - const endIndex = startIndex + perPage; - const paginatedItems = items.slice(startIndex, endIndex); - - return { - pageNumber, - setPageNumber, - totalPages, - paginatedItems, - }; -} - -export function updateWorkflowsState( - fetched: string[], - visible: string[], - currentNew: string[], - setNew: (w: string[]) => void, -) { - const added = fetched.filter((name) => !visible.includes(name)); - - const combined = [...new Set([...currentNew, ...added])]; - const newChanged = - combined.length !== currentNew.length || - combined.some((name, i) => name !== currentNew[i]); - - if (newChanged) { - setNew(combined); - } -} - -const finishedStatuses = new Set([ - "WorkflowErroredStatus", - "WorkflowFailedStatus", - "WorkflowSucceededStatus", -]); - -export function isFinished( - data: workflowRelayQuery$data | workflowRelaySubscription$data, -) { - return ( - data.workflow.status?.__typename && - finishedStatuses.has(data.workflow.status.__typename) - ); -} diff --git a/frontend/relay-workflows-lib/lib/utils/coreUtils.ts b/frontend/relay-workflows-lib/lib/utils/coreUtils.ts new file mode 100644 index 000000000..be69f0fb9 --- /dev/null +++ b/frontend/relay-workflows-lib/lib/utils/coreUtils.ts @@ -0,0 +1,135 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { Visit, visitToText } from "@diamondlightsource/sci-react-ui"; +import { visitTextToVisit } from "workflows-lib/lib/utils/commonUtils"; +import { workflowRelayQuery$data } from "../graphql/__generated__/workflowRelayQuery.graphql"; +import { workflowRelaySubscription$data } from "../graphql/__generated__/workflowRelaySubscription.graphql"; +import { LiveWorkflowRelaySubscription$data } from "../subscription-components/__generated__/LiveWorkflowRelaySubscription.graphql"; +import { LiveSingleWorkflowViewSubscription$data } from "../views/__generated__/LiveSingleWorkflowViewSubscription.graphql"; +import { Task } from "workflows-lib"; + +export const useVisitInput = (initialVisitId?: string | null) => { + const navigate = useNavigate(); + const location = useLocation(); + const [visit, setVisit] = useState( + visitTextToVisit(initialVisitId ?? undefined), + ); + + const handleVisitSubmit = (visit: Visit | null) => { + if (visit) { + const route = location.pathname.split("/")[1]; // Extract the first segment of the path + const visitid = visitToText(visit); + localStorage.setItem("instrumentSessionID", visitid); + (navigate(`/${route}/${visitid}/`) as Promise) + .then(() => { + setVisit(visit); + }) + .catch((error: unknown) => { + console.error("Navigation error:", error); + }); + } + }; + + return { visit, handleVisitSubmit }; +}; + +export function ScrollRestorer() { + const scrollRef = useRef(0); + + useEffect(() => { + return () => { + scrollRef.current = window.scrollY; + }; + }, []); + + useEffect(() => { + window.scrollTo(0, scrollRef.current); + }, []); + + return null; +} + +type WorkflowStatusWithTasks = + | { + __typename: "WorkflowRunningStatus"; + tasks: Task[]; + } + | { + __typename: "WorkflowSucceededStatus"; + tasks: Task[]; + } + | { + __typename: "WorkflowFailedStatus"; + tasks: Task[]; + } + | { + __typename: "WorkflowErroredStatus"; + tasks: Task[]; + }; + +export function isWorkflowWithTasks(status: { + __typename: string; +}): status is WorkflowStatusWithTasks { + return ( + status.__typename === "WorkflowRunningStatus" || + status.__typename === "WorkflowSucceededStatus" || + status.__typename === "WorkflowFailedStatus" || + status.__typename === "WorkflowErroredStatus" + ); +} + +export function useClientSidePagination( + items: readonly T[] | T[], + perPage: number = 10, +) { + const [pageNumber, setPageNumber] = useState(1); + + const totalPages = Math.ceil(items.length / perPage); + const startIndex = (pageNumber - 1) * perPage; + const endIndex = startIndex + perPage; + const paginatedItems = items.slice(startIndex, endIndex); + + return { + pageNumber, + setPageNumber, + totalPages, + paginatedItems, + }; +} + +export function updateWorkflowsState( + fetched: string[], + visible: string[], + currentNew: string[], + setNew: (w: string[]) => void, +) { + const added = fetched.filter((name) => !visible.includes(name)); + + const combined = [...new Set([...currentNew, ...added])]; + const newChanged = + combined.length !== currentNew.length || + combined.some((name, i) => name !== currentNew[i]); + + if (newChanged) { + setNew(combined); + } +} + +export const finishedStatuses = new Set([ + "WorkflowErroredStatus", + "WorkflowFailedStatus", + "WorkflowSucceededStatus", +]); + +export function isFinished( + data: + | workflowRelayQuery$data + | workflowRelaySubscription$data + | LiveSingleWorkflowViewSubscription$data + | LiveWorkflowRelaySubscription$data, +) { + return ( + data.workflow.status?.__typename && + finishedStatuses.has(data.workflow.status.__typename) + ); +} diff --git a/frontend/relay-workflows-lib/lib/components/useServerSidePagination.ts b/frontend/relay-workflows-lib/lib/utils/useServerSidePagination.ts similarity index 100% rename from frontend/relay-workflows-lib/lib/components/useServerSidePagination.ts rename to frontend/relay-workflows-lib/lib/utils/useServerSidePagination.ts diff --git a/frontend/relay-workflows-lib/lib/utils/useTemplateMatchesSearch.ts b/frontend/relay-workflows-lib/lib/utils/useTemplateMatchesSearch.ts new file mode 100644 index 000000000..7f1d3af8c --- /dev/null +++ b/frontend/relay-workflows-lib/lib/utils/useTemplateMatchesSearch.ts @@ -0,0 +1,19 @@ +export function templateMatchesSearch( + search: string, + name: string, + title?: string | null, + description?: string | null, +): boolean { + const trimmedSearch = search.trim(); + if (!trimmedSearch) { + console.log("No search for", name); + return true; + } + + const upperSearch = trimmedSearch.toUpperCase(); + + return [name, title, description].some( + (field): field is string => + typeof field === "string" && field.toUpperCase().includes(upperSearch), + ); +} diff --git a/frontend/relay-workflows-lib/lib/components/workflowRelayUtils.ts b/frontend/relay-workflows-lib/lib/utils/workflowRelayUtils.ts similarity index 70% rename from frontend/relay-workflows-lib/lib/components/workflowRelayUtils.ts rename to frontend/relay-workflows-lib/lib/utils/workflowRelayUtils.ts index 09d3ed35b..aba96f6bf 100644 --- a/frontend/relay-workflows-lib/lib/components/workflowRelayUtils.ts +++ b/frontend/relay-workflows-lib/lib/utils/workflowRelayUtils.ts @@ -1,8 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; -import { Task, TaskStatus } from "workflows-lib"; -import { isWorkflowWithTasks } from "../utils"; -import { workflowRelaySubscription$data } from "../graphql/__generated__/workflowRelaySubscription.graphql"; +import { Artifact, Task } from "workflows-lib"; +import { isWorkflowWithTasks } from "../utils/coreUtils"; +import { useFragment } from "react-relay"; +import { WorkflowTasksFragment } from "../graphql/WorkflowTasksFragment"; +import { WorkflowTasksFragment$key } from "../graphql/__generated__/WorkflowTasksFragment.graphql"; export function updateSearchParamsWithTaskIds( updatedTaskIds: string[], @@ -50,30 +52,27 @@ export function useSelectedTaskIds(): [string[], (tasks: string[]) => void] { } export function useFetchedTasks( - data: workflowRelaySubscription$data | null, + fragmentRef: WorkflowTasksFragment$key | null, ): Task[] { const [fetchedTasks, setFetchedTasks] = useState([]); + const data = useFragment(WorkflowTasksFragment, fragmentRef); useEffect(() => { - if ( - data && - data.workflow.status && - isWorkflowWithTasks(data.workflow.status) - ) { + if (data && data.status && isWorkflowWithTasks(data.status)) { setFetchedTasks( - data.workflow.status.tasks.map((task) => ({ + data.status.tasks.map((task: Task) => ({ id: task.id, name: task.name, - status: task.status as TaskStatus, - depends: [...task.depends], - artifacts: task.artifacts.map((artifact) => ({ + status: task.status, + depends: [...(task.depends ?? [])], + artifacts: task.artifacts.map((artifact: Artifact) => ({ ...artifact, parentTask: task.name, parentTaskId: task.id, key: `${task.id}-${artifact.name}`, })), - workflow: data.workflow.name, - instrumentSession: data.workflow.visit, + workflow: data.name, + instrumentSession: data.visit, stepType: task.stepType, })), ); diff --git a/frontend/relay-workflows-lib/lib/components/BaseSingleWorkflowView.tsx b/frontend/relay-workflows-lib/lib/views/BaseSingleWorkflowView.tsx similarity index 76% rename from frontend/relay-workflows-lib/lib/components/BaseSingleWorkflowView.tsx rename to frontend/relay-workflows-lib/lib/views/BaseSingleWorkflowView.tsx index 6330ebbbf..9e2690cd9 100644 --- a/frontend/relay-workflows-lib/lib/components/BaseSingleWorkflowView.tsx +++ b/frontend/relay-workflows-lib/lib/views/BaseSingleWorkflowView.tsx @@ -3,23 +3,41 @@ import { Box, ToggleButton } from "@mui/material"; import { TaskInfo } from "workflows-lib/lib/components/workflow/TaskInfo"; import { buildTaskTree } from "workflows-lib/lib/utils/tasksFlowUtils"; import { Artifact, Task, TaskNode } from "workflows-lib/lib/types"; -import { useFetchedTasks, useSelectedTaskIds } from "./workflowRelayUtils"; -import WorkflowInfo from "./WorkflowInfo"; -import { workflowRelaySubscription$data } from "../graphql/__generated__/workflowRelaySubscription.graphql"; -import WorkflowRelay from "./WorkflowRelay"; +import { + useFetchedTasks, + useSelectedTaskIds, +} from "../utils/workflowRelayUtils"; +import WorkflowInfo from "../components/WorkflowInfo"; +import { useFragment } from "react-relay"; +import { graphql } from "react-relay"; +import { BaseSingleWorkflowViewFragment$key } from "./__generated__/BaseSingleWorkflowViewFragment.graphql"; +import BaseWorkflowRelay from "../components/BaseWorkflowRelay"; + +export const BaseSingleWorkflowViewFragment = graphql` + fragment BaseSingleWorkflowViewFragment on Workflow @relay(mask: false) { + status { + __typename + } + ...BaseWorkflowRelayFragment + ...WorkflowRelayFragment + ...WorkflowInfoFragment + ...WorkflowTasksFragment + } +`; interface BaseSingleWorkflowViewProps { - data: workflowRelaySubscription$data | null; + fragmentRef: BaseSingleWorkflowViewFragment$key | null; taskIds?: string[]; } export default function BaseSingleWorkflowView({ taskIds, - data, + fragmentRef, }: BaseSingleWorkflowViewProps) { const [artifactList, setArtifactList] = useState([]); const [outputTaskIds, setOutputTaskIds] = useState([]); - const fetchedTasks = useFetchedTasks(data); + const data = useFragment(BaseSingleWorkflowViewFragment, fragmentRef); + const fetchedTasks = useFetchedTasks(data ?? null); const [selectedTaskIds, setSelectedTaskIds] = useSelectedTaskIds(); const [filledTaskId, setFilledTaskId] = useState(null); @@ -73,6 +91,10 @@ export default function BaseSingleWorkflowView({ setOutputTaskIds(newOutputTaskIds); }, [taskTree]); + if (!data || !data.status) { + return null; + } + return ( <> - {data && ( - - {taskIds && ( )} - {data && } + {} ); } diff --git a/frontend/relay-workflows-lib/lib/views/LiveSingleWorkflowView.tsx b/frontend/relay-workflows-lib/lib/views/LiveSingleWorkflowView.tsx new file mode 100644 index 000000000..e708f3ff9 --- /dev/null +++ b/frontend/relay-workflows-lib/lib/views/LiveSingleWorkflowView.tsx @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from "react"; +import { requestSubscription, useRelayEnvironment } from "react-relay"; +import { graphql } from "react-relay"; +import { + LiveSingleWorkflowViewSubscription, + LiveSingleWorkflowViewSubscription$data, +} from "./__generated__/LiveSingleWorkflowViewSubscription.graphql"; +import BaseSingleWorkflowView from "./BaseSingleWorkflowView"; +import { BaseSingleWorkflowViewFragment$key } from "./__generated__/BaseSingleWorkflowViewFragment.graphql"; +import { SingleWorkflowViewProps } from "./SingleWorkflowView"; +import { isFinished } from "../utils/coreUtils"; + +const LiveSingleWorkflowViewSubscriptionQuery = graphql` + subscription LiveSingleWorkflowViewSubscription( + $visit: VisitInput! + $name: String! + ) { + workflow(visit: $visit, name: $name) { + status { + __typename + } + ...BaseSingleWorkflowViewFragment + } + } +`; + +interface LiveWorkflowRelayProps extends SingleWorkflowViewProps { + onNullSubscriptionData: () => void; +} + +export default function LiveWorkflowView({ + visit, + workflowName, + taskIds, + onNullSubscriptionData, +}: LiveWorkflowRelayProps) { + const [workflowFragmentRef, setWorkflowFragmentRef] = + useState(null); + const environment = useRelayEnvironment(); + const subscriptionRef = useRef<{ dispose: () => void } | null>(null); + + useEffect(() => { + const subscription = + requestSubscription(environment, { + subscription: LiveSingleWorkflowViewSubscriptionQuery, + variables: { visit, name: workflowName }, + onNext: (response?: LiveSingleWorkflowViewSubscription$data | null) => { + if (response?.workflow) { + setWorkflowFragmentRef(response.workflow); + if (isFinished(response)) { + console.log("Workflow finished, unsubscribing."); + subscriptionRef.current?.dispose(); + } + } else { + onNullSubscriptionData(); + setWorkflowFragmentRef(null); + } + }, + onError: (error: unknown) => { + console.error("Subscription error:", error); + }, + onCompleted: () => { + console.log("Subscription completed"); + }, + }); + + subscriptionRef.current = subscription; + + return () => { + subscriptionRef.current?.dispose(); + }; + }, [visit, workflowName, environment, onNullSubscriptionData]); + + return workflowFragmentRef ? ( + + ) : null; +} diff --git a/frontend/relay-workflows-lib/lib/views/SingleWorkflowView.tsx b/frontend/relay-workflows-lib/lib/views/SingleWorkflowView.tsx new file mode 100644 index 000000000..8493051b0 --- /dev/null +++ b/frontend/relay-workflows-lib/lib/views/SingleWorkflowView.tsx @@ -0,0 +1,55 @@ +import { Visit } from "workflows-lib"; +import LiveSingleWorkflowView from "./LiveSingleWorkflowView"; +import { useLazyLoadQuery } from "react-relay"; +import { SingleWorkflowViewQuery as SingleWorkflowViewQueryType } from "./__generated__/SingleWorkflowViewQuery.graphql"; +import { finishedStatuses } from "../utils/coreUtils"; +import BaseSingleWorkflowView from "./BaseSingleWorkflowView"; +import { graphql } from "react-relay"; +import { useState } from "react"; + +const SingleWorkflowViewQuery = graphql` + query SingleWorkflowViewQuery($visit: VisitInput!, $name: String!) { + workflow(visit: $visit, name: $name) { + status { + __typename + } + ...BaseSingleWorkflowViewFragment + } + } +`; + +export interface SingleWorkflowViewProps { + visit: Visit; + workflowName: string; + taskIds?: string[]; + onNullSubscriptionData?: () => void; +} + +export default function SingleWorkflowView(props: SingleWorkflowViewProps) { + const queryData = useLazyLoadQuery( + SingleWorkflowViewQuery, + { + visit: props.visit, + name: props.workflowName, + }, + ); + const finished = + queryData.workflow.status?.__typename && + finishedStatuses.has(queryData.workflow.status.__typename); + const [isNull, setIsNull] = useState(false); + const onNullSubscriptionData = () => { + setIsNull(true); + }; + + return finished || isNull ? ( + + ) : ( + + ); +} diff --git a/frontend/relay-workflows-lib/lib/views/TemplatesListView.tsx b/frontend/relay-workflows-lib/lib/views/TemplatesListView.tsx index 10bbe1796..854e706e7 100644 --- a/frontend/relay-workflows-lib/lib/views/TemplatesListView.tsx +++ b/frontend/relay-workflows-lib/lib/views/TemplatesListView.tsx @@ -1,26 +1,20 @@ -import { ChangeEvent, useMemo, useState } from "react"; -import TemplateCard from "../components/TemplateCard"; -import { graphql, useLazyLoadQuery, useFragment } from "react-relay/hooks"; +import { useState, ChangeEvent, useMemo, useEffect } from "react"; +import { graphql, useLazyLoadQuery } from "react-relay"; import { Box, Pagination } from "@mui/material"; -import type { TemplatesListViewQuery_templateSearch$key } from "./__generated__/TemplatesListViewQuery_templateSearch.graphql"; import { useClientSidePagination } from "../utils/coreUtils"; import TemplateSearchField from "workflows-lib/lib/components/template/TemplateSearchField"; -import { TemplatesListViewQuery as TemplatesListViewQueryType } from "./__generated__/TemplatesListViewQuery.graphql"; - -const templateSearchFragment = graphql` - fragment TemplatesListViewQuery_templateSearch on WorkflowTemplate { - name - title - description - } -`; +import type { TemplatesListViewQuery as TemplatesListViewQueryType } from "./__generated__/TemplatesListViewQuery.graphql"; +import TemplateCard from "../components/TemplateCard"; +import { templateMatchesSearch } from "../utils/useTemplateMatchesSearch"; export const TemplatesListViewQuery = graphql` query TemplatesListViewQuery { workflowTemplates { nodes { - ...TemplateCard_template - ...TemplatesListViewQuery_templateSearch + name + title + description + ...TemplateCardFragment } } } @@ -33,48 +27,30 @@ export default function TemplatesListView() { ); const [search, setSearch] = useState(""); - const templatesWithSearchData = data.workflowTemplates.nodes.map( - (template) => - template - ? { - original: template, - searchData: useFragment( - templateSearchFragment, - template as TemplatesListViewQuery_templateSearch$key, - ), - } - : null, - ); + const filteredNodes = useMemo(() => { + const result = data.workflowTemplates.nodes.filter((node) => { + const match = templateMatchesSearch( + search, + node.name, + node.title ?? "", + node.description, + ); + return match; + }); + return result; + }, [search, data.workflowTemplates.nodes]); - const filteredTemplates = useMemo(() => { - const upperSearch = search.toUpperCase(); - if (!search) - return templatesWithSearchData.map((t) => t?.original).filter(Boolean); + const { pageNumber, setPageNumber, totalPages, paginatedItems } = + useClientSidePagination(filteredNodes, 10); - return templatesWithSearchData - .filter((t) => { - if (!t) return false; - const { searchData } = t; - return ( - searchData.title?.toUpperCase().includes(upperSearch) || - searchData.name.toUpperCase().includes(upperSearch) || - searchData.description?.toUpperCase().includes(upperSearch) - ); - }) - .map((t) => t?.original); - }, [search, data]); + useEffect(() => { + setPageNumber(1); + }, [search, setPageNumber]); const handleSearch = (search: string) => { setSearch(search); }; - const { - pageNumber, - setPageNumber, - totalPages, - paginatedItems: paginatedPosts, - } = useClientSidePagination(filteredTemplates, 10); - const handlePageChange = (_event: ChangeEvent, page: number) => { setPageNumber(page); }; @@ -88,9 +64,10 @@ export default function TemplatesListView() { alignItems="center" width="100%" > - {paginatedPosts.map((template, i) => - template ? : null, - )} + {paginatedItems.map((template, i) => ( + + ))} + = ({ + instrumentSessionID, +}) => { + const { visit, handleVisitSubmit } = useVisitInput(instrumentSessionID); + const [workflowQueryFilter, setWorkflowQueryFilter] = useState< + WorkflowQueryFilter | undefined + >(undefined); + + const { + cursor, + currentPage, + totalPages, + selectedLimit, + goToPage, + changeLimit, + updatePageInfo, + } = useServerSidePagination(); + + const [queryReference, loadQuery] = + useQueryLoader(WorkflowsListViewQuery); + + const templateData = useLazyLoadQuery( + WorkflowsListViewTemplatesQuery, + {}, + { fetchPolicy: "store-and-network" }, + ); + + const [isPaginated, setIsPaginated] = useState(false); + const lastPage = useRef(currentPage); + const lastLimit = useRef(selectedLimit); + + const load = useCallback(() => { + if (visit) { + loadQuery( + { visit, limit: selectedLimit, cursor, filter: workflowQueryFilter }, + { fetchPolicy: "store-and-network" }, + ); + } + }, [visit, selectedLimit, cursor, workflowQueryFilter, loadQuery]); + + useEffect(() => { + load(); + const interval = setInterval(load, 5000); + return () => { + clearInterval(interval); + }; + }, [load]); + + useEffect(() => { + if ( + currentPage !== lastPage.current || + selectedLimit !== lastLimit.current + ) { + setIsPaginated(true); + lastPage.current = currentPage; + lastLimit.current = selectedLimit; + } + }, [currentPage, selectedLimit]); + + return ( + <> + + + + + + { + setWorkflowQueryFilter(newFilters); + }} + /> + + + {workflowQueryFilter && ( + + )} + + + + + {visit && queryReference && ( + + + + { + + } + + + )} + + + ); +}; + +export default WorkflowsListView; diff --git a/frontend/relay-workflows-lib/package.json b/frontend/relay-workflows-lib/package.json index 50ace611e..1e40114f9 100644 --- a/frontend/relay-workflows-lib/package.json +++ b/frontend/relay-workflows-lib/package.json @@ -11,7 +11,9 @@ "relay": "relay-compiler" }, "dependencies": { + "@types/relay-test-utils": "^19.0.0", "react-relay": "^20.1.1", + "relay-test-utils": "^20.1.1", "workflows-lib": "*" }, "devDependencies": { diff --git a/frontend/relay-workflows-lib/lib/components/SubmittedMessagesList.tsx b/frontend/workflows-lib/lib/components/workflow/SubmittedMessagesList.tsx similarity index 87% rename from frontend/relay-workflows-lib/lib/components/SubmittedMessagesList.tsx rename to frontend/workflows-lib/lib/components/workflow/SubmittedMessagesList.tsx index 2702d0e9a..5332aa760 100644 --- a/frontend/relay-workflows-lib/lib/components/SubmittedMessagesList.tsx +++ b/frontend/workflows-lib/lib/components/workflow/SubmittedMessagesList.tsx @@ -2,8 +2,8 @@ import React from "react"; import { Box, Divider, Paper, Typography } from "@mui/material"; import { SubmissionData } from "workflows-lib/lib/types"; -import { RenderSubmittedMessage } from "./RenderSubmittedMessage"; -import { SubscribeAndRender } from "./SubscribeAndRender"; +import { RenderSubmittedMessage } from "relay-workflows-lib/lib/components/RenderSubmittedMessage"; +import { SubscribeAndRender } from "relay-workflows-lib/lib/subscription-components/SubscribeAndRender"; interface SubmittedMessagesListProps { submittedData: SubmissionData[]; diff --git a/frontend/workflows-lib/lib/main.ts b/frontend/workflows-lib/lib/main.ts index b327d8962..964b35902 100644 --- a/frontend/workflows-lib/lib/main.ts +++ b/frontend/workflows-lib/lib/main.ts @@ -1,5 +1,4 @@ export { default as WorkflowAccordion } from "./components/workflow/WorkflowAccordion"; -export { default as TasksFlow } from "./components/workflow/TasksFlow"; export { default as TasksTable } from "./components/workflow/TasksTable"; export { default as SubmissionForm } from "./components/template/SubmissionForm"; export { default as WorkflowsErrorBoundary } from "./components/workflow/WorkflowsErrorBoundary"; diff --git a/frontend/workflows-lib/stories/TasksFlow.stories.tsx b/frontend/workflows-lib/stories/TasksFlow.stories.tsx deleted file mode 100644 index 82fff1348..000000000 --- a/frontend/workflows-lib/stories/TasksFlow.stories.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { Meta, StoryObj, StoryFn } from "@storybook/react-vite"; -import { fakeTasksA } from "./common"; -import TasksFlow from "../lib/components/workflow/TasksFlow"; -import { Box } from "@mui/material"; -import { ResizableBox } from "react-resizable"; -import "react-resizable/css/styles.css"; - -const StaticDecorator = (Story: StoryFn) => ( - - - -); - -const DynamicDecorator = (Story: StoryFn) => ( - - - -); - -const meta: Meta = { - title: "Tasks", - component: TasksFlow, -}; - -type Story = StoryObj; - -export default meta; - -export const Graph: Story = { - args: { - tasks: fakeTasksA, - isDynamic: false, - }, - decorators: [StaticDecorator], -}; - -export const Dynamic: Story = { - args: { - tasks: fakeTasksA, - isDynamic: true, - }, - decorators: [DynamicDecorator], -}; diff --git a/frontend/workflows-lib/stories/WorkflowAccordion.stories.tsx b/frontend/workflows-lib/stories/WorkflowAccordion.stories.tsx index 781597601..abcb74847 100644 --- a/frontend/workflows-lib/stories/WorkflowAccordion.stories.tsx +++ b/frontend/workflows-lib/stories/WorkflowAccordion.stories.tsx @@ -2,8 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { MemoryRouter } from "react-router-dom"; import { fakeWorkflowA } from "./common"; import WorkflowAccordion from "../lib/components/workflow/WorkflowAccordion"; -import TasksFlow from "../lib/components/workflow/TasksFlow"; -import { fakeTasksA } from "./common"; +import { Typography } from "@mui/material"; const meta: Meta = { title: "Workflow", @@ -24,13 +23,6 @@ export default meta; export const Accordion: Story = { args: { workflow: fakeWorkflowA, - children: ( - {}} - /> - ), + children: Workflow Content , }, }; diff --git a/frontend/workflows-lib/tests/components/TasksDynamic.test.tsx b/frontend/workflows-lib/tests/components/TasksDynamic.test.tsx deleted file mode 100644 index 28ac41d92..000000000 --- a/frontend/workflows-lib/tests/components/TasksDynamic.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import TasksFlow from "../../lib/components/workflow/TasksFlow"; -import { ReactFlowInstance } from "@xyflow/react"; -import { Node, Edge } from "@xyflow/react"; -import { Task } from "../../lib/types"; -import { mockTasks } from "./data"; -import "react-resizable/css/styles.css"; - -vi.mock("@xyflow/react", () => ({ - ReactFlow: ({ - onInit, - }: { - onInit: (instance: ReactFlowInstance) => void; - }) => { - const mockInstance = { - fitView: vi.fn(), - } as unknown as ReactFlowInstance; - onInit(mockInstance); - return
; - }, - getNodesBounds: () => ({ width: 100, height: 100 }), -})); - -vi.mock("../../lib/components/workflow/TasksTable", () => ({ - __esModule: true, - default: () =>
, -})); - -vi.mock("../../lib/components/workflow/TasksFlowUtils", () => ({ - applyDagreLayout: (nodes: Node, edges: Edge) => ({ nodes, edges }), - buildTaskTree: (tasks: Task[]) => tasks, - generateNodesAndEdges: () => ({}), -})); - -describe("TasksFlow", () => { - it("should render Graph when there is no overflow", () => { - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ - width: 200, - height: 200, - x: 0, - y: 0, - bottom: 0, - left: 0, - right: 0, - top: 0, - toJSON: function () { - throw new Error("Function not implemented."); - }, - }); - - console.log("Mock Tasks:", mockTasks); - - render( - {}} - />, - ); - expect(screen.getByTestId("reactflow-mock")).toBeInTheDocument(); - expect(screen.queryByTestId("taskstable-mock")).not.toBeInTheDocument(); - }); - - it("should render TasksTable when there is overflow", () => { - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ - width: 50, - height: 50, - x: 0, - y: 0, - bottom: 0, - left: 0, - right: 0, - top: 0, - toJSON: function () { - throw new Error("Function not implemented."); - }, - }); - - console.log("Mock Tasks:", mockTasks); - - render( - {}} - />, - ); - expect(screen.getByTestId("taskstable-mock")).toBeInTheDocument(); - expect(screen.queryByTestId("reactflow-mock")).not.toBeInTheDocument(); - }); - - it("should clean up event listeners on unmount", () => { - const removeEventListenerSpy = vi.spyOn(window, "removeEventListener"); - - const { unmount } = render( - {}} - />, - ); - unmount(); - - expect(removeEventListenerSpy).toHaveBeenCalledWith( - "resize", - expect.any(Function), - ); - }); -}); diff --git a/frontend/workflows-lib/tests/components/TasksFlow.test.tsx b/frontend/workflows-lib/tests/components/TasksFlow.test.tsx deleted file mode 100644 index 60b9b4190..000000000 --- a/frontend/workflows-lib/tests/components/TasksFlow.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { act, render, renderHook } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import TasksFlow from "../../lib/components/workflow/TasksFlow"; -import { - applyDagreLayout, - buildTaskTree, - generateNodesAndEdges, - usePersistentViewport, -} from "../../lib/utils/tasksFlowUtils"; -import { ReactFlow } from "@xyflow/react"; -import { mockTasks } from "./data"; - -describe("TasksFlow Component", () => { - beforeEach(() => { - vi.mock( - "../../lib/components/workflow/TasksFlowNode", - async (importOriginal) => ({ - ...(await importOriginal()), - TaskFlowNode: vi.fn().mockReturnValue(
CustomNode Mock
), - }), - ); - }); - - const mockTaskTree = vi.hoisted(() => ({})); - const mockNodes = vi.hoisted(() => [ - { id: "node-1", position: { x: 0, y: 0 }, data: {} }, - ]); - const mockEdges = vi.hoisted(() => [ - { id: "edge-1", source: "node-1", target: "node-2" }, - ]); - const mockLayoutedNodes = vi.hoisted(() => [ - { id: "node-1", position: { x: 0, y: 0 }, data: {} }, - ]); - const mockLayoutedEdges = vi.hoisted(() => [ - { id: "edge-1", source: "node-1", target: "node-2" }, - ]); - - beforeEach(() => { - vi.mock("../../lib/utils/tasksFlowUtils", async (importOriginal) => ({ - ...(await importOriginal()), - buildTaskTree: vi.fn().mockReturnValue(mockTaskTree), - generateNodesAndEdges: vi.fn().mockReturnValue({ - nodes: mockNodes, - edges: mockEdges, - }), - applyDagreLayout: vi.fn().mockReturnValue({ - nodes: mockLayoutedNodes, - edges: mockLayoutedEdges, - }), - })); - }); - - beforeEach(() => { - vi.mock("@xyflow/react", async (importOriginal) => ({ - ...(await importOriginal()), - ReactFlow: vi.fn().mockReturnValue(
ReactFlow Mock
), - })); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render without crashing", () => { - const { getByText } = render( - {}} - />, - ); - expect(getByText("ReactFlow Mock")).toBeInTheDocument(); - }); - - it("should build the task tree", () => { - render( - {}} - />, - ); - - expect(buildTaskTree).toHaveBeenCalledWith(mockTasks); - }); - - it("should generate nodes and edges based on the task tree", () => { - render( - {}} - />, - ); - - expect(generateNodesAndEdges).toHaveBeenCalledWith(mockTaskTree); - }); - - it("should apply the dagre layout", () => { - render( - {}} - />, - ); - - expect(applyDagreLayout).toHaveBeenCalledWith(mockNodes, mockEdges); - }); - - it("should initialize ReactFlow with the correct nodes and edges", () => { - render( - {}} - />, - ); - - expect(ReactFlow).toHaveBeenCalledWith( - expect.objectContaining({ - defaultViewport: { - x: 0, - y: 0, - zoom: 1.5, - }, - nodes: mockLayoutedNodes, - edges: mockLayoutedEdges, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - nodeTypes: expect.objectContaining({ custom: expect.any(Function) }), - nodesDraggable: false, - nodesConnectable: false, - elementsSelectable: true, - zoomOnScroll: false, - zoomOnPinch: false, - zoomOnDoubleClick: false, - panOnDrag: true, - preventScrolling: false, - fitView: false, - style: { width: "100%", overflow: "auto", height: "100%" }, - }), - {}, - ); - }); -}); - -describe("usePersistentViewport hook tests", () => { - beforeEach(() => { - sessionStorage.clear(); - vi.clearAllMocks(); - }); - - const mockViewport = { x: 20, y: 30, zoom: 2.5 }; - - it("should save viewport to sessionStorage", () => { - const { result } = renderHook(() => usePersistentViewport("testWorkflowA")); - - act(() => { - result.current.saveViewport(mockViewport); - }); - - const stored = sessionStorage.getItem("testWorkflowAViewport"); - expect(stored).toBe(JSON.stringify(mockViewport)); - }); - - it("loads viewport from sessionStorage", () => { - sessionStorage.setItem( - "testWorkflowBViewport", - JSON.stringify(mockViewport), - ); - - const { result } = renderHook(() => usePersistentViewport("testWorkflowB")); - - let loadedViewport; - act(() => { - loadedViewport = result.current.loadViewport(); - }); - - expect(loadedViewport).toEqual(mockViewport); - }); - - it("clears viewport from sessionStorage", () => { - sessionStorage.setItem( - "testWorkflowCViewport", - JSON.stringify(mockViewport), - ); - - const { result } = renderHook(() => usePersistentViewport("testWorkflowC")); - - act(() => { - result.current.clearViewport(); - }); - - expect(sessionStorage.getItem("testWorkflowCViewport")).toBeNull(); - }); -}); diff --git a/frontend/workflows-lib/tests/components/TasksTable.test.tsx b/frontend/workflows-lib/tests/components/TasksTable.test.tsx deleted file mode 100644 index 261dfc1c7..000000000 --- a/frontend/workflows-lib/tests/components/TasksTable.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { render } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import TasksTable from "../../lib/components/workflow/TasksFlow"; -import { getTaskStatusIcon } from "../../lib/components/common/StatusIcons"; -import { mockTasks } from "./data"; - -describe("TaskTable Component", () => { - beforeEach(() => { - vi.mock( - "../../lib/components/common/StatusIcons", - async (importOriginal) => ({ - ...(await importOriginal()), - getTaskStatusIcon: vi - .fn() - .mockReturnValueOnce(Pending Icon) - .mockReturnValueOnce(Completed Icon) - .mockReturnValueOnce(In-Progress Icon), - }), - ); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render without crashing", () => { - const { getByText } = render( - {}} - />, - ); - expect(getByText("task-1")).toBeInTheDocument(); - expect(getByText("task-2")).toBeInTheDocument(); - expect(getByText("task-3")).toBeInTheDocument(); - }); - - it("should call getStatusIcon for each task", () => { - render( - {}} - />, - ); - expect(getTaskStatusIcon).toHaveBeenCalledWith("Pending"); - expect(getTaskStatusIcon).toHaveBeenCalledWith("Succeeded"); - expect(getTaskStatusIcon).toHaveBeenCalledWith("Running"); - }); -}); diff --git a/frontend/workflows-lib/tests/components/TemplateList.test.tsx b/frontend/workflows-lib/tests/components/TemplateList.test.tsx deleted file mode 100644 index 265123bd4..000000000 --- a/frontend/workflows-lib/tests/components/TemplateList.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import "@testing-library/jest-dom"; -import TemplatesList from "relay-workflows-lib/lib/components/TemplatesList"; -import { TemplateCardProps } from "relay-workflows-lib/lib/components/TemplateCard"; -import { Box } from "@mui/material"; -import userEvent from "@testing-library/user-event"; -import templateListResponse from "dashboard/src/mocks/responses/templates/templateListResponse.json"; - -const mockTemplates = { - workflowTemplates: { - nodes: templateListResponse.workflowTemplates.nodes.slice(0, 10), - }, -}; - -vi.mock("relay-runtime", () => ({ - graphql: () => {}, -})); - -vi.mock("workflows-lib/lib/components/template/TemplateCard", () => ({ - TemplateCard: (props: TemplateCardProps) => ( - {(props as any).template.name} - ), -})); - -vi.mock("react-relay/hooks", () => ({ - useLazyLoadQuery: vi.fn(() => mockTemplates), -})); - -describe("TemplateList", () => { - // Get the names of all mock templates in ./data.ts - const allTemplateNames: string[] = []; - mockTemplates.workflowTemplates.nodes.map((template) => { - allTemplateNames.push(template.name); - }); - - // [search string, template names expected to be visible in list] - const cases: [string, string[]][] = [ - ["", allTemplateNames], - ["e02-mib2x", ["e02-mib2x"]], - ["e02", ["e02-mib2x", "e02-auto-mib2x"]], - ["based on conditions", ["conditional-steps"]], - ["HTTOMO-cor-SwEeP", ["httomo-cor-sweep"]], - ]; - - const user = userEvent.setup(); - - beforeEach(() => { - vi.clearAllMocks(); - render(); - }); - - test.each(cases)( - "returns a search of '%s' with '%s'", - async (search, results) => { - const searchInput = screen.getByTestId("searchInput"); - const filteredOutTemplates = allTemplateNames.filter( - (name) => !results.includes(name), - ); - - allTemplateNames.forEach((templateName) => { - expect(screen.getByText(templateName)).toBeInTheDocument(); - }); - - if (search) await user.type(searchInput, search); - - results.forEach((template) => { - expect(screen.getByText(template)).toBeInTheDocument(); - }); - filteredOutTemplates.forEach((template) => { - expect(screen.queryByText(template)).not.toBeInTheDocument(); - }); - }, - ); - - it("shows all templates again when search is cleared", async () => { - const searchInput = screen.getByTestId("searchInput"); - const clearButton = screen.getByTestId("clear-search"); - - await user.type(searchInput, "ePSIC mib conversion"); - - expect(screen.getByText("e02-mib2x")).toBeInTheDocument(); - expect(screen.queryByText("conditional-steps")).not.toBeInTheDocument(); - - await user.click(clearButton); - - expect(screen.getByText("e02-mib2x")).toBeInTheDocument(); - expect(screen.getByText("conditional-steps")).toBeInTheDocument(); - }); -}); diff --git a/frontend/workflows-lib/tests/components/WorkflowAccordion.test.tsx b/frontend/workflows-lib/tests/components/WorkflowAccordion.test.tsx index 8933d561d..f3319f0c9 100644 --- a/frontend/workflows-lib/tests/components/WorkflowAccordion.test.tsx +++ b/frontend/workflows-lib/tests/components/WorkflowAccordion.test.tsx @@ -1,17 +1,16 @@ import { render, fireEvent } from "@testing-library/react"; import "@testing-library/jest-dom"; import { MemoryRouter } from "react-router-dom"; -import { WorkflowAccordion } from "../../lib/main"; -import { TaskStatus, WorkflowStatus } from "../../lib/types"; +import { WorkflowAccordion } from "workflows-lib"; +import { TaskStatus, WorkflowStatus } from "workflows-lib/lib/types"; import { Visit } from "@diamondlightsource/sci-react-ui"; +import { vi } from "vitest"; -describe("WorkflowAccordion Component", () => { - beforeAll(() => { - vi.mock("../../lib/components/common/StatusIcons", () => ({ - getWorkflowStatusIcon: vi.fn(() =>
Mocked WorkflowStatusIcon
), - })); - }); +vi.mock("../../lib/components/common/StatusIcons", () => ({ + getWorkflowStatusIcon: vi.fn(() =>
Mocked WorkflowStatusIcon
), +})); +describe("WorkflowAccordion Component", () => { const mockWorkflow = { name: "Test Workflow", status: "Running" as WorkflowStatus, diff --git a/frontend/workflows-lib/tests/components/WorkflowsListFilterDrawer.test.tsx b/frontend/workflows-lib/tests/components/WorkflowsListFilterDrawer.test.tsx deleted file mode 100644 index 89c0fe268..000000000 --- a/frontend/workflows-lib/tests/components/WorkflowsListFilterDrawer.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import "@testing-library/jest-dom"; -import { render, screen } from "@testing-library/react"; -import templateListResponse from "dashboard/src/mocks/responses/templates/templateListResponse.json"; -import { WorkflowListFilterDrawer } from "relay-workflows-lib"; -import userEvent from "@testing-library/user-event"; - -vi.mock("relay-runtime", () => ({ - graphql: () => {}, -})); - -vi.mock("react-relay", () => ({ - graphql: () => {}, -})); - -vi.mock("react-relay/hooks", () => ({ - useLazyLoadQuery: vi.fn(() => templateListResponse), -})); - -describe("WorkflowListFilterDrawer", () => { - const mockApplyFilter = vi.fn(); - const user = userEvent.setup(); - - beforeEach(() => { - vi.clearAllMocks(); - render(); - }); - - it("displays the list of templates as options", async () => { - await user.click(screen.getByRole("button")); - - const autocomplete = screen.getByRole("combobox", { name: "Template" }); - expect(autocomplete).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Open" })); - - const options = await screen.findAllByRole("option"); - expect(options).toHaveLength(28); - }); - - it("allows a template to be selected", async () => { - await user.click(screen.getByRole("button")); - - const autocomplete = screen.getByRole("combobox", { name: "Template" }); - expect(autocomplete).toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Open" })); - - expect(autocomplete).toHaveValue(""); - await user.keyboard("{ArrowDown}{Enter}"); - expect(autocomplete).toHaveValue("conditional-steps"); - }); - - it("filters templates as text is entered", async () => { - await user.click(screen.getByRole("button")); - await userEvent.click(screen.getByRole("button", { name: "Open" })); - - await user.keyboard("httomo"); - const options = await screen.findAllByRole("option"); - expect(options).toHaveLength(2); - }); - - it("updates the filter when applied", async () => { - await user.click(screen.getByRole("button")); - const autocomplete = screen.getByRole("combobox", { name: "Template" }); - await userEvent.click(screen.getByRole("button", { name: "Open" })); - const options = await screen.findAllByRole("option"); - await userEvent.click(options[2]); - expect(autocomplete).toHaveValue("e02-mib2x"); - await user.click(screen.getByRole("button", { name: "Apply" })); - - expect(mockApplyFilter).toHaveBeenCalledWith({ - creator: undefined, - template: "e02-mib2x", - workflowStatusFilter: undefined, - }); - }); -}); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index c61ae6d3a..f7662420a 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1505,7 +1505,7 @@ dependencies: "@types/react" "*" -"@types/react-relay@18.2.1", "@types/react-relay@^18.2.1": +"@types/react-relay@*", "@types/react-relay@18.2.1", "@types/react-relay@^18.2.1": version "18.2.1" resolved "https://registry.yarnpkg.com/@types/react-relay/-/react-relay-18.2.1.tgz#2c87a622e98ebf3d0162dd2edc9c3ed014df6ca1" integrity sha512-KgmFapsxAylhxcFfaAv5GZZJhTHnDvV8IDZVsUm5afpJUvgZC1Y68ssfOGsFfiFY/2EhxHM/YPfpdKbfmF3Ecg== @@ -1538,6 +1538,15 @@ resolved "https://registry.yarnpkg.com/@types/relay-runtime/-/relay-runtime-19.0.2.tgz#1fe872771ce75835b301916c2f4403db5534fff8" integrity sha512-lcWb+/7cPG21UgtK/fdpt8QgtV2GYc8TQIusZUBL1k2oy+8oNwYQAAj0/j1pkg77sk/p5MQPQZYOWN9i8wpUhg== +"@types/relay-test-utils@^19.0.0": + version "19.0.0" + resolved "https://registry.yarnpkg.com/@types/relay-test-utils/-/relay-test-utils-19.0.0.tgz#3ee2a4c8f577d812fdfb8e61dbd85e3558f9dbfa" + integrity sha512-yC/wVgDetV+88HrHLKlesIfju3RGsp32vLs0IiwBPKD0npQSTrFddcP3FNPe5Kkk4/Y/kgY3oR/DA8g8Bmmppw== + dependencies: + "@types/react" "*" + "@types/react-relay" "*" + "@types/relay-runtime" "*" + "@types/resolve@^1.20.2": version "1.20.6" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.6.tgz#e6e60dad29c2c8c206c026e6dd8d6d1bdda850b8" @@ -4771,6 +4780,16 @@ relay-runtime@20.1.1, relay-runtime@^20.0.0: fbjs "^3.0.2" invariant "^2.2.4" +relay-test-utils@^20.1.1: + version "20.1.1" + resolved "https://registry.yarnpkg.com/relay-test-utils/-/relay-test-utils-20.1.1.tgz#7dbb7cdd3439ad1278ee1ce217ebdfbe75e9568a" + integrity sha512-9E6+smojY43XmBi7un5l+8xWpqq1AgJtNEJZHDOLbdF8NIOzxI1n/HF2zZeGbqVQWjpvl/EyqegL6LLrIIJpcA== + dependencies: + "@babel/runtime" "^7.25.0" + fbjs "^3.0.2" + invariant "^2.2.4" + relay-runtime "20.1.1" + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"