Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 148 additions & 1 deletion services/frontend/src/components/island/SeatRows.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
}>()

/**
Expand Down Expand Up @@ -77,6 +91,7 @@ const toggle = (row: SeatRow) => {
<template>
<div
class="seat-rows"
:class="{'seat-rows--editable': mayEdit}"
:data-testid="`${testidPrefix}-seat-rows`"
:style="{'--accent': accent}"
>
Expand Down Expand Up @@ -166,7 +181,59 @@ const toggle = (row: SeatRow) => {
>
<p>{{ row.blurb }}</p>
</div>

<!--
Offered only to somebody who may take it up, and over the row rather than inside it:
the head is a button wherever a blurb was written, and a button inside a button is not
markup. It waits to be approached, because a stack of rows under a column of pencils
reads as a form rather than as the association's history.
-->
<button
v-if="mayEdit"
:aria-label="`Edit ${row.name}`"
class="seat-row__edit"
:data-testid="`${testidPrefix}-seat-edit-${row.id}`"
type="button"
@click="emit('edit', row.id)"
>
<svg
aria-hidden="true"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4Z" />
</svg>
</button>
</article>

<!--
Another seat is added at the end of the stack, which is where its absence is noticed. It
stands rather than waiting to be approached: there is no row under the pointer for it to
belong to, and a board that gained a sixth member has nothing yet to hover over.
-->
<button
v-if="mayEdit"
class="seat-row__add"
:data-testid="`${testidPrefix}-seat-add`"
type="button"
@click="emit('add')"
>
<span
aria-hidden="true"
class="seat-row__plus island-plus"
>
<svg
class="island-plus__edge"
fill="none"
viewBox="0 0 100 100"
>
<path d="M38 2 H62 V38 H98 V62 H62 V98 H38 V62 H2 V38 H38 Z" />
</svg>
</span>
<span class="seat-row__add-label">{{ addLabel }}</span>
</button>
</div>
</template>

Expand All @@ -187,6 +254,7 @@ const toggle = (row: SeatRow) => {
}

.seat-row {
position: relative;
border-bottom: 1px solid var(--color-hairline);
}

Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
111 changes: 80 additions & 31 deletions services/frontend/src/domains/boards/adapters/boards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(" ")
Expand Down Expand Up @@ -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<BoardSeat | null> {
seat: SeatWrite,
): Promise<{ok: true; seat: BoardSeat} | Refused> {
const res = await addMember({
path: {boardId},
body: {
Expand All @@ -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<BoardSeat | null> {
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<BoardSeat | null> {
seat: Omit<SeatWrite, "userId">,
): Promise<{ok: true; seat: BoardSeat} | Refused> {
const res = await updateMember({
path: {boardId, id},
body: {
Expand All @@ -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<SeatWrite, "userId">,
): Promise<BoardSeat | null> {
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<BoardSeat | null> {
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<void> {
await removeMember({path: {boardId, id}})
await dropSeatOrReason(boardId, id)
}
Loading
Loading