diff --git a/docs/_static/workflow_batch_run/batch_button.png b/docs/_static/workflow_batch_run/batch_button.png
new file mode 100644
index 0000000000..f4e5ad42d3
Binary files /dev/null and b/docs/_static/workflow_batch_run/batch_button.png differ
diff --git a/docs/_static/workflow_batch_run/batch_record.png b/docs/_static/workflow_batch_run/batch_record.png
new file mode 100644
index 0000000000..121123f0f3
Binary files /dev/null and b/docs/_static/workflow_batch_run/batch_record.png differ
diff --git a/docs/_static/workflow_batch_run/batch_workflow_1.png b/docs/_static/workflow_batch_run/batch_workflow_1.png
new file mode 100644
index 0000000000..77360dc04c
Binary files /dev/null and b/docs/_static/workflow_batch_run/batch_workflow_1.png differ
diff --git a/docs/_static/workflow_batch_run/batch_workflow_2.png b/docs/_static/workflow_batch_run/batch_workflow_2.png
new file mode 100644
index 0000000000..ab0ca5ee19
Binary files /dev/null and b/docs/_static/workflow_batch_run/batch_workflow_2.png differ
diff --git a/docs/_static/workflow_batch_run/batch_workflow_3.png b/docs/_static/workflow_batch_run/batch_workflow_3.png
new file mode 100644
index 0000000000..4a1bd56f1c
Binary files /dev/null and b/docs/_static/workflow_batch_run/batch_workflow_3.png differ
diff --git a/docs/_static/workflow_batch_run/batch_workspaces.png b/docs/_static/workflow_batch_run/batch_workspaces.png
new file mode 100644
index 0000000000..e8c31f944e
Binary files /dev/null and b/docs/_static/workflow_batch_run/batch_workspaces.png differ
diff --git a/docs/other/index.rst b/docs/other/index.rst
index 58b87894ec..6f771287cb 100644
--- a/docs/other/index.rst
+++ b/docs/other/index.rst
@@ -8,4 +8,5 @@ Other
host_for_multiuser/index
cli_execution
+ workflow_batch_run
debugging
diff --git a/docs/other/workflow_batch_run.md b/docs/other/workflow_batch_run.md
new file mode 100644
index 0000000000..da47aea0f9
--- /dev/null
+++ b/docs/other/workflow_batch_run.md
@@ -0,0 +1,124 @@
+(workflow-batch-run)=
+Workflow Batch Run
+=================
+
+By using the Workflow Batch Run function, you can batch-execute workflows set of input data.
+
+\*This feature is limited to ["Multi User Mode"](host_for_multiuser/index.rst).
+
+```{contents}
+:depth: 3
+```
+
+## Usage Procedure
+
+### Creating a Workspace (Batch Type)
+
+
+
+
+
+- Specify the type of workspace as Batch when creating a Workspace.
+
+### Batch Workspace specifics
+
+Batch workspaces have the following UI changes:
+
+
+
+
+
+- (1) Display Type: Batch
+- (2) From the Batch Data section in the left side menu, you can select each Batch Input Node.
+- (3) The "Run" button will display as the "Batch Run" button.
+
+### Selecting Data
+
+
+
+
+
+- (1) Batch Input Nodes
+ - Place a Batch Input Node from "Batch Data" in the left side menu.
+ - As an example, batch_image and batch_behavior are placed here.
+ - Each Batch Input Node corresponds to a standard Input Node.
+ - `batch_{image, csv, fluo, behavior, microscope, hdf5, matlab}`
+- (2) File Select dialog
+ - Each Batch Input Node can select multiple files.
+
+```{eval-rst}
+.. note::
+ * **Data set handling**
+ * Data are processed in the order they are selected.
+ * All input data are matched based on their order, whereby image and behaviour in position 1 will be processed together, followed by all of the data in position 2, and so on.
+ * Data in each node can be easily reordered by dragging up or down in the data list of that node.
+ * **Validation rule**
+ * Verify that the number of data in each Batch Input Data Node matches.
+ * Only the number of data items is checked. File names and data contents are not checked.
+```
+
+### Setting dialogs
+
+
+
+
+
+- (1) CSV Setting dialog
+ - For CSV-based Nodes (`batch_{cvs, fluo, behavior}`), various settings can be made in the CSV Setting dialog.
+ - In the CSV Setting dialog, **the contents of the first file** is previewed, and **same settings are applied to all files**.
+- (2) Select Structure dialog
+ - For Structured data type Nodes (`batch_{hdf5, matlab}`), the path of Input Data can be selected in the Select Structure dialog.
+ - In the Select Structure dialog, **the contents of the first file (Structure)** is displayed, and **same settings are applied to all files**.
+- (3) Node Param Setting
+ - The parameter settings for each node apply **the same settings to all workflows**.
+
+```{eval-rst}
+.. note::
+ * All workflows will complete with the **same parameter configuration**.
+```
+
+### Batch Run
+
+
+
+
+
+#### Batch Run Button execution behavior
+
+- Validation for Input Data
+ - Check the number of items for each Batch Input Data
+ - \***The only check condition is the number of items.** File names and data contents are not checked.
+- Execute Batch Run
+ - No reservations, immediate execution only
+ - As soon as the process is successfully started, the Workflow will immediately have a completed status.
+ - The following records are generated
+ - Batch Workflow Template
+ - Generate one Template Workflow as the basis of Running Workflow .
+ - Batch Workflow
+ - Automatically create Workflows for the number of Data items based on Batch Workflow Template and start parallel execution.
+
+```{eval-rst}
+.. note::
+ * About workflow behavior after RUN BATCH
+ * When submitting a RUN BATCH command, "the template workflow" then submits a new workflow (job) for each set of data.
+ * The template workflow will be shown as successful if this job submission itself is successful. However, this does **not** mean any of the jobs themselves have been completed successfully.
+ * The progress of each job can be seen in the RECORDS tab. Note that individual workflows run independently and some may still succeed or fail depending on the data contents and parameters selected.
+```
+
+## Record Screen
+
+
+
+
+
+- (1) Record of Batch Workflow Template
+ - Created when Batch Run Button is executed.
+ - This Workflow does not run snakemake directly.
+- (2) Record of Batch Workflow
+ - Records of each workflow actually executed based on Batch Workflow Template
+ - Same content as regular Workflow Record
+ - "Name" is automatically generated based on the Template Name
+- (3) Other menu of Batch Workflow Template
+ - Reproduce ... Batch Workflow Template can be reproduced
+ - Download workflow yaml ... You can also download and import Batch Workflow Template workflow.yaml
+ - Download snakemake yaml ... It is not possible to download snakemake.yaml of Batch Workflow Template (because it is not subject to snakemake execution)
diff --git a/frontend/src/api/run/Run.ts b/frontend/src/api/run/Run.ts
index 0217c3dd28..6c9920514f 100644
--- a/frontend/src/api/run/Run.ts
+++ b/frontend/src/api/run/Run.ts
@@ -81,6 +81,17 @@ export async function runByUidApi(
return response.data
}
+export async function batchRunApi(
+ workspaceId: number,
+ data: RunPostData,
+): Promise {
+ const response = await axios.post(
+ `${BASE_URL}/run/util/batch_run/${workspaceId}`,
+ data,
+ )
+ return response.data
+}
+
export type RunResultDTO = {
[nodeId: string]: {
status: string
diff --git a/frontend/src/api/workspace/index.ts b/frontend/src/api/workspace/index.ts
index 8cbfb1da36..d84f56a97d 100644
--- a/frontend/src/api/workspace/index.ts
+++ b/frontend/src/api/workspace/index.ts
@@ -1,5 +1,6 @@
import { stringify } from "qs"
+import { WORKSPACE_TYPE } from "const/Workspace"
import {
ItemsWorkspace,
WorkspaceDataDTO,
@@ -7,7 +8,11 @@ import {
} from "store/slice/Workspace/WorkspaceType"
import axios from "utils/axios"
-export type WorkspacePostDataDTO = { name: string; id?: number }
+export type WorkspacePostDataDTO = {
+ id?: number
+ name: string
+ type: WORKSPACE_TYPE
+}
export const getWorkspaceApi = async (id: number): Promise => {
const response = await axios.get(`/workspace/${id}`)
diff --git a/frontend/src/components/Workspace/FlowChart/Buttons/CreateWorkflow.tsx b/frontend/src/components/Workspace/FlowChart/Buttons/CreateWorkflow.tsx
index ad6397ac2d..948a0c5193 100644
--- a/frontend/src/components/Workspace/FlowChart/Buttons/CreateWorkflow.tsx
+++ b/frontend/src/components/Workspace/FlowChart/Buttons/CreateWorkflow.tsx
@@ -5,15 +5,17 @@ import { AddToPhotos } from "@mui/icons-material"
import { IconButton, Tooltip } from "@mui/material"
import { ConfirmDialog } from "components/common/ConfirmDialog"
-import { WORKSPACE_TYPE } from "const/Workspace"
import { clearFlowElements } from "store/slice/FlowElement/FlowElementSlice"
import { selectPipelineIsStartedSuccess } from "store/slice/Pipeline/PipelineSelectors"
+import { RootState } from "store/store"
export const CreateWorkflowButton = memo(function CreateWorkflowButton() {
const [open, setOpen] = useState(false)
const dispatch = useDispatch()
const isPending = useSelector(selectPipelineIsStartedSuccess)
- const workspaceType = WORKSPACE_TYPE.DEFAULT // Currently a fixed value
+ const workspaceType = useSelector(
+ (state: RootState) => state.workspace.currentWorkspace.type,
+ )
const openDialog = () => {
setOpen(true)
diff --git a/frontend/src/components/Workspace/FlowChart/Buttons/RunButtons.tsx b/frontend/src/components/Workspace/FlowChart/Buttons/RunButtons.tsx
index 9340f8990e..9b3cb77105 100644
--- a/frontend/src/components/Workspace/FlowChart/Buttons/RunButtons.tsx
+++ b/frontend/src/components/Workspace/FlowChart/Buttons/RunButtons.tsx
@@ -3,7 +3,7 @@ import { useSelector, useDispatch } from "react-redux"
import { useSnackbar } from "notistack"
-import { PlayArrow } from "@mui/icons-material"
+import { FastForward, PlayArrow } from "@mui/icons-material"
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"
import BlockIcon from "@mui/icons-material/Block"
import ReplayIcon from "@mui/icons-material/Replay"
@@ -22,6 +22,10 @@ import Paper from "@mui/material/Paper"
import Popper from "@mui/material/Popper"
import TextField from "@mui/material/TextField"
+import { WORKSPACE_TYPE } from "const/Workspace"
+import { selectFlowNodes } from "store/slice/FlowElement/FlowElementSelectors"
+import { selectInputNode } from "store/slice/InputNode/InputNodeSelectors"
+import { isBatchAnyInputNode } from "store/slice/InputNode/InputNodeUtils"
import { UseRunPipelineReturnType } from "store/slice/Pipeline/PipelineHook"
import {
selectPipelineIsStartedSuccess,
@@ -33,6 +37,7 @@ import {
RUN_BTN_OPTIONS,
RUN_BTN_TYPE,
} from "store/slice/Pipeline/PipelineType"
+import { selectCurrentWorkspaceType } from "store/slice/Workspace/WorkspaceSelector"
export const RunButtons = memo(function RunButtons(
props: UseRunPipelineReturnType,
@@ -44,6 +49,7 @@ export const RunButtons = memo(function RunButtons(
algorithmNodeNotExist,
handleCancelPipeline,
handleRunPipeline,
+ handleBatchRunPipeline,
handleRunPipelineByUid,
} = props
@@ -51,10 +57,14 @@ export const RunButtons = memo(function RunButtons(
const runBtnOption = useSelector(selectPipelineRunBtn)
const isStartedSuccess = useSelector(selectPipelineIsStartedSuccess)
+ const workspaceType = useSelector(selectCurrentWorkspaceType)
+ const flowNodes = useSelector(selectFlowNodes)
+ const inputNodes = useSelector(selectInputNode)
const sendingRunRequest = useRef(false)
const [dialogOpen, setDialogOpen] = useState(false)
+ const [batchDialogOpen, setBatchDialogOpen] = useState(false)
const { enqueueSnackbar } = useSnackbar()
const handleClick = () => {
let errorMessage: string | null = null
@@ -93,6 +103,77 @@ export const RunButtons = memo(function RunButtons(
const onClickCancel = () => {
handleCancelPipeline()
}
+ /**
+ * Validates batch input nodes in the flowchart
+ * @returns Error message if validation fails, null otherwise
+ */
+ const validateBatchInputNodes = (): string | null => {
+ // Find all batch input nodes in the flowchart
+ const batchInputNodes = flowNodes
+ .filter((node) => {
+ const inputNode = inputNodes[node.id]
+ return inputNode && isBatchAnyInputNode(inputNode)
+ })
+ .map((node) => ({
+ nodeId: node.id,
+ inputNode: inputNodes[node.id],
+ }))
+
+ // Check if there are any batch nodes
+ if (batchInputNodes.length === 0) {
+ return "There are no batch input nodes."
+ }
+
+ // Check file count consistency across all batch nodes
+ const fileCounts = batchInputNodes.map((node) => {
+ const filePath = node.inputNode.selectedFilePath
+ if (Array.isArray(filePath)) {
+ return filePath.length
+ }
+ return filePath ? 1 : 0
+ })
+
+ const minCount = Math.min(...fileCounts)
+ const maxCount = Math.max(...fileCounts)
+
+ if (minCount !== maxCount) {
+ return `Number of batch input files does not match. [${minCount} - ${maxCount}]`
+ }
+
+ return null
+ }
+
+ const onClickBatchRun = () => {
+ let errorMessage: string | null = null
+ if (algorithmNodeNotExist) {
+ errorMessage = "please add some algorithm nodes to the flowchart"
+ }
+ if (filePathIsUndefined) {
+ errorMessage = "please select input file"
+ }
+
+ // Validate batch nodes
+ if (errorMessage == null) {
+ errorMessage = validateBatchInputNodes()
+ }
+
+ if (errorMessage != null) {
+ enqueueSnackbar(errorMessage, {
+ variant: "error",
+ })
+ } else {
+ setBatchDialogOpen(true)
+ }
+ }
+ const onClickDialogBatchRun = (name: string) => {
+ if (sendingRunRequest.current) return
+ sendingRunRequest.current = true
+ handleBatchRunPipeline(name)
+ setTimeout(() => {
+ sendingRunRequest.current = false
+ }, 3000)
+ setBatchDialogOpen(false)
+ }
const [menuOpen, setMenuOpen] = useState(false)
const anchorRef = useRef(null)
@@ -116,68 +197,79 @@ export const RunButtons = memo(function RunButtons(
setMenuOpen(false)
}
const uidExists = uid != null
+ const isBatchWorkspace = workspaceType === WORKSPACE_TYPE.BATCH
+
return (
<>
-
-
- ) : (
-
- )
- }
- >
- {RUN_BTN_LABELS[runBtnOption]}
-
-
-
-
-
-
- {({ TransitionProps, placement }) => (
-
+
+
+ ) : (
+
+ )
+ }
+ >
+ {RUN_BTN_LABELS[runBtnOption]}
+
+
+
+
+
+
-
-
-
- {Object.values(RUN_BTN_OPTIONS).map((option) => (
- handleMenuItemClick(event, option)}
- >
- {RUN_BTN_LABELS[option]}
-
- ))}
-
-
-
-
- )}
-
+ {({ TransitionProps, placement }) => (
+
+
+
+
+ {Object.values(RUN_BTN_OPTIONS).map((option) => (
+
+ handleMenuItemClick(event, option)
+ }
+ >
+ {RUN_BTN_LABELS[option]}
+
+ ))}
+
+
+
+
+ )}
+
+ >
+ )}
+
+ {/* Show Cancel button for all workspace types when workflow is running */}
{isStartedSuccess && (
@@ -185,11 +277,29 @@ export const RunButtons = memo(function RunButtons(
)}
+
+ {/* Show Batch Run button only for batch workspaces */}
+ {isBatchWorkspace && (
+ }
+ >
+ Batch Run
+
+ )}
setDialogOpen(false)}
/>
+ setBatchDialogOpen(false)}
+ />
>
)
})
@@ -198,14 +308,18 @@ interface RunDialogProps {
open: boolean
handleRun: (name: string) => void
handleClose: () => void
+ title?: string
+ defaultName?: string
}
const RunDialog = memo(function RunDialog({
open,
handleClose,
handleRun,
+ title = "Name and run workflow",
+ defaultName = "New flow",
}: RunDialogProps) {
- const [name, setName] = useState("New flow")
+ const [name, setName] = useState(defaultName)
const [error, setError] = useState(null)
const onClickRun = () => {
if (name !== "") {
@@ -222,7 +336,7 @@ const RunDialog = memo(function RunDialog({
}
return (
- Name and run workflow
+ {title}
)
})
+
+interface BatchRunDialogProps {
+ open: boolean
+ handleRun: (name: string) => void
+ handleClose: () => void
+}
+
+const BatchRunDialog = memo(function BatchRunDialog({
+ open,
+ handleClose,
+ handleRun,
+}: BatchRunDialogProps) {
+ return (
+
+ )
+})
diff --git a/frontend/src/components/Workspace/FlowChart/Dialog/CsvParamSettingDialog.tsx b/frontend/src/components/Workspace/FlowChart/Dialog/CsvParamSettingDialog.tsx
index a089cefa09..da7e2e078b 100644
--- a/frontend/src/components/Workspace/FlowChart/Dialog/CsvParamSettingDialog.tsx
+++ b/frontend/src/components/Workspace/FlowChart/Dialog/CsvParamSettingDialog.tsx
@@ -15,6 +15,7 @@ import {
LinearProgress,
Typography,
IconButton,
+ Chip,
} from "@mui/material"
import { PresentationalCsvPlot } from "components/Workspace/Visualize/Plot/CsvPlot"
@@ -82,7 +83,15 @@ export const CsvParamSettingDialog = memo(function CsvParamSettingDialog({
- Csv Setting
+
+ Csv Setting
+
+
void
config: FileNodeConfig
+ filePath?: string
} & NodeIdProps
export const StructureItemSelectDialog = memo(
@@ -37,6 +45,7 @@ export const StructureItemSelectDialog = memo(
open,
setOpen,
config,
+ filePath,
}: StructureItemSelectProps) {
const dispatch = useDispatch()
const [fileSelect, setFileSelect] = useState("")
@@ -61,7 +70,17 @@ export const StructureItemSelectDialog = memo(
: "No structure is selected."}
setOpen(false)} fullWidth>
- Select Structure
+
+ Select Structure
+ {filePath && (
+
+ )}
+
)}
(state: RootState) => string | string[] | undefined
+ selectStructurePath: (
+ nodeId: string,
+ ) => (state: RootState) => string | undefined
+ setStructurePath: (params: {
+ nodeId: string
+ path: string
+ }) => Action
+ getTree: (params: {
+ path: string
+ workspaceId: number
+ }) => ThunkAction>
+ selectTree: () => (state: RootState) => TreeNodeType[] | undefined
+ selectIsLoading: () => (state: RootState) => boolean
+}
+
+function createBatchConfigAdapter(
+ config: BatchFileNodeConfig,
+ _filePath: string[] | undefined,
+): FileNodeConfig {
+ return {
+ ...config,
+ selectFilePath: (nodeId: string) => (state: RootState) => {
+ const paths = config.selectFilePath(nodeId)(state)
+ if (Array.isArray(paths) && paths.length > 0) {
+ return paths[0]
+ }
+ return paths
+ },
+ }
+}
+
+export function createBatchStructuredFileNode(config: BatchFileNodeConfig) {
+ const BatchFileNode = memo(function BatchFileNode(element: NodeProps) {
+ const defined = useSelector(selectInputNodeDefined(element.id))
+ if (defined) {
+ return
+ } else {
+ return null
+ }
+ })
+ BatchFileNode.displayName = `Batch${config.fileType}FileNode`
+ return BatchFileNode
+}
+
+const BatchFileNodeImple = memo(function BatchFileNodeImple({
+ id: nodeId,
+ selected,
+ config,
+}: NodeProps & { config: BatchFileNodeConfig }) {
+ const dispatch = useDispatch()
+ const filePath = useSelector(config.selectFilePath(nodeId), (a, b) =>
+ a != null && b != null && Array.isArray(a) && Array.isArray(b)
+ ? arrayEqualityFn(a, b)
+ : a === b,
+ )
+
+ const [open, setOpen] = useState(false)
+ const onChangeFilePath = (path: string[]) => {
+ dispatch(setInputNodeFilePath({ nodeId, filePath: path }))
+ }
+
+ const onClickDeleteIcon = () => {
+ dispatch(deleteFlowNodeById(nodeId))
+ }
+
+ return (
+
+
+ ×
+
+ {
+ if (Array.isArray(path)) {
+ onChangeFilePath(path)
+ }
+ }}
+ setOpen={setOpen}
+ fileType={config.fileType}
+ filePath={
+ Array.isArray(filePath) ? filePath : filePath ? [filePath] : []
+ }
+ />
+ {filePath !== undefined &&
+ Array.isArray(filePath) &&
+ filePath.length > 0 && (
+
+ )}
+
+
+ )
+})
diff --git a/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchBehaviorFileNode.tsx b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchBehaviorFileNode.tsx
new file mode 100644
index 0000000000..324abf6fd4
--- /dev/null
+++ b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchBehaviorFileNode.tsx
@@ -0,0 +1,86 @@
+import { memo } from "react"
+import { useDispatch, useSelector } from "react-redux"
+import { Handle, Position, NodeProps } from "reactflow"
+
+import { FileSelect } from "components/Workspace/FlowChart/FlowChartNode/FileSelect"
+import { toHandleId } from "components/Workspace/FlowChart/FlowChartNode/FlowChartUtils"
+import { useHandleColor } from "components/Workspace/FlowChart/FlowChartNode/HandleColorHook"
+import { NodeContainer } from "components/Workspace/FlowChart/FlowChartNode/NodeContainer"
+import { HANDLE_STYLE } from "const/flowchart"
+import { deleteFlowNodeById } from "store/slice/FlowElement/FlowElementSlice"
+import { setInputNodeFilePath } from "store/slice/InputNode/InputNodeActions"
+import {
+ selectInputNodeDefined,
+ selectBehaviorLikeInputNodeSelectedFilePath,
+} from "store/slice/InputNode/InputNodeSelectors"
+import { FILE_TYPE_SET } from "store/slice/InputNode/InputNodeType"
+import { arrayEqualityFn } from "utils/EqualityUtils"
+
+export const BatchBehaviorFileNode = memo(function BatchBehaviorFileNode(
+ element: NodeProps,
+) {
+ const defined = useSelector(selectInputNodeDefined(element.id))
+ if (defined) {
+ return
+ } else {
+ return null
+ }
+})
+
+const BatchBehaviorFileNodeImple = memo(function BatchBehaviorFileNodeImple({
+ id: nodeId,
+ selected: elementSelected,
+}: NodeProps) {
+ const dispatch = useDispatch()
+ const filePath = useSelector(
+ selectBehaviorLikeInputNodeSelectedFilePath(nodeId),
+ (a, b) =>
+ a != null && b != null && Array.isArray(a) && Array.isArray(b)
+ ? arrayEqualityFn(a, b)
+ : a === b,
+ )
+ const onChangeFilePath = (path: string[]) => {
+ dispatch(setInputNodeFilePath({ nodeId, filePath: path }))
+ }
+
+ const returnType = "BehaviorData"
+ const behaviorColor = useHandleColor(returnType)
+
+ const onClickDeleteIcon = () => {
+ dispatch(deleteFlowNodeById(nodeId))
+ }
+
+ return (
+
+
+ ×
+
+ {
+ if (Array.isArray(path)) {
+ onChangeFilePath(path)
+ }
+ }}
+ fileType={FILE_TYPE_SET.BATCH_BEHAVIOR}
+ filePath={
+ Array.isArray(filePath) ? filePath : filePath ? [filePath] : []
+ }
+ />
+
+
+ )
+})
diff --git a/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchCsvFileNode.tsx b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchCsvFileNode.tsx
new file mode 100644
index 0000000000..c399d88155
--- /dev/null
+++ b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchCsvFileNode.tsx
@@ -0,0 +1,86 @@
+import { memo } from "react"
+import { useDispatch, useSelector } from "react-redux"
+import { Handle, Position, NodeProps } from "reactflow"
+
+import { FileSelect } from "components/Workspace/FlowChart/FlowChartNode/FileSelect"
+import { toHandleId } from "components/Workspace/FlowChart/FlowChartNode/FlowChartUtils"
+import { useHandleColor } from "components/Workspace/FlowChart/FlowChartNode/HandleColorHook"
+import { NodeContainer } from "components/Workspace/FlowChart/FlowChartNode/NodeContainer"
+import { HANDLE_STYLE } from "const/flowchart"
+import { deleteFlowNodeById } from "store/slice/FlowElement/FlowElementSlice"
+import { setInputNodeFilePath } from "store/slice/InputNode/InputNodeActions"
+import {
+ selectInputNodeDefined,
+ selectCsvLikeInputNodeSelectedFilePath,
+} from "store/slice/InputNode/InputNodeSelectors"
+import { FILE_TYPE_SET } from "store/slice/InputNode/InputNodeType"
+import { arrayEqualityFn } from "utils/EqualityUtils"
+
+export const BatchCsvFileNode = memo(function BatchCsvFileNode(
+ element: NodeProps,
+) {
+ const defined = useSelector(selectInputNodeDefined(element.id))
+ if (defined) {
+ return
+ } else {
+ return null
+ }
+})
+
+const BatchCsvFileNodeImple = memo(function BatchCsvFileNodeImple({
+ id: nodeId,
+ selected: elementSelected,
+}: NodeProps) {
+ const dispatch = useDispatch()
+ const filePath = useSelector(
+ selectCsvLikeInputNodeSelectedFilePath(nodeId),
+ (a, b) => {
+ if (Array.isArray(a) && Array.isArray(b)) {
+ return arrayEqualityFn(a, b)
+ }
+ return a === b
+ },
+ )
+ const onChangeFilePath = (path: string[]) => {
+ dispatch(setInputNodeFilePath({ nodeId, filePath: path }))
+ }
+
+ const returnType = "CsvData"
+ const csvColor = useHandleColor(returnType)
+
+ const onClickDeleteIcon = () => {
+ dispatch(deleteFlowNodeById(nodeId))
+ }
+
+ return (
+
+
+ ×
+
+ {
+ if (Array.isArray(path)) {
+ onChangeFilePath(path)
+ }
+ }}
+ fileType={FILE_TYPE_SET.BATCH_CSV}
+ filePath={typeof filePath === "string" ? [filePath] : filePath || []}
+ />
+
+
+ )
+})
diff --git a/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchFluoFileNode.tsx b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchFluoFileNode.tsx
new file mode 100644
index 0000000000..1d4dc05a52
--- /dev/null
+++ b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchFluoFileNode.tsx
@@ -0,0 +1,86 @@
+import { memo } from "react"
+import { useDispatch, useSelector } from "react-redux"
+import { Handle, Position, NodeProps } from "reactflow"
+
+import { FileSelect } from "components/Workspace/FlowChart/FlowChartNode/FileSelect"
+import { toHandleId } from "components/Workspace/FlowChart/FlowChartNode/FlowChartUtils"
+import { useHandleColor } from "components/Workspace/FlowChart/FlowChartNode/HandleColorHook"
+import { NodeContainer } from "components/Workspace/FlowChart/FlowChartNode/NodeContainer"
+import { HANDLE_STYLE } from "const/flowchart"
+import { deleteFlowNodeById } from "store/slice/FlowElement/FlowElementSlice"
+import { setInputNodeFilePath } from "store/slice/InputNode/InputNodeActions"
+import {
+ selectInputNodeDefined,
+ selectFluoLikeInputNodeSelectedFilePath,
+} from "store/slice/InputNode/InputNodeSelectors"
+import { FILE_TYPE_SET } from "store/slice/InputNode/InputNodeType"
+import { arrayEqualityFn } from "utils/EqualityUtils"
+
+export const BatchFluoFileNode = memo(function BatchFluoFileNode(
+ element: NodeProps,
+) {
+ const defined = useSelector(selectInputNodeDefined(element.id))
+ if (defined) {
+ return
+ } else {
+ return null
+ }
+})
+
+const BatchFluoFileNodeImple = memo(function BatchFluoFileNodeImple({
+ id: nodeId,
+ selected: elementSelected,
+}: NodeProps) {
+ const dispatch = useDispatch()
+ const filePath = useSelector(
+ selectFluoLikeInputNodeSelectedFilePath(nodeId),
+ (a, b) =>
+ a != null && b != null && Array.isArray(a) && Array.isArray(b)
+ ? arrayEqualityFn(a, b)
+ : a === b,
+ )
+ const onChangeFilePath = (path: string[]) => {
+ dispatch(setInputNodeFilePath({ nodeId, filePath: path }))
+ }
+
+ const returnType = "FluoData"
+ const fluoColor = useHandleColor(returnType)
+
+ const onClickDeleteIcon = () => {
+ dispatch(deleteFlowNodeById(nodeId))
+ }
+
+ return (
+
+
+ ×
+
+ {
+ if (Array.isArray(path)) {
+ onChangeFilePath(path)
+ }
+ }}
+ fileType={FILE_TYPE_SET.BATCH_FLUO}
+ filePath={
+ Array.isArray(filePath) ? filePath : filePath ? [filePath] : []
+ }
+ />
+
+
+ )
+})
diff --git a/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchHDF5FileNode.tsx b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchHDF5FileNode.tsx
new file mode 100644
index 0000000000..dad1c7fbe4
--- /dev/null
+++ b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchHDF5FileNode.tsx
@@ -0,0 +1,30 @@
+import {
+ createBatchStructuredFileNode,
+ BatchFileNodeConfig,
+} from "components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchBaseStructuredFileNode"
+import { FILE_TYPE_SET } from "config/fileTypes.config"
+import { getHDF5Tree } from "store/slice/HDF5/HDF5Action"
+import {
+ selectHDF5IsLoading,
+ selectHDF5Nodes,
+} from "store/slice/HDF5/HDF5Selectors"
+import {
+ selectHdf5LikeInputNodeSelectedFilePath,
+ selectInputNodeHDF5Path,
+} from "store/slice/InputNode/InputNodeSelectors"
+import { setInputNodeHDF5Path } from "store/slice/InputNode/InputNodeSlice"
+
+const batchHdf5Config: BatchFileNodeConfig = {
+ fileType: FILE_TYPE_SET.BATCH_HDF5,
+ handleId: "hdf5",
+ handleType: "HDF5Data",
+ treeKeyPrefix: "hdf5tree",
+ selectFilePath: selectHdf5LikeInputNodeSelectedFilePath,
+ selectStructurePath: selectInputNodeHDF5Path,
+ setStructurePath: setInputNodeHDF5Path,
+ getTree: getHDF5Tree,
+ selectTree: selectHDF5Nodes,
+ selectIsLoading: selectHDF5IsLoading,
+}
+
+export const BatchHDF5FileNode = createBatchStructuredFileNode(batchHdf5Config)
diff --git a/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchImageFileNode.tsx b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchImageFileNode.tsx
new file mode 100644
index 0000000000..e89389a0c3
--- /dev/null
+++ b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchImageFileNode.tsx
@@ -0,0 +1,88 @@
+import { memo } from "react"
+import { useSelector, useDispatch } from "react-redux"
+import { Handle, Position, NodeProps } from "reactflow"
+
+import { FileSelect } from "components/Workspace/FlowChart/FlowChartNode/FileSelect"
+import {
+ toHandleId,
+ isValidConnection,
+} from "components/Workspace/FlowChart/FlowChartNode/FlowChartUtils"
+import { useHandleColor } from "components/Workspace/FlowChart/FlowChartNode/HandleColorHook"
+import { NodeContainer } from "components/Workspace/FlowChart/FlowChartNode/NodeContainer"
+import { HANDLE_STYLE } from "const/flowchart"
+import { deleteFlowNodeById } from "store/slice/FlowElement/FlowElementSlice"
+import { setInputNodeFilePath } from "store/slice/InputNode/InputNodeActions"
+import {
+ selectInputNodeDefined,
+ selectImageLikeInputNodeSelectedFilePath,
+} from "store/slice/InputNode/InputNodeSelectors"
+import { FILE_TYPE_SET } from "store/slice/InputNode/InputNodeType"
+import { arrayEqualityFn } from "utils/EqualityUtils"
+
+export const BatchImageFileNode = memo(function BatchImageFileNode(
+ element: NodeProps,
+) {
+ const defined = useSelector(selectInputNodeDefined(element.id))
+ if (defined) {
+ return
+ } else {
+ return null
+ }
+})
+
+const BatchImageFileNodeImple = memo(function BatchImageFileNodeImple({
+ id: nodeId,
+ selected: elementSelected,
+}: NodeProps) {
+ const dispatch = useDispatch()
+ const filePath = useSelector(
+ selectImageLikeInputNodeSelectedFilePath(nodeId),
+ (a, b) =>
+ a != null && b != null && Array.isArray(a) && Array.isArray(b)
+ ? arrayEqualityFn(a, b)
+ : a === b,
+ )
+ const onChangeFilePath = (path: string[]) => {
+ dispatch(setInputNodeFilePath({ nodeId, filePath: path }))
+ }
+
+ const returnType = "ImageData"
+ const imageColor = useHandleColor(returnType)
+
+ const onClickDeleteIcon = () => {
+ dispatch(deleteFlowNodeById(nodeId))
+ }
+
+ return (
+
+
+ ×
+
+ {
+ if (Array.isArray(path)) {
+ onChangeFilePath(path)
+ }
+ }}
+ fileType={FILE_TYPE_SET.BATCH_IMAGE}
+ filePath={typeof filePath === "string" ? [filePath] : filePath || []}
+ />
+
+
+ )
+})
diff --git a/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchMatlabFileNode.tsx b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchMatlabFileNode.tsx
new file mode 100644
index 0000000000..ed412aa746
--- /dev/null
+++ b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchMatlabFileNode.tsx
@@ -0,0 +1,31 @@
+import {
+ createBatchStructuredFileNode,
+ BatchFileNodeConfig,
+} from "components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchBaseStructuredFileNode"
+import { FILE_TYPE_SET } from "config/fileTypes.config"
+import {
+ selectInputNodeMatlabPath,
+ selectMatlabLikeInputNodeSelectedFilePath,
+} from "store/slice/InputNode/InputNodeSelectors"
+import { setInputNodeMatlabPath } from "store/slice/InputNode/InputNodeSlice"
+import { getMatlabTree } from "store/slice/Matlab/MatlabAction"
+import {
+ selectMatlabIsLoading,
+ selectMatlabNodes,
+} from "store/slice/Matlab/MatlabSelectors"
+
+const batchMatlabConfig: BatchFileNodeConfig = {
+ fileType: FILE_TYPE_SET.BATCH_MATLAB,
+ handleId: "matlab",
+ handleType: "MatlabData",
+ treeKeyPrefix: "matlabtree",
+ selectFilePath: selectMatlabLikeInputNodeSelectedFilePath,
+ selectStructurePath: selectInputNodeMatlabPath,
+ setStructurePath: setInputNodeMatlabPath,
+ getTree: getMatlabTree,
+ selectTree: selectMatlabNodes,
+ selectIsLoading: selectMatlabIsLoading,
+}
+
+export const BatchMatlabFileNode =
+ createBatchStructuredFileNode(batchMatlabConfig)
diff --git a/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchMicroscopeFileNode.tsx b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchMicroscopeFileNode.tsx
new file mode 100644
index 0000000000..ee0c8367b8
--- /dev/null
+++ b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchMicroscopeFileNode.tsx
@@ -0,0 +1,88 @@
+import { memo } from "react"
+import { useSelector, useDispatch } from "react-redux"
+import { Handle, Position, NodeProps } from "reactflow"
+
+import { FileSelect } from "components/Workspace/FlowChart/FlowChartNode/FileSelect"
+import { toHandleId } from "components/Workspace/FlowChart/FlowChartNode/FlowChartUtils"
+import { useHandleColor } from "components/Workspace/FlowChart/FlowChartNode/HandleColorHook"
+import { NodeContainer } from "components/Workspace/FlowChart/FlowChartNode/NodeContainer"
+import { HANDLE_STYLE } from "const/flowchart"
+import { deleteFlowNodeById } from "store/slice/FlowElement/FlowElementSlice"
+import { setInputNodeFilePath } from "store/slice/InputNode/InputNodeActions"
+import {
+ selectInputNodeDefined,
+ selectMicroscopeLikeInputNodeSelectedFilePath,
+} from "store/slice/InputNode/InputNodeSelectors"
+import { FILE_TYPE_SET } from "store/slice/InputNode/InputNodeType"
+import { arrayEqualityFn } from "utils/EqualityUtils"
+
+export const BatchMicroscopeFileNode = memo(function BatchMicroscopeFileNode(
+ element: NodeProps,
+) {
+ const defined = useSelector(selectInputNodeDefined(element.id))
+ if (defined) {
+ return
+ } else {
+ return null
+ }
+})
+
+const BatchMicroscopeFileNodeImple = memo(
+ function BatchMicroscopeFileNodeImple({
+ id: nodeId,
+ selected: elementSelected,
+ }: NodeProps) {
+ const dispatch = useDispatch()
+ const filePath = useSelector(
+ selectMicroscopeLikeInputNodeSelectedFilePath(nodeId),
+ (a, b) =>
+ a != null && b != null && Array.isArray(a) && Array.isArray(b)
+ ? arrayEqualityFn(a, b)
+ : a === b,
+ )
+ const onChangeFilePath = (path: string[]) => {
+ dispatch(setInputNodeFilePath({ nodeId, filePath: path }))
+ }
+
+ const returnType = "MicroscopeData"
+ const microscopeColor = useHandleColor(returnType)
+
+ const onClickDeleteIcon = () => {
+ dispatch(deleteFlowNodeById(nodeId))
+ }
+
+ return (
+
+
+ ×
+
+ {
+ if (Array.isArray(path)) {
+ onChangeFilePath(path)
+ }
+ }}
+ fileType={FILE_TYPE_SET.BATCH_MICROSCOPE}
+ filePath={
+ Array.isArray(filePath) ? filePath : filePath ? [filePath] : []
+ }
+ />
+
+
+ )
+ },
+)
diff --git a/frontend/src/components/Workspace/FlowChart/FlowChartNode/BehaviorFileNode.tsx b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BehaviorFileNode.tsx
index a6e691385b..73c5f6fc4b 100644
--- a/frontend/src/components/Workspace/FlowChart/FlowChartNode/BehaviorFileNode.tsx
+++ b/frontend/src/components/Workspace/FlowChart/FlowChartNode/BehaviorFileNode.tsx
@@ -10,7 +10,7 @@ import { HANDLE_STYLE } from "const/flowchart"
import { deleteFlowNodeById } from "store/slice/FlowElement/FlowElementSlice"
import { setInputNodeFilePath } from "store/slice/InputNode/InputNodeActions"
import {
- selectCsvInputNodeSelectedFilePath,
+ selectBehaviorLikeInputNodeSelectedFilePath,
selectInputNodeDefined,
} from "store/slice/InputNode/InputNodeSelectors"
import { FILE_TYPE_SET } from "store/slice/InputNode/InputNodeType"
@@ -31,7 +31,9 @@ const BehaviorFileNodeImple = memo(function BehaviorFileNodeImple({
selected,
}: NodeProps) {
const dispatch = useDispatch()
- const filePath = useSelector(selectCsvInputNodeSelectedFilePath(nodeId))
+ const filePath = useSelector(
+ selectBehaviorLikeInputNodeSelectedFilePath(nodeId),
+ )
const onChangeFilePath = (path: string) => {
dispatch(setInputNodeFilePath({ nodeId, filePath: path }))
}
@@ -60,7 +62,7 @@ const BehaviorFileNodeImple = memo(function BehaviorFileNodeImple({
}}
nameNode={FILE_TYPE_SET.BEHAVIOR}
fileType={FILE_TYPE_SET.CSV}
- filePath={filePath ?? ""}
+ filePath={typeof filePath === "string" ? filePath : ""}
/>
()
- const filePath = useSelector(selectCsvInputNodeSelectedFilePath(nodeId))
+ const filePath = useSelector(selectCsvLikeInputNodeSelectedFilePath(nodeId))
const onChangeFilePath = (path: string) => {
dispatch(setInputNodeFilePath({ nodeId, filePath: path }))
}
diff --git a/frontend/src/components/Workspace/FlowChart/FlowChartNode/FileSelect.tsx b/frontend/src/components/Workspace/FlowChart/FlowChartNode/FileSelect.tsx
index 93f32063c5..bd202ec25d 100644
--- a/frontend/src/components/Workspace/FlowChart/FlowChartNode/FileSelect.tsx
+++ b/frontend/src/components/Workspace/FlowChart/FlowChartNode/FileSelect.tsx
@@ -12,7 +12,7 @@ import AccountTreeIcon from "@mui/icons-material/AccountTree"
import AddLinkIcon from "@mui/icons-material/AddLink"
import AddPhotoAlternateIcon from "@mui/icons-material/AddPhotoAlternate"
import ChecklistRtlIcon from "@mui/icons-material/ChecklistRtl"
-import { IconButton, Tooltip, Typography } from "@mui/material"
+import { IconButton, Tooltip, Typography, Chip, Box } from "@mui/material"
import ButtonGroup from "@mui/material/ButtonGroup"
import { FILE_TREE_TYPE, FILE_TREE_TYPE_SET } from "api/files/Files"
@@ -191,6 +191,7 @@ export const FileSelectImple = memo(function FileSelectImple({
const accept = getFileInputAccept(fileTreeType)
const fileName = getLabelByPath(filePath)
+ const fileCount = Array.isArray(filePath) ? filePath.length : 1
return (
@@ -250,21 +251,25 @@ export const FileSelectImple = memo(function FileSelectImple({
) : null}
- {fileTreeType === FILE_TREE_TYPE_SET.CSV && !!filePath && !!nodeId && (
-
-
-
-
-
- )}
+ {fileTreeType === FILE_TREE_TYPE_SET.CSV &&
+ !!filePath &&
+ (Array.isArray(filePath) ? filePath.length > 0 : true) &&
+ !!nodeId && (
+
+
+
+
+
+ )}
{(
[FILE_TREE_TYPE_SET.HDF5, FILE_TREE_TYPE_SET.MATLAB] as string[]
).includes(fileTreeType as string) &&
!!filePath &&
+ (Array.isArray(filePath) ? filePath.length > 0 : true) &&
!!nodeId && (
@@ -289,12 +294,24 @@ export const FileSelectImple = memo(function FileSelectImple({
visibility: "hidden",
width: 0,
height: 0,
+ position: "absolute",
}}
/>
-
- {fileName ? fileName : "No file is selected."}
-
+
+ {fileCount > 1 && (
+
+ )}
+
+ {fileName ? fileName : "No file is selected."}
+
+
diff --git a/frontend/src/components/Workspace/FlowChart/FlowChartNode/FluoFileNode.tsx b/frontend/src/components/Workspace/FlowChart/FlowChartNode/FluoFileNode.tsx
index 16204184f1..9de7dce285 100644
--- a/frontend/src/components/Workspace/FlowChart/FlowChartNode/FluoFileNode.tsx
+++ b/frontend/src/components/Workspace/FlowChart/FlowChartNode/FluoFileNode.tsx
@@ -10,7 +10,7 @@ import { HANDLE_STYLE } from "const/flowchart"
import { deleteFlowNodeById } from "store/slice/FlowElement/FlowElementSlice"
import { setInputNodeFilePath } from "store/slice/InputNode/InputNodeActions"
import {
- selectCsvInputNodeSelectedFilePath,
+ selectFluoLikeInputNodeSelectedFilePath,
selectInputNodeDefined,
} from "store/slice/InputNode/InputNodeSelectors"
import { FILE_TYPE_SET } from "store/slice/InputNode/InputNodeType"
@@ -29,7 +29,7 @@ const FluoFileNodeImple = memo(function FluoFileNodeImple({
selected,
}: NodeProps) {
const dispatch = useDispatch()
- const filePath = useSelector(selectCsvInputNodeSelectedFilePath(nodeId))
+ const filePath = useSelector(selectFluoLikeInputNodeSelectedFilePath(nodeId))
const onChangeFilePath = (path: string) => {
dispatch(setInputNodeFilePath({ nodeId, filePath: path }))
}
@@ -58,7 +58,7 @@ const FluoFileNodeImple = memo(function FluoFileNodeImple({
}}
nameNode={FILE_TYPE_SET.FLUO}
fileType={FILE_TYPE_SET.CSV}
- filePath={filePath ?? ""}
+ filePath={typeof filePath === "string" ? filePath : ""}
/>
- a != null && b != null
- ? arrayEqualityFn(a as string[], b as string[])
+ a != null && b != null && Array.isArray(a) && Array.isArray(b)
+ ? arrayEqualityFn(a, b)
: a === b,
)
const onChangeFilePath = (path: string[]) => {
diff --git a/frontend/src/components/Workspace/FlowChart/FlowChartNode/MatlabFileNode.tsx b/frontend/src/components/Workspace/FlowChart/FlowChartNode/MatlabFileNode.tsx
index 2cb198e8ac..d8b1f5461f 100644
--- a/frontend/src/components/Workspace/FlowChart/FlowChartNode/MatlabFileNode.tsx
+++ b/frontend/src/components/Workspace/FlowChart/FlowChartNode/MatlabFileNode.tsx
@@ -5,7 +5,7 @@ import {
import { FILE_TYPE_SET } from "config/fileTypes.config"
import {
selectInputNodeMatlabPath,
- selectMatlabInputNodeSelectedFilePath,
+ selectMatlabLikeInputNodeSelectedFilePath,
} from "store/slice/InputNode/InputNodeSelectors"
import { setInputNodeMatlabPath } from "store/slice/InputNode/InputNodeSlice"
import { getMatlabTree } from "store/slice/Matlab/MatlabAction"
@@ -19,7 +19,7 @@ const matlabConfig: FileNodeConfig = {
handleId: "matlab",
handleType: "MatlabData",
treeKeyPrefix: "matlabtree",
- selectFilePath: selectMatlabInputNodeSelectedFilePath,
+ selectFilePath: selectMatlabLikeInputNodeSelectedFilePath,
selectStructurePath: selectInputNodeMatlabPath,
setStructurePath: setInputNodeMatlabPath,
getTree: getMatlabTree,
diff --git a/frontend/src/components/Workspace/FlowChart/FlowChartNode/MicroscopeFileNode.tsx b/frontend/src/components/Workspace/FlowChart/FlowChartNode/MicroscopeFileNode.tsx
index 0b7ddcdf19..7a79a67f28 100644
--- a/frontend/src/components/Workspace/FlowChart/FlowChartNode/MicroscopeFileNode.tsx
+++ b/frontend/src/components/Workspace/FlowChart/FlowChartNode/MicroscopeFileNode.tsx
@@ -13,7 +13,7 @@ import { HANDLE_STYLE } from "const/flowchart"
import { deleteFlowNodeById } from "store/slice/FlowElement/FlowElementSlice"
import { setInputNodeFilePath } from "store/slice/InputNode/InputNodeActions"
import {
- selectMicroscopeInputNodeSelectedFilePath,
+ selectMicroscopeLikeInputNodeSelectedFilePath,
selectInputNodeDefined,
} from "store/slice/InputNode/InputNodeSelectors"
import { FILE_TYPE_SET } from "store/slice/InputNode/InputNodeType"
@@ -35,7 +35,7 @@ const MicroscopeFileNodeImple = memo(function MicroscopeFileNodeImple({
}: NodeProps) {
const dispatch = useDispatch()
const filePath = useSelector(
- selectMicroscopeInputNodeSelectedFilePath(nodeId),
+ selectMicroscopeLikeInputNodeSelectedFilePath(nodeId),
)
const onChangeFilePath = (path: string) => {
dispatch(setInputNodeFilePath({ nodeId, filePath: path }))
diff --git a/frontend/src/components/Workspace/FlowChart/FlowChartNode/NodeComponentRegistry.ts b/frontend/src/components/Workspace/FlowChart/FlowChartNode/NodeComponentRegistry.ts
index dece33f2c8..d387532e9d 100644
--- a/frontend/src/components/Workspace/FlowChart/FlowChartNode/NodeComponentRegistry.ts
+++ b/frontend/src/components/Workspace/FlowChart/FlowChartNode/NodeComponentRegistry.ts
@@ -3,6 +3,13 @@ import { NodeProps } from "reactflow"
// Import all node components
import { AlgorithmNode } from "components/Workspace/FlowChart/FlowChartNode/AlgorithmNode"
+import { BatchBehaviorFileNode } from "components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchBehaviorFileNode"
+import { BatchCsvFileNode } from "components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchCsvFileNode"
+import { BatchFluoFileNode } from "components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchFluoFileNode"
+import { BatchHDF5FileNode } from "components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchHDF5FileNode"
+import { BatchImageFileNode } from "components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchImageFileNode"
+import { BatchMatlabFileNode } from "components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchMatlabFileNode"
+import { BatchMicroscopeFileNode } from "components/Workspace/FlowChart/FlowChartNode/BatchInputNode/BatchMicroscopeFileNode"
import { BehaviorFileNode } from "components/Workspace/FlowChart/FlowChartNode/BehaviorFileNode"
import { CsvFileNode } from "components/Workspace/FlowChart/FlowChartNode/CsvFileNode"
import { FluoFileNode } from "components/Workspace/FlowChart/FlowChartNode/FluoFileNode"
@@ -24,6 +31,13 @@ export const nodeComponentRegistry: Record = {
FluoFileNode,
BehaviorFileNode,
MicroscopeFileNode,
+ BatchImageFileNode,
+ BatchCsvFileNode,
+ BatchFluoFileNode,
+ BatchBehaviorFileNode,
+ BatchMicroscopeFileNode,
+ BatchHDF5FileNode,
+ BatchMatlabFileNode,
}
// Get component by node type name
diff --git a/frontend/src/components/Workspace/FlowChart/TreeView.tsx b/frontend/src/components/Workspace/FlowChart/TreeView.tsx
index 2f3472b00a..9eb32ee3a8 100644
--- a/frontend/src/components/Workspace/FlowChart/TreeView.tsx
+++ b/frontend/src/components/Workspace/FlowChart/TreeView.tsx
@@ -23,7 +23,10 @@ import {
import {
getFileTypeConfigsByHierarchy,
REACT_FLOW_NODE_TYPE_KEY,
+ WORKSPACE_TYPE_HIERARCHY_MAPPING,
+ TreeHierarchyType,
} from "config/fileTypes.config"
+import { WORKSPACE_TYPE } from "const/Workspace"
import { FileNodeFactory } from "factories/FileNodeFactory"
import { getAlgoList } from "store/slice/AlgorithmList/AlgorithmListActions"
import {
@@ -42,6 +45,7 @@ import {
import { NODE_TYPE_SET } from "store/slice/FlowElement/FlowElementType"
import { FILE_TYPE } from "store/slice/InputNode/InputNodeType"
import { selectPipelineLatestUid } from "store/slice/Pipeline/PipelineSelectors"
+import { selectCurrentWorkspaceType } from "store/slice/Workspace/WorkspaceSelector"
import { AppDispatch } from "store/store"
import { getNanoId } from "utils/nanoid/NanoIdUtils"
@@ -50,6 +54,7 @@ export const AlgorithmTreeView = memo(function AlgorithmTreeView() {
const algoList = useSelector(selectAlgorithmListTree)
const isLatest = useSelector(selectAlgorithmListIsLatest)
const workflowId = useSelector(selectPipelineLatestUid)
+ const workspaceType = useSelector(selectCurrentWorkspaceType)
const runAlready = typeof workflowId !== "undefined"
useEffect(() => {
@@ -86,6 +91,19 @@ export const AlgorithmTreeView = memo(function AlgorithmTreeView() {
// Get file type configs grouped by hierarchy
const fileTypesByHierarchy = getFileTypeConfigsByHierarchy()
+ // Get allowed hierarchies for the current workspace type
+ const allowedHierarchies =
+ WORKSPACE_TYPE_HIERARCHY_MAPPING[
+ (workspaceType ?? WORKSPACE_TYPE.DEFAULT) as WORKSPACE_TYPE
+ ]
+
+ // Filter hierarchies based on workspace type
+ const filteredHierarchies = Object.entries(fileTypesByHierarchy).filter(
+ ([hierarchyName]) => {
+ return allowedHierarchies.includes(hierarchyName as TreeHierarchyType)
+ },
+ )
+
return (
}
>
{/* Dynamically generate hierarchy nodes for file types */}
- {Object.entries(fileTypesByHierarchy).map(([hierarchyName, configs]) => (
+ {filteredHierarchies.map(([hierarchyName, configs]) => (
{
algorithmNodeNotExist: false,
handleCancelPipeline: jest.fn(),
handleRunPipeline: jest.fn(),
+ handleBatchRunPipeline: jest.fn(),
handleRunPipelineByUid: jest.fn(),
}
@@ -37,6 +38,19 @@ describe("RunButtons component", () => {
uid: "test-uid",
},
runBtn: RUN_BTN_OPTIONS.RUN_NEW,
+ workspace: {
+ currentWorkspace: {
+ type: 1, // WORKSPACE_TYPE.NORMAL
+ },
+ },
+ flowElement: {
+ flowNodes: [],
+ flowEdges: [],
+ flowPosition: { x: 0, y: 0, zoom: 1 },
+ elementCoord: {},
+ loading: false,
+ },
+ inputNode: {},
})
})
diff --git a/frontend/src/components/common/CurrentPipelineInfo.tsx b/frontend/src/components/common/CurrentPipelineInfo.tsx
index 6cb9e220a5..0ad626aff4 100644
--- a/frontend/src/components/common/CurrentPipelineInfo.tsx
+++ b/frontend/src/components/common/CurrentPipelineInfo.tsx
@@ -4,6 +4,7 @@ import { useSelector } from "react-redux"
import { Typography, Grid, Popover } from "@mui/material"
import { ParamSection } from "components/common/ParamSection"
+import { WORKSPACE_TYPE, WORKSPACE_TYPE_LABEL } from "const/Workspace"
import {
selectCurrentPipelineName,
selectPipelineLatestUid,
@@ -12,11 +13,13 @@ import { selectModeStandalone } from "store/slice/Standalone/StandaloneSeclector
import {
selectCurrentWorkspaceId,
selectCurrentWorkspaceName,
+ selectCurrentWorkspaceType,
} from "store/slice/Workspace/WorkspaceSelector"
export const CurrentPipelineInfo: FC = () => {
const workspaceId = useSelector(selectCurrentWorkspaceId)
const workspaceName = useSelector(selectCurrentWorkspaceName)
+ const workspaceType = useSelector(selectCurrentWorkspaceType)
const workflowId = useSelector(selectPipelineLatestUid)
const workflowName = useSelector(selectCurrentPipelineName)
const isStandalone = useSelector(selectModeStandalone)
@@ -40,6 +43,16 @@ export const CurrentPipelineInfo: FC = () => {
section="workspace"
/>
)}
+ {workspaceType !== undefined && (
+
+ )}
)}
diff --git a/frontend/src/config/fileTypes.config.ts b/frontend/src/config/fileTypes.config.ts
index 87307da8d5..32dd041ea5 100644
--- a/frontend/src/config/fileTypes.config.ts
+++ b/frontend/src/config/fileTypes.config.ts
@@ -1,11 +1,24 @@
+import { WORKSPACE_TYPE } from "const/Workspace"
+
// Define tree hierarchy constants for better maintainability
export const TREE_HIERARCHY = {
DATA: "Data",
+ BATCH_DATA: "Batch Data",
} as const
export type TreeHierarchyType =
(typeof TREE_HIERARCHY)[keyof typeof TREE_HIERARCHY]
+// Define mapping between workspace types and allowed tree hierarchies
+export const WORKSPACE_TYPE_HIERARCHY_MAPPING: Record<
+ WORKSPACE_TYPE,
+ TreeHierarchyType[]
+> = {
+ [WORKSPACE_TYPE.DEFAULT]: [TREE_HIERARCHY.DATA],
+ [WORKSPACE_TYPE.NORMAL]: [TREE_HIERARCHY.DATA],
+ [WORKSPACE_TYPE.BATCH]: [TREE_HIERARCHY.DATA, TREE_HIERARCHY.BATCH_DATA],
+}
+
export interface FileTypeConfig {
key: string
displayName?: string // Optional: If not provided, key will be used for display
@@ -65,6 +78,13 @@ export const REACT_FLOW_NODE_TYPE_KEY = {
BehaviorFileNode: "BehaviorFileNode",
MatlabFileNode: "MatlabFileNode",
MicroscopeFileNode: "MicroscopeFileNode",
+ BatchImageFileNode: "BatchImageFileNode",
+ BatchCsvFileNode: "BatchCsvFileNode",
+ BatchFluoFileNode: "BatchFluoFileNode",
+ BatchBehaviorFileNode: "BatchBehaviorFileNode",
+ BatchMicroscopeFileNode: "BatchMicroscopeFileNode",
+ BatchHDF5FileNode: "BatchHDF5FileNode",
+ BatchMatlabFileNode: "BatchMatlabFileNode",
} as const
// Streamlined config - nodeType references REACT_FLOW_NODE_TYPE_KEY
@@ -141,6 +161,91 @@ export const FILE_TYPE_CONFIGS: Record = {
dataType: "matlab", // Special: uses matlab data type
nodeType: REACT_FLOW_NODE_TYPE_KEY.MicroscopeFileNode,
},
+ BATCH_IMAGE: {
+ key: "batch_image",
+ hasFilePath: true,
+ filePathType: "array",
+ defaultParam: {},
+ treeType: "image",
+ nodeType: REACT_FLOW_NODE_TYPE_KEY.BatchImageFileNode,
+ treeHierarchy: TREE_HIERARCHY.BATCH_DATA,
+ },
+ BATCH_CSV: {
+ key: "batch_csv",
+ hasFilePath: true,
+ filePathType: "array",
+ defaultParam: {
+ setHeader: null,
+ setIndex: false,
+ transpose: false,
+ },
+ treeType: "csv",
+ nodeType: REACT_FLOW_NODE_TYPE_KEY.BatchCsvFileNode,
+ treeHierarchy: TREE_HIERARCHY.BATCH_DATA,
+ },
+ BATCH_HDF5: {
+ key: "batch_hdf5",
+ hasFilePath: true,
+ filePathType: "array",
+ hasSpecialPath: {
+ name: "hdf5Path",
+ type: "hdf5Path",
+ },
+ defaultParam: {},
+ treeType: "hdf5",
+ nodeType: REACT_FLOW_NODE_TYPE_KEY.BatchHDF5FileNode,
+ treeHierarchy: TREE_HIERARCHY.BATCH_DATA,
+ },
+ BATCH_FLUO: {
+ key: "batch_fluo",
+ hasFilePath: true,
+ filePathType: "array",
+ defaultParam: {
+ setHeader: null,
+ setIndex: false,
+ transpose: false,
+ },
+ stateFileType: "batch_csv", // Special: stored as batch_csv in state
+ treeType: "csv",
+ nodeType: REACT_FLOW_NODE_TYPE_KEY.BatchFluoFileNode,
+ treeHierarchy: TREE_HIERARCHY.BATCH_DATA,
+ },
+ BATCH_BEHAVIOR: {
+ key: "batch_behavior",
+ hasFilePath: true,
+ filePathType: "array",
+ defaultParam: {
+ setHeader: null,
+ setIndex: false,
+ transpose: false,
+ },
+ stateFileType: "batch_csv", // Special: stored as batch_csv in state
+ treeType: "csv",
+ nodeType: REACT_FLOW_NODE_TYPE_KEY.BatchBehaviorFileNode,
+ treeHierarchy: TREE_HIERARCHY.BATCH_DATA,
+ },
+ BATCH_MATLAB: {
+ key: "batch_matlab",
+ hasFilePath: true,
+ filePathType: "array",
+ hasSpecialPath: {
+ name: "matPath",
+ type: "matPath",
+ },
+ defaultParam: {},
+ treeType: "matlab",
+ nodeType: REACT_FLOW_NODE_TYPE_KEY.BatchMatlabFileNode,
+ treeHierarchy: TREE_HIERARCHY.BATCH_DATA,
+ },
+ BATCH_MICROSCOPE: {
+ key: "batch_microscope",
+ hasFilePath: true,
+ filePathType: "array",
+ defaultParam: {},
+ treeType: "microscope",
+ nodeType: REACT_FLOW_NODE_TYPE_KEY.BatchMicroscopeFileNode,
+ treeHierarchy: TREE_HIERARCHY.BATCH_DATA,
+ },
} as const
// Enhanced configs with computed properties
diff --git a/frontend/src/const/Workspace.ts b/frontend/src/const/Workspace.ts
index 71e211f39d..187a41090f 100644
--- a/frontend/src/const/Workspace.ts
+++ b/frontend/src/const/Workspace.ts
@@ -1,9 +1,22 @@
export enum WORKSPACE_TYPE {
DEFAULT = 0,
NORMAL = 1,
+ BATCH = 2,
}
export const WORKSPACE_TYPE_LABEL: Record = {
[WORKSPACE_TYPE.DEFAULT]: "Normal", // Same as NORMAL
[WORKSPACE_TYPE.NORMAL]: "Normal",
+ [WORKSPACE_TYPE.BATCH]: "Batch",
}
+
+export const WORKSPACE_TYPE_OPTIONS = [
+ {
+ value: WORKSPACE_TYPE.NORMAL,
+ label: WORKSPACE_TYPE_LABEL[WORKSPACE_TYPE.NORMAL],
+ },
+ {
+ value: WORKSPACE_TYPE.BATCH,
+ label: WORKSPACE_TYPE_LABEL[WORKSPACE_TYPE.BATCH],
+ },
+]
diff --git a/frontend/src/factories/FileNodeFactory.ts b/frontend/src/factories/FileNodeFactory.ts
index afc2e7a57a..6669a3a7d5 100644
--- a/frontend/src/factories/FileNodeFactory.ts
+++ b/frontend/src/factories/FileNodeFactory.ts
@@ -31,15 +31,24 @@ export class FileNodeFactory {
throw new Error(`Unsupported file type: ${fileType}`)
}
- // Map special cases
+ // Map special cases using config's stateFileType
let actualFileType: FILE_TYPE
- switch (fileType) {
- case FILE_TYPE_SET.FLUO:
- case FILE_TYPE_SET.BEHAVIOR:
- actualFileType = FILE_TYPE_SET.CSV
- break
- default:
- actualFileType = fileType
+ if (config.stateFileType) {
+ actualFileType = config.stateFileType as FILE_TYPE
+ } else {
+ // Fallback to legacy switch statement
+ switch (fileType) {
+ case FILE_TYPE_SET.FLUO:
+ case FILE_TYPE_SET.BEHAVIOR:
+ actualFileType = FILE_TYPE_SET.CSV
+ break
+ case FILE_TYPE_SET.BATCH_FLUO:
+ case FILE_TYPE_SET.BATCH_BEHAVIOR:
+ actualFileType = FILE_TYPE_SET.BATCH_CSV
+ break
+ default:
+ actualFileType = fileType
+ }
}
// Return proper typed InputNode based on fileType
@@ -138,10 +147,12 @@ export class FileNodeFactory {
const config = getFileTypeConfig(fileType)
if (!config) return `selectGeneric${suffix}`
- // Capitalize first letter for function name
- const capitalizedName =
- config.key.charAt(0).toUpperCase() + config.key.slice(1)
- return `select${capitalizedName}InputNode${suffix}`
+ // Convert snake_case to PascalCase (e.g., batch_csv -> BatchCsv)
+ const pascalCaseName = config.key
+ .split("_")
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join("")
+ return `select${pascalCaseName}InputNode${suffix}`
}
/**
diff --git a/frontend/src/pages/Workspace/index.tsx b/frontend/src/pages/Workspace/index.tsx
index 673f85e3f4..39df0e93bc 100644
--- a/frontend/src/pages/Workspace/index.tsx
+++ b/frontend/src/pages/Workspace/index.tsx
@@ -16,9 +16,14 @@ import {
DialogTitle,
DialogContent,
DialogActions,
- Input,
+ TextField,
Tooltip,
IconButton,
+ FormControl,
+ FormLabel,
+ RadioGroup,
+ FormControlLabel,
+ Radio,
} from "@mui/material"
import {
GridEventListener,
@@ -36,6 +41,11 @@ import DeleteConfirmModal from "components/common/DeleteConfirmModal"
import Loading from "components/common/Loading"
import PaginationCustom from "components/common/PaginationCustom"
import PopupShare from "components/Workspace/PopupShare"
+import {
+ WORKSPACE_TYPE,
+ WORKSPACE_TYPE_LABEL,
+ WORKSPACE_TYPE_OPTIONS,
+} from "const/Workspace"
import { selectCurrentUser } from "store/slice/User/UserSelector"
import { resetVisualizeLayout } from "store/slice/VisualizeItem/VisualizeItemSlice"
import {
@@ -59,8 +69,8 @@ type PopupType = {
open: boolean
handleClose: () => void
handleOkDel?: () => void
- setNewWorkSpace?: (name: string) => void
- value?: string
+ setNewWorkSpace?: (workspace: { name: string; type: WORKSPACE_TYPE }) => void
+ value?: { name: string; type: WORKSPACE_TYPE }
handleOkNew?: () => void
handleOkSave?: () => void
error?: string
@@ -147,6 +157,20 @@ const columns = (
),
},
+ {
+ field: "type",
+ headerName: "Type",
+ filterable: false,
+ sortable: false,
+ flex: 1,
+ minWidth: 80,
+ renderCell: (params: GridRenderCellParams) => (
+
+ {WORKSPACE_TYPE_LABEL[params.value as WORKSPACE_TYPE] ||
+ WORKSPACE_TYPE_LABEL[WORKSPACE_TYPE.DEFAULT]}
+
+ ),
+ },
{
field: "created_at",
headerName: "Created",
@@ -276,7 +300,18 @@ const PopupNew = ({
}: PopupType) => {
if (!setNewWorkSpace) return null
const handleName = (event: ChangeEvent) => {
- setNewWorkSpace(event.target.value)
+ setNewWorkSpace({
+ ...value,
+ name: event.target.value,
+ type: value?.type || WORKSPACE_TYPE.NORMAL,
+ })
+ }
+
+ const handleType = (event: ChangeEvent) => {
+ setNewWorkSpace({
+ name: value?.name || "",
+ type: Number(event.target.value) as WORKSPACE_TYPE,
+ })
}
return (
@@ -284,14 +319,33 @@ const PopupNew = ({
New Workspace
-
-
- {error ? {error} : null}
+
+ Type
+
+ {WORKSPACE_TYPE_OPTIONS.map((option) => (
+ }
+ label={option.label}
+ />
+ ))}
+
+
@@ -323,7 +377,10 @@ const Workspaces = () => {
id: number
name: string
}>()
- const [newWorkspace, setNewWorkSpace] = useState()
+ const [newWorkspace, setNewWorkSpace] = useState<{
+ name: string
+ type: WORKSPACE_TYPE
+ }>()
const [error, setError] = useState("")
const [initName, setInitName] = useState("")
const [rowModesModel, setRowModesModel] = useState({})
@@ -392,11 +449,14 @@ const Workspaces = () => {
const handleOpenPopupNew = () => {
setOpen({ ...open, new: true })
+ setNewWorkSpace(undefined)
+ setError("")
}
const handleClosePopupNew = () => {
setOpen({ ...open, new: false })
setError("")
+ setNewWorkSpace(undefined)
}
const handleNavWorkflow = (id: number) => {
@@ -417,11 +477,16 @@ const Workspaces = () => {
}
const handleOkNew = async () => {
- if (!newWorkspace) {
+ if (!newWorkspace?.name) {
setError("Workspace Name can't empty")
return
}
- const data = await dispatch(postWorkspace({ name: newWorkspace }))
+ const data = await dispatch(
+ postWorkspace({
+ name: newWorkspace.name,
+ type: newWorkspace.type || WORKSPACE_TYPE.NORMAL,
+ }),
+ )
if (isRejectedWithValue(data)) {
handleClickVariant("error", "Workspace creation failed!")
} else {
@@ -433,7 +498,7 @@ const Workspaces = () => {
await dispatch(getWorkspaceList(dataParams))
setOpen({ ...open, new: false })
setError("")
- setNewWorkSpace("")
+ setNewWorkSpace(undefined)
}
const onProcessRowUpdateError = (newRow: unknown) => {
@@ -484,7 +549,11 @@ const Workspaces = () => {
}
if (newRow.name === initName) return newRow
const data = await dispatch(
- putWorkspace({ name: newRow.name, id: newRow.id }),
+ putWorkspace({
+ name: newRow.name,
+ id: newRow.id,
+ type: newRow.type || WORKSPACE_TYPE.NORMAL,
+ }),
)
if (isRejectedWithValue(data)) {
handleClickVariant("error", "Workspace name edit failed!")
diff --git a/frontend/src/store/slice/FlowElement/FlowElementSlice.ts b/frontend/src/store/slice/FlowElement/FlowElementSlice.ts
index 55e740d23f..8882648a98 100644
--- a/frontend/src/store/slice/FlowElement/FlowElementSlice.ts
+++ b/frontend/src/store/slice/FlowElement/FlowElementSlice.ts
@@ -44,8 +44,10 @@ import { getWorkspace } from "store/slice/Workspace/WorkspaceActions"
/**
* Get the appropriate React Flow node type for the initial node
*/
-const getInitialNodeType = (_workspaceType?: number): string => {
- return REACT_FLOW_NODE_TYPE_KEY.ImageFileNode
+const getInitialNodeType = (workspaceType?: number): string => {
+ return workspaceType === WORKSPACE_TYPE.BATCH
+ ? REACT_FLOW_NODE_TYPE_KEY.BatchImageFileNode
+ : REACT_FLOW_NODE_TYPE_KEY.ImageFileNode
}
const createInitialNodes = (workspaceType?: number): Node[] => [
@@ -178,9 +180,9 @@ export const flowElementSlice = createSlice({
},
extraReducers: (builder) =>
builder
- .addCase(getWorkspace.fulfilled, (state) => {
+ .addCase(getWorkspace.fulfilled, (state, action) => {
// Save workspace type for later use
- const workspaceType = WORKSPACE_TYPE.DEFAULT // Currently a fixed value
+ const workspaceType = action.payload.type
state.currentWorkspaceType = workspaceType
// Update the initial node type based on workspace type
diff --git a/frontend/src/store/slice/InputNode/InputNodeSelectors.ts b/frontend/src/store/slice/InputNode/InputNodeSelectors.ts
index 96fa8b62f9..221825c32d 100644
--- a/frontend/src/store/slice/InputNode/InputNodeSelectors.ts
+++ b/frontend/src/store/slice/InputNode/InputNodeSelectors.ts
@@ -7,6 +7,14 @@ import {
isHDF5InputNode,
isMatlabInputNode,
isMicroscopeInputNode,
+ isBatchImageInputNode,
+ isBatchCsvInputNode,
+ isBatchFluoInputNode,
+ isBatchBehaviorInputNode,
+ isBatchMicroscopeInputNode,
+ isBatchHDF5InputNode,
+ isBatchMatlabInputNode,
+ isCsvBasedInputNode,
} from "store/slice/InputNode/InputNodeUtils"
import { RootState } from "store/store"
@@ -61,6 +69,13 @@ const generateTypedSelectors = () => {
hdf5: isHDF5InputNode,
matlab: isMatlabInputNode,
microscope: isMicroscopeInputNode,
+ batch_image: isBatchImageInputNode,
+ batch_csv: isBatchCsvInputNode,
+ batch_fluo: isBatchFluoInputNode,
+ batch_behavior: isBatchBehaviorInputNode,
+ batch_microscope: isBatchMicroscopeInputNode,
+ batch_hdf5: isBatchHDF5InputNode,
+ batch_matlab: isBatchMatlabInputNode,
}
getAllFileTypeConfigs().forEach((config) => {
@@ -95,13 +110,116 @@ export const selectMatlabInputNodeSelectedFilePath =
typedSelectors.selectMatlabInputNodeSelectedFilePath
export const selectMicroscopeInputNodeSelectedFilePath =
typedSelectors.selectMicroscopeInputNodeSelectedFilePath
+export const selectBatchImageInputNodeSelectedFilePath =
+ typedSelectors.selectBatchImageInputNodeSelectedFilePath
+export const selectBatchCsvInputNodeSelectedFilePath =
+ typedSelectors.selectBatchCsvInputNodeSelectedFilePath
+export const selectBatchFluoInputNodeSelectedFilePath =
+ typedSelectors.selectBatchFluoInputNodeSelectedFilePath
+export const selectBatchBehaviorInputNodeSelectedFilePath =
+ typedSelectors.selectBatchBehaviorInputNodeSelectedFilePath
+export const selectBatchMicroscopeInputNodeSelectedFilePath =
+ typedSelectors.selectBatchMicroscopeInputNodeSelectedFilePath
+export const selectBatchHdf5InputNodeSelectedFilePath =
+ typedSelectors.selectBatchHdf5InputNodeSelectedFilePath
+export const selectBatchMatlabInputNodeSelectedFilePath =
+ typedSelectors.selectBatchMatlabInputNodeSelectedFilePath
// Dynamic selectors generation capability is available for future extensions via getAllFileTypeConfigs()
+// Mixed type selectors for handling both single and batch file types
+export const selectCsvLikeInputNodeSelectedFilePath =
+ (nodeId: string) => (state: RootState) => {
+ const node = selectInputNodeById(nodeId)(state)
+ if (isCsvInputNode(node) || isBatchCsvInputNode(node)) {
+ return node.selectedFilePath
+ } else {
+ throw new Error("invalid input node type - expected csv or batch_csv")
+ }
+ }
+
+export const selectBehaviorLikeInputNodeSelectedFilePath =
+ (nodeId: string) => (state: RootState) => {
+ const node = selectInputNodeById(nodeId)(state)
+ // Behavior nodes can be csv, batch_csv types
+ if (
+ isCsvInputNode(node) ||
+ isBatchCsvInputNode(node) ||
+ isBatchBehaviorInputNode(node)
+ ) {
+ return node.selectedFilePath
+ } else {
+ throw new Error(
+ "invalid input node type - expected csv, batch_csv, or batch_behavior",
+ )
+ }
+ }
+
+export const selectFluoLikeInputNodeSelectedFilePath =
+ (nodeId: string) => (state: RootState) => {
+ const node = selectInputNodeById(nodeId)(state)
+ // Fluo nodes can be csv, batch_csv types
+ if (
+ isCsvInputNode(node) ||
+ isBatchCsvInputNode(node) ||
+ isBatchFluoInputNode(node)
+ ) {
+ return node.selectedFilePath
+ } else {
+ throw new Error(
+ "invalid input node type - expected csv, batch_csv, or batch_fluo",
+ )
+ }
+ }
+
+export const selectImageLikeInputNodeSelectedFilePath =
+ (nodeId: string) => (state: RootState) => {
+ const node = selectInputNodeById(nodeId)(state)
+ if (isImageInputNode(node) || isBatchImageInputNode(node)) {
+ return node.selectedFilePath
+ } else {
+ throw new Error("invalid input node type - expected image or batch_image")
+ }
+ }
+
+export const selectHdf5LikeInputNodeSelectedFilePath =
+ (nodeId: string) => (state: RootState) => {
+ const node = selectInputNodeById(nodeId)(state)
+ if (isHDF5InputNode(node) || isBatchHDF5InputNode(node)) {
+ return node.selectedFilePath
+ } else {
+ throw new Error("invalid input node type - expected hdf5 or batch_hdf5")
+ }
+ }
+
+export const selectMatlabLikeInputNodeSelectedFilePath =
+ (nodeId: string) => (state: RootState) => {
+ const node = selectInputNodeById(nodeId)(state)
+ if (isMatlabInputNode(node) || isBatchMatlabInputNode(node)) {
+ return node.selectedFilePath
+ } else {
+ throw new Error(
+ "invalid input node type - expected matlab or batch_matlab",
+ )
+ }
+ }
+
+export const selectMicroscopeLikeInputNodeSelectedFilePath =
+ (nodeId: string) => (state: RootState) => {
+ const node = selectInputNodeById(nodeId)(state)
+ if (isMicroscopeInputNode(node) || isBatchMicroscopeInputNode(node)) {
+ return node.selectedFilePath
+ } else {
+ throw new Error(
+ "invalid input node type - expected microscope or batch_microscope",
+ )
+ }
+ }
+
export const selectFilePathIsUndefined = (state: RootState) =>
Object.keys(state.inputNode).length === 0 ||
Object.values(state.inputNode).filter((inputNode) => {
- if (isHDF5InputNode(inputNode)) {
+ if (isHDF5InputNode(inputNode) || isBatchHDF5InputNode(inputNode)) {
return inputNode.selectedFilePath == null || inputNode.hdf5Path == null
} else {
const filePath = inputNode.selectedFilePath
@@ -118,10 +236,10 @@ export const selectInputNodeParam = (nodeId: string) => (state: RootState) =>
const selectCsvInputNodeParam = (nodeId: string) => (state: RootState) => {
const inputNode = selectInputNodeById(nodeId)(state)
- if (isCsvInputNode(inputNode)) {
+ if (isCsvBasedInputNode(inputNode)) {
return inputNode.param
} else {
- throw new Error(`The InputNode is not CsvInputNode. (nodeId: ${nodeId})`)
+ throw new Error(`The InputNode is not CSV-based. (nodeId: ${nodeId})`)
}
}
@@ -140,7 +258,7 @@ export const selectCsvInputNodeParamTranspose =
export const selectInputNodeHDF5Path =
(nodeId: string) => (state: RootState) => {
const item = selectInputNodeById(nodeId)(state)
- if (isHDF5InputNode(item)) {
+ if (isHDF5InputNode(item) || isBatchHDF5InputNode(item)) {
return item.hdf5Path
} else {
return undefined
@@ -150,7 +268,7 @@ export const selectInputNodeHDF5Path =
export const selectInputNodeMatlabPath =
(nodeId: string) => (state: RootState) => {
const item = selectInputNodeById(nodeId)(state)
- if (isMatlabInputNode(item)) {
+ if (isMatlabInputNode(item) || isBatchMatlabInputNode(item)) {
return item.matPath
} else {
return undefined
diff --git a/frontend/src/store/slice/InputNode/InputNodeSlice.ts b/frontend/src/store/slice/InputNode/InputNodeSlice.ts
index 8dfe987097..a25a243b15 100644
--- a/frontend/src/store/slice/InputNode/InputNodeSlice.ts
+++ b/frontend/src/store/slice/InputNode/InputNodeSlice.ts
@@ -17,16 +17,21 @@ import {
CsvInputParamType,
FILE_TYPE_SET,
HDF5InputNode,
+ BatchHDF5InputNode,
InputNode,
InputNodeType,
INPUT_NODE_SLICE_NAME,
MatlabInputNode,
+ BatchMatlabInputNode,
WORKSPACE_TYPE_KEY,
} from "store/slice/InputNode/InputNodeType"
import {
- isCsvInputNode,
isHDF5InputNode,
isMatlabInputNode,
+ isBatchHDF5InputNode,
+ isBatchMatlabInputNode,
+ isCsvBasedFileType,
+ isCsvBasedInputNode,
} from "store/slice/InputNode/InputNodeUtils"
import {
reproduceWorkflow,
@@ -38,8 +43,10 @@ import { getWorkspace } from "store/slice/Workspace/WorkspaceActions"
/**
* Get the appropriate file type for the initial node
*/
-const getInitialNodeFileType = (_workspaceType?: number) => {
- return FILE_TYPE_SET.IMAGE
+const getInitialNodeFileType = (workspaceType?: number) => {
+ return workspaceType === WORKSPACE_TYPE.BATCH
+ ? FILE_TYPE_SET.BATCH_IMAGE
+ : FILE_TYPE_SET.IMAGE
}
/**
@@ -94,7 +101,7 @@ export const inputNodeSlice = createSlice({
) {
const { nodeId, param } = action.payload
const inputNode = state[nodeId]
- if (isCsvInputNode(inputNode)) {
+ if (isCsvBasedInputNode(inputNode)) {
inputNode.param = param
}
},
@@ -107,7 +114,7 @@ export const inputNodeSlice = createSlice({
) {
const { nodeId, path } = action.payload
const item = state[nodeId]
- if (isMatlabInputNode(item)) {
+ if (isMatlabInputNode(item) || isBatchMatlabInputNode(item)) {
item.matPath = path
}
},
@@ -120,16 +127,16 @@ export const inputNodeSlice = createSlice({
) {
const { nodeId, path } = action.payload
const item = state[nodeId]
- if (isHDF5InputNode(item)) {
+ if (isHDF5InputNode(item) || isBatchHDF5InputNode(item)) {
item.hdf5Path = path
}
},
},
extraReducers: (builder) =>
builder
- .addCase(getWorkspace.fulfilled, (state) => {
+ .addCase(getWorkspace.fulfilled, (state, action) => {
// Save workspace type for later use
- const workspaceType = WORKSPACE_TYPE.DEFAULT // Currently a fixed value
+ const workspaceType = action.payload.type
state[WORKSPACE_TYPE_KEY] = workspaceType
// Update the initial node based on workspace type
@@ -143,10 +150,13 @@ export const inputNodeSlice = createSlice({
const { nodeId, filePath } = action.payload
const targetNode = state[nodeId]
targetNode.selectedFilePath = filePath
- if (isHDF5InputNode(targetNode)) {
+ if (isHDF5InputNode(targetNode) || isBatchHDF5InputNode(targetNode)) {
targetNode.hdf5Path = undefined
}
- if (isMatlabInputNode(targetNode)) {
+ if (
+ isMatlabInputNode(targetNode) ||
+ isBatchMatlabInputNode(targetNode)
+ ) {
targetNode.matPath = undefined
}
})
@@ -209,11 +219,10 @@ export const inputNodeSlice = createSlice({
const baseNode = FileNodeFactory.createInputNode(
node.data.fileType,
)
- // Use specific param for CSV nodes
- const param =
- node.data.fileType === FILE_TYPE_SET.CSV
- ? (node.data.param as CsvInputParamType)
- : baseNode.param
+ // Use specific param for CSV-based nodes
+ const param = isCsvBasedFileType(node.data.fileType)
+ ? (node.data.param as CsvInputParamType)
+ : baseNode.param
newState[node.id] = {
...baseNode,
param,
@@ -249,11 +258,10 @@ export const inputNodeSlice = createSlice({
node.data.fileType,
)
- // Use specific param for CSV nodes
- const param =
- node.data.fileType === FILE_TYPE_SET.CSV
- ? (node.data.param as CsvInputParamType)
- : baseNode.param
+ // Use specific param for CSV-based nodes
+ const param = isCsvBasedFileType(node.data.fileType)
+ ? (node.data.param as CsvInputParamType)
+ : baseNode.param
const nodeState: InputNodeType = {
...baseNode,
@@ -269,17 +277,21 @@ export const inputNodeSlice = createSlice({
if (
specialPath.type === "hdf5Path" &&
"hdf5Path" in node.data &&
- isHDF5InputNode(nodeState)
+ (isHDF5InputNode(nodeState) ||
+ isBatchHDF5InputNode(nodeState))
) {
- ;(nodeState as HDF5InputNode).hdf5Path =
- node.data.hdf5Path
+ ;(
+ nodeState as HDF5InputNode | BatchHDF5InputNode
+ ).hdf5Path = node.data.hdf5Path
} else if (
specialPath.type === "matPath" &&
"matPath" in node.data &&
- isMatlabInputNode(nodeState)
+ (isMatlabInputNode(nodeState) ||
+ isBatchMatlabInputNode(nodeState))
) {
- ;(nodeState as MatlabInputNode).matPath =
- node.data.matPath
+ ;(
+ nodeState as MatlabInputNode | BatchMatlabInputNode
+ ).matPath = node.data.matPath
}
}
diff --git a/frontend/src/store/slice/InputNode/InputNodeType.ts b/frontend/src/store/slice/InputNode/InputNodeType.ts
index 8469ace700..bf8b3037ba 100644
--- a/frontend/src/store/slice/InputNode/InputNodeType.ts
+++ b/frontend/src/store/slice/InputNode/InputNodeType.ts
@@ -20,6 +20,13 @@ export type InputNodeType =
| HDF5InputNode
| MatlabInputNode
| MicroscopeInputNode
+ | BatchImageInputNode
+ | BatchCsvInputNode
+ | BatchFluoInputNode
+ | BatchBehaviorInputNode
+ | BatchMicroscopeInputNode
+ | BatchHDF5InputNode
+ | BatchMatlabInputNode
interface InputNodeBaseType<
T extends FILE_TYPE,
@@ -62,3 +69,40 @@ export interface MicroscopeInputNode
extends InputNodeBaseType<"microscope", Record> {
selectedFilePath?: string
}
+
+export interface BatchImageInputNode
+ extends InputNodeBaseType<"batch_image", Record> {
+ selectedFilePath?: string[]
+}
+
+export interface BatchCsvInputNode
+ extends InputNodeBaseType<"batch_csv", CsvInputParamType> {
+ selectedFilePath?: string[]
+}
+
+export interface BatchFluoInputNode
+ extends InputNodeBaseType<"batch_fluo", CsvInputParamType> {
+ selectedFilePath?: string[]
+}
+
+export interface BatchBehaviorInputNode
+ extends InputNodeBaseType<"batch_behavior", CsvInputParamType> {
+ selectedFilePath?: string[]
+}
+
+export interface BatchMicroscopeInputNode
+ extends InputNodeBaseType<"batch_microscope", Record> {
+ selectedFilePath?: string[]
+}
+
+export interface BatchHDF5InputNode
+ extends InputNodeBaseType<"batch_hdf5", Record> {
+ selectedFilePath?: string[]
+ hdf5Path?: string
+}
+
+export interface BatchMatlabInputNode
+ extends InputNodeBaseType<"batch_matlab", Record> {
+ selectedFilePath?: string[]
+ matPath?: string
+}
diff --git a/frontend/src/store/slice/InputNode/InputNodeUtils.ts b/frontend/src/store/slice/InputNode/InputNodeUtils.ts
index 746d2659a6..a58571b381 100644
--- a/frontend/src/store/slice/InputNode/InputNodeUtils.ts
+++ b/frontend/src/store/slice/InputNode/InputNodeUtils.ts
@@ -6,6 +6,13 @@ import {
FILE_TYPE_SET,
MatlabInputNode,
MicroscopeInputNode,
+ BatchImageInputNode,
+ BatchCsvInputNode,
+ BatchFluoInputNode,
+ BatchBehaviorInputNode,
+ BatchMicroscopeInputNode,
+ BatchHDF5InputNode,
+ BatchMatlabInputNode,
} from "store/slice/InputNode/InputNodeType"
export function isImageInputNode(
@@ -37,3 +44,89 @@ export function isMicroscopeInputNode(
): inputNode is MicroscopeInputNode {
return inputNode.fileType === FILE_TYPE_SET.MICROSCOPE
}
+
+export function isBatchAnyInputNode(
+ inputNode: InputNodeType,
+): inputNode is
+ | BatchImageInputNode
+ | BatchCsvInputNode
+ | BatchFluoInputNode
+ | BatchBehaviorInputNode
+ | BatchMicroscopeInputNode
+ | BatchHDF5InputNode
+ | BatchMatlabInputNode {
+ return (
+ isBatchImageInputNode(inputNode) ||
+ isBatchCsvInputNode(inputNode) ||
+ isBatchFluoInputNode(inputNode) ||
+ isBatchBehaviorInputNode(inputNode) ||
+ isBatchMicroscopeInputNode(inputNode) ||
+ isBatchHDF5InputNode(inputNode) ||
+ isBatchMatlabInputNode(inputNode)
+ )
+}
+
+export function isBatchImageInputNode(
+ inputNode: InputNodeType,
+): inputNode is BatchImageInputNode {
+ return inputNode.fileType === FILE_TYPE_SET.BATCH_IMAGE
+}
+
+export function isBatchCsvInputNode(
+ inputNode: InputNodeType,
+): inputNode is BatchCsvInputNode {
+ return inputNode.fileType === FILE_TYPE_SET.BATCH_CSV
+}
+
+export function isBatchFluoInputNode(
+ inputNode: InputNodeType,
+): inputNode is BatchFluoInputNode {
+ return inputNode.fileType === FILE_TYPE_SET.BATCH_FLUO
+}
+
+export function isBatchBehaviorInputNode(
+ inputNode: InputNodeType,
+): inputNode is BatchBehaviorInputNode {
+ return inputNode.fileType === FILE_TYPE_SET.BATCH_BEHAVIOR
+}
+
+export function isBatchMicroscopeInputNode(
+ inputNode: InputNodeType,
+): inputNode is BatchMicroscopeInputNode {
+ return inputNode.fileType === FILE_TYPE_SET.BATCH_MICROSCOPE
+}
+
+export function isBatchHDF5InputNode(
+ inputNode: InputNodeType,
+): inputNode is BatchHDF5InputNode {
+ return inputNode.fileType === FILE_TYPE_SET.BATCH_HDF5
+}
+
+export function isBatchMatlabInputNode(
+ inputNode: InputNodeType,
+): inputNode is BatchMatlabInputNode {
+ return inputNode.fileType === FILE_TYPE_SET.BATCH_MATLAB
+}
+
+// Helper function to check if a file type is CSV-based
+export function isCsvBasedFileType(fileType: string): boolean {
+ return [
+ FILE_TYPE_SET.CSV,
+ FILE_TYPE_SET.BATCH_CSV,
+ FILE_TYPE_SET.FLUO,
+ FILE_TYPE_SET.BATCH_FLUO,
+ FILE_TYPE_SET.BEHAVIOR,
+ FILE_TYPE_SET.BATCH_BEHAVIOR,
+ ].includes(fileType)
+}
+
+// Helper function to check if an InputNode is CSV-based
+export function isCsvBasedInputNode(
+ inputNode: InputNodeType,
+): inputNode is
+ | CsvInputNode
+ | BatchCsvInputNode
+ | BatchFluoInputNode
+ | BatchBehaviorInputNode {
+ return isCsvBasedFileType(inputNode.fileType)
+}
diff --git a/frontend/src/store/slice/Pipeline/Pipeline.test.ts b/frontend/src/store/slice/Pipeline/Pipeline.test.ts
index 96d5dae243..78919b73be 100644
--- a/frontend/src/store/slice/Pipeline/Pipeline.test.ts
+++ b/frontend/src/store/slice/Pipeline/Pipeline.test.ts
@@ -48,6 +48,7 @@ describe("Pipeline State Test", () => {
uid: "response data",
},
runBtn: 1,
+ isBatchRun: false,
}
expect(targetState).toEqual(expectState)
})
@@ -60,6 +61,7 @@ describe("Pipeline State Test", () => {
const expectState = {
run: { status: RUN_STATUS.START_PENDING },
runBtn: 1,
+ isBatchRun: false,
}
expect(targetState).toEqual(expectState)
})
@@ -69,7 +71,11 @@ describe("Pipeline State Test", () => {
reducer(initialState, createPendingWithRequestIdAction(run)),
createRejectedAction(run),
)
- const expectState = { run: { status: RUN_STATUS.START_ERROR }, runBtn: 1 }
+ const expectState = {
+ run: { status: RUN_STATUS.START_ERROR },
+ runBtn: 1,
+ isBatchRun: false,
+ }
expect(targetState).toEqual(expectState)
})
})
@@ -92,6 +98,7 @@ describe("Pipeline State Test", () => {
uid: "response data",
},
runBtn: 1,
+ isBatchRun: false,
}
expect(targetState).toEqual(expectState)
})
@@ -104,6 +111,7 @@ describe("Pipeline State Test", () => {
const expectState = {
run: { status: RUN_STATUS.START_PENDING },
runBtn: 1,
+ isBatchRun: false,
}
expect(targetState).toEqual(expectState)
})
@@ -113,7 +121,11 @@ describe("Pipeline State Test", () => {
reducer(initialState, createPendingAction(runByCurrentUid)),
createRejectedAction(runByCurrentUid),
)
- const expectState = { run: { status: RUN_STATUS.START_ERROR }, runBtn: 1 }
+ const expectState = {
+ run: { status: RUN_STATUS.START_ERROR },
+ runBtn: 1,
+ isBatchRun: false,
+ }
expect(targetState).toEqual(expectState)
})
})
@@ -176,6 +188,7 @@ describe("Pipeline State Test", () => {
status: RUN_STATUS.FINISHED,
uid: "test-uid",
},
+ isBatchRun: false,
}
expect(targetState).toEqual(expectState)
})
@@ -188,6 +201,7 @@ describe("Pipeline State Test", () => {
const expectState = {
...initialState,
run: { ...initialState.run, status: RUN_STATUS.ABORTED },
+ isBatchRun: false,
}
expect(targetState).toEqual(expectState)
})
@@ -209,6 +223,7 @@ describe("Pipeline State Test", () => {
...initialState.run,
status: RUN_STATUS.CANCELED,
},
+ isBatchRun: false,
}
expect(targetState).toEqual(expectState)
})
@@ -222,6 +237,7 @@ describe("Pipeline State Test", () => {
const expectState = {
run: { status: RUN_STATUS.START_UNINITIALIZED },
runBtn: 1,
+ isBatchRun: false,
}
expect(targetState).toEqual(expectState)
})
@@ -235,6 +251,7 @@ describe("Pipeline State Test", () => {
const expectState = {
...initialState,
run: { ...initialState.run, status: RUN_STATUS.START_UNINITIALIZED },
+ isBatchRun: false,
}
expect(targetState).toEqual(expectState)
})
diff --git a/frontend/src/store/slice/Pipeline/PipelineActions.ts b/frontend/src/store/slice/Pipeline/PipelineActions.ts
index c5fd5f9f3a..e9f3e110b4 100644
--- a/frontend/src/store/slice/Pipeline/PipelineActions.ts
+++ b/frontend/src/store/slice/Pipeline/PipelineActions.ts
@@ -8,6 +8,7 @@ import {
RunPostData,
cancelResultApi,
runFilterApi,
+ batchRunApi,
} from "api/run/Run"
import { TDataFilterParam } from "store/slice/AlgorithmNode/AlgorithmNodeType"
import {
@@ -99,6 +100,24 @@ export const runByCurrentUid = createAsyncThunk<
},
)
+export const batchRun = createAsyncThunk<
+ string,
+ { runPostData: RunPostData },
+ ThunkApiConfig
+>(`${PIPELINE_SLICE_NAME}/batchRun`, async ({ runPostData }, thunkAPI) => {
+ const workspaceId = selectCurrentWorkspaceId(thunkAPI.getState())
+ if (workspaceId) {
+ try {
+ const responseData = await batchRunApi(workspaceId, runPostData)
+ return responseData
+ } catch (e) {
+ return thunkAPI.rejectWithValue(e)
+ }
+ } else {
+ return thunkAPI.rejectWithValue("workspace id does not exist.")
+ }
+})
+
export const pollRunResult = createAsyncThunk<
RunResultDTO,
{
diff --git a/frontend/src/store/slice/Pipeline/PipelineHook.ts b/frontend/src/store/slice/Pipeline/PipelineHook.ts
index 40dc89d001..993abd6ebc 100644
--- a/frontend/src/store/slice/Pipeline/PipelineHook.ts
+++ b/frontend/src/store/slice/Pipeline/PipelineHook.ts
@@ -17,20 +17,28 @@ import {
pollRunResult,
runByCurrentUid,
cancelResult,
+ batchRun,
} from "store/slice/Pipeline/PipelineActions"
import {
selectPipelineIsCanceled,
selectPipelineIsStartedSuccess,
selectPipelineLatestUid,
selectPipelineStatus,
+ selectPipelineIsBatchRun,
} from "store/slice/Pipeline/PipelineSelectors"
import { RUN_STATUS } from "store/slice/Pipeline/PipelineType"
import { handleWorkflowYamlError } from "store/slice/Pipeline/PipelineUtils"
import { selectRunPostData } from "store/slice/Run/RunSelectors"
import { selectModeStandalone } from "store/slice/Standalone/StandaloneSeclector"
-import { fetchWorkflow } from "store/slice/Workflow/WorkflowActions"
+import {
+ fetchWorkflow,
+ reproduceWorkflow,
+} from "store/slice/Workflow/WorkflowActions"
import { getWorkspace } from "store/slice/Workspace/WorkspaceActions"
-import { selectIsWorkspaceOwner } from "store/slice/Workspace/WorkspaceSelector"
+import {
+ selectIsWorkspaceOwner,
+ selectCurrentWorkspaceId,
+} from "store/slice/Workspace/WorkspaceSelector"
import {
clearCurrentWorkspace,
setActiveTab,
@@ -85,27 +93,51 @@ export function useRunPipeline() {
const isCanceled = useSelector(selectPipelineIsCanceled)
const isStartedSuccess = useSelector(selectPipelineIsStartedSuccess)
const runDisabled = useIsRunDisabled()
+ const isBatchRun = useSelector(selectPipelineIsBatchRun)
+ const currentWorkspaceId = useSelector(selectCurrentWorkspaceId)
const filePathIsUndefined = useSelector(selectFilePathIsUndefined)
const algorithmNodeNotExist = useSelector(selectAlgorithmNodeNotExist)
const runPostData = useSelector(selectRunPostData)
const { enqueueSnackbar } = useSnackbar()
- const handleRunPipeline = useCallback(
+ const prepareRunPostData = useCallback(
(name: string) => {
- const newNodeDict = runPostData.nodeDict
+ const newNodeDict = { ...runPostData.nodeDict }
Object.keys(newNodeDict).forEach((key) => {
delete newNodeDict[key].data.dataFilterParam
delete newNodeDict[key].data.draftDataFilterParam
})
+ return {
+ name,
+ ...runPostData,
+ nodeDict: newNodeDict,
+ forceRunList: [],
+ }
+ },
+ [runPostData],
+ )
+
+ const handleRunPipeline = useCallback(
+ (name: string) => {
dispatch(
run({
- runPostData: {
- name,
- ...runPostData,
- nodeDict: newNodeDict,
- forceRunList: [],
- },
+ runPostData: prepareRunPostData(name),
+ }),
+ )
+ .unwrap()
+ .catch((error) => {
+ handleWorkflowYamlError(error, enqueueSnackbar)
+ })
+ },
+ [dispatch, enqueueSnackbar, prepareRunPostData],
+ )
+
+ const handleBatchRunPipeline = useCallback(
+ (name: string) => {
+ dispatch(
+ batchRun({
+ runPostData: prepareRunPostData(name),
}),
)
.unwrap()
@@ -113,7 +145,7 @@ export function useRunPipeline() {
handleWorkflowYamlError(error, enqueueSnackbar)
})
},
- [dispatch, enqueueSnackbar, runPostData],
+ [dispatch, enqueueSnackbar, prepareRunPostData],
)
const handleRunPipelineByUid = useCallback(() => {
@@ -156,6 +188,13 @@ export function useRunPipeline() {
// タブ移動による再レンダリングするたびにスナックバーが実行されてしまう挙動を回避するために前回の値を保持
const [prevStatus, setPrevStatus] = useState(status)
+ // Handle batch run completion separately
+ const handleBatchRunCompletion = useCallback(() => {
+ if (uid && currentWorkspaceId) {
+ dispatch(reproduceWorkflow({ workspaceId: currentWorkspaceId, uid }))
+ }
+ }, [uid, currentWorkspaceId, dispatch])
+
useEffect(() => {
if (prevStatus !== status) {
let isRunFinished = false
@@ -163,7 +202,15 @@ export function useRunPipeline() {
if (status === RUN_STATUS.START_SUCCESS) {
dispatch(getExperiments())
} else if (status === RUN_STATUS.FINISHED) {
- enqueueSnackbar("Workflow finished", { variant: "success" })
+ // Show different message based on batch run flag
+ const message = isBatchRun ? "Batch run started" : "Workflow finished"
+ enqueueSnackbar(message, { variant: "success" })
+
+ // Update flowchart for batch run
+ if (isBatchRun) {
+ handleBatchRunCompletion()
+ }
+
isRunFinished = true
dispatch(getExperiments())
} else if (status === RUN_STATUS.ABORTED) {
@@ -183,7 +230,14 @@ export function useRunPipeline() {
setPrevStatus(status)
}
- }, [dispatch, status, prevStatus, enqueueSnackbar])
+ }, [
+ dispatch,
+ status,
+ prevStatus,
+ enqueueSnackbar,
+ isBatchRun,
+ handleBatchRunCompletion,
+ ])
return {
filePathIsUndefined,
@@ -192,6 +246,7 @@ export function useRunPipeline() {
status,
runDisabled,
handleRunPipeline,
+ handleBatchRunPipeline,
handleRunPipelineByUid,
handleCancelPipeline,
}
diff --git a/frontend/src/store/slice/Pipeline/PipelineSelectors.ts b/frontend/src/store/slice/Pipeline/PipelineSelectors.ts
index dbbc001ac6..997f62f352 100644
--- a/frontend/src/store/slice/Pipeline/PipelineSelectors.ts
+++ b/frontend/src/store/slice/Pipeline/PipelineSelectors.ts
@@ -212,3 +212,7 @@ export const selectPipelineNodeResultOutputFileDataType =
throw new Error(`key error. outputKey:${outputKey}`)
}
}
+
+export const selectPipelineIsBatchRun = (state: RootState) => {
+ return state.pipeline.isBatchRun
+}
diff --git a/frontend/src/store/slice/Pipeline/PipelineSlice.ts b/frontend/src/store/slice/Pipeline/PipelineSlice.ts
index 4c25a89201..b825ab4251 100644
--- a/frontend/src/store/slice/Pipeline/PipelineSlice.ts
+++ b/frontend/src/store/slice/Pipeline/PipelineSlice.ts
@@ -7,6 +7,7 @@ import {
pollRunResult,
run,
runByCurrentUid,
+ batchRun,
} from "store/slice/Pipeline/PipelineActions"
import {
Pipeline,
@@ -31,6 +32,7 @@ export const initialState: Pipeline = {
status: RUN_STATUS.START_UNINITIALIZED,
},
runBtn: RUN_BTN_OPTIONS.RUN_NEW,
+ isBatchRun: false,
}
export const pipelineSlice = createSlice({
@@ -123,6 +125,13 @@ export const pipelineSlice = createSlice({
state.run = {
status: RUN_STATUS.START_PENDING,
}
+ state.isBatchRun = false
+ })
+ .addMatcher(isAnyOf(batchRun.pending), (state) => {
+ state.run = {
+ status: RUN_STATUS.START_PENDING,
+ }
+ state.isBatchRun = true
})
.addMatcher(
isAnyOf(run.fulfilled, runByCurrentUid.fulfilled),
@@ -136,15 +145,32 @@ export const pipelineSlice = createSlice({
runPostData: { name: "", ...runPostData },
}
state.currentPipeline = {
- uid: action.payload,
+ uid: uid,
}
+ state.isBatchRun = false
},
)
- .addMatcher(isAnyOf(run.rejected, runByCurrentUid.rejected), (state) => {
+ .addMatcher(isAnyOf(batchRun.fulfilled), (state, action) => {
+ const runPostData = action.meta.arg.runPostData
+ const uid = action.payload
state.run = {
- status: RUN_STATUS.START_ERROR,
+ uid,
+ status: RUN_STATUS.FINISHED,
+ runResult: getInitialRunResult({ ...runPostData }),
+ runPostData: { ...runPostData },
+ }
+ state.currentPipeline = {
+ uid: uid,
}
})
+ .addMatcher(
+ isAnyOf(run.rejected, runByCurrentUid.rejected, batchRun.rejected),
+ (state) => {
+ state.run = {
+ status: RUN_STATUS.START_ERROR,
+ }
+ },
+ )
.addMatcher(
isAnyOf(fetchWorkflow.rejected, clearFlowElements),
() => initialState,
diff --git a/frontend/src/store/slice/Pipeline/PipelineType.ts b/frontend/src/store/slice/Pipeline/PipelineType.ts
index caaa567469..ee789a6273 100644
--- a/frontend/src/store/slice/Pipeline/PipelineType.ts
+++ b/frontend/src/store/slice/Pipeline/PipelineType.ts
@@ -9,6 +9,7 @@ export type Pipeline = {
uid: string
}
runBtn: RUN_BTN_TYPE
+ isBatchRun?: boolean
}
export const RUN_STATUS = {
diff --git a/frontend/src/store/slice/Workspace/WorkspaceSelector.ts b/frontend/src/store/slice/Workspace/WorkspaceSelector.ts
index afa2406b58..f9941c1afb 100644
--- a/frontend/src/store/slice/Workspace/WorkspaceSelector.ts
+++ b/frontend/src/store/slice/Workspace/WorkspaceSelector.ts
@@ -35,3 +35,6 @@ export const selectIsWorkspaceOwner = (state: RootState) =>
selectModeStandalone(state)
? true
: state.workspace.currentWorkspace.ownerId === state.user.currentUser?.id
+
+export const selectCurrentWorkspaceType = (state: RootState) =>
+ state.workspace?.currentWorkspace?.type
diff --git a/frontend/src/store/slice/Workspace/WorkspaceSlice.ts b/frontend/src/store/slice/Workspace/WorkspaceSlice.ts
index c9e8ae31ac..5797f4617e 100644
--- a/frontend/src/store/slice/Workspace/WorkspaceSlice.ts
+++ b/frontend/src/store/slice/Workspace/WorkspaceSlice.ts
@@ -71,6 +71,7 @@ export const workspaceSlice = createSlice({
state.currentWorkspace.workspaceId = action.payload.id
state.currentWorkspace.workspaceName = action.payload.name
state.currentWorkspace.ownerId = action.payload.user.id
+ state.currentWorkspace.type = action.payload.type
state.loading = false
})
.addCase(getWorkspaceList.fulfilled, (state, action) => {
diff --git a/frontend/src/store/slice/Workspace/WorkspaceType.ts b/frontend/src/store/slice/Workspace/WorkspaceType.ts
index 38e49b561b..f3f11cd255 100644
--- a/frontend/src/store/slice/Workspace/WorkspaceType.ts
+++ b/frontend/src/store/slice/Workspace/WorkspaceType.ts
@@ -6,6 +6,7 @@ export const WORKSPACE_SLICE_NAME = "workspace"
export type ItemsWorkspace = {
id: number
name: string
+ type: number
user: {
id: number
name: string
@@ -33,6 +34,7 @@ export type Workspace = {
workspaceName?: string
selectedTab: number
ownerId?: number
+ type?: number
}
loading: boolean
listUserShare?: ListUserShareWorkspaceDTO
diff --git a/frontend/src/store/test/record/RecordReproduce.test.ts b/frontend/src/store/test/record/RecordReproduce.test.ts
index 3c7d9825a2..2c5b0b8b5e 100644
--- a/frontend/src/store/test/record/RecordReproduce.test.ts
+++ b/frontend/src/store/test/record/RecordReproduce.test.ts
@@ -163,6 +163,7 @@ describe("RecordReproduce", () => {
algorithmNode: {
dummy_image2image_c8tqfxw0mq: {
dataFilterParam: undefined,
+ draftDataFilterParam: undefined,
name: "dummy_image2image",
functionPath: "dummy/dummy_image2image",
isUpdate: false,
@@ -177,6 +178,7 @@ describe("RecordReproduce", () => {
},
dummy_image2image8time_4mrz8h7hyk: {
dataFilterParam: undefined,
+ draftDataFilterParam: undefined,
name: "dummy_image2image8time",
functionPath: "dummy/dummy_image2image8time",
isUpdate: false,
@@ -430,6 +432,7 @@ describe("RecordReproduce", () => {
},
runBtn: RUN_BTN_OPTIONS.RUN_ALREADY,
currentPipeline: { uid: uid },
+ isBatchRun: false,
},
workspace: {
listUserShare: undefined,
diff --git a/studio/alembic/versions/86201451bfdd_add_workspaces_attributes.py b/studio/alembic/versions/86201451bfdd_add_workspaces_attributes.py
new file mode 100644
index 0000000000..3246d55669
--- /dev/null
+++ b/studio/alembic/versions/86201451bfdd_add_workspaces_attributes.py
@@ -0,0 +1,40 @@
+"""add-workspaces-attributes
+
+Revision ID: 86201451bfdd
+Revises: 0b3a8e2ca9c1
+Create Date: 2025-08-12 10:17:41.416774
+
+"""
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "86201451bfdd"
+down_revision = "0b3a8e2ca9c1"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ columns_to_add = [
+ sa.Column(
+ "type",
+ sa.Integer(),
+ server_default="0",
+ nullable=False,
+ ),
+ ]
+
+ # Execute add_column to experiment_records in bulk
+ for column in columns_to_add:
+ op.add_column("workspaces", column)
+
+
+def downgrade() -> None:
+ columns_to_drop = [
+ "type",
+ ]
+
+ # Execute drop_column to experiment_records in bulk
+ for column in columns_to_drop:
+ op.drop_column("workspaces", column)
diff --git a/studio/app/common/core/workflow/workflow.py b/studio/app/common/core/workflow/workflow.py
index 35defa2bcd..b6a23ee6bf 100644
--- a/studio/app/common/core/workflow/workflow.py
+++ b/studio/app/common/core/workflow/workflow.py
@@ -1,3 +1,4 @@
+import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Union
@@ -66,6 +67,35 @@ class NodeType:
ALGO: str = "AlgorithmNode"
+@dataclass
+class BatchInputNodeType:
+ BATCH_IMAGE: str = "BatchImageFileNode"
+ BATCH_CSV: str = "BatchCsvFileNode"
+ BATCH_FLUO: str = "BatchFluoFileNode"
+ BATCH_BEHAVIOR: str = "BatchBehaviorFileNode"
+ BATCH_HDF5: str = "BatchHDF5FileNode"
+ BATCH_MATLAB: str = "BatchMatlabFileNode"
+ BATCH_MICROSCOPE: str = "BatchMicroscopeFileNode"
+
+ @staticmethod
+ def is_batch_input_node(node_type: str) -> bool:
+ return re.match("Batch.*FileNode", node_type)
+
+ @classmethod
+ def refer_corresponding_node_type(cls, batch_node_type: str) -> list:
+ RELATION_TO_NODE_TYPES = {
+ cls.BATCH_IMAGE: (NodeType.IMAGE, FILETYPE.IMAGE),
+ cls.BATCH_CSV: (NodeType.CSV, FILETYPE.CSV),
+ cls.BATCH_FLUO: (NodeType.FLUO, FILETYPE.CSV),
+ cls.BATCH_BEHAVIOR: (NodeType.BEHAVIOR, FILETYPE.CSV),
+ cls.BATCH_HDF5: (NodeType.HDF5, FILETYPE.HDF5),
+ cls.BATCH_MATLAB: (NodeType.MATLAB, FILETYPE.MATLAB),
+ cls.BATCH_MICROSCOPE: (NodeType.MICROSCOPE, FILETYPE.MICROSCOPE),
+ }
+
+ return RELATION_TO_NODE_TYPES.get(batch_node_type)
+
+
class NodeTypeUtil:
@staticmethod
def check_nodetype(node_type: str) -> str:
diff --git a/studio/app/common/core/workflow/workflow_batch_runner.py b/studio/app/common/core/workflow/workflow_batch_runner.py
new file mode 100644
index 0000000000..f4d4a3f3ae
--- /dev/null
+++ b/studio/app/common/core/workflow/workflow_batch_runner.py
@@ -0,0 +1,161 @@
+import copy
+import time
+from typing import List
+
+from fastapi import BackgroundTasks
+
+from studio.app.common.core.logger import AppLogger
+from studio.app.common.core.workflow.workflow import BatchInputNodeType, RunItem
+from studio.app.common.core.workflow.workflow_runner import WorkflowRunner
+
+logger = AppLogger.get_logger()
+
+
+class WorkflowBatchRunner:
+ def __init__(self, workspace_id: str, unique_id: str, runItem: RunItem) -> None:
+ self.workspace_id = workspace_id
+ self.unique_id = unique_id
+ self.runItem = runItem
+
+ def run_batch_workflow(self, background_tasks: BackgroundTasks):
+ # ------------------------------------------------------------
+ # Save Batch Run Template Workflow
+ # ------------------------------------------------------------
+ WorkflowRunner(
+ self.workspace_id, self.unique_id, self.runItem
+ ).finish_workflow_without_run()
+
+ # ------------------------------------------------------------
+ # Process each Batch Run Workflows
+ # #1) Data Construction
+ # ------------------------------------------------------------
+ batch_runItems = self.__build_batch_run_items()
+
+ # ------------------------------------------------------------
+ # Process each Batch Run Workflows
+ # #2) Start multiple workflows
+ # ------------------------------------------------------------
+ self.__run_batch_workflows(batch_runItems, background_tasks)
+
+ def __build_batch_run_items(self) -> List[RunItem]:
+ """
+ Building Data (RunItem) for Batch Workflows
+ """
+
+ base_unique_id = self.unique_id
+ runItem = self.runItem
+
+ # Search for batch input nodes
+ batch_input_files = {}
+ batch_input_counts = {}
+ for node_id, node in runItem.nodeDict.items():
+ if BatchInputNodeType.is_batch_input_node(node.type):
+ data_paths = getattr(
+ getattr(node, "data", None), "path", None
+ ) # get `node.data.path`
+ batch_input_record = {node_id: node}
+ batch_input_files[node_id] = (
+ data_paths if type(data_paths) is list else [data_paths]
+ )
+ batch_input_counts[node_id] = (
+ len(data_paths) if type(data_paths) is list else 1
+ )
+
+ # Validations
+ assert batch_input_files, "No batch input nodes specified."
+ batch_input_counts_min = min(batch_input_counts.values())
+ batch_input_counts_max = max(batch_input_counts.values())
+ if batch_input_counts_min != batch_input_counts_max:
+ logger.error(
+ "The number of input files in the batch nodes does not match. [%s]",
+ batch_input_counts,
+ )
+ assert False, (
+ "The number of input files in the batch nodes does not match."
+ f" [{batch_input_counts_min} - {batch_input_counts_max}]"
+ )
+ batch_input_fixed_count = batch_input_counts_max
+ del batch_input_counts_min, batch_input_counts_max
+
+ # Transform batch input data paths
+ # into a structure suitable for batch processing.
+ batch_input_records = []
+ for idx in range(batch_input_fixed_count):
+ batch_input_record = {}
+ for node_id, data_paths in batch_input_files.items():
+ batch_input_record[node_id] = data_paths[idx]
+
+ batch_input_records.append(batch_input_record)
+
+ # Build workflow execution data (RunItem type data)
+ batch_runItems: List[RunItem] = []
+ for idx, batch_input_record in enumerate(batch_input_records):
+ # Duplicate and use the original RunItem
+ new_run_item = copy.deepcopy(runItem)
+
+ new_run_item.name = f"{new_run_item.name} ({base_unique_id} - {idx+1})"
+
+ # Scan batch input records
+ for node_id, data_path in batch_input_record.items():
+ node_type = new_run_item.nodeDict[node_id].type
+
+ # Construct RunItem parameters
+ new_run_item.nodeDict[node_id].data.path = (
+ [data_path]
+ if node_type == BatchInputNodeType.BATCH_IMAGE
+ else data_path
+ )
+ new_run_item.nodeDict[node_id].data.label = data_path
+
+ # Replace node type with corresponding standard node type.
+ normal_node_type = BatchInputNodeType.refer_corresponding_node_type(
+ node_type
+ )
+ assert normal_node_type, f"Invalid batch node type: {node_type}"
+ new_run_item.nodeDict[node_id].type = normal_node_type[0]
+ new_run_item.nodeDict[node_id].data.fileType = normal_node_type[1]
+
+ batch_runItems.append(new_run_item)
+
+ return batch_runItems
+
+ def __run_batch_workflows(
+ self,
+ batch_runItems: List[RunItem],
+ background_tasks: BackgroundTasks,
+ ):
+ """
+ Run each Batch Workflows
+ Simply register all workflows to background_tasks for parallel execution.
+ The actual parallel execution is handled by ProcessPoolExecutor
+ in snakemake_executor.
+ """
+
+ workspace_id = self.workspace_id
+
+ # Wait a short time before starting a batch run
+ time.sleep(1)
+
+ # Register all workflows to background_tasks
+ # They will be executed in parallel automatically
+ workflow_ids = []
+ for idx, run_item in enumerate(batch_runItems):
+ unique_id = WorkflowRunner.create_workflow_unique_id()
+ WorkflowRunner(workspace_id, unique_id, run_item).run_workflow(
+ background_tasks
+ )
+ workflow_ids.append(unique_id)
+
+ logger.info(
+ "Registered workflow %d/%d (uid: %s)",
+ idx + 1,
+ len(batch_runItems),
+ unique_id,
+ )
+
+ logger.info(
+ "All %d workflows have been registered for execution",
+ len(batch_runItems),
+ )
+
+ return workflow_ids
diff --git a/studio/app/common/models/workspace.py b/studio/app/common/models/workspace.py
index 9e323a124f..65007f117d 100644
--- a/studio/app/common/models/workspace.py
+++ b/studio/app/common/models/workspace.py
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import List, Optional
-from sqlalchemy.dialects.mysql import BIGINT
+from sqlalchemy.dialects.mysql import BIGINT, TINYINT
from sqlalchemy.sql.functions import current_timestamp
from sqlmodel import Column, Field, ForeignKey, Relationship, String, UniqueConstraint
@@ -36,6 +36,9 @@ class Workspace(Base, TimestampMixin, table=True):
BIGINT(unsigned=True), ForeignKey("users.id", name="user"), nullable=False
),
)
+ type: int = Field(
+ sa_column=Column(TINYINT(unsigned=True), nullable=False),
+ )
deleted: bool = Field(nullable=False)
input_data_usage: int = Field(
sa_column=Column(
diff --git a/studio/app/common/routers/run.py b/studio/app/common/routers/run.py
index 4701854e7c..6e64e85457 100644
--- a/studio/app/common/routers/run.py
+++ b/studio/app/common/routers/run.py
@@ -9,6 +9,7 @@
NodeItem,
RunItem,
)
+from studio.app.common.core.workflow.workflow_batch_runner import WorkflowBatchRunner
from studio.app.common.core.workflow.workflow_filter import WorkflowNodeDataFilter
from studio.app.common.core.workflow.workflow_result import (
WorkflowMonitor,
@@ -164,3 +165,38 @@ async def apply_filter(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to filter data.",
)
+
+
+@router.post(
+ "/util/batch_run/{workspace_id}",
+ response_model=str,
+ dependencies=[Depends(is_workspace_owner)],
+)
+async def batch_run(
+ workspace_id: str, runItem: RunItem, background_tasks: BackgroundTasks
+):
+ try:
+ new_unique_id = WorkflowRunner.create_workflow_unique_id()
+ WorkflowBatchRunner(workspace_id, new_unique_id, runItem).run_batch_workflow(
+ background_tasks
+ )
+
+ logger.info("Start processing batch workflows.")
+
+ return new_unique_id
+
+ except KeyError as e:
+ logger.error(e, exc_info=True)
+ # Pass through the specific error message for KeyErrors
+ raise HTTPException(
+ # Changed to 422 since it's a client configuration issue
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+ detail=str(e).strip('"'), # Remove quotes from the KeyError message
+ )
+
+ except Exception as e:
+ logger.error(e, exc_info=True)
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to run workflow.",
+ )
diff --git a/studio/app/common/schemas/workspace.py b/studio/app/common/schemas/workspace.py
index 7b6d181314..87f8d275d6 100644
--- a/studio/app/common/schemas/workspace.py
+++ b/studio/app/common/schemas/workspace.py
@@ -10,6 +10,7 @@ class Workspace(BaseModel):
id: Optional[int]
name: str
user: Optional[UserInfo]
+ type: int
shared_count: int
created_at: Optional[datetime]
updated_at: Optional[datetime]
@@ -22,6 +23,7 @@ class Config:
class WorkspaceCreate(BaseModel):
name: str
+ type: Optional[int]
class WorkspaceUpdate(WorkspaceCreate):