From b5dc9433db0525d8a14fdbd7e0a0a0334d6d2b63 Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 1 Jan 2025 14:42:16 +0000 Subject: [PATCH] feat: update workspace slug --- apps/web/src/components/Input.tsx | 31 +++++-- apps/web/src/providers/workspace.tsx | 29 ++++--- .../components/UpdateWorkspaceUrlForm.tsx | 84 +++++++++++++++++++ apps/web/src/views/settings/index.tsx | 23 +++-- packages/api/src/routers/workspace.ts | 4 +- packages/db/src/repository/workspace.repo.ts | 8 +- 6 files changed, 144 insertions(+), 35 deletions(-) create mode 100644 apps/web/src/views/settings/components/UpdateWorkspaceUrlForm.tsx diff --git a/apps/web/src/components/Input.tsx b/apps/web/src/components/Input.tsx index 567ac3c3..39fa469e 100644 --- a/apps/web/src/components/Input.tsx +++ b/apps/web/src/components/Input.tsx @@ -1,8 +1,10 @@ import React, { forwardRef } from "react"; import ContentEditable from "react-contenteditable"; +import { twMerge } from "tailwind-merge"; interface InputProps extends React.InputHTMLAttributes { contentEditable?: boolean; + prefix?: string; value?: string; errorMessage?: string; onChange?: ( @@ -11,26 +13,39 @@ interface InputProps extends React.InputHTMLAttributes { } const Input = forwardRef( - ({ contentEditable, errorMessage, value, onChange, ...props }, ref) => { + ( + { contentEditable, errorMessage, prefix, value, onChange, ...props }, + ref, + ) => { if (contentEditable) { return ( ); } return (
- +
+ {prefix && ( +
+ {prefix} +
+ )} + +
{errorMessage && (
{errorMessage}
)} diff --git a/apps/web/src/providers/workspace.tsx b/apps/web/src/providers/workspace.tsx index 91fff323..77aa2c9e 100644 --- a/apps/web/src/providers/workspace.tsx +++ b/apps/web/src/providers/workspace.tsx @@ -1,15 +1,9 @@ -import React, { - createContext, - useContext, - useEffect, - useState, - type ReactNode, -} from "react"; +import type { ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import React, { createContext, useContext, useEffect, useState } from "react"; import { api } from "~/utils/api"; -import { useRouter } from "next/navigation"; - interface WorkspaceContextProps { workspace: Workspace; isLoading: boolean; @@ -20,11 +14,13 @@ interface WorkspaceContextProps { interface Workspace { name: string; publicId: string; + slug: string; } const initialWorkspace: Workspace = { name: "", publicId: "", + slug: "", }; const initialAvailableWorkspaces: Workspace[] = []; @@ -58,7 +54,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({ const storedWorkspaceId: string | null = localStorage.getItem("workspacePublicId"); - if (data?.length) { + if (data.length) { const workspaces = data .map(({ workspace }) => { if (!workspace) return; @@ -66,6 +62,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({ return { publicId: workspace.publicId, name: workspace.name, + slug: workspace.slug, }; }) .filter((workspace) => workspace !== null) as Workspace[]; @@ -75,7 +72,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({ if (storedWorkspaceId !== null) { const newData = data; - const selectedWorkspace = newData?.find( + const selectedWorkspace = newData.find( ({ workspace }) => workspace?.publicId === storedWorkspaceId, ); @@ -84,14 +81,16 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({ setWorkspace({ publicId: selectedWorkspace.workspace.publicId, name: selectedWorkspace.workspace.name, + slug: selectedWorkspace.workspace.slug, }); } else { - const primaryWorkspace = data?.[0]?.workspace; + const primaryWorkspace = data[0]?.workspace; if (!primaryWorkspace) return; - localStorage.setItem("workspacePublicId", primaryWorkspace?.publicId); + localStorage.setItem("workspacePublicId", primaryWorkspace.publicId); setWorkspace({ - publicId: primaryWorkspace?.publicId, - name: primaryWorkspace?.name, + publicId: primaryWorkspace.publicId, + name: primaryWorkspace.name, + slug: primaryWorkspace.slug, }); } }, [data]); diff --git a/apps/web/src/views/settings/components/UpdateWorkspaceUrlForm.tsx b/apps/web/src/views/settings/components/UpdateWorkspaceUrlForm.tsx new file mode 100644 index 00000000..993196fa --- /dev/null +++ b/apps/web/src/views/settings/components/UpdateWorkspaceUrlForm.tsx @@ -0,0 +1,84 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import Button from "~/components/Button"; +import Input from "~/components/Input"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; + +const schema = z.object({ + slug: z + .string() + .min(3, { message: "Workspace URL must be at least 3 characters long" }) + .max(24, { message: "Workspace URL cannot exceed 24 characters" }), +}); + +type FormValues = z.infer; + +const UpdateWorkspaceUrlForm = ({ + workspacePublicId, + workspaceUrl, +}: { + workspacePublicId: string; + workspaceUrl: string; +}) => { + const utils = api.useUtils(); + const { showPopup } = usePopup(); + const { + register, + handleSubmit, + formState: { isDirty, errors }, + } = useForm({ + resolver: zodResolver(schema), + values: { + slug: workspaceUrl, + }, + }); + + const updateWorkspaceSlug = api.workspace.update.useMutation({ + onSuccess: async () => { + try { + await utils.workspace.all.refetch(); + } catch (e) { + console.error(e); + throw e; + } + }, + onError: () => { + showPopup({ + header: "Error updating workspace URL", + message: "Please try again later, or contact customer support.", + }); + }, + }); + + const onSubmit = (data: FormValues) => { + updateWorkspaceSlug.mutate({ + workspacePublicId, + slug: data.slug, + }); + }; + + return ( + <> +
+ +
+ + + ); +}; + +export default UpdateWorkspaceUrlForm; diff --git a/apps/web/src/views/settings/index.tsx b/apps/web/src/views/settings/index.tsx index 186660e0..06c8a3ad 100644 --- a/apps/web/src/views/settings/index.tsx +++ b/apps/web/src/views/settings/index.tsx @@ -1,12 +1,11 @@ -import Modal from "~/components/modal"; import Button from "~/components/Button"; - +import Modal from "~/components/modal"; import { PageHead } from "~/components/PageHead"; -import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation"; -import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm"; - import { useModal } from "~/providers/modal"; import { useWorkspace } from "~/providers/workspace"; +import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation"; +import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm"; +import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm"; export default function SettingsPage() { const { modalContentType, openModal } = useModal(); @@ -14,7 +13,7 @@ export default function SettingsPage() { return ( <> - +

@@ -27,8 +26,16 @@ export default function SettingsPage() { Workspace name

+ +

+ Workspace URL +

+
diff --git a/packages/api/src/routers/workspace.ts b/packages/api/src/routers/workspace.ts index c6854652..e8c38c88 100644 --- a/packages/api/src/routers/workspace.ts +++ b/packages/api/src/routers/workspace.ts @@ -119,7 +119,8 @@ export const workspaceRouter = createTRPCRouter({ .input( z.object({ workspacePublicId: z.string().min(12), - name: z.string().min(3).max(24), + name: z.string().min(3).max(24).optional(), + slug: z.string().min(3).max(24).optional(), }), ) .output(z.custom>>()) @@ -128,6 +129,7 @@ export const workspaceRouter = createTRPCRouter({ ctx.db, input.workspacePublicId, input.name, + input.slug, ); return result; diff --git a/packages/db/src/repository/workspace.repo.ts b/packages/db/src/repository/workspace.repo.ts index beef65b2..1bbe8ccb 100644 --- a/packages/db/src/repository/workspace.repo.ts +++ b/packages/db/src/repository/workspace.repo.ts @@ -42,11 +42,12 @@ export const create = async ( export const update = async ( db: SupabaseClient, workspacePublicId: string, - name: string, + name: string | undefined, + slug: string | undefined, ) => { const { data } = await db .from("workspace") - .update({ name }) + .update({ name, slug }) .eq("publicId", workspacePublicId) .is("deletedAt", null); @@ -110,7 +111,8 @@ export const getAllByUserId = async ( role, workspace ( publicId, - name + name, + slug ) `, )