diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7cd30ab..f76e955 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,4 +6,4 @@ ### Issue -* Closes: # +- Closes: # diff --git a/README.md b/README.md index c991c8e..4cd02d1 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,10 @@ The initial user interface was designed and prototyped in Figma. These images sh Mobile interface prototype ## Sustainability + To make our project more sustainable we have made sure to not load any information in before it is asked for by the user. We have also used pictures of pokemon instead of GIFs. -However, this is a subject where you are expected to use AI to assist you in the project, and we have used AI. It would have been environmentally beneficial to not use AI, but the way the subject and it's requirements are now, we did not see that as a possibility. +However, this is a subject where you are expected to use AI to assist you in the project, and we have used AI. It would have been environmentally beneficial to not use AI, but the way the subject and it's requirements are now, we did not see that as a possibility. ## Architecture diff --git a/backend/scripts/import/extract.ts b/backend/scripts/import/extract.ts index 3b32f8f..7099c6b 100644 --- a/backend/scripts/import/extract.ts +++ b/backend/scripts/import/extract.ts @@ -47,7 +47,10 @@ export async function extractShowdownData(): Promise { if (Gen2Dex.species.get(s.id).exists && s.gen <= 2) { entries.push(...Gen2Dex.species.getFullLearnset(s.id)); } - learnsets.set(s.id, entries.map((entry) => entry.learnset)); + learnsets.set( + s.id, + entries.map((entry) => entry.learnset), + ); } return { diff --git a/backend/scripts/import/load/batch.ts b/backend/scripts/import/load/batch.ts index 425226a..d186695 100644 --- a/backend/scripts/import/load/batch.ts +++ b/backend/scripts/import/load/batch.ts @@ -3,7 +3,11 @@ import type { Connection } from 'mariadb'; // Keeps each batch well below MariaDB's max_allowed_packet of 16 MB const CHUNK_SIZE = 5000; -export async function batchInsert(conn: Connection, sql: string, values: unknown[][]): Promise { +export async function batchInsert( + conn: Connection, + sql: string, + values: unknown[][], +): Promise { for (let i = 0; i < values.length; i += CHUNK_SIZE) { await conn.batch(sql, values.slice(i, i + CHUNK_SIZE)); } diff --git a/backend/scripts/import/load/moves.ts b/backend/scripts/import/load/moves.ts index 7fd8154..6cd7ad3 100644 --- a/backend/scripts/import/load/moves.ts +++ b/backend/scripts/import/load/moves.ts @@ -22,8 +22,19 @@ export async function loadMoves(conn: Connection, rows: MoveRow[]): Promise [ - m.id, m.num, m.name, m.typeId, m.category, m.basePower, m.accuracy, - m.pp, m.priority, m.target, m.shortDesc, m.gen, m.isNonstandard, + m.id, + m.num, + m.name, + m.typeId, + m.category, + m.basePower, + m.accuracy, + m.pp, + m.priority, + m.target, + m.shortDesc, + m.gen, + m.isNonstandard, ]), ); } diff --git a/backend/scripts/import/load/pokemon.ts b/backend/scripts/import/load/pokemon.ts index 1dfb926..4ca0e6c 100644 --- a/backend/scripts/import/load/pokemon.ts +++ b/backend/scripts/import/load/pokemon.ts @@ -6,7 +6,11 @@ import { batchInsert } from './batch.ts'; export async function loadPokemon( conn: Connection, - rows: { pokemon: PokemonRow[]; pokemonTypes: PokemonTypeRow[]; pokemonAbilities: PokemonAbilityRow[] }, + rows: { + pokemon: PokemonRow[]; + pokemonTypes: PokemonTypeRow[]; + pokemonAbilities: PokemonAbilityRow[]; + }, ): Promise { // We update if the primary already exists, as hypothetically, if the pokemon showdown dataset was changed // our application would break, as published teams would use the wrong ID's @@ -36,8 +40,24 @@ export async function loadPokemon( is_nonstandard = VALUES(is_nonstandard), sprite = VALUES(sprite)`, rows.pokemon.map((p) => [ - p.id, p.num, p.name, p.baseSpecies, p.forme, p.gen, p.hp, p.atk, p.def, p.spa, p.spd, p.spe, - p.weightKg, p.tier, p.natdexTier, p.doublesTier, p.isNonstandard, p.sprite, + p.id, + p.num, + p.name, + p.baseSpecies, + p.forme, + p.gen, + p.hp, + p.atk, + p.def, + p.spa, + p.spd, + p.spe, + p.weightKg, + p.tier, + p.natdexTier, + p.doublesTier, + p.isNonstandard, + p.sprite, ]), ); diff --git a/backend/scripts/import/load/users.ts b/backend/scripts/import/load/users.ts index 1f0c6a2..bc6bb51 100644 --- a/backend/scripts/import/load/users.ts +++ b/backend/scripts/import/load/users.ts @@ -10,4 +10,4 @@ export async function loadUsers(conn: Connection): Promise { ON DUPLICATE KEY UPDATE username = VALUES(username)`, DEMO_USERS.map((username) => [username]), ); -} \ No newline at end of file +} diff --git a/backend/scripts/import/sprites.ts b/backend/scripts/import/sprites.ts index 329d281..b7e6d34 100644 --- a/backend/scripts/import/sprites.ts +++ b/backend/scripts/import/sprites.ts @@ -40,7 +40,8 @@ export async function downloadSprites(species: Species[]): Promise [ability.name, ability.id as string])); + const abilityIdByName = new Map( + data.abilities.map((ability) => [ability.name, ability.id as string]), + ); const pokemon: PokemonRow[] = []; const pokemonTypes: PokemonTypeRow[] = []; diff --git a/backend/scripts/import/transform/types.ts b/backend/scripts/import/transform/types.ts index 315914c..acbe9a9 100644 --- a/backend/scripts/import/transform/types.ts +++ b/backend/scripts/import/transform/types.ts @@ -12,7 +12,7 @@ const DAMAGE_TAKEN_MULTIPLIER: Record = { 0: 1, 1: 2, 2: 0.5, - 3: 0 + 3: 0, }; export function transformTypes(data: ShowdownData): { diff --git a/backend/src/graphql/api.test.ts b/backend/src/graphql/api.test.ts index c1d233a..d683d8a 100644 --- a/backend/src/graphql/api.test.ts +++ b/backend/src/graphql/api.test.ts @@ -1,7 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest'; -const endpoint = - process.env.GRAPHQL_TEST_URL ?? "http://localhost:3001/"; +const endpoint = process.env.GRAPHQL_TEST_URL ?? 'http://localhost:3001/'; type Pokemon = { id: string; @@ -22,14 +21,11 @@ type GraphQLResponse = { errors?: { message: string }[]; }; -async function sendQuery( - query: string, - variables?: Record, -) { +async function sendQuery(query: string, variables?: Record) { const response = await fetch(endpoint, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify({ query, variables }), }); @@ -39,8 +35,8 @@ async function sendQuery( return { response, body }; } -describe("GraphQL API integration", () => { - it("returns a list of Pokémon with their fields", async () => { +describe('GraphQL API integration', () => { + it('returns a list of Pokémon with their fields', async () => { const { response, body } = await sendQuery<{ pokemons: Pokemon[]; }>(` @@ -62,10 +58,10 @@ describe("GraphQL API integration", () => { expect(firstPokemon).toBeDefined(); expect(firstPokemon.id).toBeTruthy(); expect(firstPokemon.name).toBeTruthy(); - expect(typeof firstPokemon.type).toBe("string"); + expect(typeof firstPokemon.type).toBe('string'); }); - it("returns a Pokémon when queried by its ID", async () => { + it('returns a Pokémon when queried by its ID', async () => { const listResult = await sendQuery<{ pokemons: Pokemon[]; }>(` @@ -104,7 +100,7 @@ describe("GraphQL API integration", () => { expect(body.data?.pokemon).toEqual(firstPokemon); }); - it("returns the sprite file name for a Pokémon", async () => { + it('returns the sprite file name for a Pokémon', async () => { const { response, body } = await sendQuery<{ pokemon: { id: string; sprite: string | null } | null; }>( @@ -116,15 +112,15 @@ describe("GraphQL API integration", () => { } } `, - { id: "bulbasaur" }, + { id: 'bulbasaur' }, ); expect(response.status).toBe(200); expect(body.errors).toBeUndefined(); - expect(body.data?.pokemon?.sprite).toBe("bulbasaur.png"); + expect(body.data?.pokemon?.sprite).toBe('bulbasaur.png'); }); - it("returns null for an unknown Pokémon ID", async () => { + it('returns null for an unknown Pokémon ID', async () => { const { response, body } = await sendQuery<{ pokemon: Pokemon | null; }>( @@ -137,7 +133,7 @@ describe("GraphQL API integration", () => { } } `, - { id: "__integration_test_missing_pokemon__" }, + { id: '__integration_test_missing_pokemon__' }, ); expect(response.status).toBe(200); @@ -145,7 +141,7 @@ describe("GraphQL API integration", () => { expect(body.data?.pokemon).toBeNull(); }); - it("returns an error for an invalid GraphQL field", async () => { + it('returns an error for an invalid GraphQL field', async () => { const { body } = await sendQuery(` query { pokemons { @@ -158,7 +154,7 @@ describe("GraphQL API integration", () => { expect(body.errors!.length).toBeGreaterThan(0); }); - it("respects the requested result limit", async () => { + it('respects the requested result limit', async () => { const { response, body } = await sendQuery<{ searchPokemons: SearchPokemon[]; }>(` @@ -176,7 +172,7 @@ describe("GraphQL API integration", () => { expect(body.data!.searchPokemons).toHaveLength(5); }); - it("returns different results when the offset changes", async () => { + it('returns different results when the offset changes', async () => { const query = ` query Search($limit: Int, $offset: Int) { searchPokemons( @@ -216,7 +212,7 @@ describe("GraphQL API integration", () => { expect(secondIds.every((id) => !firstIds.includes(id))).toBe(true); }); - it("returns an empty list when the offset is beyond the results", async () => { + it('returns an empty list when the offset is beyond the results', async () => { const { response, body } = await sendQuery<{ searchPokemons: SearchPokemon[]; }>(` @@ -238,7 +234,7 @@ describe("GraphQL API integration", () => { expect(body.data!.searchPokemons).toEqual([]); }); - it("sorts Pokémon by name in ascending order", async () => { + it('sorts Pokémon by name in ascending order', async () => { const { response, body } = await sendQuery<{ searchPokemons: SearchPokemon[]; }>(` @@ -265,7 +261,7 @@ describe("GraphQL API integration", () => { expect(names).toEqual(sortedNames); }); - it("sorts Pokémon by name in descending order", async () => { + it('sorts Pokémon by name in descending order', async () => { const { response, body } = await sendQuery<{ searchPokemons: SearchPokemon[]; }>(` @@ -292,7 +288,7 @@ describe("GraphQL API integration", () => { expect(names).toEqual(sortedNames); }); - it("sorts Pokémon by number in descending order", async () => { + it('sorts Pokémon by number in descending order', async () => { const { response, body } = await sendQuery<{ searchPokemons: SearchPokemon[]; }>(` @@ -326,14 +322,12 @@ describe("GraphQL API integration", () => { for (let i = 0; i < results.length - 1; i++) { if (numbers[i] === numbers[i + 1]) { - expect( - results[i].name.localeCompare(results[i + 1].name), - ).toBeLessThanOrEqual(0); + expect(results[i].name.localeCompare(results[i + 1].name)).toBeLessThanOrEqual(0); } } }); - it("caps the requested result limit at 100", async () => { + it('caps the requested result limit at 100', async () => { const { response, body } = await sendQuery<{ searchPokemons: SearchPokemon[]; }>(` @@ -351,7 +345,7 @@ describe("GraphQL API integration", () => { expect(body.data!.searchPokemons.length).toBeLessThanOrEqual(100); }); - it("rejects an invalid sort field", async () => { + it('rejects an invalid sort field', async () => { const { body } = await sendQuery(` query { searchPokemons(format: "gen9", sortBy: "invalid") { @@ -364,7 +358,7 @@ describe("GraphQL API integration", () => { expect(body.errors!.length).toBeGreaterThan(0); }); - it("rejects an invalid sort direction", async () => { + it('rejects an invalid sort direction', async () => { const { body } = await sendQuery(` query { searchPokemons(format: "gen9", sortDirection: "SIDEWAYS") { diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index 8289ae4..dbe605d 100644 --- a/backend/src/graphql/resolvers.ts +++ b/backend/src/graphql/resolvers.ts @@ -1,5 +1,5 @@ -import { pool } from "../db/connection.js"; -import { GraphQLError } from "graphql"; +import { pool } from '../db/connection.js'; +import { GraphQLError } from 'graphql'; type SearchPokemonArgs = { format: string; @@ -27,22 +27,19 @@ function formatUser(row: UserRow) { function isDuplicateEntryError(error: unknown): boolean { return ( - typeof error === "object" && - error !== null && - "code" in error && - error.code === "ER_DUP_ENTRY" + typeof error === 'object' && error !== null && 'code' in error && error.code === 'ER_DUP_ENTRY' ); } function generationForFormat(format: string): number { const generationMatch = /^gen([1-9])(?:natdex)?$/.exec(format); if (generationMatch) return Number(generationMatch[1]); - if (format === "champions" || format === "championsnatdex") return 9; + if (format === 'champions' || format === 'championsnatdex') return 9; throw new Error(`Unsupported Pokémon format: ${format}`); } function likeSubstring(value: string): string { - return `%${value.replace(/[!%_]/g, "!$&")}%`; + return `%${value.replace(/[!%_]/g, '!$&')}%`; } function paginationValue( @@ -53,13 +50,10 @@ function paginationValue( ): number { if (value == null) return defaultValue; if (!Number.isSafeInteger(value)) { - throw new Error("Pagination values must be integers"); + throw new Error('Pagination values must be integers'); } - return Math.min( - Math.max(value, minimum), - maximum ?? Number.MAX_SAFE_INTEGER, - ); + return Math.min(Math.max(value, minimum), maximum ?? Number.MAX_SAFE_INTEGER); } export const resolvers = { @@ -114,7 +108,7 @@ export const resolvers = { typeChart: async () => { const [types, effectiveness] = await Promise.all([ - pool.query("SELECT name FROM types ORDER BY name"), + pool.query('SELECT name FROM types ORDER BY name'), pool.query( `SELECT attacking.name AS attackingType, @@ -137,7 +131,7 @@ export const resolvers = { searchPokemons: async (_: unknown, args: SearchPokemonArgs) => { const generation = generationForFormat(args.format); - const conditions = ["p.gen <= ?"]; + const conditions = ['p.gen <= ?']; const parameters: (string | number)[] = [generation]; if (args.name?.trim()) { @@ -192,19 +186,19 @@ export const resolvers = { const limit = paginationValue(args.limit, 20, 1, 100); const offset = paginationValue(args.offset, 0, 0); - const sortBy = args.sortBy ?? "number"; - const sortDirection = (args.sortDirection ?? "ASC").toUpperCase(); + const sortBy = args.sortBy ?? 'number'; + const sortDirection = (args.sortDirection ?? 'ASC').toUpperCase(); - if (sortBy !== "name" && sortBy !== "number") { + if (sortBy !== 'name' && sortBy !== 'number') { throw new Error("sortBy must be 'name' or 'number'"); } - if (sortDirection !== "ASC" && sortDirection !== "DESC") { + if (sortDirection !== 'ASC' && sortDirection !== 'DESC') { throw new Error("sortDirection must be 'ASC' or 'DESC'"); } const orderBy = - sortBy === "name" + sortBy === 'name' ? `p.name ${sortDirection}, p.num ASC, p.id ASC` : `p.num ${sortDirection}, p.name ASC, p.id ASC`; @@ -222,7 +216,7 @@ export const resolvers = { FROM pokemon p LEFT JOIN pokemon_types pt ON pt.pokemon_id = p.id LEFT JOIN types t ON t.id = pt.type_id - WHERE ${conditions.join(" AND ")} + WHERE ${conditions.join(' AND ')} GROUP BY p.id, p.name, p.gen, p.num, p.sprite ORDER BY ${orderBy} LIMIT ? OFFSET ?`, @@ -271,16 +265,15 @@ export const resolvers = { if (!/^[a-z0-9_]{3,20}$/.test(normalizedUsername)) { throw new GraphQLError( - "Username must be 3–20 characters and contain only letters, numbers and underscores.", - { extensions: { code: "BAD_USER_INPUT" } }, + 'Username must be 3–20 characters and contain only letters, numbers and underscores.', + { extensions: { code: 'BAD_USER_INPUT' } }, ); } try { - const result = (await pool.execute( - `INSERT INTO users (username) VALUES (?)`, - [normalizedUsername], - )) as { insertId: number }; + const result = (await pool.execute(`INSERT INTO users (username) VALUES (?)`, [ + normalizedUsername, + ])) as { insertId: number }; return { id: String(result.insertId), @@ -288,10 +281,9 @@ export const resolvers = { }; } catch (error) { if (isDuplicateEntryError(error)) { - throw new GraphQLError( - "This username is already taken.", - { extensions: { code: "BAD_USER_INPUT" } }, - ); + throw new GraphQLError('This username is already taken.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); } throw error; diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index 18135be..73e13a2 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -54,4 +54,4 @@ export const typeDefs = `#graphql type Mutation { createUser(username: String!): User! } -`; \ No newline at end of file +`; diff --git a/backend/src/graphql/users.test.ts b/backend/src/graphql/users.test.ts index 96c530a..e3f7842 100644 --- a/backend/src/graphql/users.test.ts +++ b/backend/src/graphql/users.test.ts @@ -1,8 +1,7 @@ -import { describe, expect, it } from "vitest"; -import { pool } from "../db/connection.js"; +import { describe, expect, it } from 'vitest'; +import { pool } from '../db/connection.js'; -const endpoint = - process.env.GRAPHQL_TEST_URL ?? "http://localhost:3001/"; +const endpoint = process.env.GRAPHQL_TEST_URL ?? 'http://localhost:3001/'; type User = { id: string; @@ -19,14 +18,11 @@ type GraphQLResponse = { }[]; }; -async function sendQuery( - query: string, - variables?: Record, -) { +async function sendQuery(query: string, variables?: Record) { const response = await fetch(endpoint, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify({ query, variables }), }); @@ -36,8 +32,8 @@ async function sendQuery( return { response, body }; } -describe("User API integration", () => { - it("returns the seeded demo users", async () => { +describe('User API integration', () => { + it('returns the seeded demo users', async () => { const { response, body } = await sendQuery<{ users: User[]; }>(` @@ -55,7 +51,7 @@ describe("User API integration", () => { const users = body.data!.users; expect(users.map((user) => user.username)).toEqual( - expect.arrayContaining(["ash", "misty", "brock", "dawn"]), + expect.arrayContaining(['ash', 'misty', 'brock', 'dawn']), ); for (const user of users) { @@ -63,7 +59,7 @@ describe("User API integration", () => { } }); - it("finds an existing user by username", async () => { + it('finds an existing user by username', async () => { const { response, body } = await sendQuery<{ user: User | null; }>( @@ -75,16 +71,16 @@ describe("User API integration", () => { } } `, - { username: "ash" }, + { username: 'ash' }, ); expect(response.status).toBe(200); expect(body.errors).toBeUndefined(); - expect(body.data!.user?.username).toBe("ash"); + expect(body.data!.user?.username).toBe('ash'); expect(body.data!.user?.id).toMatch(/^\d+$/); }); - it("finds an existing user by numeric ID", async () => { + it('finds an existing user by numeric ID', async () => { const lookup = await sendQuery<{ user: User | null; }>( @@ -96,7 +92,7 @@ describe("User API integration", () => { } } `, - { username: "ash" }, + { username: 'ash' }, ); expect(lookup.body.errors).toBeUndefined(); @@ -121,11 +117,11 @@ describe("User API integration", () => { expect(body.errors).toBeUndefined(); expect(body.data!.userById).toEqual({ id: userId, - username: "ash", + username: 'ash', }); }); - it("returns null for an unknown numeric user ID", async () => { + it('returns null for an unknown numeric user ID', async () => { const { response, body } = await sendQuery<{ userById: User | null; }>( @@ -137,7 +133,7 @@ describe("User API integration", () => { } } `, - { id: "999999999" }, + { id: '999999999' }, ); expect(response.status).toBe(200); @@ -145,7 +141,7 @@ describe("User API integration", () => { expect(body.data!.userById).toBeNull(); }); - it("returns null for an unknown username", async () => { + it('returns null for an unknown username', async () => { const { response, body } = await sendQuery<{ user: User | null; }>( @@ -157,7 +153,7 @@ describe("User API integration", () => { } } `, - { username: "unknown_test_user" }, + { username: 'unknown_test_user' }, ); expect(response.status).toBe(200); @@ -165,8 +161,8 @@ describe("User API integration", () => { expect(body.data!.user).toBeNull(); }); - it("creates a new user with a unique numeric ID", async () => { - const username = "test_create_user"; + it('creates a new user with a unique numeric ID', async () => { + const username = 'test_create_user'; try { const { response, body } = await sendQuery<{ @@ -188,15 +184,12 @@ describe("User API integration", () => { expect(body.data!.createUser.username).toBe(username); expect(body.data!.createUser.id).toMatch(/^\d+$/); } finally { - await pool.execute( - "DELETE FROM users WHERE username = ?", - [username], - ); + await pool.execute('DELETE FROM users WHERE username = ?', [username]); } }); - it("normalizes usernames to lowercase", async () => { - const username = "test_normalized_user"; + it('normalizes usernames to lowercase', async () => { + const username = 'test_normalized_user'; try { const { body } = await sendQuery<{ @@ -210,21 +203,18 @@ describe("User API integration", () => { } } `, - { username: "Test_Normalized_User" }, + { username: 'Test_Normalized_User' }, ); expect(body.errors).toBeUndefined(); expect(body.data!.createUser.username).toBe(username); } finally { - await pool.execute( - "DELETE FROM users WHERE username = ?", - [username], - ); + await pool.execute('DELETE FROM users WHERE username = ?', [username]); } }); - it("rejects duplicate usernames, regardless of letter casing", async () => { - for (const username of ["ash", "ASH"]) { + it('rejects duplicate usernames, regardless of letter casing', async () => { + for (const username of ['ash', 'ASH']) { const { body } = await sendQuery( ` mutation CreateUser($username: String!) { @@ -238,20 +228,18 @@ describe("User API integration", () => { ); expect(body.errors).toBeDefined(); - expect(body.errors![0].message).toBe( - "This username is already taken.", - ); + expect(body.errors![0].message).toBe('This username is already taken.'); } }); it.each([ - "ab", - "this_username_is_way_too_long", - "invalid username", - "user-name", - "user.name", - "user@name", - ])("rejects an invalid username: %s", async (username) => { + 'ab', + 'this_username_is_way_too_long', + 'invalid username', + 'user-name', + 'user.name', + 'user@name', + ])('rejects an invalid username: %s', async (username) => { const { body } = await sendQuery( ` mutation CreateUser($username: String!) { @@ -265,6 +253,6 @@ describe("User API integration", () => { ); expect(body.errors).toBeDefined(); - expect(body.errors![0].extensions?.code).toBe("BAD_USER_INPUT"); + expect(body.errors![0].extensions?.code).toBe('BAD_USER_INPUT'); }); -}); \ No newline at end of file +}); diff --git a/backend/src/server.ts b/backend/src/server.ts index a7d3c56..f94760a 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,20 +1,20 @@ -import { ApolloServer } from "@apollo/server"; -import { startStandaloneServer } from "@apollo/server/standalone"; -import { typeDefs } from "./graphql/schema.js"; -import { resolvers } from "./graphql/resolvers.js"; +import { ApolloServer } from '@apollo/server'; +import { startStandaloneServer } from '@apollo/server/standalone'; +import { typeDefs } from './graphql/schema.js'; +import { resolvers } from './graphql/resolvers.js'; const server = new ApolloServer({ typeDefs, resolvers, includeStacktraceInErrorResponses: false, formatError: (formattedError, error) => { - if (formattedError.extensions?.code === "INTERNAL_SERVER_ERROR") { - console.error("GraphQL internal error:", error); + if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') { + console.error('GraphQL internal error:', error); return { - message: "An internal server error occurred.", + message: 'An internal server error occurred.', extensions: { - code: "INTERNAL_SERVER_ERROR", + code: 'INTERNAL_SERVER_ERROR', }, }; } diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 5fe8d2d..cd10b46 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,4 +1,3 @@ - { "compilerOptions": { "target": "ES2023", diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index ae847ff..6ec74ee 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ["src/**/*.test.ts"], + include: ['src/**/*.test.ts'], }, }); diff --git a/docs/database-setup.md b/docs/database-setup.md index d36dcbd..8f348f9 100644 --- a/docs/database-setup.md +++ b/docs/database-setup.md @@ -20,16 +20,19 @@ Arch: `sudo pacman -S mariadb` ### 2. Setting up the database and user Enter MariaDB as root + ``` sudo mariadb -u root ``` Create a database + ``` MariaDB> CREATE DATABASE mydb; ``` Create a user + ``` MariaDB> CREATE USER 'my_user'@'localhost' IDENTIFIED BY 'my_password'; MariaDB> GRANT ALL PRIVILEGES ON my_db.* TO 'my_user'@'localhost'; diff --git a/docs/git-workflow.md b/docs/git-workflow.md index c5f3755..9e807bb 100644 --- a/docs/git-workflow.md +++ b/docs/git-workflow.md @@ -9,9 +9,9 @@ we work effectively towards our larger goals. We tie our branches to the `github issue` we are working on. This can be done either from the issue page by clicking `Create a branch` from the right hand -menu, or by manually naming it using this convention: +menu, or by manually naming it using this convention: -```git checkout -b -``` +`git checkout -b -` We always use issues, so if you are working on anything, make sure you have set up an issue first. This is a part of our documentation strategy. diff --git a/docs/mock-login.md b/docs/mock-login.md index 2f6dd39..628ad39 100644 --- a/docs/mock-login.md +++ b/docs/mock-login.md @@ -9,11 +9,11 @@ The system is intended for demonstration purposes. It does not use passwords and The database is seeded with the following predefined users: | Username | -|---| -| `ash` | -| `misty` | -| `brock` | -| `dawn` | +| -------- | +| `ash` | +| `misty` | +| `brock` | +| `dawn` | These accounts can be used to test the login functionality. @@ -51,4 +51,4 @@ The mock login system uses: - **Backend:** Node.js, TypeScript and Apollo Server. - **Database:** MariaDB for persistent user storage. -The `users` table uses an auto-incrementing numeric ID as its primary key and enforces unique usernames using a case-insensitive collation. \ No newline at end of file +The `users` table uses an auto-incrementing numeric ID as its primary key and enforces unique usernames using a case-insensitive collation. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index ef614d2..973e273 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,9 +1,9 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import { defineConfig, globalIgnores } from 'eslint/config'; export default defineConfig([ globalIgnores(['dist']), @@ -19,4 +19,4 @@ export default defineConfig([ globals: globals.browser, }, }, -]) +]); diff --git a/frontend/index.html b/frontend/index.html index 2392c73..6950f44 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -11,4 +11,4 @@
- \ No newline at end of file + diff --git a/frontend/src/App.css b/frontend/src/App.css index c81d3ea..11ecad9 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -59,7 +59,9 @@ padding: 0.6rem 1.4rem; font: 600 1rem var(--sans); cursor: pointer; - transition: filter 0.2s ease, transform 0.1s ease; + transition: + filter 0.2s ease, + transform 0.1s ease; } .search-button:hover { @@ -163,7 +165,9 @@ font-family: var(--sans); cursor: pointer; outline: none; - transition: border-color 0.2s, box-shadow 0.2s; + transition: + border-color 0.2s, + box-shadow 0.2s; } #formats:focus, @@ -180,7 +184,9 @@ font-family: var(--sans); border-radius: 999px; cursor: pointer; - transition: transform 0.1s ease, filter 0.2s ease; + transition: + transform 0.1s ease, + filter 0.2s ease; } .addPokemon:hover { @@ -263,4 +269,4 @@ .nav-links.active { display: flex; } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e2d6ca..7e0a3b4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ -import "./App.css"; -import { useEffect, useState } from "react"; +import './App.css'; +import { useEffect, useState } from 'react'; import { Link, Navigate, @@ -8,14 +8,14 @@ import { Routes, useNavigate, useOutletContext, -} from "react-router-dom"; -import HomePage from "./assets/pages/home/page.tsx"; -import TeamsPage from "./assets/pages/teams/page.tsx"; -import SearchModal from "./search/SearchModal.tsx"; -import { MAX_TEAM_SIZE, type TeamPokemon } from "./team.ts"; -import { useAuth } from "./auth/AuthContext.tsx"; -import ProtectedRoute from "./auth/ProtectedRoute.tsx"; -import LoginPage from "./auth/LoginPage.tsx"; +} from 'react-router-dom'; +import HomePage from './assets/pages/home/page.tsx'; +import TeamsPage from './assets/pages/teams/page.tsx'; +import SearchModal from './search/SearchModal.tsx'; +import { MAX_TEAM_SIZE, type TeamPokemon } from './team.ts'; +import { useAuth } from './auth/AuthContext.tsx'; +import ProtectedRoute from './auth/ProtectedRoute.tsx'; +import LoginPage from './auth/LoginPage.tsx'; type ApplicationContext = { format: string; @@ -31,15 +31,11 @@ function ApplicationLayout() { const [menuOpen, setMenuOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const [format, setFormat] = useState( - () => - localStorage.getItem(`pokemon-draft-planner-format-${user?.id}`) || - "gen9", + () => localStorage.getItem(`pokemon-draft-planner-format-${user?.id}`) || 'gen9', ); const [team, setTeam] = useState(() => { - const savedTeam = localStorage.getItem( - `pokemon-draft-planner-team-${user?.id}`, - ); + const savedTeam = localStorage.getItem(`pokemon-draft-planner-team-${user?.id}`); return savedTeam ? JSON.parse(savedTeam) : []; }); @@ -47,10 +43,7 @@ function ApplicationLayout() { useEffect(() => { if (!user) return; - localStorage.setItem( - `pokemon-draft-planner-team-${user.id}`, - JSON.stringify(team), - ); + localStorage.setItem(`pokemon-draft-planner-team-${user.id}`, JSON.stringify(team)); }, [team, user]); const navigate = useNavigate(); @@ -63,7 +56,7 @@ function ApplicationLayout() { logout(); setSearchOpen(false); closeMenu(); - navigate("/login", { replace: true }); + navigate('/login', { replace: true }); } function handleFormatChange(newFormat: string) { @@ -73,17 +66,14 @@ function ApplicationLayout() { function addPokemon(pokemon: TeamPokemon) { setTeam((currentTeam) => - currentTeam.length >= MAX_TEAM_SIZE || - currentTeam.some((member) => member.id === pokemon.id) + currentTeam.length >= MAX_TEAM_SIZE || currentTeam.some((member) => member.id === pokemon.id) ? currentTeam : [...currentTeam, pokemon], ); } function removePokemon(id: string) { - setTeam((currentTeam) => - currentTeam.filter((pokemon) => pokemon.id !== id), - ); + setTeam((currentTeam) => currentTeam.filter((pokemon) => pokemon.id !== id)); } return ( @@ -104,15 +94,13 @@ function ApplicationLayout() { - {user?.username - ? user.username.charAt(0).toUpperCase() + user.username.slice(1) - : ""} + {user?.username ? user.username.charAt(0).toUpperCase() + user.username.slice(1) : ''} -
-
) : (

- No Pokémon on your team yet. Add one with "Add Pokémon +" to start - building it. + No Pokémon on your team yet. Add one with "Add Pokémon +" to start building it.

)} @@ -139,9 +122,7 @@ function HomePage({

Type Matchups

{loading &&

Loading type matchups…

} - {error && ( -

Could not load type matchups: {error.message}

- )} + {error &&

Could not load type matchups: {error.message}

} {!loading && !error && (
@@ -164,7 +145,7 @@ function HomePage({ return ( diff --git a/frontend/src/assets/pages/teams/page.tsx b/frontend/src/assets/pages/teams/page.tsx index 5e3f57f..97d8d57 100644 --- a/frontend/src/assets/pages/teams/page.tsx +++ b/frontend/src/assets/pages/teams/page.tsx @@ -7,4 +7,4 @@ function TeamsPage() { ); } -export default TeamsPage; \ No newline at end of file +export default TeamsPage; diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index 62a683d..86b1112 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -1,12 +1,6 @@ -import { - createContext, - useContext, - useEffect, - useState, - type ReactNode, -} from "react"; -import { useApolloClient } from "@apollo/client/react"; -import { CREATE_USER, USER_BY_ID, USER_BY_USERNAME } from "../graphql/users.ts"; +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { useApolloClient } from '@apollo/client/react'; +import { CREATE_USER, USER_BY_ID, USER_BY_USERNAME } from '../graphql/users.ts'; export type User = { id: string; @@ -23,7 +17,7 @@ type AuthContextType = { const AuthContext = createContext(undefined); -const STORAGE_KEY = "pokemon-draft-planner-user-id"; +const STORAGE_KEY = 'pokemon-draft-planner-user-id'; export function AuthProvider({ children }: { children: ReactNode }) { const client = useApolloClient(); @@ -47,7 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }>({ query: USER_BY_ID, variables: { id: storedUserId }, - fetchPolicy: "network-only", + fetchPolicy: 'network-only', }); if (!active) return; @@ -86,11 +80,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { }>({ query: USER_BY_USERNAME, variables: { username: normalizedUsername }, - fetchPolicy: "network-only", + fetchPolicy: 'network-only', }); if (!data?.user) { - throw new Error("No account was found with that username."); + throw new Error('No account was found with that username.'); } setUser(data.user); @@ -108,7 +102,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); if (!data?.createUser) { - throw new Error("Could not create your account. Please try again."); + throw new Error('Could not create your account. Please try again.'); } setUser(data.createUser); @@ -131,7 +125,7 @@ export function useAuth(): AuthContextType { const context = useContext(AuthContext); if (!context) { - throw new Error("useAuth must be used within an AuthProvider"); + throw new Error('useAuth must be used within an AuthProvider'); } return context; diff --git a/frontend/src/auth/LoginPage.css b/frontend/src/auth/LoginPage.css index 02d663c..41bad98 100644 --- a/frontend/src/auth/LoginPage.css +++ b/frontend/src/auth/LoginPage.css @@ -141,4 +141,4 @@ .login-card { padding: 1.5rem; } -} \ No newline at end of file +} diff --git a/frontend/src/auth/LoginPage.tsx b/frontend/src/auth/LoginPage.tsx index 1706848..c047e2f 100644 --- a/frontend/src/auth/LoginPage.tsx +++ b/frontend/src/auth/LoginPage.tsx @@ -1,23 +1,21 @@ -import { useState, type SyntheticEvent } from "react"; -import { Navigate, useLocation, useNavigate } from "react-router-dom"; -import { useAuth } from "./AuthContext.tsx"; -import "./LoginPage.css"; +import { useState, type SyntheticEvent } from 'react'; +import { Navigate, useLocation, useNavigate } from 'react-router-dom'; +import { useAuth } from './AuthContext.tsx'; +import './LoginPage.css'; function LoginPage() { const { user, loading, login, register } = useAuth(); const navigate = useNavigate(); const location = useLocation(); - const [mode, setMode] = useState<"login" | "register">("login"); - const [username, setUsername] = useState(""); - const [error, setError] = useState(""); + const [mode, setMode] = useState<'login' | 'register'>('login'); + const [username, setUsername] = useState(''); + const [error, setError] = useState(''); const [submitting, setSubmitting] = useState(false); - const previousPath = ( - location.state as { from?: { pathname?: string } } | null - )?.from?.pathname; + const previousPath = (location.state as { from?: { pathname?: string } } | null)?.from?.pathname; - const destination = previousPath ?? "/home"; + const destination = previousPath ?? '/home'; if (loading) { return ( @@ -33,13 +31,13 @@ function LoginPage() { async function handleSubmit(event: SyntheticEvent) { event.preventDefault(); - setError(""); + setError(''); const normalizedUsername = username.trim().toLowerCase(); if (!/^[a-z0-9_]{3,20}$/.test(normalizedUsername)) { setError( - "Username must be 3–20 characters and contain only letters, numbers and underscores.", + 'Username must be 3–20 characters and contain only letters, numbers and underscores.', ); return; } @@ -47,7 +45,7 @@ function LoginPage() { setSubmitting(true); try { - if (mode === "login") { + if (mode === 'login') { await login(normalizedUsername); } else { await register(normalizedUsername); @@ -55,19 +53,15 @@ function LoginPage() { navigate(destination, { replace: true }); } catch (err) { - setError( - err instanceof Error - ? err.message - : "Something went wrong. Please try again.", - ); + setError(err instanceof Error ? err.message : 'Something went wrong. Please try again.'); } finally { setSubmitting(false); } } - function changeMode(nextMode: "login" | "register") { + function changeMode(nextMode: 'login' | 'register') { setMode(nextMode); - setError(""); + setError(''); } return ( @@ -77,9 +71,9 @@ function LoginPage() {

Pokémon Draft Planner

- {mode === "login" - ? "Log in to access your teams." - : "Create an account to start building your teams."} + {mode === 'login' + ? 'Log in to access your teams.' + : 'Create an account to start building your teams.'}

@@ -88,18 +82,18 @@ function LoginPage() { @@ -134,11 +128,7 @@ function LoginPage() { )} diff --git a/frontend/src/auth/ProtectedRoute.tsx b/frontend/src/auth/ProtectedRoute.tsx index 84387fa..630d175 100644 --- a/frontend/src/auth/ProtectedRoute.tsx +++ b/frontend/src/auth/ProtectedRoute.tsx @@ -1,5 +1,5 @@ -import { Navigate, Outlet, useLocation } from "react-router-dom"; -import { useAuth } from "./AuthContext.tsx"; +import { Navigate, Outlet, useLocation } from 'react-router-dom'; +import { useAuth } from './AuthContext.tsx'; function ProtectedRoute() { const { user, loading } = useAuth(); diff --git a/frontend/src/graphql/users.ts b/frontend/src/graphql/users.ts index b579667..f6c9fc5 100644 --- a/frontend/src/graphql/users.ts +++ b/frontend/src/graphql/users.ts @@ -1,4 +1,4 @@ -import { gql } from "@apollo/client"; +import { gql } from '@apollo/client'; export const USER_BY_ID = gql` query UserById($id: ID!) { @@ -25,4 +25,4 @@ export const CREATE_USER = gql` username } } -`; \ No newline at end of file +`; diff --git a/frontend/src/index.css b/frontend/src/index.css index 79981c7..e91fd3e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -8,8 +8,7 @@ --accent-bg: rgba(59, 248, 255, 0.1); --accent-border: rgba(59, 239, 255, 0.5); --social-bg: rgba(223, 222, 215, 0.545); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; --sans: system-ui, 'Segoe UI', Roboto, sans-serif; --heading: system-ui, 'Segoe UI', Roboto, sans-serif; @@ -83,4 +82,4 @@ code { line-height: 135%; padding: 4px 8px; background: var(--code-bg); -} \ No newline at end of file +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 72448f9..44f5fc7 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,11 +1,11 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client"; -import { ApolloProvider } from "@apollo/client/react"; -import { BrowserRouter } from "react-router-dom"; -import "./index.css"; -import App from "./App.tsx"; -import { AuthProvider } from "./auth/AuthContext.tsx"; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'; +import { ApolloProvider } from '@apollo/client/react'; +import { BrowserRouter } from 'react-router-dom'; +import './index.css'; +import App from './App.tsx'; +import { AuthProvider } from './auth/AuthContext.tsx'; const client = new ApolloClient({ link: new HttpLink({ @@ -14,7 +14,7 @@ const client = new ApolloClient({ cache: new InMemoryCache(), }); -createRoot(document.getElementById("root")!).render( +createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/search/SearchModal.test.tsx b/frontend/src/search/SearchModal.test.tsx index 51232aa..fba257f 100644 --- a/frontend/src/search/SearchModal.test.tsx +++ b/frontend/src/search/SearchModal.test.tsx @@ -1,41 +1,31 @@ -import { MockedProvider } from "@apollo/client/testing/react"; -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import { useState } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - MAX_TEAM_SIZE, - TEAM_FULL_MESSAGE, - type TeamPokemon, -} from "../team.ts"; -import SearchModal from "./SearchModal"; -import { SEARCH_POKEMONS } from "./search"; +import { MockedProvider } from '@apollo/client/testing/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { MAX_TEAM_SIZE, TEAM_FULL_MESSAGE, type TeamPokemon } from '../team.ts'; +import SearchModal from './SearchModal'; +import { SEARCH_POKEMONS } from './search'; afterEach(cleanup); const defaultVariables = { - format: "gen9", + format: 'gen9', name: null, types: [], abilities: [], moves: [], limit: 21, offset: 0, - sortBy: "number", - sortDirection: "ASC", + sortBy: 'number', + sortDirection: 'ASC', }; function makePokemon(index: number) { return { - __typename: "PokemonSearchResult", + __typename: 'PokemonSearchResult', id: `pokemon-${index}`, name: `Pokemon ${index}`, - type: "Normal", + type: 'Normal', gen: 1, sprite: `pokemon-${index}.png`, }; @@ -48,43 +38,39 @@ function SearchModalHarness() { pokemon.id)} - onAddPokemon={(pokemon) => - setTeam((currentTeam) => [...currentTeam, pokemon]) - } + onAddPokemon={(pokemon) => setTeam((currentTeam) => [...currentTeam, pokemon])} onRemovePokemon={(id) => - setTeam((currentTeam) => - currentTeam.filter((pokemon) => pokemon.id !== id), - ) + setTeam((currentTeam) => currentTeam.filter((pokemon) => pokemon.id !== id)) } onClose={() => {}} /> ); } -describe("SearchModal", () => { - it("renders mocked results for multiple type, ability, and move filters", async () => { +describe('SearchModal', () => { + it('renders mocked results for multiple type, ability, and move filters', async () => { const mocks = [ { request: { query: SEARCH_POKEMONS, variables: { ...defaultVariables, - name: "char", - types: ["Fire", "Flying"], - abilities: ["Blaze"], - moves: ["Flamethrower", "Fly"], + name: 'char', + types: ['Fire', 'Flying'], + abilities: ['Blaze'], + moves: ['Flamethrower', 'Fly'], }, }, result: { data: { searchPokemons: [ { - __typename: "PokemonSearchResult", - id: "charizard", - name: "Charizard", - type: "Fire, Flying", + __typename: 'PokemonSearchResult', + id: 'charizard', + name: 'Charizard', + type: 'Fire, Flying', gen: 1, - sprite: "charizard.png", + sprite: 'charizard.png', }, ], }, @@ -106,65 +92,60 @@ describe("SearchModal", () => { , ); - fireEvent.change(screen.getByRole("searchbox", { name: "Pokémon name" }), { - target: { value: "char" }, + fireEvent.change(screen.getByRole('searchbox', { name: 'Pokémon name' }), { + target: { value: 'char' }, }); - fireEvent.change(screen.getByRole("searchbox", { name: "Type filter 1" }), { - target: { value: "Fire" }, + fireEvent.change(screen.getByRole('searchbox', { name: 'Type filter 1' }), { + target: { value: 'Fire' }, }); - fireEvent.click(screen.getByRole("button", { name: "+ Add type" })); - fireEvent.change(screen.getByRole("searchbox", { name: "Type filter 2" }), { - target: { value: "Flying" }, + fireEvent.click(screen.getByRole('button', { name: '+ Add type' })); + fireEvent.change(screen.getByRole('searchbox', { name: 'Type filter 2' }), { + target: { value: 'Flying' }, }); - fireEvent.change( - screen.getByRole("searchbox", { name: "Ability filter 1" }), - { target: { value: "Blaze" } }, - ); + fireEvent.change(screen.getByRole('searchbox', { name: 'Ability filter 1' }), { + target: { value: 'Blaze' }, + }); - fireEvent.change(screen.getByRole("searchbox", { name: "Move filter 1" }), { - target: { value: "Flamethrower" }, + fireEvent.change(screen.getByRole('searchbox', { name: 'Move filter 1' }), { + target: { value: 'Flamethrower' }, }); - fireEvent.click(screen.getByRole("button", { name: "+ Add move" })); - fireEvent.change(screen.getByRole("searchbox", { name: "Move filter 2" }), { - target: { value: "Fly" }, + fireEvent.click(screen.getByRole('button', { name: '+ Add move' })); + fireEvent.change(screen.getByRole('searchbox', { name: 'Move filter 2' }), { + target: { value: 'Fly' }, }); - expect(await screen.findByText("Charizard")).toBeTruthy(); - expect(screen.getByText("Fire")).toBeTruthy(); - expect(screen.getByText("Flying")).toBeTruthy(); - expect(screen.getByText("Gen 1")).toBeTruthy(); - expect( - screen.getByRole("img", { name: "Charizard sprite" }), - ).toBeTruthy(); + expect(await screen.findByText('Charizard')).toBeTruthy(); + expect(screen.getByText('Fire')).toBeTruthy(); + expect(screen.getByText('Flying')).toBeTruthy(); + expect(screen.getByText('Gen 1')).toBeTruthy(); + expect(screen.getByRole('img', { name: 'Charizard sprite' })).toBeTruthy(); - fireEvent.click( - screen.getByRole("button", { name: "Add Charizard to team" }), - ); + fireEvent.click(screen.getByRole('button', { name: 'Add Charizard to team' })); expect(onAddPokemon).toHaveBeenCalledWith({ - __typename: "PokemonSearchResult", - id: "charizard", - name: "Charizard", - type: "Fire, Flying", + __typename: 'PokemonSearchResult', + id: 'charizard', + name: 'Charizard', + type: 'Fire, Flying', gen: 1, - sprite: "charizard.png", + sprite: 'charizard.png', }); await waitFor(() => { - expect(screen.queryByText("Searching Pokémon…")).toBeNull(); + expect(screen.queryByText('Searching Pokémon…')).toBeNull(); }); }); - it("switches a search card between Add and Remove as team membership changes", async () => { + it('switches a search card between Add and Remove as team membership changes', async () => { const mocks = [ { request: { query: SEARCH_POKEMONS, variables: { ...defaultVariables, - name: "pikachu", + name: 'pikachu', }, }, result: { @@ -179,33 +160,29 @@ describe("SearchModal", () => { , ); - fireEvent.change(screen.getByRole("searchbox", { name: "Pokémon name" }), { - target: { value: "pikachu" }, + fireEvent.change(screen.getByRole('searchbox', { name: 'Pokémon name' }), { + target: { value: 'pikachu' }, }); - fireEvent.click( - await screen.findByRole("button", { name: "Add Pokemon 1 to team" }), - ); + fireEvent.click(await screen.findByRole('button', { name: 'Add Pokemon 1 to team' })); fireEvent.click( - await screen.findByRole("button", { - name: "Remove Pokemon 1 from team", + await screen.findByRole('button', { + name: 'Remove Pokemon 1 from team', }), ); - expect( - await screen.findByRole("button", { name: "Add Pokemon 1 to team" }), - ).toBeTruthy(); + expect(await screen.findByRole('button', { name: 'Add Pokemon 1 to team' })).toBeTruthy(); }); - it("offers removal for Pokémon that are already on the team", async () => { + it('offers removal for Pokémon that are already on the team', async () => { const mocks = [ { request: { query: SEARCH_POKEMONS, variables: { ...defaultVariables, - name: "pikachu", + name: 'pikachu', }, }, result: { @@ -220,7 +197,7 @@ describe("SearchModal", () => { {}} onRemovePokemon={onRemovePokemon} onClose={() => {}} @@ -228,30 +205,28 @@ describe("SearchModal", () => { , ); - fireEvent.change(screen.getByRole("searchbox", { name: "Pokémon name" }), { - target: { value: "pikachu" }, + fireEvent.change(screen.getByRole('searchbox', { name: 'Pokémon name' }), { + target: { value: 'pikachu' }, }); fireEvent.click( - await screen.findByRole("button", { - name: "Remove Pokemon 1 from team", + await screen.findByRole('button', { + name: 'Remove Pokemon 1 from team', }), ); - expect(onRemovePokemon).toHaveBeenCalledWith("pokemon-1"); - expect( - screen.queryByRole("button", { name: "Add Pokemon 1 to team" }), - ).toBeNull(); + expect(onRemovePokemon).toHaveBeenCalledWith('pokemon-1'); + expect(screen.queryByRole('button', { name: 'Add Pokemon 1 to team' })).toBeNull(); }); - it("prevents adding a seventh Pokémon when the team is full", async () => { + it('prevents adding a seventh Pokémon when the team is full', async () => { const mocks = [ { request: { query: SEARCH_POKEMONS, variables: { ...defaultVariables, - name: "pikachu", + name: 'pikachu', }, }, result: { @@ -259,10 +234,7 @@ describe("SearchModal", () => { }, }, ]; - const fullTeamIds = Array.from( - { length: MAX_TEAM_SIZE }, - (_, index) => `team-member-${index}`, - ); + const fullTeamIds = Array.from({ length: MAX_TEAM_SIZE }, (_, index) => `team-member-${index}`); const onAddPokemon = vi.fn(); render( @@ -277,27 +249,27 @@ describe("SearchModal", () => { , ); - fireEvent.change(screen.getByRole("searchbox", { name: "Pokémon name" }), { - target: { value: "pikachu" }, + fireEvent.change(screen.getByRole('searchbox', { name: 'Pokémon name' }), { + target: { value: 'pikachu' }, }); - const addButton = await screen.findByRole("button", { - name: "Add Pokemon 1 to team", + const addButton = await screen.findByRole('button', { + name: 'Add Pokemon 1 to team', }); - expect(addButton.getAttribute("aria-disabled")).toBe("true"); - expect(addButton.hasAttribute("disabled")).toBe(false); + expect(addButton.getAttribute('aria-disabled')).toBe('true'); + expect(addButton.hasAttribute('disabled')).toBe(false); expect(screen.getByText(TEAM_FULL_MESSAGE)).toBeTruthy(); - const alertBefore = screen.getByRole("alert"); + const alertBefore = screen.getByRole('alert'); fireEvent.click(addButton); expect(onAddPokemon).not.toHaveBeenCalled(); - expect(screen.getByRole("alert")).not.toBe(alertBefore); + expect(screen.getByRole('alert')).not.toBe(alertBefore); expect(screen.getByText(TEAM_FULL_MESSAGE)).toBeTruthy(); }); - it("offers all four sorting options", () => { + it('offers all four sorting options', () => { render( { , ); - expect(screen.getByRole("option", { name: "Name (A–Z)" })).toBeTruthy(); - expect(screen.getByRole("option", { name: "Name (Z–A)" })).toBeTruthy(); - expect( - screen.getByRole("option", { name: "Pokédex number (ascending)" }), - ).toBeTruthy(); - expect( - screen.getByRole("option", { name: "Pokédex number (descending)" }), - ).toBeTruthy(); + expect(screen.getByRole('option', { name: 'Name (A–Z)' })).toBeTruthy(); + expect(screen.getByRole('option', { name: 'Name (Z–A)' })).toBeTruthy(); + expect(screen.getByRole('option', { name: 'Pokédex number (ascending)' })).toBeTruthy(); + expect(screen.getByRole('option', { name: 'Pokédex number (descending)' })).toBeTruthy(); }); - it("requests the next page and displays at most 20 results", async () => { - const firstPage = Array.from({ length: 21 }, (_, index) => - makePokemon(index + 1), - ); + it('requests the next page and displays at most 20 results', async () => { + const firstPage = Array.from({ length: 21 }, (_, index) => makePokemon(index + 1)); const secondPage = [makePokemon(22)]; @@ -333,7 +299,7 @@ describe("SearchModal", () => { query: SEARCH_POKEMONS, variables: { ...defaultVariables, - name: "pokemon", + name: 'pokemon', }, }, result: { @@ -345,7 +311,7 @@ describe("SearchModal", () => { query: SEARCH_POKEMONS, variables: { ...defaultVariables, - name: "pokemon", + name: 'pokemon', offset: 20, }, }, @@ -367,32 +333,30 @@ describe("SearchModal", () => { , ); - fireEvent.change(screen.getByRole("searchbox", { name: "Pokémon name" }), { - target: { value: "pokemon" }, + fireEvent.change(screen.getByRole('searchbox', { name: 'Pokémon name' }), { + target: { value: 'pokemon' }, }); - expect(await screen.findByText("Pokemon 1")).toBeTruthy(); - expect(screen.getByText("Pokemon 20")).toBeTruthy(); - expect(screen.queryByText("Pokemon 21")).toBeNull(); + expect(await screen.findByText('Pokemon 1')).toBeTruthy(); + expect(screen.getByText('Pokemon 20')).toBeTruthy(); + expect(screen.queryByText('Pokemon 21')).toBeNull(); - fireEvent.click(screen.getByRole("button", { name: "Next" })); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); - expect(await screen.findByText("Pokemon 22")).toBeTruthy(); - expect(screen.getByText("Page 2")).toBeTruthy(); + expect(await screen.findByText('Pokemon 22')).toBeTruthy(); + expect(screen.getByText('Page 2')).toBeTruthy(); - expect( - screen.getByRole("button", { name: "Previous" }).hasAttribute("disabled"), - ).toBe(false); + expect(screen.getByRole('button', { name: 'Previous' }).hasAttribute('disabled')).toBe(false); }); - it("sends the selected sorting option to GraphQL", async () => { + it('sends the selected sorting option to GraphQL', async () => { const mocks = [ { request: { query: SEARCH_POKEMONS, variables: { ...defaultVariables, - name: "pikachu", + name: 'pikachu', }, }, result: { @@ -404,9 +368,9 @@ describe("SearchModal", () => { query: SEARCH_POKEMONS, variables: { ...defaultVariables, - name: "pikachu", - sortBy: "name", - sortDirection: "DESC", + name: 'pikachu', + sortBy: 'name', + sortDirection: 'DESC', }, }, result: { @@ -427,25 +391,25 @@ describe("SearchModal", () => { , ); - fireEvent.change(screen.getByRole("searchbox", { name: "Pokémon name" }), { - target: { value: "pikachu" }, + fireEvent.change(screen.getByRole('searchbox', { name: 'Pokémon name' }), { + target: { value: 'pikachu' }, }); - expect(await screen.findByText("Pokemon 1")).toBeTruthy(); + expect(await screen.findByText('Pokemon 1')).toBeTruthy(); - const sortSelect = screen.getByRole("combobox", { - name: "Sort results", + const sortSelect = screen.getByRole('combobox', { + name: 'Sort results', }) as HTMLSelectElement; fireEvent.change(sortSelect, { - target: { value: "name-desc" }, + target: { value: 'name-desc' }, }); await waitFor(() => { - expect(sortSelect.value).toBe("name-desc"); + expect(sortSelect.value).toBe('name-desc'); expect(screen.queryByText(/Search failed:/)).toBeNull(); }); - expect(await screen.findByText("Pokemon 1")).toBeTruthy(); + expect(await screen.findByText('Pokemon 1')).toBeTruthy(); }); }); diff --git a/frontend/src/search/SearchModal.tsx b/frontend/src/search/SearchModal.tsx index bc1c676..fa9886f 100644 --- a/frontend/src/search/SearchModal.tsx +++ b/frontend/src/search/SearchModal.tsx @@ -1,9 +1,9 @@ -import { useQuery } from "@apollo/client/react"; -import { useEffect, useState } from "react"; -import PokemonCard from "../assets/components/pokemoncard/pokemoncard.tsx"; -import { MAX_TEAM_SIZE, TEAM_FULL_MESSAGE, type TeamPokemon } from "../team.ts"; -import "./search-modal.css"; -import { SEARCH_POKEMONS } from "./search"; +import { useQuery } from '@apollo/client/react'; +import { useEffect, useState } from 'react'; +import PokemonCard from '../assets/components/pokemoncard/pokemoncard.tsx'; +import { MAX_TEAM_SIZE, TEAM_FULL_MESSAGE, type TeamPokemon } from '../team.ts'; +import './search-modal.css'; +import { SEARCH_POKEMONS } from './search'; const PAGE_SIZE = 20; @@ -11,7 +11,7 @@ type SearchResponse = { searchPokemons: TeamPokemon[]; }; -type SortOption = "name-asc" | "name-desc" | "number-asc" | "number-desc"; +type SortOption = 'name-asc' | 'name-desc' | 'number-asc' | 'number-desc'; type SearchVariables = { format: string; @@ -21,8 +21,8 @@ type SearchVariables = { moves: string[]; limit: number; offset: number; - sortBy: "name" | "number"; - sortDirection: "ASC" | "DESC"; + sortBy: 'name' | 'number'; + sortDirection: 'ASC' | 'DESC'; }; type SearchModalProps = { @@ -40,20 +40,20 @@ function SearchModal({ onRemovePokemon, onClose, }: SearchModalProps) { - const [name, setName] = useState(""); - const [types, setTypes] = useState([""]); - const [abilities, setAbilities] = useState([""]); - const [moves, setMoves] = useState([""]); + const [name, setName] = useState(''); + const [types, setTypes] = useState(['']); + const [abilities, setAbilities] = useState(['']); + const [moves, setMoves] = useState(['']); const [debouncedSearch, setDebouncedSearch] = useState({ - name: "", - types: [""], - abilities: [""], - moves: [""], + name: '', + types: [''], + abilities: [''], + moves: [''], }); const [offset, setOffset] = useState(0); - const [sortOption, setSortOption] = useState("number-asc"); + const [sortOption, setSortOption] = useState('number-asc'); const [teamFullAttempt, setTeamFullAttempt] = useState(0); useEffect(() => { @@ -66,35 +66,28 @@ function SearchModal({ useEffect(() => { function handleKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") onClose(); + if (event.key === 'Escape') onClose(); } - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); const hasSearchTerm = - debouncedSearch.name.trim() !== "" || - [ - ...debouncedSearch.types, - ...debouncedSearch.abilities, - ...debouncedSearch.moves, - ].some((value) => value.trim() !== ""); + debouncedSearch.name.trim() !== '' || + [...debouncedSearch.types, ...debouncedSearch.abilities, ...debouncedSearch.moves].some( + (value) => value.trim() !== '', + ); - const [sortBy, direction] = sortOption.split("-") as [ - "name" | "number", - "asc" | "desc", - ]; + const [sortBy, direction] = sortOption.split('-') as ['name' | 'number', 'asc' | 'desc']; - const sortDirection = direction.toUpperCase() as "ASC" | "DESC"; + const sortDirection = direction.toUpperCase() as 'ASC' | 'DESC'; const variables: SearchVariables = { format, name: debouncedSearch.name.trim() || null, types: debouncedSearch.types.map((value) => value.trim()).filter(Boolean), - abilities: debouncedSearch.abilities - .map((value) => value.trim()) - .filter(Boolean), + abilities: debouncedSearch.abilities.map((value) => value.trim()).filter(Boolean), moves: debouncedSearch.moves.map((value) => value.trim()).filter(Boolean), limit: PAGE_SIZE + 1, offset, @@ -102,14 +95,11 @@ function SearchModal({ sortDirection, }; - const { data, loading, error } = useQuery( - SEARCH_POKEMONS, - { - variables, - skip: !format || !hasSearchTerm, - fetchPolicy: "network-only", - }, - ); + const { data, loading, error } = useQuery(SEARCH_POKEMONS, { + variables, + skip: !format || !hasSearchTerm, + fetchPolicy: 'network-only', + }); const results = data?.searchPokemons ?? []; const visibleResults = results.slice(0, PAGE_SIZE); @@ -164,8 +154,7 @@ function SearchModal({

Filters

- Every entered filter must match. Add multiple filters to narrow - results. + Every entered filter must match. Add multiple filters to narrow results.

@@ -224,16 +213,11 @@ function SearchModal({ {loading &&

Searching Pokémon…

} - {error && ( -

Search failed: {error.message}

- )} + {error &&

Search failed: {error.message}

} - {!loading && - !error && - hasSearchTerm && - visibleResults.length === 0 && ( -

No Pokémon match those criteria.

- )} + {!loading && !error && hasSearchTerm && visibleResults.length === 0 && ( +

No Pokémon match those criteria.

+ )} {visibleResults.length > 0 && (
@@ -244,16 +228,10 @@ function SearchModal({ onAddPokemon(pokemon) - } - onRemoveFromTeam={ - isInTeam ? () => onRemovePokemon(pokemon.id) : undefined - } + onAddToTeam={isInTeam ? undefined : () => onAddPokemon(pokemon)} + onRemoveFromTeam={isInTeam ? () => onRemovePokemon(pokemon.id) : undefined} addDisabled={teamFull} - onAddBlocked={() => - setTeamFullAttempt((attempt) => attempt + 1) - } + onAddBlocked={() => setTeamFullAttempt((attempt) => attempt + 1)} /> ); })} @@ -294,12 +272,7 @@ type FilterInputsProps = { onChange: (values: string[]) => void; }; -function FilterInputs({ - label, - placeholder, - values, - onChange, -}: FilterInputsProps) { +function FilterInputs({ label, placeholder, values, onChange }: FilterInputsProps) { const normalizedLabel = label.toLowerCase(); return ( @@ -325,9 +298,7 @@ function FilterInputs({ type="button" className="search-filter-remove" aria-label={`Remove ${normalizedLabel} filter ${index + 1}`} - onClick={() => - onChange(values.filter((_, itemIndex) => itemIndex !== index)) - } + onClick={() => onChange(values.filter((_, itemIndex) => itemIndex !== index))} > × @@ -335,11 +306,7 @@ function FilterInputs({
))} - diff --git a/frontend/src/search/search.ts b/frontend/src/search/search.ts index ad41964..6893159 100644 --- a/frontend/src/search/search.ts +++ b/frontend/src/search/search.ts @@ -1,4 +1,4 @@ -import { gql } from "@apollo/client"; +import { gql } from '@apollo/client'; export const SEARCH_POKEMONS = gql` query SearchPokemons( @@ -43,4 +43,4 @@ export const TYPE_CHART = gql` } } } -`; \ No newline at end of file +`; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 1ffef60..d32ff68 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,7 +1,4 @@ { "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ac7746e..eecc903 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,12 +1,12 @@ -import { createReadStream, statSync } from 'node:fs' -import type { ServerResponse } from 'node:http' -import { join, normalize } from 'node:path' -import { fileURLToPath } from 'node:url' -import react, { reactCompilerPreset } from '@vitejs/plugin-react' -import babel from '@rolldown/plugin-babel' -import { defineConfig, type Plugin } from 'vite' +import { createReadStream, statSync } from 'node:fs'; +import type { ServerResponse } from 'node:http'; +import { join, normalize } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import react, { reactCompilerPreset } from '@vitejs/plugin-react'; +import babel from '@rolldown/plugin-babel'; +import { defineConfig, type Plugin } from 'vite'; -const SPRITES_DIR = fileURLToPath(new URL('../backend/sprites/', import.meta.url)) +const SPRITES_DIR = fileURLToPath(new URL('../backend/sprites/', import.meta.url)); // Serves the sprites downloaded by `npm run import` in backend/ during development. // In production Apache serves the same files from /project2/sprites/. @@ -15,56 +15,52 @@ function serveSprites(): Plugin { name: 'serve-sprites', configureServer(server) { const notFound = (res: ServerResponse) => { - res.statusCode = 404 - res.end('Not found') - } + res.statusCode = 404; + res.end('Not found'); + }; server.middlewares.use(`${server.config.base}sprites`, (req, res) => { - let requestPath: string + let requestPath: string; try { - requestPath = decodeURIComponent((req.url ?? '').split('?')[0]).replace(/^\/+/, '') + requestPath = decodeURIComponent((req.url ?? '').split('?')[0]).replace(/^\/+/, ''); } catch { - notFound(res) - return + notFound(res); + return; } const fileName = requestPath.startsWith('sprites/') ? requestPath.slice('sprites/'.length) - : requestPath + : requestPath; if (!fileName.endsWith('.png')) { - notFound(res) - return + notFound(res); + return; } - const filePath = join(SPRITES_DIR, normalize(fileName)) + const filePath = join(SPRITES_DIR, normalize(fileName)); try { if (!filePath.startsWith(SPRITES_DIR) || !statSync(filePath).isFile()) { - notFound(res) - return + notFound(res); + return; } } catch { - notFound(res) - return + notFound(res); + return; } - res.setHeader('Content-Type', 'image/png') - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') - createReadStream(filePath).pipe(res) - }) + res.setHeader('Content-Type', 'image/png'); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + createReadStream(filePath).pipe(res); + }); }, - } + }; } // https://vite.dev/config/ export default defineConfig({ base: '/project2/', - plugins: [ - react(), - babel({ presets: [reactCompilerPreset()] }), - serveSprites(), - ], + plugins: [react(), babel({ presets: [reactCompilerPreset()] }), serveSprites()], server: { proxy: { '/project2/graphql': { @@ -73,4 +69,4 @@ export default defineConfig({ }, }, }, -}) +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 32cb081..81a9905 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,10 +1,10 @@ -import react from "@vitejs/plugin-react"; -import { defineConfig } from "vitest/config"; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [react()], test: { - environment: "jsdom", - include: ["src/**/*.test.{ts,tsx}"], + environment: 'jsdom', + include: ['src/**/*.test.{ts,tsx}'], }, });
1 ? "weak" : "normal"}`} + className={`matchup-${multiplier === 0 ? 'immune' : multiplier < 1 ? 'resist' : multiplier > 1 ? 'weak' : 'normal'}`} > {formatMultiplier(multiplier)}