diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index e253887..27158f1 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -1,31 +1,35 @@ name: Playwright Tests env: - NEXTAUTH_URL: http://localhost:3000 - DB_NAME: 'animal-tracking' - MONGO_DB: ${{ secrets.MONGO_DB }} + NEXTAUTH_URL: http://localhost:3000 + DB_NAME: 'animal-tracking' + MONGO_DB: ${{ secrets.MONGO_DB }} + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] + push: + branches: [main, master] + pull_request: + branches: [main, master] jobs: - test: - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - name: Install dependencies - run: npm ci - - name: Install Playwright Browsers - run: npx playwright install --with-deps - - name: Run Playwright tests - run: npx playwright test - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 + test: + environment: dev + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - name: Print Environment Variables + run: printenv + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + - name: Install dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run Playwright tests + run: npx playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/e2e/animals.spec.ts b/e2e/animals.spec.ts new file mode 100644 index 0000000..ef4cf8a --- /dev/null +++ b/e2e/animals.spec.ts @@ -0,0 +1,32 @@ +import test, { expect } from '@playwright/test'; + +test('create a new animal', async ({ page }) => { + await page.goto('/'); + // Expect a title "to contain" a substring. + await page.getByRole('link', { name: 'app logo Progress' }).click(); + await page.getByRole('link', { name: 'Log In' }).click(); + await page.getByRole('textbox', { name: 'Email' }).click(); + await page + .getByRole('textbox', { name: 'Email' }) + .fill('alice3@gatech.edu'); + await page.getByRole('textbox', { name: 'Email' }).press('Tab'); + await page.getByRole('textbox', { name: 'Password' }).fill('12345678'); + await page.getByRole('button', { name: 'Log In' }).click(); + + await page.getByRole('link', { name: 'Animals icon Animals' }).click(); + await page + .getByRole('link', { name: 'create new animal Create New' }) + .click(); + await page.getByRole('textbox', { name: 'Animal Name' }).click(); + await page.getByRole('textbox', { name: 'Animal Name' }).fill('Rover'); + await page.getByLabel('Breed').selectOption('German Shepherd'); + await page + .getByRole('spinbutton', { name: 'Total hours trained' }) + .fill('5'); + + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Animal created successfully!')).toBeVisible(); + + await page.getByRole('link', { name: 'Animals icon Animals' }).click(); + await expect(page.getByText('Rover - German Shepherd')).toBeVisible(); +}); diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..82afb16 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test'; + +test.beforeAll(async ({ request }) => { + const response = await request.post('/api/user/register', { + data: { + fullName: 'Admin User', + email: 'admin@gatech.edu', + password: '12345678', + confirmPassword: '12345678', + admin: true, + }, + }); + await expect(response).toBeOK(); +}); + +test('create admin user', async ({ page }) => { + await page.goto('/signup'); + // Expect a title "to contain" a substring. + await page.getByRole('textbox', { name: 'Full Name' }).click(); + await page.getByRole('textbox', { name: 'Full Name' }).fill('Admin User 2'); + await page.getByRole('textbox', { name: 'Full Name' }).press('Tab'); + await page + .getByRole('textbox', { name: 'Email' }) + .fill('admin2@gatech.edu'); + await page.getByRole('textbox', { name: 'Password', exact: true }).click(); + await page + .getByRole('textbox', { name: 'Password', exact: true }) + .fill('12345678'); + await page.getByRole('textbox', { name: 'Confirm Password' }).click(); + await page + .getByRole('textbox', { name: 'Confirm Password' }) + .fill('12345678'); + await page.getByRole('checkbox', { name: 'Admin Access' }).check(); + await page.getByRole('button', { name: 'Sign up' }).click(); + await expect( + page.getByRole('heading', { name: 'Create Account' }) + ).toBeVisible(); +}); + +test('login as admin user', async ({ page }) => { + await page.goto('/'); + // Expect a title "to contain" a substring. + await page.getByRole('link', { name: 'app logo Progress' }).click(); + await page.getByRole('link', { name: 'Log In' }).click(); + await page.getByRole('textbox', { name: 'Email' }).click(); + await page.getByRole('textbox', { name: 'Email' }).fill('admin@gatech.edu'); + await page.getByRole('textbox', { name: 'Email' }).press('Tab'); + await page.getByRole('textbox', { name: 'Password' }).fill('12345678'); + await page.getByRole('button', { name: 'Log In' }).click(); + await expect( + page.getByRole('heading', { name: 'Admin Access' }) + ).toBeVisible(); +}); diff --git a/e2e/example.spec.ts b/e2e/example.spec.ts deleted file mode 100644 index 8131631..0000000 --- a/e2e/example.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test('has title', async ({ page }) => { - await page.goto('/signup') - // Expect a title "to contain" a substring. - await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible(); -}); - diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..386481e --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,35 @@ +import { chromium, expect, request, type FullConfig } from '@playwright/test'; +import connectDB from '../server/mongodb'; +import { User } from '../server/mongodb/models/User'; +import { TrainingLog } from '../server/mongodb/models/TrainingLog'; +import { Animal } from '../server/mongodb/models/Animal'; + +async function globalSetup(config: FullConfig) { + // clear the database and seed with users + try { + await connectDB(); + await User.collection.drop(); + await TrainingLog.collection.drop(); + await Animal.collection.drop(); + } catch (error) { + console.error('Unable to reset the database'); + } + console.log('URL ', `${config.webServer?.url}/api/user/register`); + const requestCtx = await request.newContext(); + const response = await requestCtx.post( + `${config.webServer?.url}/api/user/register`, + { + data: { + fullName: 'Bob Johnson', + email: 'alice3@gatech.edu', + password: '12345678', + confirmPassword: '12345678', + admin: true, + }, + } + ); + + console.log('Response ', response); +} + +export default globalSetup; diff --git a/e2e/train-logs.spec.ts b/e2e/train-logs.spec.ts new file mode 100644 index 0000000..f026820 --- /dev/null +++ b/e2e/train-logs.spec.ts @@ -0,0 +1,100 @@ +import test, { expect } from '@playwright/test'; +test.describe.serial('training log tests ', () => { + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + await page.goto('/'); + // Expect a title "to contain" a substring. + await page.getByRole('link', { name: 'app logo Progress' }).click(); + await page.getByRole('link', { name: 'Log In' }).click(); + await page.getByRole('textbox', { name: 'Email' }).click(); + await page + .getByRole('textbox', { name: 'Email' }) + .fill('alice3@gatech.edu'); + await page.getByRole('textbox', { name: 'Email' }).press('Tab'); + await page.getByRole('textbox', { name: 'Password' }).fill('12345678'); + await page.getByRole('button', { name: 'Log In' }).click(); + await page.getByRole('link', { name: 'Animals icon Animals' }).click(); + await page + .getByRole('link', { name: 'create new animal Create New' }) + .click(); + await page.getByRole('textbox', { name: 'Animal Name' }).click(); + await page.getByRole('textbox', { name: 'Animal Name' }).fill('Rover'); + await page.getByLabel('Breed').selectOption('German Shepherd'); + await page + .getByRole('spinbutton', { name: 'Total hours trained' }) + .fill('5'); + + await page.getByRole('button', { name: 'Save' }).click(); + await expect( + page.getByText('Animal created successfully!') + ).toBeVisible(); + }); + + test('create a new training log', async ({ page }) => { + await page.goto('/'); + // Expect a title "to contain" a substring. + await page.getByRole('link', { name: 'app logo Progress' }).click(); + await page.getByRole('link', { name: 'Log In' }).click(); + await page.getByRole('textbox', { name: 'Email' }).click(); + await page + .getByRole('textbox', { name: 'Email' }) + .fill('alice3@gatech.edu'); + await page.getByRole('textbox', { name: 'Email' }).press('Tab'); + await page.getByRole('textbox', { name: 'Password' }).fill('12345678'); + await page.getByRole('button', { name: 'Log In' }).click(); + await page + .getByRole('link', { name: 'create new training log' }) + .click(); + await page.getByRole('textbox', { name: 'Title' }).click(); + await page + .getByRole('textbox', { name: 'Title' }) + .fill('Fetch Practice'); + + await page.getByLabel('Select Animal').selectOption({ label: 'Rover' }); + await page.getByRole('textbox', { name: 'Description' }).click(); + await page + .getByRole('textbox', { name: 'Description' }) + .fill('Rover played fetch and returned the ball every time.'); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.getByText('Training log created')).toBeVisible(); + + await page + .getByRole('link', { name: 'Training Logs icon Training' }) + .click(); + await expect(page.getByRole('main')).toContainText( + 'Rover played fetch and returned the ball every time.' + ); + }); + + test('edit a training log', async ({ page }) => { + await page.goto('/'); + // Expect a title "to contain" a substring. + await page.getByRole('link', { name: 'app logo Progress' }).click(); + await page.getByRole('link', { name: 'Log In' }).click(); + await page.getByRole('textbox', { name: 'Email' }).click(); + await page + .getByRole('textbox', { name: 'Email' }) + .fill('alice3@gatech.edu'); + await page.getByRole('textbox', { name: 'Email' }).press('Tab'); + await page.getByRole('textbox', { name: 'Password' }).fill('12345678'); + await page.getByRole('button', { name: 'Log In' }).click(); + + const dataLoaded = page.waitForRequest('/api/training-log/*'); + await page.getByRole('link', { name: 'edit training log' }).click(); + + await dataLoaded; + await page.getByRole('textbox', { name: 'Title' }).click(); + await page.waitForTimeout(5000); + await page + .getByRole('textbox', { name: 'Title' }) + .fill('Running Practice'); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.locator('[id="1"]')).toContainText( + 'Training log updated successfully.' + ); + await page + .getByRole('link', { name: 'Training Logs icon Training' }) + .click(); + await expect(page.locator('h3')).toContainText('Running Practice'); + }); +}); diff --git a/package-lock.json b/package-lock.json index 86424c9..b2cfb97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "argon2": "^0.44.0", "axios": "^1.13.2", + "dotenv": "^17.2.3", "jose": "^6.1.2", "mongoose": "^8.19.4", "next": "16.0.1", @@ -1819,6 +1820,18 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index 19f0aab..c79809b 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "argon2": "^0.44.0", "axios": "^1.13.2", + "dotenv": "^17.2.3", "jose": "^6.1.2", "mongoose": "^8.19.4", "next": "16.0.1", diff --git a/playwright.config.ts b/playwright.config.ts index 1ca9b0a..2adc1a1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,81 +1,83 @@ -import { defineConfig, devices } from "@playwright/test"; +import { defineConfig, devices } from '@playwright/test'; /** * Read environment variables from file. * https://github.com/motdotla/dotenv */ -// import dotenv from 'dotenv'; -// import path from 'path'; -// dotenv.config({ path: path.resolve(__dirname, '.env') }); - - +import dotenv from 'dotenv'; +import path from 'path'; +dotenv.config({ path: path.resolve(__dirname, '.env.local') }); /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: './e2e', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', - - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - screenshot: 'on-first-failure', - /* Base URL to use in actions like `await page.goto('')`. */ - baseURL: 'http://localhost:3000', - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - }, + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + globalSetup: './e2e/global-setup.ts', - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + screenshot: 'on-first-failure', + /* Base URL to use in actions like `await page.goto('')`. */ + baseURL: 'http://localhost:3000', + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + launchOptions: { + slowMo: 100, + }, }, - // { - // name: 'firefox', - // use: { ...devices['Desktop Firefox'] }, - // }, + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, - // { - // name: 'webkit', - // use: { ...devices['Desktop Safari'] }, - // }, + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, - /* Run your local dev server before starting the tests */ - webServer: { - command: 'npm run dev', - url: 'http://localhost:3000', - }, + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + }, }); diff --git a/server/mongodb/actions/findAllTrainingLogs.ts b/server/mongodb/actions/findAllTrainingLogs.ts index a25a84c..fd38f6a 100644 --- a/server/mongodb/actions/findAllTrainingLogs.ts +++ b/server/mongodb/actions/findAllTrainingLogs.ts @@ -7,7 +7,6 @@ const findAllTrainingLogs = async (): Promise => { const logs = (await TrainingLog.find({}).exec()).sort((a, b) => a.date < b.date ? -1 : b.date > a.date ? 1 : 0 ); - console.log('Sorted training logs ', logs); return logs; } catch (err) { console.error(`[ERROR]: Error encountered while creating user: ${err}`); diff --git a/server/mongodb/actions/findUserByEmail.ts b/server/mongodb/actions/findUserByEmail.ts index 1405e64..78a919a 100644 --- a/server/mongodb/actions/findUserByEmail.ts +++ b/server/mongodb/actions/findUserByEmail.ts @@ -1,18 +1,20 @@ import connectDB from '..'; import { User, UserDocument } from '../models/User'; -const findUserByEmail = async ( - {email} : {email: string} -): Promise => { +const findUserByEmail = async ({ + email, +}: { + email: string; +}): Promise => { try { await connectDB(); const user = (await User.find({ email }).exec()).at(0); if (!user) { - throw new Error('User could not be found with the given email'); + return null; } return user; } catch (err) { - console.error(`[ERROR]: Error encountered while creating user: ${err}`); + console.error(err); return null; } }; diff --git a/src/app/api/training-log/route.ts b/src/app/api/training-log/route.ts index eb189e5..4d4ef81 100644 --- a/src/app/api/training-log/route.ts +++ b/src/app/api/training-log/route.ts @@ -44,9 +44,11 @@ export const GET = async (req: NextRequest): Promise => { }); animalLogs?.forEach((a) => logs.push(a)); } + logs = logs.sort((a, b) => + a.date < b.date ? -1 : b.date > a.date ? 1 : 0 + ); } - console.log('Training logs ', logs); return new Response(JSON.stringify({ data: logs }), { status: 200 }); }; diff --git a/src/app/dashboard/admin/animals/page.tsx b/src/app/dashboard/admin/animals/page.tsx index 6c2cbac..bd0a3ee 100644 --- a/src/app/dashboard/admin/animals/page.tsx +++ b/src/app/dashboard/admin/animals/page.tsx @@ -1,35 +1,12 @@ 'use client'; -import Link from 'next/link'; -import Image from 'next/image'; import { AnimalDocument } from '../../../../../server/mongodb/models/Animal'; import { useEffect, useState } from 'react'; -import AnimalCard from '@/components/AnimalCard'; -type LogData = { - _id: string; - user: { _id: string; fullName: string }; - name: string; - animal_name: string; - breed: string; - hours: number; - url: string; -}; +import AnimalsGrid from '@/components/AnimalsGrid'; -const mapDbAnimalToLogData = (dbAnimal: any): LogData => ({ - _id: dbAnimal._id, - user: { - _id: dbAnimal.owner._id, - fullName: dbAnimal.owner.fullName - }, - name: dbAnimal.name, - animal_name: dbAnimal.name, - breed: dbAnimal.breed, - hours: dbAnimal.hoursTrained, - url: dbAnimal.profilePicture, -}); export default function AdminAnimalsPage() { const [isLoading, setIsLoading] = useState(true); - const [animals, setAnimals] = useState([]); + const [animals, setAnimals] = useState([]); useEffect(() => { const fetchAnimals = async () => { @@ -42,7 +19,7 @@ export default function AdminAnimalsPage() { const animals: AnimalDocument[] = respBody?.data; if (animals != undefined) { - setAnimals(respBody?.data?.map(mapDbAnimalToLogData) ?? []); + setAnimals(animals); } setIsLoading(false); } catch (error) { @@ -53,37 +30,15 @@ export default function AdminAnimalsPage() { fetchAnimals(); }, [isLoading]); return ( -
+

All Animals

- -
- create new animal{' '} - - Create New - -
-

-
-
- {animals.map((a, i) => ( -
- -
- ))} - {!isLoading && animals.length == 0 &&

No animals yet!

} -
-
+
); } diff --git a/src/app/dashboard/admin/layout.tsx b/src/app/dashboard/admin/layout.tsx index ca8dd52..809a312 100644 --- a/src/app/dashboard/admin/layout.tsx +++ b/src/app/dashboard/admin/layout.tsx @@ -13,7 +13,7 @@ export default function RootLayout({ const isAdmin = session?.user && (session?.user as any).admin; return isAdmin ? ( -
{children}
+ <>{children} ) : (

diff --git a/src/app/dashboard/admin/training-logs/page.tsx b/src/app/dashboard/admin/training-logs/page.tsx index eed7a43..19439ea 100644 --- a/src/app/dashboard/admin/training-logs/page.tsx +++ b/src/app/dashboard/admin/training-logs/page.tsx @@ -1,8 +1,7 @@ 'use client'; -import TrainingLogCard, { mockData } from '@/components/TrainingLogCard'; import { useEffect, useState } from 'react'; import { TrainingLogDocument } from '../../../../../server/mongodb/models/TrainingLog'; -import Skeleton from 'react-loading-skeleton'; +import TrainingLogList from '@/components/TrainingLogList'; export default function AdminTrainingLogsPage() { const [isLoading, setIsLoading] = useState(true); @@ -31,33 +30,15 @@ export default function AdminTrainingLogsPage() { }, [isLoading]); return ( -

+

- All training Logs + All Training Logs


-
- {isLoading ? ( - - ) : ( - logs.map((l, i) => ( - - )) - )} - {!isLoading && logs.length == 0 &&

No training logs yet!

} -
+
); } diff --git a/src/app/dashboard/admin/users/page.tsx b/src/app/dashboard/admin/users/page.tsx index bed69c6..01005fa 100644 --- a/src/app/dashboard/admin/users/page.tsx +++ b/src/app/dashboard/admin/users/page.tsx @@ -32,7 +32,7 @@ export default function AdminUsersPage() { }, [isLoading]); return ( -
+

All Users @@ -40,7 +40,7 @@ export default function AdminUsersPage() {


-
+
{isLoading ? ( ) : ( diff --git a/src/app/dashboard/animals/new/page.tsx b/src/app/dashboard/animals/new/page.tsx index c7e8b76..589e9dd 100644 --- a/src/app/dashboard/animals/new/page.tsx +++ b/src/app/dashboard/animals/new/page.tsx @@ -2,12 +2,12 @@ import EditAnimalForm from '@/components/EditAnimalForm'; export default function SignInPage() { return ( -
-

+
+

New Animal


-
+
{' '}
diff --git a/src/app/dashboard/animals/page.tsx b/src/app/dashboard/animals/page.tsx index c2c6a5e..10c9c9c 100644 --- a/src/app/dashboard/animals/page.tsx +++ b/src/app/dashboard/animals/page.tsx @@ -1,65 +1,45 @@ -"use client" -import LoginForm from '@/components/LoginForm'; -import TrainingLogCard, { mockData } from '@/components/TrainingLogCard'; +'use client'; import Link from 'next/link'; import Image from 'next/image'; import AnimalCard from '@/components/AnimalCard'; import { useEffect, useState } from 'react'; -import { useSession } from 'next-auth/react'; +import { AnimalDocument } from '../../../../server/mongodb/models/Animal'; +import AnimalsGrid from '@/components/AnimalsGrid'; +import { ToastContainer } from 'react-toastify'; -type LogData = { - _id: string; - user: { _id: string; fullName: string }; - name: string; - animal_name: string; - breed: string; - hours: number; - url: string; -}; - -const mapDbAnimalToLogData = (dbAnimal: any): LogData => ({ - _id: dbAnimal._id, - user: { - _id: dbAnimal.owner._id, - fullName: dbAnimal.owner.fullName - }, - name: dbAnimal.name, - animal_name: dbAnimal.name, - breed: dbAnimal.breed, - hours: dbAnimal.hoursTrained, - url: dbAnimal.profilePicture, -}); export default function AnimalsPage() { - const { data: session, status } = useSession(); - const [animals, setAnimals] = useState([]); - const [loading, setLoading] = useState(true); + const [isLoading, setIsLoading] = useState(true); + const [animals, setAnimals] = useState([]); + useEffect(() => { - const fetchTrainingLogs = async () => { - try { - const res = await fetch('/api/animal', { method: 'GET', credentials: 'include' }); - if (!res.ok) throw new Error("Failed to fetch logs"); - const data = await res.json(); - console.log("test") - console.log(data) - setAnimals(data?.data?.map(mapDbAnimalToLogData) ?? []); - } catch (err) { - console.error(err); - } finally { - setLoading(false); - } - }; + const fetchAnimals = async () => { + try { + const response = await fetch('/api/animal', { + method: 'GET', + credentials: 'include', + }); + const respBody = await response.json(); + const animals: AnimalDocument[] = respBody?.data; + + if (animals != undefined) { + setAnimals(animals); + } + setIsLoading(false); + } catch (error) { + setIsLoading(false); + } + }; - if (session) { - fetchTrainingLogs(); - } - }, [session]); + fetchAnimals(); + }, [isLoading]); return ( -
+

Animals

+
-
- -
-
- {loading &&
Loading...
} - {!loading && animals.length === 0 &&
No animals found
} - - {!loading && - animals.map((animal) => ( - - - - )) - } -
-
+

+
+ +
); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 598a215..ccd866a 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -14,8 +14,8 @@ export default function RootLayout({ console.log('Active session data ', session); return ( -
-
+
+
diff --git a/src/app/dashboard/training-logs/edit/[id]/page.tsx b/src/app/dashboard/training-logs/edit/[id]/page.tsx index d6c6206..9e6586a 100644 --- a/src/app/dashboard/training-logs/edit/[id]/page.tsx +++ b/src/app/dashboard/training-logs/edit/[id]/page.tsx @@ -2,12 +2,12 @@ import EditTrainingLogForm from '@/components/EditTrainingLogForm'; export default function SignInPage() { return ( -
+

Training logs


-
+
{' '}
diff --git a/src/app/dashboard/training-logs/new/page.tsx b/src/app/dashboard/training-logs/new/page.tsx index cb6902f..cd13500 100644 --- a/src/app/dashboard/training-logs/new/page.tsx +++ b/src/app/dashboard/training-logs/new/page.tsx @@ -2,12 +2,12 @@ import EditTrainingLogForm from '@/components/EditTrainingLogForm'; export default function SignInPage() { return ( -
+

New Training Log


-
+
{' '}
diff --git a/src/app/dashboard/training-logs/page.tsx b/src/app/dashboard/training-logs/page.tsx index 9feaf84..1aef23c 100644 --- a/src/app/dashboard/training-logs/page.tsx +++ b/src/app/dashboard/training-logs/page.tsx @@ -1,11 +1,10 @@ 'use client'; -import TrainingLogCard, { mockData } from '@/components/TrainingLogCard'; import Link from 'next/link'; import Image from 'next/image'; import { useEffect, useState } from 'react'; import { useSession } from 'next-auth/react'; import { TrainingLogDocument } from '../../../../server/mongodb/models/TrainingLog'; -import Skeleton from 'react-loading-skeleton'; +import TrainingLogList from '@/components/TrainingLogList'; export default function TrainingLogsPage() { const { data: session } = useSession(); @@ -35,9 +34,8 @@ export default function TrainingLogsPage() { fetchTrainingLogs(); }, [isLoading]); - console.log('Training logs ', logs); return ( -
+

Training logs @@ -59,27 +57,7 @@ export default function TrainingLogsPage() {
-
- {isLoading ? ( - - ) : ( - logs.map((l, i) => ( - - )) - )} - {!isLoading && logs.length == 0 && ( -

No training logs yet!

- )} -
+

); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 64b6164..2d2d228 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -30,7 +30,7 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > -
+
diff --git a/src/components/AnimalCard.tsx b/src/components/AnimalCard.tsx index 7cd8f8c..9cf4417 100644 --- a/src/components/AnimalCard.tsx +++ b/src/components/AnimalCard.tsx @@ -1,23 +1,36 @@ -"use client"; -import mongoose, { ObjectId } from "mongoose"; -import { useEffect } from "react"; -import Image from "next/image"; -import Link from "next/link"; +'use client'; +import { useEffect, useState } from 'react'; +import { AnimalDocument } from '../../server/mongodb/models/Animal'; +import { UserDocument } from '../../server/mongodb/models/User'; +const AnimalCard = ({ data }: { data: AnimalDocument }) => { + const [user, setUser] = useState | null>(null); -type LogData = { - _id: ObjectId | string; - user: { _id: ObjectId | string; fullName: string } - name: string; - animal_name: string; - breed: string; - hours: number; - url: string; -}; -const AnimalCard = ({ data }: { data: LogData }) => { + useEffect(() => { + const fetchUser = async () => { + try { + const response = await fetch( + `/api/user/${data.owner._id.toString()}` + ); + const respBody = await response.json(); + const user = respBody.data; + if (user != undefined) { + setUser(user); + } else { + setUser({ fullName: 'Long Lam' }); + } + } catch (error) { + setUser({ fullName: 'Long Lam' }); + } + }; + + if (!user) { + fetchUser(); + } + }, [user]); return (
- +
{data.name.charAt(0)} @@ -28,8 +41,11 @@ const AnimalCard = ({ data }: { data: LogData }) => { {data.name + ' - ' + data.breed}
- {data.user.fullName + - " - Trained " + data.hours + " hours"} + {user && + user.fullName + + ' - Trained ' + + data.hoursTrained + + ' hours'}
diff --git a/src/components/AnimalForm.tsx b/src/components/AnimalForm.tsx deleted file mode 100644 index 31e309d..0000000 --- a/src/components/AnimalForm.tsx +++ /dev/null @@ -1,104 +0,0 @@ -// import { useParams, useRouter } from 'next/navigation'; -// import FormInput from './FormInput'; -// import { SubmitHandler, useForm } from 'react-hook-form'; - -// type Inputs = { -// AnimalName: string; -// Breed: string; -// TotalHours: number; -// Month: string; -// Date: string; -// Year: number; -// Note: string; -// } -// const AnimalForm = () => { -// const params = useParams(); -// const {id} = params; -// const router = useRouter(); -// const { register, handleSubmit, formState: { errors }} = useForm() - -// const onSubmit: SubmitHandler = (data) => { - -// router.push('/dashboard') - -// } -// return ( -//
-// -// -// -//
-//
-// -//
-//
-// -//
- -//
-// -//
- -//
-//
-// -//
-//
-// {/* */} -// {/* */} -//
- - -//
-// ) -// } -// export default AnimalForm \ No newline at end of file diff --git a/src/components/AnimalsGrid.tsx b/src/components/AnimalsGrid.tsx new file mode 100644 index 0000000..fe31f45 --- /dev/null +++ b/src/components/AnimalsGrid.tsx @@ -0,0 +1,34 @@ +import { AnimalDocument } from '../../server/mongodb/models/Animal'; +import AnimalCard from './AnimalCard'; + +const AnimalsGrid = ({ + isLoading, + animals, +}: { + isLoading: boolean; + animals: AnimalDocument[]; +}) => { + return ( + <> + {!isLoading && animals.length == 0 && ( +
+

No animals yet!

+
+ )} +
+ {animals.map((a, i) => { + return ( +
+ +
+ ); + })} +
+ + ); +}; + +export default AnimalsGrid; diff --git a/src/components/EditAnimalForm.tsx b/src/components/EditAnimalForm.tsx index 1733ab7..97c3ff5 100644 --- a/src/components/EditAnimalForm.tsx +++ b/src/components/EditAnimalForm.tsx @@ -29,7 +29,8 @@ const EditAnimalForm = () => { } = useForm({ defaultValues: { hoursTrained: 1, - profilePicture: 'https://www.vidavetcare.com/wp-content/uploads/sites/234/2022/04/golden-retriever-dog-breed-info.jpeg', + profilePicture: + 'https://www.vidavetcare.com/wp-content/uploads/sites/234/2022/04/golden-retriever-dog-breed-info.jpeg', }, }); @@ -38,30 +39,28 @@ const EditAnimalForm = () => { if (id) { // edit existing training log try { - const response = await fetch(`/api/animal/${id}`, { - method: 'PATCH', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }); + const response = await fetch(`/api/animal/${id}`, { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); - const respBody = await response.json(); + const respBody = await response.json(); - if (!respBody.data) { - setError('root.serverError', { - message: respBody.error, - }); - return; - } + if (!respBody.data) { + setError('root.serverError', { + message: respBody.error, + }); + return; + } - toast('Animal updated successfully!'); - router.push('/dashboard/animals'); + toast('Animal updated successfully!'); } catch (err) { - setError('root.serverError', { - message: 'Failed to update animal', - }); + setError('root.serverError', { + message: 'Failed to update animal', + }); } - } else { //create new training log try { @@ -78,10 +77,9 @@ const EditAnimalForm = () => { }); } toast('Animal created successfully!'); - router.push('/dashboard/animals'); reset(); } catch (err) { - console.log + console.log; setError('root.serverError', { message: 'Failed to create a new animal', }); @@ -93,42 +91,42 @@ const EditAnimalForm = () => { if (!id) return; const fetchAnimal = async () => { - try { - const response = await fetch(`/api/animal/${id}`, { - method: 'GET', - credentials: 'include', - }); - const respBody = await response.json(); - const animal = respBody.data; + try { + const response = await fetch(`/api/animal/${id}`, { + method: 'GET', + credentials: 'include', + }); + const respBody = await response.json(); + const animal = respBody.data; - if (animal) { - reset({ - name: animal.name, - breed: animal.breed, - hoursTrained: animal.hoursTrained, - profilePicture: animal.profilePicture, - }); + if (animal) { + reset({ + name: animal.name, + breed: animal.breed, + hoursTrained: animal.hoursTrained, + profilePicture: animal.profilePicture, + }); + } + } catch (err) { + console.error('Failed to load animal:', err); } - } catch (err) { - console.error('Failed to load animal:', err); - } }; fetchAnimal(); }, [id, reset]); return ( -
+
{errors?.root?.serverError && (

{errors?.root?.serverError.message}

)} +
-
{ className="flex-1 p-4 text-2xl w-full rounded-lg " {...register('name', { required: true })} /> - {errors.name && ( + {errors.name?.type === 'required' && (

- {errors.name.message} + Animal name is required

)}
@@ -147,14 +145,13 @@ const EditAnimalForm = () => { @@ -219,4 +216,4 @@ const EditAnimalForm = () => { ); }; -export default EditAnimalForm; \ No newline at end of file +export default EditAnimalForm; diff --git a/src/components/EditTrainingLogForm.tsx b/src/components/EditTrainingLogForm.tsx index 0cf5d9b..41a513a 100644 --- a/src/components/EditTrainingLogForm.tsx +++ b/src/components/EditTrainingLogForm.tsx @@ -156,7 +156,7 @@ const EditTrainingLogForm = () => {
diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 795463a..1ba7822 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -23,17 +23,36 @@ const LoginForm = () => { const result = await signIn('credentials', { email: data.email, password: data.password, - callbackUrl: '/dashboard/training-logs', + + redirect: false, }); - console.log(result); - if (!result || result.error) { - if (result?.status == 401) { - setError('root.serverError', { - type: result.status as any, - message: 'Your email or password is incorrect.', - }); + console.log('Result ', result); + // callbackUrl: '/dashboard/training-logs', + + if (result) { + if (result?.ok) { + router.push('/dashboard/training-logs'); + } else { + if (result?.status == 401) { + setError('root.serverError', { + type: 'auth', + message: 'Your email or password is incorrect.', + }); + } else { + setError('root.serverError', { + type: 'general', + message: + 'Internal server error. Contact admin for more details', + }); + } } + } else { + setError('root.serverError', { + type: 'general', + message: + 'Internal server error. Contact admin for more details', + }); } }; @@ -46,7 +65,9 @@ const LoginForm = () => {

Log In

{errors?.root?.serverError && ( -

{errors?.root?.serverError?.message}

+

+ {errors?.root?.serverError?.message} +

)}
{ const { data: session } = useSession(); return ( -
+
{ url="/dashboard/animals" />
-

Admin Access

- - - - - - -
+ {(session?.user as any)?.admin && ( + <> +

Admin Access

+ + + + + + +
+ + )}
-
+
{session?.user?.name?.charAt(0)} @@ -143,10 +147,12 @@ const SideBar = () => { )}
- +
+ +
); }; -export default SideBar; \ No newline at end of file +export default SideBar; diff --git a/src/components/TrainingLogCard.tsx b/src/components/TrainingLogCard.tsx index ab05c68..46b76d0 100644 --- a/src/components/TrainingLogCard.tsx +++ b/src/components/TrainingLogCard.tsx @@ -82,7 +82,7 @@ const TrainingLogCard = ({ data }: { data: LogData }) => { const dateObj = new Date(dateStr); return ( -
+
{dateObj.getDate()} diff --git a/src/components/TrainingLogList.tsx b/src/components/TrainingLogList.tsx new file mode 100644 index 0000000..b9b6107 --- /dev/null +++ b/src/components/TrainingLogList.tsx @@ -0,0 +1,38 @@ +import Skeleton from 'react-loading-skeleton'; +import { TrainingLogDocument } from '../../server/mongodb/models/TrainingLog'; +import TrainingLogCard from './TrainingLogCard'; + +const TrainingLogList = ({ + isLoading, + logs, +}: { + isLoading: boolean; + logs: TrainingLogDocument[]; +}) => { + console.log('Training logs ', logs); + return ( +
+ {isLoading ? ( + + ) : ( + logs.map((l, i) => ( + + )) + )} + {!isLoading && logs.length == 0 && ( +

No training logs yet!

+ )} +
+ ); +}; + +export default TrainingLogList;