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 ContentEditable from "react-contenteditable";
import { twMerge } from "tailwind-merge";
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
contentEditable?: boolean;
prefix?: string;
value?: string;
errorMessage?: string;
onChange?: (
@@ -11,26 +13,39 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ contentEditable, errorMessage, value, onChange, ...props }, ref) => {
(
{ contentEditable, errorMessage, prefix, value, onChange, ...props },
ref,
) => {
if (contentEditable) {
return (
<ContentEditable
placeholder={props.placeholder}
html={value ?? ""}
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 (
<div className="flex w-full flex-col gap-1">
<input
ref={ref}
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"
{...props}
/>
<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
ref={ref}
onChange={onChange}
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}
/>
</div>
{errorMessage && (
<div className="text-xs text-red-500">{errorMessage}</div>
)}

View File

@@ -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]);

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 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 (
<>
<PageHead title={`Settings | ${workspace?.name ?? "Workspace"}`} />
<PageHead title={`Settings | ${workspace.name ?? "Workspace"}`} />
<div className="px-28 py-12">
<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]">
@@ -27,8 +26,16 @@ export default function SettingsPage() {
Workspace name
</h2>
<UpdateWorkspaceNameForm
workspacePublicId={workspace?.publicId}
workspaceName={workspace?.name}
workspacePublicId={workspace.publicId}
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>

View File

@@ -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<Awaited<ReturnType<typeof workspaceRepo.update>>>())
@@ -128,6 +129,7 @@ export const workspaceRouter = createTRPCRouter({
ctx.db,
input.workspacePublicId,
input.name,
input.slug,
);
return result;

View File

@@ -42,11 +42,12 @@ export const create = async (
export const update = async (
db: SupabaseClient<Database>,
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
)
`,
)