-
Notifications
You must be signed in to change notification settings - Fork 6
feat: Guided Tours Ephemeral Pipelines #2334
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 05-29-feat_guided_tours_navigation_dots_track_progress
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,49 +44,51 @@ const PipelineNameDialog = ({ | |
| title, | ||
| description = "Please, name your pipeline.", | ||
| initialName, | ||
| excludeNames, | ||
| submitButtonText, | ||
| submitButtonIcon, | ||
| onSubmit, | ||
| isSubmitDisabled, | ||
| onOpenChange, | ||
| }: PipelineNameDialogProps) => { | ||
| const [error, setError] = useState<string | null>(null); | ||
| const [name, setName] = useState(initialName); | ||
| // Gate the destructive Alert on interaction so dialogs opened with an empty | ||
| // initialName don't flash "Name cannot be empty" before the user types. The | ||
| // submit guard below still uses `error` directly, so empty submits stay blocked. | ||
| const [touched, setTouched] = useState(false); | ||
|
|
||
| const { | ||
| userPipelines, | ||
| isLoadingUserPipelines, | ||
| refetch: refetchUserPipelines, | ||
| } = useLoadUserPipelines(); | ||
|
|
||
| const handleOnChange = useCallback( | ||
| (e: ChangeEvent<HTMLInputElement>) => { | ||
| 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"; | ||
|
camielvs marked this conversation as resolved.
|
||
| const excluded = new Set( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The dialog now removes excludeNames from the duplicate-name set using trimmed/lowercased values, but the rename callers still disable submit with raw equality checks such as name === pipelineNameFromSpec. Entering the current name with leading/trailing whitespace avoids the duplicate error, is not disabled, then handleSubmit trims it back to the existing name and the underlying rename path throws because that file already exists. Suggestion: Normalize once inside PipelineNameDialog and pass the trimmed name into isSubmitDisabled, or make the callers compare name.trim().toLowerCase() against the excluded/current name. Keep the duplicate guard and submit-disabled logic aligned on the same normalized value. Rule: Tangle UI review checklist: TypeScript/General quality — prefer type-safe validation over downstream runtime errors; user-facing dialogs should prevent invalid submissions before invoking storage mutations. |
||
| (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<HTMLInputElement>) => { | ||
| setName(e.target.value); | ||
| setTouched(true); | ||
| }, []); | ||
|
|
||
| const handleDialogOpenChange = useCallback( | ||
| (open: boolean) => { | ||
| if (!open) { | ||
| setError(null); | ||
| } else { | ||
| if (open) { | ||
| setName(initialName); | ||
| setTouched(false); | ||
| refetchUserPipelines(); | ||
| } | ||
| onOpenChange?.(open); | ||
|
|
@@ -112,7 +116,7 @@ const PipelineNameDialog = ({ | |
| </DialogHeader> | ||
| <BlockStack gap="2"> | ||
| <Input value={name} onChange={handleOnChange} /> | ||
| <Activity mode={error ? "visible" : "hidden"}> | ||
| <Activity mode={error && touched ? "visible" : "hidden"}> | ||
| <Alert variant="destructive"> | ||
| <Icon name="CircleAlert" /> | ||
| <AlertDescription>{error}</AlertDescription> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<PipelineFileDescriptor[]> { | ||
| 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<string> { | ||
| 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<void> { | ||
| sessionStorage.setItem(this.key(storageKey), content); | ||
| } | ||
|
|
||
| async delete(storageKey: string): Promise<void> { | ||
| sessionStorage.removeItem(this.key(storageKey)); | ||
| } | ||
|
|
||
| async rename(oldStorageKey: string, newStorageKey: string): Promise<void> { | ||
| 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<boolean> { | ||
| return sessionStorage.getItem(this.key(storageKey)) !== null; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { PipelineFile } from "@/services/pipelineStorage/PipelineFile"; | ||
| import { PipelineFolder } from "@/services/pipelineStorage/PipelineFolder"; | ||
|
|
||
| /** | ||
| * Session-storage-backed folder for ephemeral tour pipelines. Files are built | ||
| * with `id: storageKey` and have no IndexedDB registry entry, so the inherited | ||
| * `PipelineFile.rename()` / `deleteFile()` — which call `updateEntry` / | ||
| * `deleteEntry` against that registry — are unsupported in tour mode and would | ||
| * no-op or error against the missing row. Latent today: the tour UI disables | ||
| * rename/delete, so these paths are never reached. | ||
| */ | ||
| export class TourPipelineFolder extends PipelineFolder { | ||
|
camielvs marked this conversation as resolved.
|
||
| override async listPipelines(): Promise<PipelineFile[]> { | ||
| 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<PipelineFile | undefined> { | ||
| if (!(await this.driver.hasKey(storageKey))) return undefined; | ||
| return new PipelineFile({ | ||
| id: storageKey, | ||
| storageKey, | ||
| folder: this, | ||
| }); | ||
| } | ||
|
|
||
| override async assignFile(storageKey: string): Promise<PipelineFile> { | ||
| return new PipelineFile({ | ||
| id: storageKey, | ||
| storageKey, | ||
| folder: this, | ||
| }); | ||
| } | ||
|
|
||
| override async addFile( | ||
| storageKey: string, | ||
| content: string, | ||
| ): Promise<PipelineFile> { | ||
| await this.driver.write(storageKey, content); | ||
| return new PipelineFile({ | ||
| id: storageKey, | ||
| storageKey, | ||
| folder: this, | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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({ | ||
|
camielvs marked this conversation as resolved.
|
||
| children, | ||
| }: { | ||
| children: ReactNode; | ||
| }) { | ||
| const [service] = useState(() => new TourPipelineStorageService()); | ||
|
|
||
| return ( | ||
| <PipelineStorageCtx.Provider value={service}> | ||
| {children} | ||
| </PipelineStorageCtx.Provider> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<PipelineFile> { | ||
| 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<PipelineFile | undefined> { | ||
| return this.rootFolder.findFile(name); | ||
| } | ||
|
|
||
| override async findFolderById(id: string): Promise<PipelineFolder> { | ||
| 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<PipelineFolder[]> { | ||
| return [this.rootFolder]; | ||
| } | ||
|
|
||
| override async getFavoriteFolders(): Promise<PipelineFolder[]> { | ||
| return []; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export const SESSION_KEY_PREFIX = "tour-pipeline:"; | ||
| export const TOUR_FOLDER_ID = "__tour_folder__"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| ); | ||
| }, | ||
| }); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.