Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dev #10

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open

Dev #10

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
4 changes: 2 additions & 2 deletions app/routes/users+/$username.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
getUserImgSrc,
invariantResponse,
} from '#app/utils/misc.tsx'
import { userHasRole } from '#app/utils/permissions.ts'
import { userIsAdmin } from '#app/utils/permissions.ts'
import { useOptionalUser } from '#app/utils/user.ts'

export async function loader({ request, params }: DataFunctionArgs) {
Expand All @@ -34,7 +34,7 @@ export async function loader({ request, params }: DataFunctionArgs) {

invariantResponse(user, 'User not found', { status: 404 })

const isAdmin = userHasRole(user, 'admin')
const isAdmin = userIsAdmin(user)

return json({
user,
Expand Down
4 changes: 4 additions & 0 deletions app/utils/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,7 @@ export function userHasRole(
if (!user) return false
return user.roles.some(r => r.name === role)
}

export function userIsAdmin(user: Pick<ReturnType<typeof useUser>, 'roles'>) {
return userHasRole(user, 'admin')
}
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)
}
Loading