feat: board slugs
This commit is contained in:
@@ -1,44 +1,27 @@
|
|||||||
import { Fragment } from "react";
|
import { HiEllipsisHorizontal, HiLink, HiOutlineTrash } from "react-icons/hi2";
|
||||||
import { Menu, Transition } from "@headlessui/react";
|
|
||||||
import { HiEllipsisHorizontal } from "react-icons/hi2";
|
import Dropdown from "~/components/Dropdown";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
|
|
||||||
export default function BoardDropdown() {
|
export default function BoardDropdown() {
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu as="div" className="relative inline-block text-left">
|
<Dropdown
|
||||||
<div>
|
items={[
|
||||||
<Menu.Button className="flex h-8 w-8 items-center justify-center rounded-[5px] hover:bg-light-200 dark:hover:bg-dark-200">
|
{
|
||||||
<HiEllipsisHorizontal
|
label: "Edit board URL",
|
||||||
size={25}
|
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||||
className="text-light-900 dark:text-dark-900"
|
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||||
/>
|
},
|
||||||
</Menu.Button>
|
{
|
||||||
</div>
|
label: "Delete board",
|
||||||
|
action: () => openModal("DELETE_BOARD"),
|
||||||
<Transition
|
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||||
as={Fragment}
|
},
|
||||||
enter="transition ease-out duration-100"
|
]}
|
||||||
enterFrom="transform opacity-0 scale-95"
|
>
|
||||||
enterTo="transform opacity-100 scale-100"
|
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||||
leave="transition ease-in duration-75"
|
</Dropdown>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 List from "./components/List";
|
||||||
import { NewCardForm } from "./components/NewCardForm";
|
import { NewCardForm } from "./components/NewCardForm";
|
||||||
import { NewListForm } from "./components/NewListForm";
|
import { NewListForm } from "./components/NewListForm";
|
||||||
|
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
|
||||||
|
|
||||||
type PublicListId = string;
|
type PublicListId = string;
|
||||||
|
|
||||||
@@ -313,6 +314,13 @@ export default function BoardPage() {
|
|||||||
<NewListForm boardPublicId={boardId} />
|
<NewListForm boardPublicId={boardId} />
|
||||||
)}
|
)}
|
||||||
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||||
|
{modalContentType === "UPDATE_BOARD_SLUG" && (
|
||||||
|
<UpdateBoardSlugForm
|
||||||
|
boardPublicId={boardId}
|
||||||
|
workspaceSlug={workspace.slug}
|
||||||
|
boardSlug={boardData.slug}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function WorkspaceSlugPage() {
|
|||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
}: {
|
}: {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
boards: { publicId: string; name: string }[];
|
boards: { publicId: string; name: string; slug: string }[];
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
}) => {
|
}) => {
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
@@ -45,7 +45,7 @@ export default function WorkspaceSlugPage() {
|
|||||||
{boards.map((board) => (
|
{boards.map((board) => (
|
||||||
<Link
|
<Link
|
||||||
key={board.publicId}
|
key={board.publicId}
|
||||||
href={`/${workspaceSlug}/${board.publicId}`}
|
href={`/${workspaceSlug}/${board.slug}`}
|
||||||
className="h-full"
|
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">
|
<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(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
boardPublicId: z.string().min(12),
|
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>>>())
|
.output(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>())
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const result = await boardRepo.update(ctx.db, {
|
const result = await boardRepo.update(ctx.db, {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
slug: input.slug,
|
||||||
boardPublicId: input.boardPublicId,
|
boardPublicId: input.boardPublicId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,15 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
protect: true,
|
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(
|
.output(
|
||||||
z.custom<Awaited<ReturnType<typeof workspaceRepo.getBySlugWithBoards>>>(),
|
z.custom<Awaited<ReturnType<typeof workspaceRepo.getBySlugWithBoards>>>(),
|
||||||
)
|
)
|
||||||
@@ -144,7 +152,12 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
z.object({
|
z.object({
|
||||||
workspacePublicId: z.string().min(12),
|
workspacePublicId: z.string().min(12),
|
||||||
name: z.string().min(3).max(24).optional(),
|
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(),
|
description: z.string().min(3).max(280).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -225,7 +238,15 @@ export const workspaceRouter = createTRPCRouter({
|
|||||||
protect: true,
|
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(
|
.output(
|
||||||
z.object({
|
z.object({
|
||||||
isAvailable: z.boolean(),
|
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,
|
"when": 1736084845433,
|
||||||
"tag": "0003_fine_bedlam",
|
"tag": "0003_fine_bedlam",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1736087559461,
|
||||||
|
"tag": "0004_eminent_living_mummy",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,7 @@ export const getByPublicId = async (
|
|||||||
`
|
`
|
||||||
publicId,
|
publicId,
|
||||||
name,
|
name,
|
||||||
|
slug,
|
||||||
workspace (
|
workspace (
|
||||||
publicId,
|
publicId,
|
||||||
members:workspace_members (
|
members:workspace_members (
|
||||||
@@ -151,14 +152,19 @@ export const create = async (
|
|||||||
|
|
||||||
export const update = async (
|
export const update = async (
|
||||||
db: SupabaseClient<Database>,
|
db: SupabaseClient<Database>,
|
||||||
boardInput: { name: string; boardPublicId: string },
|
boardInput: {
|
||||||
|
name: string | undefined;
|
||||||
|
slug: string | undefined;
|
||||||
|
boardPublicId: string;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
const { data } = await db
|
const { data } = await db
|
||||||
.from("board")
|
.from("board")
|
||||||
.update({ name: boardInput.name })
|
.update({ name: boardInput.name, slug: boardInput.slug })
|
||||||
.eq("publicId", boardInput.boardPublicId)
|
.eq("publicId", boardInput.boardPublicId)
|
||||||
.select(`publicId, name`)
|
.select(`publicId, name`)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
.order("id", { ascending: false })
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export const getBySlugWithBoards = async (
|
|||||||
slug,
|
slug,
|
||||||
boards: board (
|
boards: board (
|
||||||
publicId,
|
publicId,
|
||||||
|
slug,
|
||||||
name
|
name
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
primaryKey,
|
primaryKey,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
uuid,
|
uuid,
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
@@ -46,22 +47,35 @@ export const workspacePlanEnum = pgEnum("workspace_plan", [
|
|||||||
"enterprise",
|
"enterprise",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const boards = pgTable("board", {
|
export const boards = pgTable(
|
||||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
"board",
|
||||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
{
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||||
createdBy: uuid("createdBy")
|
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||||
.notNull()
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
.references(() => users.id),
|
description: text("description"),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
slug: varchar("slug", { length: 255 }).notNull(),
|
||||||
updatedAt: timestamp("updatedAt"),
|
createdBy: uuid("createdBy")
|
||||||
deletedAt: timestamp("deletedAt"),
|
.notNull()
|
||||||
deletedBy: uuid("deletedBy").references(() => users.id),
|
.references(() => users.id),
|
||||||
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
workspaceId: bigint("workspaceId", { mode: "number" })
|
updatedAt: timestamp("updatedAt"),
|
||||||
.notNull()
|
deletedAt: timestamp("deletedAt"),
|
||||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
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 }) => ({
|
export const boardsRelations = relations(boards, ({ one, many }) => ({
|
||||||
createdBy: one(users, {
|
createdBy: one(users, {
|
||||||
|
|||||||
@@ -75,10 +75,12 @@ export type Database = {
|
|||||||
createdBy: string
|
createdBy: string
|
||||||
deletedAt: string | null
|
deletedAt: string | null
|
||||||
deletedBy: string | null
|
deletedBy: string | null
|
||||||
|
description: string | null
|
||||||
id: number
|
id: number
|
||||||
importId: number | null
|
importId: number | null
|
||||||
name: string
|
name: string
|
||||||
publicId: string
|
publicId: string
|
||||||
|
slug: string
|
||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
workspaceId: number
|
workspaceId: number
|
||||||
}
|
}
|
||||||
@@ -87,10 +89,12 @@ export type Database = {
|
|||||||
createdBy: string
|
createdBy: string
|
||||||
deletedAt?: string | null
|
deletedAt?: string | null
|
||||||
deletedBy?: string | null
|
deletedBy?: string | null
|
||||||
|
description?: string | null
|
||||||
id?: number
|
id?: number
|
||||||
importId?: number | null
|
importId?: number | null
|
||||||
name: string
|
name: string
|
||||||
publicId: string
|
publicId: string
|
||||||
|
slug: string
|
||||||
updatedAt?: string | null
|
updatedAt?: string | null
|
||||||
workspaceId: number
|
workspaceId: number
|
||||||
}
|
}
|
||||||
@@ -99,10 +103,12 @@ export type Database = {
|
|||||||
createdBy?: string
|
createdBy?: string
|
||||||
deletedAt?: string | null
|
deletedAt?: string | null
|
||||||
deletedBy?: string | null
|
deletedBy?: string | null
|
||||||
|
description?: string | null
|
||||||
id?: number
|
id?: number
|
||||||
importId?: number | null
|
importId?: number | null
|
||||||
name?: string
|
name?: string
|
||||||
publicId?: string
|
publicId?: string
|
||||||
|
slug?: string
|
||||||
updatedAt?: string | null
|
updatedAt?: string | null
|
||||||
workspaceId?: number
|
workspaceId?: number
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user