V1.14.1: choose where a new encounter goes
- A new encounter, battle or quest, goes to the bottom of the campaign's encounter list. It used to go to the top. - A menu beside the name when adding one says where it goes instead: at the bottom, at the top, or straight after any encounter on the list. Top or bottom is remembered per browser. - All packages at version 1.14.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.14.0",
|
||||
"version": "1.14.1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.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']);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+4
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.14.0",
|
||||
"version": "1.14.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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'] }),
|
||||
});
|
||||
|
||||
@@ -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:<id>"). 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<EncounterMode>('BATTLE');
|
||||
const [newEncounterPlace, setNewEncounterPlace] = useState(storedEncounterPlace);
|
||||
const [encounterSort, setEncounterSort] = useState<EncounterSort>(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() {
|
||||
<option value="BATTLE">Battle</option>
|
||||
<option value="QUEST">NPC quest</option>
|
||||
</select>
|
||||
{liveEncounters.length > 0 && (
|
||||
<select
|
||||
aria-label="Where the new encounter goes in the list"
|
||||
title="Where it goes in the campaign's order. ▲ and ▼ move it afterwards."
|
||||
value={encounterPlace}
|
||||
onChange={(e) => {
|
||||
setNewEncounterPlace(e.target.value);
|
||||
if (!e.target.value.startsWith(AFTER)) localStorage.setItem(ENCOUNTER_PLACE_KEY, e.target.value);
|
||||
}}
|
||||
style={{ maxWidth: 220 }}
|
||||
>
|
||||
<option value="BOTTOM">At the bottom of the list</option>
|
||||
<option value="TOP">At the top of the list</option>
|
||||
<optgroup label="Straight after…">
|
||||
{liveEncounters.map((e) => (
|
||||
<option key={e.id} value={AFTER + e.id}>
|
||||
After {e.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={createEncounter.isPending}>
|
||||
{newEncounterMode === 'QUEST' ? '📜 Start Quest' : '⚔ Start Encounter'}
|
||||
</button>
|
||||
|
||||
Generated
+4
-4
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user