From 5efae9e7aef23cb532d30b34299d2e2440349cd0 Mon Sep 17 00:00:00 2001 From: trex <307289341+ohdsi-trex@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:17:14 +0000 Subject: [PATCH] feat: add unified dataflow dialog --- .../Flow/ImportFlow/ImportFlowButton.tsx | 79 ---- .../Flow/SaveFlow/SaveFlowDialog.scss | 170 ++++++++- .../Flow/SaveFlow/SaveFlowDialog.tsx | 347 ++++++++++-------- .../Flow/SaveFlow/SaveNewFlowButton.tsx | 4 +- .../features/flow/containers/FlowLayout.tsx | 2 - .../data/export-import-dataflow.spec.ts | 213 +++++++---- 6 files changed, 480 insertions(+), 335 deletions(-) delete mode 100644 plugins/ui/apps/flow/src/features/flow/containers/Flow/ImportFlow/ImportFlowButton.tsx diff --git a/plugins/ui/apps/flow/src/features/flow/containers/Flow/ImportFlow/ImportFlowButton.tsx b/plugins/ui/apps/flow/src/features/flow/containers/Flow/ImportFlow/ImportFlowButton.tsx deleted file mode 100644 index 06e3e30be3..0000000000 --- a/plugins/ui/apps/flow/src/features/flow/containers/Flow/ImportFlow/ImportFlowButton.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { ChangeEvent, FC, useCallback, useRef } from "react"; -import { IconButton, Tooltip } from "@portal/components"; -import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined"; -import { dispatch } from "~/store"; -import { DataflowExportDto } from "~/features/flow/types"; -import { - markStatusAsDraft, - replaceEdges, - replaceNodes, - replaceVariables, - replaceImportLibs, - replaceDatabases, - replaceSchemas, -} from "~/features/flow/reducers"; -import { - sanitizeFlowEdges, - sanitizeFlowNodes, -} from "~/features/flow/utils"; - -export interface ImportFlowButtonProps {} - -export const ImportFlowButton: FC = () => { - const hiddenFileInput = useRef(null); - - const handleImport = useCallback(async () => { - hiddenFileInput.current && hiddenFileInput.current.click(); - }, [hiddenFileInput]); - - const handleFileOpen = useCallback((event: ChangeEvent) => { - const files = Array.from(event.target.files || []).map((file: any) => file); - if (files.length >= 1) { - const file = files[0]; - const reader = new FileReader(); - reader.onload = () => { - const jsonData = reader.result as string; - - try { - const json = JSON.parse(jsonData) as DataflowExportDto; - console.debug("JSON content:", json); - - const safeNodes = sanitizeFlowNodes(json.nodes); - const safeEdges = sanitizeFlowEdges(json.edges, safeNodes); - dispatch(replaceNodes(safeNodes)); - dispatch(replaceEdges(safeEdges)); - dispatch(replaceVariables(json.variables ?? [])); - dispatch(replaceImportLibs(json.importLibs ?? [])); - dispatch(replaceDatabases(json.databases ?? [])); - dispatch(replaceSchemas(json.schemas ?? [])); - dispatch(markStatusAsDraft()); - } catch (err) { - console.error("Error parsing JSON:", err); - } - }; - reader.readAsText(file); - } - }, []); - - return ( - -
- } - onClick={handleImport} - /> - { - (event.target as any).value = null; - }} - style={{ display: "none" }} - id="open-flow-json" - /> -
-
- ); -}; diff --git a/plugins/ui/apps/flow/src/features/flow/containers/Flow/SaveFlow/SaveFlowDialog.scss b/plugins/ui/apps/flow/src/features/flow/containers/Flow/SaveFlow/SaveFlowDialog.scss index e903603160..4db47e0a1e 100644 --- a/plugins/ui/apps/flow/src/features/flow/containers/Flow/SaveFlow/SaveFlowDialog.scss +++ b/plugins/ui/apps/flow/src/features/flow/containers/Flow/SaveFlow/SaveFlowDialog.scss @@ -1,30 +1,176 @@ @use '../../../../../theme/variables.scss' as *; .save-flow-dialog { + .MuiBackdrop-root { + background: rgba(0, 0, 0, 0.4); + } + + .alp-dialog__paper { + width: 600px; + height: 460px; + max-width: calc(100vw - 64px); + border-radius: 16px; + font-family: "IBM Plex Sans", sans-serif; + } + + .alp-dialog__title { + margin: 0; + padding: 24px 24px 12px; + + &-text { + color: $primary-color; + font-family: "IBM Plex Sans", sans-serif; + font-size: 18px; + font-weight: 600; + line-height: 1.2; + } + } + + .alp-dialog__body { + display: flex; + min-height: 0; + } + + &__form { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + } + &__content { - padding: 1.5em; + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + gap: 24px; + overflow-y: auto; + padding: 24px; + font-size: 16px; + line-height: 1.5; } - &__footer { - border-top: 1px solid $border-color; - padding: 1.5em; + .MuiInputBase-root, + .MuiSelect-select { + min-height: 48px; + box-sizing: border-box; + border: 1px solid $neutral-light-color; + border-radius: 8px; + } - &-actions { - justify-content: center; + &__mode { + display: flex; + gap: 24px; + margin: 0; + padding: 0; + border: 0; + + legend { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + } + + label { + display: flex; + align-items: center; + min-height: 42px; + gap: 8px; + cursor: pointer; + } + + input { + width: 24px; + height: 24px; + margin: 0; + accent-color: $primary-color; + } + } - button { - width: 150px; + &__file-input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + } + + &__drop-zone { + display: flex; + min-height: 128px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px; + border: 1px dashed $neutral-light-color; + border-radius: 8px; + color: $neutral-color; + text-align: center; + cursor: pointer; + + svg { + color: $primary-color; + font-size: 32px; + } + + button { + padding: 0; + border: 0; + background: none; + color: $primary-color; + font: inherit; + font-weight: 600; + text-decoration: underline; + cursor: pointer; + } + + &--active { + border-color: $primary-color; + background: $primary-extra-lightest-color; + } + + &--uploading { + cursor: progress; + } + + &--success { + border-color: var(--color-feedback-success, #00855f); + color: var(--color-feedback-success, #00855f); + + svg { + color: var(--color-feedback-success, #00855f); + } + } + + &--error { + border-color: var(--color-feedback-error, var(--color-feedback-alarm, #d53939)); + color: var(--color-feedback-error, var(--color-feedback-alarm, #d53939)); + + svg { + color: var(--color-feedback-error, var(--color-feedback-alarm, #d53939)); } } } + &__footer { + flex: 0 0 auto; + border-top: 1px solid $neutral-lighter-color; + background: #fbfbfd; + padding: 16px 24px; + + &-actions { + justify-content: flex-end; + } + } + d4l-snack-bar { position: initial; .snack-bar { - margin-bottom: 1.5em; - margin-left: -1.5em; - margin-right: -1.5em; + margin: 0; } } -} \ No newline at end of file +} diff --git a/plugins/ui/apps/flow/src/features/flow/containers/Flow/SaveFlow/SaveFlowDialog.tsx b/plugins/ui/apps/flow/src/features/flow/containers/Flow/SaveFlow/SaveFlowDialog.tsx index 5074d37e2b..42b84707a4 100644 --- a/plugins/ui/apps/flow/src/features/flow/containers/Flow/SaveFlow/SaveFlowDialog.tsx +++ b/plugins/ui/apps/flow/src/features/flow/containers/Flow/SaveFlow/SaveFlowDialog.tsx @@ -12,12 +12,17 @@ import { Snackbar, TextField, } from "@portal/components"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; +import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined"; import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query"; import React, { ChangeEvent, + DragEvent, FC, useCallback, useEffect, + useRef, useState, } from "react"; import { useSelector } from "react-redux"; @@ -37,7 +42,8 @@ import { useGetTemplatesQuery, useSaveDataflowMutation, } from "../../../slices"; -import { ErrorResponse, SaveDataflowDto } from "../../../types"; +import { DataflowExportDto, ErrorResponse, SaveDataflowDto } from "../../../types"; +import { sanitizeFlowEdges, sanitizeFlowNodes } from "../../../utils"; import "./SaveFlowDialog.scss"; export interface SaveFlowDialogProps extends DialogProps {} @@ -48,16 +54,45 @@ interface FormData { selectedTemplate: string; } +type AddDataflowMode = "create" | "import"; +type UploadState = "idle" | "uploading" | "success" | "error"; +type ImportedDataflow = SaveDataflowDto["dataflow"]; + const EMPTY_FORM_DATA: FormData = { name: "", comment: "", selectedTemplate: "", }; -export const SaveFlowDialog: FC = ({ - onClose, - ...props -}) => { +const getImportedDataflow = (value: unknown): ImportedDataflow => { + if (!value || typeof value !== "object") { + throw new Error("The selected file does not contain a dataflow."); + } + + const exported = value as Partial; + if (!Array.isArray(exported.nodes) || !Array.isArray(exported.edges)) { + throw new Error("The selected file is not a valid dataflow export."); + } + + const nodes = sanitizeFlowNodes(exported.nodes); + const edges = sanitizeFlowEdges(exported.edges, nodes); + + if (exported.nodes.length > 0 && nodes.length === 0) { + throw new Error("The selected file does not contain valid dataflow nodes."); + } + + return { + nodes, + edges, + variables: Array.isArray(exported.variables) ? exported.variables : [], + importLibs: Array.isArray(exported.importLibs) ? exported.importLibs : [], + databases: Array.isArray(exported.databases) ? exported.databases : [], + schemas: Array.isArray(exported.schemas) ? exported.schemas : [], + comment: "", + }; +}; + +export const SaveFlowDialog: FC = ({ onClose, ...props }) => { const saveFlowDialog = useSelector( (state: RootState) => state.flow.saveFlowDialog ); @@ -67,10 +102,9 @@ export const SaveFlowDialog: FC = ({ { skip: !saveFlowDialog.dataflowId } ); const [saveDataflow, { isLoading }] = useSaveDataflowMutation(); - const { data: templates = [], isLoading: templatesLoading } = - useGetTemplatesQuery(undefined, { - skip: !isNew, - }); + const { data: templates = [], isLoading: templatesLoading } = useGetTemplatesQuery(undefined, { + skip: !isNew, + }); const [createFromTemplate, { isLoading: createFromTemplateLoading }] = useCreateCanvasFromTemplateMutation(); const nodes = useSelector(selectFlowNodes); @@ -80,219 +114,208 @@ export const SaveFlowDialog: FC = ({ const databases = useSelector((state: RootState) => state.flow.databases); const schemas = useSelector((state: RootState) => state.flow.schemas); const revisionId = useSelector((state: RootState) => state.flow.revisionId); - const { formData, setFormData, onFormDataChange } = - useFormData(EMPTY_FORM_DATA); + const { formData, setFormData, onFormDataChange } = useFormData(EMPTY_FORM_DATA); const [editName, setEditName] = useState(false); const [nameRef, setNameRef] = useState(); const [commentRef, setCommentRef] = useState(); const [error, setError] = useState(); + const [mode, setMode] = useState("create"); + const [uploadState, setUploadState] = useState("idle"); + const [uploadError, setUploadError] = useState(); + const [uploadedFileName, setUploadedFileName] = useState(); + const [importedDataflow, setImportedDataflow] = useState(); + const [dragActive, setDragActive] = useState(false); + const hiddenFileInput = useRef(null); + + const resetAddDataflowState = useCallback(() => { + setMode("create"); + setUploadState("idle"); + setUploadError(undefined); + setUploadedFileName(undefined); + setImportedDataflow(undefined); + setDragActive(false); + if (hiddenFileInput.current) hiddenFileInput.current.value = ""; + }, []); useEffect(() => { if (props.open) { setError(undefined); setEditName(false); + resetAddDataflowState(); } - }, [props.open]); + }, [props.open, resetAddDataflowState]); useEffect(() => { if (props.open) { if (isNew || !dataflow) { onFormDataChange(EMPTY_FORM_DATA); } else { - setFormData({ - name: dataflow.canvas.name, - comment: "", - selectedTemplate: "", - }); + setFormData({ name: dataflow.canvas.name, comment: "", selectedTemplate: "" }); } if (isNew) { - nameRef && nameRef.focus(); + nameRef?.focus(); } else { - commentRef && commentRef.focus(); + commentRef?.focus(); } } - }, [props.open, dataflow, nameRef, commentRef]); + }, [props.open, dataflow, isNew, nameRef, commentRef, onFormDataChange, setFormData]); - const handleSave = useCallback(async () => { - const trimmedName = formData.name.trim(); - - if (!trimmedName) { + const processFile = useCallback((file: File) => { + if (!file.name.toLowerCase().endsWith(".json")) { + setUploadState("error"); + setUploadError("Choose a JSON dataflow export file."); + setImportedDataflow(undefined); return; } - - if (isNew && formData.selectedTemplate) { - // Create from template + + setUploadState("uploading"); + setUploadError(undefined); + setUploadedFileName(undefined); + setImportedDataflow(undefined); + + const reader = new FileReader(); + reader.onload = () => { + try { + const parsed = getImportedDataflow(JSON.parse(String(reader.result))); + setImportedDataflow(parsed); + setUploadedFileName(file.name); + setUploadState("success"); + } catch (fileError) { + setUploadState("error"); + setUploadError(fileError instanceof Error ? fileError.message : "Unable to read this dataflow export."); + } + }; + reader.onerror = () => { + setUploadState("error"); + setUploadError("Unable to read this dataflow export."); + }; + reader.readAsText(file); + }, []); + + const handleFileChange = useCallback((event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) processFile(file); + event.target.value = ""; + }, [processFile]); + + const handleDrop = useCallback((event: DragEvent) => { + event.preventDefault(); + setDragActive(false); + const file = event.dataTransfer.files?.[0]; + if (file) processFile(file); + }, [processFile]); + + const handleSave = useCallback(async () => { + const trimmedName = formData.name.trim(); + if (!trimmedName) return; + + if (isNew && mode === "import" && !importedDataflow) return; + + if (isNew && mode === "create" && formData.selectedTemplate) { const response = await createFromTemplate({ templateId: formData.selectedTemplate, name: trimmedName, comment: formData.comment.trim(), }); - if ("error" in response) { setError((response.error as FetchBaseQueryError).data as ErrorResponse); return; } - if ("data" in response && response.data?.id) { dispatch(setDataflowId(response.data.id)); dispatch(setRevisionId(undefined)); dispatch(markStatusAsSaved()); - typeof onClose === "function" && onClose(); + onClose?.(); } - } else { - // Without template - const dataflow: SaveDataflowDto = { - id: saveFlowDialog.dataflowId, - name: trimmedName, - dataflow: isNew - ? { - nodes: [], - edges: [], - variables: [], - importLibs: [], - databases: [], - schemas: [], + return; + } + + const dataflowPayload: SaveDataflowDto = { + id: saveFlowDialog.dataflowId, + name: trimmedName, + dataflow: isNew + ? mode === "import" + ? { ...importedDataflow!, comment: formData.comment.trim() } + : { + nodes: [], edges: [], variables: [], importLibs: [], databases: [], schemas: [], comment: formData.comment.trim(), } - : { nodes, edges, variables, importLibs, databases, schemas, comment: formData.comment.trim() }, - }; - const response = await saveDataflow(dataflow); - - if ("error" in response) { - setError((response.error as FetchBaseQueryError).data as ErrorResponse); - return; - } - - if (isNew && "data" in response) { - if (response.data?.id) { - dispatch(setDataflowId(response.data.id)); - dispatch(setAddNodeTypeDialog({ visible: true })); - } - } + : { nodes, edges, variables, importLibs, databases, schemas, comment: formData.comment.trim() }, + }; + const response = await saveDataflow(dataflowPayload); + if ("error" in response) { + setError((response.error as FetchBaseQueryError).data as ErrorResponse); + return; + } - dispatch(setRevisionId(undefined)); - dispatch(markStatusAsSaved()); - typeof onClose === "function" && onClose(); + if (isNew && "data" in response && response.data?.id) { + dispatch(setDataflowId(response.data.id)); + if (mode === "create") dispatch(setAddNodeTypeDialog({ visible: true })); } - }, [ - saveFlowDialog, - isNew, - formData, - nodes, - edges, - variables, - importLibs, - databases, - schemas, - createFromTemplate, - saveDataflow, - onClose, - ]); + dispatch(setRevisionId(undefined)); + dispatch(markStatusAsSaved()); + onClose?.(); + }, [saveFlowDialog.dataflowId, isNew, mode, formData, importedDataflow, nodes, edges, variables, importLibs, databases, schemas, createFromTemplate, saveDataflow, onClose]); - const handleClose = useCallback(() => { - typeof onClose === "function" && onClose(); - }, [onClose]); - - const handleSubmit = useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - handleSave(); - }, - [handleSave] - ); + const handleClose = useCallback(() => onClose?.(), [onClose]); + const handleSubmit = useCallback((event: React.FormEvent) => { + event.preventDefault(); + handleSave(); + }, [handleSave]); + const confirmationDisabled = !formData.name.trim() || (isNew && mode === "import" && uploadState !== "success"); return ( -
+
- setError(undefined)} - /> - + setError(undefined)} /> {isNew || editName ? ( - setNameRef(ref)} - sx={{ width: "100%" }} - variant="standard" - value={formData.name} - onChange={(e: ChangeEvent) => - onFormDataChange({ name: e.target.value }) - } - /> + ) => onFormDataChange({ name: event.target.value })} /> ) : (
Name - - {formData.name} - } - onClick={() => setEditName(true)} - /> - + {formData.name}} onClick={() => setEditName(true)} />
)} -
- - setCommentRef(ref)} - sx={{ width: "100%" }} - variant="standard" - label={isNew ? "Comment" : "Describe your changes"} - value={formData.comment} - onChange={(e: ChangeEvent) => - onFormDataChange({ comment: e.target.value }) - } - /> - - {isNew && ( - - Template (Optional) - - - )} + ) => onFormDataChange({ comment: event.target.value })} /> + + {isNew && ( + <> +
+ Dataflow type + + +
+ {mode === "create" ? ( +
+ Template (Optional) + +
+ ) : ( +
+ +
hiddenFileInput.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") hiddenFileInput.current?.click(); }} onDragEnter={(event) => { event.preventDefault(); setDragActive(true); }} onDragOver={(event) => event.preventDefault()} onDragLeave={(event) => { event.preventDefault(); setDragActive(false); }} onDrop={handleDrop}> + {uploadState === "success" ? : uploadState === "error" ? : } +
{uploadState === "uploading" ? "Uploading dataflow…" : uploadState === "success" ? uploadedFileName : uploadState === "error" ? uploadError : <>Add by importing or drag and drop}
+
+
+ )} + + )}
- -
diff --git a/plugins/ui/apps/flow/src/features/flow/containers/Flow/SaveFlow/SaveNewFlowButton.tsx b/plugins/ui/apps/flow/src/features/flow/containers/Flow/SaveFlow/SaveNewFlowButton.tsx index 0089a11eb5..90b6b47510 100644 --- a/plugins/ui/apps/flow/src/features/flow/containers/Flow/SaveFlow/SaveNewFlowButton.tsx +++ b/plugins/ui/apps/flow/src/features/flow/containers/Flow/SaveFlow/SaveNewFlowButton.tsx @@ -11,9 +11,9 @@ export const SaveNewFlowButton: FC = () => { }, []); return ( - +
- } onClick={handleClick} /> + } onClick={handleClick} />
); diff --git a/plugins/ui/apps/flow/src/features/flow/containers/FlowLayout.tsx b/plugins/ui/apps/flow/src/features/flow/containers/FlowLayout.tsx index fa85c5f7b0..88cd5b9c38 100644 --- a/plugins/ui/apps/flow/src/features/flow/containers/FlowLayout.tsx +++ b/plugins/ui/apps/flow/src/features/flow/containers/FlowLayout.tsx @@ -26,7 +26,6 @@ import { FlowPanel } from "./Flow/FlowPanel/FlowPanel"; import { FlowRevisionsButton } from "./Flow/FlowRevisions/FlowRevisionsButton"; import { ResultsPolling } from "./Flow/FlowRunResults/ResultsPolling"; import { FlowSettingsButton } from "./Flow/FlowSettings/FlowSettingsButton"; -import { ImportFlowButton } from "./Flow/ImportFlow/ImportFlowButton"; import { SaveFlowButton } from "./Flow/SaveFlow/SaveFlowButton"; import { SaveNewFlowButton } from "./Flow/SaveFlow/SaveNewFlowButton"; import { SyncFromRemoteButton } from "./Flow/SyncFromRemote/SyncFromRemoteButton"; @@ -98,7 +97,6 @@ export const FlowLayout: FC = ({ isStandalone }) => { - {isFetching && } diff --git a/tests/e2e/tests/18-flows/data/export-import-dataflow.spec.ts b/tests/e2e/tests/18-flows/data/export-import-dataflow.spec.ts index 12481a5d04..7c75731c2d 100644 --- a/tests/e2e/tests/18-flows/data/export-import-dataflow.spec.ts +++ b/tests/e2e/tests/18-flows/data/export-import-dataflow.spec.ts @@ -1,120 +1,177 @@ import { test, expect } from '../../fixtures' +import type { Page } from '@playwright/test' import fs from 'fs/promises' import path from 'path' -let exportedFilePath = '' -let createdDataflowName = '' +const TEST_NAME = 'e2e-export-import-nodestest' +const SHOULD_SKIP = false +test.fixme(SHOULD_SKIP, `${TEST_NAME} test is temporarily disabled.`) + +let exportedFilePaths: string[] = [] +let createdDataflowNames: string[] = [] + +const NODES_TEST_NODE_NAMES = [ + 'python_node_0', + 'py2table_node_0', + 'sql_node_0', + 'db_writer_node_0', + 'r_node_0', + 'db_reader_node_0', + 'file_node_0', + 'csv_node_0' +] + +type DataflowTemplate = { + id?: string + name?: string + description?: string + nodes: Array<{ id: string; type: string; data: { name: string; [key: string]: unknown }; [key: string]: unknown }> + edges: Array<{ source: string; target: string; [key: string]: unknown }> + variables?: unknown[] + importLibs?: unknown[] + databases?: unknown[] + schemas?: unknown[] +} test.afterEach(async ({ page }) => { - if (createdDataflowName) { + try { + await page.goto('/d2e/portal/etl') + + const flowSelector = page.getByRole('combobox').filter({ hasText: /.+/ }).first() + if (await flowSelector.isVisible({ timeout: 5000 }).catch(() => false)) { + await flowSelector.click() + const testFlowOptions = await page + .getByRole('option') + .filter({ hasText: /^NodesTest(Source|Imported)_/ }) + .allTextContents() + await page.keyboard.press('Escape') + + createdDataflowNames = Array.from( + new Set([...createdDataflowNames, ...testFlowOptions.map(name => name.trim()).filter(Boolean)]) + ).reverse() + } + } catch { + // Continue with the names recorded by the test if the flow list cannot be inspected. + } + + for (const createdDataflowName of createdDataflowNames) { try { + await page.goto('/d2e/portal') + const etlLink = page.getByRole('link', { name: 'ETL' }) + if (await etlLink.isVisible({ timeout: 5000 }).catch(() => false)) { + await etlLink.click() + } else { + await page.goto('/d2e/portal/etl') + } + + const flowSelector = page.getByRole('combobox').filter({ hasText: /.+/ }).first() + await flowSelector.click() + await page.getByRole('option', { name: createdDataflowName }).click() + await expect(page.getByRole('combobox', { name: createdDataflowName })).toBeVisible() await page.getByLabel('Delete flow').getByRole('button').click({ timeout: 3000 }) await page.getByRole('textbox').fill(createdDataflowName) await page.getByRole('button', { name: 'Delete' }).click() } catch { - // best-effort cleanup — flow may have already been deleted by the test + // best-effort cleanup - flow may have already been deleted by the test } - createdDataflowName = '' } + createdDataflowNames = [] - if (exportedFilePath) { + for (const exportedFilePath of exportedFilePaths) { await fs .access(exportedFilePath) .then(() => fs.rm(exportedFilePath)) .catch(() => undefined) - exportedFilePath = '' } + exportedFilePaths = [] }) -test('export-import-dataflow', async ({ page }) => { +test('create and import a Nodes Test dataflow through Add Dataflow', async ({ page }) => { + test.setTimeout(300000) + const timestamp = Date.now() - const dataflowName = `ExportImportFlow_${timestamp}` - const nodeTitle = page.locator('.node__title').filter({ hasText: 'python_node_0' }) + const sourceFlowName = `NodesTestSource_${timestamp}` + const importedFlowName = `NodesTestImported_${timestamp}` + const exportedFlowPath = path.join(__dirname, `nodestest-exported-${timestamp}.json`) + exportedFilePaths.push(exportedFlowPath) + createdDataflowNames.push(importedFlowName, sourceFlowName) + + const openAddDataflow = async () => { + const firstFlowButton = page.getByRole('button', { name: 'Create your first dataflow' }) + if (await firstFlowButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await firstFlowButton.click() + } else { + await page.getByRole('button', { name: 'Add Dataflow' }).click() + } + + const dialog = page.getByRole('dialog').filter({ hasText: 'Add Dataflow' }) + await expect(dialog).toBeVisible() + return dialog + } - await test.step('Authenticate and navigate to Admin portal', async () => { + await test.step('Sign in and open the ETL workflow page', async () => { await page.goto('/d2e/portal') await page.locator('input[name="identifier"]').fill('admin') await page.locator('input[name="password"]').fill('Updatepassword12345') await page.getByRole('button', { name: 'Sign in' }).click() await page.getByTestId('button').nth(1).click() await page.getByRole('button', { name: 'Switch to Admin portal' }).click() - }) - - await test.step('Create a new dataflow with a python node', async () => { - await page.getByRole('link', { name: 'ETL' }).click() - // Handle both scenarios: no flows (Create your first dataflow) or existing flows (Create new dataflow) - const firstFlowBtn = page.getByRole('button', { name: 'Create your first dataflow' }) - if (await firstFlowBtn.isVisible({ timeout: 3000 }).catch(() => false)) { - await firstFlowBtn.click() + const etlLink = page.getByRole('link', { name: 'ETL' }) + if (await etlLink.isVisible({ timeout: 5000 }).catch(() => false)) { + await etlLink.click() } else { - await page.getByLabel('Create new dataflow').getByRole('button').click() + await page.goto('/d2e/portal/etl') } - await expect(page.getByRole('textbox', { name: 'Name' })).toBeVisible() - await page.getByRole('textbox', { name: 'Name' }).fill(dataflowName) - await page.getByRole('textbox', { name: 'Comment' }).fill('Test export import flow') - await expect(page.getByRole('button', { name: 'Create' })).toBeVisible() - await page.getByRole('button', { name: 'Create' }).click() - await expect(page.getByRole('button', { name: 'Create' })).not.toBeVisible() - createdDataflowName = dataflowName - }) - await test.step('Export the flow', async () => { - // This timeout is necessary as clicking the python button too quickly seems to have an issue which causes the node not to be added. Remove this wait to see if the issue persists. - await page.waitForTimeout(1500) - await page.getByText('Run python code').click() - await expect(nodeTitle).toBeVisible() + await expect( + page.getByRole('button', { name: 'Add Dataflow' }).or(page.getByRole('button', { name: 'Create your first dataflow' })) + ).toBeVisible({ timeout: 30000 }) + }) - await expect(page.getByRole('button', { name: 'Save' })).toBeVisible() - await page.getByRole('button', { name: 'Save' }).click() - const saveDialog = page.getByRole('dialog') - await expect(saveDialog.getByRole('button', { name: 'Save' })).toBeEnabled() - await saveDialog.getByRole('button', { name: 'Save' }).click() + await test.step('Create a workflow from the Nodes Test template using Add Dataflow', async () => { + const dialog = await openAddDataflow() + await dialog.getByRole('textbox', { name: 'Name' }).fill(sourceFlowName) + await dialog.getByRole('textbox', { name: 'Comment' }).fill('Nodes test template source flow') + await dialog.getByLabel('Create a new dataflow').check() + await dialog.getByRole('combobox').click() + await page.getByRole('option', { name: /Nodes Test|nodestest|testsnode/i }).click() + await dialog.getByRole('button', { name: 'Create' }).click() + await expect(dialog).not.toBeVisible() + + await expect(expectNode(page, 'db_writer_node_0')).toBeVisible() + await expect(expectNode(page, 'db_reader_node_0')).toBeVisible() + }) - // Export the flow + await test.step('Export the template-based workflow to JSON', async () => { const downloadPromise = page.waitForEvent('download') await page.getByLabel('Export flow').getByRole('button').click() const download = await downloadPromise - exportedFilePath = path.join(__dirname, `exported-flow-${timestamp}.json`) - await download.saveAs(exportedFilePath) + await download.saveAs(exportedFlowPath) + + const exported = JSON.parse(await fs.readFile(exportedFlowPath, 'utf-8')) as DataflowTemplate + expect(exported.nodes.map(node => node.data.name).sort()).toEqual([...NODES_TEST_NODE_NAMES].sort()) }) - await test.step('Import the flow and verify', async () => { - // Import the exported flow - const fileChooserPromise = page.waitForEvent('filechooser') - await page.getByLabel('Import flow').getByRole('button').click() - const fileChooser = await fileChooserPromise - await fileChooser.setFiles(exportedFilePath) - - // Verify the imported node is present on the canvas - await expect(nodeTitle).toBeVisible() - - // Close dialog - const visibleDialogs = page.locator('[role="dialog"]:visible') - const activeDialog = visibleDialogs.last() - const closeButton = activeDialog.getByRole('button', { name: /cancel/i }).first() - if (await closeButton.isVisible().catch(() => false)) { - await closeButton.click() - } else { - await page.keyboard.press('Escape') - } - await expect(page.locator('[role="dialog"]:visible')).toHaveCount(0, { timeout: 5000 }) - // Hover to reveal edit button, then click it - await nodeTitle.hover() - const editBtn = page.locator('.node__setting') - await expect(editBtn).toBeVisible() - await editBtn.click() - - // Verify data integrity: imported code matches exactly what was exported - const exported = JSON.parse(await fs.readFile(exportedFilePath, 'utf-8')) - const exportedScript = exported.nodes[0].data.python_code - console.log('Exported script:', exportedScript) - - const editor = page.getByRole('textbox', { name: 'Editor content;Press Alt+F1' }) - await editor.focus() - const importedCode = await editor.inputValue() - expect(importedCode).toBe(exportedScript) - - await page.getByRole('button', { name: 'Close' }).click() + await test.step('Import the exported JSON as a new persisted dataflow using Add Dataflow', async () => { + const dialog = await openAddDataflow() + await dialog.getByRole('textbox', { name: 'Name' }).fill(importedFlowName) + await dialog.getByRole('textbox', { name: 'Comment' }).fill('Imported nodes test template flow') + await dialog.getByLabel('Import a dataflow').check() + + await dialog.locator('input[type="file"]').setInputFiles(exportedFlowPath) + + await expect(dialog.getByText(path.basename(exportedFlowPath))).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Import' })).toBeEnabled() + await dialog.getByRole('button', { name: 'Import' }).click() + await expect(dialog).not.toBeVisible() + + await expect(expectNode(page, 'db_writer_node_0')).toBeVisible() + await expect(expectNode(page, 'db_reader_node_0')).toBeVisible() + await expect(page.getByRole('combobox').filter({ hasText: importedFlowName })).toBeVisible() }) }) + +function expectNode(page: Page, nodeName: string) { + return page.locator('.node').filter({ has: page.locator('.node__title', { hasText: nodeName }) }) +}