diff --git a/convex/_fixtures/createMockListData.ts b/convex/_fixtures/createMockListData.ts new file mode 100644 index 00000000..c5d8e551 --- /dev/null +++ b/convex/_fixtures/createMockListData.ts @@ -0,0 +1,45 @@ +import { FlamesOfWarV4, TeamYankeeV2 } from '@ianpaschal/combat-command-game-systems'; +import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; +import { merge } from 'lodash'; + +import { Doc, Id } from '../_generated/dataModel'; + +type ListData = Omit, '_id'|'_creationTime'>; + +const mockListDataByGameSystem: Record = { + [GameSystem.FlamesOfWarV4]: { + meta: { + faction: FlamesOfWarV4.Faction.UnitedStates, + alignment: FlamesOfWarV4.Alignment.Allies, + era: FlamesOfWarV4.Era.LW, + forceDiagram: FlamesOfWarV4.ForceDiagram.FortressEuropeAmerican, + pointsLimit: 100, + }, + formations: [], + units: [], + commandCards: [], + }, + [GameSystem.TeamYankeeV2]: { + meta: { + faction: TeamYankeeV2.Faction.UnitedStates, + alignment: TeamYankeeV2.Alignment.Nato, + era: TeamYankeeV2.Era.Default, + forceDiagram: TeamYankeeV2.ForceDiagram.American, + pointsLimit: 100, + }, + formations: [], + units: [], + commandCards: [], + }, +}; + +export const createMockListData = ( + gameSystem: GameSystem, + userId: Id<'users'>, + overrides: Partial = {}, +): ListData => merge({ + gameSystem, + userId, + locked: false, + data: mockListDataByGameSystem[gameSystem], +}, overrides); diff --git a/convex/_fixtures/createMockTournament.ts b/convex/_fixtures/createMockTournament.ts index 96a4befd..04f26825 100644 --- a/convex/_fixtures/createMockTournament.ts +++ b/convex/_fixtures/createMockTournament.ts @@ -3,6 +3,7 @@ import { GameSystem, getGameSystem, } from '@ianpaschal/combat-command-game-systems/common'; +import { merge } from 'lodash'; import { Doc } from '../_generated/dataModel'; import { @@ -33,7 +34,7 @@ export const createMockTournament = ( overrides: Partial, ): TournamentData => { const { gameSystemConfig } = getGameSystem(gameSystem); - return { + return merge({}, { gameSystem, title: 'Test Tournament', description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut a elit vehicula, vehicula tortor vitae, varius mauris. Ut varius erat a eros venenatis auctor. Donec erat turpis, pulvinar a eleifend et, consequat vel enim. Etiam vehicula risus eu hendrerit lacinia. Duis rhoncus vehicula justo ut vehicula.', @@ -61,7 +62,7 @@ export const createMockTournament = ( competitorSize: 1, useNationalTeams: false, roundStructure: { - pairingTime: 0, + pairingTime: (overrides.competitorSize ?? 1) > 1 ? 1 : 0, setUpTime: 2, playingTime: 3, }, @@ -71,6 +72,5 @@ export const createMockTournament = ( 'total_points', 'total_units_destroyed', ] as RankingFactor[], - ...overrides, - }; + }, overrides); }; diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 0a721b5b..9b7e1f47 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -8,13 +8,17 @@ * @module */ +import type * as _fixtures_createMockListData from "../_fixtures/createMockListData.js"; import type * as _fixtures_createMockTournament from "../_fixtures/createMockTournament.js"; import type * as _fixtures_createMockTournamentCompetitor from "../_fixtures/createMockTournamentCompetitor.js"; import type * as _fixtures_fowV4_createMockFowV4MatchResultData from "../_fixtures/fowV4/createMockFowV4MatchResultData.js"; +import type * as _model__test__helpers_buildTestListData from "../_model/_test/_helpers/buildTestListData.js"; import type * as _model__test__helpers_testUsers from "../_model/_test/_helpers/testUsers.js"; import type * as _model__test_actions_populateUsers from "../_model/_test/actions/populateUsers.js"; import type * as _model__test_index from "../_model/_test/index.js"; import type * as _model__test_mutations_cleanUp from "../_model/_test/mutations/cleanUp.js"; +import type * as _model__test_mutations_createTestTournament from "../_model/_test/mutations/createTestTournament.js"; +import type * as _model__test_mutations_populateTestTournament from "../_model/_test/mutations/populateTestTournament.js"; import type * as _model_common_VisibilityLevel from "../_model/common/VisibilityLevel.js"; import type * as _model_common__helpers_buildFilteredQuery from "../_model/common/_helpers/buildFilteredQuery.js"; import type * as _model_common__helpers_checkAuth from "../_model/common/_helpers/checkAuth.js"; @@ -39,6 +43,7 @@ import type * as _model_common_errors from "../_model/common/errors.js"; import type * as _model_common_faction from "../_model/common/faction.js"; import type * as _model_common_gameSystemConfig from "../_model/common/gameSystemConfig.js"; import type * as _model_common_leagueStatus from "../_model/common/leagueStatus.js"; +import type * as _model_common_listData from "../_model/common/listData.js"; import type * as _model_common_location from "../_model/common/location.js"; import type * as _model_common_matchResultDetails from "../_model/common/matchResultDetails.js"; import type * as _model_common_rankingFactor from "../_model/common/rankingFactor.js"; @@ -88,6 +93,7 @@ import type * as _model_listComments_index from "../_model/listComments/index.js import type * as _model_listComments_mutations_createListComment from "../_model/listComments/mutations/createListComment.js"; import type * as _model_listComments_table from "../_model/listComments/table.js"; import type * as _model_listComments_types from "../_model/listComments/types.js"; +import type * as _model_lists__helpers_checkListApproved from "../_model/lists/_helpers/checkListApproved.js"; import type * as _model_lists__helpers_checkListSubmittedOnTime from "../_model/lists/_helpers/checkListSubmittedOnTime.js"; import type * as _model_lists__helpers_deepenList from "../_model/lists/_helpers/deepenList.js"; import type * as _model_lists__helpers_getAvailableActions from "../_model/lists/_helpers/getAvailableActions.js"; @@ -146,7 +152,6 @@ import type * as _model_tournamentCompetitors__helpers_getDetails from "../_mode import type * as _model_tournamentCompetitors__helpers_getDisplayName from "../_model/tournamentCompetitors/_helpers/getDisplayName.js"; import type * as _model_tournamentCompetitors__helpers_sortTournamentCompetitorsByName from "../_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName.js"; import type * as _model_tournamentCompetitors_index from "../_model/tournamentCompetitors/index.js"; -import type * as _model_tournamentCompetitors_mutations_createTournamentCompetitor from "../_model/tournamentCompetitors/mutations/createTournamentCompetitor.js"; import type * as _model_tournamentCompetitors_mutations_deleteTournamentCompetitor from "../_model/tournamentCompetitors/mutations/deleteTournamentCompetitor.js"; import type * as _model_tournamentCompetitors_mutations_toggleTournamentCompetitorActive from "../_model/tournamentCompetitors/mutations/toggleTournamentCompetitorActive.js"; import type * as _model_tournamentCompetitors_mutations_updateTournamentCompetitor from "../_model/tournamentCompetitors/mutations/updateTournamentCompetitor.js"; @@ -197,6 +202,7 @@ import type * as _model_tournamentRegistrations__helpers_getAvailableActions fro import type * as _model_tournamentRegistrations__helpers_getCreateSuccessMessage from "../_model/tournamentRegistrations/_helpers/getCreateSuccessMessage.js"; import type * as _model_tournamentRegistrations__helpers_getDeleteSuccessMessage from "../_model/tournamentRegistrations/_helpers/getDeleteSuccessMessage.js"; import type * as _model_tournamentRegistrations__helpers_sortTournamentCompetitorsByName from "../_model/tournamentRegistrations/_helpers/sortTournamentCompetitorsByName.js"; +import type * as _model_tournamentRegistrations__helpers_validateListMeta from "../_model/tournamentRegistrations/_helpers/validateListMeta.js"; import type * as _model_tournamentRegistrations_index from "../_model/tournamentRegistrations/index.js"; import type * as _model_tournamentRegistrations_mutations_createTournamentRegistration from "../_model/tournamentRegistrations/mutations/createTournamentRegistration.js"; import type * as _model_tournamentRegistrations_mutations_deleteTournamentRegistration from "../_model/tournamentRegistrations/mutations/deleteTournamentRegistration.js"; @@ -307,6 +313,7 @@ import type * as _model_utils_index from "../_model/utils/index.js"; import type * as _model_utils_mergeUser from "../_model/utils/mergeUser.js"; import type * as _model_utils_mutations_refreshSearchIndex from "../_model/utils/mutations/refreshSearchIndex.js"; import type * as _model_utils_mutations_revealTournamentPlayerNames from "../_model/utils/mutations/revealTournamentPlayerNames.js"; +import type * as _test from "../_test.js"; import type * as auth from "../auth.js"; import type * as crons from "../crons.js"; import type * as emails_InviteUserEmail from "../emails/InviteUserEmail.js"; @@ -328,7 +335,6 @@ import type * as matchResults from "../matchResults.js"; import type * as migrations from "../migrations.js"; import type * as photos from "../photos.js"; import type * as scheduledTasks from "../scheduledTasks.js"; -import type * as test from "../test.js"; import type * as tournamentCompetitors from "../tournamentCompetitors.js"; import type * as tournamentPairings from "../tournamentPairings.js"; import type * as tournamentRegistrations from "../tournamentRegistrations.js"; @@ -354,13 +360,17 @@ import type { * ``` */ declare const fullApi: ApiFromModules<{ + "_fixtures/createMockListData": typeof _fixtures_createMockListData; "_fixtures/createMockTournament": typeof _fixtures_createMockTournament; "_fixtures/createMockTournamentCompetitor": typeof _fixtures_createMockTournamentCompetitor; "_fixtures/fowV4/createMockFowV4MatchResultData": typeof _fixtures_fowV4_createMockFowV4MatchResultData; + "_model/_test/_helpers/buildTestListData": typeof _model__test__helpers_buildTestListData; "_model/_test/_helpers/testUsers": typeof _model__test__helpers_testUsers; "_model/_test/actions/populateUsers": typeof _model__test_actions_populateUsers; "_model/_test/index": typeof _model__test_index; "_model/_test/mutations/cleanUp": typeof _model__test_mutations_cleanUp; + "_model/_test/mutations/createTestTournament": typeof _model__test_mutations_createTestTournament; + "_model/_test/mutations/populateTestTournament": typeof _model__test_mutations_populateTestTournament; "_model/common/VisibilityLevel": typeof _model_common_VisibilityLevel; "_model/common/_helpers/buildFilteredQuery": typeof _model_common__helpers_buildFilteredQuery; "_model/common/_helpers/checkAuth": typeof _model_common__helpers_checkAuth; @@ -385,6 +395,7 @@ declare const fullApi: ApiFromModules<{ "_model/common/faction": typeof _model_common_faction; "_model/common/gameSystemConfig": typeof _model_common_gameSystemConfig; "_model/common/leagueStatus": typeof _model_common_leagueStatus; + "_model/common/listData": typeof _model_common_listData; "_model/common/location": typeof _model_common_location; "_model/common/matchResultDetails": typeof _model_common_matchResultDetails; "_model/common/rankingFactor": typeof _model_common_rankingFactor; @@ -434,6 +445,7 @@ declare const fullApi: ApiFromModules<{ "_model/listComments/mutations/createListComment": typeof _model_listComments_mutations_createListComment; "_model/listComments/table": typeof _model_listComments_table; "_model/listComments/types": typeof _model_listComments_types; + "_model/lists/_helpers/checkListApproved": typeof _model_lists__helpers_checkListApproved; "_model/lists/_helpers/checkListSubmittedOnTime": typeof _model_lists__helpers_checkListSubmittedOnTime; "_model/lists/_helpers/deepenList": typeof _model_lists__helpers_deepenList; "_model/lists/_helpers/getAvailableActions": typeof _model_lists__helpers_getAvailableActions; @@ -492,7 +504,6 @@ declare const fullApi: ApiFromModules<{ "_model/tournamentCompetitors/_helpers/getDisplayName": typeof _model_tournamentCompetitors__helpers_getDisplayName; "_model/tournamentCompetitors/_helpers/sortTournamentCompetitorsByName": typeof _model_tournamentCompetitors__helpers_sortTournamentCompetitorsByName; "_model/tournamentCompetitors/index": typeof _model_tournamentCompetitors_index; - "_model/tournamentCompetitors/mutations/createTournamentCompetitor": typeof _model_tournamentCompetitors_mutations_createTournamentCompetitor; "_model/tournamentCompetitors/mutations/deleteTournamentCompetitor": typeof _model_tournamentCompetitors_mutations_deleteTournamentCompetitor; "_model/tournamentCompetitors/mutations/toggleTournamentCompetitorActive": typeof _model_tournamentCompetitors_mutations_toggleTournamentCompetitorActive; "_model/tournamentCompetitors/mutations/updateTournamentCompetitor": typeof _model_tournamentCompetitors_mutations_updateTournamentCompetitor; @@ -543,6 +554,7 @@ declare const fullApi: ApiFromModules<{ "_model/tournamentRegistrations/_helpers/getCreateSuccessMessage": typeof _model_tournamentRegistrations__helpers_getCreateSuccessMessage; "_model/tournamentRegistrations/_helpers/getDeleteSuccessMessage": typeof _model_tournamentRegistrations__helpers_getDeleteSuccessMessage; "_model/tournamentRegistrations/_helpers/sortTournamentCompetitorsByName": typeof _model_tournamentRegistrations__helpers_sortTournamentCompetitorsByName; + "_model/tournamentRegistrations/_helpers/validateListMeta": typeof _model_tournamentRegistrations__helpers_validateListMeta; "_model/tournamentRegistrations/index": typeof _model_tournamentRegistrations_index; "_model/tournamentRegistrations/mutations/createTournamentRegistration": typeof _model_tournamentRegistrations_mutations_createTournamentRegistration; "_model/tournamentRegistrations/mutations/deleteTournamentRegistration": typeof _model_tournamentRegistrations_mutations_deleteTournamentRegistration; @@ -653,6 +665,7 @@ declare const fullApi: ApiFromModules<{ "_model/utils/mergeUser": typeof _model_utils_mergeUser; "_model/utils/mutations/refreshSearchIndex": typeof _model_utils_mutations_refreshSearchIndex; "_model/utils/mutations/revealTournamentPlayerNames": typeof _model_utils_mutations_revealTournamentPlayerNames; + _test: typeof _test; auth: typeof auth; crons: typeof crons; "emails/InviteUserEmail": typeof emails_InviteUserEmail; @@ -674,7 +687,6 @@ declare const fullApi: ApiFromModules<{ migrations: typeof migrations; photos: typeof photos; scheduledTasks: typeof scheduledTasks; - test: typeof test; tournamentCompetitors: typeof tournamentCompetitors; tournamentPairings: typeof tournamentPairings; tournamentRegistrations: typeof tournamentRegistrations; diff --git a/convex/_model/_test/_helpers/buildTestListData.ts b/convex/_model/_test/_helpers/buildTestListData.ts new file mode 100644 index 00000000..ec162297 --- /dev/null +++ b/convex/_model/_test/_helpers/buildTestListData.ts @@ -0,0 +1,18 @@ +import { FlamesOfWarV4, TeamYankeeV2 } from '@ianpaschal/combat-command-game-systems'; +import { GameSystem, getGameSystem } from '@ianpaschal/combat-command-game-systems/common'; + +export const buildTestListData = (gameSystem: GameSystem): FlamesOfWarV4.ListData | TeamYankeeV2.ListData => { + const { listData } = getGameSystem(gameSystem); + const { meta, formations, units, commandCards } = listData.defaultValues; + + const storageMeta = Object.fromEntries( + Object.entries(meta).filter(([, value]) => value !== null), + ); + + return { + meta: storageMeta, + formations, + units, + commandCards, + } as unknown as FlamesOfWarV4.ListData | TeamYankeeV2.ListData; +}; diff --git a/convex/_model/_test/mutations/cleanUp.ts b/convex/_model/_test/mutations/cleanUp.ts index 656a0f28..39a8b82a 100644 --- a/convex/_model/_test/mutations/cleanUp.ts +++ b/convex/_model/_test/mutations/cleanUp.ts @@ -18,7 +18,10 @@ export const cleanUp = async ( const preserved = new Set(args.preservedTables); const tables = (Object.keys(schema.tables) as TableName[]).filter((t) => !preserved.has(t)); for (const table of tables) { - const docs = await ctx.db.query(table).collect(); - await Promise.all(docs.map((doc) => ctx.db.delete(doc._id))); + let docs = await ctx.db.query(table).take(256); + while (docs.length > 0) { + await Promise.all(docs.map((doc) => ctx.db.delete(doc._id))); + docs = await ctx.db.query(table).take(256); + } } }; diff --git a/convex/_model/_test/mutations/createTestTournament.ts b/convex/_model/_test/mutations/createTestTournament.ts new file mode 100644 index 00000000..aab05b05 --- /dev/null +++ b/convex/_model/_test/mutations/createTestTournament.ts @@ -0,0 +1,39 @@ +import { Infer, v } from 'convex/values'; + +import { createMockTournament } from '../../../_fixtures/createMockTournament'; +import { Id } from '../../../_generated/dataModel'; +import { MutationCtx } from '../../../_generated/server'; +import { editableFields } from '../../tournaments/table'; + +export const createTestTournamentArgs = v.object({ + ...editableFields, + organizerUserId: v.id('users'), +}); + +export const createTestTournament = async ( + ctx: MutationCtx, + args: Infer, +): Promise> => { + const { organizerUserId, ...restArgs } = args; + + const competitorSize = args.competitorSize ?? 1; + const maxCompetitors = args.maxCompetitors ?? (competitorSize > 1 ? 12 : 24); + + // 1. Gather users + const requiredUserCount = (maxCompetitors * competitorSize) + 1; + const users = await ctx.db.query('users').take(requiredUserCount); + if (users.length < requiredUserCount) { + throw new Error('Not enough users'); + } + + // 2. Insert the mock tournament + const tournamentId = await ctx.db.insert('tournaments', createMockTournament(args.gameSystem, restArgs)); + + // 3. Insert the mock organizer + await ctx.db.insert('tournamentOrganizers', { + userId: organizerUserId, + tournamentId, + }); + + return tournamentId; +}; diff --git a/convex/_model/_test/mutations/populateTestTournament.ts b/convex/_model/_test/mutations/populateTestTournament.ts new file mode 100644 index 00000000..1547f0b5 --- /dev/null +++ b/convex/_model/_test/mutations/populateTestTournament.ts @@ -0,0 +1,96 @@ +import { Infer, v } from 'convex/values'; + +import { createMockListData } from '../../../_fixtures/createMockListData'; +import { Id } from '../../../_generated/dataModel'; +import { MutationCtx } from '../../../_generated/server'; + +export const populateTestTournamentArgs = v.object({ + tournamentId: v.id('tournaments'), + limit: v.optional(v.number()), +}); + +const countryCodes = [ + 'nl', 'be', 'de', 'dk', + 'se', 'gb-nir', 'fr', 'it', + 'es', 'pt', 'ie', 'us', +]; + +export const populateTestTournament = async ( + ctx: MutationCtx, + args: Infer, +): Promise> => { + + const tournament = await ctx.db.get(args.tournamentId); + if (!tournament) { + throw new Error('Could not find a tournament with that ID!'); + } + + const { competitorSize, useNationalTeams } = tournament; + const limit = args.limit ?? tournament.maxCompetitors; + + // 1. Find already-registered users so we don't reuse them: + const existingRegistrations = await ctx.db.query('tournamentRegistrations') + .withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId)) + .collect(); + const existingUserIds = new Set(existingRegistrations.map((r) => r.userId)); + + const existingCompetitors = await ctx.db.query('tournamentCompetitors') + .withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId)) + .collect(); + const slotsToFill = limit - existingCompetitors.length; + + if (slotsToFill <= 0) { + return tournament._id; + } + + // 2. Gather enough users not already in the tournament: + const requiredUserCount = slotsToFill * competitorSize; + const candidates = await ctx.db.query('users').collect(); + const availableUsers = candidates.filter((u) => !existingUserIds.has(u._id)); + if (availableUsers.length < requiredUserCount) { + throw new Error('Not enough users to fill remaining slots'); + } + + // 3. Publish the tournament if it's still a draft: + if (tournament.status === 'draft') { + await ctx.db.patch(tournament._id, { status: 'published' }); + } + + // 4. Insert competitors and registrations: + for (let i = 0; i < slotsToFill; i++) { + const competitorIndex = existingCompetitors.length + i; + const startingIndex = i * competitorSize; + const players = availableUsers.slice(startingIndex, startingIndex + competitorSize); + + let teamName = undefined; + if (competitorSize > 1) { + if (useNationalTeams && countryCodes[competitorIndex]) { + teamName = countryCodes[competitorIndex]; + } else { + teamName = `Team ${competitorIndex + 1}`; + } + } + + const tournamentCompetitorId = await ctx.db.insert('tournamentCompetitors', { + captainUserId: players[0]._id, + teamName, + tournamentId: tournament._id, + }); + + for (const player of players) { + const tournamentRegistrationId = await ctx.db.insert('tournamentRegistrations', { + tournamentId: tournament._id, + tournamentCompetitorId, + userId: player._id, + active: true, + confirmed: true, + }); + + await ctx.db.insert('lists', createMockListData(tournament.gameSystem, player._id, { + tournamentRegistrationId, + })); + } + } + + return tournament._id; +}; diff --git a/convex/_model/common/listData.ts b/convex/_model/common/listData.ts new file mode 100644 index 00000000..ae73e6bf --- /dev/null +++ b/convex/_model/common/listData.ts @@ -0,0 +1,8 @@ +import { FlamesOfWarV4, TeamYankeeV2 } from '@ianpaschal/combat-command-game-systems'; +import { v } from 'convex/values'; +import { zodToConvex } from 'convex-helpers/server/zod'; + +export const listData = v.union( + zodToConvex(FlamesOfWarV4.listData.schema), + zodToConvex(TeamYankeeV2.listData.schema), +); diff --git a/convex/_model/common/types.ts b/convex/_model/common/types.ts index 837a2729..55ee3219 100644 --- a/convex/_model/common/types.ts +++ b/convex/_model/common/types.ts @@ -27,7 +27,7 @@ export type TriggerChange = { }; export type MutationIssue = { - fieldPath: string; + path: string[]; message: string; }; diff --git a/convex/_model/lists/_helpers/checkListApproved.ts b/convex/_model/lists/_helpers/checkListApproved.ts new file mode 100644 index 00000000..57a65fa0 --- /dev/null +++ b/convex/_model/lists/_helpers/checkListApproved.ts @@ -0,0 +1,16 @@ +import { Doc } from '../../../_generated/dataModel'; +import { QueryCtx } from '../../../_generated/server'; + +export const checkListApproved = async ( + ctx: QueryCtx, + doc: Doc<'lists'>, +): Promise => { + if (!doc.tournamentRegistrationId) { + return true; + } + const listCommentResults = await ctx.db.query('listComments') + .withIndex('by_list', (q) => q.eq('listId', doc._id)) + .collect(); + const lastControlComment = [...listCommentResults].reverse().find((c) => c.control !== undefined) ?? null; + return lastControlComment?.control === 'approved'; +}; diff --git a/convex/_model/lists/_helpers/checkListSubmittedOnTime.ts b/convex/_model/lists/_helpers/checkListSubmittedOnTime.ts index 39f3572a..e1af15b2 100644 --- a/convex/_model/lists/_helpers/checkListSubmittedOnTime.ts +++ b/convex/_model/lists/_helpers/checkListSubmittedOnTime.ts @@ -6,6 +6,9 @@ export const checkListSubmittedOnTime = async ( ctx: QueryCtx, doc: Doc<'lists'>, ): Promise => { + if (!doc.storageId) { + return false; + } if (doc.tournamentRegistrationId) { const tournamentRegistration = await getDocStrict(ctx, doc.tournamentRegistrationId); const tournament = await getDocStrict(ctx, tournamentRegistration.tournamentId); diff --git a/convex/_model/lists/_helpers/deepenList.ts b/convex/_model/lists/_helpers/deepenList.ts index 7b2f1b59..c48f4563 100644 --- a/convex/_model/lists/_helpers/deepenList.ts +++ b/convex/_model/lists/_helpers/deepenList.ts @@ -1,4 +1,5 @@ import { getAuthUserId } from '@convex-dev/auth/server'; +import { getGameSystem } from '@ianpaschal/combat-command-game-systems/common'; import { ConvexError } from 'convex/values'; import { Doc } from '../../../_generated/dataModel'; @@ -60,16 +61,23 @@ export const deepenList = async ( } } + const helpers = getGameSystem(doc.gameSystem); + const derivedMeta = doc.data?.meta ? { + ...doc.data.meta, + faction: helpers.getForceDiagramFaction(doc.data.meta.forceDiagram), + alignment: helpers.getForceDiagramAlignment(doc.data.meta.forceDiagram), + era: helpers.getForceDiagramEra(doc.data.meta.forceDiagram), + } : undefined; + return { ...doc, + data: doc.data ? { ...doc.data, meta: derivedMeta! } : undefined, availableActions: await getAvailableActions(ctx, doc), lastControlComment, displayName: undefined, // Future: Set displayName based on extracted list data comments, onTime: await checkListSubmittedOnTime(ctx, doc), user, - fileUrl: await getFileUrl(ctx, { id: doc.storageId }), + fileUrl: doc.storageId ? await getFileUrl(ctx, { id: doc.storageId }) : undefined, }; }; - -// FUTURE: Deepen list based on game system diff --git a/convex/_model/lists/index.ts b/convex/_model/lists/index.ts index ca89f1d0..a2a66de9 100644 --- a/convex/_model/lists/index.ts +++ b/convex/_model/lists/index.ts @@ -3,6 +3,9 @@ export { } from './_helpers/getAvailableActions'; export * from './types'; +// Helpers +export * from './_helpers/checkListApproved'; + // Mutations export { createList, diff --git a/convex/_model/lists/mutations/createList.ts b/convex/_model/lists/mutations/createList.ts index 7e58309e..a41db593 100644 --- a/convex/_model/lists/mutations/createList.ts +++ b/convex/_model/lists/mutations/createList.ts @@ -14,15 +14,28 @@ export const createList = async ( ): Promise> => { const userId = await checkAuth(ctx); - const computedFields = { + const computedFields: { locked: boolean; submittedAt?: number } = { locked: false, - submittedAt: Date.now(), }; - if (args.tournamentRegistrationId) { - const tournamentRegistration = await getDocStrict(ctx, args.tournamentRegistrationId); - const tournament = await getDocStrict(ctx, tournamentRegistration.tournamentId); - computedFields.locked = computedFields.submittedAt > tournament.listSubmissionClosesAt; + if (args.storageId) { + const submittedAt = Date.now(); + computedFields.submittedAt = submittedAt; + + if (args.tournamentRegistrationId) { + const tournamentRegistration = await getDocStrict(ctx, args.tournamentRegistrationId); + const tournament = await getDocStrict(ctx, tournamentRegistration.tournamentId); + computedFields.locked = submittedAt > tournament.listSubmissionClosesAt; + } + } else if (args.tournamentRegistrationId) { + const existingBareList = await ctx.db + .query('lists') + .withIndex('by_tournament_registration', (q) => q.eq('tournamentRegistrationId', args.tournamentRegistrationId)) + .filter((q) => q.eq(q.field('storageId'), undefined)) + .first(); + if (existingBareList) { + throw new Error('A bare list already exists for this registration.'); + } } return await ctx.db.insert('lists', { diff --git a/convex/_model/lists/mutations/updateList.ts b/convex/_model/lists/mutations/updateList.ts index 323bb1a3..cc22f158 100644 --- a/convex/_model/lists/mutations/updateList.ts +++ b/convex/_model/lists/mutations/updateList.ts @@ -32,8 +32,21 @@ export const updateList = async ( throw new ConvexError(getErrorMessage('USER_DOES_NOT_HAVE_PERMISSION')); } - await ctx.db.patch(id, { + const patch: Parameters>[1] = { ...updated, modifiedAt: Date.now(), - }); + }; + + if (args.storageId && !list.storageId) { + const submittedAt = Date.now(); + patch.submittedAt = submittedAt; + + if (list.tournamentRegistrationId) { + const tournamentRegistration = await getDocStrict(ctx, list.tournamentRegistrationId); + const tournament = await getDocStrict(ctx, tournamentRegistration.tournamentId); + patch.locked = submittedAt > tournament.listSubmissionClosesAt; + } + } + + await ctx.db.patch(id, patch); }; diff --git a/convex/_model/lists/table.ts b/convex/_model/lists/table.ts index 8e973e8a..e75aace1 100644 --- a/convex/_model/lists/table.ts +++ b/convex/_model/lists/table.ts @@ -1,19 +1,24 @@ +import { FlamesOfWarV4, TeamYankeeV2 } from '@ianpaschal/combat-command-game-systems'; import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; import { defineTable } from 'convex/server'; import { v } from 'convex/values'; +import { zodToConvex } from 'convex-helpers/server/zod'; import { getStaticEnumConvexValidator } from '../common/_helpers/getStaticEnumConvexValidator'; const gameSystem = getStaticEnumConvexValidator(GameSystem); +const listDataValidator = v.union( + zodToConvex(FlamesOfWarV4.listData.schema), + zodToConvex(TeamYankeeV2.listData.schema), +); + export const editableFields = { gameSystem, - storageId: v.id('_storage'), + storageId: v.optional(v.id('_storage')), userId: v.id('users'), tournamentRegistrationId: v.optional(v.id('tournamentRegistrations')), - - // FUTURE: - // data: listData, + data: v.optional(listDataValidator), }; /** diff --git a/convex/_model/matchResults/_helpers/deepenMatchResult.ts b/convex/_model/matchResults/_helpers/deepenMatchResult.ts index bf30fa04..4d14e418 100644 --- a/convex/_model/matchResults/_helpers/deepenMatchResult.ts +++ b/convex/_model/matchResults/_helpers/deepenMatchResult.ts @@ -56,10 +56,12 @@ export const deepenMatchResult = async ( availableActions, player0DisplayName, player0Score, + player0Faction: player0List?.data?.meta?.faction ?? doc.details?.player0Faction ?? null, // TODO: REMOVE POST MIGRATION ...(player0User ? { player0User } : {}), ...(player0List ? { player0List } : {}), player1DisplayName, player1Score, + player1Faction: player1List?.data?.meta?.faction ?? doc.details?.player1Faction ?? null, // TODO: REMOVE POST MIGRATION ...(player1User ? { player1User } : {}), ...(player1List ? { player1List } : {}), details, diff --git a/convex/_model/tournamentCompetitors/index.ts b/convex/_model/tournamentCompetitors/index.ts index 9eb2ade5..0e833cb2 100644 --- a/convex/_model/tournamentCompetitors/index.ts +++ b/convex/_model/tournamentCompetitors/index.ts @@ -22,10 +22,6 @@ export { } from './_helpers/getDisplayName'; // Mutations -export { - createTournamentCompetitor, - createTournamentCompetitorArgs, -} from './mutations/createTournamentCompetitor'; export { deleteTournamentCompetitor, deleteTournamentCompetitorArgs, diff --git a/convex/_model/tournamentCompetitors/mutations/createTournamentCompetitor.ts b/convex/_model/tournamentCompetitors/mutations/createTournamentCompetitor.ts deleted file mode 100644 index eea5e72e..00000000 --- a/convex/_model/tournamentCompetitors/mutations/createTournamentCompetitor.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - ConvexError, - Infer, - v, -} from 'convex/values'; - -import { Id } from '../../../_generated/dataModel'; -import { MutationCtx } from '../../../_generated/server'; -import { checkAuth } from '../../common/_helpers/checkAuth'; -import { getErrorMessage } from '../../common/errors'; -import { getTournamentOrganizersByTournament } from '../../tournamentOrganizers'; -import { - createTournamentRegistration, -} from '../../tournamentRegistrations/mutations/createTournamentRegistration'; -import { editableFields } from '../table'; - -export const createTournamentCompetitorArgs = v.object({ - ...editableFields, - captainUserId: v.id('users'), -}); - -export const createTournamentCompetitor = async ( - ctx: MutationCtx, - args: Infer, -): Promise> => { - - // --- CHECK AUTH ---- - const userId = await checkAuth(ctx); - - // ---- VALIDATE ---- - const tournament = await ctx.db.get(args.tournamentId); - if (!tournament) { - throw new ConvexError(getErrorMessage('TOURNAMENT_NOT_FOUND')); - } - if (tournament.status === 'archived') { - throw new ConvexError(getErrorMessage('CANNOT_MODIFY_ARCHIVED_TOURNAMENT')); - } - const tournamentCompetitors = await ctx.db.query('tournamentCompetitors') - .withIndex('by_tournament', (q) => q.eq('tournamentId', args.tournamentId)) - .collect(); - const existingTeamNames = tournamentCompetitors.map((item) => item.teamName); - if (args.teamName && existingTeamNames.includes(args.teamName)) { - throw new ConvexError(getErrorMessage('TEAM_ALREADY_IN_TOURNAMENT')); - } - - const existingTournamentRegistration = await ctx.db.query('tournamentRegistrations') - .withIndex('by_tournament_user', (q) => q.eq('tournamentId', tournament._id).eq('userId', args.captainUserId)) - .first(); - if (existingTournamentRegistration) { - throw new ConvexError(getErrorMessage('USER_ALREADY_IN_TOURNAMENT')); - } - if (!args.captainUserId) { - throw new ConvexError(getErrorMessage('CANNOT_CREATE_COMPETITOR_WITH_0_PLAYERS')); - } - - // ---- EXTENDED AUTH CHECK ---- - /* These user IDs can create a tournament competitor: - * - Tournament organizers; - * - The captain of the competitor; - */ - const tournamentOrganizers = await getTournamentOrganizersByTournament(ctx, { - tournamentId: args.tournamentId, - }); - const authorizedUserIds = [ - ...tournamentOrganizers.map((r) => r.userId), - args.captainUserId, - ]; - if (!authorizedUserIds.includes(userId)) { - throw new ConvexError(getErrorMessage('USER_DOES_NOT_HAVE_PERMISSION')); - } - - // ---- PRIMARY ACTIONS ---- - const { ...restArgs } = args; - const tournamentCompetitorId = await ctx.db.insert('tournamentCompetitors', { - ...restArgs, - active: false, - }); - - await createTournamentRegistration(ctx, { - userId: args.captainUserId, - tournamentId: args.tournamentId, - tournamentCompetitorId, - }); - - return tournamentCompetitorId; -}; diff --git a/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts b/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts index 76134ca8..36955d3a 100644 --- a/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts +++ b/convex/_model/tournamentRegistrations/_helpers/deepenTournamentRegistration.ts @@ -38,14 +38,18 @@ export const deepenTournamentRegistration = async ( const alignmentsVisible = isOrganizer || tournament.alignmentsRevealed; const factionsVisible = isOrganizer || tournament.factionsRevealed; - // TODO: Use lists if they are present. getDetails() const lists = await getListsByTournamentRegistration(ctx, { tournamentRegistrationId: doc._id }); - const alignments = Array.from(new Set(alignmentsVisible && details?.alignment ? [details.alignment] : [])); - const factions = Array.from(new Set(factionsVisible && details?.faction ? [details.faction] : [])); + + const registrationList = lists.find((l) => !l.storageId); + const derivedAlignment = registrationList?.data?.meta?.alignment ?? details?.alignment ?? null; + const derivedFaction = registrationList?.data?.meta?.faction ?? details?.faction ?? null; + + const alignments = Array.from(new Set(alignmentsVisible && derivedAlignment ? [derivedAlignment] : [])); + const factions = Array.from(new Set(factionsVisible && derivedFaction ? [derivedFaction] : [])); const visibleDetails = { - alignment: alignmentsVisible ? details?.alignment ?? null : null, - faction: factionsVisible ? details?.faction ?? null : null, + alignment: alignmentsVisible ? derivedAlignment : null, + faction: factionsVisible ? derivedFaction : null, }; return { diff --git a/convex/_model/tournamentRegistrations/_helpers/getAvailableActions.ts b/convex/_model/tournamentRegistrations/_helpers/getAvailableActions.ts index 8c3a89bd..f3afa4cf 100644 --- a/convex/_model/tournamentRegistrations/_helpers/getAvailableActions.ts +++ b/convex/_model/tournamentRegistrations/_helpers/getAvailableActions.ts @@ -15,7 +15,10 @@ export enum TournamentRegistrationActionKey { /** Create a new list for this TournamentRegistration. */ CreateList = 'createList', - + + /** Open the list management drawer for this TournamentRegistration. */ + ManageLists = 'manageLists', + // TODO // Transfer = 'transfer', @@ -71,6 +74,7 @@ export const getAvailableActions = async ( if (isOrganizer || isCaptain || isSelf) { actions.push(TournamentRegistrationActionKey.CreateList); + actions.push(TournamentRegistrationActionKey.ManageLists); } if ((isOrganizer || isCaptain) && !isSelf && tournament.status === 'published') { diff --git a/convex/_model/tournamentRegistrations/_helpers/validateListMeta.ts b/convex/_model/tournamentRegistrations/_helpers/validateListMeta.ts new file mode 100644 index 00000000..6e7b4379 --- /dev/null +++ b/convex/_model/tournamentRegistrations/_helpers/validateListMeta.ts @@ -0,0 +1,60 @@ +import { Infer } from 'convex/values'; + +import { Doc } from '../../../_generated/dataModel'; +import { listData } from '../../common/listData'; +import { MutationIssue } from '../../common/types'; + +/** + * Validates list meta fields against a tournament's `requireListMeta` configuration. + * + * Checks (in order): + * - No required fields: If none of the meta fields are marked `'required'`, skip validation + * entirely and return no issues. + * + * - No lists submitted: If there are required fields but no lists were submitted, return a single + * top-level issue at `['lists']` prompting the user to add a list. + * + * - Missing required fields: For each submitted list, check that every required meta field is + * present and non-empty. Issues use a path of `['lists', index, 'meta', field]` so they map + * directly onto form fields. + * + * @param lists - The list data submitted with the registration. + * @param requireListMeta - The tournament's meta field requirements. + * + * @returns An array of `MutationIssue`s. Empty if all required fields are satisfied. + */ +export const validateListMeta = ( + lists: Infer[], + requireListMeta: NonNullable['requireListMeta']>, +): MutationIssue[] => { + const requiredFields = requireListMeta + .filter(({ required }) => !!required) + .map(({ path }) => path); + + // No required fields: + if (requiredFields.length === 0) { + return []; + } + + // No lists submitted: + if (!lists.length) { + return [{ + path: ['lists'], + message: 'This tournament requires some preliminary army information. Please add a blank army list and fill in the minimum required fields.', + }]; + } + + // Missing required fields: + const issues: MutationIssue[] = []; + lists.forEach((list, i) => { + for (const field of requiredFields) { + if (!list.meta[field as keyof typeof list.meta]) { + issues.push({ + path: ['lists', String(i), 'meta', field], + message: `${field} is required`, + }); + } + } + }); + return issues; +}; diff --git a/convex/_model/tournamentRegistrations/mutations/createTournamentRegistration.ts b/convex/_model/tournamentRegistrations/mutations/createTournamentRegistration.ts index 2b2a045f..7c5193af 100644 --- a/convex/_model/tournamentRegistrations/mutations/createTournamentRegistration.ts +++ b/convex/_model/tournamentRegistrations/mutations/createTournamentRegistration.ts @@ -7,24 +7,25 @@ import { import { MutationCtx } from '../../../_generated/server'; import { checkAuth } from '../../common/_helpers/checkAuth'; import { getErrorMessage } from '../../common/errors'; +import { listData } from '../../common/listData'; import { scoreAdjustment } from '../../common/scoreAdjustment'; -import { MutationResponse } from '../../common/types'; +import { MutationIssue, MutationResponse } from '../../common/types'; import { VisibilityLevel } from '../../common/VisibilityLevel'; import { getTournamentOrganizersByTournament } from '../../tournamentOrganizers'; import { checkUserIsRegistered } from '../_helpers/checkUserIsRegistered'; import { getCreateSuccessMessage } from '../_helpers/getCreateSuccessMessage'; -import { detailFields } from '../table'; +import { validateListMeta } from '../_helpers/validateListMeta'; export const createTournamentRegistrationArgs = v.object({ userId: v.id('users'), tournamentId: v.id('tournaments'), - details: v.optional(detailFields), tournamentCompetitorId: v.optional(v.id('tournamentCompetitors')), tournamentCompetitor: v.optional(v.object({ teamName: v.optional(v.string()), })), nameVisibilityConsent: v.optional(v.boolean()), scoreAdjustments: v.optional(v.array(scoreAdjustment)), + lists: v.array(listData), }); export const createTournamentRegistration = async ( @@ -79,6 +80,17 @@ export const createTournamentRegistration = async ( throw new ConvexError(getErrorMessage('CANNOT_CREATE_REGISTRATION_WITHOUT_REAL_NAME')); } + const issues: MutationIssue[] = []; + + // ---- VALIDATE LIST META ---- + if (tournament.requireListMeta) { + issues.push(...validateListMeta(args.lists, tournament.requireListMeta)); + } + + if (issues.length > 0) { + return { error: { issues } }; + } + // ---- PRIMARY ACTIONS ---- let tournamentCompetitorId = args.tournamentCompetitorId; @@ -99,12 +111,20 @@ export const createTournamentRegistration = async ( const tournamentRegistrationId = await ctx.db.insert('tournamentRegistrations', { active: activePlayerCount < tournament.competitorSize, confirmed: args.userId === currentUserId, - listApproved: false, tournamentCompetitorId, tournamentId: args.tournamentId, userId: args.userId, - details: args.details, }); + + for (const data of args.lists) { + await ctx.db.insert('lists', { + gameSystem: tournament.gameSystem, + userId: args.userId, + tournamentRegistrationId, + locked: false, + data, + }); + } // Update user's name visibility if consent given: const consentRequired = tournament.requireRealNames && user.nameVisibility < VisibilityLevel.Tournaments; diff --git a/convex/_model/tournamentRegistrations/mutations/updateTournamentRegistration.ts b/convex/_model/tournamentRegistrations/mutations/updateTournamentRegistration.ts index 0934340d..dbebb660 100644 --- a/convex/_model/tournamentRegistrations/mutations/updateTournamentRegistration.ts +++ b/convex/_model/tournamentRegistrations/mutations/updateTournamentRegistration.ts @@ -8,6 +8,7 @@ import { MutationCtx } from '../../../_generated/server'; import { getDocStrict } from '../../common/_helpers/getDocStrict'; import { getErrorMessage } from '../../common/errors'; import { scoreAdjustment } from '../../common/scoreAdjustment'; +import { MutationResponse } from '../../common/types'; import { getAvailableActions, TournamentRegistrationActionKey, @@ -23,7 +24,7 @@ export const updateTournamentRegistrationArgs = v.object({ export const updateTournamentRegistration = async ( ctx: MutationCtx, args: Infer, -): Promise => { +): Promise => { const { _id, ...updated } = args; // ---- AUTH & VALIDATION CHECK ---- @@ -38,4 +39,6 @@ export const updateTournamentRegistration = async ( ...updated, modifiedAt: Date.now(), }); + + return { success: { message: 'Registration updated.' } }; }; diff --git a/convex/_model/tournamentRegistrations/table.ts b/convex/_model/tournamentRegistrations/table.ts index 3b8f6746..25965805 100644 --- a/convex/_model/tournamentRegistrations/table.ts +++ b/convex/_model/tournamentRegistrations/table.ts @@ -5,6 +5,8 @@ import { alignment } from '../common/alignment'; import { faction } from '../common/faction'; import { scoreAdjustment } from '../common/scoreAdjustment'; +// Deprecated: alignment and faction are now derived from the registration's list data. +// Kept for backward compat with existing registrations that predate list data entry. export const detailFields = v.object({ alignment: v.optional(alignment), faction: v.optional(faction), diff --git a/convex/_model/tournaments/table.ts b/convex/_model/tournaments/table.ts index 7f372e1a..23fb5a56 100644 --- a/convex/_model/tournaments/table.ts +++ b/convex/_model/tournaments/table.ts @@ -53,10 +53,11 @@ export const editableFields = { // Registrations requireRealNames: v.boolean(), - registrationDetails: v.optional(v.object({ - alignment: v.union(v.literal('optional'), v.literal('required'), v.null()), - faction: v.union(v.literal('optional'), v.literal('required'), v.null()), - })), + requireListMeta: v.optional(v.array(v.object({ + path: v.string(), + required: v.boolean(), + hidden: v.boolean(), + }))), alignmentsRevealed: v.optional(v.boolean()), factionsRevealed: v.optional(v.boolean()), diff --git a/convex/test.ts b/convex/_test.ts similarity index 100% rename from convex/test.ts rename to convex/_test.ts diff --git a/convex/migrations.ts b/convex/migrations.ts index 49fbd94a..120275e6 100644 --- a/convex/migrations.ts +++ b/convex/migrations.ts @@ -2,6 +2,7 @@ import { Migrations } from '@convex-dev/migrations'; import { components } from './_generated/api'; import { DataModel, Id } from './_generated/dataModel'; +import { checkListApproved } from './_model/lists'; import { refreshTournamentResult } from './_model/tournamentResults'; export const migrations = new Migrations(components.migrations); @@ -150,6 +151,123 @@ export const addTableCountToPairingConfig = migrations.define({ }, }); +export const linkLegacyListsToRegistrations = migrations.define({ + table: 'tournamentRegistrations', + migrateOne: async (ctx, doc) => { + if (!doc.listId) { + return; + } + const list = await ctx.db.get(doc.listId); + if (!list || list.tournamentRegistrationId) { + return; + } + await ctx.db.patch(doc.listId, { tournamentRegistrationId: doc._id }); + }, +}); + +export const backfillMatchResultLists = migrations.define({ + table: 'matchResults', + migrateOne: async (ctx, doc) => { + for (const slot of [0, 1] as const) { + const listIdKey = `player${slot}ListId` as const; + const userId = doc[`player${slot}UserId` as const]; + const factionKey = `player${slot}Faction`as const; + const faction = doc.details[factionKey]; + const { tournamentId, gameSystem } = doc; + + // If the doc has the list key, skip it. + if (doc[listIdKey]) { + continue; + } + + // If the doc has a faction set but no list attached and non-tournament: + if (faction && userId) { + + if (!tournamentId) { + + // Create a list from that faction and attach its id to the matchResult's playerXListId field + await ctx.db.patch(doc._id, { + [listIdKey]: await ctx.db.insert('lists', { + gameSystem, + userId, + locked: false, + data: { + meta: { + faction, + pointsLimit: doc.gameSystemConfig.points, + era: doc.gameSystemConfig.era, + }, + formations: [], + units: [], + commandCards: [], + }, + }), + details: { + ...doc.details, + [factionKey]: undefined, + }, + }); + } else { + + // Look up registration: + const tournamentRegistration = await ctx.db.query('tournamentRegistrations') + .withIndex('by_tournament_user', (q) => q.eq('tournamentId', tournamentId).eq('userId', userId)) + .first(); + if (!tournamentRegistration) { + continue; + } + + // Check for existing list: + const lists = (await ctx.db.query('lists') + .withIndex('by_tournament_registration', (q) => q.eq('tournamentRegistrationId', tournamentRegistration._id)) + .collect()) + .filter((l) => l.data?.meta?.faction === faction); + + if (lists.length) { + let approvedList; + for (const list of lists) { + if (await checkListApproved(ctx, list)) { + approvedList = list; + break; + } + } + await ctx.db.patch(doc._id, { + [listIdKey]: (approvedList ?? lists[0])._id, + details: { + ...doc.details, + [factionKey]: undefined, + }, + }); + } else { + await ctx.db.patch(doc._id, { + [listIdKey]: await ctx.db.insert('lists', { + gameSystem, + userId, + locked: true, + tournamentRegistrationId: tournamentRegistration._id, + data: { + meta: { + faction, + pointsLimit: doc.gameSystemConfig.points, + era: doc.gameSystemConfig.era, + }, + formations: [], + units: [], + commandCards: [], + }, + }), + details: { + ...doc.details, + [factionKey]: undefined, + }, + }); + } + } + } + } + }, +}); + export const convertPlayedAt = migrations.define({ table: 'matchResults', migrateOne: async (ctx, doc) => { diff --git a/convex/tournamentCompetitors.ts b/convex/tournamentCompetitors.ts index dc202007..a8bee58d 100644 --- a/convex/tournamentCompetitors.ts +++ b/convex/tournamentCompetitors.ts @@ -18,11 +18,6 @@ export const getTournamentCompetitorsByTournament = query({ }); // CRUD Operations -export const createTournamentCompetitor = mutation({ - args: model.createTournamentCompetitorArgs, - handler: model.createTournamentCompetitor, -}); - export const updateTournamentCompetitor = mutationWithTrigger({ args: model.updateTournamentCompetitorArgs, handler: model.updateTournamentCompetitor, diff --git a/package-lock.json b/package-lock.json index 852a266b..467e1cdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@fontsource/figtree": "^5.1.0", "@hookform/resolvers": "^3.9.0", "@ianpaschal/combat-command-components": "^2.1.1", - "@ianpaschal/combat-command-game-systems": "^1.6.0", + "@ianpaschal/combat-command-game-systems": "file:../combat-command-game-systems/ianpaschal-combat-command-game-systems-1.6.0.tgz", "@mapbox/search-js-core": "^1.0.0-beta.25", "@pdfme/pdf-lib": "^1.17.1", "@radix-ui/colors": "^3.0.0", @@ -1604,8 +1604,8 @@ }, "node_modules/@ianpaschal/combat-command-game-systems": { "version": "1.6.0", - "resolved": "https://npm.pkg.github.com/download/@ianpaschal/combat-command-game-systems/1.6.0/eb168ddef4637b0d96d605c373c0b300d1b47bfb", - "integrity": "sha512-nrptxRdRoDH0yV81eOYV0Dylnec1/LJqohmAwOdjbBojKRMTRjMVz2Ad58A/wEGFB0g0WkYNw01sfnJhYx+Gsg==", + "resolved": "file:../combat-command-game-systems/ianpaschal-combat-command-game-systems-1.6.0.tgz", + "integrity": "sha512-xcYGVQiA7agScarXK48ZHzgfcwToC9i4V3d5ZVjCYhpgBvAyJ4dl/PUq08eg3xbNp7JgS91hL8XsjLs0rBu+JQ==", "license": "UNLICENSED", "dependencies": { "zod": "^3.25.76" diff --git a/package.json b/package.json index b3e77ea1..6440eecb 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@fontsource/figtree": "^5.1.0", "@hookform/resolvers": "^3.9.0", "@ianpaschal/combat-command-components": "^2.1.1", - "@ianpaschal/combat-command-game-systems": "^1.6.0", + "@ianpaschal/combat-command-game-systems": "file:../combat-command-game-systems/ianpaschal-combat-command-game-systems-1.6.0.tgz", "@mapbox/search-js-core": "^1.0.0-beta.25", "@pdfme/pdf-lib": "^1.17.1", "@radix-ui/colors": "^3.0.0", diff --git a/src/api.ts b/src/api.ts index 1c220fb2..da41b4b3 100644 --- a/src/api.ts +++ b/src/api.ts @@ -18,6 +18,8 @@ export { type TournamentPairingPolicies, } from '../convex/_model/common/tournamentPairingConfig'; export type { + MutationIssue, + MutationResponse, RankingFactor, TournamentStatus, } from '../convex/_model/common/types'; diff --git a/src/components/ListDetails/ListDetails.tsx b/src/components/ListDetails/ListDetails.tsx index 37da0bdb..bb4914b7 100644 --- a/src/components/ListDetails/ListDetails.tsx +++ b/src/components/ListDetails/ListDetails.tsx @@ -45,7 +45,9 @@ export const ListDetails = ({ const scrollAreaRef = useRef(null); const [commentDrawerOpen, setCommentDrawerOpen] = useState(false); const { mutation: createListComment } = useCreateListComment(); - const { data: file, loading } = useGetFileMetadata(list ? { id: list.storageId } : 'skip'); + const { data: file, loading } = useGetFileMetadata(list?.storageId ? { + id: list.storageId, + } : 'skip'); useEffect(() => { const viewport = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]'); diff --git a/src/components/ListForm/ListForm.schema.ts b/src/components/ListForm/ListForm.schema.ts index 8aee10cc..3988755a 100644 --- a/src/components/ListForm/ListForm.schema.ts +++ b/src/components/ListForm/ListForm.schema.ts @@ -1,4 +1,11 @@ -import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; +import { GameSystem, getGameSystem } from '@ianpaschal/combat-command-game-systems/common'; +import { + listData as flamesOfWarV4ListData, + ListDataFormData as FlamesOfWarV4ListDataFormData, +} from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; +import { + ListDataFormData as TeamYankeeV2ListDataFormData, +} from '@ianpaschal/combat-command-game-systems/teamYankeeV2'; import { z } from 'zod'; import { @@ -7,63 +14,21 @@ import { UserId, } from '~/api'; -// FUTURE: Add data object sub-types -// export type FormationEntry = { -// id: string; -// sourceId: Unit; -// }; -// export type UnitEntry = { -// id: string; -// sourceId: Unit; -// formationId: string; -// slotId: string; -// }; -// export type CommandCardEntry = { -// id: string; -// sourceId: string; -// appliedTo: string; -// }; - -export const schema = z.object({ - gameSystem: z.nativeEnum(GameSystem), - storageId: z.string({ message: 'Please upload a list file.' }).transform((val) => val as StorageId), - tournamentRegistrationId: z.string().nullable().transform((val) => (val ?? undefined) as TournamentRegistrationId | undefined), - userId: z.string({ message: 'User is required.' }).transform((val) => val as UserId), - - // FUTURE: Add data object (use schema from combat-command-game-systems) - // data: z.object({ - // meta: z.object({ - // forceDiagram: z.nativeEnum(ForceDiagram, { - // message: 'Please select a force diagram.', - // }), - // pointsLimit: z.coerce.number().min(1, 'Points limit must be at least 1.'), - // }), - // formations: z.array(z.object({ - // id: z.string(), - // sourceId: z.nativeEnum(Unit, { - // message: 'Please select a formation.', - // }), - // })), - // units: z.array(z.object({ - // id: z.string(), - // sourceId: z.nativeEnum(Unit, { - // message: 'Please select a unit.', - // }), - // formationId: z.string(), - // slotId: z.string(), - // })), - // commandCards: z.array(z.object({ - // id: z.string(), - // sourceId: z.string(), - // appliedTo: z.string(), - // })), - // }), -}); +export const createSchema = (gameSystem: GameSystem) => { + const { listData } = getGameSystem(gameSystem); + return z.object({ + gameSystem: z.nativeEnum(GameSystem), + storageId: z.string({ message: 'Please upload a list file.' }).transform((val) => val as StorageId).optional(), + tournamentRegistrationId: z.string().nullable().transform((val) => (val ?? undefined) as TournamentRegistrationId | undefined), + userId: z.string({ message: 'User is required.' }).transform((val) => val as UserId), + data: listData.createSchema().optional(), + }); +}; /** * The output of successful form validation. */ -export type SubmitData = z.infer; +export type SubmitData = z.infer>; /** * The internal form state before validation (may contain missing or intermediate values). @@ -73,17 +38,7 @@ export type FormData = { storageId: StorageId | null; tournamentRegistrationId: TournamentRegistrationId | null; userId: UserId | null; - - // FUTURE: Add data object (use type from combat-command-game-systems) - // data: { - // meta: { - // forceDiagram: ForceDiagram; - // pointsLimit: number; - // }, - // formations: FormationEntry[]; - // units: UnitEntry[]; - // commandCards: CommandCardEntry[]; - // }, + data: FlamesOfWarV4ListDataFormData | TeamYankeeV2ListDataFormData; }; export const defaultValues: FormData = { @@ -91,15 +46,5 @@ export const defaultValues: FormData = { storageId: null, tournamentRegistrationId: null, userId: null, - - // FUTURE: Add data object (use default values from combat-command-game-systems) - // data: { - // meta: { - // forceDiagram: ForceDiagram.BerlinGerman, - // pointsLimit: 100, - // }, - // formations: [], - // units: [], - // commandCards: [], - // }, + data: flamesOfWarV4ListData.defaultValues, }; diff --git a/src/components/ListForm/ListForm.tsx b/src/components/ListForm/ListForm.tsx index 6ca36837..dfa689f6 100644 --- a/src/components/ListForm/ListForm.tsx +++ b/src/components/ListForm/ListForm.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { useDropzone } from 'react-dropzone'; import { SubmitHandler, useForm } from 'react-hook-form'; import { getStyleClassNames, PdfViewer } from '@ianpaschal/combat-command-components'; +import { getGameSystem } from '@ianpaschal/combat-command-game-systems/common'; import clsx from 'clsx'; import { merge } from 'lodash'; import { Upload } from 'lucide-react'; @@ -9,12 +10,13 @@ import { Upload } from 'lucide-react'; import { List } from '~/api'; import { useAuth } from '~/components/AuthProvider'; import { Form } from '~/components/generic/Form'; +import { ListMetaFields } from '~/components/ListMetaFields'; import { useGetFileMetadata, useUploadDocument } from '~/services/files'; import { validateForm } from '~/utils/validateForm'; import { + createSchema, defaultValues, FormData, - schema, SubmitData, } from './ListForm.schema'; @@ -30,6 +32,7 @@ export interface ListFormProps { className?: string; disabled?: boolean; forcedValues: Partial; + hideFileUpload?: boolean; id?: string; loading?: boolean; onSubmit: (data: SubmitData) => void; @@ -41,6 +44,7 @@ export const ListForm = ({ className, disabled = false, forcedValues, + hideFileUpload = false, id, loading, onSubmit, @@ -48,8 +52,11 @@ export const ListForm = ({ existingValues, }: ListFormProps): JSX.Element => { const user = useAuth(); + const gameSystem = forcedValues.gameSystem ?? defaultValues.gameSystem; + const { listData } = getGameSystem(gameSystem); + const form = useForm({ - defaultValues: merge({}, defaultValues, existingValues, forcedValues), + defaultValues: merge({}, defaultValues, { data: listData.defaultValues }, existingValues, forcedValues), mode: 'onSubmit', }); @@ -60,14 +67,13 @@ export const ListForm = ({ }, [isDirty, setDirty]); const handleSubmit: SubmitHandler = async (formData): Promise => { + const schema = createSchema(formData.gameSystem); const validFormData = validateForm(schema, merge({}, formData, forcedValues, { userId: user?._id }), form); if (validFormData) { onSubmit(validFormData); } }; - // const { getForceDiagramOptions } = getGameSystem(forcedValues.gameSystem); - // File upload const { mutation: uploadFile, loading: isUploadLoading } = useUploadDocument({ onSuccess: (id) => { @@ -92,59 +98,6 @@ export const ListForm = ({ }, }); - // const handleReplace = async (files: FileList): Promise => { - // if (files[0]) { - // await uploadFile(files[0]); - // } - // }; - - // File render - // const storageId = form.watch('storageId'); - // const { data: file } = useGetFileMetadata(storageId ? { id: storageId } : 'skip'); - - // const handleReplace = async (event: ChangeEvent): Promise => { - // if (!event.target.files || event.target.files.length === 0) { - // throw new Error('You must select an image to upload.'); - // } - // const file = event.target.files[0]; - // if (user && file) { - // await uploadFile(file); - // } - // }; - - // const handleDownload = async (): Promise => { - // if (!file) { - // return; - // } - // try { - // const response = await fetch(file.url); - // const blob = await response.blob(); - // const url = window.URL.createObjectURL(blob); - // const link = document.createElement('a'); - // link.href = url; - // link.download = `list.${file.url.split('.').pop()}`; - // document.body.appendChild(link); - // link.click(); - // document.body.removeChild(link); - // window.URL.revokeObjectURL(url); - // } catch (err) { - // console.error('Download failed:', err); - // } - // }; - - // FUTURE: Add formations array - // const { fields: formationFields, append: appendFormation, remove: removeFormation } = useFieldArray({ - // control: form.control, - // name: 'data.formations', - // }); - // const handleAddFormation = (e: MouseEvent): void => { - // e.preventDefault(); - // appendFormation({ - // id: nanoid(), - // sourceId: PLACEHOLDER_UNIT_OPTIONS[0].value, - // }); - // }; - const storageId = form.watch('storageId'); const { data: file, loading: isMetadataLoading } = useGetFileMetadata(storageId ? { id: storageId } : 'skip'); @@ -163,15 +116,18 @@ export const ListForm = ({ corners: 'normal', }))} file={file.url} /> )} -
- - - Drag files here or click to browse... -
+ {!hideFileUpload && ( +
+ + + Drag files here or click to browse... +
+ )} + ); }; diff --git a/src/components/ListManager/ListManager.module.scss b/src/components/ListManager/ListManager.module.scss new file mode 100644 index 00000000..67b13707 --- /dev/null +++ b/src/components/ListManager/ListManager.module.scss @@ -0,0 +1,25 @@ +@use "/src/style/flex"; +@use "/src/style/borders"; + +.ListManager { + @include flex.column; + + &_Items { + @include flex.column($gap: 0); + } + + &_AddButton { + margin-top: 0.5rem; + } + + &_Item { + @include flex.column; + @include borders.normal($side: bottom); + + padding: 0.75rem 0; + + &:first-child { + padding-top: 0; + } + } +} diff --git a/src/components/ListManager/ListManager.tsx b/src/components/ListManager/ListManager.tsx new file mode 100644 index 00000000..84f58786 --- /dev/null +++ b/src/components/ListManager/ListManager.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { Button } from '@ianpaschal/combat-command-components'; +import { Plus } from 'lucide-react'; + +import { + List, + TournamentRegistration, + TournamentRegistrationActionKey, +} from '~/api'; +import { useAuth } from '~/components/AuthProvider'; +import { EmptyState } from '~/components/EmptyState'; +import { SubmitData } from '~/components/ListForm/ListForm.schema'; +import { toast } from '~/components/ToastProvider'; +import { useTournament } from '~/components/TournamentProvider'; +import { useCreateList, useUpdateList } from '~/services/lists'; +import { ListManagerItem } from './components/ListManagerItem'; + +import styles from './ListManager.module.scss'; + +export interface ListManagerProps { + tournamentRegistration: TournamentRegistration; +} + +export const ListManager = ({ tournamentRegistration }: ListManagerProps): JSX.Element => { + const [addingNew, setAddingNew] = useState(false); + const tournament = useTournament(); + const user = useAuth(); + + const { mutation: createList } = useCreateList({ + onSuccess: () => { + toast.success('List added!'); + setAddingNew(false); + }, + }); + const { mutation: updateList } = useUpdateList({ + onSuccess: () => toast.success('List saved!'), + }); + + const forcedValues = { + gameSystem: tournament.gameSystem, + tournamentRegistrationId: tournamentRegistration._id, + }; + + const handleCreate = (data: SubmitData) => createList({ ...data, userId: user!._id }); + const handleUpdate = (list: List) => (data: SubmitData) => updateList({ _id: list._id, ...data, userId: user!._id }); + + const canAdd = tournamentRegistration.availableActions.includes(TournamentRegistrationActionKey.CreateList); + const lists = tournamentRegistration.lists; + + return ( +
+ {lists.length === 0 && !addingNew ? ( + + ) : ( +
+ {lists.map((list) => ( + + ))} + {addingNew && ( + + )} +
+ )} + {canAdd && !addingNew && ( +
+ ); +}; diff --git a/src/components/ListManager/components/ListManagerItem.module.scss b/src/components/ListManager/components/ListManagerItem.module.scss new file mode 100644 index 00000000..ae124a04 --- /dev/null +++ b/src/components/ListManager/components/ListManagerItem.module.scss @@ -0,0 +1,13 @@ +@use "/src/style/flex"; +@use "/src/style/borders"; + +.ListManagerItem { + @include flex.column; + @include borders.normal($side: bottom); + + padding: 0.75rem 0; + + &:first-child { + padding-top: 0; + } +} diff --git a/src/components/ListManager/components/ListManagerItem.tsx b/src/components/ListManager/components/ListManagerItem.tsx new file mode 100644 index 00000000..29032239 --- /dev/null +++ b/src/components/ListManager/components/ListManagerItem.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import { Button } from '@ianpaschal/combat-command-components'; + +import { List, ListActionKey } from '~/api'; +import { ListForm } from '~/components/ListForm'; +import { SubmitData } from '~/components/ListForm/ListForm.schema'; + +import styles from './ListManagerItem.module.scss'; + +const FORM_ID_PREFIX = 'list-manager'; + +interface ListManagerItemProps { + list?: List; + forcedValues: Partial; + onSubmit: (data: SubmitData) => void; +} + +export const ListManagerItem = ({ + list, + forcedValues, + onSubmit, +}: ListManagerItemProps): JSX.Element => { + const [isDirty, setIsDirty] = useState(false); + const canEdit = !list || list.availableActions.includes(ListActionKey.Update); + const formId = list ? `${FORM_ID_PREFIX}-${list._id}` : `${FORM_ID_PREFIX}-new`; + + return ( +
+ + {canEdit && ( +
+ ); +}; diff --git a/src/components/ListManager/index.ts b/src/components/ListManager/index.ts new file mode 100644 index 00000000..14ed907d --- /dev/null +++ b/src/components/ListManager/index.ts @@ -0,0 +1 @@ +export * from './ListManager'; diff --git a/src/components/ListMetaFields/ListMetaFields.schema.ts b/src/components/ListMetaFields/ListMetaFields.schema.ts new file mode 100644 index 00000000..21a7fb4d --- /dev/null +++ b/src/components/ListMetaFields/ListMetaFields.schema.ts @@ -0,0 +1,10 @@ +import { listData as flamesOfWarV4 } from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; +import { listData as teamYankeeV2 } from '@ianpaschal/combat-command-game-systems/teamYankeeV2'; +import { z } from 'zod'; + +export const listMeta = z.union([ + flamesOfWarV4.schema, + teamYankeeV2.schema, +]); + +export type ListMeta = z.infer; diff --git a/src/components/ListMetaFields/ListMetaFields.tsx b/src/components/ListMetaFields/ListMetaFields.tsx new file mode 100644 index 00000000..ed40faa4 --- /dev/null +++ b/src/components/ListMetaFields/ListMetaFields.tsx @@ -0,0 +1,25 @@ +import { useFormContext } from 'react-hook-form'; +import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; + +import { FlamesOfWarV4ListMetaFields } from './gameSystems/FlamesOfWarV4ListMetaFields'; +import { TeamYankeeV2ListMetaFields } from './gameSystems/TeamYankeeV2ListMetaFields'; + +export interface ListMetaFieldsProps { + className?: string; + namePrefix?: string; +} + +export const ListMetaFields = (props: ListMetaFieldsProps): JSX.Element => { + const { watch } = useFormContext(); + const gameSystem = watch('gameSystem'); + + if (gameSystem === GameSystem.FlamesOfWarV4) { + return ; + } + + if (gameSystem === GameSystem.TeamYankeeV2) { + return ; + } + + throw new Error(`Could not find for game system ${gameSystem}`); +}; diff --git a/src/components/ListMetaFields/gameSystems/FlamesOfWarV4ListMetaFields/FlamesOfWarV4ListMetaFields.tsx b/src/components/ListMetaFields/gameSystems/FlamesOfWarV4ListMetaFields/FlamesOfWarV4ListMetaFields.tsx new file mode 100644 index 00000000..ec38ba8b --- /dev/null +++ b/src/components/ListMetaFields/gameSystems/FlamesOfWarV4ListMetaFields/FlamesOfWarV4ListMetaFields.tsx @@ -0,0 +1,51 @@ +import { useFormContext } from 'react-hook-form'; +import { InputText, Select } from '@ianpaschal/combat-command-components'; +import { + getAlignmentOptions, + getEraOptions, + getFactionOptions, + getForceDiagramOptions, +} from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; + +import { FormField } from '~/components/generic/Form'; +import { useAutoFillSelect } from '~/hooks/useAutoFillSelect'; +import { CompatibleFormData } from './FlamesOfWarV4ListMetaFields.types'; + +export interface FlamesOfWarV4ListMetaFieldsProps { + className?: string; + namePrefix?: string; +} + +export const FlamesOfWarV4ListMetaFields = ({ + className, + namePrefix = 'data.meta', +}: FlamesOfWarV4ListMetaFieldsProps): JSX.Element => { + const { watch } = useFormContext(); + const forceDiagram = watch(`${namePrefix}.forceDiagram` as 'data.meta.forceDiagram'); + + const forceDiagramOptions = getForceDiagramOptions(); + const factionOptions = getFactionOptions(); + const alignmentOptions = getAlignmentOptions(); + const eraOptions = getEraOptions(); + useAutoFillSelect(eraOptions, `${namePrefix}.era` as 'data.meta.era'); + + return ( +
+ + + + + + +
+ ); +}; diff --git a/src/components/ListMetaFields/gameSystems/FlamesOfWarV4ListMetaFields/FlamesOfWarV4ListMetaFields.types.ts b/src/components/ListMetaFields/gameSystems/FlamesOfWarV4ListMetaFields/FlamesOfWarV4ListMetaFields.types.ts new file mode 100644 index 00000000..8bcb6c23 --- /dev/null +++ b/src/components/ListMetaFields/gameSystems/FlamesOfWarV4ListMetaFields/FlamesOfWarV4ListMetaFields.types.ts @@ -0,0 +1,7 @@ +import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; +import { ListDataFormData } from '@ianpaschal/combat-command-game-systems/flamesOfWarV4'; + +export type CompatibleFormData = { + gameSystem: GameSystem.FlamesOfWarV4; + data: ListDataFormData; +}; diff --git a/src/components/ListMetaFields/gameSystems/FlamesOfWarV4ListMetaFields/index.ts b/src/components/ListMetaFields/gameSystems/FlamesOfWarV4ListMetaFields/index.ts new file mode 100644 index 00000000..fe019f16 --- /dev/null +++ b/src/components/ListMetaFields/gameSystems/FlamesOfWarV4ListMetaFields/index.ts @@ -0,0 +1,4 @@ +export { + FlamesOfWarV4ListMetaFields, + type FlamesOfWarV4ListMetaFieldsProps, +} from './FlamesOfWarV4ListMetaFields'; diff --git a/src/components/ListMetaFields/gameSystems/TeamYankeeV2ListMetaFields/TeamYankeeV2ListMetaFields.tsx b/src/components/ListMetaFields/gameSystems/TeamYankeeV2ListMetaFields/TeamYankeeV2ListMetaFields.tsx new file mode 100644 index 00000000..d5110304 --- /dev/null +++ b/src/components/ListMetaFields/gameSystems/TeamYankeeV2ListMetaFields/TeamYankeeV2ListMetaFields.tsx @@ -0,0 +1,51 @@ +import { useFormContext } from 'react-hook-form'; +import { InputText, Select } from '@ianpaschal/combat-command-components'; +import { + getAlignmentOptions, + getEraOptions, + getFactionOptions, + getForceDiagramOptions, +} from '@ianpaschal/combat-command-game-systems/teamYankeeV2'; + +import { FormField } from '~/components/generic/Form'; +import { useAutoFillSelect } from '~/hooks/useAutoFillSelect'; +import { CompatibleFormData } from './TeamYankeeV2ListMetaFields.types'; + +export interface TeamYankeeV2ListMetaFieldsProps { + className?: string; + namePrefix?: string; +} + +export const TeamYankeeV2ListMetaFields = ({ + className, + namePrefix = 'data.meta', +}: TeamYankeeV2ListMetaFieldsProps): JSX.Element => { + const { watch } = useFormContext(); + const forceDiagram = watch(`${namePrefix}.forceDiagram` as 'data.meta.forceDiagram'); + + const forceDiagramOptions = getForceDiagramOptions(); + const factionOptions = getFactionOptions(); + const alignmentOptions = getAlignmentOptions(); + const eraOptions = getEraOptions(); + useAutoFillSelect(eraOptions, `${namePrefix}.era` as 'data.meta.era'); + + return ( +
+ + + + + + +
+ ); +}; diff --git a/src/components/ListMetaFields/gameSystems/TeamYankeeV2ListMetaFields/TeamYankeeV2ListMetaFields.types.ts b/src/components/ListMetaFields/gameSystems/TeamYankeeV2ListMetaFields/TeamYankeeV2ListMetaFields.types.ts new file mode 100644 index 00000000..d068adfe --- /dev/null +++ b/src/components/ListMetaFields/gameSystems/TeamYankeeV2ListMetaFields/TeamYankeeV2ListMetaFields.types.ts @@ -0,0 +1,7 @@ +import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; +import { ListDataFormData } from '@ianpaschal/combat-command-game-systems/teamYankeeV2'; + +export type CompatibleFormData = { + gameSystem: GameSystem.TeamYankeeV2; + data: ListDataFormData; +}; diff --git a/src/components/ListMetaFields/gameSystems/TeamYankeeV2ListMetaFields/index.ts b/src/components/ListMetaFields/gameSystems/TeamYankeeV2ListMetaFields/index.ts new file mode 100644 index 00000000..ec4a8a42 --- /dev/null +++ b/src/components/ListMetaFields/gameSystems/TeamYankeeV2ListMetaFields/index.ts @@ -0,0 +1,4 @@ +export { + TeamYankeeV2ListMetaFields, + type TeamYankeeV2ListMetaFieldsProps, +} from './TeamYankeeV2ListMetaFields'; diff --git a/src/components/ListMetaFields/index.ts b/src/components/ListMetaFields/index.ts new file mode 100644 index 00000000..b9605732 --- /dev/null +++ b/src/components/ListMetaFields/index.ts @@ -0,0 +1,2 @@ +export * from './ListMetaFields'; +export * from './ListMetaFields.schema'; diff --git a/src/components/MatchResultForm/MatchResultForm.schema.ts b/src/components/MatchResultForm/MatchResultForm.schema.ts index 4e5745ea..5210b626 100644 --- a/src/components/MatchResultForm/MatchResultForm.schema.ts +++ b/src/components/MatchResultForm/MatchResultForm.schema.ts @@ -11,20 +11,38 @@ import { } from '@ianpaschal/combat-command-game-systems/teamYankeeV2'; import { z } from 'zod'; -import { TournamentPairingId, UserId } from '~/api'; +import { + ListId, + TournamentPairingId, + UserId, +} from '~/api'; export const createSchema = (gameSystem: GameSystem) => { const { matchResultDetails, gameSystemConfig } = getGameSystem(gameSystem); return z.object({ - tournamentPairingId: z.union([z.null().transform(() => undefined), z.string().transform((val) => val as TournamentPairingId)]), - player0Placeholder: z.optional(z.string()), - player0UserId: z.union([z.null().transform(() => undefined), z.undefined(), z.string().transform((val) => val.length ? val as UserId : undefined)]), - player1Placeholder: z.optional(z.string()), - player1UserId: z.union([z.null().transform(() => undefined), z.undefined(), z.string().transform((val) => val.length ? val as UserId : undefined)]), - - // Non-editable gameSystem: z.nativeEnum(GameSystem), playedAt: z.number(), + player0ListId: z.union([ + z.null().transform(() => undefined), + z.string().transform((val) => val as ListId), + ]).optional(), + player0Placeholder: z.optional(z.string()), + player0UserId: z.union([ + z.null().transform(() => undefined), + z.undefined(), + z.string().transform((val) => val.length ? val as UserId : undefined), + ]), + player1ListId: z.union([ + z.null().transform(() => undefined), + z.string().transform((val) => val as ListId), + ]).optional(), + player1Placeholder: z.optional(z.string()), + player1UserId: z.union([ + z.null().transform(() => undefined), + z.undefined(), + z.string().transform((val) => val.length ? val as UserId : undefined), + ]), + tournamentPairingId: z.union([z.null().transform(() => undefined), z.string().transform((val) => val as TournamentPairingId)]), }).extend({ details: matchResultDetails.schema, gameSystemConfig: gameSystemConfig.schema, @@ -33,7 +51,7 @@ export const createSchema = (gameSystem: GameSystem) => { if (!data[`player${i}UserId`] && !data[`player${i}Placeholder`]) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Required', + message: 'Required', path: [`player${i}UserId`], }); } @@ -44,25 +62,29 @@ export const createSchema = (gameSystem: GameSystem) => { export type SubmitData = z.infer>; export type FormData = { - tournamentPairingId: TournamentPairingId | null; details: FlamesOfWarV4MatchResultDetails | TeamYankeeV2MatchResultDetails, + gameSystem: GameSystem, + gameSystemConfig: FlamesOfWarV4GameSystemConfig | TeamYankeeV2GameSystemConfig, + playedAt: number; + player0ListId: ListId | null; player0Placeholder: string; player0UserId: UserId | null; + player1ListId: ListId | null; player1Placeholder: string; player1UserId: UserId | null; - gameSystem: GameSystem, - gameSystemConfig: FlamesOfWarV4GameSystemConfig | TeamYankeeV2GameSystemConfig, - playedAt: number; + tournamentPairingId: TournamentPairingId | null; }; export const defaultValues: FormData = { - tournamentPairingId: null, details: flamesOfWarV4MatchResultDetails.defaultValues, - player0Placeholder: '', - player0UserId: '' as UserId, - player1Placeholder: '', - player1UserId: '' as UserId, gameSystem: GameSystem.FlamesOfWarV4, gameSystemConfig: flamesOfWarV4GameSystemConfig.defaultValues, playedAt: Date.now(), + player0ListId: null, + player0Placeholder: '', + player0UserId: null, + player1ListId: null, + player1Placeholder: '', + player1UserId: null, + tournamentPairingId: null, }; diff --git a/src/components/MatchResultForm/components/MatchResultListField/MatchResultListField.module.scss b/src/components/MatchResultForm/components/MatchResultListField/MatchResultListField.module.scss new file mode 100644 index 00000000..2ef8a698 --- /dev/null +++ b/src/components/MatchResultForm/components/MatchResultListField/MatchResultListField.module.scss @@ -0,0 +1,8 @@ +.MatchResultListField { + display: contents; +} + +.MatchResultListField_Actions { + display: flex; + gap: var(--spacing-2); +} diff --git a/src/components/MatchResultForm/components/MatchResultListField/MatchResultListField.tsx b/src/components/MatchResultForm/components/MatchResultListField/MatchResultListField.tsx new file mode 100644 index 00000000..c6b9929d --- /dev/null +++ b/src/components/MatchResultForm/components/MatchResultListField/MatchResultListField.tsx @@ -0,0 +1,121 @@ +import { MouseEvent } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { + Button, + Select, + SelectOption, +} from '@ianpaschal/combat-command-components'; +import { GameSystem } from '@ianpaschal/combat-command-game-systems/common'; + +import { ListId, TournamentPairing } from '~/api'; +import { FormField } from '~/components/generic/Form'; +import { ListForm } from '~/components/ListForm'; +import { useFormDialog } from '~/hooks/useFormDialog'; +import { useCreateList, useUpdateList } from '~/services/lists'; + +import styles from './MatchResultListField.module.scss'; + +type CompatibleFormData = { + player0UserId: string | null; + player0ListId: ListId | null; + player1UserId: string | null; + player1ListId: ListId | null; + tournamentPairingId: string | null; + gameSystem: GameSystem; +}; + +export interface MatchResultListFieldProps { + index: 0 | 1; + tournamentPairing?: TournamentPairing | null; +} + +export const MatchResultListField = ({ + index, + tournamentPairing, +}: MatchResultListFieldProps): JSX.Element => { + const { watch, setValue } = useFormContext(); + + const userId = watch(`player${index}UserId`); + const tournamentPairingId = watch('tournamentPairingId'); + const gameSystem = watch('gameSystem'); + const selectedListId = watch(`player${index}ListId`); + + const competitor = tournamentPairing?.[`tournamentCompetitor${index}`]; + const registration = (competitor?.registrations ?? []).find((r) => r.userId === userId); + const allLists = registration?.lists ?? []; + + const existingListOptions: SelectOption[] = allLists.map((l) => ({ + value: l._id, + label: l.data?.meta?.faction ?? l._id, + })); + + const selectedList = allLists.find((l) => l._id === selectedListId) ?? null; + + const { mutation: createList } = useCreateList({ + onSuccess: (id) => { + setValue(`player${index}ListId`, id as ListId, { shouldDirty: true }); + closeCreate(); + }, + }); + + const { mutation: updateList } = useUpdateList({ + onSuccess: () => { + closeEdit(); + }, + }); + + const { open: openCreate, close: closeCreate } = useFormDialog({ + formId: `create-match-result-list-${index}`, + title: 'Create List', + submitLabel: 'Create', + content: ( + createList(data)} + /> + ), + }); + + const { open: openEdit, close: closeEdit } = useFormDialog({ + formId: `edit-match-result-list-${index}`, + title: 'Edit List', + submitLabel: 'Save', + content: ( + selectedList && updateList({ _id: selectedList._id, ...data })} + /> + ), + }); + + if (!tournamentPairingId) { + return <>; + } + + const handleClickCreate = (e: MouseEvent): void => { + e.preventDefault(); + openCreate(); + }; + + const handleClickEdit = (e: MouseEvent): void => { + e.preventDefault(); + openEdit(); + }; + + return ( + <> + + - + + { - const { watch, setValue, reset } = useFormContext(); + const { reset } = useFormContext(); - // Get options: - const factionOptions = getFactionOptions(); const battlePlanOptions = getBattlePlanOptions(); - // Automatically set faction if possible: - const faction = watch(`details.player${index}Faction`) as Faction; - const factions = useMemo(() => { - const selectedUserId = watch(`player${index}UserId`) as UserId | null; - const registration = (tournamentPairing?.[`tournamentCompetitor${index}`]?.registrations ?? []).find( - (r) => r.userId === selectedUserId, - ); - if (registration) { - return registration.factions.filter((f): f is Faction => VALID_FACTIONS.includes(f)); - } - return []; - }, [tournamentPairing, index, watch]); - useEffect(() => { - if (factions.length > 0 && !faction) { - setValue(`details.player${index}Faction`, factions[0]); - } - }, [factions, faction, setValue, index]); - const handleChangeBattlePlan = (value: SelectValue | null): void => { if (!value) { return; @@ -70,10 +45,8 @@ export const TeamYankeeV2MatchResultPlayerFields = ({ // Preserve per-player fields (and update the required field): player0BattlePlan: index === 0 ? value as BattlePlan : prev.details.player0BattlePlan, - player0Faction: prev.details.player0Faction, player0UnitsLost: prev.details.player0UnitsLost, player1BattlePlan: index === 1 ? value as BattlePlan : prev.details.player1BattlePlan, - player1Faction: prev.details.player1Faction, player1UnitsLost: prev.details.player1UnitsLost, }, }), { keepDirty: true }); @@ -81,14 +54,8 @@ export const TeamYankeeV2MatchResultPlayerFields = ({ return (
- - - - - - - - - - -