Repository: UoaWDCC/aucc
Pull request #56: Create execs page
Author: login: f13322
Created: 2025-04-27T07:08:25Z | Merged: 2025-04-30T04:58:48Z

closes #<48>

## 🌟 Context <!-- What is the purpose of this PR? -->

<!--
Link to related issues, user stories, or tasks: #<issue_number>
Mention relevant sprints or milestones.
Describe the main goal of this change and why it's needed
Include any business context or requirements driving this change
-->

---
We want to have a basic about us page with querying functionality so we can display all of the execs.
Each exec should have their image, name, pronoun, role, and email displayed.

## 📝 Description <!--What changes have been made? -->

<!--
Briefly describe key updates — new features, bug fixes, or refactors.
Highlight any important design or architectural decisions.
Explain how these changes address the issue/requirement
Include any dependencies that are required for this change
-->

---
- Added loading state with fall back component.
- Implemented `ExecCard`, `ExecsGrid`, and `ExecsGridFallback`.
- Added query function for getting all of the execs.
  - wrote tests for query functions


## 📋 Notes <!--Are there any important details for reviewers? -->

<!--
Potential risks or breaking changes.
Edge cases or areas requiring special attention.
Anything you want feedback on!
List any manual testing you've performed
Mention any documentation that needs updating as a result
-->
- Image fit set to contain, potentially won't fill the space given
- Original website displayed full card on click, which is not currently implemented


Diff:
diff --git a/src/app/(frontend)/about/_components/ExecsCard.tsx b/src/app/(frontend)/about/_components/ExecsCard.tsx
new file mode 100644
index 00000000..7a7a5ed5
--- /dev/null
+++ b/src/app/(frontend)/about/_components/ExecsCard.tsx
@@ -0,0 +1,40 @@
+import Image from 'next/image'
+
+import type { Exec } from '@/payload-types'
+
+interface ExecCardProps {
+  exec: Exec
+}
+
+export function ExecCard({ exec }: ExecCardProps) {
+  return (
+    <div className="block border p-2">
+      <div className="h-48">
+        {exec.image && typeof exec.image !== 'number' && exec.image.url && (
+          <Image
+            src={exec.image.url}
+            alt={exec.name || ''}
+            className="h-full w-full object-cover"
+            width={300}
+            height={300}
+            style={{ objectFit: 'contain' }}
+          />
+        )}
+      </div>
+      <div>
+        <h3 className="text-center font-medium">{exec.name}</h3>
+        {exec.pronouns && (
+          <p className="text-center break-all text-gray-600">
+            ({exec.pronouns})
+          </p>
+        )}
+        {exec.role && (
+          <p className="text-center break-all text-gray-600">{exec.role}</p>
+        )}
+        {exec.email && (
+          <p className="text-center break-all text-gray-600">{exec.email}</p>
+        )}
+      </div>
+    </div>
+  )
+}
diff --git a/src/app/(frontend)/about/_components/ExecsGrid.tsx b/src/app/(frontend)/about/_components/ExecsGrid.tsx
new file mode 100644
index 00000000..0734af60
--- /dev/null
+++ b/src/app/(frontend)/about/_components/ExecsGrid.tsx
@@ -0,0 +1,16 @@
+import type { Exec } from '@/payload-types'
+import { ExecCard } from './ExecsCard'
+
+interface ExecsGridProps {
+  execs: Exec[]
+}
+
+export function ExecsGrid({ execs }: ExecsGridProps) {
+  return (
+    <div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
+      {execs.map((exec) => (
+        <ExecCard key={exec.id} exec={exec} />
+      ))}
+    </div>
+  )
+}
diff --git a/src/app/(frontend)/about/_components/ExecsGridFallback.tsx b/src/app/(frontend)/about/_components/ExecsGridFallback.tsx
new file mode 100644
index 00000000..d4ad4630
--- /dev/null
+++ b/src/app/(frontend)/about/_components/ExecsGridFallback.tsx
@@ -0,0 +1,17 @@
+export function ExecsGridFallback() {
+  return (
+    <div className="grid grid-cols-5 gap-4">
+      {Array.from({ length: 8 }).map((_, i) => (
+        <div key={i} className="border p-2">
+          <div className="h-48 animate-pulse bg-gray-200" />
+          <div className="mt-2">
+            <div className="mr-auto ml-auto h-5 w-2/3 bg-gray-200" />
+            <div className="mt-1 mr-auto ml-auto h-4 w-1/3 animate-pulse bg-gray-200" />
+            <div className="mt-1 mr-auto ml-auto h-4 w-1/2 animate-pulse bg-gray-200" />
+            <div className="mt-1 mr-auto ml-auto h-4 w-3/5 animate-pulse bg-gray-200" />
+          </div>
+        </div>
+      ))}
+    </div>
+  )
+}
diff --git a/src/app/(frontend)/about/loading.tsx b/src/app/(frontend)/about/loading.tsx
new file mode 100644
index 00000000..16a3c196
--- /dev/null
+++ b/src/app/(frontend)/about/loading.tsx
@@ -0,0 +1,16 @@
+import { ExecsGridFallback } from './_components/ExecsGridFallback'
+
+export default function Loading() {
+  return (
+    <div className="p-4">
+      <div>
+        <div className="h-8 w-32 animate-pulse bg-gray-200" />
+        <div className="mt-1 h-6 w-64 animate-pulse bg-gray-200" />
+      </div>
+
+      <div className="mt-4">
+        <ExecsGridFallback />
+      </div>
+    </div>
+  )
+}
diff --git a/src/app/(frontend)/about/page.tsx b/src/app/(frontend)/about/page.tsx
index 7afb6266..5044eb8c 100644
--- a/src/app/(frontend)/about/page.tsx
+++ b/src/app/(frontend)/about/page.tsx
@@ -1,13 +1,26 @@
+import { Suspense } from 'react'
+
+import { getExecs } from '@/queries/execs'
+import { ExecsGrid } from './_components/ExecsGrid'
+import { ExecsGridFallback } from './_components/ExecsGridFallback'
+
 export default async function AboutPage() {
+  const { execs } = await getExecs()
+
   return (
-    <div>
-      <div>
-        <h1>Welcome to the about us page.</h1>
-        <p>Update this page by editing</p>
-        <a>
-          <code>app/(frontend)/about/page.tsx</code>
-        </a>
+    <div className="p-4">
+      <div className="text-left">
+        <h1 className="text-2xl font-bold">About Us</h1>
+        <h2 className="text-gray-600">About us text</h2>
       </div>
+      <br></br>
+      <div className="text-left">
+        <h1 className="text-2xl font-bold">Execs</h1>
+        <h2 className="text-gray-600">A list of all execs available</h2>
+      </div>
+      <Suspense fallback={<ExecsGridFallback />}>
+        <ExecsGrid execs={execs} />
+      </Suspense>
     </div>
   )
 }
diff --git a/src/queries/__tests__/execs.test.ts b/src/queries/__tests__/execs.test.ts
new file mode 100644
index 00000000..2928a2f7
--- /dev/null
+++ b/src/queries/__tests__/execs.test.ts
@@ -0,0 +1,87 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { getPayloadClient } from '@/lib/payload'
+import { getExecs } from '@/queries/execs'
+
+// Mock the payload client
+vi.mock('@/lib/payload', () => ({
+  getPayloadClient: vi.fn(),
+}))
+
+describe('Exec queries', () => {
+  const mockPayloadClient = {
+    find: vi.fn(),
+  }
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+    ;(getPayloadClient as any).mockResolvedValue(mockPayloadClient)
+  })
+
+  describe('getExecs', () => {
+    it('should fetch execs with default parameters', async () => {
+      const mockResponse = {
+        docs: [{ id: '1', name: 'Test Exec', role: 'test' }],
+        hasNextPage: false,
+        nextPage: null,
+        totalDocs: 1,
+      }
+
+      mockPayloadClient.find.mockResolvedValue(mockResponse)
+      const result = await getExecs()
+
+      expect(mockPayloadClient.find).toHaveBeenCalledWith({
+        collection: 'execs',
+        page: 1,
+        limit: 10,
+        sort: 'name',
+      })
+      expect(result).toEqual({
+        execs: mockResponse.docs,
+        hasNextPage: mockResponse.hasNextPage,
+        nextPage: mockResponse.nextPage,
+        totalDocs: mockResponse.totalDocs,
+      })
+    })
+
+    it('should fetch execs with custom parameters', async () => {
+      const mockResponse = {
+        docs: [{ id: '1', name: 'Test Exec', role: 'test' }],
+        hasNextPage: true,
+        nextPage: 3,
+        totalDocs: 25,
+      }
+
+      mockPayloadClient.find.mockResolvedValue(mockResponse)
+      const result = await getExecs({ page: 2, limit: 20, sort: '-createdAt' })
+
+      expect(mockPayloadClient.find).toHaveBeenCalledWith({
+        collection: 'execs',
+        page: 2,
+        limit: 20,
+        sort: '-createdAt',
+      })
+      expect(result).toEqual({
+        execs: mockResponse.docs,
+        hasNextPage: mockResponse.hasNextPage,
+        nextPage: mockResponse.nextPage,
+        totalDocs: mockResponse.totalDocs,
+      })
+    })
+
+    it('should handle empty result sets', async () => {
+      const mockResponse = {
+        docs: [],
+        hasNextPage: false,
+        nextPage: null,
+        totalDocs: 0,
+      }
+
+      mockPayloadClient.find.mockResolvedValue(mockResponse)
+      const result = await getExecs()
+
+      expect(result.execs).toHaveLength(0)
+      expect(result.totalDocs).toBe(0)
+    })
+  })
+})
diff --git a/src/queries/execs.ts b/src/queries/execs.ts
new file mode 100644
index 00000000..18e64642
--- /dev/null
+++ b/src/queries/execs.ts
@@ -0,0 +1,34 @@
+import { getPayloadClient } from '@/lib/payload'
+
+/**
+ * Get all execs
+ * @param page - The page number to get
+ * @param limit - The number of execs to get per page
+ * @param sort - The field to sort the execs by
+ * @returns The execs and pagination information
+ */
+export async function getExecs({
+  page = 1,
+  limit = 10,
+  sort = 'name',
+}: {
+  page?: number
+  limit?: number
+  sort?: string
+} = {}) {
+  const payload = await getPayloadClient()
+
+  const { docs, hasNextPage, nextPage, totalDocs } = await payload.find({
+    collection: 'execs',
+    page,
+    limit,
+    sort,
+  })
+
+  return {
+    execs: docs,
+    hasNextPage,
+    nextPage,
+    totalDocs,
+  }
+}


Reviews:
user: login: dyzhuu
body: LGTM! small nits but mostly non-issues since the frontend will be revamped.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T07:39:08Z
user: login: f13322
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T08:48:20Z
user: login: phawatboom
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T11:00:51Z
user: login: phawatboom
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T11:06:05Z
user: login: dyzhuu
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T11:12:41Z

Review comments:
diff_hunk: @@ -0,0 +1,34 @@
+import { getPayloadClient } from '@/lib/payload'
+
+/**
+ * Get all execs
+ * @param page - The page number to get
+ * @param limit - The number of rivers to get per page
+ * @param sort - The field to sort the rivers by
+ * @returns The trip reports and pagination information
path: src/queries/execs.ts
user: login: dyzhuu
body: ```suggestion
 * @param limit - The number of execs to get per page
 * @param sort - The field to sort the execs by
 * @returns The execs and pagination information
```
created_at: 2025-04-28T07:31:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,40 @@
+import Image from 'next/image'
+
+import type { Exec } from '@/payload-types'
+
+interface ExecCardProps {
+  exec: Exec
+}
+
+export function ExecCard({ exec }: ExecCardProps) {
+  return (
+    <div className="block border p-2">
+      <div className="h-48">
+        {exec.image && typeof exec.image !== 'number' && exec.image.url && (
+          <Image
+            src={exec.image.url}
+            alt={exec.name || ''}
+            className="h-full w-full object-cover"
+            width={1}
+            height={1}
path: src/app/(frontend)/about/_components/ExecsCard.tsx
user: login: dyzhuu
body: width and height of 1 pixel?
created_at: 2025-04-28T07:35:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,34 @@
+import { getPayloadClient } from '@/lib/payload'
+
+/**
+ * Get all execs
+ * @param page - The page number to get
+ * @param limit - The number of rivers to get per page
+ * @param sort - The field to sort the rivers by
+ * @returns The trip reports and pagination information
+ */
+export async function getExecs({
+  page = 1,
+  limit = 10,
+  sort = 'name',
+}: {
+  page?: number
+  limit?: number
+  sort?: string
+} = {}) {
+  const payload = await getPayloadClient()
+
+  const { docs, hasNextPage, nextPage, totalDocs } = await payload.find({
+    collection: 'execs',
+    page,
+    limit,
+    sort,
+  })
+
+  return {
+    execs: docs,
+    hasNextPage,
+    nextPage,
+    totalDocs,
+  }
+}
path: src/queries/execs.ts
user: login: dyzhuu
body: probably fine to leave as is? but I don't think we will need pagination for execs. 
created_at: 2025-04-28T07:38:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,40 @@
+import Image from 'next/image'
+
+import type { Exec } from '@/payload-types'
+
+interface ExecCardProps {
+  exec: Exec
+}
+
+export function ExecCard({ exec }: ExecCardProps) {
+  return (
+    <div className="block border p-2">
+      <div className="h-48">
+        {exec.image && typeof exec.image !== 'number' && exec.image.url && (
+          <Image
+            src={exec.image.url}
+            alt={exec.name || ''}
+            className="h-full w-full object-cover"
+            width={1}
+            height={1}
path: src/app/(frontend)/about/_components/ExecsCard.tsx
user: login: f13322
body: I believe that the width and height is a ratio relative to eachother instead of pixel? So in this case I meant 1:1 ratio. But I could be wrong
created_at: 2025-04-28T08:48:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,17 @@
+export function ExecsGridFallback() {
+  return (
+    <div className="grid grid-cols-5 gap-4">
+      {Array.from({ length: 8 }).map((_, i) => (
+        <div key={i} className="border p-2">
+          <div className="h-48 animate-pulse bg-gray-200" />
+          <div className="mt-2">
+            <div className="mr-auto ml-auto h-5 w-2/3 bg-gray-200" />
+            <div className="mt-1 mr-auto ml-auto h-4 w-1/3 animate-pulse bg-gray-200" />
+            <div className="mt-1 mr-auto ml-auto h-4 w-1/2 animate-pulse bg-gray-200" />
+            <div className="mt-1 mr-auto ml-auto h-4 w-5/8 animate-pulse bg-gray-200" />
path: src/app/(frontend)/about/_components/ExecsGridFallback.tsx
user: login: phawatboom
body: no w-5/8 in Tailwind though, but doesn't rlly matter here 
![image](https://github.com/user-attachments/assets/9ce35570-e322-4fd2-b377-23dd6c748d79)

created_at: 2025-04-28T11:00:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,16 @@
+import type { Exec } from '@/payload-types'
+import { ExecCard } from './ExecsCard'
+
+interface ExecsGridProps {
+  execs: Exec[]
+}
+
+export function ExecsGrid({ execs }: ExecsGridProps) {
+  return (
+    <div className="grid grid-cols-5 gap-4">
path: src/app/(frontend)/about/_components/ExecsGrid.tsx
user: login: phawatboom
body: Can make it more responsive for different screen sizes: "grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5" 

![image](https://github.com/user-attachments/assets/a58e94bc-1a4a-4f6d-bc1f-00ccde4eb145)

created_at: 2025-04-28T11:06:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,40 @@
+import Image from 'next/image'
+
+import type { Exec } from '@/payload-types'
+
+interface ExecCardProps {
+  exec: Exec
+}
+
+export function ExecCard({ exec }: ExecCardProps) {
+  return (
+    <div className="block border p-2">
+      <div className="h-48">
+        {exec.image && typeof exec.image !== 'number' && exec.image.url && (
+          <Image
+            src={exec.image.url}
+            alt={exec.name || ''}
+            className="h-full w-full object-cover"
+            width={1}
+            height={1}
path: src/app/(frontend)/about/_components/ExecsCard.tsx
user: login: dyzhuu
body: https://nextjs.org/docs/app/api-reference/components/image

Yeah it's the pixel count
created_at: 2025-04-28T11:12:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-27T07:14:30Z
body: 🚀 Preview deployment is live: [Preview Link](https://aucc-preview-pr-56.fly.dev)
author_association: NONE
user: login: phawatboom
created_at: 2025-04-28T11:09:25Z
body: LGTM!
author_association: CONTRIBUTOR