feat: Admin V2 users domain (#6844)
This commit is contained in:
@@ -7,6 +7,7 @@ import { Outlet } from "react-router-dom"
|
||||
import { Spinner } from "@medusajs/icons"
|
||||
import { ErrorBoundary } from "../../components/error/error-boundary"
|
||||
import { useV2Session } from "../../lib/api-v2"
|
||||
import { UserDTO } from "@medusajs/types"
|
||||
import { SearchProvider } from "../search-provider"
|
||||
import { SidebarProvider } from "../sidebar-provider"
|
||||
|
||||
@@ -139,6 +140,38 @@ export const v2Routes: RouteObject[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
element: <Outlet />,
|
||||
handle: {
|
||||
crumb: () => "Users",
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
lazy: () => import("../../v2-routes/users/user-list"),
|
||||
children: [
|
||||
{
|
||||
path: "invite",
|
||||
lazy: () => import("../../v2-routes/users/user-invite"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
lazy: () => import("../../v2-routes/users/user-detail"),
|
||||
handle: {
|
||||
crumb: (data: { user: UserDTO }) => data.user.email,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: "edit",
|
||||
lazy: () => import("../../v2-routes/users/user-edit"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { UserEdit as Component } from "./user-edit"
|
||||
export { UserEdit as Component } from "../../../modules/user/user-edit"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./user-general-section"
|
||||
@@ -0,0 +1,88 @@
|
||||
import { PencilSquare, Trash } from "@medusajs/icons"
|
||||
import { User } from "@medusajs/medusa"
|
||||
import { Container, Heading, Text, clx, usePrompt } from "@medusajs/ui"
|
||||
import { useAdminDeleteUser } from "medusa-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
|
||||
type UserGeneralSectionProps = {
|
||||
user: Omit<User, "password_hash">
|
||||
}
|
||||
|
||||
export const UserGeneralSection = ({ user }: UserGeneralSectionProps) => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const prompt = usePrompt()
|
||||
|
||||
const { mutateAsync } = useAdminDeleteUser(user.id)
|
||||
|
||||
const name = [user.first_name, user.last_name].filter(Boolean).join(" ")
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("users.deleteUserWarning", {
|
||||
name: name ?? user.email,
|
||||
}),
|
||||
verificationText: name ?? user.email,
|
||||
verificationInstruction: t("general.typeToConfirm"),
|
||||
confirmText: t("actions.delete"),
|
||||
cancelText: t("actions.cancel"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await mutateAsync(undefined, {
|
||||
onSuccess: () => {
|
||||
navigate("..")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading>{user.email}</Heading>
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.edit"),
|
||||
to: "edit",
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
label: t("actions.delete"),
|
||||
onClick: handleDeleteUser,
|
||||
icon: <Trash />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 items-center px-6 py-4">
|
||||
<Text size="small" leading="compact" weight="plus">
|
||||
{t("fields.name")}
|
||||
</Text>
|
||||
<Text
|
||||
size="small"
|
||||
leading="compact"
|
||||
className={clx({
|
||||
"text-ui-fg-subtle": !name,
|
||||
})}
|
||||
>
|
||||
{name ?? "-"}
|
||||
</Text>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { userLoader as loader } from "./loader"
|
||||
export { UserDetail as Component } from "./user-detail"
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AdminUserRes } from "@medusajs/medusa"
|
||||
import { Response } from "@medusajs/medusa-js"
|
||||
import { adminProductKeys } from "medusa-react"
|
||||
import { LoaderFunctionArgs } from "react-router-dom"
|
||||
|
||||
import { medusa, queryClient } from "../../../lib/medusa"
|
||||
|
||||
const userDetailQuery = (id: string) => ({
|
||||
queryKey: adminProductKeys.detail(id),
|
||||
queryFn: async () => medusa.admin.users.retrieve(id),
|
||||
})
|
||||
|
||||
export const userLoader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const id = params.id
|
||||
const query = userDetailQuery(id!)
|
||||
|
||||
return (
|
||||
queryClient.getQueryData<Response<AdminUserRes>>(query.queryKey) ??
|
||||
(await queryClient.fetchQuery(query))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useAdminUser } from "medusa-react"
|
||||
import { Outlet, json, useLoaderData, useParams } from "react-router-dom"
|
||||
import { JsonViewSection } from "../../../components/common/json-view-section"
|
||||
import { UserGeneralSection } from "./components/user-general-section"
|
||||
import { userLoader } from "./loader"
|
||||
|
||||
export const UserDetail = () => {
|
||||
const initialData = useLoaderData() as Awaited<ReturnType<typeof userLoader>>
|
||||
|
||||
const { id } = useParams()
|
||||
const { user, isLoading, isError, error } = useAdminUser(id!, {
|
||||
initialData,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (isError || !user) {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw json("An unknown error has occured", 500)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<UserGeneralSection user={user} />
|
||||
<JsonViewSection data={user} />
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { UserEdit as Component } from "../../../modules/user/user-edit"
|
||||
@@ -0,0 +1,381 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { ArrowPath, Link, XCircle } from "@medusajs/icons"
|
||||
import { Invite } from "@medusajs/medusa"
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Heading,
|
||||
Input,
|
||||
StatusBadge,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
clx,
|
||||
usePrompt,
|
||||
} from "@medusajs/ui"
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { format } from "date-fns"
|
||||
import {
|
||||
adminInviteKeys,
|
||||
useAdminCustomPost,
|
||||
useAdminDeleteInvite,
|
||||
useAdminInvites,
|
||||
useAdminResendInvite,
|
||||
} from "medusa-react"
|
||||
import { useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { Trans, useTranslation } from "react-i18next"
|
||||
import * as zod from "zod"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import { NoRecords } from "../../../../../components/common/empty-table-content"
|
||||
import { Form } from "../../../../../components/common/form"
|
||||
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
|
||||
import { RouteFocusModal } from "../../../../../components/route-modal"
|
||||
import { useV2Store } from "../../../../../lib/api-v2"
|
||||
import { InviteDTO } from "@medusajs/types"
|
||||
|
||||
const InviteUserSchema = zod.object({
|
||||
email: zod.string().email(),
|
||||
})
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
export const InviteUserForm = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const form = useForm<zod.infer<typeof InviteUserSchema>>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
resolver: zodResolver(InviteUserSchema),
|
||||
})
|
||||
|
||||
const { invites, isLoading, isError, error } = useAdminInvites()
|
||||
const count = invites?.length ?? 0
|
||||
|
||||
const noRecords = !isLoading && count === 0
|
||||
|
||||
const columns = useColumns()
|
||||
|
||||
const table = useReactTable({
|
||||
// TODO: Update type when medusa-react is 2.0 compatible
|
||||
data: (invites ?? []) as unknown as InviteDTO[],
|
||||
columns,
|
||||
pageCount: Math.ceil(count / PAGE_SIZE),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
})
|
||||
|
||||
const { mutateAsync, isLoading: isMutating } = useAdminCustomPost(
|
||||
"/admin/invites",
|
||||
adminInviteKeys.lists()
|
||||
)
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
await mutateAsync(
|
||||
{
|
||||
email: values.email,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
form.reset()
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<RouteFocusModal.Form form={form}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
>
|
||||
<RouteFocusModal.Header />
|
||||
<RouteFocusModal.Body className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col items-center overflow-y-auto">
|
||||
<div className="flex w-full max-w-[720px] flex-col gap-y-8 px-2 py-16">
|
||||
<div>
|
||||
<Heading>{t("users.inviteUser")}</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{t("users.inviteUserHint")}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Form.Field
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<Form.Item>
|
||||
<Form.Label>{t("fields.email")}</Form.Label>
|
||||
<Form.Control>
|
||||
<Input {...field} />
|
||||
</Form.Control>
|
||||
<Form.ErrorMessage />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
isLoading={isMutating}
|
||||
>
|
||||
{t("users.sendInvite")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<Heading level="h2">{t("users.pendingInvites")}</Heading>
|
||||
<Container className="overflow-hidden p-0">
|
||||
{!noRecords ? (
|
||||
<div>
|
||||
<Table>
|
||||
<Table.Header className="border-t-0">
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<Table.Row
|
||||
key={headerGroup.id}
|
||||
className="[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th]:w-1/3"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<Table.HeaderCell key={header.id}>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</Table.HeaderCell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
className={clx(
|
||||
"transition-fg[&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
||||
{
|
||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||
row.getIsSelected(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
<LocalizedTablePagination
|
||||
canNextPage={table.getCanNextPage()}
|
||||
canPreviousPage={table.getCanPreviousPage()}
|
||||
nextPage={table.nextPage}
|
||||
previousPage={table.previousPage}
|
||||
count={count}
|
||||
pageIndex={table.getState().pagination.pageIndex}
|
||||
pageCount={table.getPageCount()}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<NoRecords className="h-[200px]" />
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RouteFocusModal.Body>
|
||||
</form>
|
||||
</RouteFocusModal.Form>
|
||||
)
|
||||
}
|
||||
|
||||
const InviteActions = ({ invite }: { invite: InviteDTO }) => {
|
||||
const { mutateAsync: revokeAsync } = useAdminDeleteInvite(invite.id)
|
||||
const { mutateAsync: resendAsync } = useAdminResendInvite(invite.id)
|
||||
const { store, isLoading, isError, error } = useV2Store({})
|
||||
const prompt = usePrompt()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleRevoke = async () => {
|
||||
const res = await prompt({
|
||||
title: t("general.areYouSure"),
|
||||
description: t("users.revokeInviteWarning", {
|
||||
email: invite.email,
|
||||
}),
|
||||
cancelText: t("actions.cancel"),
|
||||
confirmText: t("actions.revoke"),
|
||||
})
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
await revokeAsync()
|
||||
}
|
||||
|
||||
const handleResend = async () => {
|
||||
await resendAsync()
|
||||
}
|
||||
|
||||
const handleCopyInviteLink = () => {
|
||||
const template = store?.invite_link_template
|
||||
|
||||
if (!template) {
|
||||
return
|
||||
}
|
||||
|
||||
const link = template.replace("{invite_token}", invite.token)
|
||||
navigator.clipboard.writeText(link)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <Link />,
|
||||
label: t("users.copyInviteLink"),
|
||||
disabled: isLoading || !store?.invite_link_template,
|
||||
onClick: handleCopyInviteLink,
|
||||
},
|
||||
{
|
||||
icon: <ArrowPath />,
|
||||
label: t("users.resendInvite"),
|
||||
onClick: handleResend,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <XCircle />,
|
||||
label: t("actions.revoke"),
|
||||
onClick: handleRevoke,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<InviteDTO>()
|
||||
|
||||
const useColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("email", {
|
||||
header: t("fields.email"),
|
||||
cell: ({ getValue }) => {
|
||||
return getValue()
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("accepted", {
|
||||
header: t("fields.status"),
|
||||
cell: ({ getValue, row }) => {
|
||||
const accepted = getValue()
|
||||
const expired = new Date(row.original.expires_at) < new Date()
|
||||
|
||||
if (accepted) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("users.acceptedOnDate", {
|
||||
date: format(
|
||||
new Date(row.original.updated_at),
|
||||
"dd MMM, yyyy"
|
||||
),
|
||||
})}
|
||||
>
|
||||
<StatusBadge color="green">
|
||||
{t("users.inviteStatus.accepted")}
|
||||
</StatusBadge>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
if (expired) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("users.expiredOnDate", {
|
||||
date: format(
|
||||
new Date(row.original.expires_at),
|
||||
"dd MMM, yyyy"
|
||||
),
|
||||
})}
|
||||
>
|
||||
<StatusBadge color="red">
|
||||
{t("users.inviteStatus.expired")}
|
||||
</StatusBadge>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
<Trans
|
||||
i18nKey={"users.validFromUntil"}
|
||||
components={[
|
||||
<span key="from" className="font-medium" />,
|
||||
<span key="untill" className="font-medium" />,
|
||||
]}
|
||||
values={{
|
||||
from: format(
|
||||
new Date(row.original.created_at),
|
||||
"dd MMM, yyyy"
|
||||
),
|
||||
until: format(
|
||||
new Date(row.original.expires_at),
|
||||
"dd MMM, yyyy"
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<StatusBadge color="orange">
|
||||
{t("users.inviteStatus.pending")}
|
||||
</StatusBadge>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row }) => <InviteActions invite={row.original} />,
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { UserInvite as Component } from "./user-invite"
|
||||
@@ -0,0 +1,10 @@
|
||||
import { RouteFocusModal } from "../../../components/route-modal"
|
||||
import { InviteUserForm } from "./components/invite-user-form/invite-user-form"
|
||||
|
||||
export const UserInvite = () => {
|
||||
return (
|
||||
<RouteFocusModal>
|
||||
<InviteUserForm />
|
||||
</RouteFocusModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./user-list-table"
|
||||
@@ -0,0 +1,232 @@
|
||||
import { PencilSquare } from "@medusajs/icons"
|
||||
import { User } from "@medusajs/medusa"
|
||||
import { Button, Container, Heading, Table, clx } from "@medusajs/ui"
|
||||
import {
|
||||
PaginationState,
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { useAdminUsers } from "medusa-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import { ActionMenu } from "../../../../../components/common/action-menu"
|
||||
import {
|
||||
NoRecords,
|
||||
NoResults,
|
||||
} from "../../../../../components/common/empty-table-content"
|
||||
import { OrderBy } from "../../../../../components/filtering/order-by"
|
||||
import { Query } from "../../../../../components/filtering/query"
|
||||
import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination"
|
||||
import { useQueryParams } from "../../../../../hooks/use-query-params"
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export const UserListTable = () => {
|
||||
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: PAGE_SIZE,
|
||||
})
|
||||
|
||||
const pagination = useMemo(
|
||||
() => ({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}),
|
||||
[pageIndex, pageSize]
|
||||
)
|
||||
|
||||
const params = useQueryParams(["q", "order"])
|
||||
const { users, count, isLoading, isError, error } = useAdminUsers(
|
||||
{
|
||||
limit: PAGE_SIZE,
|
||||
offset: pageIndex * PAGE_SIZE,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
|
||||
const columns = useColumns()
|
||||
|
||||
const table = useReactTable({
|
||||
data: users ?? [],
|
||||
columns,
|
||||
pageCount: Math.ceil((count ?? 0) / PAGE_SIZE),
|
||||
state: {
|
||||
pagination,
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
})
|
||||
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const noRecords =
|
||||
!isLoading &&
|
||||
!users?.length &&
|
||||
!Object.values(params).filter(Boolean).length
|
||||
|
||||
if (isError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("users.domain")}</Heading>
|
||||
<Button size="small" variant="secondary" asChild>
|
||||
<Link to="invite">{t("users.invite")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{!noRecords && (
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div></div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Query />
|
||||
<OrderBy
|
||||
keys={[
|
||||
"email",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{noRecords ? (
|
||||
<NoRecords />
|
||||
) : (
|
||||
<div>
|
||||
{!isLoading && !users?.length ? (
|
||||
<div className="border-b">
|
||||
<NoResults />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Table.Header className="border-t-0">
|
||||
{table.getHeaderGroups().map((headerGroup) => {
|
||||
return (
|
||||
<Table.Row
|
||||
key={headerGroup.id}
|
||||
className="[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap [&_th]:w-1/3"
|
||||
>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<Table.HeaderCell key={header.id}>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</Table.HeaderCell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
className={clx(
|
||||
"transition-fg cursor-pointer [&_td:last-of-type]:w-[1%] [&_td:last-of-type]:whitespace-nowrap",
|
||||
{
|
||||
"bg-ui-bg-highlight hover:bg-ui-bg-highlight-hover":
|
||||
row.getIsSelected(),
|
||||
}
|
||||
)}
|
||||
onClick={() => navigate(row.original.id)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
<LocalizedTablePagination
|
||||
canNextPage={table.getCanNextPage()}
|
||||
canPreviousPage={table.getCanPreviousPage()}
|
||||
nextPage={table.nextPage}
|
||||
previousPage={table.previousPage}
|
||||
count={count ?? 0}
|
||||
pageIndex={pageIndex}
|
||||
pageCount={table.getPageCount()}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const UserActions = ({ user }: { user: Omit<User, "password_hash"> }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<ActionMenu
|
||||
groups={[
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
icon: <PencilSquare />,
|
||||
label: t("actions.edit"),
|
||||
to: `${user.id}/edit`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<Omit<User, "password_hash">>()
|
||||
|
||||
const useColumns = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
columnHelper.accessor("email", {
|
||||
header: t("fields.email"),
|
||||
cell: ({ row }) => {
|
||||
return row.original.email
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "name",
|
||||
header: t("fields.name"),
|
||||
cell: ({ row }) => {
|
||||
const name = [row.original.first_name, row.original.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
if (!name) {
|
||||
return <span className="text-ui-fg-muted">-</span>
|
||||
}
|
||||
|
||||
return name
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "actions",
|
||||
cell: ({ row }) => <UserActions user={row.original} />,
|
||||
}),
|
||||
],
|
||||
[t]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { UserList as Component } from "./user-list"
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Outlet } from "react-router-dom"
|
||||
import { UserListTable } from "./components/user-list-table"
|
||||
|
||||
export const UserList = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<UserListTable />
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user