diff --git a/backend/package.json b/backend/package.json index eb49e8f..6238e33 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.14.0", + "version": "1.14.1", "private": true, "type": "commonjs", "main": "src/index.js", diff --git a/backend/src/routes/api.integration.test.js b/backend/src/routes/api.integration.test.js index c43a70a..3a225f6 100644 --- a/backend/src/routes/api.integration.test.js +++ b/backend/src/routes/api.integration.test.js @@ -1718,13 +1718,31 @@ describe('ordering, archiving and deleting encounters', () => { } const names = async (auth) => (await request(app).get(`${c}/encounters`).set(auth)).body.encounters.map((e) => e.name); - // New encounters go to the top. - expect(await names(dmAuth)).toEqual(['Three', 'Two', 'One']); + // New encounters go to the bottom. + expect(await names(dmAuth)).toEqual(['One', 'Two', 'Three']); const order = (encounterIds, auth = dmAuth) => request(app).put(`${c}/encounters/order`).set(auth).send({ encounterIds }); - expect((await order([ids.One, ids.Two, ids.Three], playerAuth)).status).toBe(403); + expect((await order([ids.Three, ids.Two, ids.One], playerAuth)).status).toBe(403); expect((await order([ids.One, ids.One])).status).toBe(400); expect((await order([ids.One, 'not-an-encounter'])).status).toBe(400); + expect((await order([ids.Three, ids.One, ids.Two])).status).toBe(204); + expect(await names(playerAuth)).toEqual(['Three', 'One', 'Two']); + + // A new one, of either kind, still lands at the bottom of a rearranged list, + // unless the DM says where: the top, or straight after another. + const add = async (body) => (await request(app).post(`${c}/encounters`).set(dmAuth).send(body)).body.encounter.id; + const extra = [await add({ name: 'Four', mode: 'QUEST' })]; + expect(await names(dmAuth)).toEqual(['Three', 'One', 'Two', 'Four']); + extra.push(await add({ name: 'First', position: 'TOP' })); + expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'Two', 'Four']); + extra.push(await add({ name: 'After One', mode: 'QUEST', afterEncounterId: ids.One })); + expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'After One', 'Two', 'Four']); + extra.push(await add({ name: 'Last', position: 'BOTTOM' })); + extra.push(await add({ name: 'After Last', position: 'TOP', afterEncounterId: extra[3] })); + expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'After One', 'Two', 'Four', 'Last', 'After Last']); + expect((await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Lost', afterEncounterId: 'not-an-encounter' })).status).toBe(400); + expect((await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Lost', position: 'MIDDLE' })).status).toBe(400); + for (const id of extra) await request(app).delete(`/encounters/${id}`).set(dmAuth); expect((await order([ids.One, ids.Two, ids.Three])).status).toBe(204); expect(await names(playerAuth)).toEqual(['One', 'Two', 'Three']); diff --git a/backend/src/routes/campaigns.routes.js b/backend/src/routes/campaigns.routes.js index f1465e1..cb469f7 100644 --- a/backend/src/routes/campaigns.routes.js +++ b/backend/src/routes/campaigns.routes.js @@ -706,6 +706,9 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(), // Encounters // --------------------------------------------------------------------------- +// The campaign's own order, which the DM arranges. +const ENCOUNTER_ORDER = [{ sortOrder: 'asc' }, { createdAt: 'asc' }]; + router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => { try { const isDM = req.membership.role === 'DM'; @@ -714,7 +717,7 @@ router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => const mine = { archivedAt: null, participants: { some: { character: { userId: req.user.id } } } }; const encounters = await prisma.encounter.findMany({ where: { campaignId: req.campaign.id, ...(isDM ? {} : mine) }, - orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }], + orderBy: ENCOUNTER_ORDER, }); // dmNotes never leaves the server for a player. res.json({ encounters: isDM ? encounters : encounters.map(({ dmNotes, ...encounter }) => encounter) }); @@ -728,21 +731,48 @@ const createEncounterSchema = z.object({ mode: z.enum(['BATTLE', 'QUEST']).optional(), description: z.string().max(4000).optional().nullable(), locationId: z.string().optional().nullable(), + // Where it goes in the campaign's order: the top, the bottom, or straight + // after another encounter (which wins over `position`). The bottom if + // neither is given. + position: z.enum(['TOP', 'BOTTOM']).optional(), + afterEncounterId: z.string().optional().nullable(), }); router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req, res, next) => { try { const parsed = createEncounterSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() }); - if (parsed.data.locationId) { - const location = await prisma.location.findFirst({ where: { id: parsed.data.locationId, campaignId: req.campaign.id } }); + const { position, afterEncounterId, ...fields } = parsed.data; + if (fields.locationId) { + const location = await prisma.location.findFirst({ where: { id: fields.locationId, campaignId: req.campaign.id } }); if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' }); } - // A new encounter goes to the top of the campaign's order. - const first = await prisma.encounter.aggregate({ where: { campaignId: req.campaign.id }, _min: { sortOrder: true } }); - const encounter = await prisma.encounter.create({ - data: { ...parsed.data, campaignId: req.campaign.id, sortOrder: (first._min.sortOrder ?? 1) - 1 }, + const order = await prisma.encounter.findMany({ + where: { campaignId: req.campaign.id }, + orderBy: ENCOUNTER_ORDER, + select: { id: true, sortOrder: true }, + }); + let index = position === 'TOP' ? 0 : order.length; + if (afterEncounterId) { + const at = order.findIndex((e) => e.id === afterEncounterId); + if (at === -1) return res.status(400).json({ error: 'The encounter to put this one after is not part of this campaign.' }); + index = at + 1; + } + + const encounter = await prisma.$transaction(async (tx) => { + // At the bottom nothing else moves. Anywhere else, the whole order is + // numbered afresh around the newcomer. + if (index === order.length) { + const last = order.length > 0 ? order[order.length - 1].sortOrder : -1; + return tx.encounter.create({ data: { ...fields, campaignId: req.campaign.id, sortOrder: last + 1 } }); + } + const created = await tx.encounter.create({ data: { ...fields, campaignId: req.campaign.id, sortOrder: index } }); + for (const [i, existing] of order.entries()) { + const sortOrder = i < index ? i : i + 1; + if (existing.sortOrder !== sortOrder) await tx.encounter.update({ where: { id: existing.id }, data: { sortOrder } }); + } + return created; }); res.status(201).json({ encounter }); } catch (err) { diff --git a/docs/ENCOUNTERS.md b/docs/ENCOUNTERS.md index 5e75049..c3153fe 100644 --- a/docs/ENCOUNTERS.md +++ b/docs/ENCOUNTERS.md @@ -84,8 +84,10 @@ the encounter does not take a visited place away again. **Order.** The campaign page lists encounters on its "Overview", under the characters, in the **campaign order**, which -the DM arranges with the ▲ and ▼ buttons and everyone sees. A new encounter -goes to the top. The "Sort by" menu re-sorts the list by name, status, type or +the DM arranges with the ▲ and ▼ buttons and everyone sees. A new encounter, +battle or quest, goes to the **bottom** unless the DM picks otherwise in the +menu beside the name when adding it: at the bottom, at the top, or straight +after any encounter on the list. Top or bottom is remembered per browser. The "Sort by" menu re-sorts the list by name, status, type or date instead; that choice is remembered per browser and changes nothing for anyone else. ▲ and ▼ only show while sorting by campaign order. diff --git a/frontend/package.json b/frontend/package.json index 0c0c850..2fb2f1e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.14.0", + "version": "1.14.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts index c62acde..e58bd3d 100644 --- a/frontend/src/api/queries.ts +++ b/frontend/src/api/queries.ts @@ -735,7 +735,8 @@ export function useCampaignEncounters(campaignId: string | undefined) { export function useCreateEncounter(campaignId: string) { const qc = useQueryClient(); return useMutation({ - mutationFn: async (data: { name: string; mode?: EncounterMode }) => + // Where it goes in the campaign's order: the top, the bottom (the default), or straight after another. + mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string }) => (await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, data)).data.encounter, onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }), }); diff --git a/frontend/src/pages/CampaignHomePage.tsx b/frontend/src/pages/CampaignHomePage.tsx index 6722383..7a66872 100644 --- a/frontend/src/pages/CampaignHomePage.tsx +++ b/frontend/src/pages/CampaignHomePage.tsx @@ -62,6 +62,15 @@ function storedEncounterSort(): EncounterSort { return stored && stored in ENCOUNTER_SORT_LABELS ? (stored as EncounterSort) : 'custom'; } +// Where a new encounter goes in the campaign's order: the bottom, the top, or +// straight after another one ("after:"). Top or bottom is remembered. +const ENCOUNTER_PLACE_KEY = 'dnd_encounter_place'; +const AFTER = 'after:'; + +function storedEncounterPlace(): string { + return localStorage.getItem(ENCOUNTER_PLACE_KEY) === 'TOP' ? 'TOP' : 'BOTTOM'; +} + // `encounters` arrives in campaign order, and the sort is stable, so ties // (same status, same type) stay in that order. function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter[] { @@ -161,6 +170,7 @@ export function CampaignHomePage() { const [newCharName, setNewCharName] = useState(''); const [newEncounterName, setNewEncounterName] = useState(''); const [newEncounterMode, setNewEncounterMode] = useState('BATTLE'); + const [newEncounterPlace, setNewEncounterPlace] = useState(storedEncounterPlace); const [encounterSort, setEncounterSort] = useState(storedEncounterSort); const [showArchivedEncounters, setShowArchivedEncounters] = useState(false); const [noteBody, setNoteBody] = useState(''); @@ -173,6 +183,8 @@ export function CampaignHomePage() { const [deleteConfirmName, setDeleteConfirmName] = useState(''); const liveEncounters = encounters?.filter((e) => !e.archivedAt) || []; + // An encounter picked to go after may have been archived or deleted since. + const encounterPlace = newEncounterPlace.startsWith(AFTER) && !liveEncounters.some((e) => AFTER + e.id === newEncounterPlace) ? 'BOTTOM' : newEncounterPlace; const archivedEncounters = encounters?.filter((e) => e.archivedAt) || []; const shownEncounters = sortEncounters(liveEncounters, encounterSort); const encountersPage = usePagination(shownEncounters, 10); @@ -208,6 +220,9 @@ export function CampaignHomePage() { const encounter = await createEncounter.mutateAsync({ name: newEncounterName || (newEncounterMode === 'QUEST' ? 'New Quest' : 'New Encounter'), mode: newEncounterMode, + ...(encounterPlace.startsWith(AFTER) + ? { afterEncounterId: encounterPlace.slice(AFTER.length) } + : { position: encounterPlace === 'TOP' ? ('TOP' as const) : ('BOTTOM' as const) }), }); navigate(`/encounters/${encounter.id}`); } catch (err) { @@ -501,6 +516,28 @@ export function CampaignHomePage() { + {liveEncounters.length > 0 && ( + + )} diff --git a/package-lock.json b/package-lock.json index c1b768e..2ce865c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "dnd-campaign-manager", - "version": "1.14.0", + "version": "1.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dnd-campaign-manager", - "version": "1.14.0", + "version": "1.14.1", "workspaces": [ "backend", "frontend" ] }, "backend": { - "version": "1.14.0", + "version": "1.14.1", "dependencies": { "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", @@ -33,7 +33,7 @@ } }, "frontend": { - "version": "1.14.0", + "version": "1.14.1", "dependencies": { "@tanstack/react-query": "^5.103.2", "axios": "^1.20.0", diff --git a/package.json b/package.json index e978bf1..315d2ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dnd-campaign-manager", - "version": "1.14.0", + "version": "1.14.1", "private": true, "description": "D&D 5e campaign manager — monorepo root", "workspaces": [