diff --git a/services/frontend/src/components/island/SeatRows.vue b/services/frontend/src/components/island/SeatRows.vue
index 3bfe74a33..9ebab8087 100644
--- a/services/frontend/src/components/island/SeatRows.vue
+++ b/services/frontend/src/components/island/SeatRows.vue
@@ -30,12 +30,26 @@ export interface SeatRow {
srcset?: string
}
-const props = defineProps<{
+const props = withDefaults(defineProps<{
rows: SeatRow[]
/** The colour the plates, the roles and the chevrons are drawn in. */
accent: string
/** What each row's data-testid is built from, since a page names its own rows. */
testidPrefix: string
+ /**
+ * Whether a row offers a way to change it, and the stack a way to add another.
+ *
+ * Decided by the page, which knows who is reading. Nothing here is a guard: the rows are
+ * handed a boolean, and what a refused request does is the api's answer.
+ */
+ mayEdit?: boolean
+ /** What the way in at the end of the stack is called, in whatever a row is called here. */
+ addLabel?: string
+}>(), {mayEdit: false, addLabel: "Add"})
+
+const emit = defineEmits<{
+ (event: "edit", id: SeatRow["id"]): void
+ (event: "add"): void
}>()
/**
@@ -77,6 +91,7 @@ const toggle = (row: SeatRow) => {
+
+
+
+
+
+
@@ -187,6 +254,7 @@ const toggle = (row: SeatRow) => {
}
.seat-row {
+ position: relative;
border-bottom: 1px solid var(--color-hairline);
}
@@ -213,6 +281,84 @@ button.seat-row__head {
cursor: pointer;
}
+/* Room for the pencil, so it never lands on the chevron beside it. Taken on every row rather
+ than only the ones with a chevron, or the column of them would be ragged. */
+.seat-rows--editable .seat-row__head {
+ padding-right: 3rem;
+}
+
+/*
+ * Hidden rather than transparent, for the reason the strip's own affordance gives: one that is
+ * merely see-through still answers a click, and a test asking whether it is on screen would be
+ * told that it is.
+ */
+.seat-row__edit {
+ position: absolute;
+ top: 0.85rem;
+ right: 0.35rem;
+ z-index: 2;
+ visibility: hidden;
+ display: grid;
+ place-items: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ background: none;
+ border: 0;
+ color: var(--color-chalk);
+ cursor: pointer;
+}
+
+.seat-row__edit svg {
+ width: 1.25rem;
+ height: 1.25rem;
+}
+
+/* Revealed by the row rather than by itself: hidden means unfocusable, so an affordance that
+ waited to be focused could never be reached. The row takes the focus first and the next tab
+ lands here. */
+.seat-row:hover .seat-row__edit,
+.seat-row:focus-within .seat-row__edit {
+ visibility: visible;
+}
+
+/* No pointer to hover with, so there is no state to reveal it from. */
+@media (hover: none) {
+ .seat-row__edit {
+ visibility: visible;
+ }
+}
+
+.seat-row__add {
+ display: flex;
+ align-items: center;
+ gap: 0.85rem;
+ width: 100%;
+ padding: 0.85rem 0.5rem;
+ background: none;
+ border: 0;
+ border-bottom: 1px solid var(--color-hairline);
+ text-align: left;
+ cursor: pointer;
+}
+
+/* The same square the plates above it are, so the column stays a column past the last seat. */
+.seat-row__plus {
+ flex: none;
+ width: calc(var(--plate) * 0.42);
+}
+
+.seat-row__add-label {
+ font-family: var(--font-display);
+ font-size: clamp(1rem, 2.6vw, 1.4rem);
+ text-transform: uppercase;
+ color: var(--color-ash);
+}
+
+.seat-row__add:hover .seat-row__add-label,
+.seat-row__add:focus-visible .seat-row__add-label {
+ color: var(--color-chalk);
+}
+
/*
* The portrait and the initials are one shape drawn two ways — cut on the island's own diagonal
* and on the same lean as the bands above it, so the column belongs to the page rather than
@@ -326,6 +472,7 @@ button.seat-row__head:hover .seat-row__name {
}
@media (prefers-reduced-motion: reduce) {
+ .seat-row__plus,
.seat-row__chevron,
.seat-row__said {
transition: none;
diff --git a/services/frontend/src/domains/boards/adapters/boards.ts b/services/frontend/src/domains/boards/adapters/boards.ts
index af48d2605..0efd9a607 100644
--- a/services/frontend/src/domains/boards/adapters/boards.ts
+++ b/services/frontend/src/domains/boards/adapters/boards.ts
@@ -72,7 +72,7 @@ export const storeSeatPortrait: PictureStore = storePicture(FileType.BOARD_PORTR
* A seat's name with its nickname back in the middle of it, the way the history was written:
* `Roos "SkyeWolf" Kruk`. The two are recorded apart so anything can ask for either.
*/
-export function seatTitle(seat: BoardSeat): string {
+export function seatTitle(seat: {name?: string | null; nickname?: string | null}): string {
const name = seat.name ?? ""
if (!seat.nickname) return name
const [first, ...rest] = name.split(" ")
@@ -165,20 +165,35 @@ export async function dropBoard(id: number): Promise<{ok: true} | Refused> {
return {ok: true}
}
-export async function addSeat(
+/**
+ * A seat as it is written down: the role it held, who held it, and when.
+ *
+ * `displayName` is the name the seat stands under rather than the account's. Most of the people
+ * who have held one never had an account here, so the name is the seat's own and an account is
+ * something a seat may additionally have.
+ */
+export interface SeatWrite {
+ role: string
+ startDate: string
+ endDate?: string | null
+ userId?: number | null
+ displayName?: string | null
+ nickname?: string | null
+ description?: string | null
+ image?: string | null
+ portrait?: string | null
+}
+
+/**
+ * A seat written down, or the api's own words for why it was not.
+ *
+ * The sdk hands a refusal back as a body rather than throwing, so a dialog that only read
+ * `data` could not tell a rejected date or a rejected upload from a save that worked.
+ */
+export async function addSeatOrReason(
boardId: number,
- seat: {
- role: string
- startDate: string
- endDate?: string | null
- userId?: number | null
- displayName?: string | null
- nickname?: string | null
- description?: string | null
- image?: string | null
- portrait?: string | null
- },
-): Promise {
+ seat: SeatWrite,
+): Promise<{ok: true; seat: BoardSeat} | Refused> {
const res = await addMember({
path: {boardId},
body: {
@@ -193,23 +208,22 @@ export async function addSeat(
portrait: seat.portrait ?? undefined,
},
})
- return res.data ? withPortrait(res.data) : null
+ if (res.error || !res.data) {
+ return {ok: false, reason: reasonFor(res.error, "That seat could not be added.")}
+ }
+ return {ok: true, seat: withPortrait(res.data)}
}
-export async function saveSeat(
+export async function addSeat(boardId: number, seat: SeatWrite): Promise {
+ const added = await addSeatOrReason(boardId, seat)
+ return added.ok ? added.seat : null
+}
+
+export async function saveSeatOrReason(
boardId: number,
id: number,
- seat: {
- role: string
- startDate: string
- endDate?: string | null
- displayName?: string | null
- nickname?: string | null
- description?: string | null
- image?: string | null
- portrait?: string | null
- },
-): Promise {
+ seat: Omit,
+): Promise<{ok: true; seat: BoardSeat} | Refused> {
const res = await updateMember({
path: {boardId, id},
body: {
@@ -223,19 +237,54 @@ export async function saveSeat(
portrait: seat.portrait ?? undefined,
},
})
- return res.data ? withPortrait(res.data) : null
+ if (res.error || !res.data) {
+ return {ok: false, reason: reasonFor(res.error, "That seat could not be saved.")}
+ }
+ return {ok: true, seat: withPortrait(res.data)}
+}
+
+export async function saveSeat(
+ boardId: number,
+ id: number,
+ seat: Omit,
+): Promise {
+ const saved = await saveSeatOrReason(boardId, id, seat)
+ return saved.ok ? saved.seat : null
}
/** A null member detaches the seat, which keeps standing under its own name. */
+export async function linkSeatMemberOrReason(
+ boardId: number,
+ id: number,
+ userId: number | null,
+): Promise<{ok: true; seat: BoardSeat} | Refused> {
+ const res = await linkMember({path: {boardId, id}, body: {userId: userId ?? undefined}})
+ if (res.error || !res.data) {
+ const what = userId == null ? "detached" : "linked to that account"
+ return {ok: false, reason: reasonFor(res.error, `That seat could not be ${what}.`)}
+ }
+ return {ok: true, seat: withPortrait(res.data)}
+}
+
export async function linkSeatMember(
boardId: number,
id: number,
userId: number | null,
): Promise {
- const res = await linkMember({path: {boardId, id}, body: {userId: userId ?? undefined}})
- return res.data ? withPortrait(res.data) : null
+ const linked = await linkSeatMemberOrReason(boardId, id, userId)
+ return linked.ok ? linked.seat : null
+}
+
+/** A seat is somebody's place in the association's history, so a refusal is worth reporting. */
+export async function dropSeatOrReason(
+ boardId: number,
+ id: number,
+): Promise<{ok: true} | Refused> {
+ const res = await removeMember({path: {boardId, id}})
+ if (res.error) return {ok: false, reason: reasonFor(res.error, "That seat could not be removed.")}
+ return {ok: true}
}
export async function dropSeat(boardId: number, id: number): Promise {
- await removeMember({path: {boardId, id}})
+ await dropSeatOrReason(boardId, id)
}
diff --git a/services/frontend/src/domains/boards/island/SeatDialog.vue b/services/frontend/src/domains/boards/island/SeatDialog.vue
new file mode 100644
index 000000000..820cd03db
--- /dev/null
+++ b/services/frontend/src/domains/boards/island/SeatDialog.vue
@@ -0,0 +1,569 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/services/frontend/src/pages/Board.vue b/services/frontend/src/pages/Board.vue
index aee0f3c60..b0c89b9cb 100644
--- a/services/frontend/src/pages/Board.vue
+++ b/services/frontend/src/pages/Board.vue
@@ -23,6 +23,7 @@ import {
seatsInOrder,
} from "@/domains/boards"
import {seatTitle, type Board, type BoardSeat} from "@/domains/boards/adapters/boards"
+import SeatDialog from "@/domains/boards/island/SeatDialog.vue"
import {$require} from "@/plugins/require"
/**
@@ -195,6 +196,32 @@ const boardRemoved = async () => {
void router.push({query})
await refresh()
}
+
+/**
+ * The seat being filled in, and whether the dialog is open on one.
+ *
+ * The seat itself rather than its id, so the dialog is handed what the page already read and
+ * asks the api nothing to open. Nothing is held for a seat being added: the dialog fills its
+ * dates from the board's own term instead.
+ */
+const seatOpen = ref(false)
+const editingSeat = ref(null)
+
+/** The rows name a seat by whatever id they were handed, which here is always the seat's. */
+const editSeat = (id: number | string) => {
+ editingSeat.value = seats.value.find(seat => seat.id === id) ?? null
+ seatOpen.value = true
+}
+
+const addSeat = () => {
+ editingSeat.value = null
+ seatOpen.value = true
+}
+
+/** A seat written down, corrected or removed is read again, so the page shows what was saved. */
+const seatSaved = () => {
+ void refresh()
+}
@@ -315,15 +342,29 @@ const boardRemoved = async () => {
+
+
+ No seats are recorded on this board yet.
+
@@ -351,6 +392,21 @@ const boardRemoved = async () => {
@update:open="editorOpen = $event"
/>
+
+
+
diff --git a/services/frontend/tests/e2e/board-seat-edit.spec.ts b/services/frontend/tests/e2e/board-seat-edit.spec.ts
new file mode 100644
index 000000000..413a9754c
--- /dev/null
+++ b/services/frontend/tests/e2e/board-seat-edit.spec.ts
@@ -0,0 +1,384 @@
+import {Buffer} from "node:buffer"
+import type {Page} from "@playwright/test"
+import {expect, test} from "./test"
+import {installApiMocks, loginAsBoard} from "./mocks"
+
+/** A portrait as the api answers with one, at the widths one is stored at. */
+const portrait = (name: string) => ({
+ path: `board-portraits/${name}.webp`,
+ url: `/files/public/board-portraits/${name}.webp`,
+ width: 640,
+ height: 960,
+ renditions: [160, 320, 640].map((width) => ({
+ url: `/files/public/board-portraits/${name}-${width}.webp`,
+ width,
+ })),
+})
+
+/**
+ * The history a spec writes to, made fresh for each one.
+ *
+ * A function rather than a constant: a seat written down lands on the board it belongs to, so a
+ * fixture shared between tests would carry one test's seat into the next.
+ */
+const history = () => [
+ {
+ id: 9, number: 9, name: "Eeveelutions", candidate: "Eeveelutions",
+ cheer: "RNG, Be With Me!", accent: null, description: null,
+ startDate: "2025-09-01", endDate: null, image: null, photo: null, version: 0,
+ createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z",
+ members: [
+ {
+ id: 91, boardId: 9, userId: 1, role: "Chair", name: "Emma Dokter", nickname: "Emmz",
+ description: "Chairing the ninth board.", image: null, portrait: portrait("emma"),
+ startDate: "2025-09-01", endDate: "2026-08-31", version: 0,
+ createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z",
+ },
+ {
+ id: 92, boardId: 9, userId: null, role: "Treasurer", name: "Viktor Petrov",
+ nickname: null, description: null, image: null, portrait: null,
+ startDate: "2025-09-01", endDate: "2026-08-31", version: 0,
+ createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z",
+ },
+ ],
+ },
+ {
+ id: 4, number: 4, name: null, candidate: "Board 4", cheer: null, accent: null,
+ description: null, startDate: "2020-09-01", endDate: "2021-08-31",
+ image: null, photo: null, version: 0,
+ createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z",
+ // A board nobody has been recorded on, which is where a seat is added from nothing.
+ members: [],
+ },
+]
+
+/**
+ * What a person does: bring the pointer to the row, then take up the pencil it reveals. On a
+ * touch screen the hover is a no-op and the pencil is already standing.
+ */
+const openSeat = async (page: Page, id: number) => {
+ await page.getByTestId(`board-seat-${id}`).hover()
+ await page.getByTestId(`board-seat-edit-${id}`).click()
+}
+
+/** The page as a board member reads it, opened on the board in office. */
+const asBoard = async (page: Page) => {
+ await loginAsBoard(page.context())
+ await installApiMocks(page, {boards: history()})
+ await page.goto("/board")
+ await expect(page.getByTestId("board-name")).toHaveText("Eeveelutions")
+}
+
+test.describe("a seat filled in on the page", () => {
+ test("offers a visitor no pencil and no way to add a seat", async ({page}) => {
+ await installApiMocks(page, {boards: history()})
+
+ await page.goto("/board")
+ await expect(page.getByTestId("board-seat-name-91")).toBeVisible()
+
+ // Absent rather than hidden: the page a visitor reads is not covered in pencils, and the
+ // history is what it shows.
+ await expect(page.getByTestId("board-seat-edit-91")).toHaveCount(0)
+ await expect(page.getByTestId("board-seat-add")).toHaveCount(0)
+ await expect(page.getByTestId("seat-dialog")).toHaveCount(0)
+ })
+
+ test("adds a seat to a board, in the board's own words for the role", async ({page}) => {
+ await asBoard(page)
+
+ await page.getByTestId("board-seat-add").click()
+ await expect(page.getByTestId("seat-dialog")).toBeVisible()
+
+ await page.getByTestId("seat-dialog-name").fill("Roos Kruk")
+ await page.getByTestId("seat-dialog-nickname").fill("SkyeWolf")
+ // Not from a fixed list: nine years of boards have renamed and combined their offices.
+ await page.getByTestId("seat-dialog-role")
+ .fill("Secretary and Commissioner of the Esports Lounge")
+ await page.getByTestId("seat-dialog-description").fill("Ran the lounge.")
+
+ // The save navigates nothing, so the request is what is awaited and the row is the proof.
+ const written = page.waitForRequest(
+ (request) => request.method() === "POST"
+ && /\/boards\/9\/members$/.test(new URL(request.url()).pathname),
+ )
+ await page.getByTestId("seat-dialog-save").click()
+ const body = JSON.parse((await written).postData() ?? "{}") as Record
+
+ expect(body.displayName).toBe("Roos Kruk")
+ // Recorded apart from the name rather than typed into the middle of it.
+ expect(body.nickname).toBe("SkyeWolf")
+ expect(body.role).toBe("Secretary and Commissioner of the Esports Lounge")
+ expect(body.description).toBe("Ran the lounge.")
+
+ await expect(page.getByTestId("seat-dialog")).toHaveCount(0)
+ // And the page reads again, so the seat is on it: the name with the nickname back inside.
+ await expect(page.getByTestId("board-seat-name-901")).toHaveText('Roos "SkyeWolf" Kruk')
+ await expect(page.getByTestId("board-seat-role-901"))
+ .toHaveText("Secretary and Commissioner of the Esports Lounge")
+ await expect(page.getByTestId("board-seat-blurb-901")).toContainText("Ran the lounge.")
+ })
+
+ test("seats somebody on a board that had nobody at all", async ({page}) => {
+ await asBoard(page)
+
+ // The fourth board has no seats, and the way in is still at the end of the stack. Reached
+ // by its own address rather than off the strip: a phone's strip pans rather than scrolls.
+ await page.goto("/board?board=4")
+ await expect(page.getByTestId("board-no-seats")).toBeVisible()
+
+ await page.getByTestId("board-seat-add").click()
+ await page.getByTestId("seat-dialog-name").fill("Anne Schrader")
+ await page.getByTestId("seat-dialog-role").fill("Chairman")
+ await page.getByTestId("seat-dialog-save").click()
+
+ await expect(page.getByTestId("board-seat-name-901")).toHaveText("Anne Schrader")
+ await expect(page.getByTestId("board-no-seats")).toHaveCount(0)
+ })
+
+ test("opens a seat on what it says, and corrects it", async ({page}) => {
+ await asBoard(page)
+
+ await openSeat(page, 91)
+
+ // Everything the page read, back in the fields it was written in.
+ await expect(page.getByTestId("seat-dialog-name")).toHaveValue("Emma Dokter")
+ await expect(page.getByTestId("seat-dialog-nickname")).toHaveValue("Emmz")
+ await expect(page.getByTestId("seat-dialog-role")).toHaveValue("Chair")
+ await expect(page.getByTestId("seat-dialog-description"))
+ .toHaveValue("Chairing the ninth board.")
+ // The nickname sits beside the name rather than inside it, and the dialog says how the
+ // page will publish the two together.
+ await expect(page.getByTestId("seat-dialog-published"))
+ .toHaveText('Reads as Emma "Emmz" Dokter')
+
+ await page.getByTestId("seat-dialog-nickname").fill("LyndisLuna")
+ await page.getByTestId("seat-dialog-description").fill("Chaired the year of the rebuild.")
+
+ const written = page.waitForRequest(
+ (request) => request.method() === "PUT"
+ && /\/boards\/9\/members\/91$/.test(new URL(request.url()).pathname),
+ )
+ await page.getByTestId("seat-dialog-save").click()
+ const body = JSON.parse((await written).postData() ?? "{}") as Record
+
+ expect(body.nickname).toBe("LyndisLuna")
+ expect(body.displayName).toBe("Emma Dokter")
+ expect(body.description).toBe("Chaired the year of the rebuild.")
+
+ await expect(page.getByTestId("board-seat-name-91")).toHaveText('Emma "LyndisLuna" Dokter')
+ await expect(page.getByTestId("board-seat-blurb-91"))
+ .toContainText("Chaired the year of the rebuild.")
+ })
+
+ test("uploads a portrait, shows it before the save, and puts it on the seat", async ({page}) => {
+ await asBoard(page)
+
+ // The seat with no portrait: the row draws its initials until one is uploaded.
+ await expect(page.getByTestId("board-seat-monogram-92")).toBeVisible()
+ await openSeat(page, 92)
+
+ await expect(page.getByTestId("seat-dialog-portrait-empty")).toBeAttached()
+ await page.getByTestId("seat-dialog-portrait-file").setInputFiles({
+ name: "viktor.png",
+ mimeType: "image/png",
+ buffer: Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==",
+ "base64",
+ ),
+ })
+
+ // Shown before it is saved, because a picture nobody can see is one nobody can tell is wrong.
+ const preview = page.getByTestId("seat-dialog-portrait-preview")
+ await expect(preview).toBeVisible()
+ await expect(preview).toHaveAttribute("src", /mock-\d+\.webp$/)
+
+ const written = page.waitForRequest(
+ (request) => request.method() === "PUT"
+ && /\/boards\/9\/members\/92$/.test(new URL(request.url()).pathname),
+ )
+ await page.getByTestId("seat-dialog-save").click()
+ const body = JSON.parse((await written).postData() ?? "{}") as Record
+
+ // The save names where the bytes are stored rather than carrying them.
+ expect(String(body.portrait)).toMatch(/mock-\d+\.webp$/)
+
+ // And the plate is a portrait now rather than a monogram.
+ await expect(page.getByTestId("board-seat-portrait-92")).toBeVisible()
+ await expect(page.getByTestId("board-seat-monogram-92")).toHaveCount(0)
+ })
+
+ test("links a seat to an account with the island's own picker", async ({page}) => {
+ await asBoard(page)
+
+ await openSeat(page, 92)
+ // Nothing attached: most people who have sat on a board never had an account here.
+ await expect(page.getByTestId("seat-dialog-attached")).toHaveCount(0)
+
+ // The island's picker rather than Vuetify's, so the search is a plain field and the list
+ // is drawn at the end of the document.
+ await page.getByTestId("seat-dialog-member-search").fill("Viktor")
+ await page.getByTestId("seat-dialog-member-2").click()
+ await expect(page.getByTestId("seat-dialog-attached")).toContainText("Viktor Petrov")
+
+ const linked = page.waitForRequest(
+ (request) => request.method() === "PUT"
+ && /\/boards\/9\/members\/92\/member$/.test(new URL(request.url()).pathname),
+ )
+ await page.getByTestId("seat-dialog-save").click()
+ const body = JSON.parse((await linked).postData() ?? "{}") as Record
+
+ expect(body.userId).toBe(2)
+ })
+
+ test("detaches an account and leaves the seat standing under its own name", async ({page}) => {
+ await asBoard(page)
+
+ await openSeat(page, 91)
+ await expect(page.getByTestId("seat-dialog-attached")).toContainText("Emma Dokter")
+
+ await page.getByTestId("seat-dialog-detach").click()
+ await expect(page.getByTestId("seat-dialog-attached")).toHaveCount(0)
+ // The name is the seat's own, so detaching leaves it in the field it was in.
+ await expect(page.getByTestId("seat-dialog-name")).toHaveValue("Emma Dokter")
+
+ const detached = page.waitForRequest(
+ (request) => request.method() === "PUT"
+ && /\/boards\/9\/members\/91\/member$/.test(new URL(request.url()).pathname),
+ )
+ await page.getByTestId("seat-dialog-save").click()
+ const body = JSON.parse((await detached).postData() ?? "{}") as Record
+
+ // A null member detaches, and the seat is still on the page under its own name.
+ expect(body.userId).toBeUndefined()
+ await expect(page.getByTestId("board-seat-name-91")).toHaveText('Emma "Emmz" Dokter')
+ })
+
+ test("pre-fills a new seat from the board's term, and records a handover part-way", async ({page}) => {
+ await asBoard(page)
+
+ await page.getByTestId("board-seat-add").click()
+ // The common case needs no typing: the board took office in the autumn of 2025 and is
+ // still in office, so the seat opens on the same stretch.
+ await expect(page.getByTestId("seat-dialog-start")).toHaveValue("2025-09-01")
+ await expect(page.getByTestId("seat-dialog-end")).toHaveValue("")
+
+ await page.getByTestId("seat-dialog-name").fill("Sylwia Nowak")
+ await page.getByTestId("seat-dialog-role").fill("Treasurer")
+ // A handover part-way through the year, recorded truthfully rather than as a full one.
+ await page.getByTestId("seat-dialog-start").fill("2026-02-01")
+
+ const written = page.waitForRequest(
+ (request) => request.method() === "POST"
+ && /\/boards\/9\/members$/.test(new URL(request.url()).pathname),
+ )
+ await page.getByTestId("seat-dialog-save").click()
+ const body = JSON.parse((await written).postData() ?? "{}") as Record
+
+ // What the cohort module reads to answer "was on the board that year".
+ expect(body.startDate).toBe("2026-02-01")
+ })
+
+ test("carries a seat's own dates into the dialog rather than the board's", async ({page}) => {
+ await asBoard(page)
+
+ await openSeat(page, 91)
+
+ await expect(page.getByTestId("seat-dialog-start")).toHaveValue("2025-09-01")
+ await expect(page.getByTestId("seat-dialog-end")).toHaveValue("2026-08-31")
+ })
+
+ test("asks before a seat is removed, and names what will go", async ({page}) => {
+ await asBoard(page)
+
+ await openSeat(page, 91)
+ await page.getByTestId("seat-dialog-remove").click()
+
+ // Named, so the question can be answered without remembering what was clicked. A blurb is
+ // somebody's own words about themselves, so the question says it goes.
+ const question = page.getByTestId("seat-remove-dialog").getByTestId("confirm-question")
+ await expect(question).toContainText('Emma "Emmz" Dokter')
+ await expect(question).toContainText("Chair")
+ await expect(question).toContainText("What they wrote about themselves goes with it.")
+
+ const dropped = page.waitForRequest(
+ (request) => request.method() === "DELETE"
+ && /\/boards\/9\/members\/91$/.test(new URL(request.url()).pathname),
+ )
+ await page.getByTestId("seat-remove-dialog").getByTestId("confirm-go").click()
+ await dropped
+
+ await expect(page.getByTestId("board-seat-91")).toHaveCount(0)
+ // The seat beside it is untouched.
+ await expect(page.getByTestId("board-seat-name-92")).toHaveText("Viktor Petrov")
+ })
+
+ test("keeps the seat where a removal is declined", async ({page}) => {
+ await asBoard(page)
+
+ await openSeat(page, 91)
+ await page.getByTestId("seat-dialog-remove").click()
+ await page.getByTestId("seat-remove-dialog").getByTestId("confirm-cancel").click()
+
+ await expect(page.getByTestId("board-seat-name-91")).toHaveText('Emma "Emmz" Dokter')
+ })
+
+ test("leaves a seat exactly as it was when the dialog is cancelled, picture and all", async ({page}) => {
+ await asBoard(page)
+
+ const plate = page.getByTestId("board-seat-portrait-91")
+ const before = await plate.getAttribute("src")
+
+ await openSeat(page, 91)
+ await page.getByTestId("seat-dialog-name").fill("Somebody Else")
+ await page.getByTestId("seat-dialog-nickname").fill("Wrong")
+ await page.getByTestId("seat-dialog-role").fill("Nobody")
+ // A picture is stored on choosing and reaches the seat only on the save, so cancelling
+ // leaves the seat on the portrait it had.
+ await page.getByTestId("seat-dialog-portrait-file").setInputFiles({
+ name: "wrong.png",
+ mimeType: "image/png",
+ buffer: Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==",
+ "base64",
+ ),
+ })
+ await expect(page.getByTestId("seat-dialog-portrait-preview")).toHaveAttribute("src", /mock-/)
+
+ await page.getByTestId("seat-dialog-cancel").click()
+ await expect(page.getByTestId("seat-dialog")).toHaveCount(0)
+
+ await expect(page.getByTestId("board-seat-name-91")).toHaveText('Emma "Emmz" Dokter')
+ await expect(page.getByTestId("board-seat-role-91")).toHaveText("Chair")
+ await expect(plate).toHaveAttribute("src", before ?? "")
+
+ // And reopening it shows the seat as it stands rather than what was typed and abandoned.
+ await openSeat(page, 91)
+ await expect(page.getByTestId("seat-dialog-name")).toHaveValue("Emma Dokter")
+ await expect(page.getByTestId("seat-dialog-role")).toHaveValue("Chair")
+ })
+
+ test("says why a save was refused, and keeps what was typed", async ({page}) => {
+ await asBoard(page)
+
+ // The sdk hands a refusal back as a body rather than throwing, so a dialog that only read
+ // `data` would close on a save that never happened.
+ await page.route("**/boards/9/members/91", async (route) => {
+ if (route.request().method() !== "PUT") return route.fallback()
+ await route.fulfill({
+ status: 400,
+ contentType: "application/json",
+ body: JSON.stringify({detail: "A seat cannot end before it began."}),
+ })
+ })
+
+ await openSeat(page, 91)
+ await page.getByTestId("seat-dialog-end").fill("2024-01-01")
+ await page.getByTestId("seat-dialog-save").click()
+
+ await expect(page.getByTestId("seat-dialog-failure"))
+ .toHaveText("A seat cannot end before it began.")
+ // Still open, and still holding what was typed: the objection is something to act on.
+ await expect(page.getByTestId("seat-dialog-end")).toHaveValue("2024-01-01")
+ await expect(page.getByTestId("seat-dialog-name")).toHaveValue("Emma Dokter")
+ })
+})
diff --git a/services/frontend/tests/e2e/mocks.ts b/services/frontend/tests/e2e/mocks.ts
index ad9f669cb..fa4e0a8fe 100644
--- a/services/frontend/tests/e2e/mocks.ts
+++ b/services/frontend/tests/e2e/mocks.ts
@@ -313,6 +313,8 @@ export async function installApiMocks(page: Page, fixtures: Fixtures = {}) {
const icons = new Map()
const stored = new Map()
let nextFileId = 500
+ /** Seats written down during a test, each taking the next id the way the api would. */
+ let nextSeatId = 900
/**
* An image as the api describes one. The size is that of the picture actually served below,
* so a page reserving an image's space reserves the right amount of it.
@@ -1025,13 +1027,96 @@ export async function installApiMocks(page: Page, fixtures: Fixtures = {}) {
boardsGone.add(id)
return route.fulfill({status: 204, body: ""})
}
+ /*
+ * A seat written down, corrected, linked or removed lands on the board it belongs to, so a
+ * page that reads again is answered the way the api would rather than told the board never
+ * changed. Held on the fixture's own `members` rather than beside it, because that is the
+ * one list every read of a board goes through.
+ *
+ * A fixture a spec shares between tests would carry a write into the next one, so a spec
+ * that writes hands `installApiMocks` boards of its own making.
+ *
+ * The boards themselves rather than `boardsNow()`, which composes a copy per board: a seat
+ * pushed onto a copy's `members` would reach the board it belongs to and a seat removed
+ * from one would not, because a removal replaces the list rather than adding to it.
+ */
+ const boardHolding = (boardId: number): Record | undefined =>
+ [...(fixtures.boards ?? boardFixtures), ...boardsMade]
+ .find((b) => Number(b.id) === boardId)
+
+ const seatsOf = (board: Record): Array> => {
+ if (!Array.isArray(board.members)) board.members = []
+ return board.members as Array>
+ }
+
+ /**
+ * A seat as the api answers with one after a write.
+ *
+ * Every field the write carries is replaced the way the api's own does, so a field the save
+ * left out is cleared rather than kept. The save named where its portrait is stored and the
+ * answer carries the picture itself; naming none clears the portrait. The name arrives as
+ * `displayName` and is answered as `name`, which is the api's own asymmetry.
+ */
+ const seatWritten = (
+ base: Record,
+ body: Record,
+ ): Record => ({
+ ...base,
+ role: body.role,
+ name: body.displayName ?? null,
+ nickname: body.nickname ?? null,
+ description: body.description ?? null,
+ image: body.image ?? null,
+ portrait: pictureNamed(body.portrait),
+ startDate: body.startDate,
+ endDate: body.endDate ?? null,
+ updatedAt: "2026-01-02T00:00:00Z",
+ })
+
if (method === "POST" && /^\/boards\/\d+\/members$/.test(path)) {
+ const boardId = Number(path.split("/")[2])
const body = JSON.parse(request.postData() ?? "{}") as Record
- return fulfillJson(route, {id: 99, boardId: Number(path.split("/")[2]), userId: body.userId ?? null, role: body.role, name: body.displayName ?? null, nickname: body.nickname ?? null, description: body.description ?? null, image: body.image ?? null, startDate: body.startDate, endDate: body.endDate ?? null, version: 0, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z"}, 201)
+ nextSeatId += 1
+ const made = seatWritten({
+ id: nextSeatId,
+ boardId,
+ userId: body.userId ?? null,
+ version: 0,
+ createdAt: "2026-01-02T00:00:00Z",
+ }, body)
+ const board = boardHolding(boardId)
+ if (board) seatsOf(board).push(made)
+ return fulfillJson(route, made, 201)
}
+ if (method === "PUT" && /^\/boards\/\d+\/members\/\d+$/.test(path)) {
+ const boardId = Number(path.split("/")[2])
+ const id = Number(path.split("/")[4])
+ const body = JSON.parse(request.postData() ?? "{}") as Record
+ const seats = seatsOf(boardHolding(boardId) ?? {})
+ const at = seats.findIndex((one) => Number(one.id) === id)
+ if (at === -1) return fulfillJson(route, {detail: "No such seat."}, 404)
+ const saved = {...seatWritten(seats[at]!, body), version: 1}
+ seats[at] = saved
+ return fulfillJson(route, saved)
+ }
+ // A null member detaches the seat, which keeps standing under its own name.
if (method === "PUT" && /^\/boards\/\d+\/members\/\d+\/member$/.test(path)) {
+ const boardId = Number(path.split("/")[2])
+ const id = Number(path.split("/")[4])
const body = JSON.parse(request.postData() ?? "{}") as Record
- return fulfillJson(route, {id: 92, boardId: 9, userId: body.userId ?? null, role: "Secretary", name: "Viktor Petrov", description: null, image: null, startDate: "2025-09-01", endDate: "2026-08-31", version: 1, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z"})
+ const seats = seatsOf(boardHolding(boardId) ?? {})
+ const at = seats.findIndex((one) => Number(one.id) === id)
+ if (at === -1) return fulfillJson(route, {detail: "No such seat."}, 404)
+ const linked = {...seats[at]!, userId: body.userId ?? null, version: 1}
+ seats[at] = linked
+ return fulfillJson(route, linked)
+ }
+ if (method === "DELETE" && /^\/boards\/\d+\/members\/\d+$/.test(path)) {
+ const boardId = Number(path.split("/")[2])
+ const id = Number(path.split("/")[4])
+ const board = boardHolding(boardId)
+ if (board) board.members = seatsOf(board).filter((one) => Number(one.id) !== id)
+ return route.fulfill({status: 204, body: ""})
}
// A game added during a test is one of the games from then on, the way the api has it.
if (method === "POST" && path === "/esports/games") {
diff --git a/services/frontend/tests/unit/components/island/SeatRows.test.ts b/services/frontend/tests/unit/components/island/SeatRows.test.ts
index 75dad7b60..1dcc849fc 100644
--- a/services/frontend/tests/unit/components/island/SeatRows.test.ts
+++ b/services/frontend/tests/unit/components/island/SeatRows.test.ts
@@ -14,7 +14,7 @@ const rows = [
{id: 93, name: "Roos Kruk", role: "Commissioner of Internal Affairs"},
]
-const mountRows = (over: Partial<{rows: typeof rows}> = {}) =>
+const mountRows = (over: Partial<{rows: typeof rows; mayEdit: boolean; addLabel: string}> = {}) =>
mount(SeatRows, {props: {rows, accent: "#3387fa", testidPrefix: "board", ...over}})
const row = (wrapper: ReturnType, id: number) =>
@@ -115,4 +115,42 @@ describe("SeatRows", () => {
expect(row(wrapper, 71).classes()).toContain("seat-row--open")
expect(row(wrapper, 72).classes()).not.toContain("seat-row--open")
})
+
+ it("offers a visitor no pencil and no way to add a seat", () => {
+ const wrapper = mountRows()
+
+ // Not hidden: absent. An affordance nobody may take up is not drawn at all.
+ expect(wrapper.find('[data-testid="board-seat-edit-91"]').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="board-seat-add"]').exists()).toBe(false)
+ })
+
+ it("offers a pencil on every row, named for the seat it opens", () => {
+ const wrapper = mountRows({mayEdit: true})
+
+ // Every row, including the one with nothing written about it, which offers no chevron.
+ expect(wrapper.get('[data-testid="board-seat-edit-91"]').attributes("aria-label"))
+ .toBe('Edit Emma "Emmz" Dokter')
+ expect(wrapper.get('[data-testid="board-seat-edit-93"]').attributes("aria-label"))
+ .toBe("Edit Roos Kruk")
+ })
+
+ it("reports which seat a pencil was pressed on", async () => {
+ const wrapper = mountRows({mayEdit: true})
+
+ await wrapper.get('[data-testid="board-seat-edit-92"]').trigger("click")
+
+ expect(wrapper.emitted("edit")).toStrictEqual([[92]])
+ // The row itself is not opened by the press: the pencil is a different act from reading.
+ expect(wrapper.emitted("add")).toBeUndefined()
+ })
+
+ it("offers one way in at the end of the stack, in the words the page chose", async () => {
+ const wrapper = mountRows({mayEdit: true, addLabel: "Add a seat"})
+
+ const add = wrapper.get('[data-testid="board-seat-add"]')
+ expect(add.text()).toBe("Add a seat")
+ await add.trigger("click")
+
+ expect(wrapper.emitted("add")).toHaveLength(1)
+ })
})