feat(dashboard): Users domain (#6212)

This commit is contained in:
Kasper Fabricius Kristensen
2024-01-30 17:20:06 +01:00
committed by GitHub
parent 7d5a6f8b00
commit 1100c21c63
16 changed files with 871 additions and 125 deletions
@@ -6,6 +6,7 @@ import { Link } from "react-router-dom"
type Action = {
icon: ReactNode
label: string
disabled?: boolean
} & (
| {
to: string
@@ -47,12 +48,13 @@ export const ActionMenu = ({ groups }: ActionMenuProps) => {
if (action.onClick) {
return (
<DropdownMenu.Item
disabled={action.disabled}
key={index}
onClick={(e) => {
e.stopPropagation()
action.onClick()
}}
className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2"
className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{action.icon}
<span>{action.label}</span>
@@ -62,12 +64,16 @@ export const ActionMenu = ({ groups }: ActionMenuProps) => {
return (
<div key={index}>
<Link to={action.to} onClick={(e) => e.stopPropagation()}>
<DropdownMenu.Item className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2">
<DropdownMenu.Item
className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2 disabled:opacity-50 disabled:cursor-not-allowed"
asChild
disabled={action.disabled}
>
<Link to={action.to} onClick={(e) => e.stopPropagation()}>
{action.icon}
<span>{action.label}</span>
</DropdownMenu.Item>
</Link>
</Link>
</DropdownMenu.Item>
</div>
)
})}
@@ -1,18 +1,24 @@
import { ExclamationCircle, MagnifyingGlass } from "@medusajs/icons"
import { Button, Text } from "@medusajs/ui"
import { Button, Text, clx } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
type NoResultsProps = {
title?: string
message?: string
className?: string
}
export const NoResults = ({ title, message }: NoResultsProps) => {
export const NoResults = ({ title, message, className }: NoResultsProps) => {
const { t } = useTranslation()
return (
<div className="flex h-[400px] w-full items-center justify-center">
<div
className={clx(
"flex h-[400px] w-full items-center justify-center",
className
)}
>
<div className="flex flex-col items-center gap-y-2">
<MagnifyingGlass />
<Text size="small" leading="compact" weight="plus">
@@ -33,13 +39,24 @@ type NoRecordsProps = {
to: string
label: string
}
className?: string
}
export const NoRecords = ({ title, message, action }: NoRecordsProps) => {
export const NoRecords = ({
title,
message,
action,
className,
}: NoRecordsProps) => {
const { t } = useTranslation()
return (
<div className="flex h-[400px] w-full flex-col items-center justify-center gap-y-6">
<div
className={clx(
"flex h-[400px] w-full flex-col items-center justify-center gap-y-6",
className
)}
>
<div className="flex flex-col items-center gap-y-2">
<ExclamationCircle />
<Text size="small" leading="compact" weight="plus">
@@ -6,6 +6,7 @@ import type {
AdminPublishableApiKeysRes,
AdminRegionsRes,
AdminSalesChannelsRes,
AdminUserRes,
} from "@medusajs/medusa"
import {
Outlet,
@@ -439,6 +440,9 @@ const router = createBrowserRouter([
{
path: ":id",
lazy: () => import("../../routes/users/user-detail"),
handle: {
crumb: (data: AdminUserRes) => data.user.email,
},
children: [
{
path: "edit",
@@ -41,6 +41,14 @@ export const ProfileGeneralSection = ({ user }: ProfileGeneralSectionProps) => {
{user.email}
</Text>
</div>
<div className="grid grid-cols-2 px-6 py-4 items-center">
<Text size="small" leading="compact" weight="plus">
{t("fields.role")}
</Text>
<Text size="small" leading="compact">
{t(`users.roles.${user.role}`)}
</Text>
</div>
<div className="grid grid-cols-2 px-6 py-4 items-center">
<Text size="small" leading="compact" weight="plus">
{t("profile.language")}
@@ -1,5 +1,5 @@
import { User } from "@medusajs/medusa"
import { Button, Container, Heading, Text } from "@medusajs/ui"
import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
import { useTranslation } from "react-i18next"
import { Link } from "react-router-dom"
@@ -9,35 +9,39 @@ type UserGeneralSection = {
export const UserGeneralSection = ({ user }: UserGeneralSection) => {
const { t } = useTranslation()
const name = [user.first_name, user.last_name].filter(Boolean).join(" ")
return (
<Container className="divide-y p-0">
<Container className="p-0 divide-y">
<div className="flex items-center justify-between px-6 py-4">
<div>
<Heading>{t("profile.domain")}</Heading>
<Text className="text-ui-fg-subtle" size="small">
{t("profile.manageYourProfileDetails")}
</Text>
</div>
<Heading>{user.email}</Heading>
<Link to={`edit`}>
<Button size="small" variant="secondary">
{t("profile.editProfile")}
{t("general.edit")}
</Button>
</Link>
</div>
<div className="grid grid-cols-2 px-6 py-4">
<div className="grid grid-cols-2 px-6 py-4 items-center">
<Text size="small" leading="compact" weight="plus">
{t("fields.name")}
</Text>
<Text size="small" leading="compact">
{user.first_name} {user.last_name}
<Text
size="small"
leading="compact"
className={clx({
"text-ui-fg-subtle": !name,
})}
>
{name ?? "-"}
</Text>
</div>
<div className="grid grid-cols-2 px-6 py-4">
<div className="grid grid-cols-2 px-6 py-4 items-center">
<Text size="small" leading="compact" weight="plus">
{t("fields.email")}
{t("fields.role")}
</Text>
<Text size="small" leading="compact">
{user.email}
{t(`users.roles.${user.role}`)}
</Text>
</div>
</Container>
@@ -1 +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))
)
}
@@ -1,11 +1,16 @@
import { useAdminUser } from "medusa-react"
import { Outlet, json, useParams } from "react-router-dom"
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!)
const { user, isLoading, isError, error } = useAdminUser(id!, {
initialData,
})
if (isLoading) {
return <div>Loading...</div>
@@ -0,0 +1,108 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { User } from "@medusajs/medusa"
import { Button, Drawer, Input } from "@medusajs/ui"
import { useAdminUpdateUser } from "medusa-react"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import * as zod from "zod"
import { Form } from "../../../../../components/common/form"
type EditUserFormProps = {
user: Omit<User, "password_hash">
subscribe: (state: boolean) => void
onSuccessfulSubmit: () => void
}
const EditUserFormSchema = zod.object({
first_name: zod.string().optional(),
last_name: zod.string().optional(),
})
export const EditUserForm = ({
user,
subscribe,
onSuccessfulSubmit,
}: EditUserFormProps) => {
const form = useForm<zod.infer<typeof EditUserFormSchema>>({
defaultValues: {
first_name: user.first_name || "",
last_name: user.last_name || "",
},
resolver: zodResolver(EditUserFormSchema),
})
const {
formState: { isDirty },
} = form
useEffect(() => {
subscribe(isDirty)
}, [isDirty])
const { t } = useTranslation()
const { mutateAsync, isLoading } = useAdminUpdateUser(user.id)
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(values, {
onSuccess: () => {
onSuccessfulSubmit()
},
})
})
return (
<Form {...form}>
<form
onSubmit={handleSubmit}
className="flex flex-col overflow-hidden flex-1"
>
<Drawer.Body className="flex flex-col gap-y-8 overflow-y-auto flex-1 max-w-full">
<Form.Field
control={form.control}
name="first_name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.firstName")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="last_name"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.lastName")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</Drawer.Body>
<Drawer.Footer>
<div className="flex items-center justify-end gap-x-2">
<Drawer.Close asChild>
<Button size="small" variant="secondary">
{t("general.cancel")}
</Button>
</Drawer.Close>
<Button size="small" type="submit" isLoading={isLoading}>
{t("general.save")}
</Button>
</div>
</Drawer.Footer>
</form>
</Form>
)
}
@@ -0,0 +1 @@
export * from "./edit-user-form"
@@ -1,12 +1,39 @@
import { Drawer } from "@medusajs/ui"
import { Drawer, Heading } from "@medusajs/ui"
import { useAdminUser } from "medusa-react"
import { useTranslation } from "react-i18next"
import { useParams } from "react-router-dom"
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
import { EditUserForm } from "./components/edit-user-form"
export const UserEdit = () => {
const [open, onOpenChange] = useRouteModalState()
const [open, onOpenChange, subscribe] = useRouteModalState()
const { t } = useTranslation()
const { id } = useParams()
const { user, isLoading, isError, error } = useAdminUser(id!)
const handleSuccessfulSubmit = () => {
onOpenChange(false, true)
}
if (isError) {
throw error
}
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<Drawer.Content></Drawer.Content>
<Drawer.Content>
<Drawer.Header>
<Heading>{t("users.editUser")}</Heading>
</Drawer.Header>
{!isLoading && user && (
<EditUserForm
user={user}
subscribe={subscribe}
onSuccessfulSubmit={handleSuccessfulSubmit}
/>
)}
</Drawer.Content>
</Drawer>
)
}
@@ -0,0 +1,436 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { ArrowPath, Link, XCircle } from "@medusajs/icons"
import { Invite } from "@medusajs/medusa"
import {
Button,
Container,
FocusModal,
Heading,
Input,
Select,
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 {
useAdminCreateInvite,
useAdminDeleteInvite,
useAdminInvites,
useAdminResendInvite,
useAdminStore,
} from "medusa-react"
import { useEffect, 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"
type InviteUserFormProps = {
subscribe: (state: boolean) => void
}
enum UserRole {
MEMBER = "member",
DEVELOPER = "developer",
ADMIN = "admin",
}
const InviteUserSchema = zod.object({
user: zod.string().email(),
role: zod.nativeEnum(UserRole),
})
const PAGE_SIZE = 10
export const InviteUserForm = ({ subscribe }: InviteUserFormProps) => {
const form = useForm<zod.infer<typeof InviteUserSchema>>({
defaultValues: {
user: "",
role: UserRole.MEMBER,
},
resolver: zodResolver(InviteUserSchema),
})
const { mutateAsync, isLoading: isMutating } = useAdminCreateInvite()
const {
formState: { isDirty },
} = form
useEffect(() => {
subscribe(isDirty)
}, [isDirty])
const { invites, isLoading, isError, error } = useAdminInvites()
const count = invites?.length ?? 0
const noRecords = !isLoading && count === 0
const columns = useColumns()
const table = useReactTable({
data: invites ?? [],
columns,
pageCount: Math.ceil(count / PAGE_SIZE),
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const { t } = useTranslation()
const handleSubmit = form.handleSubmit(async (values) => {
await mutateAsync(
{
role: values.role,
user: values.user,
},
{
onSuccess: () => {
form.reset()
},
}
)
})
if (isError) {
throw error
}
return (
<Form {...form}>
<form
onSubmit={handleSubmit}
className="flex h-full flex-col overflow-hidden"
>
<FocusModal.Header>
<div className="flex items-center justify-end gap-x-2">
<FocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("general.close")}
</Button>
</FocusModal.Close>
</div>
</FocusModal.Header>
<FocusModal.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="user"
render={({ field }) => {
return (
<Form.Item>
<Form.Label>{t("fields.email")}</Form.Label>
<Form.Control>
<Input {...field} />
</Form.Control>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
<Form.Field
control={form.control}
name="role"
render={({ field: { ref, onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("fields.role")}</Form.Label>
<Form.Control>
<Select {...field} onValueChange={onChange}>
<Select.Trigger ref={ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{Object.values(UserRole).map((role) => (
<Select.Item key={role} value={role}>
{t(`users.roles.${role}`)}
</Select.Item>
))}
</Select.Content>
</Select>
</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="p-0 overflow-hidden">
{!noRecords ? (
<div>
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th]:w-1/3 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap"
>
{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>
</FocusModal.Body>
</form>
</Form>
)
}
const InviteActions = ({ invite }: { invite: Invite }) => {
const { mutateAsync: revokeAsync } = useAdminDeleteInvite(invite.id)
const { mutateAsync: resendAsync } = useAdminResendInvite(invite.id)
const { store, isLoading, isError, error } = useAdminStore()
const prompt = usePrompt()
const { t } = useTranslation()
const handleRevoke = async () => {
const res = await prompt({
title: t("general.areYouSure"),
description: t("users.revokeInviteWarning", {
email: invite.user_email,
}),
cancelText: t("general.cancel"),
confirmText: t("general.confirm"),
})
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("general.revoke"),
onClick: handleRevoke,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<Invite>()
const useColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("user_email", {
header: t("fields.email"),
cell: ({ getValue }) => {
return getValue()
},
}),
columnHelper.accessor("role", {
header: t("fields.role"),
cell: ({ getValue }) => {
return t(`users.roles.${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 className="font-medium" />,
<span 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]
)
}
@@ -1,12 +1,15 @@
import { FocusModal } from "@medusajs/ui"
import { useRouteModalState } from "../../../hooks/use-route-modal-state"
import { InviteUserForm } from "./components/invite-user-form/invite-user-form"
export const UserInvite = () => {
const [open, onOpenChange] = useRouteModalState()
const [open, onOpenChange, subscribe] = useRouteModalState()
return (
<FocusModal open={open} onOpenChange={onOpenChange}>
<FocusModal.Content></FocusModal.Content>
<FocusModal.Content>
<InviteUserForm subscribe={subscribe} />
</FocusModal.Content>
</FocusModal>
)
}
@@ -1,7 +1,8 @@
import { PencilSquare } from "@medusajs/icons"
import { User } from "@medusajs/medusa"
import { Checkbox, Container, Heading, Table, clx } from "@medusajs/ui"
import { Button, Container, Heading, Table, clx } from "@medusajs/ui"
import {
RowSelectionState,
PaginationState,
createColumnHelper,
flexRender,
getCoreRowModel,
@@ -10,28 +11,67 @@ import {
import { useAdminUsers } from "medusa-react"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router-dom"
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 [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: PAGE_SIZE,
})
const { users, isLoading, isError, error } = useAdminUsers()
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: {
rowSelection,
pagination,
},
onRowSelectionChange: setRowSelection,
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
}
@@ -40,56 +80,121 @@ export const UserListTable = () => {
<Container className="p-0 divide-y">
<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("general.invite")}</Link>
</Button>
</div>
<Table>
<Table.Header className="border-t-0">
{table.getHeaderGroups().map((headerGroup) => {
return (
<Table.Row
key={headerGroup.id}
className="[&_th]:w-1/3 [&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap"
>
{headerGroup.headers.map((header) => {
{!noRecords && (
<div className="px-6 py-4 flex items-center justify-between">
<div></div>
<div className="flex items-center gap-x-2">
<Query />
<OrderBy
keys={[
"email",
"first_name",
"last_name",
"created_at",
"updated_at",
"role",
]}
/>
</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.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
<Table.Row
key={headerGroup.id}
className="[&_th]:w-1/3 [&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap"
>
{headerGroup.headers.map((header) => {
return (
<Table.HeaderCell key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.HeaderCell>
)
})}
</Table.Row>
)
})}
</Table.Row>
)
})}
</Table.Header>
<Table.Body className="border-b-0">
{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>
<div className="px-6 py-4"></div>
</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("general.edit"),
to: `${user.id}/edit`,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<Omit<User, "password_hash">>()
const useColumns = () => {
@@ -97,59 +202,37 @@ const useColumns = () => {
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
columnHelper.display({
id: "name",
header: t("fields.name"),
cell: ({ row }) => {
const { first_name, last_name } = row.original
if (!first_name && !last_name) {
return <span className="text-ui-fg-muted">-</span>
}
return `${first_name || ""} ${last_name || ""}`.trim()
},
}),
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.accessor("role", {
header: t("users.role"),
header: t("fields.role"),
cell: ({ row }) => {
return t(`users.roles.${row.original.role}`)
},
}),
columnHelper.display({
id: "actions",
cell: ({ row }) => <UserActions user={row.original} />,
}),
],
[t]
)
@@ -1,9 +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>
)
}