feat: board slugs

This commit is contained in:
Henry
2025-01-05 16:21:11 +00:00
parent fa9be9d280
commit d98df1d2c6
13 changed files with 1795 additions and 60 deletions

View File

@@ -1,44 +1,27 @@
import { Fragment } from "react";
import { Menu, Transition } from "@headlessui/react";
import { HiEllipsisHorizontal } from "react-icons/hi2";
import { HiEllipsisHorizontal, HiLink, HiOutlineTrash } from "react-icons/hi2";
import Dropdown from "~/components/Dropdown";
import { useModal } from "~/providers/modal";
export default function BoardDropdown() {
const { openModal } = useModal();
return (
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="flex h-8 w-8 items-center justify-center rounded-[5px] hover:bg-light-200 dark:hover:bg-dark-200">
<HiEllipsisHorizontal
size={25}
className="text-light-900 dark:text-dark-900"
/>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-30 mt-2 w-56 origin-top-right rounded-md border border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300">
<div className="flex">
<Menu.Item>
<button
onClick={() => openModal("DELETE_BOARD")}
className="m-1 w-full rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
>
Delete board
</button>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
<Dropdown
items={[
{
label: "Edit board URL",
action: () => openModal("UPDATE_BOARD_SLUG"),
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
{
label: "Delete board",
action: () => openModal("DELETE_BOARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
},
]}
>
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
</Dropdown>
);
}

View File

@@ -0,0 +1,122 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { z } from "zod";
import Button from "~/components/Button";
import Input from "~/components/Input";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
const schema = z.object({
slug: z
.string()
.min(3, {
message: "Board URL must be at least 3 characters long",
})
.max(60, { message: "Board URL cannot exceed 60 characters" })
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
message: "Board URL can only contain letters, numbers, and hyphens",
}),
});
type FormValues = z.infer<typeof schema>;
export function UpdateBoardSlugForm({
boardPublicId,
workspaceSlug,
boardSlug,
}: {
boardPublicId: string;
workspaceSlug: string;
boardSlug: string;
}) {
const { closeModal } = useModal();
const { showPopup } = usePopup();
const { refetchBoard } = useBoard();
const {
register,
handleSubmit,
formState: { isDirty, errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
values: {
slug: boardSlug,
},
mode: "onChange",
});
const updateBoardSlug = api.board.update.useMutation({
onSuccess: async () => {
await refetchBoard();
closeModal();
},
onError: () => {
closeModal();
showPopup({
header: "Unable to update board URL",
message: "Please try again later, or contact customer support.",
});
},
});
useEffect(() => {
const nameElement: HTMLElement | null =
document.querySelector<HTMLElement>("#board-slug");
if (nameElement) nameElement.focus();
}, []);
const onSubmit = (data: FormValues) => {
updateBoardSlug.mutate({
slug: data.slug,
boardPublicId,
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="flex w-full items-center justify-between pb-4">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
Edit board URL
</h2>
<button
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<Input
id="board-slug"
{...register("slug")}
errorMessage={errors.slug?.message}
prefix={`kan.bn/${workspaceSlug}/`}
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button
type="submit"
isLoading={updateBoardSlug.isPending}
disabled={
!isDirty ||
updateBoardSlug.isPending ||
errors.slug?.message !== undefined
}
>
Update
</Button>
</div>
</div>
</form>
);
}

View File

@@ -26,6 +26,7 @@ import Filters from "./components/Filters";
import List from "./components/List";
import { NewCardForm } from "./components/NewCardForm";
import { NewListForm } from "./components/NewListForm";
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
type PublicListId = string;
@@ -313,6 +314,13 @@ export default function BoardPage() {
<NewListForm boardPublicId={boardId} />
)}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
{modalContentType === "UPDATE_BOARD_SLUG" && (
<UpdateBoardSlugForm
boardPublicId={boardId}
workspaceSlug={workspace.slug}
boardSlug={boardData.slug}
/>
)}
</Modal>
</div>
</>

View File

@@ -25,7 +25,7 @@ export default function WorkspaceSlugPage() {
workspaceSlug,
}: {
isLoading: boolean;
boards: { publicId: string; name: string }[];
boards: { publicId: string; name: string; slug: string }[];
workspaceSlug: string;
}) => {
if (isLoading)
@@ -45,7 +45,7 @@ export default function WorkspaceSlugPage() {
{boards.map((board) => (
<Link
key={board.publicId}
href={`/${workspaceSlug}/${board.publicId}`}
href={`/${workspaceSlug}/${board.slug}`}
className="h-full"
>
<div className="relative flex h-full w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">

View File

@@ -138,13 +138,20 @@ export const boardRouter = createTRPCRouter({
.input(
z.object({
boardPublicId: z.string().min(12),
name: z.string().min(1),
name: z.string().min(1).optional(),
slug: z
.string()
.min(3)
.max(60)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>())
.mutation(async ({ ctx, input }) => {
const result = await boardRepo.update(ctx.db, {
name: input.name,
slug: input.slug,
boardPublicId: input.boardPublicId,
});

View File

@@ -77,7 +77,15 @@ export const workspaceRouter = createTRPCRouter({
protect: true,
},
})
.input(z.object({ workspaceSlug: z.string().min(3).max(24) }))
.input(
z.object({
workspaceSlug: z
.string()
.min(3)
.max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}),
)
.output(
z.custom<Awaited<ReturnType<typeof workspaceRepo.getBySlugWithBoards>>>(),
)
@@ -144,7 +152,12 @@ export const workspaceRouter = createTRPCRouter({
z.object({
workspacePublicId: z.string().min(12),
name: z.string().min(3).max(24).optional(),
slug: z.string().min(3).max(24).optional(),
slug: z
.string()
.min(3)
.max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(),
description: z.string().min(3).max(280).optional(),
}),
)
@@ -225,7 +238,15 @@ export const workspaceRouter = createTRPCRouter({
protect: true,
},
})
.input(z.object({ workspaceSlug: z.string().min(3).max(24) }))
.input(
z.object({
workspaceSlug: z
.string()
.min(3)
.max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
}),
)
.output(
z.object({
isAvailable: z.boolean(),

View File

@@ -0,0 +1,12 @@
-- First add the columns without NOT NULL constraint
ALTER TABLE "board" ADD COLUMN "description" text;--> statement-breakpoint
ALTER TABLE "board" ADD COLUMN "slug" varchar(255);--> statement-breakpoint
-- Update existing records with a default slug (using board id to ensure uniqueness)
UPDATE "board" SET "slug" = 'board-' || id::text;--> statement-breakpoint
-- Now add the NOT NULL constraint
ALTER TABLE "board" ALTER COLUMN "slug" SET NOT NULL;--> statement-breakpoint
-- Finally create the unique index
CREATE UNIQUE INDEX IF NOT EXISTS "unique_slug_per_workspace" ON "board" USING btree ("workspaceId","slug");

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,13 @@
"when": 1736084845433,
"tag": "0003_fine_bedlam",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1736087559461,
"tag": "0004_eminent_living_mummy",
"breakpoints": true
}
]
}

View File

@@ -30,6 +30,7 @@ export const getByPublicId = async (
`
publicId,
name,
slug,
workspace (
publicId,
members:workspace_members (
@@ -151,14 +152,19 @@ export const create = async (
export const update = async (
db: SupabaseClient<Database>,
boardInput: { name: string; boardPublicId: string },
boardInput: {
name: string | undefined;
slug: string | undefined;
boardPublicId: string;
},
) => {
const { data } = await db
.from("board")
.update({ name: boardInput.name })
.update({ name: boardInput.name, slug: boardInput.slug })
.eq("publicId", boardInput.boardPublicId)
.select(`publicId, name`)
.limit(1)
.order("id", { ascending: false })
.single();
return data;

View File

@@ -123,6 +123,7 @@ export const getBySlugWithBoards = async (
slug,
boards: board (
publicId,
slug,
name
)
`,

View File

@@ -8,6 +8,7 @@ import {
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
} from "drizzle-orm/pg-core";
@@ -46,22 +47,35 @@ export const workspacePlanEnum = pgEnum("workspace_plan", [
"enterprise",
]);
export const boards = pgTable("board", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
workspaceId: bigint("workspaceId", { mode: "number" })
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
});
export const boards = pgTable(
"board",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
slug: varchar("slug", { length: 255 }).notNull(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
importId: bigint("importId", { mode: "number" }).references(
() => imports.id,
),
workspaceId: bigint("workspaceId", { mode: "number" })
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
},
(table) => ({
uniqueSlugPerWorkspace: uniqueIndex("unique_slug_per_workspace").on(
table.workspaceId,
table.slug,
),
}),
);
export const boardsRelations = relations(boards, ({ one, many }) => ({
createdBy: one(users, {

View File

@@ -75,10 +75,12 @@ export type Database = {
createdBy: string
deletedAt: string | null
deletedBy: string | null
description: string | null
id: number
importId: number | null
name: string
publicId: string
slug: string
updatedAt: string | null
workspaceId: number
}
@@ -87,10 +89,12 @@ export type Database = {
createdBy: string
deletedAt?: string | null
deletedBy?: string | null
description?: string | null
id?: number
importId?: number | null
name: string
publicId: string
slug: string
updatedAt?: string | null
workspaceId: number
}
@@ -99,10 +103,12 @@ export type Database = {
createdBy?: string
deletedAt?: string | null
deletedBy?: string | null
description?: string | null
id?: number
importId?: number | null
name?: string
publicId?: string
slug?: string
updatedAt?: string | null
workspaceId?: number
}