Openstatus www.openstatus.dev

chore: add workspace name form (#516)

authored by

Maximilian Kaske and committed by
GitHub
fcadac0c 7c074441

+151 -11
+43
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/general/_components/CopyToClipboardButton.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + 5 + import { Button } from "@openstatus/ui"; 6 + import type { ButtonProps } from "@openstatus/ui"; 7 + 8 + import { Icons } from "@/components/icons"; 9 + import { copyToClipboard } from "@/lib/utils"; 10 + 11 + export function CopyToClipboardButton({ 12 + children, 13 + onClick, 14 + ...props 15 + }: ButtonProps) { 16 + const [hasCopied, setHasCopied] = React.useState(false); 17 + 18 + React.useEffect(() => { 19 + if (hasCopied) { 20 + setTimeout(() => { 21 + setHasCopied(false); 22 + }, 2000); 23 + } 24 + }, [hasCopied]); 25 + 26 + return ( 27 + <Button 28 + onClick={(e) => { 29 + copyToClipboard(children?.toString() ?? ""); 30 + setHasCopied(true); 31 + onClick?.(e); 32 + }} 33 + {...props} 34 + > 35 + {children} 36 + {!hasCopied ? ( 37 + <Icons.copy className="ml-2 h-4 w-4" /> 38 + ) : ( 39 + <Icons.check className="ml-2 h-4 w-4" /> 40 + )} 41 + </Button> 42 + ); 43 + }
+18 -3
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/general/page.tsx
··· 1 + import { Separator } from "@openstatus/ui"; 2 + 3 + import { WorkspaceForm } from "@/components/forms/workspace-form"; 1 4 import { api } from "@/trpc/server"; 5 + import { CopyToClipboardButton } from "./_components/CopyToClipboardButton"; 2 6 3 7 export default async function GeneralPage() { 4 8 const data = await api.workspace.getWorkspace.query(); 5 9 6 10 return ( 7 - <div className="text-muted-foreground"> 8 - Your workspace slug is:{" "} 9 - <span className="text-foreground pl-1 font-medium">{data.slug}</span>. 11 + <div className="flex flex-col gap-8"> 12 + <WorkspaceForm defaultValues={{ name: data.name ?? "" }} /> 13 + <Separator /> 14 + <div className="flex flex-col gap-2"> 15 + <p>Workspace Slug</p> 16 + <p className="text-muted-foreground text-sm"> 17 + The unique identifier for your workspace. 18 + </p> 19 + <div> 20 + <CopyToClipboardButton variant="outline" size="sm"> 21 + {data.slug} 22 + </CopyToClipboardButton> 23 + </div> 24 + </div> 10 25 </div> 11 26 ); 12 27 }
+81
apps/web/src/components/forms/workspace-form.tsx
··· 1 + "use client"; 2 + 3 + import { useTransition } from "react"; 4 + import { useRouter } from "next/navigation"; 5 + import { zodResolver } from "@hookform/resolvers/zod"; 6 + import { useForm } from "react-hook-form"; 7 + import * as z from "zod"; 8 + 9 + import { insertWorkspaceSchema } from "@openstatus/db/src/schema"; 10 + import { 11 + Button, 12 + Form, 13 + FormControl, 14 + FormDescription, 15 + FormField, 16 + FormItem, 17 + FormLabel, 18 + FormMessage, 19 + Input, 20 + } from "@openstatus/ui"; 21 + 22 + import { useToastAction } from "@/hooks/use-toast-action"; 23 + import { api } from "@/trpc/client"; 24 + import { LoadingAnimation } from "../loading-animation"; 25 + 26 + // or insertWorkspaceSchema.pick({ name: true }) and updating name to not be nullable 27 + const schema = z.object({ 28 + name: z.string().min(3, "workspace names must contain at least 3 characters"), 29 + }); 30 + type Schema = z.infer<typeof schema>; 31 + 32 + export function WorkspaceForm({ defaultValues }: { defaultValues: Schema }) { 33 + const form = useForm<Schema>({ 34 + resolver: zodResolver(schema), 35 + defaultValues, 36 + }); 37 + const router = useRouter(); 38 + const [isPending, startTransition] = useTransition(); 39 + const { toast } = useToastAction(); 40 + 41 + async function onSubmit(data: Schema) { 42 + startTransition(async () => { 43 + try { 44 + await api.workspace.updateWorkspace.mutate(data); 45 + toast("saved"); 46 + router.refresh(); 47 + } catch { 48 + toast("error"); 49 + } 50 + }); 51 + } 52 + 53 + return ( 54 + <Form {...form}> 55 + <form 56 + onSubmit={form.handleSubmit(onSubmit)} 57 + className="grid w-full grid-cols-1 items-center gap-6 sm:grid-cols-6" 58 + > 59 + <FormField 60 + control={form.control} 61 + name="name" 62 + render={({ field }) => ( 63 + <FormItem className="sm:col-span-4"> 64 + <FormLabel>Name</FormLabel> 65 + <FormControl> 66 + <Input placeholder="Documenso" {...field} /> 67 + </FormControl> 68 + <FormDescription>The name of your workspace.</FormDescription> 69 + <FormMessage /> 70 + </FormItem> 71 + )} 72 + /> 73 + <div className="sm:col-span-full"> 74 + <Button className="w-full sm:w-auto" size="lg"> 75 + {!isPending ? "Confirm" : <LoadingAnimation />} 76 + </Button> 77 + </div> 78 + </form> 79 + </Form> 80 + ); 81 + }
+9 -8
packages/api/src/router/workspace.ts
··· 47 47 }), 48 48 49 49 getWorkspaceUsers: protectedProcedure.query(async (opts) => { 50 - // const result = await opts.ctx.db 51 - // .select() 52 - // .from(usersToWorkspaces) 53 - // .leftJoin(user, eq(user.id, usersToWorkspaces.userId)) 54 - // .where(eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id)) 55 - // .all(); 56 - // return result.map(({ user }) => user); 57 - 58 50 const result = await opts.ctx.db.query.usersToWorkspaces.findMany({ 59 51 with: { 60 52 user: true, ··· 63 55 }); 64 56 return result; 65 57 }), 58 + 59 + updateWorkspace: protectedProcedure 60 + .input(z.object({ name: z.string() })) 61 + .mutation(async (opts) => { 62 + return await opts.ctx.db 63 + .update(workspace) 64 + .set({ name: opts.input.name }) 65 + .where(eq(workspace.id, opts.ctx.workspace.id)); 66 + }), 66 67 67 68 removeWorkspaceUser: protectedProcedure 68 69 .input(z.object({ id: z.number() }))