diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml index c3038c99..7ef9d7bf 100644 --- a/docker-compose-dev.yaml +++ b/docker-compose-dev.yaml @@ -309,7 +309,7 @@ services: - DOMINO_DB_HOST=domino_postgres - DOMINO_DB_PORT=5432 - DOMINO_DB_NAME=postgres - - DOMINO_DEFAULT_PIECES_REPOSITORY_VERSION=0.4.2 + - DOMINO_DEFAULT_PIECES_REPOSITORY_VERSION=0.4.3 - DOMINO_DEFAULT_PIECES_REPOSITORY_TOKEN=${DOMINO_DEFAULT_PIECES_REPOSITORY_TOKEN} - DOMINO_GITHUB_ACCESS_TOKEN_WORKFLOWS=${DOMINO_GITHUB_ACCESS_TOKEN_WORKFLOWS} - DOMINO_GITHUB_WORKFLOWS_REPOSITORY=${DOMINO_GITHUB_WORKFLOWS_REPOSITORY} diff --git a/frontend/src/components/CodeEditorInput/index.tsx b/frontend/src/components/CodeEditorInput/index.tsx index d787245d..e7f6cb20 100644 --- a/frontend/src/components/CodeEditorInput/index.tsx +++ b/frontend/src/components/CodeEditorInput/index.tsx @@ -7,11 +7,16 @@ import { useFormContext, } from "react-hook-form"; -const CodeEditorItem = React.forwardRef( - ({ ...register }, ref) => ( +interface Props { + language?: string; + placeholder?: string; +} + +const CodeEditorItem = React.forwardRef( + ({ language = "python", placeholder, ...register }, ref) => ( ( CodeEditorItem.displayName = "CodeEditorItem"; -interface Props { +interface CodeEditorInputProps { name: Path; + language?: string; + placeholder?: string; } -function CodeEditorInput({ name }: Props) { +function CodeEditorInput({ + name, + language, + placeholder, +}: CodeEditorInputProps) { const { control } = useFormContext(); return ( } + render={({ field }) => ( + + )} /> ); } diff --git a/frontend/src/components/Modal/index.tsx b/frontend/src/components/Modal/index.tsx new file mode 100644 index 00000000..56e938f8 --- /dev/null +++ b/frontend/src/components/Modal/index.tsx @@ -0,0 +1,81 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Grid, +} from "@mui/material"; +import React, { useCallback, useImperativeHandle, useState } from "react"; + +interface Props { + title: string; + content?: string | React.ReactNode; + + confirmFn?: () => void; + cancelFn?: () => void; +} + +export interface ModalRef { + open: () => void; + close: () => void; +} + +export const Modal = React.forwardRef( + ({ cancelFn, confirmFn, title, content }, ref) => { + const [isOpen, setIsOpen] = useState(false); + + const open = () => { + setIsOpen(true); + }; + + const handleClose = useCallback(() => { + if (cancelFn) { + cancelFn(); + } + + setIsOpen(false); + }, []); + + const handleConfirm = useCallback(() => { + if (confirmFn) { + confirmFn(); + } + + setIsOpen(false); + }, []); + + useImperativeHandle(ref, () => ({ + open, + close: handleClose, + })); + + return ( + + {title} + + {content} + + + + {cancelFn && ( + + + + )} + + + + + + + ); + }, +); + +Modal.displayName = "Modal"; diff --git a/frontend/src/components/NewFeatureDialog/index.tsx b/frontend/src/components/NewFeatureDialog/index.tsx deleted file mode 100644 index 449507f7..00000000 --- a/frontend/src/components/NewFeatureDialog/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - Grid, -} from "@mui/material"; -import React from "react"; - -interface Props { - isOpen: boolean; - confirmFn: () => void; -} - -export const NewFeatureDialog: React.FC = ({ isOpen, confirmFn }) => { - return ( - - New feature - - - This feature is not ready yet! We launch new versions every time, - check out our changelog for more information ! - - - - - - - - - - - ); -}; diff --git a/frontend/src/components/WorkflowPanel/DefaultNode/index.tsx b/frontend/src/components/WorkflowPanel/DefaultNode/index.tsx index 6f351d66..e30c310a 100644 --- a/frontend/src/components/WorkflowPanel/DefaultNode/index.tsx +++ b/frontend/src/components/WorkflowPanel/DefaultNode/index.tsx @@ -71,7 +71,7 @@ export const CustomNode = memo(({ id, data, selected }) => { ...data.style.nodeStyle, display: "flex", flexDirection: "row", - justifyContent: "center", + justifyContent: "space-evenly", alignItems: "center", position: "relative", @@ -88,7 +88,7 @@ export const CustomNode = memo(({ id, data, selected }) => { backgroundColor: theme.palette.error.light, color: theme.palette.error.contrastText, }), - }; + } satisfies CSSProperties; }, [data, selected]); const { sourcePosition, targetPosition } = useMemo( diff --git a/frontend/src/components/WorkflowPanel/RunNode/index.tsx b/frontend/src/components/WorkflowPanel/RunNode/index.tsx index 6ee470b7..d3f844a4 100644 --- a/frontend/src/components/WorkflowPanel/RunNode/index.tsx +++ b/frontend/src/components/WorkflowPanel/RunNode/index.tsx @@ -88,7 +88,7 @@ const RunNode = memo(({ id, data, selected }) => { ...data.style.nodeStyle, display: "flex", flexDirection: "row", - justifyContent: "center", + justifyContent: "space-evenly", alignItems: "center", textAlign: "center", position: "relative", diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/index.tsx b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/index.tsx index 618da339..e70f6105 100644 --- a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/index.tsx +++ b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/index.tsx @@ -155,10 +155,40 @@ const PieceFormItem: React.FC = ({ "type" in schema && "widget" in schema && schema.type === "string" && - schema.widget === "codeeditor" + (schema.widget === "codeeditor" || schema.widget === "codeeditor-python") ) { inputElement = ( - name={`inputs.${itemKey}.value`} /> + + name={`inputs.${itemKey}.value`} + language="python" + placeholder="Enter Python code." + /> + ); + } else if ( + "type" in schema && + "widget" in schema && + schema.type === "string" && + schema.widget === "codeeditor-json" + ) { + inputElement = ( + + name={`inputs.${itemKey}.value`} + language="json" + placeholder="Enter JSON code." + /> + ); + } else if ( + "type" in schema && + "widget" in schema && + schema.type === "string" && + schema.widget === "codeeditor-sql" + ) { + inputElement = ( + + name={`inputs.${itemKey}.value`} + language="sql" + placeholder="Enter SQL code." + /> ); } else if ( "type" in schema && diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/index.tsx b/frontend/src/features/workflowEditor/components/SidebarForm/index.tsx index 936a57f5..dae41c48 100644 --- a/frontend/src/features/workflowEditor/components/SidebarForm/index.tsx +++ b/frontend/src/features/workflowEditor/components/SidebarForm/index.tsx @@ -33,7 +33,7 @@ const SidebarPieceForm: React.FC = (props) => { const { schema, formId, open, onClose, title } = props; const { - setForageWorkflowPiecesData, + setForageWorkflowPiecesDataById, fetchForageWorkflowPiecesDataById, setForageWorkflowPiecesOutputSchema, clearDownstreamDataById, @@ -141,10 +141,10 @@ const SidebarPieceForm: React.FC = (props) => { const saveData = useCallback(async () => { if (formId && open) { - await setForageWorkflowPiecesData(formId, data as IWorkflowPieceData); + await setForageWorkflowPiecesDataById(formId, data as IWorkflowPieceData); await updateOutputSchema(); } - }, [formId, open, setForageWorkflowPiecesData, data, updateOutputSchema]); + }, [formId, open, setForageWorkflowPiecesDataById, data, updateOutputSchema]); // load forage useEffect(() => { diff --git a/frontend/src/features/workflowEditor/components/SidebarSettingsForm/index.tsx b/frontend/src/features/workflowEditor/components/SidebarSettingsForm/index.tsx index 1c6a29b5..093da384 100644 --- a/frontend/src/features/workflowEditor/components/SidebarSettingsForm/index.tsx +++ b/frontend/src/features/workflowEditor/components/SidebarSettingsForm/index.tsx @@ -15,7 +15,14 @@ import { storageSourcesAWS, storageSourcesLocal, } from "features/workflowEditor/context/types"; -import { useCallback, useEffect, useState } from "react"; +import { + useCallback, + useEffect, + useState, + forwardRef, + useImperativeHandle, + type ForwardedRef, +} from "react"; import { FormProvider, useForm } from "react-hook-form"; import { yupResolver } from "utils"; import * as yup from "yup"; @@ -100,181 +107,203 @@ export const WorkflowSettingsFormSchema: ValidationSchema = yup.object().shape({ }), }); -const SidebarSettingsForm = (props: ISidebarSettingsFormProps) => { - const { open, onClose } = props; +export interface SidebarSettingsFormRef { + loadData: () => Promise; +} - const { fetchWorkflowSettingsData, setWorkflowSettingsData } = - useWorkflowsEditor(); +const SidebarSettingsForm = forwardRef< + SidebarSettingsFormRef, + ISidebarSettingsFormProps +>( + ( + props: ISidebarSettingsFormProps, + ref: ForwardedRef, + ) => { + const { open, onClose } = props; - const resolver = yupResolver(WorkflowSettingsFormSchema); - const methods = useForm({ mode: "onChange", resolver }); - const { register, watch, reset, trigger, getValues } = methods; - const formData = watch(); + const { fetchWorkflowSettingsData, setWorkflowSettingsData } = + useWorkflowsEditor(); - const [loaded, setLoaded] = useState(false); + const resolver = yupResolver(WorkflowSettingsFormSchema); + const methods = useForm({ mode: "onChange", resolver }); + const { register, watch, reset, trigger, getValues } = methods; + const formData = watch(); - const validate = useCallback(() => { - if (loaded) void trigger(); - }, [loaded, trigger]); + const [loaded, setLoaded] = useState(false); - useEffect(() => { - validate(); - }, [validate]); + const validate = useCallback(() => { + if (loaded) void trigger(); + }, [loaded, trigger]); - const loadData = useCallback(async () => { - const data = await fetchWorkflowSettingsData(); - if (Object.keys(data).length === 0) { - reset(defaultSettingsData); - } else { - reset(data); - } - setLoaded(true); - }, [reset, fetchWorkflowSettingsData]); + useEffect(() => { + validate(); + }, [validate]); - const saveData = useCallback(async () => { - if (open) { - await setWorkflowSettingsData(formData); - } - }, [formData, open, setWorkflowSettingsData]); + const loadData = useCallback(async () => { + const data = await fetchWorkflowSettingsData(); + if (Object.keys(data).length === 0) { + reset(defaultSettingsData); + await setWorkflowSettingsData(defaultSettingsData); + } else { + reset(data); + } + setLoaded(true); + }, [reset, fetchWorkflowSettingsData, setWorkflowSettingsData]); + + const saveData = useCallback(async () => { + if (open) { + await setWorkflowSettingsData(formData); + } + }, [formData, open, setWorkflowSettingsData]); - useEffect(() => { - if (open) { + useEffect(() => { void loadData(); - } - }, [open, loadData]); + }, [open, loadData]); - useEffect(() => { - void saveData(); - }, [saveData]); + useEffect(() => { + void saveData(); + }, [saveData]); - if (Object.keys(formData).length === 0) { - return null; - } + useImperativeHandle( + ref, + () => { + return { + loadData, + }; + }, + [loadData], + ); - return ( - - - - - Settings - - - - - - - - - - - - - - - - - {getValues().config.endDateType === - endDateTypes.UserDefined && ( - - - - )} - - - - - - + if (Object.keys(formData).length === 0) { + return null; + } + + return ( + + + - Storage + Settings - - - - - - - {formData.storage.storageSource === storageSourcesAWS.AWS_S3 ? ( - <> - - - - - + + + + + + + + + + + + + - - ) : null} + {getValues().config.endDateType === + endDateTypes.UserDefined && ( + + + + )} + + + + + + + + Storage + - + + + + + + {formData.storage.storageSource === storageSourcesAWS.AWS_S3 ? ( + <> + + + + + + + + ) : null} + + + - - - ); -}; -export default SidebarSettingsForm; + + ); + }, +); +SidebarSettingsForm.displayName = "SidebarSettingsForm"; +export { SidebarSettingsForm }; diff --git a/frontend/src/features/workflowEditor/components/WorkflowEditor.tsx b/frontend/src/features/workflowEditor/components/WorkflowEditor.tsx index 58c21f8e..b783d81e 100644 --- a/frontend/src/features/workflowEditor/components/WorkflowEditor.tsx +++ b/frontend/src/features/workflowEditor/components/WorkflowEditor.tsx @@ -1,10 +1,12 @@ import { Settings as SettingsSuggestIcon } from "@mui/icons-material"; import ClearIcon from "@mui/icons-material/Clear"; import DownloadIcon from "@mui/icons-material/Download"; +import IosShareIcon from "@mui/icons-material/IosShare"; import SaveIcon from "@mui/icons-material/Save"; -import { Button, Grid, Paper } from "@mui/material"; +import { Button, Grid, Paper, styled } from "@mui/material"; import { AxiosError } from "axios"; import Loading from "components/Loading"; +import { Modal, type ModalRef } from "components/Modal"; import { type WorkflowPanelRef, WorkflowPanel, @@ -15,11 +17,13 @@ import { useWorkflowsEditor } from "features/workflowEditor/context"; import { type DragEvent, useCallback, useRef, useState } from "react"; import { toast } from "react-toastify"; import { type Edge, type Node, type XYPosition } from "reactflow"; -import { yupResolver, useInterval } from "utils"; +import localForage from "services/config/localForage.config"; +import { yupResolver, useInterval, exportToJson } from "utils"; import { v4 as uuidv4 } from "uuid"; import * as yup from "yup"; import { type IWorkflowPieceData, storageAccessModes } from "../context/types"; +import { type DominoWorkflowForage } from "../context/workflowsEditor"; import { containerResourcesSchema } from "../schemas/containerResourcesSchemas"; import { extractDefaultInputValues, extractDefaultValues } from "../utils"; @@ -28,8 +32,10 @@ import SidebarPieceForm from "./SidebarForm"; import { ContainerResourceFormSchema } from "./SidebarForm/ContainerResourceForm"; import { createInputsSchemaValidation } from "./SidebarForm/PieceForm/validation"; import { storageFormSchema } from "./SidebarForm/StorageForm"; -import SidebarSettingsForm, { +import { + SidebarSettingsForm, WorkflowSettingsFormSchema, + type SidebarSettingsFormRef, } from "./SidebarSettingsForm"; /** @@ -42,8 +48,21 @@ const getId = (module_name: string) => { return `${module_name}_${uuidv4()}`; }; +const VisuallyHiddenInput = styled("input")({ + clip: "rect(0 0 0 0)", + clipPath: "inset(50%)", + height: 1, + overflow: "hidden", + position: "absolute", + bottom: 0, + left: 0, + whiteSpace: "nowrap", + width: 1, +}); + export const WorkflowsEditorComponent: React.FC = () => { const workflowPanelRef = useRef(null); + const sidebarSettingsRef = useRef(null); const [sidebarSettingsDrawer, setSidebarSettingsDrawer] = useState(false); const [sidebarPieceDrawer, setSidebarPieceDrawer] = useState(false); const [formId, setFormId] = useState(""); @@ -55,6 +74,9 @@ export const WorkflowsEditorComponent: React.FC = () => { "horizontal", ); + const incompatiblePiecesModalRef = useRef(null); + const [incompatiblesPieces, setIncompatiblesPieces] = useState([]); + const { workspace } = useWorkspaces(); const saveDataToLocalForage = useCallback(async () => { @@ -70,7 +92,7 @@ export const WorkflowsEditorComponent: React.FC = () => { const { clearForageData, - workflowsEditorBodyFromFlowchart, + generateWorkflowsEditorBodyParams, fetchWorkflowForage, handleCreateWorkflow, fetchForagePieceById, @@ -81,7 +103,8 @@ export const WorkflowsEditorComponent: React.FC = () => { removeForageWorkflowPiecesById, removeForageWorkflowPieceDataById, fetchWorkflowPieceById, - setForageWorkflowPiecesData, + setForageWorkflowPiecesDataById, + importWorkflowToForage, clearDownstreamDataById, setWorkflowEdges, setWorkflowNodes, @@ -153,7 +176,7 @@ export const WorkflowsEditorComponent: React.FC = () => { await validateWorkflowPiecesData(payload); await validateWorkflowSettings(payload); - const data = await workflowsEditorBodyFromFlowchart(); + const data = await generateWorkflowsEditorBodyParams(payload); await handleCreateWorkflow({ workspace_id: workspace?.id, ...data }); @@ -175,7 +198,7 @@ export const WorkflowsEditorComponent: React.FC = () => { handleCreateWorkflow, validateWorkflowPiecesData, validateWorkflowSettings, - workflowsEditorBodyFromFlowchart, + generateWorkflowsEditorBodyParams, workspace?.id, ]); @@ -183,8 +206,109 @@ export const WorkflowsEditorComponent: React.FC = () => { await clearForageData(); workflowPanelRef.current?.setEdges([]); workflowPanelRef.current?.setNodes([]); + await sidebarSettingsRef.current?.loadData(); }, [clearForageData]); + const handleExport = useCallback(async () => { + await saveDataToLocalForage(); + const payload = await fetchWorkflowForage(); + if (Object.keys(payload.workflowPieces).length === 0) { + toast.error("Workflow must have at least one piece to be exported."); + return; + } + exportToJson(payload, payload.workflowSettingsData?.config?.name); + }, []); + + const validateJsonImported = useCallback( + async (json: DominoWorkflowForage) => { + const getRepositories = function ( + workflowPieces: DominoWorkflowForage["workflowPieces"], + ) { + return [ + ...new Set( + Object.values(workflowPieces) + .reduce>((acc, next) => { + acc.push(next.source_image); + return acc; + }, []) + .filter((su) => !!su) as string[], + ), + ]; + }; + + const currentRepositories = [ + ...new Set( + Object.values((await localForage.getItem("pieces")) as any)?.map( + (p: any) => p?.source_image, + ), + ), + ]; + const incomeRepositories = getRepositories(json.workflowPieces); + + const differences = incomeRepositories.filter( + (x) => !currentRepositories.includes(x), + ); + + return differences.length ? differences : null; + }, + [fetchWorkflowForage], + ); + + const fileInputRef = useRef(null); + + const handleImport = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + + if (file) { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const jsonData = JSON.parse( + e.target?.result as string, + ) as DominoWorkflowForage; + + validateJsonImported(jsonData) + .then((diferences) => { + if (diferences) { + toast.error( + "Some repositories are missing or incompatible version", + ); + setIncompatiblesPieces(diferences); + incompatiblePiecesModalRef.current?.open(); + return; + } + + workflowPanelRef?.current?.setNodes(jsonData.workflowNodes); + workflowPanelRef?.current?.setEdges(jsonData.workflowEdges); + void importWorkflowToForage(jsonData); + }) + .catch((e) => { + console.log(e); + }); + + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } catch (error) { + console.error("Error parsing JSON file:", error); + } + }; + + reader.readAsText(file); + } + }, + [ + validateJsonImported, + workflowPanelRef, + importWorkflowToForage, + setIncompatiblesPieces, + incompatiblePiecesModalRef, + fileInputRef, + ], + ); + const onNodesDelete = useCallback( async (nodes: any) => { for (const node of nodes) { @@ -267,7 +391,10 @@ export const WorkflowsEditorComponent: React.FC = () => { inputs: defaultInputs, }; - await setForageWorkflowPiecesData(newNode.id, defaultWorkflowPieceData); + await setForageWorkflowPiecesDataById( + newNode.id, + defaultWorkflowPieceData, + ); return newNode; }, [ @@ -275,7 +402,7 @@ export const WorkflowsEditorComponent: React.FC = () => { fetchForagePieceById, setForageWorkflowPieces, getForageWorkflowPieces, - setForageWorkflowPiecesData, + setForageWorkflowPiecesDataById, ], ); @@ -344,11 +471,45 @@ export const WorkflowsEditorComponent: React.FC = () => { + + + +