From 4413494d066bbb6dec5eabb08b764930321524a3 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Wed, 2 Sep 2026 10:30:23 +0200 Subject: [PATCH] feat(board): a seat is filled in on the page it is read on --- .../src/components/island/SeatRows.vue | 149 ++++- .../src/domains/boards/adapters/boards.ts | 111 +++- .../src/domains/boards/island/SeatDialog.vue | 569 ++++++++++++++++++ services/frontend/src/pages/Board.vue | 58 +- .../tests/e2e/board-seat-edit.spec.ts | 384 ++++++++++++ services/frontend/tests/e2e/mocks.ts | 89 ++- .../unit/components/island/SeatRows.test.ts | 40 +- 7 files changed, 1364 insertions(+), 36 deletions(-) create mode 100644 services/frontend/src/domains/boards/island/SeatDialog.vue create mode 100644 services/frontend/tests/e2e/board-seat-edit.spec.ts 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 @@ + + +