feat: board slugs
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
122
apps/web/src/views/board/components/UpdateBoardSlugForm.tsx
Normal file
122
apps/web/src/views/board/components/UpdateBoardSlugForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
12
packages/db/migrations/0004_eminent_living_mummy.sql
Normal file
12
packages/db/migrations/0004_eminent_living_mummy.sql
Normal 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");
|
||||
1548
packages/db/migrations/meta/0004_snapshot.json
Normal file
1548
packages/db/migrations/meta/0004_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -123,6 +123,7 @@ export const getBySlugWithBoards = async (
|
||||
slug,
|
||||
boards: board (
|
||||
publicId,
|
||||
slug,
|
||||
name
|
||||
)
|
||||
`,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user