From ddc90f424a832e6901deb4ccc5ea44891c68fe16 Mon Sep 17 00:00:00 2001 From: Pat Needham Date: Thu, 9 Nov 2023 12:57:54 -0500 Subject: [PATCH 1/2] userIsAdmin function --- app/routes/users+/$username.tsx | 4 ++-- app/utils/permissions.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/routes/users+/$username.tsx b/app/routes/users+/$username.tsx index f1286962..e45946bb 100644 --- a/app/routes/users+/$username.tsx +++ b/app/routes/users+/$username.tsx @@ -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) { @@ -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, diff --git a/app/utils/permissions.ts b/app/utils/permissions.ts index 9f1be9b8..4a007929 100644 --- a/app/utils/permissions.ts +++ b/app/utils/permissions.ts @@ -99,3 +99,7 @@ export function userHasRole( if (!user) return false return user.roles.some(r => r.name === role) } + +export function userIsAdmin(user: Pick, 'roles'>) { + return userHasRole(user, 'admin') +} From f74d994e299eb1f85057caf87e014055b48bd1cb Mon Sep 17 00:00:00 2001 From: Pat Needham Date: Mon, 13 Nov 2023 12:08:02 -0500 Subject: [PATCH 2/2] adding table component to display users in admin dashboard --- app/components/ui/primitives/index.ts | 1 + app/components/ui/primitives/table.tsx | 114 ++++++++++++++++++++++++ app/routes/admin+/users.tsx | 51 ++++++++++- tests/e2e/admin/users/users-utils.ts | 19 ++++ tests/e2e/admin/users/users.test.ts | 26 ++++-- tests/playwright-utils.ts | 11 ++- tests/utils/playwright-locator-utils.ts | 35 ++++++++ 7 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 app/components/ui/primitives/table.tsx create mode 100644 tests/utils/playwright-locator-utils.ts diff --git a/app/components/ui/primitives/index.ts b/app/components/ui/primitives/index.ts index b6cda67b..49d06348 100644 --- a/app/components/ui/primitives/index.ts +++ b/app/components/ui/primitives/index.ts @@ -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' diff --git a/app/components/ui/primitives/table.tsx b/app/components/ui/primitives/table.tsx new file mode 100644 index 00000000..75dcf505 --- /dev/null +++ b/app/components/ui/primitives/table.tsx @@ -0,0 +1,114 @@ +import * as React from "react" + +import { cn } from "#app/utils/misc.tsx" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/app/routes/admin+/users.tsx b/app/routes/admin+/users.tsx index aae382ac..8b68558d 100644 --- a/app/routes/admin+/users.tsx +++ b/app/routes/admin+/users.tsx @@ -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) @@ -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 + const data = useLoaderData() + const { users } = data + return ( + +

Admin Users

+ + All existing users + + + Username + Name + Joined + Roles + + + + {users.map(user => ( + + {user.username} + {user.name} + {formatDate(new Date(user.createdAt))} + + {user.roles.map(role => role.name).join(', ')} + + + ))} + +
+
+ ) } export const meta: MetaFunction = () => { diff --git a/tests/e2e/admin/users/users-utils.ts b/tests/e2e/admin/users/users-utils.ts index 0552d259..dd38c52c 100644 --- a/tests/e2e/admin/users/users-utils.ts +++ b/tests/e2e/admin/users/users-utils.ts @@ -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) { @@ -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) + } +} diff --git a/tests/e2e/admin/users/users.test.ts b/tests/e2e/admin/users/users.test.ts index abcd1ad5..67eb9bbe 100644 --- a/tests/e2e/admin/users/users.test.ts +++ b/tests/e2e/admin/users/users.test.ts @@ -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 }) => { @@ -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) }) }) diff --git a/tests/playwright-utils.ts b/tests/playwright-utils.ts index f392ca83..927f098d 100644 --- a/tests/playwright-utils.ts +++ b/tests/playwright-utils.ts @@ -25,6 +25,8 @@ type User = { email: string username: string name: string | null + createdAt: Date | string + roles: { name: string }[] } async function getOrInsertUser({ @@ -34,7 +36,14 @@ async function getOrInsertUser({ email, roles = ['user'], }: GetOrInsertUserOptions = {}): Promise { - 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, diff --git a/tests/utils/playwright-locator-utils.ts b/tests/utils/playwright-locator-utils.ts new file mode 100644 index 00000000..5b67139d --- /dev/null +++ b/tests/utils/playwright-locator-utils.ts @@ -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) +}