diff --git a/src/components/Editor/Context/RenamePipeline.tsx b/src/components/Editor/Context/RenamePipeline.tsx index 9fcee59e5..16ae63327 100644 --- a/src/components/Editor/Context/RenamePipeline.tsx +++ b/src/components/Editor/Context/RenamePipeline.tsx @@ -66,6 +66,7 @@ const RenamePipeline = () => { onSubmit={handleTitleUpdate} submitButtonText="Update Title" isSubmitDisabled={isSubmitDisabled} + excludeNames={title ? [title] : undefined} onOpenChange={(open) => { if (open) track("pipeline_editor.name_pipeline_dialog_impression"); }} diff --git a/src/components/Learn/FeaturedTours.tsx b/src/components/Learn/FeaturedTours.tsx index 15d79f2c9..0177b8c73 100644 --- a/src/components/Learn/FeaturedTours.tsx +++ b/src/components/Learn/FeaturedTours.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; import { Badge } from "@/components/ui/badge"; @@ -5,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { resetAllTourPipelineState } from "@/providers/TourProvider/tourPipelineStorage/resetAllTourPipelineState"; import { APP_ROUTES } from "@/routes/router"; import { tracking } from "@/utils/tracking"; @@ -45,8 +47,10 @@ function buildFeaturedTours(): FeaturedTour[] { export function FeaturedTours() { const featured = buildFeaturedTours(); const navigate = useNavigate(); + const queryClient = useQueryClient(); const startTour = (tourId: string) => { + resetAllTourPipelineState(queryClient); void navigate({ to: APP_ROUTES.TOUR_DETAIL, params: { tourId } }); }; diff --git a/src/components/Learn/ToursLibrary.tsx b/src/components/Learn/ToursLibrary.tsx index 8b8c8cb8d..5ab007c90 100644 --- a/src/components/Learn/ToursLibrary.tsx +++ b/src/components/Learn/ToursLibrary.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { Badge } from "@/components/ui/badge"; @@ -12,6 +13,7 @@ import { import { Icon } from "@/components/ui/icon"; import { BlockStack, InlineStack } from "@/components/ui/layout"; import { Heading, Paragraph, Text } from "@/components/ui/typography"; +import { resetAllTourPipelineState } from "@/providers/TourProvider/tourPipelineStorage/resetAllTourPipelineState"; import { APP_ROUTES } from "@/routes/router"; import { tracking } from "@/utils/tracking"; @@ -29,8 +31,10 @@ import { getTour } from "./tours/registry"; function TourCard({ tour }: { tour: Tour }) { const isAvailable = getTour(tour.id) !== undefined; const navigate = useNavigate(); + const queryClient = useQueryClient(); const startTour = () => { + resetAllTourPipelineState(queryClient); void navigate({ to: APP_ROUTES.TOUR_DETAIL, params: { tourId: tour.id }, diff --git a/src/components/shared/Dialogs/PipelineNameDialog.tsx b/src/components/shared/Dialogs/PipelineNameDialog.tsx index a579de7d4..c6be7c7e9 100644 --- a/src/components/shared/Dialogs/PipelineNameDialog.tsx +++ b/src/components/shared/Dialogs/PipelineNameDialog.tsx @@ -3,6 +3,7 @@ import { type ChangeEvent, type ReactNode, useCallback, + useMemo, useState, } from "react"; @@ -29,6 +30,7 @@ interface PipelineNameDialogProps { title: string; description?: string; initialName: string; + excludeNames?: string[]; submitButtonText: string; submitButtonIcon?: ReactNode; onSubmit: (name: string) => void; @@ -42,13 +44,13 @@ const PipelineNameDialog = ({ title, description = "Please, name your pipeline.", initialName, + excludeNames, submitButtonText, submitButtonIcon, onSubmit, isSubmitDisabled, onOpenChange, }: PipelineNameDialogProps) => { - const [error, setError] = useState(null); const [name, setName] = useState(initialName); const { @@ -57,33 +59,29 @@ const PipelineNameDialog = ({ refetch: refetchUserPipelines, } = useLoadUserPipelines(); - const handleOnChange = useCallback( - (e: ChangeEvent) => { - const newName = e.target.value; - const existingPipelineNames = new Set( - Array.from(userPipelines.keys()).map((name) => name.toLowerCase()), - ); + const error = useMemo(() => { + if (isLoadingUserPipelines) return null; + const normalized = name.trim().toLowerCase(); + if (normalized === "") return "Name cannot be empty"; + const excluded = new Set( + (excludeNames ?? []).map((n) => n.trim().toLowerCase()), + ); + const existing = new Set( + Array.from(userPipelines.keys()) + .map((n) => n.toLowerCase()) + .filter((n) => !excluded.has(n)), + ); + if (existing.has(normalized)) return "Name already exists"; + return null; + }, [name, userPipelines, isLoadingUserPipelines, excludeNames]); - const normalizedNewName = newName.trim().toLowerCase(); - - if (normalizedNewName === "") { - setError("Name cannot be empty"); - } else if (existingPipelineNames.has(normalizedNewName)) { - setError("Name already exists"); - } else { - setError(null); - } - - setName(newName); - }, - [userPipelines], - ); + const handleOnChange = useCallback((e: ChangeEvent) => { + setName(e.target.value); + }, []); const handleDialogOpenChange = useCallback( (open: boolean) => { - if (!open) { - setError(null); - } else { + if (open) { setName(initialName); refetchUserPipelines(); } diff --git a/src/hooks/useLoadUserPipelines.ts b/src/hooks/useLoadUserPipelines.ts index b8f7f5d15..eba8ea3d7 100644 --- a/src/hooks/useLoadUserPipelines.ts +++ b/src/hooks/useLoadUserPipelines.ts @@ -7,7 +7,7 @@ import { import { USER_PIPELINES_LIST_NAME } from "@/utils/constants"; const useLoadUserPipelines = () => { - const [isLoadingUserPipelines, setIsLoadingUserPipelines] = useState(false); + const [isLoadingUserPipelines, setIsLoadingUserPipelines] = useState(true); const [userPipelines, setUserPipelines] = useState< Map >(new Map()); diff --git a/src/providers/TourProvider/tourPipelineStorage/SessionStoragePipelineDriver.ts b/src/providers/TourProvider/tourPipelineStorage/SessionStoragePipelineDriver.ts new file mode 100644 index 000000000..4e17937f4 --- /dev/null +++ b/src/providers/TourProvider/tourPipelineStorage/SessionStoragePipelineDriver.ts @@ -0,0 +1,62 @@ +import type { + PipelineFileDescriptor, + PipelineStorageDriver, +} from "@/services/pipelineStorage/types"; + +import { SESSION_KEY_PREFIX } from "./constants"; + +export class SessionStoragePipelineDriver implements PipelineStorageDriver { + readonly type = "session-storage"; + readonly allowsMoveIn = false; + readonly allowsMoveOut = false; + + private key(storageKey: string): string { + return `${SESSION_KEY_PREFIX}${storageKey}`; + } + + async list(): Promise { + const descriptors: PipelineFileDescriptor[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const fullKey = sessionStorage.key(i); + if (fullKey?.startsWith(SESSION_KEY_PREFIX)) { + descriptors.push({ + storageKey: fullKey.slice(SESSION_KEY_PREFIX.length), + }); + } + } + return descriptors; + } + + async read(storageKey: string): Promise { + const content = sessionStorage.getItem(this.key(storageKey)); + if (content === null) { + throw new Error( + `Tour pipeline "${storageKey}" not found in sessionStorage`, + ); + } + return content; + } + + async write(storageKey: string, content: string): Promise { + sessionStorage.setItem(this.key(storageKey), content); + } + + async delete(storageKey: string): Promise { + sessionStorage.removeItem(this.key(storageKey)); + } + + async rename(oldStorageKey: string, newStorageKey: string): Promise { + const content = sessionStorage.getItem(this.key(oldStorageKey)); + if (content === null) { + throw new Error( + `Tour pipeline "${oldStorageKey}" not found in sessionStorage`, + ); + } + sessionStorage.setItem(this.key(newStorageKey), content); + sessionStorage.removeItem(this.key(oldStorageKey)); + } + + async hasKey(storageKey: string): Promise { + return sessionStorage.getItem(this.key(storageKey)) !== null; + } +} diff --git a/src/providers/TourProvider/tourPipelineStorage/TourPipelineFolder.ts b/src/providers/TourProvider/tourPipelineStorage/TourPipelineFolder.ts new file mode 100644 index 000000000..eae254539 --- /dev/null +++ b/src/providers/TourProvider/tourPipelineStorage/TourPipelineFolder.ts @@ -0,0 +1,49 @@ +import { PipelineFile } from "@/services/pipelineStorage/PipelineFile"; +import { PipelineFolder } from "@/services/pipelineStorage/PipelineFolder"; + +export class TourPipelineFolder extends PipelineFolder { + override async listPipelines(): Promise { + const descriptors = await this.driver.list(); + return descriptors.map( + (d) => + new PipelineFile({ + id: d.storageKey, + storageKey: d.storageKey, + folder: this, + createdAt: d.createdAt, + modifiedAt: d.modifiedAt, + }), + ); + } + + override async findFile( + storageKey: string, + ): Promise { + if (!(await this.driver.hasKey(storageKey))) return undefined; + return new PipelineFile({ + id: storageKey, + storageKey, + folder: this, + }); + } + + override async assignFile(storageKey: string): Promise { + return new PipelineFile({ + id: storageKey, + storageKey, + folder: this, + }); + } + + override async addFile( + storageKey: string, + content: string, + ): Promise { + await this.driver.write(storageKey, content); + return new PipelineFile({ + id: storageKey, + storageKey, + folder: this, + }); + } +} diff --git a/src/providers/TourProvider/tourPipelineStorage/TourPipelineStorageProvider.tsx b/src/providers/TourProvider/tourPipelineStorage/TourPipelineStorageProvider.tsx new file mode 100644 index 000000000..1cc81bdd6 --- /dev/null +++ b/src/providers/TourProvider/tourPipelineStorage/TourPipelineStorageProvider.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; +import { useState } from "react"; + +import { PipelineStorageCtx } from "@/services/pipelineStorage/PipelineStorageProvider"; + +import { TourPipelineStorageService } from "./TourPipelineStorageService"; + +export function TourPipelineStorageProvider({ + children, +}: { + children: ReactNode; +}) { + const [service] = useState(() => new TourPipelineStorageService()); + + return ( + + {children} + + ); +} diff --git a/src/providers/TourProvider/tourPipelineStorage/TourPipelineStorageService.ts b/src/providers/TourProvider/tourPipelineStorage/TourPipelineStorageService.ts new file mode 100644 index 000000000..deb29e4eb --- /dev/null +++ b/src/providers/TourProvider/tourPipelineStorage/TourPipelineStorageService.ts @@ -0,0 +1,48 @@ +import type { PipelineFile } from "@/services/pipelineStorage/PipelineFile"; +import { PipelineFolder } from "@/services/pipelineStorage/PipelineFolder"; +import { PipelineStorageService } from "@/services/pipelineStorage/PipelineStorageService"; + +import { TOUR_FOLDER_ID } from "./constants"; +import { SessionStoragePipelineDriver } from "./SessionStoragePipelineDriver"; +import { TourPipelineFolder } from "./TourPipelineFolder"; + +export class TourPipelineStorageService extends PipelineStorageService { + constructor() { + super(); + this.rootFolder = new TourPipelineFolder({ + id: TOUR_FOLDER_ID, + name: "Tour", + parentId: null, + driver: new SessionStoragePipelineDriver(), + }); + } + + override async findPipelineById(id: string): Promise { + const file = await this.rootFolder.findFile(id); + if (!file) { + throw new Error(`Tour pipeline not found: ${id}`); + } + return file; + } + + override async resolvePipelineByName( + name: string, + ): Promise { + return this.rootFolder.findFile(name); + } + + override async findFolderById(id: string): Promise { + if (id === TOUR_FOLDER_ID || id === this.rootFolder.id) { + return this.rootFolder; + } + throw new Error(`Folder not available in tour mode: ${id}`); + } + + override async getAllFolders(): Promise { + return [this.rootFolder]; + } + + override async getFavoriteFolders(): Promise { + return []; + } +} diff --git a/src/providers/TourProvider/tourPipelineStorage/constants.ts b/src/providers/TourProvider/tourPipelineStorage/constants.ts new file mode 100644 index 000000000..132a82a54 --- /dev/null +++ b/src/providers/TourProvider/tourPipelineStorage/constants.ts @@ -0,0 +1,2 @@ +export const SESSION_KEY_PREFIX = "tour-pipeline:"; +export const TOUR_FOLDER_ID = "__tour_folder__"; diff --git a/src/providers/TourProvider/tourPipelineStorage/resetAllTourPipelineState.ts b/src/providers/TourProvider/tourPipelineStorage/resetAllTourPipelineState.ts new file mode 100644 index 000000000..2e60aed02 --- /dev/null +++ b/src/providers/TourProvider/tourPipelineStorage/resetAllTourPipelineState.ts @@ -0,0 +1,30 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { TOUR_PIPELINE_PREFIX } from "@/providers/TourProvider/tourPipelineLifecycle"; +import { EDITOR_SPEC_QUERY_KEY } from "@/routes/v2/pages/Editor/hooks/useLoadSpec"; + +import { SESSION_KEY_PREFIX } from "./constants"; + +export function resetAllTourPipelineState(queryClient: QueryClient): void { + const sessionKeys: string[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (key?.startsWith(SESSION_KEY_PREFIX)) { + sessionKeys.push(key); + } + } + for (const key of sessionKeys) { + sessionStorage.removeItem(key); + } + + queryClient.removeQueries({ + predicate: (query) => { + const [head, second] = query.queryKey; + return ( + head === EDITOR_SPEC_QUERY_KEY && + typeof second === "string" && + second.startsWith(TOUR_PIPELINE_PREFIX) + ); + }, + }); +} diff --git a/src/routes/Dashboard/Learn/Tour.tsx b/src/routes/Dashboard/Learn/Tour.tsx index d9ce008cc..a2318b42f 100644 --- a/src/routes/Dashboard/Learn/Tour.tsx +++ b/src/routes/Dashboard/Learn/Tour.tsx @@ -17,6 +17,7 @@ import { buildTourPipelineYaml, TOUR_PIPELINE_PREFIX, } from "@/providers/TourProvider/tourPipelineLifecycle"; +import { TourPipelineStorageProvider } from "@/providers/TourProvider/tourPipelineStorage/TourPipelineStorageProvider"; import { TourCompletionActions } from "@/providers/TourProvider/TourPopover"; import { waitForSelector } from "@/providers/TourProvider/waitForSelector"; import { APP_ROUTES } from "@/routes/router"; @@ -39,7 +40,7 @@ function tourPipelineName(tour: TourDefinition): string { return `${TOUR_PIPELINE_PREFIX}${tour.id}`; } -async function createTourPipeline( +async function findOrCreateTourPipeline( tour: TourDefinition, storage: PipelineStorageService, ): Promise { @@ -136,37 +137,51 @@ function TourReactourBridge({ export function TourPage() { const params = useParams({ strict: false }); - const search = useSearch({ strict: false }); - const navigate = useNavigate(); - const storage = usePipelineStorage(); - const tourId = "tourId" in params && typeof params.tourId === "string" ? params.tourId : ""; - const tour = getTour(tourId); + if (!tour) { + return ; + } + + return ( + + + + ); +} + +function TourPageBody({ + tour, + tourId, +}: { + tour: TourDefinition; + tourId: string; +}) { + const search = useSearch({ strict: false }); + const navigate = useNavigate(); + const storage = usePipelineStorage(); const [resolved, setResolved] = useState(null); useEffect(() => { - if (!tour) return; snapshotLayout(EDITOR_LAYOUT_ID); return () => { restoreLayout(EDITOR_LAYOUT_ID); }; - }, [tour]); + }, []); useEffect(() => { - if (!tour) return undefined; let cancelled = false; void (async () => { try { - const created = await createTourPipeline(tour, storage); + const result = await findOrCreateTourPipeline(tour, storage); if (cancelled) return; - setResolved(created); + setResolved(result); } catch (error) { - console.warn("Failed to create tour pipeline:", error); + console.warn("Failed to resolve tour pipeline:", error); } })(); return () => { @@ -183,10 +198,6 @@ export function TourPage() { }); }; - if (!tour) { - return ; - } - const rawStep = (search as { step?: unknown }).step; const parsedStep = typeof rawStep === "number" diff --git a/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx b/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx index cac8dd206..adf30e781 100644 --- a/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx +++ b/src/routes/v2/pages/Editor/components/EditorMenuBar/EditorMenuBar.tsx @@ -124,6 +124,7 @@ export const EditorMenuBar = observer(function EditorMenuBar() { onSubmit={handlePipelineRename} submitButtonText="Rename" isSubmitDisabled={(name) => name === pipelineNameFromSpec} + excludeNames={[pipelineNameFromSpec]} /> )} diff --git a/src/routes/v2/pages/Editor/components/EditorMenuBar/components/FileMenu.tsx b/src/routes/v2/pages/Editor/components/EditorMenuBar/components/FileMenu.tsx index b2efed891..c3ad310a5 100644 --- a/src/routes/v2/pages/Editor/components/EditorMenuBar/components/FileMenu.tsx +++ b/src/routes/v2/pages/Editor/components/EditorMenuBar/components/FileMenu.tsx @@ -193,6 +193,7 @@ export function FileMenu() { onSubmit={renamePipeline} submitButtonText="Rename" isSubmitDisabled={(name) => name === getRenameInitialName()} + excludeNames={[getRenameInitialName()]} /> {canMove && activePipeline && ( diff --git a/src/routes/v2/pages/Editor/hooks/useLoadSpec.ts b/src/routes/v2/pages/Editor/hooks/useLoadSpec.ts index 926274aa0..0181dac72 100644 --- a/src/routes/v2/pages/Editor/hooks/useLoadSpec.ts +++ b/src/routes/v2/pages/Editor/hooks/useLoadSpec.ts @@ -66,11 +66,13 @@ async function resolveSpecData( return yaml.load(yamlContent); } +export const EDITOR_SPEC_QUERY_KEY = "editor-v2-spec"; + export function useLoadSpec(ref: PipelineRef) { const storage = usePipelineStorage(); return useSuspenseQuery({ - queryKey: ["editor-v2-spec", ref.fileId ?? ref.name], + queryKey: [EDITOR_SPEC_QUERY_KEY, ref.fileId ?? ref.name], queryFn: async (): Promise => { const [specData, undoHistory] = await Promise.all([ resolveSpecData(ref, storage), diff --git a/src/routes/v2/pages/Editor/store/autoSaveStore.ts b/src/routes/v2/pages/Editor/store/autoSaveStore.ts index 871a43a34..176a861e9 100644 --- a/src/routes/v2/pages/Editor/store/autoSaveStore.ts +++ b/src/routes/v2/pages/Editor/store/autoSaveStore.ts @@ -48,9 +48,14 @@ export class AutoSaveStore { } @action dispose() { + const yaml = this.serializeSpec(); + const file = this.pipelineFileStore.activePipelineFile; + if (yaml && file) { + void file.write(yaml); + } + this.debouncedSave.cancel(); this.disposeReaction?.(); this.disposeReaction = null; - this.debouncedSave.cancel(); this.spec = null; this.pipelineName = null; } diff --git a/src/services/pipelineService.ts b/src/services/pipelineService.ts index 948b283aa..f39ca4425 100644 --- a/src/services/pipelineService.ts +++ b/src/services/pipelineService.ts @@ -19,9 +19,16 @@ import { USER_PIPELINES_LIST_NAME } from "@/utils/constants"; import { componentSpecToYaml } from "@/utils/yaml"; import { componentSpecFromYaml } from "@/utils/yaml"; +import { + deleteEntry, + findByStorageKey, +} from "./pipelineStorage/pipelineRegistry"; + export const deletePipeline = async (name: string, onDelete?: () => void) => { try { await deleteComponentFileFromList(USER_PIPELINES_LIST_NAME, name); + const entry = await findByStorageKey(name); + if (entry) await deleteEntry(entry.id); onDelete?.(); } catch (error) { console.error("Error deleting pipeline:", error); diff --git a/src/services/pipelineStorage/PipelineStorageProvider.tsx b/src/services/pipelineStorage/PipelineStorageProvider.tsx index dd70c0643..aa319b71a 100644 --- a/src/services/pipelineStorage/PipelineStorageProvider.tsx +++ b/src/services/pipelineStorage/PipelineStorageProvider.tsx @@ -8,7 +8,7 @@ import { import { PipelineStorageService } from "./PipelineStorageService"; -const PipelineStorageCtx = createRequiredContext( +export const PipelineStorageCtx = createRequiredContext( "PipelineStorageContext", );