From 6c362bc88b844eda9281c477a284cef99f3f55e0 Mon Sep 17 00:00:00 2001 From: Olaf Rosendahl Date: Fri, 26 Apr 2024 13:18:54 +0200 Subject: [PATCH] feat: new create forms --- web/app/components/Navbar.tsx | 11 +-- web/app/components/ui/sheet.tsx | 86 +++++++++++++++++++ web/app/globals.css | 4 + web/app/routes/_authed.dashboard._index.tsx | 66 ++++++++++++-- web/app/routes/_authed.dashboard.new-team.tsx | 65 -------------- ...uthed.dashboard_.$teamSlug.new-project.tsx | 77 ----------------- .../routes/_authed.dashboard_.$teamSlug.tsx | 75 ++++++++++++++-- web/package.json | 1 + web/pnpm-lock.yaml | 3 + 9 files changed, 223 insertions(+), 165 deletions(-) create mode 100644 web/app/components/ui/sheet.tsx delete mode 100644 web/app/routes/_authed.dashboard.new-team.tsx delete mode 100644 web/app/routes/_authed.dashboard_.$teamSlug.new-project.tsx diff --git a/web/app/components/Navbar.tsx b/web/app/components/Navbar.tsx index 4c031e4..0a8871f 100644 --- a/web/app/components/Navbar.tsx +++ b/web/app/components/Navbar.tsx @@ -45,7 +45,7 @@ export const Navbar = ({ user, teams }: NavbarProps) => { My teams - {teams.map((team) => ( + {teams.map((team, index) => ( @@ -66,16 +66,9 @@ export const Navbar = ({ user, teams }: NavbarProps) => { - + {index !== teams.length - 1 && } ))} - - - - New Team - - - diff --git a/web/app/components/ui/sheet.tsx b/web/app/components/ui/sheet.tsx new file mode 100644 index 0000000..162137a --- /dev/null +++ b/web/app/components/ui/sheet.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import * as SheetPrimitive from '@radix-ui/react-dialog'; +import { Cross2Icon } from '@radix-ui/react-icons'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '~/lib/utils'; + +const Sheet = SheetPrimitive.Root; + +const SheetTrigger = SheetPrimitive.Trigger; + +const SheetClose = SheetPrimitive.Close; + +const SheetPortal = SheetPrimitive.Portal; + +const SheetOverlay = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + ), +); +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; + +const sheetVariants = cva( + 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500', + { + variants: { + side: { + top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + bottom: 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm', + right: 'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm', + }, + }, + defaultVariants: { + side: 'right', + }, + }, +); + +interface SheetContentProps extends React.ComponentPropsWithoutRef, VariantProps {} + +const SheetContent = React.forwardRef, SheetContentProps>( + ({ side = 'right', className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + + ), +); +SheetContent.displayName = SheetPrimitive.Content.displayName; + +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = 'SheetHeader'; + +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +SheetFooter.displayName = 'SheetFooter'; + +const SheetTitle = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => , +); +SheetTitle.displayName = SheetPrimitive.Title.displayName; + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ); +SheetDescription.displayName = SheetPrimitive.Description.displayName; + +export { Sheet, SheetPortal, SheetOverlay, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription }; diff --git a/web/app/globals.css b/web/app/globals.css index 532b2f4..6b3846a 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -36,6 +36,10 @@ --radius: 0.75rem; } + * { + border-color: hsl(var(--border)); + } + html, body { background: hsl(var(--background)); diff --git a/web/app/routes/_authed.dashboard._index.tsx b/web/app/routes/_authed.dashboard._index.tsx index b4fcfef..3f78ca8 100644 --- a/web/app/routes/_authed.dashboard._index.tsx +++ b/web/app/routes/_authed.dashboard._index.tsx @@ -1,10 +1,15 @@ -import { Link, NavLink, useLoaderData } from '@remix-run/react'; -import type { LoaderFunctionArgs } from '@vercel/remix'; +import { Prisma } from '@prisma/client'; +import { Form, NavLink, useActionData, useLoaderData, useNavigation } from '@remix-run/react'; +import type { ActionFunctionArgs, LoaderFunctionArgs } from '@vercel/remix'; import { getUserOrRedirect } from '~/auth.server'; import { Typography } from '~/components/typography'; import { Button } from '~/components/ui/button'; import { Card } from '~/components/ui/card'; +import { Input } from '~/components/ui/input'; +import { Label } from '~/components/ui/label'; +import { Sheet, SheetClose, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '~/components/ui/sheet'; import { prismaClient } from '~/prismaClient'; +import { redirect, slugify } from '~/utils.server'; export { ErrorBoundary } from '~/components/ErrorBoundary'; @@ -22,19 +27,66 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { return { teams }; }; +export const action = async ({ request, response }: ActionFunctionArgs) => { + const user = await getUserOrRedirect(request); + const formData = await request.formData(); + const name = formData.get('name') as string; + try { + const team = await prismaClient.team.create({ + data: { + name, + slug: slugify(name), + teamUsers: { + create: { + userId: user.id, + }, + }, + }, + }); + return redirect(response, `/dashboard/${team.slug}`); + } catch (e) { + console.error('[New Team]', e); + if (e instanceof Prisma.PrismaClientKnownRequestError) { + response!.status = 400; + return { errors: { name: 'This team name is already taken' } }; + } + } + response!.status = 400; + return { errors: { name: 'Something went wrong' } }; +}; + export default function Dashboard() { const { teams } = useLoaderData(); + const actionData = useActionData(); + const { state } = useNavigation(); return ( <> Your teams - + + + + + + + Create team + +
+ + +
+ + + + +
+
+
+
{teams.map((team) => ( diff --git a/web/app/routes/_authed.dashboard.new-team.tsx b/web/app/routes/_authed.dashboard.new-team.tsx deleted file mode 100644 index 0448c4e..0000000 --- a/web/app/routes/_authed.dashboard.new-team.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { Prisma } from '@prisma/client'; -import { Form, Link, useActionData, useNavigation } from '@remix-run/react'; -import type { ActionFunctionArgs } from '@vercel/remix'; -import { getUserOrRedirect } from '~/auth.server'; -import { Typography } from '~/components/typography'; -import { Button } from '~/components/ui/button'; -import { Card } from '~/components/ui/card'; -import { Input } from '~/components/ui/input'; -import { Label } from '~/components/ui/label'; -import { prismaClient } from '~/prismaClient'; -import { redirect, slugify } from '~/utils.server'; - -export { ErrorBoundary } from '~/components/ErrorBoundary'; - -export const action = async ({ request, response }: ActionFunctionArgs) => { - const user = await getUserOrRedirect(request); - const formData = await request.formData(); - const name = formData.get('name') as string; - try { - const team = await prismaClient.team.create({ - data: { - name, - slug: slugify(name), - teamUsers: { - create: { - userId: user.id, - }, - }, - }, - }); - return redirect(response, `/dashboard/${team.slug}`); - } catch (e) { - console.error('[New Team]', e); - if (e instanceof Prisma.PrismaClientKnownRequestError) { - response!.status = 400; - return { errors: { name: 'This team name is already taken' } }; - } - } - response!.status = 400; - return { errors: { name: 'Something went wrong' } }; -}; - -export default function CreateTeam() { - const actionData = useActionData(); - const { state } = useNavigation(); - return ( - - Create team -
- - -
- - -
-
-
- ); -} diff --git a/web/app/routes/_authed.dashboard_.$teamSlug.new-project.tsx b/web/app/routes/_authed.dashboard_.$teamSlug.new-project.tsx deleted file mode 100644 index 76b3069..0000000 --- a/web/app/routes/_authed.dashboard_.$teamSlug.new-project.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Prisma } from '@prisma/client'; -import { Form, Link, useActionData, useNavigation, useParams } from '@remix-run/react'; -import type { ActionFunctionArgs } from '@vercel/remix'; -import { ensureIsTeamMember } from '~/auth.server'; -import { prismaClient } from '~/prismaClient'; -import { redirect, slugify } from '~/utils.server'; -import invariant from 'tiny-invariant'; -import { Card } from '~/components/ui/card'; -import { Typography } from '~/components/typography'; -import { Button } from '~/components/ui/button'; -import { Input } from '~/components/ui/input'; -import { Label } from '~/components/ui/label'; - -export { ErrorBoundary } from '~/components/ErrorBoundary'; - -export const action = async ({ request, params, response }: ActionFunctionArgs) => { - invariant(params.teamSlug, 'Expected params.teamSlug'); - const team = await ensureIsTeamMember(request, params.teamSlug); - const formData = await request.formData(); - const name = formData.get('name') as string; - const url = formData.get('url') as string; - const slug = slugify(name); - - if (slug === 'settings' || slug === 'members') { - response!.status = 400; - return { errors: { name: `"settings" and "members" are protected project names, chose a different name and try again` } }; - } - - try { - const project = await prismaClient.project.create({ - data: { - name, - slug, - url, - teamSlug: team.slug, - allowed_hosts: '', - }, - }); - return redirect(response, `/dashboard/${team.slug}/${project.slug}`); - } catch (e) { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - response!.status = 400; - return { errors: { name: `This team already contains a project named "${name}"` } }; - } - } - response!.status = 400; - return { errors: { name: 'Something went wrong' } }; -}; - -export default function CreateProject() { - const actionData = useActionData(); - const { state } = useNavigation(); - const { teamSlug } = useParams(); - return ( - - - Create project - -
- - - - -
- - -
-
-
- ); -} diff --git a/web/app/routes/_authed.dashboard_.$teamSlug.tsx b/web/app/routes/_authed.dashboard_.$teamSlug.tsx index 04b0db2..e2cb80d 100644 --- a/web/app/routes/_authed.dashboard_.$teamSlug.tsx +++ b/web/app/routes/_authed.dashboard_.$teamSlug.tsx @@ -1,11 +1,17 @@ -import { Link, Outlet, useLoaderData, type MetaArgs_SingleFetch } from '@remix-run/react'; -import type { LoaderFunctionArgs, MetaFunction } from '@vercel/remix'; +import { Form, Outlet, useActionData, useLoaderData, useNavigation, type MetaArgs_SingleFetch } from '@remix-run/react'; +import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from '@vercel/remix'; import { ensureIsTeamMember } from '~/auth.server'; import { LinkTabs } from '~/components/LinkTabs'; import invariant from 'tiny-invariant'; import { Button } from '~/components/ui/button'; import { Card } from '~/components/ui/card'; import { Typography } from '~/components/typography'; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from '~/components/ui/sheet'; +import { Label } from '~/components/ui/label'; +import { Input } from '~/components/ui/input'; +import { redirect, slugify } from '~/utils.server'; +import { prismaClient } from '~/prismaClient'; +import { Prisma } from '@prisma/client'; export { ErrorBoundary } from '~/components/ErrorBoundary'; @@ -16,6 +22,40 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { return { team: await ensureIsTeamMember(request, params.teamSlug) }; }; +export const action = async ({ request, params, response }: ActionFunctionArgs) => { + invariant(params.teamSlug, 'Expected params.teamSlug'); + const team = await ensureIsTeamMember(request, params.teamSlug); + const formData = await request.formData(); + const name = formData.get('name') as string; + const url = formData.get('url') as string; + const slug = slugify(name); + + if (slug === 'settings' || slug === 'members') { + response!.status = 400; + return { errors: { name: `"settings" and "members" are protected project names, chose a different name and try again` } }; + } + + try { + const project = await prismaClient.project.create({ + data: { + name, + slug, + url, + teamSlug: team.slug, + allowed_hosts: '', + }, + }); + return redirect(response, `/dashboard/${team.slug}/${project.slug}`); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + response!.status = 400; + return { errors: { name: `This team already contains a project named "${name}"` } }; + } + } + response!.status = 400; + return { errors: { name: 'Something went wrong' } }; +}; + const TABS = [ { label: 'Projects', url: '' }, { label: 'Members', url: 'members' }, @@ -25,17 +65,38 @@ const TABS = [ export default function ProjectDashboard() { const { team } = useLoaderData(); + const actionData = useActionData(); + const { state } = useNavigation(); return ( <> {team.name} - + + + + + + + Create team + +
+ + + + +
+ + + + +
+
+
+
diff --git a/web/package.json b/web/package.json index 5231232..1070eac 100644 --- a/web/package.json +++ b/web/package.json @@ -40,6 +40,7 @@ "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 5338361..51d4d5e 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: '@radix-ui/react-checkbox': specifier: ^1.0.4 version: 1.0.4(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@radix-ui/react-dialog': + specifier: ^1.0.5 + version: 1.0.5(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@radix-ui/react-dropdown-menu': specifier: ^2.0.6 version: 2.0.6(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)