From e8509c63bb26acb133a8c49a8029f249712476f8 Mon Sep 17 00:00:00 2001 From: Maksym Yezhov Date: Mon, 23 Feb 2026 12:22:10 -0800 Subject: [PATCH] poc: consolidated settings page --- src/components/layout/AppMenu.tsx | 12 +- src/components/shared/BackendStatus.tsx | 65 ---- .../Dialogs/BackendConfigurationDialog.tsx | 286 ------------------ .../SecretsManagement/ManageSecretsButton.tsx | 19 -- .../SecretsManagement/ManageSecretsDialog.tsx | 183 ----------- .../components/AddSecretView.tsx | 30 ++ .../components/ReplaceSecretView.tsx | 60 ++++ .../components/SecretsBreadcrumbs.tsx | 34 +++ .../components/SecretsList.tsx | 28 +- .../components/SecretsListView.tsx | 45 +++ .../shared/Settings/PersonalPreferences.tsx | 22 -- .../Settings/PersonalPreferencesDialog.tsx | 82 ----- .../tests/PersonalPreferencesDialog.test.tsx | 247 --------------- src/routes/Settings/SettingsFlagsContext.tsx | 44 +++ src/routes/Settings/SettingsLayout.tsx | 110 +++++++ .../Settings/sections/BackendSettings.tsx | 248 +++++++++++++++ .../sections/BetaFeaturesSettings.tsx | 9 + .../Settings/sections/PreferencesSettings.tsx | 9 + .../Settings/sections/SecretsSettings.tsx | 21 ++ src/routes/router.ts | 90 +++++- tests/e2e/componentlib.spec.ts | 24 +- tests/e2e/helpers.ts | 118 ++++++++ tests/e2e/published-componentlib.spec.ts | 25 +- .../e2e/published-componentlifecycle.spec.ts | 24 +- tests/e2e/secrets-in-arguments.spec.ts | 77 ++--- tests/e2e/secrets-management.spec.ts | 249 ++++----------- 26 files changed, 916 insertions(+), 1245 deletions(-) delete mode 100644 src/components/shared/BackendStatus.tsx delete mode 100644 src/components/shared/Dialogs/BackendConfigurationDialog.tsx delete mode 100644 src/components/shared/SecretsManagement/ManageSecretsButton.tsx delete mode 100644 src/components/shared/SecretsManagement/ManageSecretsDialog.tsx create mode 100644 src/components/shared/SecretsManagement/components/AddSecretView.tsx create mode 100644 src/components/shared/SecretsManagement/components/ReplaceSecretView.tsx create mode 100644 src/components/shared/SecretsManagement/components/SecretsBreadcrumbs.tsx create mode 100644 src/components/shared/SecretsManagement/components/SecretsListView.tsx delete mode 100644 src/components/shared/Settings/PersonalPreferences.tsx delete mode 100644 src/components/shared/Settings/PersonalPreferencesDialog.tsx delete mode 100644 src/components/shared/Settings/tests/PersonalPreferencesDialog.test.tsx create mode 100644 src/routes/Settings/SettingsFlagsContext.tsx create mode 100644 src/routes/Settings/SettingsLayout.tsx create mode 100644 src/routes/Settings/sections/BackendSettings.tsx create mode 100644 src/routes/Settings/sections/BetaFeaturesSettings.tsx create mode 100644 src/routes/Settings/sections/PreferencesSettings.tsx create mode 100644 src/routes/Settings/sections/SecretsSettings.tsx diff --git a/src/components/layout/AppMenu.tsx b/src/components/layout/AppMenu.tsx index d1ec8a7e0..95c0071a8 100644 --- a/src/components/layout/AppMenu.tsx +++ b/src/components/layout/AppMenu.tsx @@ -1,3 +1,4 @@ +import { Link as RouterLink } from "@tanstack/react-router"; import { Menu, Plus, Upload } from "lucide-react"; import { useState } from "react"; @@ -26,11 +27,8 @@ import { import { useComponentSpec } from "@/providers/ComponentSpecProvider"; import { DOCUMENTATION_URL, TOP_NAV_HEIGHT } from "@/utils/constants"; -import BackendStatus from "../shared/BackendStatus"; import TooltipButton from "../shared/Buttons/TooltipButton"; import NewPipelineButton from "../shared/NewPipelineButton"; -import { ManageSecretsButton } from "../shared/SecretsManagement/ManageSecretsButton"; -import { PersonalPreferences } from "../shared/Settings/PersonalPreferences"; const AppMenu = () => { const requiresAuthorization = isAuthorizationRequired(); @@ -84,9 +82,11 @@ const AppMenu = () => { {/* Settings & status */} - - - + + + + + { - const { available, backendUrl, isConfiguredFromEnv, configured } = - useBackend(); - - const [open, setOpen] = useState(false); - - const handleOpen = useCallback(() => { - setOpen(true); - }, []); - - const backendAvailableString = isConfiguredFromEnv - ? "Backend available" - : `Connected to ${backendUrl}`; - const backendNotAvailableString = configured - ? "Backend unavailable" - : "Backend not configured"; - - const configuredStatusColor = available ? "bg-green-500" : "bg-red-500"; - const notConfiguredStatusColor = "bg-yellow-500"; - - return ( - <> - - - - - - {available ? backendAvailableString : backendNotAvailableString} - - - - - - ); -}; - -export default BackendStatus; diff --git a/src/components/shared/Dialogs/BackendConfigurationDialog.tsx b/src/components/shared/Dialogs/BackendConfigurationDialog.tsx deleted file mode 100644 index 73cc1f339..000000000 --- a/src/components/shared/Dialogs/BackendConfigurationDialog.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import { type ChangeEvent, useCallback, useEffect, useState } from "react"; - -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Icon } from "@/components/ui/icon"; -import { Input } from "@/components/ui/input"; -import { BlockStack, InlineStack } from "@/components/ui/layout"; -import { Separator } from "@/components/ui/separator"; -import { Switch } from "@/components/ui/switch"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { Heading, Paragraph } from "@/components/ui/typography"; -import { cn } from "@/lib/utils"; -import { useBackend } from "@/providers/BackendProvider"; -import { API_URL } from "@/utils/constants"; - -import { InfoBox } from "../InfoBox"; - -interface BackendConfigurationDialogProps { - open: boolean; - setOpen: (open: boolean) => void; -} - -const BackendConfigurationDialog = ({ - open, - setOpen, -}: BackendConfigurationDialogProps) => { - const { - backendUrl, - available, - isConfiguredFromEnv, - isConfiguredFromRelativePath, - ping, - setEnvConfig, - setRelativePathConfig, - setBackendUrl, - } = useBackend(); - - const [inputBackendUrl, setInputBackendUrl] = useState( - isConfiguredFromEnv ? "" : backendUrl, - ); - const [inputBackendTestResult, setInputBackendTestResult] = useState< - boolean | null - >(null); - const [isEnvConfig, setIsEnvConfig] = useState(isConfiguredFromEnv); - const [isRelativePathConfig, setIsRelativePathConfig] = useState( - isConfiguredFromRelativePath, - ); - - const hasEnvConfig = !!API_URL; - const showRelativePathOption = !API_URL; - - const handleInputChange = useCallback((e: ChangeEvent) => { - setInputBackendUrl(e.target.value); - setInputBackendTestResult(null); - }, []); - - const handleRefresh = useCallback(() => { - ping({}); - }, [ping]); - - const handleTest = useCallback(async () => { - const result = await ping({ - url: inputBackendUrl, - notifyResult: true, - saveAvailability: false, - }); - setInputBackendTestResult(result); - }, [inputBackendUrl, ping]); - - const handleEnvSwitch = useCallback((checked: boolean) => { - setIsEnvConfig(checked); - if (checked) setIsRelativePathConfig(false); - }, []); - - const handleConfirm = useCallback(() => { - setEnvConfig(isEnvConfig); - setRelativePathConfig(isRelativePathConfig); - setBackendUrl(inputBackendUrl); - setInputBackendUrl(inputBackendUrl.trim()); - setInputBackendTestResult(null); - setOpen(false); - }, [ - isEnvConfig, - isRelativePathConfig, - inputBackendUrl, - setEnvConfig, - setRelativePathConfig, - setBackendUrl, - setOpen, - ]); - - const handleClose = useCallback(() => { - setIsEnvConfig(isConfiguredFromEnv); - setIsRelativePathConfig(isConfiguredFromRelativePath); - setInputBackendUrl(""); - setInputBackendTestResult(null); - setOpen(false); - }, [isConfiguredFromEnv, isConfiguredFromRelativePath, setOpen]); - - useEffect(() => { - setIsEnvConfig(isConfiguredFromEnv); - setIsRelativePathConfig(isConfiguredFromRelativePath); - }, [isConfiguredFromEnv, isConfiguredFromRelativePath]); - - useEffect(() => { - setInputBackendUrl( - isConfiguredFromEnv || isConfiguredFromRelativePath ? "" : backendUrl, - ); - }, [isConfiguredFromEnv, isConfiguredFromRelativePath, backendUrl]); - - const hasBackendConfigured = - !!inputBackendUrl.trim() || - (isEnvConfig && hasEnvConfig) || - isRelativePathConfig; - const confirmButtonText = hasBackendConfigured - ? "Confirm" - : "Continue without backend"; - - return ( - - - - Configure Backend - - - - Attach the Tangle frontend to a custom backend. - - - - - Backend status: - - - - - {available ? "available" : "unavailable"} - - - - {isConfiguredFromEnv && hasEnvConfig - ? "Configured from .env" - : backendUrl.length > 0 - ? `Configured to ${backendUrl}` - : isConfiguredFromRelativePath - ? "Configured relative to host domain" - : "No backend configured"} - - - - - - {hasEnvConfig && ( - <> - - - Configure using .env - - - - Use backend url configuration from environment file. - - - - - )} - - {showRelativePathOption && ( - <> - - - Use same-domain backend - - { - setIsRelativePathConfig(checked); - if (checked) setIsEnvConfig(false); - }} - /> - - Use backend configuration relative to the current domain. - - - {isRelativePathConfig && ( - - Backend requests will be made to {window.location.origin} - /api. - - )} - - - )} - - {!(isEnvConfig && hasEnvConfig) && !isRelativePathConfig && ( - <> - - - You can set the backend URL in the environment file or use the - input below. - - - Backend URL - - - - {inputBackendTestResult !== null && ( - - - {inputBackendTestResult ? "✓" : "✗"} - - - {inputBackendTestResult - ? "Backend responded" - : "No response"} - - - )} - - - - - - {!inputBackendUrl.trim() && ( - - - - No backend is configured. Certain features may not be - operable. - - - )} - - )} - - - - - - - - ); -}; - -export default BackendConfigurationDialog; diff --git a/src/components/shared/SecretsManagement/ManageSecretsButton.tsx b/src/components/shared/SecretsManagement/ManageSecretsButton.tsx deleted file mode 100644 index 39616bb32..000000000 --- a/src/components/shared/SecretsManagement/ManageSecretsButton.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Icon } from "@/components/ui/icon"; - -import TooltipButton from "../Buttons/TooltipButton"; -import { ManageSecretsDialog } from "./ManageSecretsDialog"; - -export function ManageSecretsButton() { - return ( - - - - } - /> - ); -} diff --git a/src/components/shared/SecretsManagement/ManageSecretsDialog.tsx b/src/components/shared/SecretsManagement/ManageSecretsDialog.tsx deleted file mode 100644 index e11a85a66..000000000 --- a/src/components/shared/SecretsManagement/ManageSecretsDialog.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import { type ReactNode, useState } from "react"; - -import { withSuspenseWrapper } from "@/components/shared/SuspenseWrapper"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Icon } from "@/components/ui/icon"; -import { BlockStack, InlineStack } from "@/components/ui/layout"; -import { Separator } from "@/components/ui/separator"; -import { Spinner } from "@/components/ui/spinner"; -import useToastNotification from "@/hooks/useToastNotification"; - -import { AddSecretForm } from "./components/AddSecretForm"; -import { SecretsList } from "./components/SecretsList"; -import type { Secret } from "./types"; - -type DialogMode = "list" | "add" | "replace"; - -interface ManageSecretsDialogProps { - defaultMode?: DialogMode; - trigger?: ReactNode; -} - -interface ManageSecretsDialogContentProps { - defaultMode: DialogMode; -} - -function ManageSecretsDialogContentSkeleton() { - return ; -} - -function ManageSecretsDialogContentInternal({ - defaultMode = "list", -}: ManageSecretsDialogContentProps) { - const notify = useToastNotification(); - - const [mode, setMode] = useState(defaultMode); - const [secretToReplace, setSecretToReplace] = useState(); - - const handleAddSuccess = () => { - notify("Secret added successfully", "success"); - setMode("list"); - }; - - const handleReplaceSuccess = () => { - notify(`Secret "${secretToReplace?.name}" updated successfully`, "success"); - setSecretToReplace(undefined); - setMode("list"); - }; - - const handleRemoveSuccess = () => { - notify("Secret removed", "success"); - }; - - const handleStartReplace = (secret: Secret) => { - setSecretToReplace(secret); - setMode("replace"); - }; - - const handleCancelForm = () => { - setSecretToReplace(undefined); - setMode("list"); - }; - - return ( - - {mode === "add" && ( - <> - - - - - Add Secret - - - - - - )} - - {mode === "replace" && secretToReplace && ( - <> - - - - - Replace Secret - - - - - {`Update the value for secret "${secretToReplace.name}"`} - - - - )} - - {mode === "list" && ( - <> - - Manage Secrets - - - Manage your secrets for use in pipelines. Secret values are stored - securely and injected at runtime. - - - - - - - - - - )} - - ); -} - -const ManageSecretsDialogContent = withSuspenseWrapper( - ManageSecretsDialogContentInternal, - ManageSecretsDialogContentSkeleton, -); - -export function ManageSecretsDialog({ - defaultMode = "list", - trigger, -}: ManageSecretsDialogProps) { - const [open, setOpen] = useState(false); - - const handleDialogOpenChange = (open: boolean) => { - setOpen(open); - }; - - const defaultTrigger = ( - - ); - - return ( - - {trigger ?? defaultTrigger} - {open && } - - ); -} diff --git a/src/components/shared/SecretsManagement/components/AddSecretView.tsx b/src/components/shared/SecretsManagement/components/AddSecretView.tsx new file mode 100644 index 000000000..ecaeb3dc3 --- /dev/null +++ b/src/components/shared/SecretsManagement/components/AddSecretView.tsx @@ -0,0 +1,30 @@ +import { useNavigate } from "@tanstack/react-router"; + +import { BlockStack } from "@/components/ui/layout"; +import { Separator } from "@/components/ui/separator"; +import useToastNotification from "@/hooks/useToastNotification"; + +import { AddSecretForm } from "./AddSecretForm"; +import { SecretsBreadcrumbs } from "./SecretsBreadcrumbs"; + +export function AddSecretView() { + const notify = useToastNotification(); + const navigate = useNavigate(); + + const navigateToList = () => { + navigate({ to: "/settings/secrets", replace: true }); + }; + + const handleSuccess = () => { + notify("Secret added successfully", "success"); + navigateToList(); + }; + + return ( + + + + + + ); +} diff --git a/src/components/shared/SecretsManagement/components/ReplaceSecretView.tsx b/src/components/shared/SecretsManagement/components/ReplaceSecretView.tsx new file mode 100644 index 000000000..9491c4d87 --- /dev/null +++ b/src/components/shared/SecretsManagement/components/ReplaceSecretView.tsx @@ -0,0 +1,60 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { BlockStack } from "@/components/ui/layout"; +import { Separator } from "@/components/ui/separator"; +import { Paragraph } from "@/components/ui/typography"; +import useToastNotification from "@/hooks/useToastNotification"; + +import { fetchSecretsList } from "../secretsStorage"; +import { SecretsQueryKeys } from "../types"; +import { AddSecretForm } from "./AddSecretForm"; +import { SecretsBreadcrumbs } from "./SecretsBreadcrumbs"; + +export function ReplaceSecretView() { + const { secretId } = useParams({ strict: false }); + const notify = useToastNotification(); + const navigate = useNavigate(); + + const { data: secrets } = useSuspenseQuery({ + queryKey: SecretsQueryKeys.All(), + queryFn: fetchSecretsList, + }); + + const secret = secrets.find((s) => s.id === secretId); + + const navigateToList = () => { + navigate({ to: "/settings/secrets", replace: true }); + }; + + useEffect(() => { + if (!secret) { + navigateToList(); + } + }, [secret, navigateToList]); + + if (!secret) { + return null; + } + + const handleSuccess = () => { + notify(`Secret "${secret.name}" updated successfully`, "success"); + navigateToList(); + }; + + return ( + + + + {`Update the value for secret "${secret.name}"`} + + + + + ); +} diff --git a/src/components/shared/SecretsManagement/components/SecretsBreadcrumbs.tsx b/src/components/shared/SecretsManagement/components/SecretsBreadcrumbs.tsx new file mode 100644 index 000000000..6413db14d --- /dev/null +++ b/src/components/shared/SecretsManagement/components/SecretsBreadcrumbs.tsx @@ -0,0 +1,34 @@ +import { Link } from "@tanstack/react-router"; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +interface SecretsBreadcrumbsProps { + title: string; +} + +export function SecretsBreadcrumbs({ title }: SecretsBreadcrumbsProps) { + return ( + + + + + + Secrets Management + + + + + + {title} + + + + ); +} diff --git a/src/components/shared/SecretsManagement/components/SecretsList.tsx b/src/components/shared/SecretsManagement/components/SecretsList.tsx index 5aadccd80..95dc88b04 100644 --- a/src/components/shared/SecretsManagement/components/SecretsList.tsx +++ b/src/components/shared/SecretsManagement/components/SecretsList.tsx @@ -1,24 +1,23 @@ import { useSuspenseQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; -import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import { Text } from "@/components/ui/typography"; import { formatRelativeTime } from "@/utils/date"; import { withSuspenseWrapper } from "../../SuspenseWrapper"; import { fetchSecretsList } from "../secretsStorage"; -import { type Secret, SecretsQueryKeys } from "../types"; +import { SecretsQueryKeys } from "../types"; import { RemoveSecretButton } from "./RemoveSecretButton"; interface SecretsListProps { - onReplace: (secret: Secret) => void; onRemoveSuccess?: () => void; } -function SecretsListInternal({ onReplace, onRemoveSuccess }: SecretsListProps) { +function SecretsListInternal({ onRemoveSuccess }: SecretsListProps) { const { data: secrets } = useSuspenseQuery({ queryKey: SecretsQueryKeys.All(), queryFn: fetchSecretsList, @@ -41,9 +40,8 @@ function SecretsListInternal({ onReplace, onRemoveSuccess }: SecretsListProps) { } return ( - @@ -69,20 +67,22 @@ function SecretsListInternal({ onReplace, onRemoveSuccess }: SecretsListProps) { - + + ))} - + ); } diff --git a/src/components/shared/SecretsManagement/components/SecretsListView.tsx b/src/components/shared/SecretsManagement/components/SecretsListView.tsx new file mode 100644 index 000000000..94e70a241 --- /dev/null +++ b/src/components/shared/SecretsManagement/components/SecretsListView.tsx @@ -0,0 +1,45 @@ +import { Link } from "@tanstack/react-router"; + +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Separator } from "@/components/ui/separator"; +import { Heading, Paragraph } from "@/components/ui/typography"; +import useToastNotification from "@/hooks/useToastNotification"; + +import { SecretsList } from "./SecretsList"; + +export function SecretsListView() { + const notify = useToastNotification(); + + const handleRemoveSuccess = () => { + notify("Secret removed", "success"); + }; + + return ( + + + Secrets Management + + Manage your secrets for use in pipelines. Secret values are stored + securely and injected at runtime. + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/shared/Settings/PersonalPreferences.tsx b/src/components/shared/Settings/PersonalPreferences.tsx deleted file mode 100644 index c7d471f97..000000000 --- a/src/components/shared/Settings/PersonalPreferences.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Settings } from "lucide-react"; -import { useState } from "react"; - -import TooltipButton from "../Buttons/TooltipButton"; -import { PersonalPreferencesDialog } from "./PersonalPreferencesDialog"; - -export function PersonalPreferences() { - const [open, setOpen] = useState(false); - - return ( - <> - setOpen(true)} - data-testid="personal-preferences-button" - > - - - - - ); -} diff --git a/src/components/shared/Settings/PersonalPreferencesDialog.tsx b/src/components/shared/Settings/PersonalPreferencesDialog.tsx deleted file mode 100644 index 944eb6d50..000000000 --- a/src/components/shared/Settings/PersonalPreferencesDialog.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { ExistingFlags } from "@/flags"; - -import { BetaFeatures } from "./BetaFeatures"; -import { Settings } from "./Settings"; -import { useFlagsReducer } from "./useFlagsReducer"; - -interface PersonalPreferencesDialogProps { - open: boolean; - setOpen: (open: boolean) => void; -} - -export function PersonalPreferencesDialog({ - open, - setOpen, -}: PersonalPreferencesDialogProps) { - const [flags, dispatch] = useFlagsReducer(ExistingFlags); - - const handleSetFlag = (flag: string, enabled: boolean) => { - dispatch({ type: "setFlag", payload: { key: flag, enabled } }); - }; - - const betaFlags = Object.values(flags).filter( - (flag) => flag.category === "beta", - ); - const settings = Object.values(flags).filter( - (flag) => flag.category === "setting", - ); - - return ( - - - - Personal Preferences - - - - Configure your personal preferences. - - - - - Settings - Beta Features - - - - - - - - - - - - - - - - - ); -} diff --git a/src/components/shared/Settings/tests/PersonalPreferencesDialog.test.tsx b/src/components/shared/Settings/tests/PersonalPreferencesDialog.test.tsx deleted file mode 100644 index 8ed93692f..000000000 --- a/src/components/shared/Settings/tests/PersonalPreferencesDialog.test.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import type { Flag } from "@/types/configuration"; - -import { PersonalPreferencesDialog } from "../PersonalPreferencesDialog"; -import { useFlagsReducer } from "../useFlagsReducer"; - -// Mock the useFlagsReducer hook -vi.mock("../useFlagsReducer"); - -describe("PersonalPreferencesDialog", () => { - const mockDispatch = vi.fn(); - const mockSetOpen = vi.fn(); - - const mockBetaFlags: Flag[] = [ - { - key: "codeViewer", - name: "Code Viewer virtualization", - description: "Enable the code viewer virtualization.", - enabled: false, - default: false, - category: "beta", - }, - { - key: "testFeature", - name: "Test Feature", - description: "A test feature for testing purposes.", - enabled: true, - default: false, - category: "setting", - }, - ]; - - const mockuseFlagsReducer = vi.mocked(useFlagsReducer); - - beforeEach(() => { - vi.clearAllMocks(); - mockuseFlagsReducer.mockReturnValue([mockBetaFlags, mockDispatch]); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should render dialog when open is true", () => { - render(); - - expect( - screen.getByRole("dialog", { name: "Personal Preferences" }), - ).toBeInTheDocument(); - expect(screen.getByText("Personal Preferences")).toBeInTheDocument(); - expect( - screen.getByText("Configure your personal preferences."), - ).toBeInTheDocument(); - }); - - it("should not render dialog when open is false", () => { - render(); - - expect( - screen.queryByRole("dialog", { name: "Personal Preferences" }), - ).not.toBeInTheDocument(); - }); - - it("should render both tabs", () => { - render(); - - expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument(); - expect( - screen.getByRole("tab", { name: "Beta Features" }), - ).toBeInTheDocument(); - }); - - it("should render settings tab content by default", () => { - render(); - - expect(screen.getByText("Test Feature")).toBeInTheDocument(); - expect( - screen.getByText("A test feature for testing purposes."), - ).toBeInTheDocument(); - }); - - it("should render beta features tab content when clicked", async () => { - const user = userEvent.setup(); - render(); - - const betaTab = screen.getByRole("tab", { name: "Beta Features" }); - await user.click(betaTab); - - expect( - await screen.findByText("Code Viewer virtualization"), - ).toBeInTheDocument(); - expect( - screen.getByText("Enable the code viewer virtualization."), - ).toBeInTheDocument(); - }); - - it("should render all flags with correct information in their respective tabs", async () => { - const user = userEvent.setup(); - render(); - - expect(screen.getByText("Test Feature")).toBeInTheDocument(); - expect( - screen.getByText("A test feature for testing purposes."), - ).toBeInTheDocument(); - - const betaTab = screen.getByRole("tab", { name: "Beta Features" }); - await user.click(betaTab); - - expect( - await screen.findByText("Code Viewer virtualization"), - ).toBeInTheDocument(); - expect( - screen.getByText("Enable the code viewer virtualization."), - ).toBeInTheDocument(); - }); - - it("should render switches with correct initial states in settings tab", () => { - render(); - - const settingsSwitches = screen.getAllByRole("switch"); - - expect(settingsSwitches).toHaveLength(1); - expect(settingsSwitches[0]).toBeChecked(); - }); - - it("should render switches with correct initial states in beta features tab", async () => { - const user = userEvent.setup(); - render(); - - const betaTab = screen.getByRole("tab", { name: "Beta Features" }); - await user.click(betaTab); - - const betaSwitches = await screen.findAllByRole("switch"); - - expect(betaSwitches).toHaveLength(1); - expect(betaSwitches[0]).not.toBeChecked(); - }); - - it("should dispatch setFlag action when settings switch is toggled", async () => { - const user = userEvent.setup(); - render(); - - const settingsSwitch = screen.getByTestId("testFeature-switch"); - await user.click(settingsSwitch); - - expect(mockDispatch).toHaveBeenCalledWith({ - type: "setFlag", - payload: { - key: "testFeature", - enabled: false, - }, - }); - }); - - it("should dispatch setFlag action when beta switch is toggled", async () => { - const user = userEvent.setup(); - render(); - - const betaTab = screen.getByRole("tab", { name: "Beta Features" }); - await user.click(betaTab); - - const betaSwitch = await screen.findByTestId("codeViewer-switch"); - await user.click(betaSwitch); - - expect(mockDispatch).toHaveBeenCalledWith({ - type: "setFlag", - payload: { - key: "codeViewer", - enabled: true, - }, - }); - }); - - it("should render Close button", () => { - render(); - - const closeButton = screen.getByTestId("close-button"); - expect(closeButton).toBeInTheDocument(); - }); - - it("should call setOpen(false) when Close button is clicked", async () => { - const user = userEvent.setup(); - render(); - - const closeButton = screen.getByTestId("close-button"); - await user.click(closeButton); - - expect(mockSetOpen).toHaveBeenCalledWith(false); - }); - - it("should call setOpen when dialog is closed via onOpenChange", async () => { - const user = userEvent.setup(); - render(); - - await user.keyboard("{Escape}"); - - expect(mockSetOpen).toHaveBeenCalled(); - }); - - it("should have proper accessibility attributes", () => { - render(); - - const dialog = screen.getByRole("dialog", { name: "Personal Preferences" }); - expect(dialog).toHaveAttribute("aria-label", "Personal Preferences"); - - expect( - screen.getByText("Configure your personal preferences."), - ).toBeInTheDocument(); - }); - - it("should handle empty flags arrays", async () => { - const user = userEvent.setup(); - mockuseFlagsReducer.mockReturnValue([[], mockDispatch]); - - render(); - - expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument(); - expect( - screen.getByRole("tab", { name: "Beta Features" }), - ).toBeInTheDocument(); - - expect(screen.queryAllByRole("switch")).toHaveLength(0); - - const betaTab = screen.getByRole("tab", { name: "Beta Features" }); - await user.click(betaTab); - - expect(screen.queryAllByRole("switch")).toHaveLength(0); - }); - - it("should maintain consistent dialog structure", () => { - render(); - - expect(screen.getByRole("dialog")).toBeInTheDocument(); - expect(screen.getByText("Personal Preferences")).toBeInTheDocument(); - expect( - screen.getByText("Configure your personal preferences."), - ).toBeInTheDocument(); - expect(screen.getByRole("tab", { name: "Settings" })).toBeInTheDocument(); - expect( - screen.getByRole("tab", { name: "Beta Features" }), - ).toBeInTheDocument(); - expect(screen.getByTestId("close-button")).toBeInTheDocument(); - }); -}); diff --git a/src/routes/Settings/SettingsFlagsContext.tsx b/src/routes/Settings/SettingsFlagsContext.tsx new file mode 100644 index 000000000..56493e5c5 --- /dev/null +++ b/src/routes/Settings/SettingsFlagsContext.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; + +import { useFlagsReducer } from "@/components/shared/Settings/useFlagsReducer"; +import { ExistingFlags } from "@/flags"; +import { + createRequiredContext, + useRequiredContext, +} from "@/hooks/useRequiredContext"; +import type { Flag } from "@/types/configuration"; + +interface SettingsFlagsContextValue { + betaFlags: Flag[]; + settings: Flag[]; + handleSetFlag: (flag: string, enabled: boolean) => void; +} + +const SettingsFlagsContext = createRequiredContext( + "SettingsFlagsContext", +); + +export function SettingsFlagsProvider({ children }: { children: ReactNode }) { + const [flags, dispatch] = useFlagsReducer(ExistingFlags); + + const handleSetFlag = (flag: string, enabled: boolean) => { + dispatch({ type: "setFlag", payload: { key: flag, enabled } }); + }; + + const betaFlags = Object.values(flags).filter( + (flag) => flag.category === "beta", + ); + const settings = Object.values(flags).filter( + (flag) => flag.category === "setting", + ); + + return ( + + {children} + + ); +} + +export function useSettingsFlags() { + return useRequiredContext(SettingsFlagsContext); +} diff --git a/src/routes/Settings/SettingsLayout.tsx b/src/routes/Settings/SettingsLayout.tsx new file mode 100644 index 000000000..6d95a6ede --- /dev/null +++ b/src/routes/Settings/SettingsLayout.tsx @@ -0,0 +1,110 @@ +import { Link, Outlet, useRouter } from "@tanstack/react-router"; + +import { Button } from "@/components/ui/button"; +import { Icon, type IconName } from "@/components/ui/icon"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Heading, Text } from "@/components/ui/typography"; +import { cn } from "@/lib/utils"; + +import { SettingsFlagsProvider } from "./SettingsFlagsContext"; + +interface SidebarItem { + to: string; + label: string; + icon: IconName; + testId: string; +} + +const SIDEBAR_ITEMS: SidebarItem[] = [ + { + to: "/settings/backend", + label: "Backend", + icon: "Database", + testId: "settings-nav-backend", + }, + { + to: "/settings/preferences", + label: "Preferences", + icon: "Settings", + testId: "settings-nav-preferences", + }, + { + to: "/settings/beta-features", + label: "Beta Features", + icon: "FlaskConical", + testId: "settings-nav-beta-features", + }, + { + to: "/settings/secrets", + label: "Secrets", + icon: "Lock", + testId: "settings-nav-secrets", + }, +]; + +export function SettingsLayout() { + const router = useRouter(); + + const handleGoBack = () => { + router.history.back(); + }; + + return ( + +
+ + + + Settings + + + + + {SIDEBAR_ITEMS.map((item) => ( + + {({ isActive }) => ( + + )} + + ))} + + + + + + + +
+
+ ); +} diff --git a/src/routes/Settings/sections/BackendSettings.tsx b/src/routes/Settings/sections/BackendSettings.tsx new file mode 100644 index 000000000..e1b863801 --- /dev/null +++ b/src/routes/Settings/sections/BackendSettings.tsx @@ -0,0 +1,248 @@ +import { type ChangeEvent, useEffect, useState } from "react"; + +import { InfoBox } from "@/components/shared/InfoBox"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { Input } from "@/components/ui/input"; +import { BlockStack, InlineStack } from "@/components/ui/layout"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Heading, Paragraph } from "@/components/ui/typography"; +import useToastNotification from "@/hooks/useToastNotification"; +import { cn } from "@/lib/utils"; +import { useBackend } from "@/providers/BackendProvider"; +import { API_URL } from "@/utils/constants"; + +const HasEnvConfig = !!API_URL; +const ShowRelativePathOption = !API_URL; + +export function BackendSettings() { + const notify = useToastNotification(); + const { + backendUrl, + available, + isConfiguredFromEnv, + isConfiguredFromRelativePath, + ping, + setEnvConfig, + setRelativePathConfig, + setBackendUrl, + } = useBackend(); + + const [inputBackendUrl, setInputBackendUrl] = useState( + isConfiguredFromEnv ? "" : backendUrl, + ); + const [inputBackendTestResult, setInputBackendTestResult] = useState< + boolean | null + >(null); + const [isEnvConfig, setIsEnvConfig] = useState(isConfiguredFromEnv); + const [isRelativePathConfig, setIsRelativePathConfig] = useState( + isConfiguredFromRelativePath, + ); + + const handleInputChange = (e: ChangeEvent) => { + setInputBackendUrl(e.target.value); + setInputBackendTestResult(null); + }; + + const handleRefresh = () => { + ping({}); + }; + + const handleTest = async () => { + const result = await ping({ + url: inputBackendUrl, + notifyResult: true, + saveAvailability: false, + }); + setInputBackendTestResult(result); + }; + + const handleEnvSwitch = (checked: boolean) => { + setIsEnvConfig(checked); + if (checked) setIsRelativePathConfig(false); + }; + + const handleRelativePathSwitch = (checked: boolean) => { + setIsRelativePathConfig(checked); + if (checked) setIsEnvConfig(false); + }; + + const handleSave = () => { + setEnvConfig(isEnvConfig); + setRelativePathConfig(isRelativePathConfig); + setBackendUrl(inputBackendUrl); + setInputBackendUrl(inputBackendUrl.trim()); + setInputBackendTestResult(null); + notify("Backend configuration saved", "success"); + }; + + useEffect(() => { + setIsEnvConfig(isConfiguredFromEnv); + setIsRelativePathConfig(isConfiguredFromRelativePath); + }, [isConfiguredFromEnv, isConfiguredFromRelativePath]); + + useEffect(() => { + setInputBackendUrl( + isConfiguredFromEnv || isConfiguredFromRelativePath ? "" : backendUrl, + ); + }, [isConfiguredFromEnv, isConfiguredFromRelativePath, backendUrl]); + + const hasBackendConfigured = + !!inputBackendUrl.trim() || + (isEnvConfig && HasEnvConfig) || + isRelativePathConfig; + const saveButtonText = hasBackendConfigured + ? "Save" + : "Continue without backend"; + + return ( + + + Backend Configuration + + Configure the connection to your Tangle backend server. + + + + + + + Backend status: + + + + + {available ? "available" : "unavailable"} + + + + {isConfiguredFromEnv && HasEnvConfig + ? "Configured from .env" + : backendUrl.length > 0 + ? `Configured to ${backendUrl}` + : isConfiguredFromRelativePath + ? "Configured relative to host domain" + : "No backend configured"} + + + + + + {HasEnvConfig && ( + <> + + + Configure using .env + + + + Use backend url configuration from environment file. + + + + + )} + + {ShowRelativePathOption && ( + <> + + + Use same-domain backend + + + + Use backend configuration relative to the current domain. + + + {isRelativePathConfig && ( + + Backend requests will be made to {window.location.origin} + /api. + + )} + + + )} + + {!(isEnvConfig && HasEnvConfig) && !isRelativePathConfig && ( + <> + + + You can set the backend URL in the environment file or use the input + below. + + + Backend URL + + + + {inputBackendTestResult !== null && ( + + + {inputBackendTestResult ? "✓" : "✗"} + + + {inputBackendTestResult + ? "Backend responded" + : "No response"} + + + )} + + + + + + {!inputBackendUrl.trim() && ( + + + + No backend is configured. Certain features may not be operable. + + + )} + + )} + + + + + + + + ); +} diff --git a/src/routes/Settings/sections/BetaFeaturesSettings.tsx b/src/routes/Settings/sections/BetaFeaturesSettings.tsx new file mode 100644 index 000000000..ea0367aff --- /dev/null +++ b/src/routes/Settings/sections/BetaFeaturesSettings.tsx @@ -0,0 +1,9 @@ +import { BetaFeatures } from "@/components/shared/Settings/BetaFeatures"; + +import { useSettingsFlags } from "../SettingsFlagsContext"; + +export function BetaFeaturesSettings() { + const { betaFlags, handleSetFlag } = useSettingsFlags(); + + return ; +} diff --git a/src/routes/Settings/sections/PreferencesSettings.tsx b/src/routes/Settings/sections/PreferencesSettings.tsx new file mode 100644 index 000000000..7ac41e6db --- /dev/null +++ b/src/routes/Settings/sections/PreferencesSettings.tsx @@ -0,0 +1,9 @@ +import { Settings } from "@/components/shared/Settings/Settings"; + +import { useSettingsFlags } from "../SettingsFlagsContext"; + +export function PreferencesSettings() { + const { settings, handleSetFlag } = useSettingsFlags(); + + return ; +} diff --git a/src/routes/Settings/sections/SecretsSettings.tsx b/src/routes/Settings/sections/SecretsSettings.tsx new file mode 100644 index 000000000..873dc915f --- /dev/null +++ b/src/routes/Settings/sections/SecretsSettings.tsx @@ -0,0 +1,21 @@ +import { Outlet } from "@tanstack/react-router"; +import { Suspense } from "react"; + +import { BlockStack } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; + +function SecretsSettingsSkeleton() { + return ( + + + + ); +} + +export function SecretsSettings() { + return ( + }> + + + ); +} diff --git a/src/routes/router.ts b/src/routes/router.ts index 212152935..06e5c9211 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -4,11 +4,15 @@ import { createRoute, createRouter, Outlet, + redirect, } from "@tanstack/react-router"; import { ErrorPage } from "@/components/shared/ErrorPage"; import { AuthorizationResultScreen as GitHubAuthorizationResultScreen } from "@/components/shared/GitHubAuth/AuthorizationResultScreen"; import { AuthorizationResultScreen as HuggingFaceAuthorizationResultScreen } from "@/components/shared/HuggingFaceAuth/AuthorizationResultScreen"; +import { AddSecretView } from "@/components/shared/SecretsManagement/components/AddSecretView"; +import { ReplaceSecretView } from "@/components/shared/SecretsManagement/components/ReplaceSecretView"; +import { SecretsListView } from "@/components/shared/SecretsManagement/components/SecretsListView"; import { BASE_URL, IS_GITHUB_PAGES } from "@/utils/constants"; import RootLayout from "../components/layout/RootLayout"; @@ -17,6 +21,11 @@ import Home from "./Home"; import NotFoundPage from "./NotFoundPage"; import PipelineRun from "./PipelineRun"; import { QuickStartPage } from "./QuickStart"; +import { BackendSettings } from "./Settings/sections/BackendSettings"; +import { BetaFeaturesSettings } from "./Settings/sections/BetaFeaturesSettings"; +import { PreferencesSettings } from "./Settings/sections/PreferencesSettings"; +import { SecretsSettings } from "./Settings/sections/SecretsSettings"; +import { SettingsLayout } from "./Settings/SettingsLayout"; declare module "@tanstack/react-router" { interface Register { @@ -27,6 +36,7 @@ declare module "@tanstack/react-router" { export const EDITOR_PATH = "/editor"; export const RUNS_BASE_PATH = "/runs"; export const QUICK_START_PATH = "/quick-start"; +const SETTINGS_PATH = "/settings"; export const APP_ROUTES = { HOME: "/", QUICK_START: QUICK_START_PATH, @@ -34,9 +44,16 @@ export const APP_ROUTES = { RUN_DETAIL: `${RUNS_BASE_PATH}/$id`, RUN_DETAIL_WITH_SUBGRAPH: `${RUNS_BASE_PATH}/$id/$subgraphExecutionId`, RUNS: RUNS_BASE_PATH, + SETTINGS: SETTINGS_PATH, + SETTINGS_BACKEND: `${SETTINGS_PATH}/backend`, + SETTINGS_PREFERENCES: `${SETTINGS_PATH}/preferences`, + SETTINGS_BETA_FEATURES: `${SETTINGS_PATH}/beta-features`, + SETTINGS_SECRETS: `${SETTINGS_PATH}/secrets`, + SETTINGS_SECRETS_ADD: `${SETTINGS_PATH}/secrets/add`, + SETTINGS_SECRETS_REPLACE: `${SETTINGS_PATH}/secrets/$secretId/replace`, GITHUB_AUTH_CALLBACK: "/authorize/github", HUGGINGFACE_AUTH_CALLBACK: "/authorize/huggingface", -}; +} as const; const rootRoute = createRootRoute({ component: Outlet, @@ -62,6 +79,62 @@ const quickStartRoute = createRoute({ component: QuickStartPage, }); +const settingsLayoutRoute = createRoute({ + getParentRoute: () => mainLayout, + path: SETTINGS_PATH, + component: SettingsLayout, +}); + +const settingsIndexRoute = createRoute({ + getParentRoute: () => settingsLayoutRoute, + path: "/", + beforeLoad: () => { + throw redirect({ to: APP_ROUTES.SETTINGS_BACKEND }); + }, +}); + +const settingsBackendRoute = createRoute({ + getParentRoute: () => settingsLayoutRoute, + path: "/backend", + component: BackendSettings, +}); + +const settingsPreferencesRoute = createRoute({ + getParentRoute: () => settingsLayoutRoute, + path: "/preferences", + component: PreferencesSettings, +}); + +const settingsBetaFeaturesRoute = createRoute({ + getParentRoute: () => settingsLayoutRoute, + path: "/beta-features", + component: BetaFeaturesSettings, +}); + +const settingsSecretsRoute = createRoute({ + getParentRoute: () => settingsLayoutRoute, + path: "/secrets", + component: SecretsSettings, +}); + +const secretsIndexRoute = createRoute({ + getParentRoute: () => settingsSecretsRoute, + path: "/", + component: SecretsListView, +}); + +const secretsAddRoute = createRoute({ + getParentRoute: () => settingsSecretsRoute, + path: "/add", + component: AddSecretView, +}); + +const secretsReplaceRoute = createRoute({ + getParentRoute: () => settingsSecretsRoute, + path: "/$secretId/replace", + component: ReplaceSecretView, +}); + const editorRoute = createRoute({ getParentRoute: () => mainLayout, path: APP_ROUTES.PIPELINE_EDITOR, @@ -96,9 +169,24 @@ const runDetailWithSubgraphRoute = createRoute({ component: PipelineRun, }); +const secretsRouteTree = settingsSecretsRoute.addChildren([ + secretsIndexRoute, + secretsAddRoute, + secretsReplaceRoute, +]); + +const settingsRouteTree = settingsLayoutRoute.addChildren([ + settingsIndexRoute, + settingsBackendRoute, + settingsPreferencesRoute, + settingsBetaFeaturesRoute, + secretsRouteTree, +]); + const appRouteTree = mainLayout.addChildren([ indexRoute, quickStartRoute, + settingsRouteTree, editorRoute, runDetailRoute, runDetailWithSubgraphRoute, diff --git a/tests/e2e/componentlib.spec.ts b/tests/e2e/componentlib.spec.ts index 3ce395b6f..612f3c43d 100644 --- a/tests/e2e/componentlib.spec.ts +++ b/tests/e2e/componentlib.spec.ts @@ -9,6 +9,7 @@ import { locateFolderByName, openComponentLibFolder, removeComponentFromCanvas, + setBetaFlag, } from "./helpers"; /** @@ -25,31 +26,10 @@ test.describe("Component Library", () => { test.beforeAll(async ({ browser }) => { page = await browser.newPage(); + await setBetaFlag(page, "remote-component-library-search", false); await createNewPipeline(page); await expect(page.locator("[data-testid='search-input']")).toBeVisible(); - - await page.getByTestId("personal-preferences-button").click(); - - const dialog = page.getByTestId("personal-preferences-dialog"); - await expect(dialog).toBeVisible(); - - await dialog.getByRole("tab", { name: "Beta Features" }).click(); - - const switchElement = dialog.getByTestId( - "remote-component-library-search-switch", - ); - await expect(switchElement).toBeVisible({ timeout: 10000 }); - - // Enable secrets if not already enabled - if ((await switchElement.getAttribute("aria-checked")) !== "false") { - await switchElement.click(); - await expect(switchElement).toHaveAttribute("aria-checked", "false"); - } - - await dialog.press("Escape"); - await expect(dialog).toBeHidden(); - await locateFolderByName(page, "Standard library"); }); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 068f8ca76..ab96423fd 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -274,6 +274,124 @@ export async function removeComponentFromCanvas( * @param options.searchFilterCount - Expected filter count badge text (optional) * @param options.searchResultsCount - Expected result count or "*" for any (optional) */ +/** + * Settings Helpers + */ + +type SettingsSection = "backend" | "preferences" | "beta-features" | "secrets"; + +/** + * Navigates to a settings section by URL. + * @param page - Playwright page object + * @param section - The settings section to navigate to + */ +async function navigateToSettings( + page: Page, + section: SettingsSection, +): Promise { + await page.goto(`/settings/${section}`); + const nav = page.getByTestId(`settings-nav-${section}`); + await expect( + nav, + `Settings nav item for "${section}" should be visible`, + ).toBeVisible(); +} + +/** + * Toggles a beta feature flag via the settings UI. + * @param page - Playwright page object + * @param flagKey - The flag key as defined in flags.ts (e.g. "remote-component-library-search") + * @param enabled - Whether the flag should be enabled or disabled + */ +export async function setBetaFlag( + page: Page, + flagKey: string, + enabled: boolean, +): Promise { + await navigateToSettings(page, "beta-features"); + + const flagSwitch = page.getByTestId(`${flagKey}-switch`); + await expect( + flagSwitch, + `Beta flag switch "${flagKey}" should be visible`, + ).toBeVisible(); + + const isChecked = await flagSwitch.isChecked(); + if (isChecked !== enabled) { + await flagSwitch.click(); + } +} + +/** + * Secrets Management Helpers + */ + +/** + * Navigates to the secrets list view in settings. + * @param page - Playwright page object + */ +export async function navigateToSecretsList(page: Page): Promise { + await navigateToSettings(page, "secrets"); + await expect( + page.getByRole("heading", { name: "Secrets Management" }), + "Secrets Management heading should be visible", + ).toBeVisible(); +} + +/** + * Adds a new secret via the settings secrets UI. + * Assumes the page is already on the secrets list view. + * @param page - Playwright page object + * @param name - The secret name + * @param value - The secret value + */ +export async function addSecret( + page: Page, + name: string, + value: string, +): Promise { + await page.getByTestId("add-secret-link").click(); + await expect( + page.getByTestId("secret-name-input"), + "Secret name input should be visible on Add Secret view", + ).toBeVisible(); + + await page.getByTestId("secret-name-input").fill(name); + await page.getByTestId("secret-value-input").fill(value); + await page.getByTestId("add-secret-submit-button").click(); + + await expect( + page.getByRole("heading", { name: "Secrets Management" }), + "Should navigate back to secrets list after adding", + ).toBeVisible(); +} + +/** + * Removes a secret from the secrets list view. + * Assumes the page is already on the secrets list view. + * @param page - Playwright page object + * @param secretName - The name of the secret to remove + */ +export async function removeSecret( + page: Page, + secretName: string, +): Promise { + const secretItem = page.locator( + `[data-testid="secret-item"][data-secret-name="${secretName}"]`, + ); + await expect( + secretItem, + `Secret "${secretName}" should be visible for removal`, + ).toBeVisible(); + + await secretItem.getByTestId("secret-remove-button").click(); + + await expect( + secretItem, + `Secret "${secretName}" should be removed from list`, + ).toBeHidden(); +} + export async function assertSearchState( page: Page, options: { diff --git a/tests/e2e/published-componentlib.spec.ts b/tests/e2e/published-componentlib.spec.ts index c2fc266e3..070765192 100644 --- a/tests/e2e/published-componentlib.spec.ts +++ b/tests/e2e/published-componentlib.spec.ts @@ -10,6 +10,7 @@ import { locateFolderByName, openComponentLibFolder, removeComponentFromCanvas, + setBetaFlag, } from "./helpers"; /** @@ -27,30 +28,10 @@ test.describe("Published Component Library", () => { page = await browser.newPage(); await createNewPipeline(page); + await setBetaFlag(page, "remote-component-library-search", true); + await page.goBack(); await expect(page.locator("[data-testid='search-input']")).toBeVisible(); - - await page.getByTestId("personal-preferences-button").click(); - - const dialog = page.getByTestId("personal-preferences-dialog"); - await expect(dialog).toBeVisible(); - - await dialog.getByRole("tab", { name: "Beta Features" }).click(); - - const switchElement = dialog.getByTestId( - "remote-component-library-search-switch", - ); - await expect(switchElement).toBeVisible({ timeout: 10000 }); - - // Enable secrets if not already enabled - if ((await switchElement.getAttribute("aria-checked")) !== "true") { - await switchElement.click(); - await expect(switchElement).toHaveAttribute("aria-checked", "true"); - } - - // bypass the dialog close button in case it is out of view - await dialog.press("Escape"); - await expect(dialog).toBeHidden(); }); test.afterAll(async () => { diff --git a/tests/e2e/published-componentlifecycle.spec.ts b/tests/e2e/published-componentlifecycle.spec.ts index ac46ebcb9..1ea2159cf 100644 --- a/tests/e2e/published-componentlifecycle.spec.ts +++ b/tests/e2e/published-componentlifecycle.spec.ts @@ -6,6 +6,7 @@ import { locateComponentInFolder, locateFolderByName, openComponentLibFolder, + setBetaFlag, } from "./helpers"; /** @@ -24,31 +25,12 @@ test.describe("Published Component Library - Lifecycle", () => { test.beforeAll(async ({ browser }) => { page = await browser.newPage(); + await setBetaFlag(page, "remote-component-library-search", true); + await createNewPipeline(page); await expect(page.locator("[data-testid='search-input']")).toBeVisible(); - await page.getByTestId("personal-preferences-button").click(); - - const dialog = page.getByTestId("personal-preferences-dialog"); - await expect(dialog).toBeVisible(); - - await dialog.getByRole("tab", { name: "Beta Features" }).click(); - - const switchElement = dialog.getByTestId( - "remote-component-library-search-switch", - ); - await expect(switchElement).toBeVisible({ timeout: 10000 }); - - // Enable secrets if not already enabled - if ((await switchElement.getAttribute("aria-checked")) !== "true") { - await switchElement.click(); - await expect(switchElement).toHaveAttribute("aria-checked", "true"); - } - - await dialog.press("Escape"); - await expect(dialog).toBeHidden(); - await locateFolderByName(page, "Standard library"); }); diff --git a/tests/e2e/secrets-in-arguments.spec.ts b/tests/e2e/secrets-in-arguments.spec.ts index 97fb5515a..77c01ba08 100644 --- a/tests/e2e/secrets-in-arguments.spec.ts +++ b/tests/e2e/secrets-in-arguments.spec.ts @@ -2,20 +2,24 @@ import { expect, type Page, test } from "@playwright/test"; import { readFileSync } from "fs"; import { + addSecret, createNewPipeline, locateFlowCanvas, + navigateToSecretsList, + removeSecret, waitForContextPanel, } from "./helpers"; /** * Tests for using secrets in component arguments. - * Verifies the flow: enable secrets flag → create secret → drop component → + * Verifies the flow: create secret → drop component → * assign secret to argument → fill other fields → submit run. */ test.describe.configure({ mode: "serial" }); test.describe("Secrets in Component Arguments", () => { let page: Page; + let editorUrl: string; const testSecretName = "TEST_GITHUB_PAT"; const testSecretValue = "ghp_test_token_12345"; @@ -24,6 +28,7 @@ test.describe("Secrets in Component Arguments", () => { test.beforeAll(async ({ browser }) => { page = await browser.newPage(); await createNewPipeline(page); + editorUrl = page.url(); }); test.afterAll(async () => { @@ -32,20 +37,10 @@ test.describe("Secrets in Component Arguments", () => { }); test("create a secret for use in component arguments", async () => { - const dialog = await openManageSecretsDialog(page); + await navigateToSecretsList(page); + await addSecret(page, testSecretName, testSecretValue); - await dialog.getByTestId("add-secret-button").click(); - - await expect( - dialog.getByRole("heading"), - "Dialog should show Add Secret form title", - ).toContainText("Add Secret"); - - await dialog.getByTestId("secret-name-input").fill(testSecretName); - await dialog.getByTestId("secret-value-input").fill(testSecretValue); - await dialog.getByTestId("add-secret-submit-button").click(); - - const secretItem = dialog.locator( + const secretItem = page.locator( `[data-testid="secret-item"][data-secret-name="${testSecretName}"]`, ); await expect( @@ -53,7 +48,11 @@ test.describe("Secrets in Component Arguments", () => { "Newly added secret should appear in the list", ).toBeVisible(); - await closeDialog(page); + await page.goto(editorUrl); + await expect( + locateFlowCanvas(page), + "Editor canvas should be visible after navigating back", + ).toBeVisible({ timeout: 30_000 }); }); test("drop component with secret argument onto canvas", async () => { @@ -92,8 +91,6 @@ test.describe("Secrets in Component Arguments", () => { await githubPatField.hover(); - // When only secrets are available (no task annotations), the component renders - // a direct button that opens the secret dialog immediately const directSecretButton = githubPatField.getByTestId( "open-secret-dialog-button", ); @@ -174,55 +171,15 @@ test.describe("Secrets in Component Arguments", () => { }); /** - * Opens the Manage Secrets dialog via the top bar button - * @returns The dialog locator for further interactions - */ -async function openManageSecretsDialog(page: Page) { - const manageSecretsButton = page.getByTestId("manage-secrets-button"); - await expect( - manageSecretsButton, - "Manage Secrets button should be visible", - ).toBeVisible(); - await manageSecretsButton.click(); - - const dialog = page.getByTestId("manage-secrets-dialog"); - await expect(dialog, "Manage Secrets dialog should open").toBeVisible(); - - return dialog; -} - -/** - * Closes the currently open dialog using Escape key - */ -async function closeDialog(page: Page): Promise { - await page.keyboard.press("Escape"); - - const manageSecretsDialog = page.getByTestId("manage-secrets-dialog"); - await expect(manageSecretsDialog).toBeHidden(); -} - -/** - * Cleans up a test secret. + * Cleans up a test secret via the settings secrets UI. * Runs in afterAll hook to ensure cleanup happens regardless of test failures. */ async function cleanupTestSecret( page: Page, secretName: string, ): Promise { - const dialog = await openManageSecretsDialog(page); - - const secretItem = dialog.locator( - `[data-testid="secret-item"][data-secret-name="${secretName}"]`, - ); - - await expect( - secretItem, - "Test secret should exist for cleanup", - ).toBeVisible(); - await secretItem.getByTestId("secret-remove-button").click(); - await expect(secretItem, "Test secret should be removed").toBeHidden(); - - await closeDialog(page); + await navigateToSecretsList(page); + await removeSecret(page, secretName); } /** diff --git a/tests/e2e/secrets-management.spec.ts b/tests/e2e/secrets-management.spec.ts index 70c533611..f0fde51f8 100644 --- a/tests/e2e/secrets-management.spec.ts +++ b/tests/e2e/secrets-management.spec.ts @@ -1,6 +1,6 @@ import { expect, type Page, test } from "@playwright/test"; -import { createNewPipeline } from "./helpers"; +import { addSecret, navigateToSecretsList, removeSecret } from "./helpers"; /** * Due to the serial nature of secrets management (shared state), @@ -15,75 +15,35 @@ test.describe("Secrets Management", () => { test.beforeAll(async ({ browser }) => { page = await browser.newPage(); - - await createNewPipeline(page); + await navigateToSecretsList(page); }); test.afterAll(async () => { await page.close(); }); - test("Manage Secrets button is visible when flag is enabled", async () => { - const manageSecretsButton = page.getByTestId("manage-secrets-button"); - await expect( - manageSecretsButton, - "Manage Secrets button should be visible when secrets flag is enabled", - ).toBeVisible(); - }); - - test("opens Manage Secrets dialog and verifies empty state", async () => { - await openManageSecretsDialog(page); - - const dialog = page.getByTestId("manage-secrets-dialog"); - await expect( - dialog, - "Dialog should be visible after opening", - ).toBeVisible(); - - // Verify empty state is shown - const emptyState = dialog.getByTestId("secrets-empty-state"); + test("verifies empty state when no secrets exist", async () => { + const emptyState = page.getByTestId("secrets-empty-state"); await expect( emptyState, "Empty state should be visible when no secrets exist", ).toBeVisible(); await expect(emptyState).toContainText("No secrets configured"); - // Verify Add Secret button is visible - const addSecretButton = dialog.getByTestId("add-secret-button"); + const addSecretLink = page.getByTestId("add-secret-link"); await expect( - addSecretButton, - "Add Secret button should be visible in list view", + addSecretLink, + "Add Secret link should be visible in list view", ).toBeVisible(); - - await closeDialog(page); }); test("adds a new secret and removes it", async () => { const testSecretName = "TEST_API_KEY"; const testSecretValue = "super-secret-value-123"; - await openManageSecretsDialog(page); - - const dialog = page.getByTestId("manage-secrets-dialog"); - - // Click Add Secret button - await dialog.getByTestId("add-secret-button").click(); - - // Verify form is shown with correct title - await expect( - dialog.getByRole("heading"), - "Dialog should show Add Secret form title", - ).toContainText("Add Secret"); - - // Fill in the secret form - await dialog.getByTestId("secret-name-input").fill(testSecretName); - await dialog.getByTestId("secret-value-input").fill(testSecretValue); - - // Submit the form - await dialog.getByTestId("add-secret-submit-button").click(); + await addSecret(page, testSecretName, testSecretValue); - // Verify we're back to list view with the secret visible - const secretItem = dialog.locator( + const secretItem = page.locator( `[data-testid="secret-item"][data-secret-name="${testSecretName}"]`, ); await expect( @@ -92,20 +52,12 @@ test.describe("Secrets Management", () => { ).toBeVisible(); await expect(secretItem).toContainText(testSecretName); - // Clean up: Remove the secret - await secretItem.getByTestId("secret-remove-button").click(); + await removeSecret(page, testSecretName); - // Verify secret is removed and empty state is shown await expect( - secretItem, - "Secret should be removed from list after deletion", - ).toBeHidden(); - await expect( - dialog.getByTestId("secrets-empty-state"), + page.getByTestId("secrets-empty-state"), "Empty state should reappear after last secret is deleted", ).toBeVisible(); - - await closeDialog(page); }); test("replaces an existing secret value", async () => { @@ -113,18 +65,9 @@ test.describe("Secrets Management", () => { const initialValue = "initial-value"; const replacedValue = "replaced-value"; - await openManageSecretsDialog(page); - - const dialog = page.getByTestId("manage-secrets-dialog"); + await addSecret(page, testSecretName, initialValue); - // First, create a secret to replace - await dialog.getByTestId("add-secret-button").click(); - await dialog.getByTestId("secret-name-input").fill(testSecretName); - await dialog.getByTestId("secret-value-input").fill(initialValue); - await dialog.getByTestId("add-secret-submit-button").click(); - - // Wait for the secret to appear in the list - const secretItem = dialog.locator( + const secretItem = page.locator( `[data-testid="secret-item"][data-secret-name="${testSecretName}"]`, ); await expect( @@ -132,61 +75,45 @@ test.describe("Secrets Management", () => { "Secret should appear in list after creation", ).toBeVisible(); - // Click the edit button to replace the secret await secretItem.getByTestId("secret-edit-button").click(); - // Verify we're in replace mode + const replaceBreadcrumb = page.getByRole("navigation", { + name: "breadcrumb", + }); await expect( - dialog.getByRole("heading"), - "Dialog should show Replace Secret form title", - ).toContainText("Replace Secret"); + replaceBreadcrumb.getByText("Replace Secret"), + "Breadcrumb should show Replace Secret", + ).toBeVisible(); - // The name input should be disabled in replace mode - const nameInput = dialog.getByTestId("secret-name-input"); + const nameInput = page.getByTestId("secret-name-input"); await expect( nameInput, "Name input should be disabled when replacing a secret", ).toBeDisabled(); await expect(nameInput).toHaveValue(testSecretName); - // Fill in the new value - await dialog.getByTestId("secret-value-input").fill(replacedValue); + await page.getByTestId("secret-value-input").fill(replacedValue); + await page.getByTestId("update-secret-submit-button").click(); - // Submit the update - await dialog.getByTestId("update-secret-submit-button").click(); - - // Verify we're back to list view await expect( - secretItem, - "Secret should remain visible after value update", + page.getByRole("heading", { name: "Secrets Management" }), + "Should navigate back to list after replacing", ).toBeVisible(); - - // Clean up: Remove the secret - await secretItem.getByTestId("secret-remove-button").click(); await expect( secretItem, - "Secret should be removed after cleanup", - ).toBeHidden(); + "Secret should remain visible after value update", + ).toBeVisible(); - await closeDialog(page); + await removeSecret(page, testSecretName); }); test("removes a secret from the list", async () => { const testSecretName = "DELETE_TEST_SECRET"; const testSecretValue = "delete-me"; - await openManageSecretsDialog(page); - - const dialog = page.getByTestId("manage-secrets-dialog"); - - // Create a secret to delete - await dialog.getByTestId("add-secret-button").click(); - await dialog.getByTestId("secret-name-input").fill(testSecretName); - await dialog.getByTestId("secret-value-input").fill(testSecretValue); - await dialog.getByTestId("add-secret-submit-button").click(); + await addSecret(page, testSecretName, testSecretValue); - // Verify the secret is in the list - const secretItem = dialog.locator( + const secretItem = page.locator( `[data-testid="secret-item"][data-secret-name="${testSecretName}"]`, ); await expect( @@ -194,67 +121,39 @@ test.describe("Secrets Management", () => { "Secret should appear in list after creation", ).toBeVisible(); - // Remove the secret - await secretItem.getByTestId("secret-remove-button").click(); + await removeSecret(page, testSecretName); - // Verify the secret is removed await expect( secretItem, - "Secret should be removed from list after deletion", + "Deleted secret should no longer appear in the list", ).toBeHidden(); - - // Verify empty state is shown again - await expect( - dialog.getByTestId("secrets-empty-state"), - "Empty state should reappear after last secret is deleted", - ).toBeVisible(); - - await closeDialog(page); }); test("cancels adding a secret and returns to list", async () => { - await openManageSecretsDialog(page); - - const dialog = page.getByTestId("manage-secrets-dialog"); - - // Click Add Secret button - await dialog.getByTestId("add-secret-button").click(); + await page.getByTestId("add-secret-link").click(); - // Verify form is shown + const breadcrumb = page.getByRole("navigation", { name: "breadcrumb" }); await expect( - dialog.getByRole("heading"), - "Dialog should show Add Secret form", - ).toContainText("Add Secret"); + breadcrumb.getByText("Add Secret"), + "Breadcrumb should show Add Secret", + ).toBeVisible(); - // Click Cancel button - await dialog.getByTestId("secret-form-cancel-button").click(); + await page.getByTestId("secret-form-cancel-button").click(); - // Verify we're back to list view await expect( - dialog.getByRole("heading"), - "Dialog should return to list view after cancel", - ).toContainText("Manage Secrets"); - await expect(dialog.getByTestId("add-secret-button")).toBeVisible(); - - await closeDialog(page); + page.getByRole("heading", { name: "Secrets Management" }), + "Should return to list view after cancel", + ).toBeVisible(); + await expect(page.getByTestId("add-secret-link")).toBeVisible(); }); test("cancels replacing a secret and returns to list", async () => { const testSecretName = "CANCEL_REPLACE_TEST"; const testSecretValue = "cancel-test-value"; - await openManageSecretsDialog(page); + await addSecret(page, testSecretName, testSecretValue); - const dialog = page.getByTestId("manage-secrets-dialog"); - - // Create a secret - await dialog.getByTestId("add-secret-button").click(); - await dialog.getByTestId("secret-name-input").fill(testSecretName); - await dialog.getByTestId("secret-value-input").fill(testSecretValue); - await dialog.getByTestId("add-secret-submit-button").click(); - - // Click edit to go to replace mode - const secretItem = dialog.locator( + const secretItem = page.locator( `[data-testid="secret-item"][data-secret-name="${testSecretName}"]`, ); await expect( @@ -263,65 +162,25 @@ test.describe("Secrets Management", () => { ).toBeVisible(); await secretItem.getByTestId("secret-edit-button").click(); - // Verify we're in replace mode + const cancelReplaceBreadcrumb = page.getByRole("navigation", { + name: "breadcrumb", + }); await expect( - dialog.getByRole("heading"), - "Dialog should show Replace Secret form", - ).toContainText("Replace Secret"); + cancelReplaceBreadcrumb.getByText("Replace Secret"), + "Breadcrumb should show Replace Secret", + ).toBeVisible(); - // Cancel the replace - await dialog.getByTestId("secret-form-cancel-button").click(); + await page.getByTestId("secret-form-cancel-button").click(); - // Verify we're back to list view await expect( - dialog.getByRole("heading"), - "Dialog should return to list view after cancel", - ).toContainText("Manage Secrets"); + page.getByRole("heading", { name: "Secrets Management" }), + "Should return to list view after cancel", + ).toBeVisible(); await expect( secretItem, "Secret should still be visible after canceling replace", ).toBeVisible(); - // Clean up - await secretItem.getByTestId("secret-remove-button").click(); - await expect( - secretItem, - "Secret should be removed after cleanup", - ).toBeHidden(); - - await closeDialog(page); + await removeSecret(page, testSecretName); }); }); - -/** - * Opens the Manage Secrets dialog via the top bar button - * @param page - Playwright page object - * @throws Error if button is not visible or dialog fails to open - */ -async function openManageSecretsDialog(page: Page): Promise { - const manageSecretsButton = page.getByTestId("manage-secrets-button"); - await expect( - manageSecretsButton, - "Manage Secrets button should be visible to open dialog", - ).toBeVisible(); - await manageSecretsButton.click(); - - const dialog = page.getByTestId("manage-secrets-dialog"); - await expect( - dialog, - "Manage Secrets dialog should open after clicking button", - ).toBeVisible(); -} - -/** - * Closes the currently open dialog using Escape key - * @param page - Playwright page object - */ -async function closeDialog(page: Page): Promise { - await page.keyboard.press("Escape"); - const dialog = page.getByTestId("manage-secrets-dialog"); - await expect( - dialog, - "Dialog should close after pressing Escape", - ).toBeHidden(); -}