diff --git a/.lostpixel/baseline/components-combobox--simple.png b/.lostpixel/baseline/components-combobox--simple.png index de57d90089..c5bdaf570a 100644 Binary files a/.lostpixel/baseline/components-combobox--simple.png and b/.lostpixel/baseline/components-combobox--simple.png differ diff --git a/.lostpixel/baseline/components-select--boundaries.png b/.lostpixel/baseline/components-select--boundaries.png index 569027a090..7deb2aa008 100644 Binary files a/.lostpixel/baseline/components-select--boundaries.png and b/.lostpixel/baseline/components-select--boundaries.png differ diff --git a/.lostpixel/baseline/components-select--full-width.png b/.lostpixel/baseline/components-select--full-width.png index 7ae429abb5..d5df6aab7b 100644 Binary files a/.lostpixel/baseline/components-select--full-width.png and b/.lostpixel/baseline/components-select--full-width.png differ diff --git a/.lostpixel/baseline/components-select--ghost.png b/.lostpixel/baseline/components-select--ghost.png index ee09a4f0ee..855a2db426 100644 Binary files a/.lostpixel/baseline/components-select--ghost.png and b/.lostpixel/baseline/components-select--ghost.png differ diff --git a/.lostpixel/baseline/components-select--simple.png b/.lostpixel/baseline/components-select--simple.png index f0004e1244..a24abb7ed5 100644 Binary files a/.lostpixel/baseline/components-select--simple.png and b/.lostpixel/baseline/components-select--simple.png differ diff --git a/.lostpixel/baseline/components-select--with-complex-items.png b/.lostpixel/baseline/components-select--with-complex-items.png index d76876ff8a..5bf1f06f98 100644 Binary files a/.lostpixel/baseline/components-select--with-complex-items.png and b/.lostpixel/baseline/components-select--with-complex-items.png differ diff --git a/.lostpixel/baseline/components-text-field--click-capture.png b/.lostpixel/baseline/components-text-field--click-capture.png index fdabc3984a..470a6171cc 100644 Binary files a/.lostpixel/baseline/components-text-field--click-capture.png and b/.lostpixel/baseline/components-text-field--click-capture.png differ diff --git a/.lostpixel/baseline/components-text-field--layout.png b/.lostpixel/baseline/components-text-field--layout.png index e0b8da56d0..3edfd45c13 100644 Binary files a/.lostpixel/baseline/components-text-field--layout.png and b/.lostpixel/baseline/components-text-field--layout.png differ diff --git a/.lostpixel/baseline/components-text-field--prefix-suffix.png b/.lostpixel/baseline/components-text-field--prefix-suffix.png index b113514ec3..7a46df00c1 100644 Binary files a/.lostpixel/baseline/components-text-field--prefix-suffix.png and b/.lostpixel/baseline/components-text-field--prefix-suffix.png differ diff --git a/apps/designer/app/designer/features/sidebar-left/lib/header.tsx b/apps/designer/app/designer/features/sidebar-left/lib/header.tsx index 0b47b76754..b8817acb35 100644 --- a/apps/designer/app/designer/features/sidebar-left/lib/header.tsx +++ b/apps/designer/app/designer/features/sidebar-left/lib/header.tsx @@ -4,31 +4,27 @@ import { Cross1Icon } from "@webstudio-is/icons"; type HeaderProps = { title: string; - isClosable?: boolean; - onClose?: () => void; + suffix: React.ReactNode; }; -export const Header = ({ title, isClosable = true, onClose }: HeaderProps) => { +export const Header = ({ title, suffix }: HeaderProps) => { return ( <> {title} - {isClosable && ( - onClose?.()} - size="1" - css={{ marginRight: "$2" }} - aria-label="Close" - > - - - )} + {suffix && {suffix}} ); }; + +export const CloseButton = ({ onClick }: { onClick: () => void }) => ( + + + +); diff --git a/apps/designer/app/designer/features/sidebar-left/lib/navigator/navigator.tsx b/apps/designer/app/designer/features/sidebar-left/lib/navigator/navigator.tsx index b5fd3977c7..9e7b0a2d61 100644 --- a/apps/designer/app/designer/features/sidebar-left/lib/navigator/navigator.tsx +++ b/apps/designer/app/designer/features/sidebar-left/lib/navigator/navigator.tsx @@ -4,7 +4,7 @@ import { type Publish } from "~/shared/pubsub"; import { useCallback } from "react"; import { useSelectedInstanceData } from "~/designer/shared/nano-states"; import { useRootInstance } from "~/shared/nano-states"; -import { Header } from "../header"; +import { Header, CloseButton } from "../header"; import { InstanceTree } from "~/designer/shared/tree"; declare module "~/shared/pubsub" { @@ -81,7 +81,10 @@ export const Navigator = ({ publish, isClosable, onClose }: NavigatorProps) => { if (rootInstance === undefined) return null; return ( -
+
onClose?.()} />} + />
{ - onSetActiveTab("none"); - }} + suffix={ onSetActiveTab("none")} />} /> diff --git a/apps/designer/app/designer/features/sidebar-left/panels/components/components.tsx b/apps/designer/app/designer/features/sidebar-left/panels/components/components.tsx index e273be4873..80773e8224 100644 --- a/apps/designer/app/designer/features/sidebar-left/panels/components/components.tsx +++ b/apps/designer/app/designer/features/sidebar-left/panels/components/components.tsx @@ -9,7 +9,7 @@ import { createInstance } from "~/shared/tree-utils"; import type { TabName } from "../../types"; import { ComponentThumb } from "./component-thumb"; import { useCanvasRect, useZoom } from "~/designer/shared/nano-states"; -import { Header } from "../../lib/header"; +import { Header, CloseButton } from "../../lib/header"; const componentNames = ( Object.keys(components) as Array @@ -132,17 +132,15 @@ export const TabContent = ({ publish, onSetActiveTab }: TabContentProps) => { }); return ( - <> +
{ - onSetActiveTab("none"); - }} + suffix={ onSetActiveTab("none")} />} /> {componentNames.map((component: Instance["component"]) => ( @@ -160,7 +158,7 @@ export const TabContent = ({ publish, onSetActiveTab }: TabContentProps) => { ))} {dragComponent && } - + ); }; diff --git a/apps/designer/app/designer/features/sidebar-left/panels/pages/pages.tsx b/apps/designer/app/designer/features/sidebar-left/panels/pages/pages.tsx index acd8d6aa08..917a24f8aa 100644 --- a/apps/designer/app/designer/features/sidebar-left/panels/pages/pages.tsx +++ b/apps/designer/app/designer/features/sidebar-left/panels/pages/pages.tsx @@ -1,10 +1,14 @@ -import { TreeNodeLabel, TreeNode } from "@webstudio-is/design-system"; +import { + TreeNodeLabel, + TreeNode, + IconButton, +} from "@webstudio-is/design-system"; import { type Publish } from "~/shared/pubsub"; -import { PageIcon } from "@webstudio-is/icons"; +import { NewPageIcon, PageIcon } from "@webstudio-is/icons"; import type { TabName } from "../../types"; -import { Header } from "../../lib/header"; +import { CloseButton, Header } from "../../lib/header"; import { type Page, type Pages } from "@webstudio-is/project"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { type Config } from "~/config"; import { @@ -12,6 +16,8 @@ import { usePages, useProject, } from "~/designer/shared/nano-states"; +import { SettingsPanel } from "./settings-panel"; +import { NewPageSettings } from "./settings"; type TabContentProps = { onSetActiveTab: (tabName: TabName) => void; @@ -60,20 +66,24 @@ const staticTreeProps = { } return ( - + <> + + ); }, }; const PagesPanel = ({ onClose, + onNewPage, onSelect, selectedPageId, }: { - onClose: () => void; + onClose?: () => void; + onNewPage?: () => void; onSelect: (pageId: string) => void; selectedPageId: string; }) => { @@ -86,7 +96,23 @@ const PagesPanel = ({ return ( <> -
+
+ {onNewPage && ( + onNewPage()} + aria-label="New Page" + > + + + )} + {onClose && } + + } + /> { navigate(`${props.config.designerPath}/${project.id}?pageId=${pageId}`); }; - if (currentPageId === undefined) { + const [newPageOpen, setNewPageOpen] = useState(false); + + if (currentPageId === undefined || project === undefined) { return null; } return ( - props.onSetActiveTab("none")} - onSelect={handleSelect} - selectedPageId={currentPageId} - /> + <> + props.onSetActiveTab("none")} + onNewPage={() => setNewPageOpen((current) => !current)} + onSelect={handleSelect} + selectedPageId={currentPageId} + /> + + setNewPageOpen(false)} + onSuccess={(page) => { + setNewPageOpen(false); + handleSelect(page.id); + }} + /> + + ); }; diff --git a/apps/designer/app/designer/features/sidebar-left/panels/pages/settings-panel.tsx b/apps/designer/app/designer/features/sidebar-left/panels/pages/settings-panel.tsx new file mode 100644 index 0000000000..44b7f6aa1f --- /dev/null +++ b/apps/designer/app/designer/features/sidebar-left/panels/pages/settings-panel.tsx @@ -0,0 +1,61 @@ +import { + styled, + keyframes, + Collapsible, + Box, +} from "@webstudio-is/design-system"; + +const CollapsibleRoot = styled(Collapsible.Root, { + position: "absolute", + left: "100%", + top: 0, + bottom: 0, + display: "flex", + flexDirection: "column", + marginLeft: 1, +}); + +const openKeyframes = keyframes({ + from: { width: 0 }, + to: { width: "var(--radix-collapsible-content-width)" }, +}); + +const closeKeyframes = keyframes({ + from: { width: "var(--radix-collapsible-content-width)" }, + to: { width: 0 }, +}); + +const CollapsibleContent = styled(Collapsible.Content, { + overflow: "hidden", + flexGrow: "1", + display: "flex", + flexDirection: "column", + '&[data-state="open"]': { animation: `${openKeyframes} 200ms ease-out` }, + '&[data-state="closed"]': { animation: `${closeKeyframes} 200ms ease-out` }, +}); + +export const SettingsPanel = ({ + children, + isOpen, +}: { + children: React.ReactNode; + isOpen: boolean; +}) => { + return ( + + + + {children} + + + + ); +}; diff --git a/apps/designer/app/designer/features/sidebar-left/panels/pages/settings.tsx b/apps/designer/app/designer/features/sidebar-left/panels/pages/settings.tsx new file mode 100644 index 0000000000..2cd0e1eb37 --- /dev/null +++ b/apps/designer/app/designer/features/sidebar-left/panels/pages/settings.tsx @@ -0,0 +1,129 @@ +import { + IconButton, + Button, + Box, + Label, + TextField, + useId, + styled, + Flex, + toast, + InputErrorsTooltip, +} from "@webstudio-is/design-system"; +import { useFetcher } from "@remix-run/react"; +import { ChevronDoubleLeftIcon } from "@webstudio-is/icons"; +import type { ZodError } from "zod"; +import { Header } from "../../lib/header"; +import { useEffect, useRef, useState } from "react"; +import { type Page } from "@webstudio-is/project"; + +const Group = styled(Flex, { + marginBottom: "$3", + gap: "$2", + defaultVariants: { direction: "column" }, +}); + +export type CreatePageData = + | { ok: true; page: Page } + | { errors: string | ZodError["formErrors"] }; + +export const NewPageSettings = ({ + onClose, + onSuccess, + projectId, +}: { + onClose?: () => void; + onSuccess?: (page: Page) => void; + projectId: string; +}) => { + const nameFieldId = useId(); + const pathFieldId = useId(); + + const fetcher = useFetcher(); + + const [fieldErrors, setFieldErrors] = useState< + ZodError["formErrors"]["fieldErrors"] + >({}); + + const isSubmitting = fetcher.state !== "idle"; + + const prevFetcher = useRef(fetcher); + useEffect(() => { + if (prevFetcher.current.state !== fetcher.state) { + if (fetcher.state === "idle" && fetcher.data !== undefined) { + if ("ok" in fetcher.data) { + onSuccess?.(fetcher.data.page); + } else { + const errors = + typeof fetcher.data.errors === "string" + ? { formErrors: [fetcher.data.errors], fieldErrors: {} } + : fetcher.data.errors; + + if (errors.formErrors.length > 0) { + for (const message of errors.formErrors) { + toast.error(message); + } + } + setFieldErrors(errors.fieldErrors); + } + } + } + prevFetcher.current = fetcher; + }, [fetcher, onSuccess]); + + return ( + <> +
+ + + ) + } + /> + + + + + + { + setFieldErrors(({ name, ...rest }) => rest); + }} + /> + + + + + + { + setFieldErrors(({ path, ...rest }) => rest); + }} + /> + + + + + + + + + ); +}; diff --git a/apps/designer/app/designer/features/sidebar-left/sidebar-left.tsx b/apps/designer/app/designer/features/sidebar-left/sidebar-left.tsx index f82b138aaf..7be0e00aac 100644 --- a/apps/designer/app/designer/features/sidebar-left/sidebar-left.tsx +++ b/apps/designer/app/designer/features/sidebar-left/sidebar-left.tsx @@ -77,7 +77,6 @@ export const SidebarLeft = ({ publish, config }: SidebarLeftProps) => { dragAndDropState.isDragging && dragAndDropState.origin === "panel" ? "hidden" : "visible", - overflow: "auto", }} > value.trim()) + .refine((value) => value !== "", "Can't be empty"); + +const CreatePageInput = zfd.formData({ + name: nonEmptyString, + path: nonEmptyString + .refine((path) => path !== "/", "Can't be just a /") + .transform((path) => (path.endsWith("/") ? path.slice(0, -1) : path)) + .transform((path) => path.replace(/\/\//g, "/")) + .refine( + (path) => path === "" || path.startsWith("/"), + "Must start with a /" + ) + .refine( + (path) => /^[-_a-z0-9\\/]*$/.test(path), + "Only a-z, 0-9, -, _ and / are allowed" + ), +}); + +const handlePut = async ( + projectId: string, + request: Request +): Promise => { + const result = CreatePageInput.safeParse(await request.formData()); + if (result.success === false) { + return { errors: result.error.formErrors }; + } + const data = result.data; + + const devBuild = await db.build.loadByProjectId(projectId, "dev"); + + const existingPage = utils.pages.findByPath(devBuild.pages, data.path); + if (existingPage !== undefined) { + return { + errors: { + formErrors: [], + fieldErrors: { + path: [`Already used for "${existingPage.name}"`], + }, + }, + }; + } + + const updatedBuild = await db.build.addPage(devBuild.id, data); + + const newPage = utils.pages.findByPath(updatedBuild.pages, data.path); + + if (newPage === undefined) { + throw new Error("New page not found"); + } + + return { ok: true, page: newPage }; +}; + +export const action: ActionFunction = async ({ request, params }) => { + try { + if (params.projectId === undefined) { + throw new Error(`Project ID required`); + } + + if (request.method === "PUT") { + const result = await handlePut(params.projectId, request); + return result; + } + + throw new Error(`Method ${request.method} is not supported`); + } catch (error) { + sentryException({ error }); + return { + errors: error instanceof Error ? error.message : String(error), + }; + } +}; export type ErrorData = { errors: string; @@ -24,11 +107,9 @@ export const loader: LoaderFunction = async ({ return prodBuild.pages; } catch (error) { - if (error instanceof Error) { - return { - errors: error.message, - }; - } + sentryException({ error }); + return { + errors: error instanceof Error ? error.message : String(error), + }; } - return { errors: "Unexpected error" }; }; diff --git a/apps/designer/docs/test-cases.md b/apps/designer/docs/test-cases.md index 477b264dd3..cb5960f96b 100644 --- a/apps/designer/docs/test-cases.md +++ b/apps/designer/docs/test-cases.md @@ -149,3 +149,13 @@ - Check if gap inputs work. - Check if tooltips are present on all icons(including gap input icons). - Check that tooltips have a first time interaction delay. + +1. Creating a new page + + - Open the pages panel + - Click on the "New Page" button in the panel header + - Enter a page name + - Enter a page path + - Click on the "Create" button + - Check than the page is created and selected as the current page + - Repeat the proccess and make sure it doens't allow you to create a page with an empty name or a path that's used for another page diff --git a/packages/design-system/src/components/select.stories.tsx b/packages/design-system/src/components/select.stories.tsx index 598ddbe691..b72262586f 100644 --- a/packages/design-system/src/components/select.stories.tsx +++ b/packages/design-system/src/components/select.stories.tsx @@ -64,7 +64,7 @@ export const WithComplexItems: ComponentStory = () => { export const Boundaries: ComponentStory = () => { const items = Array(100) .fill(0) - .map((_, i) => `Item ${i}`); + .map((_, index) => `Item ${index}`); const [value, setValue] = React.useState(items[0]); return (