diff --git a/package.json b/package.json index 3f5c0b18f..239211215 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "@rjsf/validator-ajv8": "5.24.10", "@sentry/nextjs": "8.55.0", "@squonk/account-server-client": "4.2.1", - "@squonk/data-manager-client": "4.1.0", + "@squonk/data-manager-client": "4.1.5", "@squonk/mui-theme": "5.0.0", "@squonk/sdf-parser": "1.3.1", "@tanstack/match-sorter-utils": "8.19.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f22ea2a4..141684b63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,8 +71,8 @@ importers: specifier: 4.2.1 version: 4.2.1(@tanstack/react-query@5.77.2(react@19.1.0))(axios@1.9.0) '@squonk/data-manager-client': - specifier: 4.1.0 - version: 4.1.0(@tanstack/react-query@5.77.2(react@19.1.0))(axios@1.9.0) + specifier: 4.1.5 + version: 4.1.5(@tanstack/react-query@5.77.2(react@19.1.0))(axios@1.9.0) '@squonk/mui-theme': specifier: 5.0.0 version: 5.0.0(@mui/material@6.4.11(@emotion/react@11.14.0(@types/react@19.1.6)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react@19.1.0))(@types/react@19.1.6)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) @@ -1605,8 +1605,8 @@ packages: '@tanstack/react-query': '>=4' axios: '>=0.23' - '@squonk/data-manager-client@4.1.0': - resolution: {integrity: sha512-QYeKqw+UrbkHo5l44glQgL9DrU7VnnqplPRmt5rTdAQX8vFdYbH21te1ZUXpDqH/eF62zv47DoPcAPWSBLLfVQ==} + '@squonk/data-manager-client@4.1.5': + resolution: {integrity: sha512-lNrMtDaciZkLRULjZHHr+stALr/pOCHavuaF8KclmDPmMSgp1dADOIniAh9OB1r5C02B0USy8pTQhRMMik30kQ==} peerDependencies: '@tanstack/react-query': '>=4' axios: '>=0.23' @@ -7651,7 +7651,7 @@ snapshots: '@tanstack/react-query': 5.77.2(react@19.1.0) axios: 1.9.0 - '@squonk/data-manager-client@4.1.0(@tanstack/react-query@5.77.2(react@19.1.0))(axios@1.9.0)': + '@squonk/data-manager-client@4.1.5(@tanstack/react-query@5.77.2(react@19.1.0))(axios@1.9.0)': dependencies: '@tanstack/react-query': 5.77.2(react@19.1.0) axios: 1.9.0 diff --git a/src/components/instances/JobDetails/JobInputSection/useGetJobInputs.ts b/src/components/instances/JobDetails/JobInputSection/useGetJobInputs.ts index 7553b5ece..b1dfa80ad 100644 --- a/src/components/instances/JobDetails/JobInputSection/useGetJobInputs.ts +++ b/src/components/instances/JobDetails/JobInputSection/useGetJobInputs.ts @@ -5,7 +5,7 @@ import { type InputFieldSchema } from "../../../runCards/JobCard/JobInputFields" import { TEST_JOB_ID } from "../../../runCards/TestJob/jobId"; // Contains only fields we are interested in -type ApplicationSpecification = { variables: Record }; +type ApplicationSpecification = { variables?: Record }; // Contains only fields we are interested in type JobInput = { title: string; type: InputFieldSchema["type"] }; @@ -27,6 +27,8 @@ export const useGetJobInputs = (instance: InstanceGetResponse | InstanceSummary) { query: { enabled: inputsEnabled, retry: instance.job_id === TEST_JOB_ID ? 1 : 3 } }, ); + console.log(instance); + // Parse application specification const applicationSpecification: ApplicationSpecification = instance.application_specification ? JSON.parse(instance.application_specification) @@ -40,10 +42,10 @@ export const useGetJobInputs = (instance: InstanceGetResponse | InstanceSummary) // Get information about inputs that were provided when creating the job with their respective // values const usedInputs = Object.entries(jobVariables.properties) - .filter(([name]) => Boolean(applicationSpecification.variables[name])) + .filter(([name]) => Boolean(applicationSpecification.variables?.[name])) .map(([name, jobInput]) => { // Let's assume inputs can only contain string or array of strings as values - const value = applicationSpecification.variables[name] as string[] | string; + const value = applicationSpecification.variables?.[name] as string[] | string; return { name, diff --git a/src/components/runCards/JobCard/JobInputsAndOptionsForm.tsx b/src/components/runCards/JobCard/JobInputsAndOptionsForm.tsx new file mode 100644 index 000000000..a5c8e23f7 --- /dev/null +++ b/src/components/runCards/JobCard/JobInputsAndOptionsForm.tsx @@ -0,0 +1,81 @@ +import { type Dispatch, type RefObject, type SetStateAction } from "react"; + +import { type JobOrderDetail } from "@squonk/data-manager-client"; + +import { Grid2 as Grid, Typography } from "@mui/material"; +import { Form } from "@rjsf/mui"; +import validator from "@rjsf/validator-ajv8"; + +import { JobInputFields } from "./JobInputFields"; +import { type InputData } from "./JobModal"; + +interface JobInputsAndOptionsFormProps { + inputs?: any; + options?: any; + order: JobOrderDetail["options"]; + projectId: string; + inputsData: InputData; + setInputsData: Dispatch>; + optionsFormData: any; + setOptionsFormData: Dispatch>; + formRef: RefObject; + specVariables?: any; +} + +export const JobInputsAndOptionsForm = ({ + inputs, + options, + order, + projectId, + inputsData, + setInputsData, + optionsFormData, + setOptionsFormData, + formRef, + specVariables, +}: JobInputsAndOptionsFormProps) => { + return ( + + <> + + + Inputs + + + {!!inputs && ( + + )} + + + {!!options && ( + <> + + Options + +
setOptionsFormData(event.formData)} + onError={() => {}} + > + {/* Remove the default submit button */} +
+ + + )} + + + ); +}; diff --git a/src/components/runCards/JobCard/JobModal.tsx b/src/components/runCards/JobCard/JobModal.tsx index 7b13ff0ee..58dc75e33 100644 --- a/src/components/runCards/JobCard/JobModal.tsx +++ b/src/components/runCards/JobCard/JobModal.tsx @@ -9,11 +9,8 @@ import { import { getGetInstancesQueryKey, useCreateInstance } from "@squonk/data-manager-client/instance"; import { useGetJob } from "@squonk/data-manager-client/job"; -import { Box, Grid2 as Grid, TextField, Typography } from "@mui/material"; -import { type FormProps } from "@rjsf/core"; -import validator from "@rjsf/validator-ajv8"; +import { Box, TextField } from "@mui/material"; import { useQueryClient } from "@tanstack/react-query"; -import dynamic from "next/dynamic"; import { useEnqueueError } from "../../../hooks/useEnqueueStackError"; import { CenterLoader } from "../../CenterLoader"; @@ -21,17 +18,8 @@ import { ModalWrapper } from "../../modals/ModalWrapper"; import { DebugCheckbox, type DebugValue } from "../DebugCheckbox"; import { TEST_JOB_ID } from "../TestJob/jobId"; import { type CommonModalProps } from "../types"; -import { type InputSchema, type JobInputFieldsProps, validateInputData } from "./JobInputFields"; - -const JobInputFields = dynamic( - () => import("./JobInputFields").then((mod) => mod.JobInputFields), - { loading: () => }, -); - -// this dynamic import is necessary to avoid hydration issues with the form -const Form = dynamic(() => import("@rjsf/mui").then((mod) => mod.Form), { - loading: () => , -}); +import { type InputSchema, validateInputData } from "./JobInputFields"; +import { JobInputsAndOptionsForm } from "./JobInputsAndOptionsForm"; export type InputData = Record; @@ -167,16 +155,14 @@ export const JobModal = ({ } }; - const validateForm = formRef.current?.validateForm; + const variables = job?.variables; return ( setDebug(debug)} /> - {!!job.variables && ( - - {!!job.variables.inputs && ( - <> - - - Inputs - - - - - )} - - - {!!job.variables.options && ( - <> - - Options - -
setOptionsFormData(event.formData)} - onError={() => {}} - > - {/* Remove the default submit button */} -
- - - )} - - - )} + ) : ( diff --git a/src/components/runCards/WorkflowCard/RunWorkflowButton.tsx b/src/components/runCards/WorkflowCard/RunWorkflowButton.tsx new file mode 100644 index 000000000..a17cd6624 --- /dev/null +++ b/src/components/runCards/WorkflowCard/RunWorkflowButton.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; + +import { type WorkflowSummary } from "@squonk/data-manager-client"; + +import { Button, CircularProgress, Tooltip } from "@mui/material"; +import dynamic from "next/dynamic"; + +export interface RunWorkflowButtonProps { + workflowId: WorkflowSummary["id"]; + projectId: string; + onLaunch?: (instanceId: string) => void; + disabled?: boolean; +} + +const WorkflowModal = dynamic( + () => import("./WorkflowModal").then((mod) => mod.WorkflowModal), + { loading: () => }, +); + +/** + * MuiButton that controls a modal to run a workflow instance + */ +export const RunWorkflowButton = ({ + workflowId, + projectId, + onLaunch, + disabled, +}: RunWorkflowButtonProps) => { + const [open, setOpen] = useState(false); + const [hasOpened, setHasOpened] = useState(false); + + return ( + <> + + + + + + {!!hasOpened && ( + setOpen(false)} + onLaunch={onLaunch} + /> + )} + + ); +}; diff --git a/src/components/runCards/WorkflowCard/WorkflowCard.tsx b/src/components/runCards/WorkflowCard/WorkflowCard.tsx new file mode 100644 index 000000000..1e8dca7ea --- /dev/null +++ b/src/components/runCards/WorkflowCard/WorkflowCard.tsx @@ -0,0 +1,60 @@ +import { type WorkflowSummary } from "@squonk/data-manager-client"; + +import { Chip, Typography } from "@mui/material"; + +import { useCurrentProjectId } from "../../../hooks/projectHooks"; +import { BaseCard } from "../../BaseCard"; +import { RunWorkflowButton } from "./RunWorkflowButton"; + +export interface WorkflowCardProps { + workflow: WorkflowSummary; +} + +/** + * MuiCard that displays a summary of a workflow definition. + */ +export const WorkflowCard = ({ workflow }: WorkflowCardProps) => { + const { projectId } = useCurrentProjectId(); + return ( + ( + + )} + header={{ + color: "#f1c40f", + subtitle: workflow.name, + avatar: workflow.name[0], + title: workflow.workflow_name ?? workflow.name, + }} + key={workflow.id} + > + + {workflow.workflow_description ?? No description} + + + Version: {workflow.version ?? n/a} + + + Scope: {workflow.scope} + {workflow.scope_id ? ` (${workflow.scope_id})` : null} + + + Validated:{" "} + {workflow.validated ? ( + + ) : ( + + )} + + {!!workflow.source_id && ( + + Source Workflow ID: {workflow.source_id} + + )} + + ); +}; diff --git a/src/components/runCards/WorkflowCard/WorkflowModal.tsx b/src/components/runCards/WorkflowCard/WorkflowModal.tsx new file mode 100644 index 000000000..fb7f8a8f7 --- /dev/null +++ b/src/components/runCards/WorkflowCard/WorkflowModal.tsx @@ -0,0 +1,91 @@ +import { useRef, useState } from "react"; + +import { useGetWorkflow, useRunWorkflow } from "@squonk/data-manager-client/workflow"; + +import { Box, TextField } from "@mui/material"; + +import { ModalWrapper } from "../../modals/ModalWrapper"; +import { DebugCheckbox, type DebugValue } from "../DebugCheckbox"; +import { JobInputsAndOptionsForm } from "../JobCard/JobInputsAndOptionsForm"; +import { type InputData } from "../JobCard/JobModal"; + +export interface WorkflowModalProps { + workflowId: string; + projectId: string; + open: boolean; + onClose: () => void; + onLaunch?: (instanceId: string) => void; +} + +/** + * Modal for running a workflow instance. Fetches workflow details and displays the correct form. + */ +export const WorkflowModal = ({ workflowId, projectId, open, onClose }: WorkflowModalProps) => { + const { data: workflow } = useGetWorkflow(workflowId); + const specVariables = workflow?.variables; + + const [nameState, setNameState] = useState(""); + const [debug, setDebug] = useState("0"); + + const [inputsData, setInputsData] = useState({}); + const [optionsFormData, setOptionsFormData] = useState(specVariables); + + const formRef = useRef(null); + + const { mutateAsync: runWorkflow } = useRunWorkflow(); + + console.log(specVariables); + console.log(inputsData); + + const handleSubmit = async () => { + workflow?.id && + (await runWorkflow({ + workflowId: workflow.id, + data: { + as_name: nameState, + debug, + project_id: projectId, + variables: JSON.stringify({ ...optionsFormData, ...inputsData }), + }, + })); + onClose(); + }; + + return ( + void handleSubmit()} + > + + setNameState(event.target.value)} + /> + + + setDebug(debug)} /> + {!!workflow && ( + + )} + + ); +}; diff --git a/src/pages/run.tsx b/src/pages/run.tsx index a1a02dbf6..864ddd073 100644 --- a/src/pages/run.tsx +++ b/src/pages/run.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useGetApplications } from "@squonk/data-manager-client/application"; import { useGetJobs } from "@squonk/data-manager-client/job"; +import { useGetWorkflows } from "@squonk/data-manager-client/workflow"; import { withPageAuthRequired as withPageAuthRequiredCSR } from "@auth0/nextjs-auth0/client"; import { Alert, Container, Grid2 as Grid, MenuItem, TextField } from "@mui/material"; @@ -15,6 +16,7 @@ import { CenterLoader } from "../components/CenterLoader"; import { ApplicationCard } from "../components/runCards/ApplicationCard"; import { JobCard } from "../components/runCards/JobCard"; import { TEST_JOB_ID } from "../components/runCards/TestJob/jobId"; +import { WorkflowCard } from "../components/runCards/WorkflowCard/WorkflowCard"; import { SearchTextField } from "../components/SearchTextField"; import { AS_ROLES, DM_ROLES } from "../constants/auth"; import { useCurrentProject, useIsUserAdminOrEditorOfCurrentProject } from "../hooks/projectHooks"; @@ -26,11 +28,18 @@ const TestJobCard = dynamic( () => import("../components/runCards/TestJob/TestJobCard").then((mod) => mod.TestJobCard), { loading: () => }, ); + +type FilterOptions = "application" | "job" | "workflow"; + /** * Page allowing the user to run jobs and applications */ const Run = () => { - const [executionTypes, setExecutionTypes] = useState(["application", "job"]); + const [executionTypes, setExecutionTypes] = useState([ + "workflow", + "application", + "job", + ]); const [searchValue, setSearchValue] = useState(""); const [debouncedSearchValue, setDebouncedSearchValue] = useState(""); const inputRef = useKeyboardFocus(); @@ -96,9 +105,23 @@ const Run = () => { setSearchValue(event.target.value); }, []); - const handleExecutionTypesChange = useCallback((event: any) => { - setExecutionTypes(event.target.value as string[]); - }, []); + const { + data: workflows, + isError: isWorkflowsError, + error: workflowsError, + } = useGetWorkflows({ query: { select: (data) => data.workflows } }); + + // Memoize filtered and grouped jobs + const filteredAndGroupedWorkflows = useMemo(() => { + if (!workflows) { + return {}; + } + const filteredWorkflows = workflows.filter(({ workflow_name, workflow_description }) => + search([workflow_name, workflow_description], debouncedSearchValue), + ); + + return groupBy(filteredWorkflows, (workflow) => workflow.name); + }, [workflows, debouncedSearchValue]); const cards = useMemo(() => { const applicationCards = filteredApplications.map((app) => ( @@ -113,22 +136,32 @@ const Run = () => { )); + const workflowCards = Object.entries(filteredAndGroupedWorkflows).map( + ([name, workflowGroup]) => ( + + + + ), + ); + process.env.NODE_ENV === "development" && jobCards.push(); - const showApplications = executionTypes.includes("application"); - const showJobs = executionTypes.includes("job"); + // Create a map of execution types to their corresponding card arrays + const cardsByType = { application: applicationCards, job: jobCards, workflow: workflowCards }; - if (showApplications && showJobs) { - return [...applicationCards, ...jobCards]; - } else if (showApplications) { - return applicationCards; - } - return jobCards; + // Filter and flatten the card arrays based on selected execution types + const visibleCards = executionTypes + .map((type) => cardsByType[type]) + .filter(Boolean) + .flat(); + + return visibleCards.length > 0 ? visibleCards : []; }, [ filteredApplications, filteredAndGroupedJobs, - currentProject?.project_id, + filteredAndGroupedWorkflows, executionTypes, + currentProject?.project_id, hasPermissionToRun, ]); @@ -148,9 +181,17 @@ const Run = () => { fullWidth select label="Filter" - slotProps={{ select: { multiple: true, onChange: handleExecutionTypesChange } }} + slotProps={{ + select: { + multiple: true, + onChange: (event) => { + setExecutionTypes(event.target.value as FilterOptions[]); + }, + }, + }} value={executionTypes} > + Workflows Applications Jobs @@ -183,7 +224,13 @@ const Run = () => { )} - + {!!isWorkflowsError && ( + + + Workflows failed to load ({workflowsError.response?.status}) + + + )} {/* Warnings */} {!currentProject && ( diff --git a/src/utils/platform.ts b/src/utils/platform.ts index fb20c6263..46f63ac9c 100644 --- a/src/utils/platform.ts +++ b/src/utils/platform.ts @@ -14,8 +14,10 @@ export const isMac = () => { } } + const pattern = /Mac|iPhone|iPad|iPod/u; + // Fallback to userAgent string parsing - return (/Mac|iPhone|iPad|iPod/u).test(navigator.userAgent); + return pattern.test(navigator.userAgent); }; /**