diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index eb9a5f1..585cb5f 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -109,4 +109,42 @@ CREATE TABLE IF NOT EXISTS users ( COLLATE ascii_general_ci NOT NULL, UNIQUE INDEX idx_users_username (username) +); + +-- Saved teams created by users. +CREATE TABLE IF NOT EXISTS saved_teams ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + name VARCHAR(100) NOT NULL, + format VARCHAR(50) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_saved_teams_user_id (user_id), + + FOREIGN KEY (user_id) + REFERENCES users(id) + ON DELETE CASCADE +); + +-- Pokémon belonging to saved teams, with their original order. +CREATE TABLE IF NOT EXISTS saved_team_pokemon ( + team_id INT NOT NULL, + pokemon_id VARCHAR(50) NOT NULL, + position TINYINT UNSIGNED NOT NULL, + + PRIMARY KEY (team_id, pokemon_id), + UNIQUE KEY uq_saved_team_position (team_id, position), + + CONSTRAINT chk_saved_team_position + CHECK (position BETWEEN 1 AND 12), + + FOREIGN KEY (team_id) + REFERENCES saved_teams(id) + ON DELETE CASCADE, + + FOREIGN KEY (pokemon_id) + REFERENCES pokemon(id) + ON DELETE RESTRICT ); \ No newline at end of file diff --git a/backend/src/graphql/resolvers.ts b/backend/src/graphql/resolvers.ts index a9d8e7c..6405ef6 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; @@ -40,6 +40,49 @@ type UserRow = { username: string; }; +type SavedTeamPokemon = { + id: string; + name: string; + type: string; + gen: number; + sprite: string | null; + position: number; +}; + +type SavedTeam = { + id: string; + name: string; + format: string; + updatedAt: string; + pokemon: SavedTeamPokemon[]; +}; + +type SavedTeamRow = { + id: number; + name: string; + format: string; + updatedAt: Date | string; + pokemonId: string | null; + pokemonName: string | null; + type: string | null; + gen: number | null; + sprite: string | null; + position: number | null; +}; + +type SaveTeamArgs = { + userId: string; + id?: string | null; + name: string; + format: string; + pokemonIds: string[]; +}; + +type DeleteSavedTeamArgs = { + id: string; + userId: string; +}; + function formatUser(row: UserRow) { return { id: String(row.id), @@ -49,22 +92,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( @@ -75,13 +115,89 @@ 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); +} + +function parsePositiveId(value: string, fieldName: string): number { + const id = Number(value); + + if (!Number.isSafeInteger(id) || id <= 0) { + throw new GraphQLError(`${fieldName} must be a valid positive ID`, { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + return id; +} + +function formatSavedTeam(row: SavedTeamRow): SavedTeam { + return { + id: String(row.id), + name: row.name, + format: row.format, + updatedAt: + row.updatedAt instanceof Date + ? row.updatedAt.toISOString() + : new Date(row.updatedAt).toISOString(), + pokemon: [], + }; +} + +async function getSavedTeam(teamId: number, userId: number): Promise { + const rows = (await pool.execute( + `SELECT + st.id, + st.name, + st.format, + st.updated_at AS updatedAt, + p.id AS pokemonId, + p.name AS pokemonName, + COALESCE( + GROUP_CONCAT(DISTINCT t.name ORDER BY pt.slot SEPARATOR ', '), + '' + ) AS type, + p.gen, + p.sprite, + stp.position + FROM saved_teams st + LEFT JOIN saved_team_pokemon stp ON stp.team_id = st.id + LEFT JOIN pokemon p ON p.id = stp.pokemon_id + LEFT JOIN pokemon_types pt ON pt.pokemon_id = p.id + LEFT JOIN types t ON t.id = pt.type_id + WHERE st.id = ? AND st.user_id = ? + GROUP BY + st.id, st.name, st.format, st.updated_at, + p.id, p.name, p.gen, p.sprite, stp.position + ORDER BY stp.position ASC`, + [teamId, userId], + )) as SavedTeamRow[]; + + if (rows.length === 0) return null; + + const team = formatSavedTeam(rows[0]); + + team.pokemon = rows + .filter( + (row) => + row.pokemonId !== null && + row.pokemonName !== null && + row.type !== null && + row.gen !== null && + row.position !== null, + ) + .map((row) => ({ + id: row.pokemonId!, + name: row.pokemonName!, + type: row.type!, + gen: row.gen!, + sprite: row.sprite, + position: row.position!, + })); + + return team; } export const resolvers = { @@ -140,12 +256,10 @@ export const resolvers = { if (uniqueIds.length === 0) return []; if (uniqueIds.length > 100) { - throw new Error( - "At most 100 Pokémon IDs can be requested at once", - ); + throw new Error('At most 100 Pokémon IDs can be requested at once'); } - const placeholders = uniqueIds.map(() => "?").join(", "); + const placeholders = uniqueIds.map(() => '?').join(', '); const rows = (await pool.execute( `SELECT @@ -185,10 +299,7 @@ export const resolvers = { ); }, - moveLearners: async ( - _: unknown, - { format, pokemonIds, moveIds }: MoveLearnersArgs, - ) => { + moveLearners: async (_: unknown, { format, pokemonIds, moveIds }: MoveLearnersArgs) => { const generation = generationForFormat(format); const uniquePokemonIds = [...new Set(pokemonIds)]; const uniqueMoveIds = [...new Set(moveIds)]; @@ -201,13 +312,11 @@ export const resolvers = { } if (uniquePokemonIds.length > 100 || uniqueMoveIds.length > 100) { - throw new Error( - "At most 100 Pokémon IDs and 100 move IDs can be requested at once", - ); + throw new Error('At most 100 Pokémon IDs and 100 move IDs can be requested at once'); } - const pokemonPlaceholders = uniquePokemonIds.map(() => "?").join(", "); - const movePlaceholders = uniqueMoveIds.map(() => "?").join(", "); + const pokemonPlaceholders = uniquePokemonIds.map(() => '?').join(', '); + const movePlaceholders = uniqueMoveIds.map(() => '?').join(', '); const rows = (await pool.execute( `SELECT @@ -243,7 +352,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, @@ -266,7 +375,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()) { @@ -321,19 +430,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`; @@ -351,7 +460,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 ?`, @@ -392,6 +501,68 @@ export const resolvers = { return rows[0] ? formatUser(rows[0]) : null; }, + + savedTeams: async (_: unknown, { userId }: { userId: string }) => { + const parsedUserId = parsePositiveId(userId, 'User ID'); + + const rows = (await pool.execute( + `SELECT + st.id, + st.name, + st.format, + st.updated_at AS updatedAt, + p.id AS pokemonId, + p.name AS pokemonName, + COALESCE( + GROUP_CONCAT(DISTINCT t.name ORDER BY pt.slot SEPARATOR ', '), + '' + ) AS type, + p.gen, + p.sprite, + stp.position + FROM saved_teams st + LEFT JOIN saved_team_pokemon stp ON stp.team_id = st.id + LEFT JOIN pokemon p ON p.id = stp.pokemon_id + LEFT JOIN pokemon_types pt ON pt.pokemon_id = p.id + LEFT JOIN types t ON t.id = pt.type_id + WHERE st.user_id = ? + GROUP BY + st.id, st.name, st.format, st.updated_at, + p.id, p.name, p.gen, p.sprite, stp.position + ORDER BY st.updated_at DESC, st.id DESC, stp.position ASC`, + [parsedUserId], + )) as SavedTeamRow[]; + + const teams = new Map(); + + for (const row of rows) { + let team = teams.get(row.id); + + if (!team) { + team = formatSavedTeam(row); + teams.set(row.id, team); + } + + if ( + row.pokemonId !== null && + row.pokemonName !== null && + row.type !== null && + row.gen !== null && + row.position !== null + ) { + team.pokemon.push({ + id: row.pokemonId, + name: row.pokemonName, + type: row.type, + gen: row.gen, + sprite: row.sprite, + position: row.position, + }); + } + } + + return Array.from(teams.values()); + }, }, Mutation: { @@ -400,16 +571,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), @@ -417,14 +587,170 @@ 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; + } + }, + + saveTeam: async (_: unknown, args: SaveTeamArgs) => { + const userId = parsePositiveId(args.userId, 'User ID'); + const teamId = args.id == null ? null : parsePositiveId(args.id, 'Team ID'); + const name = args.name.trim(); + const format = args.format.trim(); + const pokemonIds = args.pokemonIds; + + if (!name || name.length > 100) { + throw new GraphQLError('Team name must contain between 1 and 100 characters.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + if (!format || format.length > 50) { + throw new GraphQLError('Invalid team format.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + try { + generationForFormat(format); + } catch { + throw new GraphQLError('Unsupported Pokémon format.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + if (pokemonIds.length > 12) { + throw new GraphQLError('A team cannot contain more than 12 Pokémon.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + if (new Set(pokemonIds).size !== pokemonIds.length) { + throw new GraphQLError('A team cannot contain the same Pokémon more than once.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + + const users = (await connection.execute('SELECT id FROM users WHERE id = ?', [userId])) as { + id: number; + }[]; + + if (users.length === 0) { + throw new GraphQLError('User not found.', { + extensions: { code: 'NOT_FOUND' }, + }); + } + + if (teamId !== null) { + const existingTeams = (await connection.execute( + `SELECT id + FROM saved_teams + WHERE id = ? AND user_id = ?`, + [teamId, userId], + )) as { id: number }[]; + + if (existingTeams.length === 0) { + throw new GraphQLError('Saved team not found.', { + extensions: { code: 'NOT_FOUND' }, + }); + } + + await connection.execute( + `UPDATE saved_teams + SET name = ?, format = ? + WHERE id = ? AND user_id = ?`, + [name, format, teamId, userId], ); + + await connection.execute('DELETE FROM saved_team_pokemon WHERE team_id = ?', [teamId]); + } + + let savedTeamId = teamId; + + if (savedTeamId === null) { + const result = (await connection.execute( + `INSERT INTO saved_teams (user_id, name, format) + VALUES (?, ?, ?)`, + [userId, name, format], + )) as { insertId: number }; + + savedTeamId = Number(result.insertId); + } + + if (pokemonIds.length > 0) { + const placeholders = pokemonIds.map(() => '?').join(', '); + + const existingPokemon = (await connection.execute( + `SELECT id + FROM pokemon + WHERE id IN (${placeholders})`, + pokemonIds, + )) as { id: string }[]; + + if (existingPokemon.length !== pokemonIds.length) { + throw new GraphQLError('One or more Pokémon could not be found.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + for (const [index, pokemonId] of pokemonIds.entries()) { + await connection.execute( + `INSERT INTO saved_team_pokemon + (team_id, pokemon_id, position) + VALUES (?, ?, ?)`, + [savedTeamId, pokemonId, index + 1], + ); + } + } + + await connection.commit(); + connection.release(); + + const savedTeam = await getSavedTeam(savedTeamId, userId); + + if (!savedTeam) { + throw new Error('Could not retrieve the saved team after saving.'); + } + + return savedTeam; + } catch (error) { + await connection.rollback(); + connection.release(); + + if (error instanceof GraphQLError) { + throw error; + } + + if (isDuplicateEntryError(error)) { + throw new GraphQLError('A Pokémon cannot appear more than once in a team.', { + extensions: { code: 'BAD_USER_INPUT' }, + }); } throw error; } }, + + deleteSavedTeam: async (_: unknown, { id, userId }: DeleteSavedTeamArgs) => { + const teamId = parsePositiveId(id, 'Team ID'); + const parsedUserId = parsePositiveId(userId, 'User ID'); + + const result = (await pool.execute( + `DELETE FROM saved_teams + WHERE id = ? AND user_id = ?`, + [teamId, parsedUserId], + )) as { affectedRows: number }; + + return result.affectedRows > 0; + }, }, }; diff --git a/backend/src/graphql/savedTeams.test.ts b/backend/src/graphql/savedTeams.test.ts new file mode 100644 index 0000000..ad63c8e --- /dev/null +++ b/backend/src/graphql/savedTeams.test.ts @@ -0,0 +1,324 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, describe, expect, it } from 'vitest'; +import { pool } from '../db/connection.js'; + +const endpoint = process.env.GRAPHQL_TEST_URL ?? 'http://localhost:3001/'; + +type GraphQLResponse = { + data?: T; + errors?: { + message: string; + extensions?: { + code?: string; + }; + }[]; +}; + +type User = { + id: string; + username: string; +}; + +type SavedTeam = { + id: string; + name: string; + format: string; + updatedAt: string; + pokemon: { + id: string; + name: string; + type: string; + gen: number; + sprite: string | null; + position: number; + }[]; +}; + +async function sendQuery(query: string, variables?: Record) { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }); + + const body = (await response.json()) as GraphQLResponse; + + return { response, body }; +} + +async function createTestUser(): Promise { + const username = `teamtest_${randomUUID().replace(/-/g, '').slice(0, 10)}`; + + const { body } = await sendQuery<{ createUser: User }>( + ` + mutation CreateUser($username: String!) { + createUser(username: $username) { + id + username + } + } + `, + { username }, + ); + + if (!body.data?.createUser) { + throw new Error(`Could not create test user: ${body.errors?.[0]?.message}`); + } + + return body.data.createUser; +} + +async function deleteTestUser(userId: string) { + await pool.execute('DELETE FROM users WHERE id = ?', [userId]); +} + +async function saveTeam(userId: string, name: string, pokemonIds: string[], id?: string) { + return sendQuery<{ saveTeam: SavedTeam }>( + ` + mutation SaveTeam( + $userId: ID! + $id: ID + $name: String! + $format: String! + $pokemonIds: [ID!]! + ) { + saveTeam( + userId: $userId + id: $id + name: $name + format: $format + pokemonIds: $pokemonIds + ) { + id + name + format + updatedAt + pokemon { + id + name + type + gen + sprite + position + } + } + } + `, + { + userId, + id, + name, + format: 'gen9', + pokemonIds, + }, + ); +} + +async function getSavedTeams(userId: string) { + return sendQuery<{ savedTeams: SavedTeam[] }>( + ` + query SavedTeams($userId: ID!) { + savedTeams(userId: $userId) { + id + name + format + updatedAt + pokemon { + id + name + type + gen + sprite + position + } + } + } + `, + { userId }, + ); +} + +async function deleteSavedTeam(teamId: string, userId: string) { + return sendQuery<{ deleteSavedTeam: boolean }>( + ` + mutation DeleteSavedTeam($id: ID!, $userId: ID!) { + deleteSavedTeam(id: $id, userId: $userId) + } + `, + { id: teamId, userId }, + ); +} + +describe('Saved teams API integration', () => { + afterAll(async () => { + // Ensure the shared connection pool remains available to other tests. + }); + + it('saves and retrieves a team with its Pokémon in the correct order', async () => { + const user = await createTestUser(); + + try { + const pokemonIds = ['pikachu', 'charizard', 'bulbasaur']; + + const { response, body } = await saveTeam(user.id, 'Integration Test Team', pokemonIds); + + expect(response.status).toBe(200); + expect(body.errors).toBeUndefined(); + + const savedTeam = body.data!.saveTeam; + + expect(savedTeam.name).toBe('Integration Test Team'); + expect(savedTeam.format).toBe('gen9'); + expect(savedTeam.pokemon.map((pokemon) => pokemon.id)).toEqual(pokemonIds); + expect(savedTeam.pokemon.map((pokemon) => pokemon.position)).toEqual([1, 2, 3]); + + const retrieved = await getSavedTeams(user.id); + + expect(retrieved.body.errors).toBeUndefined(); + expect(retrieved.body.data!.savedTeams).toHaveLength(1); + expect(retrieved.body.data!.savedTeams[0]).toMatchObject({ + id: savedTeam.id, + name: 'Integration Test Team', + format: 'gen9', + }); + expect(retrieved.body.data!.savedTeams[0].pokemon.map((pokemon) => pokemon.id)).toEqual( + pokemonIds, + ); + } finally { + await deleteTestUser(user.id); + } + }); + + it('updates an existing team without creating a duplicate', async () => { + const user = await createTestUser(); + + try { + const initial = await saveTeam(user.id, 'Team Before Update', ['pikachu', 'charizard']); + + expect(initial.body.errors).toBeUndefined(); + + const teamId = initial.body.data!.saveTeam.id; + + const updated = await saveTeam( + user.id, + 'Team After Update', + ['bulbasaur', 'pikachu'], + teamId, + ); + + expect(updated.body.errors).toBeUndefined(); + expect(updated.body.data!.saveTeam.id).toBe(teamId); + expect(updated.body.data!.saveTeam.name).toBe('Team After Update'); + expect(updated.body.data!.saveTeam.pokemon.map((pokemon) => pokemon.id)).toEqual([ + 'bulbasaur', + 'pikachu', + ]); + + const retrieved = await getSavedTeams(user.id); + + expect(retrieved.body.errors).toBeUndefined(); + expect(retrieved.body.data!.savedTeams).toHaveLength(1); + } finally { + await deleteTestUser(user.id); + } + }); + + it('deletes a saved team', async () => { + const user = await createTestUser(); + + try { + const created = await saveTeam(user.id, 'Team To Delete', ['pikachu']); + + expect(created.body.errors).toBeUndefined(); + + const teamId = created.body.data!.saveTeam.id; + + const deleted = await deleteSavedTeam(teamId, user.id); + + expect(deleted.body.errors).toBeUndefined(); + expect(deleted.body.data!.deleteSavedTeam).toBe(true); + + const retrieved = await getSavedTeams(user.id); + + expect(retrieved.body.errors).toBeUndefined(); + expect(retrieved.body.data!.savedTeams).toHaveLength(0); + } finally { + await deleteTestUser(user.id); + } + }); + + it('rejects duplicate Pokémon in a team', async () => { + const user = await createTestUser(); + + try { + const { body } = await saveTeam(user.id, 'Duplicate Pokémon Team', ['pikachu', 'pikachu']); + + expect(body.errors).toBeDefined(); + expect(body.errors![0].message).toContain('same Pokémon more than once'); + } finally { + await deleteTestUser(user.id); + } + }); + + it('rejects teams containing more than 12 Pokémon', async () => { + const user = await createTestUser(); + + try { + const rows = (await pool.query('SELECT id FROM pokemon ORDER BY num LIMIT 13')) as { + id: string; + }[]; + + expect(rows).toHaveLength(13); + + const { body } = await saveTeam( + user.id, + 'Too Many Pokémon', + rows.map((pokemon) => pokemon.id), + ); + + expect(body.errors).toBeDefined(); + expect(body.errors![0].message).toContain('cannot contain more than 12'); + } finally { + await deleteTestUser(user.id); + } + }); + + it("prevents users from updating or deleting another user's team", async () => { + const owner = await createTestUser(); + const otherUser = await createTestUser(); + + try { + const created = await saveTeam(owner.id, 'Private Team', ['pikachu', 'charizard']); + + expect(created.body.errors).toBeUndefined(); + + const teamId = created.body.data!.saveTeam.id; + + const update = await saveTeam(otherUser.id, 'Attempted Update', ['bulbasaur'], teamId); + + expect(update.body.errors).toBeDefined(); + expect(update.body.errors![0].message).toBe('Saved team not found.'); + + const deletion = await deleteSavedTeam(teamId, otherUser.id); + + expect(deletion.body.errors).toBeUndefined(); + expect(deletion.body.data!.deleteSavedTeam).toBe(false); + + const ownerTeams = await getSavedTeams(owner.id); + + expect(ownerTeams.body.errors).toBeUndefined(); + expect(ownerTeams.body.data!.savedTeams).toHaveLength(1); + expect(ownerTeams.body.data!.savedTeams[0].name).toBe('Private Team'); + + const otherUserTeams = await getSavedTeams(otherUser.id); + + expect(otherUserTeams.body.errors).toBeUndefined(); + expect(otherUserTeams.body.data!.savedTeams).toHaveLength(0); + } finally { + await deleteTestUser(owner.id); + await deleteTestUser(otherUser.id); + } + }); +}); diff --git a/backend/src/graphql/schema.ts b/backend/src/graphql/schema.ts index 29988b5..7bd448f 100644 --- a/backend/src/graphql/schema.ts +++ b/backend/src/graphql/schema.ts @@ -54,6 +54,23 @@ export const typeDefs = `#graphql username: String! } + type SavedTeamPokemon { + id: ID! + name: String! + type: String! + gen: Int! + sprite: String + position: Int! + } + + type SavedTeam { + id: ID! + name: String! + format: String! + updatedAt: String! + pokemon: [SavedTeamPokemon!]! + } + type Query { pokemons: [Pokemon!]! pokemon(id: ID!): Pokemon @@ -79,9 +96,18 @@ export const typeDefs = `#graphql users: [User!]! user(username: String!): User userById(id: ID!): User + savedTeams(userId: ID!): [SavedTeam!]! } type Mutation { createUser(username: String!): User! + saveTeam( + userId: ID! + id: ID + name: String! + format: String! + pokemonIds: [ID!]! + ): SavedTeam! + deleteSavedTeam(id: ID!, userId: ID!): Boolean! } -`; \ No newline at end of file +`; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f07edc3..be90486 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,23 +1,64 @@ -import { MockedProvider } from "@apollo/client/testing/react"; +import { MockedProvider } from '@apollo/client/testing/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import App from './App.tsx'; +import { AUTH_STORAGE_KEY, AuthProvider } from './auth/AuthContext.tsx'; +import { USER_BY_ID } from './graphql/users.ts'; import { - cleanup, - fireEvent, - render, - screen, - waitFor, - within, -} from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import App from "./App.tsx"; -import { AUTH_STORAGE_KEY, AuthProvider } from "./auth/AuthContext.tsx"; -import { USER_BY_ID } from "./graphql/users.ts"; -import { loadSavedTeams } from "./savedTeams.ts"; -import { SEARCH_POKEMONS, TYPE_CHART } from "./search/search.ts"; -import { POKEMON_STATS } from "./stats.ts"; -import { MOVES } from "./moves.ts"; - -const USER_ID = "user-1"; + DELETE_SAVED_TEAM_MUTATION, + SAVE_TEAM_MUTATION, + SAVED_TEAMS_QUERY, + type SavedTeam, +} from './savedTeams.ts'; +import { SEARCH_POKEMONS, TYPE_CHART } from './search/search.ts'; +import { POKEMON_STATS } from './stats.ts'; +import { MOVES } from './moves.ts'; +import type { MockLink } from '@apollo/client/testing'; + +const USER_ID = 'user-1'; + +const savedTeam: SavedTeam = { + id: '1', + name: 'Kanto Classics', + format: 'gen9', + updatedAt: '2026-11-03T12:00:00.000Z', + pokemon: [ + { + id: 'pikachu', + name: 'Pikachu', + type: 'Electric', + gen: 1, + sprite: 'pikachu.png', + position: 1, + }, + ], +}; + +function savedTeamResult(team: SavedTeam) { + return { + __typename: 'SavedTeam', + ...team, + pokemon: team.pokemon.map((pokemon) => ({ + __typename: 'SavedTeamPokemon', + ...pokemon, + })), + }; +} + +function savedTeamsQueryMock(teams: SavedTeam[] = []) { + return { + request: { + query: SAVED_TEAMS_QUERY, + variables: { userId: USER_ID }, + }, + result: { + data: { + savedTeams: teams.map(savedTeamResult), + }, + }, + }; +} afterEach(cleanup); @@ -34,9 +75,9 @@ const userMock = { result: { data: { userById: { - __typename: "User", + __typename: 'User', id: USER_ID, - username: "ash", + username: 'ash', }, }, }, @@ -58,27 +99,27 @@ const searchMock = { request: { query: SEARCH_POKEMONS, variables: { - format: "gen9", - name: "pikachu", + format: 'gen9', + name: 'pikachu', types: [], abilities: [], moves: [], limit: 21, offset: 0, - sortBy: "number", - sortDirection: "ASC", + sortBy: 'number', + sortDirection: 'ASC', }, }, result: { data: { searchPokemons: [ { - __typename: "PokemonSearchResult", - id: "pikachu", - name: "Pikachu", - type: "Electric", + __typename: 'PokemonSearchResult', + id: 'pikachu', + name: 'Pikachu', + type: 'Electric', gen: 1, - sprite: "pikachu.png", + sprite: 'pikachu.png', }, ], }, @@ -88,15 +129,15 @@ const searchMock = { const statsMock = { request: { query: POKEMON_STATS, - variables: { ids: ["pikachu"] }, + variables: { ids: ['pikachu'] }, }, result: { data: { pokemonStats: [ { - __typename: "PokemonStats", - id: "pikachu", - name: "Pikachu", + __typename: 'PokemonStats', + id: 'pikachu', + name: 'Pikachu', hp: 35, atk: 55, def: 40, @@ -113,7 +154,7 @@ const statsMock = { const movesMock = { request: { query: MOVES, - variables: { format: "gen9" }, + variables: { format: 'gen9' }, }, result: { data: { @@ -122,14 +163,22 @@ const movesMock = { }, }; -function renderApp() { +function renderApp(extraMocks: MockLink.MockedResponse[] = []) { window.localStorage.setItem(AUTH_STORAGE_KEY, USER_ID); render( - + @@ -139,153 +188,203 @@ function renderApp() { } async function addPikachuToTeam() { - fireEvent.click(screen.getByRole("button", { name: "Search" })); + fireEvent.click(screen.getByRole('button', { name: 'Search' })); - 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 Pikachu to team" }), - ); + fireEvent.click(await screen.findByRole('button', { name: 'Add Pikachu to team' })); - fireEvent.click(screen.getByRole("button", { name: "Close search" })); + fireEvent.click(screen.getByRole('button', { name: 'Close search' })); } -describe("App format selector", () => { - it("defaults to SV and has no placeholder option", async () => { +describe('App format selector', () => { + it('defaults to SV and has no placeholder option', async () => { renderApp(); - // Wait for the type chart query so state updates settle before asserting. - await screen.findByText( - "Add Pokémon to your team to see their type matchups.", - ); + await screen.findByText('Add Pokémon to your team to see their type matchups.'); - const selector = screen.getByRole("combobox", { - name: "Format", + const selector = screen.getByRole('combobox', { + name: 'Format', }) as HTMLSelectElement; - expect(selector.value).toBe("gen9"); - expect( - within(selector).queryByRole("option", { name: "Select format" }), - ).toBeNull(); + expect(selector.value).toBe('gen9'); + expect(within(selector).queryByRole('option', { name: 'Select format' })).toBeNull(); expect( - (within(selector).getByRole("option", { name: "SV" }) as HTMLOptionElement) - .selected, + ( + within(selector).getByRole('option', { + name: 'SV', + }) as HTMLOptionElement + ).selected, ).toBe(true); }); - it("falls back to SV when the stored format is invalid", async () => { - window.localStorage.setItem("pokemon-draft-planner-format", "invalid"); + it('falls back to SV when the stored format is invalid', async () => { + window.localStorage.setItem(`pokemon-draft-planner-format-${USER_ID}`, 'invalid'); + renderApp(); - await screen.findByText( - "Add Pokémon to your team to see their type matchups.", - ); + await screen.findByText('Add Pokémon to your team to see their type matchups.'); - const selector = screen.getByRole("combobox", { - name: "Format", + const selector = screen.getByRole('combobox', { + name: 'Format', }) as HTMLSelectElement; - expect(selector.value).toBe("gen9"); + expect(selector.value).toBe('gen9'); }); }); -describe("Saving the active team", () => { - it("prompts for a name the first time and updates the saved team later", async () => { - renderApp(); +describe('Saving the active team', () => { + it('prompts for a name the first time and updates the saved team later', async () => { + const updatedTeam: SavedTeam = { + ...savedTeam, + name: 'Kanto Classics Updated', + }; + + renderApp([ + { + request: { + query: SAVE_TEAM_MUTATION, + variables: { + userId: USER_ID, + name: 'Kanto Classics', + format: 'gen9', + pokemonIds: ['pikachu'], + }, + }, + result: { + data: { + saveTeam: savedTeamResult(savedTeam), + }, + }, + }, + savedTeamsQueryMock([savedTeam]), + { + request: { + query: SAVE_TEAM_MUTATION, + variables: { + userId: USER_ID, + id: savedTeam.id, + name: savedTeam.name, + format: 'gen9', + pokemonIds: ['pikachu'], + }, + }, + result: { + data: { + saveTeam: savedTeamResult(updatedTeam), + }, + }, + }, + savedTeamsQueryMock([updatedTeam]), + ]); - await screen.findByText( - "Add Pokémon to your team to see their type matchups.", - ); + await screen.findByText('Add Pokémon to your team to see their type matchups.'); await addPikachuToTeam(); - fireEvent.click(screen.getByRole("button", { name: "Save team" })); + fireEvent.click(screen.getByRole('button', { name: 'Save team' })); - const nameInput = await screen.findByRole("textbox", { name: "Team name" }); - fireEvent.change(nameInput, { target: { value: "Kanto Classics" } }); - fireEvent.click(screen.getByRole("button", { name: "Save" })); + const nameInput = await screen.findByRole('textbox', { name: 'Team name' }); + fireEvent.change(nameInput, { target: { value: 'Kanto Classics' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => { - expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.queryByRole('dialog')).toBeNull(); }); - expect(screen.getByText("Saved: Kanto Classics")).toBeTruthy(); - expect(screen.getByText("Team saved as “Kanto Classics”.")).toBeTruthy(); + expect(screen.getByText('Saved: Kanto Classics')).toBeTruthy(); + expect(screen.getByText('Team saved as “Kanto Classics”.')).toBeTruthy(); - const savedTeams = loadSavedTeams(USER_ID); - expect(savedTeams).toHaveLength(1); - expect(savedTeams[0]).toMatchObject({ - name: "Kanto Classics", - format: "gen9", - pokemon: [{ id: "pikachu", name: "Pikachu" }], - }); + fireEvent.click(screen.getByRole('button', { name: 'Save team' })); - fireEvent.click(screen.getByRole("button", { name: "Save team" })); + await waitFor(() => { + expect(screen.getByText('Team saved as “Kanto Classics Updated”.')).toBeTruthy(); + }); - expect(screen.queryByRole("dialog")).toBeNull(); - expect(loadSavedTeams(USER_ID)).toHaveLength(1); + expect(screen.queryByRole('dialog')).toBeNull(); }); - it("disables saving when the active team is empty", async () => { + it('disables saving when the active team is empty', async () => { renderApp(); - await screen.findByText( - "Add Pokémon to your team to see their type matchups.", - ); + await screen.findByText('Add Pokémon to your team to see their type matchups.'); - const saveButton = screen.getByRole("button", { name: "Save team" }); + const saveButton = screen.getByRole('button', { name: 'Save team' }); - expect(saveButton.hasAttribute("disabled")).toBe(true); + expect(saveButton.hasAttribute('disabled')).toBe(true); }); }); -describe("Teams page", () => { - it("lists a saved team, loads it as active, and deletes it with confirmation", async () => { - renderApp(); +describe('Teams page', () => { + it('lists a saved team, loads it as active, and deletes it with confirmation', async () => { + renderApp([ + { + request: { + query: SAVE_TEAM_MUTATION, + variables: { + userId: USER_ID, + name: 'Kanto Classics', + format: 'gen9', + pokemonIds: ['pikachu'], + }, + }, + result: { + data: { + saveTeam: savedTeamResult(savedTeam), + }, + }, + }, + savedTeamsQueryMock([savedTeam]), + { + request: { + query: DELETE_SAVED_TEAM_MUTATION, + variables: { + id: savedTeam.id, + userId: USER_ID, + }, + }, + result: { + data: { + deleteSavedTeam: true, + }, + }, + }, + savedTeamsQueryMock([]), + ]); + + await screen.findByText('Add Pokémon to your team to see their type matchups.'); - await screen.findByText( - "Add Pokémon to your team to see their type matchups.", - ); await addPikachuToTeam(); - fireEvent.click(screen.getByRole("button", { name: "Save team" })); - const nameInput = await screen.findByRole("textbox", { name: "Team name" }); - fireEvent.change(nameInput, { target: { value: "Kanto Classics" } }); - fireEvent.click(screen.getByRole("button", { name: "Save" })); + fireEvent.click(screen.getByRole('button', { name: 'Save team' })); + + const nameInput = await screen.findByRole('textbox', { name: 'Team name' }); + fireEvent.change(nameInput, { target: { value: 'Kanto Classics' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => { - expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.queryByRole('dialog')).toBeNull(); }); - fireEvent.click(screen.getByRole("link", { name: "Teams" })); + fireEvent.click(screen.getByRole('link', { name: 'Teams' })); - expect( - await screen.findByRole("heading", { name: "SV Kanto Classics" }), - ).toBeTruthy(); - expect(screen.getByRole("img", { name: "Pikachu sprite" })).toBeTruthy(); + expect(await screen.findByRole('heading', { name: 'SV Kanto Classics' })).toBeTruthy(); + expect(screen.getByRole('img', { name: 'Pikachu sprite' })).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Load team" })); + fireEvent.click(screen.getByRole('button', { name: 'Load team' })); - expect( - await screen.findByRole("heading", { name: "Your team" }), - ).toBeTruthy(); - expect(screen.getByRole("article", { name: "Pikachu" })).toBeTruthy(); + expect(await screen.findByRole('heading', { name: 'Your team' })).toBeTruthy(); + expect(screen.getByRole('article', { name: 'Pikachu' })).toBeTruthy(); - fireEvent.click(screen.getByRole("link", { name: "Teams" })); - fireEvent.click( - await screen.findByRole("button", { name: "Delete team" }), - ); + fireEvent.click(screen.getByRole('link', { name: 'Teams' })); + fireEvent.click(await screen.findByRole('button', { name: 'Delete team' })); - expect( - screen.getByRole("dialog", { name: "Delete saved team?" }), - ).toBeTruthy(); + expect(screen.getByRole('dialog', { name: 'Delete saved team?' })).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Yes, delete" })); + fireEvent.click(screen.getByRole('button', { name: 'Yes, delete' })); expect(await screen.findByText(/No saved teams yet/)).toBeTruthy(); - expect(loadSavedTeams(USER_ID)).toEqual([]); }); }); - diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 25ee3da..ca0fc83 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ -import "./App.css"; -import { useEffect, useState } from "react"; +import './App.css'; +import { useEffect, useState } from 'react'; +import { useMutation, useQuery } from '@apollo/client/react'; import { Link, Navigate, @@ -8,23 +9,48 @@ 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 SaveTeamModal from "./save/SaveTeamModal.tsx"; +} from 'react-router-dom'; +import HomePage from './assets/pages/home/page.tsx'; +import TeamsPage from './assets/pages/teams/page.tsx'; +import SaveTeamModal from './save/SaveTeamModal.tsx'; import { - deleteSavedTeam, - loadSavedTeams, + DELETE_SAVED_TEAM_MUTATION, matchesSavedTeam, - saveTeam as persistSavedTeam, + SAVE_TEAM_MUTATION, + SAVED_TEAMS_QUERY, type SavedTeam, -} from "./savedTeams.ts"; -import SearchModal from "./search/SearchModal.tsx"; -import { MAX_TEAM_SIZE, type TeamPokemon } from "./team.ts"; -import { FORMAT_OPTIONS } from "./formats.ts"; -import { useAuth, type User } from "./auth/AuthContext.tsx"; -import ProtectedRoute from "./auth/ProtectedRoute.tsx"; -import LoginPage from "./auth/LoginPage.tsx"; +} from './savedTeams.ts'; +import SearchModal from './search/SearchModal.tsx'; +import { MAX_TEAM_SIZE, type TeamPokemon } from './team.ts'; +import { FORMAT_OPTIONS } from './formats.ts'; +import { useAuth, type User } from './auth/AuthContext.tsx'; +import ProtectedRoute from './auth/ProtectedRoute.tsx'; +import LoginPage from './auth/LoginPage.tsx'; + +type SavedTeamsQueryData = { + savedTeams: SavedTeam[]; +}; + +type SaveTeamMutationData = { + saveTeam: SavedTeam; +}; + +type SaveTeamMutationVariables = { + userId: string; + id?: string; + name: string; + format: string; + pokemonIds: string[]; +}; + +type DeleteSavedTeamMutationData = { + deleteSavedTeam: boolean; +}; + +type DeleteSavedTeamMutationVariables = { + id: string; + userId: string; +}; type ApplicationContext = { userId: string; @@ -61,44 +87,55 @@ function ApplicationLayout() { function SignedInLayout({ user, logout }: SignedInLayoutProps) { const [menuOpen, setMenuOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); + const [format, setFormat] = useState(() => { - const storedFormat = localStorage.getItem( - `pokemon-draft-planner-format-${user.id}`, - ); + const storedFormat = localStorage.getItem(`pokemon-draft-planner-format-${user.id}`); - if ( - storedFormat && - FORMAT_OPTIONS.some((option) => option.value === storedFormat) - ) { + if (storedFormat && FORMAT_OPTIONS.some((option) => option.value === storedFormat)) { return storedFormat; } - return "gen9"; + return 'gen9'; + }); + + const { + data: savedTeamsData, + error: savedTeamsQueryError, + refetch: refetchSavedTeams, + } = useQuery(SAVED_TEAMS_QUERY, { + variables: { userId: user.id }, + fetchPolicy: 'network-only', }); - const [savedTeams, setSavedTeams] = useState(() => - loadSavedTeams(user.id), + + const [persistTeam] = useMutation( + SAVE_TEAM_MUTATION, ); + + const [removeSavedTeam] = useMutation< + DeleteSavedTeamMutationData, + DeleteSavedTeamMutationVariables + >(DELETE_SAVED_TEAM_MUTATION); + + const savedTeams = savedTeamsData?.savedTeams ?? []; + const [activeSavedTeam, setActiveSavedTeam] = useState<{ id: string; name: string; } | null>(null); + const [saveModalOpen, setSaveModalOpen] = useState(false); const [saveMessage, setSaveMessage] = useState(null); const [saveError, setSaveError] = useState(null); 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) : []; }); + // The active, unsaved draft team is still stored locally. useEffect(() => { - localStorage.setItem( - `pokemon-draft-planner-team-${user.id}`, - JSON.stringify(team), - ); + localStorage.setItem(`pokemon-draft-planner-team-${user.id}`, JSON.stringify(team)); }, [team, user.id]); const navigate = useNavigate(); @@ -111,7 +148,7 @@ function SignedInLayout({ user, logout }: SignedInLayoutProps) { logout(); setSearchOpen(false); closeMenu(); - navigate("/login", { replace: true }); + navigate('/login', { replace: true }); } function handleFormatChange(newFormat: string) { @@ -124,9 +161,9 @@ function SignedInLayout({ user, logout }: SignedInLayoutProps) { function addPokemon(pokemon: TeamPokemon) { setSaveMessage(null); setSaveError(null); + 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], ); @@ -135,30 +172,47 @@ function SignedInLayout({ user, logout }: SignedInLayoutProps) { function removePokemon(id: string) { setSaveMessage(null); setSaveError(null); - setTeam((currentTeam) => - currentTeam.filter((pokemon) => pokemon.id !== id), - ); + + setTeam((currentTeam) => currentTeam.filter((pokemon) => pokemon.id !== id)); } - function saveCurrentTeam(name: string, id: string | null) { + async function saveCurrentTeam(name: string, id: string | null) { + const trimmedName = name.trim(); + + if (!trimmedName) { + setSaveError('Enter a name for your team.'); + return; + } + try { - const savedTeam = persistSavedTeam({ - userId: user.id, - id, - name, - format, - pokemon: team, + const result = await persistTeam({ + variables: { + userId: user.id, + ...(id ? { id } : {}), + name: trimmedName, + format, + pokemonIds: team.map((pokemon) => pokemon.id), + }, + }); + + const savedTeam = result.data?.saveTeam; + + if (!savedTeam) { + throw new Error('The server did not return the saved team.'); + } + + await refetchSavedTeams(); + + setActiveSavedTeam({ + id: savedTeam.id, + name: savedTeam.name, }); - setActiveSavedTeam({ id: savedTeam.id, name: savedTeam.name }); - setSavedTeams(loadSavedTeams(user.id)); setSaveModalOpen(false); setSaveError(null); setSaveMessage(`Team saved as “${savedTeam.name}”.`); } catch (error) { - setSaveError( - error instanceof Error ? error.message : "Could not save your team.", - ); + setSaveError(error instanceof Error ? error.message : 'Could not save your team.'); } } @@ -168,7 +222,7 @@ function SignedInLayout({ user, logout }: SignedInLayoutProps) { setSaveMessage(null); if (activeSavedTeam) { - saveCurrentTeam(activeSavedTeam.name, activeSavedTeam.id); + void saveCurrentTeam(activeSavedTeam.name, activeSavedTeam.id); return; } @@ -179,28 +233,46 @@ function SignedInLayout({ user, logout }: SignedInLayoutProps) { function loadTeam(savedTeam: SavedTeam) { setTeam(savedTeam.pokemon.map((pokemon) => ({ ...pokemon }))); setFormat(savedTeam.format); - setActiveSavedTeam({ id: savedTeam.id, name: savedTeam.name }); + setActiveSavedTeam({ + id: savedTeam.id, + name: savedTeam.name, + }); setSaveMessage(`Loaded “${savedTeam.name}”.`); setSaveError(null); } - function deleteTeam(id: string) { - const teams = deleteSavedTeam(user.id, id); - setSavedTeams(teams); - setSaveMessage(null); - setSaveError(null); + async function deleteTeam(id: string) { + try { + const result = await removeSavedTeam({ + variables: { + id, + userId: user.id, + }, + }); - if (activeSavedTeam?.id === id) { - setActiveSavedTeam(null); + if (!result.data?.deleteSavedTeam) { + throw new Error('Could not delete the saved team.'); + } + + await refetchSavedTeams(); + + setSaveMessage(null); + setSaveError(null); + + if (activeSavedTeam?.id === id) { + setActiveSavedTeam(null); + } + } catch (error) { + setSaveError(error instanceof Error ? error.message : 'Could not delete the team.'); } } const activeSavedRecord = activeSavedTeam ? savedTeams.find((savedTeam) => savedTeam.id === activeSavedTeam.id) : undefined; + const activeTeamIsSaved = - activeSavedRecord !== undefined && - matchesSavedTeam(activeSavedRecord, format, team); + activeSavedRecord !== undefined && matchesSavedTeam(activeSavedRecord, format, team); return ( <> @@ -219,16 +291,15 @@ function SignedInLayout({ user, logout }: SignedInLayoutProps) { + - {user?.username - ? user.username.charAt(0).toUpperCase() + user.username.slice(1) - : ""} + {user?.username ? user.username.charAt(0).toUpperCase() + user.username.slice(1) : ''} -
-