diff --git a/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx b/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx index dcde7fcce..5db3d6896 100644 --- a/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/components/FlowMenu/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate"; import useAddFlow from "@/hooks/flows/use-add-flow"; @@ -17,6 +17,7 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; import { UPLOAD_ERROR_ALERT } from "@/constants/alerts_constants"; import { SAVED_HOVER } from "@/constants/constants"; import { useGetRefreshFlowsQuery } from "@/controllers/API/queries/flows/use-get-refresh-flows-query"; @@ -54,8 +55,26 @@ export const MenuBar = ({}: {}): JSX.Element => { const onFlowPage = useFlowStore((state) => state.onFlowPage); const setCurrentFlow = useFlowsManagerStore((state) => state.setCurrentFlow); const stopBuilding = useFlowStore((state) => state.stopBuilding); + const [editingName, setEditingName] = useState(false); + const [flowName, setFlowName] = useState(currentFlow?.name ?? ""); + const [isInvalidName, setIsInvalidName] = useState(false); + const nameInputRef = useRef(null); + const [inputWidth, setInputWidth] = useState(0); + const measureRef = useRef(null); const { data: folders, isFetched: isFoldersFetched } = useGetFoldersQuery(); + const flows = useFlowsManagerStore((state) => state.flows); + const [nameLists, setNameList] = useState([]); + + useEffect(() => { + if (flows) { + const tempNameList: string[] = []; + flows.forEach((flow) => { + tempNameList.push(flow.name); + }); + setNameList(tempNameList.filter((name) => name !== currentFlow?.name)); + } + }, [flows, currentFlow?.name]); useGetRefreshFlowsQuery( { @@ -73,6 +92,12 @@ export const MenuBar = ({}: {}): JSX.Element => { const changesNotSaved = customStringify(currentFlow) !== customStringify(currentSavedFlow); + useEffect(() => { + if (measureRef.current) { + setInputWidth(measureRef.current.offsetWidth); + } + }, [flowName]); + function handleAddFlow() { try { addFlow().then((id) => { @@ -113,6 +138,80 @@ export const MenuBar = ({}: {}): JSX.Element => { const changes = useShortcutsStore((state) => state.changesSave); useHotkeys(changes, handleSave, { preventDefault: true }); + const handleEditName = useCallback( + (e: React.ChangeEvent) => { + const { value } = e.target; + let invalid = false; + for (let i = 0; i < nameLists.length; i++) { + if (value === nameLists[i]) { + invalid = true; + break; + } + } + setIsInvalidName(invalid); + setFlowName(value); + }, + [nameLists], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + setEditingName(false); + setFlowName(currentFlow?.name ?? ""); + setIsInvalidName(false); + } + if (e.key === "Enter") { + nameInputRef.current?.blur(); + } + }, + [currentFlow?.name], + ); + + const handleNameSubmit = useCallback(() => { + if ( + flowName.trim() !== "" && + flowName !== currentFlow?.name && + !isInvalidName + ) { + const newFlow = { + ...currentFlow!, + name: flowName, + id: currentFlow!.id, + }; + setCurrentFlow(newFlow); + saveFlow(newFlow) + .then(() => { + setSuccessData({ title: "Flow name updated successfully" }); + }) + .catch((error) => { + setErrorData({ + title: "Error updating flow name", + list: [(error as Error).message], + }); + setFlowName(currentFlow?.name ?? ""); + }); + } else if (isInvalidName) { + setErrorData({ + title: "Invalid flow name", + list: ["Name already exists"], + }); + setFlowName(currentFlow?.name ?? ""); + } else { + setFlowName(currentFlow?.name ?? ""); + } + setEditingName(false); + setIsInvalidName(false); + }, [ + flowName, + currentFlow, + setCurrentFlow, + saveFlow, + setSuccessData, + setErrorData, + isInvalidName, + ]); + return currentFlow && onFlowPage ? (
@@ -138,148 +237,191 @@ export const MenuBar = ({}: {}): JSX.Element => {
- - -
+
+
+ + {flowName} + + {editingName ? ( + <> + + + ) : (
{ + setEditingName(true); + setFlowName(currentFlow.name); + }} > -
- {currentFlow.name} -
+ {currentFlow.name}
+ )} +
+ + -
- - - Options - { - handleAddFlow(); - }} - className="cursor-pointer" - > - - New - + + + Options + { + handleAddFlow(); + }} + className="cursor-pointer" + > + + New + - { - setOpenSettings(true); - }} - className="cursor-pointer" - > - - Flow Settings - - {!autoSaving && ( - + { + setOpenSettings(true); + }} + className="cursor-pointer" + > + + Edit Details + + {!autoSaving && ( + + s.name.toLowerCase() === "changes save", + )?.shortcut! + } + /> + + )} + { + setOpenLogs(true); + }} + className="cursor-pointer" + > + + Logs + + { + uploadFlow({ position: { x: 300, y: 100 } }) + .then(() => { + setSuccessData({ + title: "Uploaded successfully", + }); + }) + .catch((error) => { + setErrorData({ + title: UPLOAD_ERROR_ALERT, + list: [(error as Error).message], + }); + }); + }} + > + + Import + + +
+ + Export +
+
+ { + undo(); + }} + className="cursor-pointer" + > s.name.toLowerCase() === "changes save", - )?.shortcut! + shortcuts.find((s) => s.name.toLowerCase() === "undo") + ?.shortcut! } /> - )} - { - setOpenLogs(true); - }} - className="cursor-pointer" - > - - Logs - - { - uploadFlow({ position: { x: 300, y: 100 } }) - .then(() => { - setSuccessData({ - title: "Uploaded successfully", - }); - }) - .catch((error) => { - setErrorData({ - title: UPLOAD_ERROR_ALERT, - list: [(error as Error).message], - }); - }); - }} - > - - Import - - -
+ { + redo(); + }} + className="cursor-pointer" + > + s.name.toLowerCase() === "redo") + ?.shortcut! + } + /> + + { + handleReloadComponents(); + }} + className="cursor-pointer" + > - Export -
-
- { - undo(); - }} - className="cursor-pointer" - > - s.name.toLowerCase() === "undo") - ?.shortcut! - } - /> - - { - redo(); - }} - className="cursor-pointer" - > - s.name.toLowerCase() === "redo") - ?.shortcut! - } - /> - - { - handleReloadComponents(); - }} - className="cursor-pointer" - > - - Refresh All - -
- + Refresh All + +
+ +
+ = ({ onDoubleClickCapture={(event) => { handleFocus(event); }} + data-testid="input-flow-name" /> ) : ( diff --git a/src/frontend/src/constants/constants.ts b/src/frontend/src/constants/constants.ts index 5a4a29739..fc3fb2360 100644 --- a/src/frontend/src/constants/constants.ts +++ b/src/frontend/src/constants/constants.ts @@ -104,7 +104,7 @@ export const EXPORT_DIALOG_SUBTITLE = "Export flow as JSON file."; * @constant */ export const SETTINGS_DIALOG_SUBTITLE = - "Customize workspace settings and preferences."; + "Customize your flow details and settings."; /** * The base text for subtitle of Flow Logs (Menubar) diff --git a/src/frontend/src/modals/flowSettingsModal/index.tsx b/src/frontend/src/modals/flowSettingsModal/index.tsx index a1bab19f8..4437ae8c4 100644 --- a/src/frontend/src/modals/flowSettingsModal/index.tsx +++ b/src/frontend/src/modals/flowSettingsModal/index.tsx @@ -15,29 +15,30 @@ import BaseModal from "../baseModal"; export default function FlowSettingsModal({ open, setOpen, + flowData, + details, }: FlowSettingsPropsType): JSX.Element { const saveFlow = useSaveFlow(); const currentFlow = useFlowStore((state) => state.currentFlow); const setCurrentFlow = useFlowStore((state) => state.setCurrentFlow); const setSuccessData = useAlertStore((state) => state.setSuccessData); const flows = useFlowsManagerStore((state) => state.flows); + const flow = flowData ?? currentFlow; useEffect(() => { - setName(currentFlow!.name); - setDescription(currentFlow!.description); - }, [currentFlow?.name, currentFlow?.description, open]); + setName(flow!.name); + setDescription(flow!.description); + }, [flow?.name, flow?.description, open]); - const [name, setName] = useState(currentFlow!.name); - const [description, setDescription] = useState(currentFlow!.description); - const [endpoint_name, setEndpointName] = useState( - currentFlow!.endpoint_name ?? "", - ); + const [name, setName] = useState(flow!.name); + const [description, setDescription] = useState(flow!.description); + const [endpoint_name, setEndpointName] = useState(flow!.endpoint_name ?? ""); const [isSaving, setIsSaving] = useState(false); const [disableSave, setDisableSave] = useState(true); const autoSaving = useFlowsManagerStore((state) => state.autoSaving); function handleClick(): void { setIsSaving(true); - if (!currentFlow) return; - const newFlow = cloneDeep(currentFlow); + if (!flow) return; + const newFlow = cloneDeep(flow); newFlow.name = name; newFlow.description = description; newFlow.endpoint_name = @@ -67,22 +68,22 @@ export default function FlowSettingsModal({ flows.forEach((flow: FlowType) => { tempNameList.push(flow.name); }); - setNameList(tempNameList.filter((name) => name !== currentFlow!.name)); + setNameList(tempNameList.filter((name) => name !== flow!.name)); } }, [flows]); useEffect(() => { if ( - (!nameLists.includes(name) && currentFlow?.name !== name) || - currentFlow?.description !== description || - ((currentFlow?.endpoint_name ?? "") !== endpoint_name && + (!nameLists.includes(name) && flow?.name !== name) || + flow?.description !== description || + ((flow?.endpoint_name ?? "") !== endpoint_name && isEndpointNameValid(endpoint_name ?? "", 50)) ) { setDisableSave(false); } else { setDisableSave(true); } - }, [nameLists, currentFlow, description, endpoint_name, name]); + }, [nameLists, flow, description, endpoint_name, name]); return ( - Settings - + Details + diff --git a/src/frontend/src/pages/MainPage/components/dropdown/index.tsx b/src/frontend/src/pages/MainPage/components/dropdown/index.tsx index 18df53ad8..907128bee 100644 --- a/src/frontend/src/pages/MainPage/components/dropdown/index.tsx +++ b/src/frontend/src/pages/MainPage/components/dropdown/index.tsx @@ -10,11 +10,13 @@ type DropdownComponentProps = { flowData: FlowType; setOpenDelete: (open: boolean) => void; handlePlaygroundClick?: () => void; + handleEdit: () => void; }; const DropdownComponent = ({ flowData, setOpenDelete, + handleEdit, }: DropdownComponentProps) => { const setSuccessData = useAlertStore((state) => state.setSuccessData); const setErrorData = useAlertStore((state) => state.setErrorData); @@ -29,17 +31,32 @@ const DropdownComponent = ({ downloadFlow(flowData, flowData.name, flowData.description); setSuccessData({ title: `${flowData.name} exported successfully` }); }; - const { handleSelectOptionsChange } = useSelectOptionsChange( [flowData.id], setErrorData, setOpenDelete, handleDuplicate, handleExport, + handleEdit, ); return ( <> + { + e.stopPropagation(); + handleSelectOptionsChange("edit"); + }} + className="cursor-pointer" + data-testid="btn-edit-flow" + > + { e.stopPropagation(); diff --git a/src/frontend/src/pages/MainPage/components/grid/index.tsx b/src/frontend/src/pages/MainPage/components/grid/index.tsx index 0f6c5fc48..1d0918579 100644 --- a/src/frontend/src/pages/MainPage/components/grid/index.tsx +++ b/src/frontend/src/pages/MainPage/components/grid/index.tsx @@ -10,10 +10,10 @@ import { import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate"; import useDeleteFlow from "@/hooks/flows/use-delete-flow"; import DeleteConfirmationModal from "@/modals/deleteConfirmationModal"; +import FlowSettingsModal from "@/modals/flowSettingsModal"; import useAlertStore from "@/stores/alertStore"; import useFlowsManagerStore from "@/stores/flowsManagerStore"; import { FlowType } from "@/types/flow"; -import { getInputsAndOutputs } from "@/utils/storeUtils"; import { swatchColors } from "@/utils/styleUtils"; import { cn, getNumberFromString } from "@/utils/utils"; import { useState } from "react"; @@ -27,6 +27,7 @@ const GridComponent = ({ flowData }: { flowData: FlowType }) => { const navigate = useCustomNavigate(); const [openDelete, setOpenDelete] = useState(false); + const [openSettings, setOpenSettings] = useState(false); const setSuccessData = useAlertStore((state) => state.setSuccessData); const { deleteFlow } = useDeleteFlow(); @@ -124,6 +125,9 @@ const GridComponent = ({ flowData }: { flowData: FlowType }) => { { + setOpenSettings(true); + }} />
@@ -145,6 +149,12 @@ const GridComponent = ({ flowData }: { flowData: FlowType }) => { <> )} + ); }; diff --git a/src/frontend/src/pages/MainPage/components/list/index.tsx b/src/frontend/src/pages/MainPage/components/list/index.tsx index 97e283047..ea7764d99 100644 --- a/src/frontend/src/pages/MainPage/components/list/index.tsx +++ b/src/frontend/src/pages/MainPage/components/list/index.tsx @@ -10,6 +10,7 @@ import { import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate"; import useDeleteFlow from "@/hooks/flows/use-delete-flow"; import DeleteConfirmationModal from "@/modals/deleteConfirmationModal"; +import FlowSettingsModal from "@/modals/flowSettingsModal"; import useAlertStore from "@/stores/alertStore"; import useFlowsManagerStore from "@/stores/flowsManagerStore"; import { FlowType } from "@/types/flow"; @@ -30,6 +31,7 @@ const ListComponent = ({ flowData }: { flowData: FlowType }) => { const { deleteFlow } = useDeleteFlow(); const setErrorData = useAlertStore((state) => state.setErrorData); const { folderId } = useParams(); + const [openSettings, setOpenSettings] = useState(false); const isComponent = flowData.is_component ?? false; const setFlowToCanvas = useFlowsManagerStore( (state) => state.setFlowToCanvas, @@ -144,6 +146,9 @@ const ListComponent = ({ flowData }: { flowData: FlowType }) => { { + setOpenSettings(true); + }} handlePlaygroundClick={() => { // handlePlaygroundClick(); }} @@ -163,6 +168,12 @@ const ListComponent = ({ flowData }: { flowData: FlowType }) => { <> )} + ); }; diff --git a/src/frontend/src/pages/MainPage/hooks/use-select-options-change.tsx b/src/frontend/src/pages/MainPage/hooks/use-select-options-change.tsx index dc7053600..41525a160 100644 --- a/src/frontend/src/pages/MainPage/hooks/use-select-options-change.tsx +++ b/src/frontend/src/pages/MainPage/hooks/use-select-options-change.tsx @@ -6,6 +6,7 @@ const useSelectOptionsChange = ( setOpenDelete: (value: boolean) => void, handleDuplicate: () => void, handleExport: () => void, + handleEdit: () => void, ) => { const handleSelectOptionsChange = useCallback( (action) => { @@ -23,6 +24,8 @@ const useSelectOptionsChange = ( handleDuplicate(); } else if (action === "export") { handleExport(); + } else if (action === "edit") { + handleEdit(); } }, [ @@ -31,6 +34,7 @@ const useSelectOptionsChange = ( setOpenDelete, handleDuplicate, handleExport, + handleEdit, ], ); diff --git a/src/frontend/src/types/components/index.ts b/src/frontend/src/types/components/index.ts index d32bd1af9..562578678 100644 --- a/src/frontend/src/types/components/index.ts +++ b/src/frontend/src/types/components/index.ts @@ -672,6 +672,8 @@ export type buttonBoxPropsType = { export type FlowSettingsPropsType = { open: boolean; setOpen: (open: boolean) => void; + details?: boolean; + flowData?: FlowType; }; export type groupDataType = { diff --git a/src/frontend/tests/core/features/auto-login-off.spec.ts b/src/frontend/tests/core/features/auto-login-off.spec.ts index b89b41599..d7a4bdbdb 100644 --- a/src/frontend/tests/core/features/auto-login-off.spec.ts +++ b/src/frontend/tests/core/features/auto-login-off.spec.ts @@ -128,8 +128,8 @@ test( await page.getByTestId("fit_view").click(); await page.getByTestId("zoom_out").click(); - await page.getByTestId("flow-configuration-button").click(); - await page.getByText("Flow Settings", { exact: true }).last().click(); + await page.getByTestId("flow_menu_trigger").click(); + await page.getByText("Edit Details", { exact: true }).last().click(); await page.getByPlaceholder("Flow Name").fill(randomFlowName); @@ -201,8 +201,8 @@ test( await page.getByTestId("fit_view").click(); await page.getByTestId("zoom_out").click(); - await page.getByTestId("flow-configuration-button").click(); - await page.getByText("Flow Settings", { exact: true }).last().click(); + await page.getByTestId("flow_menu_trigger").click(); + await page.getByText("Edit Details", { exact: true }).last().click(); await page.getByPlaceholder("Flow Name").fill(secondRandomFlowName); diff --git a/src/frontend/tests/core/features/store-shard-2.spec.ts b/src/frontend/tests/core/features/store-shard-2.spec.ts index e076b7c5b..8615d9a8d 100644 --- a/src/frontend/tests/core/features/store-shard-2.spec.ts +++ b/src/frontend/tests/core/features/store-shard-2.spec.ts @@ -116,8 +116,8 @@ test("should share component with share button", async ({ page }) => { await page.getByRole("heading", { name: "Basic Prompting" }).click(); await page.waitForTimeout(1000); const flowName = await page.getByTestId("flow_name").innerText(); - await page.getByTestId("flow_name").click(); - await page.getByText("Flow Settings").click(); + await page.getByTestId("flow_menu_trigger").click(); + await page.getByText("Edit Details").click(); const flowDescription = await page .getByPlaceholder("Flow description") .inputValue(); diff --git a/src/frontend/tests/core/regression/generalBugs-shard-4.spec.ts b/src/frontend/tests/core/regression/generalBugs-shard-4.spec.ts index 552cb497c..e82196a56 100644 --- a/src/frontend/tests/core/regression/generalBugs-shard-4.spec.ts +++ b/src/frontend/tests/core/regression/generalBugs-shard-4.spec.ts @@ -25,8 +25,8 @@ test( await page.getByTestId("fit_view").click(); - await page.getByTestId("flow-configuration-button").click(); - await page.getByText("Flow Settings").click(); + await page.getByTestId("flow_menu_trigger").click(); + await page.getByText("Edit Details").click(); await page.getByPlaceholder("Flow name").fill(randomName); await page.getByText("Save").last().click(); await page.getByTestId("icon-ChevronLeft").last().click(); @@ -72,8 +72,8 @@ test( await page.getByTestId(`card-${randomName}`).first().click(); - await page.getByTestId("flow-configuration-button").click(); - await page.getByText("Flow Settings").click(); + await page.getByTestId("flow_menu_trigger").click(); + await page.getByText("Edit Details").click(); await page.getByPlaceholder("Flow name").fill(secondRandomName); await page.getByText("Save").last().click(); await page.getByTestId("icon-ChevronLeft").last().click(); diff --git a/src/frontend/tests/extended/features/edit-flow-name.spec.ts b/src/frontend/tests/extended/features/edit-flow-name.spec.ts new file mode 100644 index 000000000..7069b1db0 --- /dev/null +++ b/src/frontend/tests/extended/features/edit-flow-name.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "fs"; +import { awaitBootstrapTest } from "../../utils/await-bootstrap-test"; +import { simulateDragAndDrop } from "../../utils/simulate-drag-and-drop"; +test( + "user should be able to edit flow name by clicking on the header or on the main page", + { tag: ["@release"] }, + async ({ page }) => { + const randomName = Math.random().toString(36).substring(2, 15); + const randomName2 = Math.random().toString(36).substring(2, 15); + const randomName3 = Math.random().toString(36).substring(2, 15); + const randomName4 = Math.random().toString(36).substring(2, 15); + + await awaitBootstrapTest(page); + + await page.getByRole("heading", { name: "Basic Prompting" }).click(); + + await page.getByTestId("flow_name").click(); + + await page.getByTestId("input-flow-name").fill(randomName); + + await page.keyboard.press("Enter"); + + let flowName = await page.getByTestId("flow_name").textContent(); + + expect(flowName).toBe(randomName); + + await page.getByTestId("icon-ChevronLeft").first().click(); + + await page.waitForSelector('[data-testid="home-dropdown-menu"]', { + timeout: 5000, + }); + + await page.waitForSelector(`text=${randomName}`, { + timeout: 3000, + state: "visible", + }); + + expect(await page.getByText(randomName).count()).toBe(1); + + await page.getByText(randomName).click(); + + await page.getByTestId("flow_name").click(); + + await page.getByTestId("input-flow-name").fill(randomName2); + + await page.keyboard.press("Enter"); + + flowName = await page.getByTestId("flow_name").textContent(); + + expect(flowName).toBe(randomName2); + + await page.getByTestId("icon-ChevronLeft").first().click(); + + await page.waitForSelector('[data-testid="home-dropdown-menu"]', { + timeout: 5000, + }); + + await page.waitForSelector(`text=${randomName2}`, { + timeout: 3000, + state: "visible", + }); + + expect(await page.getByText(randomName2).count()).toBe(1); + + await page.getByTestId("home-dropdown-menu").first().click(); + + await page.getByTestId("btn-edit-flow").click(); + + await page.getByTestId("input-flow-name").fill(randomName3); + + await page.getByTestId("save-flow-settings").click(); + + await page.waitForSelector(`text=${randomName3}`, { + timeout: 3000, + state: "visible", + }); + + expect(await page.getByText(randomName3).count()).toBe(1); + + await page.getByText(randomName3).click(); + + await page.getByTestId("flow_name").click(); + + await page.getByTestId("input-flow-name").fill(randomName4); + + await page.keyboard.press("Enter"); + + flowName = await page.getByTestId("flow_name").textContent(); + + expect(flowName).toBe(randomName4); + + await page.getByTestId("icon-ChevronLeft").first().click(); + + await page.waitForSelector('[data-testid="home-dropdown-menu"]', { + timeout: 5000, + }); + + await page.waitForSelector(`text=${randomName4}`, { + timeout: 3000, + state: "visible", + }); + + expect(await page.getByText(randomName4).count()).toBe(1); + }, +); diff --git a/src/frontend/tests/extended/features/flowSettings.spec.ts b/src/frontend/tests/extended/features/flowSettings.spec.ts index b5f662d17..5b6f3b1da 100644 --- a/src/frontend/tests/extended/features/flowSettings.spec.ts +++ b/src/frontend/tests/extended/features/flowSettings.spec.ts @@ -15,8 +15,8 @@ test( timeout: 3000, }); - await page.getByTestId("flow_name").click(); - await page.getByText("Flow Settings").first().click(); + await page.getByTestId("flow_menu_trigger").click(); + await page.getByText("Edit Details").first().click(); await page .getByPlaceholder("Flow name") .fill( @@ -39,8 +39,8 @@ test( await page.getByText("Changes saved successfully").isVisible(); - await page.getByTestId("flow_name").click(); - await page.getByText("Flow Settings").first().click(); + await page.getByTestId("flow_menu_trigger").click(); + await page.getByText("Edit Details").first().click(); const flowName = await page.getByPlaceholder("Flow name").inputValue(); const flowDescription = await page