From aa34fe7838a2e7b4332f0bfc1bb2370ce49dc9a7 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Fri, 17 Apr 2026 15:23:11 -0500 Subject: [PATCH 01/13] fix being able to accept multiple times by invite code --- src/invites/mutations/acceptInvite.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/invites/mutations/acceptInvite.ts b/src/invites/mutations/acceptInvite.ts index bd1f9744..02ad9064 100644 --- a/src/invites/mutations/acceptInvite.ts +++ b/src/invites/mutations/acceptInvite.ts @@ -226,12 +226,9 @@ export default resolver.pipe( ctx ) - // Delete invitation(s) for that email and project Id - await db.invitation.deleteMany({ - where: { - email: invite.email, - projectId: invite.projectId, - }, + // Delete this specific invitation by id so it can only be used once + await db.invitation.delete({ + where: { id: invite.id }, }) return project From d7216e93110e7424fda38b103a87fdcd2923b6a6 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Fri, 17 Apr 2026 15:48:49 -0500 Subject: [PATCH 02/13] fix task refresh on window tab switching --- src/blitz-client.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/blitz-client.ts b/src/blitz-client.ts index db1b392d..33833cd8 100644 --- a/src/blitz-client.ts +++ b/src/blitz-client.ts @@ -7,5 +7,14 @@ export const authConfig = { } export const { withBlitz } = setupBlitzClient({ - plugins: [AuthClientPlugin(authConfig), BlitzRpcPlugin({})], + plugins: [ + AuthClientPlugin(authConfig), + BlitzRpcPlugin({ + reactQueryOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, + }), + ], }) From 67a8fbd43b84214b569e96482cde4668cf9d2fa1 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Fri, 17 Apr 2026 15:48:57 -0500 Subject: [PATCH 03/13] fix issue with new task flashing --- src/pages/projects/[projectId]/tasks/new.tsx | 264 +++++++++---------- 1 file changed, 129 insertions(+), 135 deletions(-) diff --git a/src/pages/projects/[projectId]/tasks/new.tsx b/src/pages/projects/[projectId]/tasks/new.tsx index bc3ecc2b..d9bcf401 100644 --- a/src/pages/projects/[projectId]/tasks/new.tsx +++ b/src/pages/projects/[projectId]/tasks/new.tsx @@ -15,6 +15,135 @@ import Link from "next/link" import { InformationCircleIcon } from "@heroicons/react/24/outline" import { Tooltip } from "react-tooltip" +const mapTaskToInitialValues = (task: any) => { + if (!task) return undefined + + const initial: any = {} + + // Only copy simple text/date/ids + if (typeof task.name === "string" && task.name.trim()) initial.name = `${task.name} (Copy)` + if ("description" in task) initial.description = task.description ?? null + if ("milestoneId" in task) initial.milestoneId = task.milestoneId ?? null + if ("deadline" in task && task.deadline) initial.deadline = new Date(task.deadline) + if ("formVersionId" in task) initial.formVersionId = task.formVersionId ?? null + if ("startDate" in task && task.startDate) initial.startDate = new Date(task.startDate) + if ("containerId" in task) initial.containerId = task.containerId ?? null + if ("anonymous" in task) initial.anonymous = task.anonymous ?? false + + // Map contributors/teams from assignedMembers if present (kept minimal) + if (Array.isArray((task as any).assignedMembers)) { + const assigned = (task as any).assignedMembers as any[] + const contributors: number[] = [] + const teams: number[] = [] + for (const m of assigned) { + const id = typeof m?.id === "number" ? m.id : undefined + const usersLen = Array.isArray(m?.users) ? m.users.length : 0 + if (typeof id === "number") { + if (usersLen > 1) teams.push(id) + else contributors.push(id) + } + } + if (contributors.length) initial.projectMembersId = contributors + if (teams.length) initial.teamsId = teams + } + + // Map roles if included + if (Array.isArray((task as any).roles)) { + const rids = (task as any).roles + .map((r: any) => (typeof r?.id === "number" ? r.id : undefined)) + .filter((n: any) => typeof n === "number") + if (rids.length) initial.rolesId = rids + } + + // Copy simple numeric ID arrays (no relation includes) + const toNumArray = (arr: any) => + Array.isArray(arr) + ? arr + .map((v) => (v == null ? v : Number(v))) + .filter((v) => typeof v === "number" && !Number.isNaN(v)) + : undefined + + const roleIds = toNumArray((task as any).rolesId) + if (roleIds && roleIds.length) initial.rolesId = roleIds + + const memberIds = toNumArray((task as any).projectMembersId) + if (memberIds && memberIds.length) initial.projectMembersId = memberIds + + const teamIds = toNumArray((task as any).teamsId) + if (teamIds && teamIds.length) initial.teamsId = teamIds + + // Copy tags in UI shape expected by the tag input ({ id, text }) + if (Array.isArray((task as any).tags)) { + const src = (task as any).tags as any[] + const uiTags = src + .map((t) => { + if (!t) return undefined + if (typeof t === "object") { + if ("id" in t && "text" in t && typeof t.id === "string" && typeof t.text === "string") { + return { id: t.id, text: t.text } + } + if ( + "key" in t && + "value" in t && + typeof (t as any).key === "string" && + typeof (t as any).value === "string" + ) { + return { id: (t as any).key, text: (t as any).value } + } + } + if (typeof t === "string") return { id: t, text: t } + return undefined + }) + .filter(Boolean) + if (uiTags.length) initial.tags = uiTags as { id: string; text: string }[] + } + + return initial +} + +const TaskFormWrapper = ({ projectId, onSubmit, onCancel, schema }: any) => { + const router = useRouter() + const copyFromTaskIdParam = router.query.copyFromTaskId + const copyFromTaskId = + typeof copyFromTaskIdParam === "string" + ? parseInt(copyFromTaskIdParam, 10) + : Array.isArray(copyFromTaskIdParam) + ? parseInt(copyFromTaskIdParam[0]!, 10) + : undefined + + const [sourceTask] = useQuery( + getTask, + { + where: { id: copyFromTaskId ?? -1 }, + include: { + assignedMembers: { include: { users: { select: { id: true } } } }, + roles: { select: { id: true } }, + }, + }, + { + enabled: Boolean(copyFromTaskId), + suspense: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + } + ) + + const initialValues = mapTaskToInitialValues(sourceTask) + + return ( + + ) +} + const NewTaskPage = () => { const router = useRouter() const [createTaskMutation] = useMutation(createTask) @@ -61,141 +190,6 @@ const NewTaskPage = () => { } } - const mapTaskToInitialValues = (task: any) => { - if (!task) return undefined - - const initial: any = {} - - // Only copy simple text/date/ids - if (typeof task.name === "string" && task.name.trim()) initial.name = `${task.name} (Copy)` - if ("description" in task) initial.description = task.description ?? null - if ("milestoneId" in task) initial.milestoneId = task.milestoneId ?? null - if ("deadline" in task && task.deadline) initial.deadline = new Date(task.deadline) - if ("formVersionId" in task) initial.formVersionId = task.formVersionId ?? null - if ("startDate" in task && task.startDate) initial.startDate = new Date(task.startDate) - if ("containerId" in task) initial.containerId = task.containerId ?? null - if ("anonymous" in task) initial.anonymous = task.anonymous ?? false - - // Map contributors/teams from assignedMembers if present (kept minimal) - if (Array.isArray((task as any).assignedMembers)) { - const assigned = (task as any).assignedMembers as any[] - const contributors: number[] = [] - const teams: number[] = [] - for (const m of assigned) { - const id = typeof m?.id === "number" ? m.id : undefined - const usersLen = Array.isArray(m?.users) ? m.users.length : 0 - if (typeof id === "number") { - if (usersLen > 1) teams.push(id) - else contributors.push(id) - } - } - if (contributors.length) initial.projectMembersId = contributors - if (teams.length) initial.teamsId = teams - } - - // Map roles if included - if (Array.isArray((task as any).roles)) { - const rids = (task as any).roles - .map((r: any) => (typeof r?.id === "number" ? r.id : undefined)) - .filter((n: any) => typeof n === "number") - if (rids.length) initial.rolesId = rids - } - - // Copy simple numeric ID arrays (no relation includes) - const toNumArray = (arr: any) => - Array.isArray(arr) - ? arr - .map((v) => (v == null ? v : Number(v))) - .filter((v) => typeof v === "number" && !Number.isNaN(v)) - : undefined - - const roleIds = toNumArray((task as any).rolesId) - if (roleIds && roleIds.length) initial.rolesId = roleIds - - const memberIds = toNumArray((task as any).projectMembersId) - if (memberIds && memberIds.length) initial.projectMembersId = memberIds - - const teamIds = toNumArray((task as any).teamsId) - if (teamIds && teamIds.length) initial.teamsId = teamIds - - // Copy tags in UI shape expected by the tag input ({ id, text }) - if (Array.isArray((task as any).tags)) { - const src = (task as any).tags as any[] - const uiTags = src - .map((t) => { - if (!t) return undefined - if (typeof t === "object") { - if ( - "id" in t && - "text" in t && - typeof t.id === "string" && - typeof t.text === "string" - ) { - return { id: t.id, text: t.text } - } - if ( - "key" in t && - "value" in t && - typeof (t as any).key === "string" && - typeof (t as any).value === "string" - ) { - return { id: (t as any).key, text: (t as any).value } - } - } - if (typeof t === "string") return { id: t, text: t } - return undefined - }) - .filter(Boolean) - if (uiTags.length) initial.tags = uiTags as { id: string; text: string }[] - } - - return initial - } - - // Wrapper to handle copyFromTaskId and fetch the source task if needed - const TaskFormWrapper = ({ projectId, onSubmit, onCancel, schema }: any) => { - const router = useRouter() - const copyFromTaskIdParam = router.query.copyFromTaskId - const copyFromTaskId = - typeof copyFromTaskIdParam === "string" - ? parseInt(copyFromTaskIdParam, 10) - : Array.isArray(copyFromTaskIdParam) - ? parseInt(copyFromTaskIdParam[0]!, 10) - : undefined - - const [sourceTask] = useQuery( - getTask, - { - where: { id: copyFromTaskId ?? -1 }, - include: { - assignedMembers: { include: { users: { select: { id: true } } } }, - roles: { select: { id: true } }, - }, - }, - { - enabled: Boolean(copyFromTaskId), - suspense: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - } - ) - - const initialValues = mapTaskToInitialValues(sourceTask) - - return ( - - ) - } - return ( // @ts-expect-error children are clearly passed below From f7709661f7540e8dbe2babcd9ac999a6a226d1db Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sat, 18 Apr 2026 11:49:44 -0500 Subject: [PATCH 04/13] adding in global search across pages --- src/core/components/Table.tsx | 22 +++++++++++++++---- src/forms/components/FormsList.tsx | 3 +++ src/pages/forms/index.tsx | 8 +++++++ src/pages/notifications/index.tsx | 18 ++++++++++++++- .../[projectId]/notifications/index.tsx | 18 ++++++++++++++- .../projects/[projectId]/teams/index.tsx | 9 +++++++- src/pages/roles/index.tsx | 19 +++++++++++++++- src/roles/components/AllRolesList.tsx | 3 +++ src/roles/components/ContributorsTab.tsx | 22 ++++++++++++++++--- src/roles/components/RoleContributorTable.tsx | 3 +++ src/tasks/components/AllTaskList.tsx | 15 +++++++++++++ src/tasks/components/ProjectTasksList.tsx | 9 +++++++- src/tasks/hooks/useProjectTasksListData.ts | 10 ++++++--- 13 files changed, 144 insertions(+), 15 deletions(-) diff --git a/src/core/components/Table.tsx b/src/core/components/Table.tsx index 6ee0d6f5..2fe2d10d 100644 --- a/src/core/components/Table.tsx +++ b/src/core/components/Table.tsx @@ -138,6 +138,7 @@ type TableProps = { onPaginationChange?: OnChangeFn pageCount?: number pageSizeOptions?: number[] + onGlobalFilterChange?: (filter: string) => void classNames?: { table?: string thead?: string @@ -191,6 +192,7 @@ const Table = ({ onPaginationChange, pageCount: controlledPageCount, pageSizeOptions = [5, 10, 20, 30, 40, 50], + onGlobalFilterChange, }: TableProps) => { const [sorting, setSorting] = React.useState([]) const [globalFilter, setGlobalFilter] = React.useState("") @@ -239,15 +241,27 @@ const Table = ({ const globalSearchTooltipId = React.useId() React.useEffect(() => { - if (!addPagination) { - return - } - + if (!addPagination) return if (!manualPagination && pageCount > 0 && pageIndex >= pageCount) { table.setPageIndex(0) } }, [addPagination, pageCount, pageIndex, table, manualPagination]) + const isFirstFilterRender = React.useRef(true) + React.useEffect(() => { + if (isFirstFilterRender.current) { + isFirstFilterRender.current = false + return + } + if (!addPagination) return + if (manualPagination) { + onGlobalFilterChange?.(globalFilter) + } else { + table.setPageIndex(0) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [globalFilter]) + return ( <> {enableGlobalSearch && ( diff --git a/src/forms/components/FormsList.tsx b/src/forms/components/FormsList.tsx index 76ff6cce..5b9cf44e 100644 --- a/src/forms/components/FormsList.tsx +++ b/src/forms/components/FormsList.tsx @@ -11,6 +11,7 @@ type FormsListProps = { onPaginationChange?: OnChangeFn pageCount?: number pageSizeOptions?: number[] + onGlobalFilterChange?: (filter: string) => void } export const FormsList = ({ @@ -20,6 +21,7 @@ export const FormsList = ({ onPaginationChange, pageCount, pageSizeOptions, + onGlobalFilterChange, }: FormsListProps) => { const formsTableData = processForms(forms) @@ -34,6 +36,7 @@ export const FormsList = ({ onPaginationChange={onPaginationChange} pageCount={pageCount} pageSizeOptions={pageSizeOptions} + onGlobalFilterChange={onGlobalFilterChange} /> ) diff --git a/src/pages/forms/index.tsx b/src/pages/forms/index.tsx index 6fcb3d18..380afc9a 100644 --- a/src/pages/forms/index.tsx +++ b/src/pages/forms/index.tsx @@ -25,6 +25,7 @@ const AllFormsPage = () => { pageIndex: 0, pageSize: 10, }) + const [search, setSearch] = useState("") const paginationArgs = useMemo( () => ({ @@ -39,6 +40,7 @@ const AllFormsPage = () => { where: { user: { id: currentUser?.id }, archived: false, + ...(search ? { name: { contains: search, mode: "insensitive" } } : {}), }, orderBy: { id: "desc" }, ...paginationArgs, @@ -52,6 +54,11 @@ const AllFormsPage = () => { setPagination((prev) => (typeof updater === "function" ? updater(prev) : updater)) } + const handleGlobalFilterChange = (filter: string) => { + setSearch(filter) + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + } + return ( // @ts-expect-error children are clearly passed below @@ -91,6 +98,7 @@ const AllFormsPage = () => { onPaginationChange={handlePaginationChange} pageCount={pageCount} pageSizeOptions={[10, 25, 50, 100]} + onGlobalFilterChange={handleGlobalFilterChange} /> diff --git a/src/pages/notifications/index.tsx b/src/pages/notifications/index.tsx index af240f53..035ce713 100644 --- a/src/pages/notifications/index.tsx +++ b/src/pages/notifications/index.tsx @@ -31,6 +31,7 @@ const NotificationContent = () => { pageIndex: 0, pageSize: 10, }) + const [search, setSearch] = useState("") const baseWhere = useMemo( () => ({ @@ -51,8 +52,16 @@ const NotificationContent = () => { }, }, }, + ...(search + ? { + OR: [ + { type: { contains: search, mode: "insensitive" } }, + { project: { name: { contains: search, mode: "insensitive" } } }, + ], + } + : {}), }), - [currentUser] + [currentUser, search] ) const paginationArgs = useMemo( @@ -103,6 +112,12 @@ const NotificationContent = () => { disableGlobalSelection() } + const handleGlobalFilterChange = (filter: string) => { + setSearch(filter) + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + disableGlobalSelection() + } + const handleActionCompleted = async () => { await refetch() resetSelection() @@ -169,6 +184,7 @@ const NotificationContent = () => { onPaginationChange={handlePaginationChange} pageCount={pageCount} pageSizeOptions={[10, 25, 50, 100]} + onGlobalFilterChange={handleGlobalFilterChange} /> diff --git a/src/pages/projects/[projectId]/notifications/index.tsx b/src/pages/projects/[projectId]/notifications/index.tsx index f1a22586..117e6b3b 100644 --- a/src/pages/projects/[projectId]/notifications/index.tsx +++ b/src/pages/projects/[projectId]/notifications/index.tsx @@ -31,6 +31,7 @@ const NotificationContent = () => { pageIndex: 0, pageSize: 10, }) + const [search, setSearch] = useState("") const baseWhere = useMemo( () => ({ @@ -40,8 +41,16 @@ const NotificationContent = () => { }, }, projectId: projectId ?? undefined, + ...(search + ? { + OR: [ + { type: { contains: search, mode: "insensitive" } }, + { project: { name: { contains: search, mode: "insensitive" } } }, + ], + } + : {}), }), - [currentUser, projectId] + [currentUser, projectId, search] ) const paginationArgs = useMemo( @@ -93,6 +102,12 @@ const NotificationContent = () => { disableGlobalSelection() } + const handleGlobalFilterChange = (filter: string) => { + setSearch(filter) + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + disableGlobalSelection() + } + const handleActionCompleted = async () => { await refetch() resetSelection() @@ -158,6 +173,7 @@ const NotificationContent = () => { onPaginationChange={handlePaginationChange} pageCount={pageCount} pageSizeOptions={[10, 25, 50, 100]} + onGlobalFilterChange={handleGlobalFilterChange} /> diff --git a/src/pages/projects/[projectId]/teams/index.tsx b/src/pages/projects/[projectId]/teams/index.tsx index 9139ac00..2f4f9d9e 100644 --- a/src/pages/projects/[projectId]/teams/index.tsx +++ b/src/pages/projects/[projectId]/teams/index.tsx @@ -29,11 +29,12 @@ export const AllTeamList = ({ privilege, projectId }: AllTeamListProps) => { pageIndex: 0, pageSize: 10, }) + const [search, setSearch] = useState("") const [{ projectMembers, count }] = usePaginatedQuery(getProjectMembers, { where: { projectId: projectId, - name: { not: null }, // Ensures the name in ProjectMember is non-null + name: { not: null, ...(search ? { contains: search, mode: "insensitive" } : {}) }, users: { some: { id: { not: undefined } }, // Ensures there's at least one user }, @@ -67,6 +68,11 @@ export const AllTeamList = ({ privilege, projectId }: AllTeamListProps) => { setPagination((prev) => (typeof updater === "function" ? updater(prev) : updater)) } + const handleGlobalFilterChange = (filter: string) => { + setSearch(filter) + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + } + return (
{ onPaginationChange={handlePaginationChange} pageCount={pageCount} pageSizeOptions={[10, 25, 50, 100]} + onGlobalFilterChange={handleGlobalFilterChange} /> ) diff --git a/src/pages/roles/index.tsx b/src/pages/roles/index.tsx index 40102354..cce02c64 100644 --- a/src/pages/roles/index.tsx +++ b/src/pages/roles/index.tsx @@ -17,6 +17,7 @@ const RoleBuilderPage = () => { pageIndex: 0, pageSize: 10, }) + const [search, setSearch] = useState("") const paginationArgs = useMemo( () => ({ @@ -27,7 +28,17 @@ const RoleBuilderPage = () => { ) const [{ roles, count }, { refetch: refetchPagedRoles }] = usePaginatedQuery(getRoles, { - where: { user: { id: currentUser?.id } }, + where: { + user: { id: currentUser?.id }, + ...(search + ? { + OR: [ + { name: { contains: search, mode: "insensitive" } }, + { taxonomy: { contains: search, mode: "insensitive" } }, + ], + } + : {}), + }, orderBy: { id: "asc" }, ...paginationArgs, }) @@ -59,6 +70,11 @@ const RoleBuilderPage = () => { setPagination((prev) => (typeof updater === "function" ? updater(prev) : updater)) } + const handleGlobalFilterChange = (filter: string) => { + setSearch(filter) + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + } + return ( // @ts-expect-error children are clearly passed below @@ -91,6 +107,7 @@ const RoleBuilderPage = () => { onPaginationChange={handlePaginationChange} pageCount={pageCount} pageSizeOptions={[10, 25, 50, 100]} + onGlobalFilterChange={handleGlobalFilterChange} /> diff --git a/src/roles/components/AllRolesList.tsx b/src/roles/components/AllRolesList.tsx index 91461668..79471190 100644 --- a/src/roles/components/AllRolesList.tsx +++ b/src/roles/components/AllRolesList.tsx @@ -13,6 +13,7 @@ interface AllRolesListProps { onPaginationChange?: OnChangeFn pageCount?: number pageSizeOptions?: number[] + onGlobalFilterChange?: (filter: string) => void } export const AllRolesList = ({ @@ -24,6 +25,7 @@ export const AllRolesList = ({ onPaginationChange, pageCount, pageSizeOptions, + onGlobalFilterChange, }: AllRolesListProps) => { // Process table data const roleTableData = processRoleTableData(roles, onRolesChanged, taxonomyList) @@ -39,6 +41,7 @@ export const AllRolesList = ({ onPaginationChange={onPaginationChange} pageCount={pageCount} pageSizeOptions={pageSizeOptions} + onGlobalFilterChange={onGlobalFilterChange} /> ) diff --git a/src/roles/components/ContributorsTab.tsx b/src/roles/components/ContributorsTab.tsx index bb8ebbd2..d0966545 100644 --- a/src/roles/components/ContributorsTab.tsx +++ b/src/roles/components/ContributorsTab.tsx @@ -13,6 +13,7 @@ import { PaginationState } from "@tanstack/react-table" const ContributorsTab = () => { const projectId = useParam("projectId", "number") const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }) + const [search, setSearch] = useState("") const [{ projectMembers: contributors, count }, { refetch }] = usePaginatedQuery( getProjectMembers, @@ -20,9 +21,18 @@ const ContributorsTab = () => { where: { projectId: projectId, users: { - every: { - id: { not: undefined }, // Ensures there's at least one user - }, + every: { id: { not: undefined } }, // Ensures there's at least one user + ...(search + ? { + some: { + OR: [ + { username: { contains: search, mode: "insensitive" } }, + { firstName: { contains: search, mode: "insensitive" } }, + { lastName: { contains: search, mode: "insensitive" } }, + ], + }, + } + : {}), }, deleted: undefined, name: { equals: null }, // Ensures ProjectMember is contributor and not team @@ -42,6 +52,11 @@ const ContributorsTab = () => { setPagination((prev) => (typeof updater === "function" ? updater(prev) : updater)) } + const handleGlobalFilterChange = (filter: string) => { + setSearch(filter) + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + } + return (
@@ -54,6 +69,7 @@ const ContributorsTab = () => { onPaginationChange={handlePaginationChange} pageCount={pageCount} pageSizeOptions={[10, 25, 50, 100]} + onGlobalFilterChange={handleGlobalFilterChange} />
pageCount?: number pageSizeOptions?: number[] + onGlobalFilterChange?: (filter: string) => void } export const RoleContributorTable = ({ @@ -18,6 +19,7 @@ export const RoleContributorTable = ({ onPaginationChange, pageCount, pageSizeOptions, + onGlobalFilterChange, }: RoleContributorTableProps) => { const processedData = contributors.map((contributor) => ({ username: contributor.users[0].username, @@ -39,6 +41,7 @@ export const RoleContributorTable = ({ onPaginationChange={onPaginationChange} pageCount={pageCount} pageSizeOptions={pageSizeOptions} + onGlobalFilterChange={onGlobalFilterChange} /> ) } diff --git a/src/tasks/components/AllTaskList.tsx b/src/tasks/components/AllTaskList.tsx index e3158fbe..4f1d42c3 100644 --- a/src/tasks/components/AllTaskList.tsx +++ b/src/tasks/components/AllTaskList.tsx @@ -20,6 +20,7 @@ export const AllTasksList = () => { pageIndex: 0, pageSize: 10, }) + const [search, setSearch] = useState("") const [{ tasks, count }] = usePaginatedQuery(getTasks, { where: { @@ -32,6 +33,14 @@ export const AllTasksList = () => { }, }, }, + ...(search + ? { + OR: [ + { name: { contains: search, mode: "insensitive" } }, + { project: { name: { contains: search, mode: "insensitive" } } }, + ], + } + : {}), }, include: { project: true, @@ -93,6 +102,11 @@ export const AllTasksList = () => { setPagination((prev) => (typeof updater === "function" ? updater(prev) : updater)) } + const handleGlobalFilterChange = (filter: string) => { + setSearch(filter) + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + } + return (
@@ -105,6 +119,7 @@ export const AllTasksList = () => { onPaginationChange={handlePaginationChange} pageCount={pageCount} pageSizeOptions={[10, 25, 50, 100]} + onGlobalFilterChange={handleGlobalFilterChange} /> Note: This list only shows comment notifications for tasks that are explicitly assigned to diff --git a/src/tasks/components/ProjectTasksList.tsx b/src/tasks/components/ProjectTasksList.tsx index ea959fb6..f80d270b 100644 --- a/src/tasks/components/ProjectTasksList.tsx +++ b/src/tasks/components/ProjectTasksList.tsx @@ -11,8 +11,9 @@ export const ProjectTasksList = () => { pageIndex: 0, pageSize: 10, }) + const [search, setSearch] = useState("") - const { tasks, count } = useProjecTasksListData(projectId, pagination) + const { tasks, count } = useProjecTasksListData(projectId, pagination, search) const pageCount = Math.max(1, Math.ceil((count ?? 0) / pagination.pageSize)) const handlePaginationChange = ( @@ -21,6 +22,11 @@ export const ProjectTasksList = () => { setPagination((prev) => (typeof updater === "function" ? updater(prev) : updater)) } + const handleGlobalFilterChange = (filter: string) => { + setSearch(filter) + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + } + return (
@@ -33,6 +39,7 @@ export const ProjectTasksList = () => { onPaginationChange={handlePaginationChange} pageCount={pageCount} pageSizeOptions={[10, 25, 50, 100]} + onGlobalFilterChange={handleGlobalFilterChange} />
diff --git a/src/tasks/hooks/useProjectTasksListData.ts b/src/tasks/hooks/useProjectTasksListData.ts index 0bb616d5..8d267e0e 100644 --- a/src/tasks/hooks/useProjectTasksListData.ts +++ b/src/tasks/hooks/useProjectTasksListData.ts @@ -10,7 +10,8 @@ import { PaginationState } from "@tanstack/react-table" export default function useProjectTasksListData( projectId: number | undefined, - pagination: PaginationState + pagination: PaginationState, + search: string = "" ) { const currentUser = useCurrentUser() const { privilege } = useMemberPrivileges() @@ -24,7 +25,10 @@ export default function useProjectTasksListData( if (!privilege || !currentUser || !projectId) return let baseParams: GetTasksInput = { - where: { project: { id: projectId } }, + where: { + project: { id: projectId }, + ...(search ? { name: { contains: search, mode: "insensitive" } } : {}), + }, orderBy: [{ id: "asc" }], include: { container: { @@ -104,7 +108,7 @@ export default function useProjectTasksListData( } setQueryParams(baseParams) - }, [privilege, currentUser, projectId, userMemberIds]) + }, [privilege, currentUser, projectId, userMemberIds, search]) const queryInput = useMemo(() => { const base = queryParams ?? { From 8559f638b7d8f8fc62d23df98060f30d4905afb6 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Sat, 18 Apr 2026 20:14:45 -0500 Subject: [PATCH 05/13] update table search for filtering columns --- src/core/components/Table.tsx | 20 +++++++ src/pages/notifications/index.tsx | 59 ++++++++++++------- .../[projectId]/notifications/index.tsx | 48 ++++++++++----- src/roles/components/ContributorsTab.tsx | 16 ++++- src/roles/components/RoleContributorTable.tsx | 5 +- src/tasks/components/AllTaskList.tsx | 16 ++++- src/tasks/components/ProjectTasksList.tsx | 11 +++- src/tasks/hooks/useProjectTasksListData.ts | 20 +++++-- 8 files changed, 148 insertions(+), 47 deletions(-) diff --git a/src/core/components/Table.tsx b/src/core/components/Table.tsx index 2fe2d10d..33ddfac4 100644 --- a/src/core/components/Table.tsx +++ b/src/core/components/Table.tsx @@ -1,5 +1,6 @@ import { ColumnDef, + ColumnFiltersState, FilterFn, flexRender, getCoreRowModel, @@ -139,6 +140,7 @@ type TableProps = { pageCount?: number pageSizeOptions?: number[] onGlobalFilterChange?: (filter: string) => void + onColumnFiltersChange?: (filters: ColumnFiltersState) => void classNames?: { table?: string thead?: string @@ -193,9 +195,11 @@ const Table = ({ pageCount: controlledPageCount, pageSizeOptions = [5, 10, 20, 30, 40, 50], onGlobalFilterChange, + onColumnFiltersChange, }: TableProps) => { const [sorting, setSorting] = React.useState([]) const [globalFilter, setGlobalFilter] = React.useState("") + const [columnFilters, setColumnFilters] = React.useState([]) const [internalPagination, setInternalPagination] = React.useState({ pageIndex: 0, pageSize: 5, @@ -224,10 +228,12 @@ const Table = ({ pageCount: manualPagination ? controlledPageCount : undefined, state: { sorting: sorting, + columnFilters: columnFilters, globalFilter: globalFilter, pagination: resolvedPaginationState, }, onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, onPaginationChange: handlePaginationChange, globalFilterFn: defaultGlobalFilterFn, @@ -262,6 +268,20 @@ const Table = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [globalFilter]) + const isFirstColumnFilterRender = React.useRef(true) + React.useEffect(() => { + if (isFirstColumnFilterRender.current) { + isFirstColumnFilterRender.current = false + return + } + if (!addPagination) return + if (manualPagination) { + onColumnFiltersChange?.(columnFilters) + } + table.setPageIndex(0) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnFilters]) + return ( <> {enableGlobalSearch && ( diff --git a/src/pages/notifications/index.tsx b/src/pages/notifications/index.tsx index 035ce713..e1e2b0cb 100644 --- a/src/pages/notifications/index.tsx +++ b/src/pages/notifications/index.tsx @@ -15,7 +15,7 @@ import { MultiReadToggleButton } from "src/notifications/components/MultiReadTog import { InformationCircleIcon } from "@heroicons/react/24/outline" import { Tooltip } from "react-tooltip" import Card from "src/core/components/Card" -import { PaginationState } from "@tanstack/react-table" +import { ColumnFiltersState, PaginationState } from "@tanstack/react-table" import { Prisma } from "db" const NotificationContent = () => { @@ -32,38 +32,46 @@ const NotificationContent = () => { pageSize: 10, }) const [search, setSearch] = useState("") + const [columnFilters, setColumnFilters] = useState([]) - const baseWhere = useMemo( + const coreWhere = useMemo( () => ({ - recipients: { - some: { - id: currentUser!.id, - }, - }, - // Only include notifications for projects where the contributor is not deleted + recipients: { some: { id: currentUser!.id } }, project: { projectMembers: { some: { - users: { - some: { id: currentUser!.id }, - }, - name: null, // Contributor (indicating it's not a team) - deleted: false, // Only include undeleted project members + users: { some: { id: currentUser!.id } }, + name: null, + deleted: false, }, }, }, - ...(search - ? { - OR: [ - { type: { contains: search, mode: "insensitive" } }, - { project: { name: { contains: search, mode: "insensitive" } } }, - ], - } - : {}), }), - [currentUser, search] + [currentUser] ) + const baseWhere = useMemo(() => { + const conditions: Prisma.NotificationWhereInput[] = [coreWhere] + if (search) { + conditions.push({ + OR: [ + { message: { contains: search, mode: "insensitive" } }, + { project: { name: { contains: search, mode: "insensitive" } } }, + ], + }) + } + for (const filter of columnFilters) { + const value = String(filter.value ?? "").trim() + if (!value) continue + if (filter.id === "readStatus") { + conditions.push({ read: value === "Read" }) + } else if (filter.id === "projectName") { + conditions.push({ project: { name: { contains: value, mode: "insensitive" } } }) + } + } + return conditions.length === 1 ? conditions[0]! : { AND: conditions } + }, [coreWhere, search, columnFilters]) + const paginationArgs = useMemo( () => ({ skip: pagination.pageIndex * pagination.pageSize, @@ -118,6 +126,12 @@ const NotificationContent = () => { disableGlobalSelection() } + const handleColumnFiltersChange = (filters: ColumnFiltersState) => { + setColumnFilters(filters) + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + disableGlobalSelection() + } + const handleActionCompleted = async () => { await refetch() resetSelection() @@ -185,6 +199,7 @@ const NotificationContent = () => { pageCount={pageCount} pageSizeOptions={[10, 25, 50, 100]} onGlobalFilterChange={handleGlobalFilterChange} + onColumnFiltersChange={handleColumnFiltersChange} />
diff --git a/src/pages/projects/[projectId]/notifications/index.tsx b/src/pages/projects/[projectId]/notifications/index.tsx index 117e6b3b..76c0c46f 100644 --- a/src/pages/projects/[projectId]/notifications/index.tsx +++ b/src/pages/projects/[projectId]/notifications/index.tsx @@ -14,7 +14,7 @@ import { MultiReadToggleButton } from "src/notifications/components/MultiReadTog import Card from "src/core/components/Card" import { InformationCircleIcon } from "@heroicons/react/24/outline" import { Tooltip } from "react-tooltip" -import { PaginationState } from "@tanstack/react-table" +import { ColumnFiltersState, PaginationState } from "@tanstack/react-table" import { Prisma } from "db" const NotificationContent = () => { @@ -32,27 +32,36 @@ const NotificationContent = () => { pageSize: 10, }) const [search, setSearch] = useState("") + const [columnFilters, setColumnFilters] = useState([]) - const baseWhere = useMemo( + const coreWhere = useMemo( () => ({ - recipients: { - some: { - id: currentUser!.id, - }, - }, + recipients: { some: { id: currentUser!.id } }, projectId: projectId ?? undefined, - ...(search - ? { - OR: [ - { type: { contains: search, mode: "insensitive" } }, - { project: { name: { contains: search, mode: "insensitive" } } }, - ], - } - : {}), }), - [currentUser, projectId, search] + [currentUser, projectId] ) + const baseWhere = useMemo(() => { + const conditions: Prisma.NotificationWhereInput[] = [coreWhere] + if (search) { + conditions.push({ + OR: [ + { message: { contains: search, mode: "insensitive" } }, + { project: { name: { contains: search, mode: "insensitive" } } }, + ], + }) + } + for (const filter of columnFilters) { + const value = String(filter.value ?? "").trim() + if (!value) continue + if (filter.id === "readStatus") { + conditions.push({ read: value === "Read" }) + } + } + return conditions.length === 1 ? conditions[0]! : { AND: conditions } + }, [coreWhere, search, columnFilters]) + const paginationArgs = useMemo( () => ({ skip: pagination.pageIndex * pagination.pageSize, @@ -108,6 +117,12 @@ const NotificationContent = () => { disableGlobalSelection() } + const handleColumnFiltersChange = (filters: ColumnFiltersState) => { + setColumnFilters(filters) + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + disableGlobalSelection() + } + const handleActionCompleted = async () => { await refetch() resetSelection() @@ -174,6 +189,7 @@ const NotificationContent = () => { pageCount={pageCount} pageSizeOptions={[10, 25, 50, 100]} onGlobalFilterChange={handleGlobalFilterChange} + onColumnFiltersChange={handleColumnFiltersChange} /> diff --git a/src/roles/components/ContributorsTab.tsx b/src/roles/components/ContributorsTab.tsx index d0966545..abec5c24 100644 --- a/src/roles/components/ContributorsTab.tsx +++ b/src/roles/components/ContributorsTab.tsx @@ -8,12 +8,17 @@ import { AddRoleModal } from "./AddRoleModal" import { ProjectMemberWithUsersAndRoles } from "src/core/types" import Link from "next/link" import { Tooltip } from "react-tooltip" -import { PaginationState } from "@tanstack/react-table" +import { ColumnFiltersState, PaginationState } from "@tanstack/react-table" const ContributorsTab = () => { const projectId = useParam("projectId", "number") const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }) const [search, setSearch] = useState("") + const [columnFilters, setColumnFilters] = useState([]) + + const roleNameFilter = columnFilters.find((f) => f.id === "roleNames")?.value as + | string + | undefined const [{ projectMembers: contributors, count }, { refetch }] = usePaginatedQuery( getProjectMembers, @@ -34,6 +39,9 @@ const ContributorsTab = () => { } : {}), }, + ...(roleNameFilter + ? { roles: { some: { name: { contains: roleNameFilter, mode: "insensitive" } } } } + : {}), deleted: undefined, name: { equals: null }, // Ensures ProjectMember is contributor and not team }, @@ -57,6 +65,11 @@ const ContributorsTab = () => { setPagination((prev) => ({ ...prev, pageIndex: 0 })) } + const handleColumnFiltersChange = (filters: ColumnFiltersState) => { + setColumnFilters(filters) + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + } + return (
@@ -70,6 +83,7 @@ const ContributorsTab = () => { pageCount={pageCount} pageSizeOptions={[10, 25, 50, 100]} onGlobalFilterChange={handleGlobalFilterChange} + onColumnFiltersChange={handleColumnFiltersChange} />
void + onColumnFiltersChange?: (filters: ColumnFiltersState) => void } export const RoleContributorTable = ({ @@ -20,6 +21,7 @@ export const RoleContributorTable = ({ pageCount, pageSizeOptions, onGlobalFilterChange, + onColumnFiltersChange, }: RoleContributorTableProps) => { const processedData = contributors.map((contributor) => ({ username: contributor.users[0].username, @@ -42,6 +44,7 @@ export const RoleContributorTable = ({ pageCount={pageCount} pageSizeOptions={pageSizeOptions} onGlobalFilterChange={onGlobalFilterChange} + onColumnFiltersChange={onColumnFiltersChange} /> ) } diff --git a/src/tasks/components/AllTaskList.tsx b/src/tasks/components/AllTaskList.tsx index 4f1d42c3..9503fd83 100644 --- a/src/tasks/components/AllTaskList.tsx +++ b/src/tasks/components/AllTaskList.tsx @@ -8,7 +8,7 @@ import { AllTasksColumns } from "../tables/columns/AllTasksColumns" import { TaskLogWithTaskProjectAndComments } from "src/core/types" import Card from "src/core/components/Card" import { useState } from "react" -import { PaginationState } from "@tanstack/react-table" +import { ColumnFiltersState, PaginationState } from "@tanstack/react-table" type TaskWithLogs = TaskLogWithTaskProjectAndComments["task"] & { taskLogs: TaskLogWithTaskProjectAndComments[] @@ -21,6 +21,12 @@ export const AllTasksList = () => { pageSize: 10, }) const [search, setSearch] = useState("") + const [columnFilters, setColumnFilters] = useState([]) + + const nameFilter = columnFilters.find((f) => f.id === "name")?.value as string | undefined + const projectNameFilter = columnFilters.find((f) => f.id === "projectName")?.value as + | string + | undefined const [{ tasks, count }] = usePaginatedQuery(getTasks, { where: { @@ -41,6 +47,8 @@ export const AllTasksList = () => { ], } : {}), + ...(nameFilter ? { name: { contains: nameFilter, mode: "insensitive" } } : {}), + ...(projectNameFilter ? { project: { name: projectNameFilter } } : {}), }, include: { project: true, @@ -107,6 +115,11 @@ export const AllTasksList = () => { setPagination((prev) => ({ ...prev, pageIndex: 0 })) } + const handleColumnFiltersChange = (filters: ColumnFiltersState) => { + setColumnFilters(filters) + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + } + return (
@@ -120,6 +133,7 @@ export const AllTasksList = () => { pageCount={pageCount} pageSizeOptions={[10, 25, 50, 100]} onGlobalFilterChange={handleGlobalFilterChange} + onColumnFiltersChange={handleColumnFiltersChange} /> Note: This list only shows comment notifications for tasks that are explicitly assigned to diff --git a/src/tasks/components/ProjectTasksList.tsx b/src/tasks/components/ProjectTasksList.tsx index f80d270b..b7859674 100644 --- a/src/tasks/components/ProjectTasksList.tsx +++ b/src/tasks/components/ProjectTasksList.tsx @@ -3,7 +3,7 @@ import { ProjectTasksColumns } from "src/tasks/tables/columns/ProjectTasksColumn import Table from "src/core/components/Table" import useProjecTasksListData from "../hooks/useProjectTasksListData" import { useState } from "react" -import { PaginationState } from "@tanstack/react-table" +import { ColumnFiltersState, PaginationState } from "@tanstack/react-table" export const ProjectTasksList = () => { const projectId = useParam("projectId", "number") @@ -12,8 +12,9 @@ export const ProjectTasksList = () => { pageSize: 10, }) const [search, setSearch] = useState("") + const [columnFilters, setColumnFilters] = useState([]) - const { tasks, count } = useProjecTasksListData(projectId, pagination, search) + const { tasks, count } = useProjecTasksListData(projectId, pagination, search, columnFilters) const pageCount = Math.max(1, Math.ceil((count ?? 0) / pagination.pageSize)) const handlePaginationChange = ( @@ -27,6 +28,11 @@ export const ProjectTasksList = () => { setPagination((prev) => ({ ...prev, pageIndex: 0 })) } + const handleColumnFiltersChange = (filters: ColumnFiltersState) => { + setColumnFilters(filters) + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + } + return (
@@ -40,6 +46,7 @@ export const ProjectTasksList = () => { pageCount={pageCount} pageSizeOptions={[10, 25, 50, 100]} onGlobalFilterChange={handleGlobalFilterChange} + onColumnFiltersChange={handleColumnFiltersChange} />
diff --git a/src/tasks/hooks/useProjectTasksListData.ts b/src/tasks/hooks/useProjectTasksListData.ts index 8d267e0e..72d61950 100644 --- a/src/tasks/hooks/useProjectTasksListData.ts +++ b/src/tasks/hooks/useProjectTasksListData.ts @@ -2,16 +2,17 @@ import { useEffect, useMemo, useState } from "react" import { usePaginatedQuery, useQuery } from "@blitzjs/rpc" import { useCurrentUser } from "src/users/hooks/useCurrentUser" import getTasks, { GetTasksInput } from "../queries/getTasks" -import { MemberPrivileges } from "@prisma/client" +import { MemberPrivileges, Status } from "@prisma/client" import { useMemberPrivileges } from "src/projectprivileges/components/MemberPrivilegesContext" import { processProjectTasks } from "../tables/processing/processProjectTasks" import getUserProjectMemberIds from "src/tasks/queries/getUserProjectMemberIds" -import { PaginationState } from "@tanstack/react-table" +import { ColumnFiltersState, PaginationState } from "@tanstack/react-table" export default function useProjectTasksListData( projectId: number | undefined, pagination: PaginationState, - search: string = "" + search: string = "", + columnFilters: ColumnFiltersState = [] ) { const currentUser = useCurrentUser() const { privilege } = useMemberPrivileges() @@ -24,10 +25,21 @@ export default function useProjectTasksListData( useEffect(() => { if (!privilege || !currentUser || !projectId) return + const nameFilter = columnFilters.find((f) => f.id === "name")?.value as string | undefined + const containerFilter = columnFilters.find((f) => f.id === "container")?.value as + | string + | undefined + const statusFilter = columnFilters.find((f) => f.id === "status")?.value as string | undefined + let baseParams: GetTasksInput = { where: { project: { id: projectId }, ...(search ? { name: { contains: search, mode: "insensitive" } } : {}), + ...(nameFilter ? { name: { contains: nameFilter, mode: "insensitive" } } : {}), + ...(containerFilter ? { container: { name: containerFilter } } : {}), + ...(statusFilter + ? { status: statusFilter === "Completed" ? Status.COMPLETED : Status.NOT_COMPLETED } + : {}), }, orderBy: [{ id: "asc" }], include: { @@ -108,7 +120,7 @@ export default function useProjectTasksListData( } setQueryParams(baseParams) - }, [privilege, currentUser, projectId, userMemberIds, search]) + }, [privilege, currentUser, projectId, userMemberIds, search, columnFilters]) const queryInput = useMemo(() => { const base = queryParams ?? { From 6a7c8c34f310d3a9769075de11a32a25d37da7de Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Tue, 26 May 2026 20:51:51 -0500 Subject: [PATCH 06/13] fix table bouncing reseting --- src/core/components/Filter.tsx | 6 ++++-- src/core/components/Table.tsx | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/core/components/Filter.tsx b/src/core/components/Filter.tsx index 8463e25c..9425fc7a 100644 --- a/src/core/components/Filter.tsx +++ b/src/core/components/Filter.tsx @@ -154,6 +154,8 @@ function DebouncedInput({ debounce?: number } & Omit, "onChange">) { const [value, setValue] = React.useState(initialValue) + const onChangeRef = React.useRef(onChange) + onChangeRef.current = onChange React.useEffect(() => { setValue(initialValue) @@ -161,11 +163,11 @@ function DebouncedInput({ React.useEffect(() => { const timeout = setTimeout(() => { - onChange(value) + onChangeRef.current(value) }, debounce) return () => clearTimeout(timeout) - }, [value, debounce, onChange]) + }, [value, debounce]) return setValue(e.target.value)} /> } diff --git a/src/core/components/Table.tsx b/src/core/components/Table.tsx index 33ddfac4..d5fa2be6 100644 --- a/src/core/components/Table.tsx +++ b/src/core/components/Table.tsx @@ -277,8 +277,9 @@ const Table = ({ if (!addPagination) return if (manualPagination) { onColumnFiltersChange?.(columnFilters) + } else { + table.setPageIndex(0) } - table.setPageIndex(0) // eslint-disable-next-line react-hooks/exhaustive-deps }, [columnFilters]) From 296ec8fb66c8756e7de058d11badf018bc312777 Mon Sep 17 00:00:00 2001 From: The Doom Lab Date: Tue, 26 May 2026 20:58:36 -0500 Subject: [PATCH 07/13] make description a markdown! --- src/formBuilder/FormBuilder.tsx | 61 +++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/src/formBuilder/FormBuilder.tsx b/src/formBuilder/FormBuilder.tsx index 76343a4b..2bb1c58c 100644 --- a/src/formBuilder/FormBuilder.tsx +++ b/src/formBuilder/FormBuilder.tsx @@ -1,6 +1,9 @@ import React, { ReactElement, useEffect, useState } from "react" import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd" import { Alert, Input } from "reactstrap" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import remarkBreaks from "remark-breaks" import Card from "./Card" import Section from "./Section" import Add from "./Add" @@ -20,6 +23,55 @@ import { import DEFAULT_FORM_INPUTS from "./defaults/defaultFormInputs" import type { Mods, InitParameters, AddFormObjectParametersType } from "./types" +function MarkdownDescriptionInput({ + value, + onChange, +}: { + value: string + onChange: (v: string) => void +}) { + const [mode, setMode] = useState<"edit" | "preview">("edit") + + return ( +
+
+
+ + +
+ Supports Markdown +
+ {mode === "edit" ? ( +