feat: Dashboards search (#4781)

## Description

- [ ] Search the projects
- [ ] Use arrow keys directly from the search or by focusing the project
- [ ] Hit enter to open
- [ ] Empty state when nothing found

## Steps for reproduction

1. click button
2. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
0000)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
This commit is contained in:
Oleg Isonen
2025-01-28 15:36:15 +00:00
committed by GitHub
parent c0b73ba145
commit 2fab4bae0e
24 changed files with 703 additions and 541 deletions

View File

@ -15,7 +15,6 @@ import { SecretLogin } from "./secret-login";
const globalStyles = globalCss({
body: {
margin: 0,
background: theme.colors.brandBackgroundDashboard,
overflow: "hidden",
},
});
@ -35,7 +34,14 @@ export const Login = ({
}: LoginProps) => {
globalStyles();
return (
<Flex align="center" justify="center" css={{ height: "100vh" }}>
<Flex
align="center"
justify="center"
css={{
height: "100vh",
background: theme.colors.brandBackgroundDashboard,
}}
>
<Flex
direction="column"
align="center"
@ -56,34 +62,30 @@ export const Login = ({
</Text>
<TooltipProvider>
<Flex
as={Form}
method="post"
direction="column"
gap="3"
css={{ width: "100%" }}
>
<Button
disabled={isGoogleEnabled === false}
prefix={<GoogleIcon size={22} />}
color="primary"
css={{ height: theme.spacing[15] }}
formAction={authPath({ provider: "google" })}
>
Sign in with Google
</Button>
<Button
disabled={isGithubEnabled === false}
prefix={<GithubIcon size={22} fill="currentColor" />}
color="ghost"
css={{
border: `1px solid ${theme.colors.borderDark}`,
height: theme.spacing[15],
}}
formAction={authPath({ provider: "github" })}
>
Sign in with GitHub
</Button>
<Flex direction="column" gap="3" css={{ width: "100%" }}>
<Form method="post" style={{ display: "contents" }}>
<Button
disabled={isGoogleEnabled === false}
prefix={<GoogleIcon size={22} />}
color="primary"
css={{ height: theme.spacing[15] }}
formAction={authPath({ provider: "google" })}
>
Sign in with Google
</Button>
<Button
disabled={isGithubEnabled === false}
prefix={<GithubIcon size={22} fill="currentColor" />}
color="ghost"
css={{
border: `1px solid ${theme.colors.borderDark}`,
height: theme.spacing[15],
}}
formAction={authPath({ provider: "github" })}
>
Sign in with GitHub
</Button>
</Form>
{isSecretLoginEnabled && <SecretLogin />}
</Flex>
</TooltipProvider>

View File

@ -5,30 +5,25 @@ import { authPath } from "~/shared/router-utils";
export const SecretLogin = () => {
const [show, setShow] = useState(false);
if (show) {
const action = authPath({ provider: "dev" });
return (
<Flex gap="2">
<InputField
name="secret"
type="password"
minLength={2}
required
autoFocus
placeholder="Auth secret"
css={{ flexGrow: 1 }}
formAction={authPath({ provider: "dev" })}
onKeyDown={(event) => {
const form = event.currentTarget.form;
if (event.key === "Enter" && form) {
form.action = action;
form.submit();
}
}}
/>
<Button type="submit" formAction={action}>
Login
</Button>
</Flex>
<form
method="post"
action={authPath({ provider: "dev" })}
style={{ display: "contents" }}
>
<Flex gap="2">
<InputField
name="secret"
type="password"
minLength={2}
required
autoFocus
placeholder="Auth secret"
css={{ flexGrow: 1 }}
/>
<Button type="submit">Login</Button>
</Flex>
</form>
);
}

View File

@ -219,7 +219,7 @@ export const ComponentsPanel = ({
const searchFieldProps = useSearchFieldKeys({
onChange: resetSelectedComponent,
onCancel: resetSelectedComponent,
onAbort: resetSelectedComponent,
onMove({ direction }) {
if (direction === "current") {
const component = getSelectedComponent();

View File

@ -1,12 +1,12 @@
import type { StoryFn } from "@storybook/react";
import type { JSX } from "react";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { Dashboard } from "./dashboard";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
import { Dashboard, DashboardSetup } from "./dashboard";
import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server";
import type { DashboardProject } from "@webstudio-is/dashboard";
export default {
title: "Dashboard / Projects",
title: "Dashboard",
component: Dashboard,
};
@ -20,14 +20,10 @@ const user = {
provider: "github",
};
const createRouter = (element: JSX.Element) =>
createBrowserRouter([
{
path: "*",
element,
loader: () => null,
},
]);
const createRouter = (element: JSX.Element, path: string, current?: string) =>
createMemoryRouter([{ path, element }], {
initialEntries: [current ?? path],
});
const userPlanFeatures: UserPlanFeatures = {
hasProPlan: false,
@ -38,60 +34,85 @@ const userPlanFeatures: UserPlanFeatures = {
maxDomainsAllowedPerUser: 1,
};
export const WithProjects: StoryFn<typeof Dashboard> = () => {
const projects = [
{
id: "0",
createdAt: new Date().toString(),
title: "My Project",
domain: "domain.com",
userId: "",
isDeleted: false,
isPublished: false,
latestBuild: null,
previewImageAsset: null,
previewImageAssetId: "",
latestBuildVirtual: null,
marketplaceApprovalStatus: "UNLISTED" as const,
} as DashboardProject,
];
const projects = [
{
id: "0",
createdAt: new Date().toString(),
title: "My Project",
domain: "domain.com",
userId: "",
isDeleted: false,
isPublished: false,
latestBuild: null,
previewImageAsset: null,
previewImageAssetId: "",
latestBuildVirtual: null,
marketplaceApprovalStatus: "UNLISTED" as const,
} as DashboardProject,
];
const data = {
user,
templates: projects,
userPlanFeatures,
publisherHost: "https://wstd.work",
projects,
};
export const Welcome: StoryFn<typeof Dashboard> = () => {
const router = createRouter(
<Dashboard
user={user}
welcome={false}
projects={projects}
userPlanFeatures={userPlanFeatures}
publisherHost={"https://wstd.work"}
/>
<>
<DashboardSetup data={{ ...data, projects: [] }} />
<Dashboard />
</>,
"/dashboard/templates"
);
return <RouterProvider router={router} />;
};
export const WithTemplates: StoryFn<typeof Dashboard> = () => {
const templates = [
{
id: "0",
createdAt: new Date().toString(),
title: "My Project",
domain: "domain.com",
userId: "",
isDeleted: false,
isPublished: false,
latestBuild: null,
previewImageAsset: null,
previewImageAssetId: "",
latestBuildVirtual: null,
marketplaceApprovalStatus: "UNLISTED" as const,
} as DashboardProject,
];
export const Projects: StoryFn<typeof Dashboard> = () => {
const router = createRouter(
<Dashboard
user={user}
templates={templates}
welcome
userPlanFeatures={userPlanFeatures}
publisherHost={"https://wstd.work"}
/>
<>
<DashboardSetup data={data} />
<Dashboard />
</>,
"/dashboard"
);
return <RouterProvider router={router} />;
};
export const Templates: StoryFn<typeof Dashboard> = () => {
const router = createRouter(
<>
<DashboardSetup data={data} />
<Dashboard />
</>,
"/dashboard/templates"
);
return <RouterProvider router={router} />;
};
export const Search: StoryFn<typeof Dashboard> = () => {
const router = createRouter(
<>
<DashboardSetup data={data} />
<Dashboard />
</>,
"/dashboard/search",
"/dashboard/search?q=my"
);
return <RouterProvider router={router} />;
};
export const SearchNothingFound: StoryFn<typeof Dashboard> = () => {
const router = createRouter(
<>
<DashboardSetup data={data} />
<Dashboard />
</>,
"/dashboard/search",
"/dashboard/search?q=notfound"
);
return <RouterProvider router={router} />;
};

View File

@ -10,19 +10,21 @@ import {
globalCss,
theme,
} from "@webstudio-is/design-system";
import type { DashboardProject } from "@webstudio-is/dashboard";
import { BodyIcon, ExtensionIcon } from "@webstudio-is/icons";
import type { User } from "~/shared/db/user.server";
import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server";
import { NavLink, useLocation, useRevalidator } from "@remix-run/react";
import { atom } from "nanostores";
import { useStore } from "@nanostores/react";
import { CloneProjectDialog } from "~/shared/clone-project";
import { dashboardPath, templatesPath } from "~/shared/router-utils";
import { dashboardPath } from "~/shared/router-utils";
import { CollapsibleSection } from "~/builder/shared/collapsible-section";
import { ProfileMenu } from "./profile-menu";
import { Projects } from "./projects/projects";
import { Templates } from "./templates/templates";
import { Header } from "./shared/layout";
import { help } from "~/shared/help";
import { SearchResults } from "./search/search-results";
import type { DashboardData } from "./shared/types";
import { Search } from "./search/search-field";
const globalStyles = globalCss({
body: {
@ -33,7 +35,7 @@ const globalStyles = globalCss({
const CloneProject = ({
projectToClone,
}: {
projectToClone: DashboardProps["projectToClone"];
projectToClone: DashboardData["projectToClone"];
}) => {
const location = useLocation();
const [isOpen, setIsOpen] = useState(projectToClone !== undefined);
@ -51,20 +53,22 @@ const CloneProject = ({
window.history.replaceState(currentState, "", location.pathname);
}, [location.search, location.pathname]);
return projectToClone !== undefined ? (
<CloneProjectDialog
isOpen={isOpen}
onOpenChange={setIsOpen}
project={{
id: projectToClone.id,
title: projectToClone.title,
}}
authToken={projectToClone.authToken}
onCreate={() => {
revalidate();
}}
/>
) : undefined;
if (projectToClone !== undefined) {
return (
<CloneProjectDialog
isOpen={isOpen}
onOpenChange={setIsOpen}
project={{
id: projectToClone.id,
title: projectToClone.title,
}}
authToken={projectToClone.authToken}
onCreate={() => {
revalidate();
}}
/>
);
}
};
const sidebarLinkStyle = css({
@ -99,7 +103,7 @@ const NavigationItems = ({
<List style={{ padding: 0, margin: 0 }}>
{items.map((item, index) => {
return (
<ListItem asChild index={index}>
<ListItem asChild index={index} key={index}>
<NavLink
to={item.to}
end
@ -118,30 +122,47 @@ const NavigationItems = ({
);
};
type DashboardProps = {
user: User;
projects?: Array<DashboardProject>;
templates?: Array<DashboardProject>;
welcome: boolean;
userPlanFeatures: UserPlanFeatures;
publisherHost: string;
projectToClone?: {
authToken: string;
id: string;
title: string;
};
const $data = atom<DashboardData | undefined>();
export const DashboardSetup = ({ data }: { data: DashboardData }) => {
$data.set(data);
globalStyles();
return undefined;
};
export const Dashboard = ({
user,
projects,
templates,
welcome,
userPlanFeatures,
publisherHost,
projectToClone,
}: DashboardProps) => {
globalStyles();
const getView = (pathname: string, hasProjects: boolean) => {
if (pathname === dashboardPath("search")) {
return "search";
}
if (hasProjects === false) {
return "welcome";
}
if (pathname === dashboardPath("templates")) {
return "templates";
}
return "projects";
};
export const Dashboard = () => {
const data = useStore($data);
const location = useLocation();
if (data === undefined) {
return;
}
const {
user,
userPlanFeatures,
publisherHost,
projectToClone,
projects,
templates,
} = data;
const hasProjects = projects.length > 0;
const view = getView(location.pathname, hasProjects);
return (
<TooltipProvider>
@ -160,26 +181,36 @@ export const Dashboard = ({
<Header variant="aside">
<ProfileMenu user={user} userPlanFeatures={userPlanFeatures} />
</Header>
<Flex
direction="column"
gap="3"
css={{
paddingInline: theme.spacing[7],
paddingBottom: theme.spacing[7],
}}
>
<Search />
</Flex>
<nav>
<CollapsibleSection label="Workspace" fullWidth>
<NavigationItems
items={
welcome
view === "welcome" || hasProjects === false
? [
{
to: templatesPath(),
to: dashboardPath(),
prefix: <ExtensionIcon />,
children: "Welcome",
},
]
: [
{
to: dashboardPath(),
to: dashboardPath("projects"),
prefix: <BodyIcon />,
children: "Projects",
},
{
to: templatesPath(),
to: dashboardPath("templates"),
prefix: <ExtensionIcon />,
children: "Starter templates",
},
@ -199,14 +230,16 @@ export const Dashboard = ({
</CollapsibleSection>
</nav>
</Flex>
{projects && (
{view === "projects" && (
<Projects
projects={projects}
hasProPlan={userPlanFeatures.hasProPlan}
publisherHost={publisherHost}
/>
)}
{templates && <Templates templates={templates} welcome={welcome} />}
{view === "templates" && <Templates projects={templates} />}
{view === "welcome" && <Templates projects={templates} welcome />}
{view === "search" && <SearchResults {...data} />}
</Flex>
<CloneProject projectToClone={projectToClone} />
<Toaster />

View File

@ -1,11 +0,0 @@
import { Flex, Text } from "@webstudio-is/design-system";
import { CreateProject } from "./project-dialogs";
export const EmptyState = () => (
<Flex align="center" justify="center" direction="column" gap="6">
<Text variant="brandMediumTitle" as="h1" align="center">
What will you create?
</Text>
<CreateProject buttonText="Create First Project" />
</Flex>
);

View File

@ -1,4 +1,4 @@
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import {
DropdownMenu,
DropdownMenuTrigger,
@ -96,47 +96,6 @@ const Menu = ({
);
};
// @todo use List/ListItem instead
export const useProjectCard = () => {
const thumbnailRef = useRef<HTMLAnchorElement & HTMLDivElement>(null);
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
const elements: Array<HTMLElement> = Array.from(
event.currentTarget.querySelectorAll(`[tabIndex='-1']`)
);
const currentIndex = elements.indexOf(
document.activeElement as HTMLElement
);
switch (event.key) {
case "Enter": {
// Only open project on enter when the project card container was focused,
// otherwise we will always open project, even when a menu was pressed.
if (event.currentTarget === document.activeElement) {
thumbnailRef.current?.click();
}
break;
}
case "ArrowUp":
case "ArrowRight": {
const nextElement = elements.at(currentIndex + 1) ?? elements[0];
nextElement?.focus();
break;
}
case "ArrowDown":
case "ArrowLeft": {
const nextElement = elements.at(currentIndex - 1) ?? elements[0];
nextElement?.focus();
break;
}
}
};
return {
thumbnailRef,
handleKeyDown,
};
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
@ -163,12 +122,12 @@ export const ProjectCard = ({
},
hasProPlan,
publisherHost,
...props
}: ProjectCardProps) => {
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [isHidden, setIsHidden] = useState(false);
const { thumbnailRef, handleKeyDown } = useProjectCard();
const handleCloneProject = useCloneProject(id);
const [isTransitioning, setIsTransitioning] = useState(false);
@ -195,7 +154,7 @@ export const ProjectCard = ({
const linkPath = builderUrl({ origin: window.origin, projectId: id });
return (
<Card hidden={isHidden} tabIndex={0} onKeyDown={handleKeyDown}>
<Card hidden={isHidden} {...props}>
<CardContent
css={{
background: theme.colors.brandBackgroundProjectCardBack,
@ -219,17 +178,9 @@ export const ProjectCard = ({
/>
{previewImageAsset ? (
<ThumbnailLinkWithImage
to={linkPath}
name={previewImageAsset.name}
ref={thumbnailRef}
/>
<ThumbnailLinkWithImage to={linkPath} name={previewImageAsset.name} />
) : (
<ThumbnailLinkWithAbbr
title={title}
to={linkPath}
ref={thumbnailRef}
/>
<ThumbnailLinkWithAbbr title={title} to={linkPath} />
)}
{isTransitioning && <Spinner delay={0} />}
</CardContent>

View File

@ -1,21 +1,54 @@
import { Flex, Grid, Text, rawTheme, theme } from "@webstudio-is/design-system";
import {
Flex,
Grid,
List,
ListItem,
Text,
rawTheme,
theme,
} from "@webstudio-is/design-system";
import type { DashboardProject } from "@webstudio-is/dashboard";
import { EmptyState } from "./empty-state";
import { ProjectCard } from "./project-card";
import { CreateProject } from "./project-dialogs";
import { Header, Main } from "../shared/layout";
export const ProjectsGrid = ({
projects,
hasProPlan,
publisherHost,
}: ProjectsProps) => {
return (
<List asChild>
<Grid
gap="6"
css={{
gridTemplateColumns: `repeat(auto-fill, minmax(${rawTheme.spacing[31]}, 1fr))`,
paddingBottom: theme.spacing[13],
}}
>
{projects.map((project) => {
return (
<ListItem index={0} key={project.id} asChild>
<ProjectCard
project={project}
hasProPlan={hasProPlan}
publisherHost={publisherHost}
/>
</ListItem>
);
})}
</Grid>
</List>
);
};
type ProjectsProps = {
projects: Array<DashboardProject>;
hasProPlan: boolean;
publisherHost: string;
};
export const Projects = ({
projects,
hasProPlan,
publisherHost,
}: ProjectsProps) => {
export const Projects = (props: ProjectsProps) => {
return (
<Main>
<Header variant="main">
@ -29,30 +62,9 @@ export const Projects = ({
<Flex
direction="column"
gap="3"
css={{
paddingInline: theme.spacing[13],
paddingTop: projects.length === 0 ? "20vh" : 0,
}}
css={{ paddingInline: theme.spacing[13] }}
>
{projects.length === 0 && <EmptyState />}
<Grid
gap="6"
css={{
gridTemplateColumns: `repeat(auto-fill, minmax(${rawTheme.spacing[31]}, 1fr))`,
paddingBottom: theme.spacing[13],
}}
>
{projects.map((project) => {
return (
<ProjectCard
project={project}
key={project.id}
hasProPlan={hasProPlan}
publisherHost={publisherHost}
/>
);
})}
</Grid>
<ProjectsGrid {...props} />
</Flex>
</Main>
);

View File

@ -0,0 +1,9 @@
import { Flex, Text } from "@webstudio-is/design-system";
export const NothingFound = () => (
<Flex align="center" justify="center" direction="column" gap="6">
<Text variant="brandSectionTitle" as="h1" align="center">
Nothing found 🙁
</Text>
</Flex>
);

View File

@ -0,0 +1,47 @@
import { SearchField } from "@webstudio-is/design-system";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { dashboardPath } from "~/shared/router-utils";
export const Search = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
const handleAbortSearch = () => {
// When user cancels the search, we try to return to the last path they were on.
if (location.state?.previousPathname) {
return navigate(location.state.previousPathname);
}
navigate(dashboardPath("projects"));
};
const isSearchRoute = location.pathname === dashboardPath("search");
return (
<SearchField
value={searchParams.get("q") ?? undefined}
onChange={(event) => {
const value = event.currentTarget.value.trim();
if (value === "") {
handleAbortSearch();
return;
}
if (isSearchRoute === false) {
navigate(
{
pathname: dashboardPath("search"),
search: `?q=${value}`,
},
// Remember the last path to return to on abort
{
state: { previousPathname: location.pathname },
}
);
return;
}
setSearchParams({ q: value }, { replace: true });
}}
onAbort={handleAbortSearch}
autoFocus
placeholder="Search for anything"
/>
);
};

View File

@ -0,0 +1,81 @@
import { useMemo } from "react";
import { matchSorter } from "match-sorter";
import { useSearchParams } from "react-router-dom";
import { Flex, Separator, Text, theme } from "@webstudio-is/design-system";
import type { DashboardProject } from "@webstudio-is/dashboard";
import { ProjectsGrid } from "../projects/projects";
import { Header, Main } from "../shared/layout";
import type { DashboardData } from "../shared/types";
import { NothingFound } from "./nothing-found";
import { TemplatesGrid } from "../templates/templates";
type SearchResults = {
projects: Array<DashboardProject>;
templates: Array<DashboardProject>;
};
const initialSearchResults: SearchResults = {
templates: [],
projects: [],
} as const;
export const SearchResults = (props: DashboardData) => {
const [searchParams] = useSearchParams();
const { projects, templates, userPlanFeatures, publisherHost } = props;
const search = searchParams.get("q");
const results = useMemo(() => {
if (!search || !projects || !templates) {
return initialSearchResults;
}
const keys = ["title", "domain"];
return {
projects: matchSorter(projects, search, { keys }),
templates: matchSorter(templates, search, { keys }),
};
}, [projects, templates, search]);
const nothingFound =
results.projects.length === 0 && results.templates.length === 0;
return (
<Main>
<Header variant="main">
<Text variant="brandRegular">
Search results for <b>"{search}"</b>
</Text>
</Header>
<Flex
direction="column"
gap="3"
css={{
paddingInline: theme.spacing[13],
paddingTop: nothingFound ? "20vh" : 0,
}}
>
{nothingFound && <NothingFound />}
{results.projects.length > 0 && (
<>
<Text variant="brandSectionTitle" as="h2">
Projects
</Text>
<ProjectsGrid
projects={results.projects}
hasProPlan={userPlanFeatures.hasProPlan}
publisherHost={publisherHost}
/>
</>
)}
{results.templates.length > 0 && (
<>
<Separator />
<Text variant="brandSectionTitle" as="h2">
Templates
</Text>
<TemplatesGrid projects={results.templates} />
</>
)}
</Flex>
</Main>
);
};

View File

@ -21,7 +21,7 @@ const cardStyle = css({
alignItems: "center",
flexShrink: 0,
outline: "none",
"&:focus-within": {
"&:focus-within, &[aria-selected=true]": {
[borderColorVar]: theme.colors.borderFocus,
},
});

View File

@ -0,0 +1,16 @@
import type { DashboardProject } from "@webstudio-is/dashboard";
import type { User } from "~/shared/db/user.server";
import type { UserPlanFeatures } from "~/shared/db/user-plan-features.server";
export type DashboardData = {
user: User;
projects: Array<DashboardProject>;
templates: Array<DashboardProject>;
userPlanFeatures: UserPlanFeatures;
publisherHost: string;
projectToClone?: {
authToken: string;
id: string;
title: string;
};
};

View File

@ -5,18 +5,16 @@ import { builderUrl } from "~/shared/router-utils";
import { Card, CardContent, CardFooter } from "../shared/card";
import { ThumbnailWithAbbr, ThumbnailWithImage } from "../shared/thumbnail";
import { CloneProjectDialog } from "~/shared/clone-project";
import { useProjectCard } from "../projects/project-card";
type TemplateCardProps = {
project: DashboardProject;
};
export const TemplateCard = ({ project }: TemplateCardProps) => {
const { thumbnailRef, handleKeyDown } = useProjectCard();
export const TemplateCard = ({ project, ...props }: TemplateCardProps) => {
const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false);
const { title, previewImageAsset } = project;
return (
<Card tabIndex={0} onKeyDown={handleKeyDown}>
<Card {...props}>
<CardContent
css={{
background: theme.colors.brandBackgroundProjectCardBack,
@ -26,7 +24,6 @@ export const TemplateCard = ({ project }: TemplateCardProps) => {
{previewImageAsset ? (
<ThumbnailWithImage
name={previewImageAsset.name}
ref={thumbnailRef}
onClick={() => {
setIsDuplicateDialogOpen(true);
}}
@ -34,7 +31,6 @@ export const TemplateCard = ({ project }: TemplateCardProps) => {
) : (
<ThumbnailWithAbbr
title={title}
ref={thumbnailRef}
onClick={() => {
setIsDuplicateDialogOpen(true);
}}

View File

@ -1,15 +1,49 @@
import { Flex, Grid, Text, rawTheme, theme } from "@webstudio-is/design-system";
import {
Flex,
Grid,
List,
ListItem,
Text,
rawTheme,
theme,
} from "@webstudio-is/design-system";
import type { DashboardProject } from "@webstudio-is/dashboard";
import { Header, Main } from "../shared/layout";
import { CreateProject } from "../projects/project-dialogs";
import { TemplateCard } from "./template-card";
type ProjectsProps = {
templates: Array<DashboardProject>;
welcome: boolean;
export const TemplatesGrid = ({
projects,
}: {
projects: Array<DashboardProject>;
}) => {
return (
<List asChild>
<Grid
gap="6"
css={{
gridTemplateColumns: `repeat(auto-fill, minmax(${rawTheme.spacing[31]}, 1fr))`,
paddingBottom: theme.spacing[13],
}}
>
{projects.map((project) => {
return (
<ListItem index={0} key={project.id} asChild>
<TemplateCard project={project} />
</ListItem>
);
})}
</Grid>
</List>
);
};
export const Templates = ({ templates, welcome }: ProjectsProps) => {
type ProjectsProps = {
projects: Array<DashboardProject>;
welcome?: boolean;
};
export const Templates = ({ projects, welcome = false }: ProjectsProps) => {
return (
<Main>
<Header variant="main">
@ -20,25 +54,13 @@ export const Templates = ({ templates, welcome }: ProjectsProps) => {
<CreateProject />
</Flex>
</Header>
{templates.length > 0 && (
<Flex
direction="column"
gap="3"
css={{ paddingInline: theme.spacing[13] }}
>
<Grid
gap="6"
css={{
gridTemplateColumns: `repeat(auto-fill, minmax(${rawTheme.spacing[31]}, 1fr))`,
paddingBottom: theme.spacing[13],
}}
>
{templates.map((project) => {
return <TemplateCard project={project} key={project.id} />;
})}
</Grid>
</Flex>
)}
<Flex
direction="column"
gap="3"
css={{ paddingInline: theme.spacing[13] }}
>
<TemplatesGrid projects={projects} />
</Flex>
</Main>
);
};

View File

@ -1,92 +1,16 @@
import { lazy } from "react";
import { useLoaderData, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { dashboardProjectRouter } from "@webstudio-is/dashboard/index.server";
import { builderUrl, templatesPath } from "~/shared/router-utils";
import env from "~/env/env.server";
import { ClientOnly } from "~/shared/client-only";
import { createCallerFactory } from "@webstudio-is/trpc-interface/index.server";
import { preconnect, prefetchDNS } from "react-dom";
import {
getProjectToClone,
loadDashboardData,
} from "~/shared/router-utils/dashboard";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
import { allowedDestinations } from "~/services/destinations.server";
import { redirect } from "react-router-dom";
export { ErrorBoundary } from "~/shared/error/error-boundary";
const dashboardProjectCaller = createCallerFactory(dashboardProjectRouter);
export const meta = () => {
const metas: ReturnType<MetaFunction> = [];
metas.push({ title: "Webstudio Dashboard | Projects" });
return metas;
};
/**
* When deleting/adding a project, then navigating to a new project and pressing the back button,
* the dashboard page may display stale data because its being retrieved from the browsers back/forward cache (bfcache).
*
* https://web.dev/articles/bfcache
*
*/
export const headers = () => {
return {
"Cache-Control": "no-store",
};
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
// CSRF token checks are not necessary for dashboard-only pages.
// All requests from the builder or canvas app are safeguarded either by preventCrossOriginCookie for fetch requests
// or by allowedDestinations for iframe requests.
preventCrossOriginCookie(request);
allowedDestinations(request, ["document", "empty"]);
const { context, user, userPlanFeatures, origin } =
await loadDashboardData(request);
const projects = await dashboardProjectCaller(context).findMany({
userId: user.id,
});
if (projects.length === 0) {
throw redirect(templatesPath());
}
const projectToClone = await getProjectToClone(request, context);
return {
user,
projects,
welcome: false,
userPlanFeatures,
publisherHost: env.PUBLISHER_HOST,
origin,
projectToClone,
};
};
const Dashboard = lazy(async () => {
const { Dashboard } = await import("~/dashboard/index.client");
return { default: Dashboard };
});
const DashboardRoute = () => {
const data = useLoaderData<typeof loader>();
data.projects.slice(0, 5).forEach((project) => {
prefetchDNS(builderUrl({ projectId: project.id, origin: data.origin }));
});
data.projects.slice(0, 5).forEach((project) => {
preconnect(builderUrl({ projectId: project.id, origin: data.origin }));
});
return (
<ClientOnly>
<Dashboard {...data} />
<Dashboard />
</ClientOnly>
);
};

View File

@ -0,0 +1,27 @@
import { lazy } from "react";
import { type MetaFunction } from "@remix-run/react";
import { ClientOnly } from "~/shared/client-only";
export { ErrorBoundary } from "~/shared/error/error-boundary";
export const meta = () => {
const metas: ReturnType<MetaFunction> = [];
metas.push({ title: "Webstudio Dashboard | Search" });
return metas;
};
const Dashboard = lazy(async () => {
const { Dashboard } = await import("~/dashboard/index.client");
return { default: Dashboard };
});
const DashboardRoute = () => {
return (
<ClientOnly>
<Dashboard />
</ClientOnly>
);
};
export default DashboardRoute;

View File

@ -1,67 +1,25 @@
import { lazy } from "react";
import { useLoaderData, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { dashboardProjectRouter } from "@webstudio-is/dashboard/index.server";
import env from "~/env/env.server";
import { type MetaFunction } from "@remix-run/react";
import { ClientOnly } from "~/shared/client-only";
import { createCallerFactory } from "@webstudio-is/trpc-interface/index.server";
import {
getProjectToClone,
loadDashboardData,
} from "~/shared/router-utils/dashboard";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
import { allowedDestinations } from "~/services/destinations.server";
export { ErrorBoundary } from "~/shared/error/error-boundary";
const dashboardProjectCaller = createCallerFactory(dashboardProjectRouter);
export const meta = () => {
const metas: ReturnType<MetaFunction> = [];
metas.push({ title: "Webstudio Dashboard | Starter templates" });
metas.push({ title: "Webstudio Dashboard | Templates" });
return metas;
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
// CSRF token checks are not necessary for dashboard-only pages.
// All requests from the builder or canvas app are safeguarded either by preventCrossOriginCookie for fetch requests
// or by allowedDestinations for iframe requests.
preventCrossOriginCookie(request);
allowedDestinations(request, ["document", "empty"]);
const { context, user, userPlanFeatures, origin } =
await loadDashboardData(request);
const projectToClone = await getProjectToClone(request, context);
const templates = await dashboardProjectCaller(context).findManyByIds({
projectIds: env.PROJECT_TEMPLATES,
});
const hasProjects = await dashboardProjectCaller(context).hasAny({
userId: user.id,
});
return {
user,
templates,
welcome: hasProjects === false,
userPlanFeatures,
publisherHost: env.PUBLISHER_HOST,
origin,
projectToClone,
};
};
const Dashboard = lazy(async () => {
const { Dashboard } = await import("~/dashboard/index.client");
return { default: Dashboard };
});
const DashboardRoute = () => {
const data = useLoaderData<typeof loader>();
return (
<ClientOnly>
<Dashboard {...data} />
<Dashboard />
</ClientOnly>
);
};

View File

@ -0,0 +1,197 @@
import { lazy } from "react";
import { preconnect, prefetchDNS } from "react-dom";
import {
Outlet,
redirect,
type ShouldRevalidateFunction,
} from "react-router-dom";
import { useLoaderData, type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import {
createCallerFactory,
AuthorizationError,
type AppContext,
} from "@webstudio-is/trpc-interface/index.server";
import { db as authDb } from "@webstudio-is/authorization-token/index.server";
import { db } from "@webstudio-is/project/index.server";
import { parseBuilderUrl } from "@webstudio-is/http-client";
import { dashboardProjectRouter } from "@webstudio-is/dashboard/index.server";
import { builderUrl, isDashboard, loginPath } from "~/shared/router-utils";
import env from "~/env/env.server";
import { ClientOnly } from "~/shared/client-only";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
import { allowedDestinations } from "~/services/destinations.server";
export { ErrorBoundary } from "~/shared/error/error-boundary";
import { findAuthenticatedUser } from "~/services/auth.server";
import { createContext } from "~/shared/context.server";
export const meta = () => {
const metas: ReturnType<MetaFunction> = [];
metas.push({ title: "Webstudio Dashboard | Projects" });
return metas;
};
/**
* When deleting/adding a project, then navigating to a new project and pressing the back button,
* the dashboard page may display stale data because its being retrieved from the browsers back/forward cache (bfcache).
*
* https://web.dev/articles/bfcache
*
*/
export const headers = () => {
return {
"Cache-Control": "no-store",
};
};
const dashboardProjectCaller = createCallerFactory(dashboardProjectRouter);
const loadDashboardData = async (request: Request) => {
if (false === isDashboard(request)) {
throw new Response("Not Found", {
status: 404,
});
}
const user = await findAuthenticatedUser(request);
const url = new URL(request.url);
if (user === null) {
throw redirect(
loginPath({
returnTo: `${url.pathname}${url.search}`,
})
);
}
const context = await createContext(request);
if (context.authorization.type !== "user") {
throw new AuthorizationError("You must be logged in to access this page");
}
const { userPlanFeatures } = context;
if (userPlanFeatures === undefined) {
throw new Response("User plan features are not defined", {
status: 404,
});
}
const { sourceOrigin } = parseBuilderUrl(request.url);
const projects = await dashboardProjectCaller(context).findMany({
userId: user.id,
});
const templates = await dashboardProjectCaller(context).findManyByIds({
projectIds: env.PROJECT_TEMPLATES,
});
return {
context,
user,
origin: sourceOrigin,
userPlanFeatures,
projects,
templates,
};
};
const getProjectToClone = async (request: Request, context: AppContext) => {
const url = new URL(request.url);
const projectToCloneAuthToken = url.searchParams.get(
"projectToCloneAuthToken"
);
if (
// Only on navigation requests
request.headers.get("sec-fetch-mode") !== "navigate" ||
projectToCloneAuthToken === null
) {
return;
}
// Clone project
const token = await authDb.getTokenInfo(projectToCloneAuthToken, context);
if (token.canClone === false) {
throw new AuthorizationError("You don't have access to clone this project");
}
const project = await db.project.loadById(
token.projectId,
await context.createTokenContext(projectToCloneAuthToken)
);
return {
id: token.projectId,
authToken: projectToCloneAuthToken,
title: project.title,
};
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
// CSRF token checks are not necessary for dashboard-only pages.
// All requests from the builder or canvas app are safeguarded either by preventCrossOriginCookie for fetch requests
// or by allowedDestinations for iframe requests.
preventCrossOriginCookie(request);
allowedDestinations(request, ["document", "empty"]);
const { context, user, userPlanFeatures, origin, projects, templates } =
await loadDashboardData(request);
const projectToClone = await getProjectToClone(request, context);
return {
user,
projects,
templates,
userPlanFeatures,
publisherHost: env.PUBLISHER_HOST,
origin,
projectToClone,
};
};
export const shouldRevalidate: ShouldRevalidateFunction = ({
defaultShouldRevalidate,
currentUrl,
nextUrl,
}) => {
// We have the entire data on the client, so we don't need to revalidate when
// URL is changing.
if (currentUrl.href !== nextUrl.href) {
return false;
}
// When .revalidate() was called explicitely without chaning the URL,
// `defaultShouldRevalidate` will be true
return defaultShouldRevalidate;
};
const DashboardSetup = lazy(async () => {
const { DashboardSetup } = await import("~/dashboard/index.client");
return { default: DashboardSetup };
});
const DashboardRoute = () => {
const data = useLoaderData<typeof loader>();
data.projects.slice(0, 5).forEach((project) => {
prefetchDNS(builderUrl({ projectId: project.id, origin: data.origin }));
});
data.projects.slice(0, 5).forEach((project) => {
preconnect(builderUrl({ projectId: project.id, origin: data.origin }));
});
return (
<ClientOnly>
<DashboardSetup data={data} />
<Outlet />
</ClientOnly>
);
};
export default DashboardRoute;

View File

@ -1,86 +0,0 @@
import {
AuthorizationError,
type AppContext,
} from "@webstudio-is/trpc-interface/index.server";
import { db as authDb } from "@webstudio-is/authorization-token/index.server";
import { db } from "@webstudio-is/project/index.server";
import { parseBuilderUrl } from "@webstudio-is/http-client";
import { findAuthenticatedUser } from "~/services/auth.server";
import { createContext } from "~/shared/context.server";
export { ErrorBoundary } from "~/shared/error/error-boundary";
import { redirect } from "~/services/no-store-redirect";
import { loginPath } from "./path-utils";
import { isDashboard } from "./is-canvas";
export const loadDashboardData = async (request: Request) => {
if (false === isDashboard(request)) {
throw new Response("Not Found", {
status: 404,
});
}
const user = await findAuthenticatedUser(request);
const url = new URL(request.url);
if (user === null) {
throw redirect(
loginPath({
returnTo: `${url.pathname}${url.search}`,
})
);
}
const context = await createContext(request);
if (context.authorization.type !== "user") {
throw new AuthorizationError("You must be logged in to access this page");
}
const { userPlanFeatures } = context;
if (userPlanFeatures === undefined) {
throw new Response("User plan features are not defined", {
status: 404,
});
}
const { sourceOrigin } = parseBuilderUrl(request.url);
return { context, user, origin: sourceOrigin, userPlanFeatures };
};
export const getProjectToClone = async (
request: Request,
context: AppContext
) => {
const url = new URL(request.url);
const projectToCloneAuthToken = url.searchParams.get(
"projectToCloneAuthToken"
);
if (
// Only on navigation requests
request.headers.get("sec-fetch-mode") !== "navigate" ||
projectToCloneAuthToken === null
) {
return;
}
// Clone project
const token = await authDb.getTokenInfo(projectToCloneAuthToken, context);
if (token.canClone === false) {
throw new AuthorizationError("You don't have access to clone this project");
}
const project = await db.project.loadById(
token.projectId,
await context.createTokenContext(projectToCloneAuthToken)
);
return {
id: token.projectId,
authToken: projectToCloneAuthToken,
title: project.title,
};
};

View File

@ -64,12 +64,13 @@ export const builderUrl = (props: {
return url.href;
};
export const dashboardPath = () => {
return "/dashboard";
};
export const templatesPath = () => {
return "/dashboard/templates";
export const dashboardPath = (
view: "templates" | "search" | "projects" = "projects"
) => {
if (view === "projects") {
return `/dashboard`;
}
return `/dashboard/${view}`;
};
export const dashboardUrl = (props: { origin: string }) => {

View File

@ -68,30 +68,3 @@ export const findManyByIds = async (
| "marketplaceApprovalStatus"
>[];
};
export const hasAny = async (userId: string, context: AppContext) => {
if (context.authorization.type !== "user") {
throw new AuthorizationError(
"Only logged in users can view the project list"
);
}
if (userId !== context.authorization.userId) {
throw new AuthorizationError(
"Only the project owner can view the project list"
);
}
const data = await context.postgrest.client
.from("DashboardProject")
.select("id")
.limit(1)
.eq("userId", userId)
.eq("isDeleted", false);
if (data.error) {
throw data.error;
}
return data.data.length > 0;
};

View File

@ -20,12 +20,6 @@ const projectRouter = router({
.query(async ({ input, ctx }) => {
return await db.findManyByIds(input.projectIds, ctx);
}),
hasAny: procedure
.input(z.object({ userId: z.string() }))
.query(async ({ input, ctx }) => {
return await db.hasAny(input.userId, ctx);
}),
});
export const dashboardProjectRouter = mergeRouters(

View File

@ -34,11 +34,11 @@ const AbortButton = styled(SmallIconButton, {
const SearchFieldBase: ForwardRefRenderFunction<
HTMLInputElement,
ComponentProps<typeof InputField> & { onCancel?: () => void }
ComponentProps<typeof InputField> & { onAbort?: () => void }
> = (props, ref) => {
const {
onChange,
onCancel,
onAbort,
value: propsValue = "",
onKeyDown,
...rest
@ -50,7 +50,7 @@ const SearchFieldBase: ForwardRefRenderFunction<
}, [propsValue]);
const handleCancel = () => {
setValue("");
onCancel?.();
onAbort?.();
};
return (
<InputField
@ -97,13 +97,13 @@ export const SearchField = forwardRef(SearchFieldBase);
type UseSearchFieldKeys = {
onMove: (event: { direction: "next" | "previous" | "current" }) => void;
onChange?: FormEventHandler<HTMLInputElement>;
onCancel?: () => void;
onAbort?: () => void;
};
export const useSearchFieldKeys = ({
onMove,
onChange,
onCancel,
onAbort,
}: UseSearchFieldKeys) => {
const [search, setSearch] = useState("");
const handleKeyDown: KeyboardEventHandler = ({ code }) => {
@ -126,12 +126,12 @@ export const useSearchFieldKeys = ({
const handleCancel = () => {
setSearch("");
onCancel?.();
onAbort?.();
};
return {
value: search,
onCancel: handleCancel,
onAbort: handleCancel,
onChange: handleChange,
onKeyDown: handleKeyDown,
};