feat: update workspace slug

This commit is contained in:
Henry
2025-01-01 14:42:16 +00:00
parent d05655ac9e
commit b5dc9433db
6 changed files with 144 additions and 35 deletions

View File

@@ -1,8 +1,10 @@
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import ContentEditable from "react-contenteditable"; import ContentEditable from "react-contenteditable";
import { twMerge } from "tailwind-merge";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
contentEditable?: boolean; contentEditable?: boolean;
prefix?: string;
value?: string; value?: string;
errorMessage?: string; errorMessage?: string;
onChange?: ( onChange?: (
@@ -11,26 +13,39 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
} }
const Input = forwardRef<HTMLInputElement, InputProps>( const Input = forwardRef<HTMLInputElement, InputProps>(
({ contentEditable, errorMessage, value, onChange, ...props }, ref) => { (
{ contentEditable, errorMessage, prefix, value, onChange, ...props },
ref,
) => {
if (contentEditable) { if (contentEditable) {
return ( return (
<ContentEditable <ContentEditable
placeholder={props.placeholder} placeholder={props.placeholder}
html={value ?? ""} html={value ?? ""}
onChange={onChange} onChange={onChange}
className="block min-h-[70px] w-full cursor-text rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-1.5 text-light-900 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 focus-visible:outline-none dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6" className="block min-h-[70px] w-full cursor-text rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-1.5 text-light-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 focus-visible:outline-none dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
/> />
); );
} }
return ( return (
<div className="flex w-full flex-col gap-1"> <div className="flex w-full flex-col gap-1">
<div className="flex">
{prefix && (
<div className="flex shrink-0 items-center rounded-l-md border border-r-0 border-light-600 px-3 text-base dark:border-dark-700 sm:text-sm/6">
{prefix}
</div>
)}
<input <input
ref={ref} ref={ref}
onChange={onChange} onChange={onChange}
className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6" className={twMerge(
"block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6",
prefix && "rounded-l-none",
)}
{...props} {...props}
/> />
</div>
{errorMessage && ( {errorMessage && (
<div className="text-xs text-red-500">{errorMessage}</div> <div className="text-xs text-red-500">{errorMessage}</div>
)} )}

View File

@@ -1,15 +1,9 @@
import React, { import type { ReactNode } from "react";
createContext, import { useRouter } from "next/navigation";
useContext, import React, { createContext, useContext, useEffect, useState } from "react";
useEffect,
useState,
type ReactNode,
} from "react";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { useRouter } from "next/navigation";
interface WorkspaceContextProps { interface WorkspaceContextProps {
workspace: Workspace; workspace: Workspace;
isLoading: boolean; isLoading: boolean;
@@ -20,11 +14,13 @@ interface WorkspaceContextProps {
interface Workspace { interface Workspace {
name: string; name: string;
publicId: string; publicId: string;
slug: string;
} }
const initialWorkspace: Workspace = { const initialWorkspace: Workspace = {
name: "", name: "",
publicId: "", publicId: "",
slug: "",
}; };
const initialAvailableWorkspaces: Workspace[] = []; const initialAvailableWorkspaces: Workspace[] = [];
@@ -58,7 +54,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
const storedWorkspaceId: string | null = const storedWorkspaceId: string | null =
localStorage.getItem("workspacePublicId"); localStorage.getItem("workspacePublicId");
if (data?.length) { if (data.length) {
const workspaces = data const workspaces = data
.map(({ workspace }) => { .map(({ workspace }) => {
if (!workspace) return; if (!workspace) return;
@@ -66,6 +62,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
return { return {
publicId: workspace.publicId, publicId: workspace.publicId,
name: workspace.name, name: workspace.name,
slug: workspace.slug,
}; };
}) })
.filter((workspace) => workspace !== null) as Workspace[]; .filter((workspace) => workspace !== null) as Workspace[];
@@ -75,7 +72,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
if (storedWorkspaceId !== null) { if (storedWorkspaceId !== null) {
const newData = data; const newData = data;
const selectedWorkspace = newData?.find( const selectedWorkspace = newData.find(
({ workspace }) => workspace?.publicId === storedWorkspaceId, ({ workspace }) => workspace?.publicId === storedWorkspaceId,
); );
@@ -84,14 +81,16 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
setWorkspace({ setWorkspace({
publicId: selectedWorkspace.workspace.publicId, publicId: selectedWorkspace.workspace.publicId,
name: selectedWorkspace.workspace.name, name: selectedWorkspace.workspace.name,
slug: selectedWorkspace.workspace.slug,
}); });
} else { } else {
const primaryWorkspace = data?.[0]?.workspace; const primaryWorkspace = data[0]?.workspace;
if (!primaryWorkspace) return; if (!primaryWorkspace) return;
localStorage.setItem("workspacePublicId", primaryWorkspace?.publicId); localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
setWorkspace({ setWorkspace({
publicId: primaryWorkspace?.publicId, publicId: primaryWorkspace.publicId,
name: primaryWorkspace?.name, name: primaryWorkspace.name,
slug: primaryWorkspace.slug,
}); });
} }
}, [data]); }, [data]);

View File

@@ -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<typeof schema>;
const UpdateWorkspaceUrlForm = ({
workspacePublicId,
workspaceUrl,
}: {
workspacePublicId: string;
workspaceUrl: string;
}) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
const {
register,
handleSubmit,
formState: { isDirty, errors },
} = useForm<FormValues>({
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 (
<>
<div className="mb-4 flex max-w-[350px] items-center gap-2">
<Input
{...register("slug")}
errorMessage={errors.slug?.message}
prefix="kanbn.com/"
/>
</div>
<Button
onClick={handleSubmit(onSubmit)}
variant="primary"
disabled={!isDirty || updateWorkspaceSlug.isPending}
isLoading={updateWorkspaceSlug.isPending}
>
Update
</Button>
</>
);
};
export default UpdateWorkspaceUrlForm;

View File

@@ -1,12 +1,11 @@
import Modal from "~/components/modal";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Modal from "~/components/modal";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
export default function SettingsPage() { export default function SettingsPage() {
const { modalContentType, openModal } = useModal(); const { modalContentType, openModal } = useModal();
@@ -14,7 +13,7 @@ export default function SettingsPage() {
return ( return (
<> <>
<PageHead title={`Settings | ${workspace?.name ?? "Workspace"}`} /> <PageHead title={`Settings | ${workspace.name ?? "Workspace"}`} />
<div className="px-28 py-12"> <div className="px-28 py-12">
<div className="mb-8 flex w-full justify-between"> <div className="mb-8 flex w-full justify-between">
<h1 className="font-medium tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]"> <h1 className="font-medium tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
@@ -27,8 +26,16 @@ export default function SettingsPage() {
Workspace name Workspace name
</h2> </h2>
<UpdateWorkspaceNameForm <UpdateWorkspaceNameForm
workspacePublicId={workspace?.publicId} workspacePublicId={workspace.publicId}
workspaceName={workspace?.name} workspaceName={workspace.name}
/>
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
Workspace URL
</h2>
<UpdateWorkspaceUrlForm
workspacePublicId={workspace.publicId}
workspaceUrl={workspace.slug}
/> />
</div> </div>

View File

@@ -119,7 +119,8 @@ export const workspaceRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
workspacePublicId: z.string().min(12), 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<Awaited<ReturnType<typeof workspaceRepo.update>>>()) .output(z.custom<Awaited<ReturnType<typeof workspaceRepo.update>>>())
@@ -128,6 +129,7 @@ export const workspaceRouter = createTRPCRouter({
ctx.db, ctx.db,
input.workspacePublicId, input.workspacePublicId,
input.name, input.name,
input.slug,
); );
return result; return result;

View File

@@ -42,11 +42,12 @@ export const create = async (
export const update = async ( export const update = async (
db: SupabaseClient<Database>, db: SupabaseClient<Database>,
workspacePublicId: string, workspacePublicId: string,
name: string, name: string | undefined,
slug: string | undefined,
) => { ) => {
const { data } = await db const { data } = await db
.from("workspace") .from("workspace")
.update({ name }) .update({ name, slug })
.eq("publicId", workspacePublicId) .eq("publicId", workspacePublicId)
.is("deletedAt", null); .is("deletedAt", null);
@@ -110,7 +111,8 @@ export const getAllByUserId = async (
role, role,
workspace ( workspace (
publicId, publicId,
name name,
slug
) )
`, `,
) )