diff --git a/frontend/src/components/Workspace/Experiment/ExperimentTable.tsx b/frontend/src/components/Workspace/Experiment/ExperimentTable.tsx index 27b329c48..549c29422 100644 --- a/frontend/src/components/Workspace/Experiment/ExperimentTable.tsx +++ b/frontend/src/components/Workspace/Experiment/ExperimentTable.tsx @@ -121,6 +121,7 @@ const TableImple = memo(function TableImple() { const dispatch = useDispatch() const [checkedList, setCheckedList] = useState([]) const [open, setOpen] = useState(false) + const [openCopy, setOpenCopy] = useState(false) const isRunning = useSelector((state: RootState) => { const currentUid = selectPipelineLatestUid(state) const isPending = selectPipelineIsStartedSuccess(state) @@ -141,6 +142,10 @@ const TableImple = memo(function TableImple() { } const onClickCopy = () => { + setOpenCopy(true) + } + + const onClickOkCopy = () => { dispatch(copyExperimentByList(checkedList)) .unwrap() .then(() => { @@ -150,6 +155,8 @@ const TableImple = memo(function TableImple() { .catch(() => { enqueueSnackbar("Failed to copy", { variant: "error" }) }) + setCheckedList([]) + setOpenCopy(false) } const onCheckBoxClick = (uid: string) => { @@ -228,6 +235,17 @@ const TableImple = memo(function TableImple() { {checkedList.length} selected )} + )} - + + {checkedList.map((uid) => ( + + ・{experimentList[uid].name} ({uid}) + + ))} + + } + iconType="warning" + confirmLabel="copy" + /> { - state.loading = false - }) + .addMatcher( + isAnyOf(copyExperimentByList.fulfilled, copyExperimentByList.rejected), + (state) => { + state.loading = false + }, + ) .addMatcher(isAnyOf(copyExperimentByList.pending), (state) => { state.loading = true }) - .addMatcher(isAnyOf(copyExperimentByList.rejected), (state) => { - state.loading = false - }) }, }) diff --git a/studio/app/common/core/experiment/experiment_writer.py b/studio/app/common/core/experiment/experiment_writer.py index 2c44f7d2d..288114692 100644 --- a/studio/app/common/core/experiment/experiment_writer.py +++ b/studio/app/common/core/experiment/experiment_writer.py @@ -124,36 +124,6 @@ def delete_data(self) -> bool: return result - def copy_data(self, new_unique_id: str) -> bool: - logger = AppLogger.get_logger() - output_filepath = join_filepath( - [DIRPATH.OUTPUT_DIR, self.workspace_id, self.unique_id] - ) - new_output_filepath = join_filepath( - [DIRPATH.OUTPUT_DIR, self.workspace_id, new_unique_id] - ) - - shutil.copytree(output_filepath, new_output_filepath) - - # Update unique_id in experiment.yml - expt_filepath = join_filepath([new_output_filepath, DIRPATH.EXPERIMENT_YML]) - try: - with open(expt_filepath, "r") as f: - config = yaml.safe_load(f) - - config["unique_id"] = new_unique_id - original_name = config["name"] - config["name"] = f"{original_name}_copy" - - with open(expt_filepath, "w") as f: - yaml.safe_dump(config, f) - - logger.info("Unique ID updated successfully.") - return True - except Exception as e: - logger.info(f"Error updating unique_id: {e}") - return False - def rename(self, new_name: str) -> ExptConfig: filepath = join_filepath( [ @@ -187,3 +157,59 @@ def rename(self, new_name: str) -> ExptConfig: nwb=config.get("nwb"), snakemake=config.get("snakemake"), ) + + def copy_data(self, new_unique_id: str) -> bool: + logger = AppLogger.get_logger() + + try: + # Define file paths + output_filepath = join_filepath( + [DIRPATH.OUTPUT_DIR, self.workspace_id, self.unique_id] + ) + new_output_filepath = join_filepath( + [DIRPATH.OUTPUT_DIR, self.workspace_id, new_unique_id] + ) + + # Copy directory + shutil.copytree(output_filepath, new_output_filepath) + + # Update experiment.yml + if not self._update_experiment_config(new_output_filepath, new_unique_id): + logger.error("Failed to update experiment.yml after copying.") + return False + + logger.info(f"Data successfully copied to {new_output_filepath}") + return True + + except Exception as e: + logger.error(f"Error copying data: {e}") + raise Exception("Error copying data") + + def _update_experiment_config( + self, new_output_filepath: str, new_unique_id: str + ) -> bool: + logger = AppLogger.get_logger() + expt_filepath = join_filepath([new_output_filepath, DIRPATH.EXPERIMENT_YML]) + + try: + with open(expt_filepath, "r") as file: + config = yaml.safe_load(file) + + if not config: + logger.error(f"Empty or invalid YAML in {expt_filepath}.") + return False + + # Update config fields + config["unique_id"] = new_unique_id + config["name"] = f"{config.get('name', 'experiment')}_copy" + + # Write back to the file + with open(expt_filepath, "w") as file: + yaml.safe_dump(config, file) + + logger.info(f"experiment.yml updated successfully at {expt_filepath}") + return True + + except Exception as e: + logger.error(f"Error updating experiment.yml: {e}") + raise Exception("Error updating experiment.yml") diff --git a/studio/app/common/routers/experiment.py b/studio/app/common/routers/experiment.py index 43923bfd2..850aa0a7d 100644 --- a/studio/app/common/routers/experiment.py +++ b/studio/app/common/routers/experiment.py @@ -15,7 +15,7 @@ is_workspace_available, is_workspace_owner, ) -from studio.app.common.schemas.experiment import DeleteItem, RenameItem +from studio.app.common.schemas.experiment import CopyItem, DeleteItem, RenameItem from studio.app.dir_path import DIRPATH router = APIRouter(prefix="/experiments", tags=["experiments"]) @@ -114,13 +114,13 @@ async def delete_experiment_list(workspace_id: str, deleteItem: DeleteItem): response_model=bool, dependencies=[Depends(is_workspace_owner)], ) -async def copy_experiment_list(workspace_id: str, copyItem: DeleteItem): +async def copy_experiment_list(workspace_id: str, copyItem: CopyItem): logger = AppLogger.get_logger() logger.info(f"workspace_id: {workspace_id}, copyItem: {copyItem}") created_unique_ids = [] # Keep track of successfully created unique IDs try: for unique_id in copyItem.uidList: - logger.info(f"unique_id: {unique_id}") + logger.info(f"copying item with unique_id of {unique_id}") new_unique_id = WorkflowRunner.create_workflow_unique_id() ExptDataWriter( workspace_id,