Skip to content

Commit

Permalink
adding table component to display users in admin dashboard
Browse files Browse the repository at this point in the history
  • Loading branch information
goodeats committed Nov 13, 2023
1 parent ddc90f4 commit f74d994
Show file tree
Hide file tree
Showing 7 changed files with 246 additions and 11 deletions.
1 change: 1 addition & 0 deletions app/components/ui/primitives/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export * from './checkbox.tsx'
export * from './dropdown-menu.tsx'
export * from './input.tsx'
export * from './label.tsx'
export * from './table.tsx'
export * from './textarea.tsx'
export * from './tooltip.tsx'
114 changes: 114 additions & 0 deletions app/components/ui/primitives/table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import * as React from "react"

import { cn } from "#app/utils/misc.tsx"

const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"

const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"

const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"

const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-primary font-medium text-primary-foreground", className)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"

const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"

const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"

const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"

const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"

export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
51 changes: 47 additions & 4 deletions app/routes/admin+/users.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { type DataFunctionArgs, json } from '@remix-run/node'
import { type MetaFunction } from '@remix-run/react'
import { PageContentIndex } from '#app/components/index.ts'
import { useLoaderData, type MetaFunction } from '@remix-run/react'
import {
Content,
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '#app/components/index.ts'
import { requireAdminUserId } from '#app/utils/auth.server.ts'
import { prisma } from '#app/utils/db.server.ts'
import { formatDate } from '#app/utils/misc.tsx'

export async function loader({ request }: DataFunctionArgs) {
await requireAdminUserId(request)
Expand All @@ -11,14 +21,47 @@ export async function loader({ request }: DataFunctionArgs) {
id: true,
name: true,
username: true,
roles: true,
roles: { select: { name: true } },
createdAt: true,
},
orderBy: {
createdAt: 'desc',
},
})
return json({ users })
}

export default function AdminUsersRoute() {
return <PageContentIndex message="Admin Users" />
const data = useLoaderData<typeof loader>()
const { users } = data
return (
<Content variant="index">
<p className="text-body-md">Admin Users</p>
<Table>
<TableCaption>All existing users</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead>Name</TableHead>
<TableHead>Joined</TableHead>
<TableHead className="text-right">Roles</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map(user => (
<TableRow key={user.id}>
<TableCell>{user.username}</TableCell>
<TableCell>{user.name}</TableCell>
<TableCell>{formatDate(new Date(user.createdAt))}</TableCell>
<TableCell className="text-right">
{user.roles.map(role => role.name).join(', ')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Content>
)
}

export const meta: MetaFunction = () => {
Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/admin/users/users-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { type Page } from '@playwright/test'
import { expect } from '#tests/playwright-utils.ts'
import { goTo } from '#tests/utils/page-utils.ts'
import { pageTableRow } from '#tests/utils/playwright-locator-utils.ts'
import { expectUrl } from '#tests/utils/url-utils.ts'

export async function goToAdminUsersPage(page: Page) {
Expand All @@ -9,3 +11,20 @@ export async function goToAdminUsersPage(page: Page) {
export async function expectAdminUsersPage(page: Page) {
await expectUrl({ page, url: '/admin/users' })
}

export async function expectAdminUsersContent(page: Page) {
await expect(page.getByRole('main').getByText('Admin Users')).toBeVisible()
await expect(page.getByText('All existing users')).toBeVisible()
}

export async function expectAdminUsersTableRowContent(
page: Page,
row: number,
rowContent: string[],
) {
const tableBodyRow = await pageTableRow(page, row)
for (let i = 0; i < rowContent.length; i++) {
const content = rowContent[i]
await expect(tableBodyRow.getByRole('cell').nth(i)).toHaveText(content)
}
}
26 changes: 20 additions & 6 deletions tests/e2e/admin/users/users.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { expect, test } from '#tests/playwright-utils.ts'
import { formatDate } from '#app/utils/misc.tsx'
import { test } from '#tests/playwright-utils.ts'
import { expectPageTableHeaders } from '#tests/utils/playwright-locator-utils.ts'
import { expectLoginUrl, expectUrl } from '#tests/utils/url-utils.ts'
import { expectAdminUsersPage, goToAdminUsersPage } from './users-utils.ts'
import {
expectAdminUsersContent,
expectAdminUsersPage,
expectAdminUsersTableRowContent,
goToAdminUsersPage,
} from './users-utils.ts'

test.describe('User cannot view Admin users page', () => {
test('when not logged in', async ({ page }) => {
Expand All @@ -18,12 +25,19 @@ test.describe('User cannot view Admin users page', () => {
test.describe('User can view Admin users', () => {
test('when logged in as admin', async ({ page, login }) => {
const user = await login({ roles: ['user', 'admin'] })

await goToAdminUsersPage(page)
await expectAdminUsersPage(page)

await expect(page.getByRole('main').getByText('Admin Users')).toBeVisible()
console.log(user)
// TODO: add user list
// await expect(page.getByRole('main').getByText(user.username)).toBeVisible()
await expectAdminUsersContent(page)

await expectPageTableHeaders(page, ['Username', 'Name', 'Joined', 'Roles'])

const name = user.name ?? '' // name is optional
const joinedDate = formatDate(new Date(user.createdAt))
const roles = user.roles.map(role => role.name).join(', ')
const rowContent = [user.username, name, joinedDate, roles]

await expectAdminUsersTableRowContent(page, 0, rowContent)
})
})
11 changes: 10 additions & 1 deletion tests/playwright-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ type User = {
email: string
username: string
name: string | null
createdAt: Date | string
roles: { name: string }[]
}

async function getOrInsertUser({
Expand All @@ -34,7 +36,14 @@ async function getOrInsertUser({
email,
roles = ['user'],
}: GetOrInsertUserOptions = {}): Promise<User> {
const select = { id: true, email: true, username: true, name: true }
const select = {
id: true,
email: true,
username: true,
name: true,
createdAt: true,
roles: { select: { name: true } },
}
if (id) {
return await prisma.user.findUniqueOrThrow({
select,
Expand Down
35 changes: 35 additions & 0 deletions tests/utils/playwright-locator-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// https://playwright.dev/docs/locators
// https://www.programsbuzz.com/article/playwright-select-first-or-last-element

import { type Page } from '@playwright/test'
import { expect } from '#tests/playwright-utils.ts'

export async function pageLocateTable(page: Page) {
return await page.getByRole('main').getByRole('table')
}

export async function pageLocateTableHeader(page: Page) {
const table = await pageLocateTable(page)
return await table.getByRole('rowgroup').first().getByRole('row')
}

export async function expectPageTableHeaders(
page: Page,
columnHeaders: string[],
) {
const tableHeader = await pageLocateTableHeader(page)
for (let i = 0; i < columnHeaders.length; i++) {
const columnHeader = columnHeaders[i]
await expect(tableHeader.getByRole('cell').nth(i)).toHaveText(columnHeader)
}
}

export async function pageLocateTableBody(page: Page) {
const table = await pageLocateTable(page)
return await table.getByRole('rowgroup').last()
}

export async function pageTableRow(page: Page, row: number = 0) {
const tableBody = await pageLocateTableBody(page)
return await tableBody.getByRole('row').nth(row)
}

0 comments on commit f74d994

Please sign in to comment.