diff --git a/src/__tests__/board.test.ts b/src/__tests__/board.test.ts index 0540344..23e9c1f 100644 --- a/src/__tests__/board.test.ts +++ b/src/__tests__/board.test.ts @@ -5,6 +5,7 @@ import { boardDemand, buildBoard, buildRoster, + inferRoster, channelForMR, configuredSlackChannels, projectPathFromWebUrl, @@ -360,6 +361,35 @@ describe("visibleMrsFor (the /data.json payload gate)", () => { }); }); +describe("inferRoster", () => { + function authored(username: string, name: string | null = null): BoardMR { + return { author: { id: username, username, name, avatarUrl: null } } as unknown as BoardMR; + } + + test("derives one entry per distinct author, busiest first", () => { + const roster = inferRoster([authored("zoe"), authored("adam"), authored("zoe"), authored("adam"), authored("zoe")]); + expect(roster).toEqual([ + { username: "zoe", name: null, count: 3 }, + { username: "adam", name: null, count: 2 }, + ]); + }); + + test("ties break alphabetically so the order is stable across polls", () => { + const roster = inferRoster([authored("zoe"), authored("adam"), authored("mira")]); + expect(roster.map((r) => r.username)).toEqual(["adam", "mira", "zoe"]); + }); + + test("keeps the author's display name and tolerates a missing one", () => { + const roster = inferRoster([authored("adam", "Adam Stranger"), authored("mira")]); + expect(roster.find((r) => r.username === "adam")?.name).toBe("Adam Stranger"); + expect(roster.find((r) => r.username === "mira")?.name).toBeNull(); + }); + + test("no MRs means no roster", () => { + expect(inferRoster([])).toEqual([]); + }); +}); + describe("buildRoster", () => { const members = [{ username: "alice" }, { username: "bob", name: "Bobby" }, { username: "carol" }]; diff --git a/src/__tests__/config-store-latch.test.ts b/src/__tests__/config-store-latch.test.ts index e9aa013..91e8b94 100644 --- a/src/__tests__/config-store-latch.test.ts +++ b/src/__tests__/config-store-latch.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, writeFileSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { getSetting, setSetting } from "@mattstack/rt-client"; -import { loadConfigFrom, saveMemberHidden, saveSwitchboardUrl, DEFAULT_SLACK_EMOJI } from "../config.ts"; +import { loadConfigFrom, saveMemberHidden, saveRosterMembers, saveSwitchboardUrl, saveTabs, DEFAULT_SLACK_EMOJI, type TabConfig } from "../config.ts"; type GetSettingFn = typeof getSetting; type SetSettingFn = typeof setSetting; @@ -210,6 +210,37 @@ describe("loadConfigFrom: board.tabs overlay", () => { }); }); +describe("saveRosterMembers: latch-gated writer", () => { + test("unowned: rewrites config.json's members, store untouched", () => { + const p = tmpConfig({ ...base, members: [{ username: "alice" }] }); + const calls: Array<{ key: string; value: unknown; scope: string }> = []; + const next = [{ username: "alice" }, { username: "bob", name: "Bob" }]; + const cfg = saveRosterMembers(next, p, fakeResolve({}), fakeWrite(calls)); + expect(calls).toEqual([]); + expect(cfg.members).toEqual(next); + expect(JSON.parse(readFileSync(p, "utf8")).members).toEqual(next); + }); + + test("owned: writes board.members (team), config.json untouched", () => { + const p = tmpConfig({ ...base, members: [{ username: "alice" }] }); + const before = readFileSync(p, "utf8"); + const calls: Array<{ key: string; value: unknown; scope: string }> = []; + const next = [{ username: "alice" }, { username: "bob" }]; + saveRosterMembers(next, p, fakeResolve({ "board.members": [{ username: "alice" }] }), fakeWrite(calls)); + expect(calls).toEqual([{ key: "board.members", value: next, scope: "team" }]); + expect(readFileSync(p, "utf8")).toBe(before); + expect(loadConfigFrom(p, fakeResolve({ "board.members": next })).members).toEqual(next); + }); + + test("removal round-trips through the same writer", () => { + const p = tmpConfig({ ...base, members: [{ username: "alice" }, { username: "bob" }] }); + const calls: Array<{ key: string; value: unknown; scope: string }> = []; + const stored = [{ username: "alice" }, { username: "bob" }]; + saveRosterMembers([{ username: "alice" }], p, fakeResolve({ "board.members": stored }), fakeWrite(calls)); + expect(calls[0]!.value).toEqual([{ username: "alice" }]); + }); +}); + describe("saveMemberHidden: latch-gated writer", () => { test("unowned: writes config.json's inline hidden flag, store untouched", () => { const p = tmpConfig({ ...base, members: [{ username: "alice" }, { username: "bob" }] }); @@ -324,3 +355,52 @@ describe("slack default emoji re-export sanity", () => { expect(DEFAULT_SLACK_EMOJI).toEqual({ looking: "eyes", commented: "speech_balloon", approved: "white_check_mark" }); }); }); + +describe("saveTabs: latch-gated writer", () => { + const codeowners: TabConfig = { id: "acme", label: "Acme", source: { kind: "codeowners", section: "Acme", excludeMembers: true } }; + const team: TabConfig = { id: "team", label: "Team", source: { kind: "authors" } }; + + test("unowned: rewrites config.json's tabs, store untouched", () => { + const p = tmpConfig(); + const calls: Array<{ key: string; value: unknown; scope: string }> = []; + const cfg = saveTabs([team, codeowners], p, fakeResolve({}), fakeWrite(calls)); + expect(calls).toEqual([]); + expect(cfg.tabs.map((t) => t.id)).toEqual(["team", "acme"]); + expect(JSON.parse(readFileSync(p, "utf8")).tabs).toEqual([team, codeowners]); + }); + + test("owned: writes board.tabs (team), config.json untouched", () => { + const p = tmpConfig({ ...base, tabs: [team] }); + const before = readFileSync(p, "utf8"); + const calls: Array<{ key: string; value: unknown; scope: string }> = []; + saveTabs([team, codeowners], p, fakeResolve({ "board.tabs": [team] }), fakeWrite(calls)); + expect(calls).toEqual([{ key: "board.tabs", value: [team, codeowners], scope: "team" }]); + expect(readFileSync(p, "utf8")).toBe(before); + }); + + test("no config.json with owned team keys establishes board.tabs ownership", () => { + const missing = join(mkdtempSync(join(tmpdir(), "board-latch-nofile-")), "config.json"); + const calls: Array<{ key: string; value: unknown; scope: string }> = []; + const owned = { "board.gitlabHost": "https://gitlab.example.com", "board.projects": ["team/repo"], "board.members": [{ username: "carol" }] }; + saveTabs([team, codeowners], missing, fakeResolve(owned), fakeWrite(calls)); + expect(calls).toEqual([{ key: "board.tabs", value: [team, codeowners], scope: "team" }]); + }); + + test("an invalid list throws before any write, on either side of the latch", () => { + const p = tmpConfig(); + const before = readFileSync(p, "utf8"); + const calls: Array<{ key: string; value: unknown; scope: string }> = []; + expect(() => saveTabs([], p, fakeResolve({}), fakeWrite(calls))).toThrow(/must not be empty/); + expect(() => saveTabs([team, team], p, fakeResolve({ "board.tabs": [team] }), fakeWrite(calls))).toThrow(/duplicate tab id/); + expect(() => saveTabs([{ id: "x", label: "X", source: { kind: "codeowners" } }], p, fakeResolve({}), fakeWrite(calls))).toThrow(/section/); + expect(calls).toEqual([]); + expect(readFileSync(p, "utf8")).toBe(before); + }); + + test("optional per-tab fields survive the round trip and absent ones stay absent", () => { + const p = tmpConfig(); + const cfg = saveTabs([{ ...codeowners, slackChannel: "team-codeowners", reviewSkill: "external-review" }], p, fakeResolve({}), fakeWrite([])); + expect(cfg.tabs[0]).toEqual({ ...codeowners, slackChannel: "team-codeowners", reviewSkill: "external-review" }); + expect("slackChannel" in saveTabs([team], p, fakeResolve({}), fakeWrite([])).tabs[0]!).toBe(false); + }); +}); diff --git a/src/__tests__/view.test.ts b/src/__tests__/view.test.ts index 976a358..0f2dfd4 100644 --- a/src/__tests__/view.test.ts +++ b/src/__tests__/view.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { BoardMR } from "../data.ts"; import type { TabConfig } from "../config.ts"; -import { filterByMember, filterByTab, sortMRs, groupMRs, commentDot, dataAgeLabel, statusFlags, nestStacks, memberPeerState, joinRowState } from "../view.ts"; +import { filterByMember, filterByTab, rosterUsernamesFor, sortMRs, groupMRs, commentDot, dataAgeLabel, statusFlags, nestStacks, memberPeerState, joinRowState } from "../view.ts"; function mr(overrides: Partial): BoardMR { return { @@ -29,6 +29,50 @@ describe("filterByMember", () => { }); }); +describe("rosterUsernamesFor", () => { + const configUsernames = ["ada", "grace"]; + const rows = [ + mr({ iid: 1, author: { username: "ada" } as any, codeownerSections: ["Acme"] } as any), + mr({ iid: 2, author: { username: "outsider" } as any, codeownerSections: ["Acme"] } as any), + mr({ iid: 3, author: { username: "drifter" } as any, codeownerSections: [] } as any), + ]; + + test("an authors tab answers with the configured roster", () => { + const team: TabConfig = { id: "t", label: "T", source: { kind: "authors" } }; + expect([...rosterUsernamesFor(rows, team, configUsernames)].sort()).toEqual(["ada", "grace"]); + }); + + test("a codeowners tab answers with the authors of the rows it shows", () => { + const q: TabConfig = { id: "q", label: "Q", source: { kind: "codeowners", section: "Acme", excludeMembers: true } }; + const valid = rosterUsernamesFor(rows, q, configUsernames); + expect([...valid]).toEqual(["outsider"]); // the picked author survives re-validation + expect(valid.has("ada")).toBe(false); // excludeMembers still applies + expect(valid.has("drifter")).toBe(false); // untagged row is not on this tab + }); + + test("no tab falls back to the configured roster", () => { + expect([...rosterUsernamesFor(rows, undefined, configUsernames)].sort()).toEqual(["ada", "grace"]); + }); + + /* Board resolves the first load in two passes for this reason: the member's + valid set depends on which tab wins, so validating against the config + roster alone drops a stored codeowners-tab author on every reload. */ + test("two-pass resolution keeps a stored codeowners-tab author across a reload", () => { + const q: TabConfig = { id: "q", label: "Q", source: { kind: "codeowners", section: "Acme", excludeMembers: true } }; + const stored = { tab: "q", member: "outsider" }; + const tabIds = ["t", "q"]; + + // Single pass drops the stored author as "not on the team" and lands on + // defaultMember, so a reload on the queue would silently filter to you. + const onePass = parseViewState("", stored, configUsernames, "ada", tabIds); + expect(onePass.tab).toBe("q"); + expect(onePass.member).toBe("ada"); + + const valid = [...rosterUsernamesFor(rows, q, configUsernames)]; + expect(parseViewState("", stored, valid, "ada", tabIds).member).toBe("outsider"); + }); +}); + describe("filterByTab", () => { const members = new Set(["ada"]); const rows = [ diff --git a/src/client/__tests__/config-shapes.test.ts b/src/client/__tests__/config-shapes.test.ts index 119d74f..6c0ba07 100644 --- a/src/client/__tests__/config-shapes.test.ts +++ b/src/client/__tests__/config-shapes.test.ts @@ -13,6 +13,7 @@ import { rowKind, scopeLabel, setLeaf, + slugTabId, type ConfigDef, } from "../board/config-shapes.ts"; @@ -36,7 +37,7 @@ function def(over: Partial & { key: string }): ConfigDef { /** Composite board.* registry keys with no edit UI yet -- rowKind's "readonly" fallback (no COMPOSITE_SHAPES entry) is the intended rendering for these, not a coverage gap. */ -const DELIBERATELY_READONLY_COMPOSITES = ["board.tabs"]; +const DELIBERATELY_READONLY_COMPOSITES: string[] = []; describe("COMPOSITE_SHAPES", () => { test("covers every composite board.* key in the registry except the deliberately-readonly ones", () => { @@ -48,12 +49,6 @@ describe("COMPOSITE_SHAPES", () => { expect(Object.keys(COMPOSITE_SHAPES).sort()).toEqual(composites); }); - test("board.tabs is a composite key deliberately left readonly", () => { - expect(allDefs().find((d) => d.key === "board.tabs")?.type).toBe("array"); - expect(COMPOSITE_SHAPES["board.tabs"]).toBeUndefined(); - expect(rowKind(def({ key: "board.tabs", type: "array" }))).toBe("readonly"); - }); - test("names no key the registry lacks", () => { const known = new Set(allDefs().map((d) => d.key)); for (const key of Object.keys(COMPOSITE_SHAPES)) expect(known.has(key)).toBe(true); @@ -212,3 +207,42 @@ describe("rosterSummary", () => { expect(rosterSummary([{ username: "a" }], undefined)).toBe("1 member"); }); }); + +describe("tabs shape", () => { + const s = COMPOSITE_SHAPES["board.tabs"]!; + const team = { id: "team", label: "Team", source: { kind: "authors" } }; + const acme = { id: "acme", label: "Acme", source: { kind: "codeowners", section: "Acme", excludeMembers: true }, slackChannel: "c", reviewSkill: "s" }; + + test("board.tabs rows are the tabs kind regardless of writability", () => { + expect(rowKind(def({ key: "board.tabs", type: "array" }))).toBe("tabs"); + }); + + test("accepts the shapes parseTabs accepts", () => { + expect(matchesShape(s, [team])).toBe(true); + expect(matchesShape(s, [team, acme])).toBe(true); + }); + + test("rejects an empty list and duplicate ids, like parseTabs", () => { + expect(matchesShape(s, [])).toBe(false); + expect(matchesShape(s, [team, { ...acme, id: "team" }])).toBe(false); + }); + + test("rejects what parseTabs rejects", () => { + expect(matchesShape(s, "team")).toBe(false); + expect(matchesShape(s, [{ ...team, id: "" }])).toBe(false); + expect(matchesShape(s, [{ ...team, label: 3 }])).toBe(false); + expect(matchesShape(s, [{ ...team, source: { kind: "codeowners" } }])).toBe(false); + expect(matchesShape(s, [{ ...acme, source: { ...acme.source, excludeMembers: "yes" } }])).toBe(false); + expect(matchesShape(s, [{ ...team, source: { kind: "other" } }])).toBe(false); + expect(matchesShape(s, [{ ...team, slackChannel: 1 }])).toBe(false); + }); +}); + +describe("slugTabId", () => { + test("slugs the label and dodges taken ids", () => { + expect(slugTabId("Acme Codeowners", [])).toBe("acme-codeowners"); + expect(slugTabId(" Team! ", ["team"])).toBe("team-2"); + expect(slugTabId("Team", ["team", "team-2"])).toBe("team-3"); + expect(slugTabId("???", [])).toBe("tab"); + }); +}); diff --git a/src/client/board/Board.tsx b/src/client/board/Board.tsx index 5b4858c..22a5e90 100644 --- a/src/client/board/Board.tsx +++ b/src/client/board/Board.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { BoardMR } from "../../data.ts"; -import { filterByMember, filterByTab, sortMRs, groupMRs, parseViewState, serializeViewState, dataAgeLabel } from "../../view.ts"; +import { inferRoster } from "../../data.ts"; +import { filterByMember, filterByTab, rosterUsernamesFor, sortMRs, groupMRs, parseViewState, serializeViewState, dataAgeLabel } from "../../view.ts"; import type { ViewState } from "../../view.ts"; import { selectionOf, postableOf, tabChangeClearsSelection } from "../../selection.ts"; import type { @@ -19,6 +20,7 @@ import { postAction } from "../api.ts"; import { ICONS, Panel, SideDrawer, ToastHost } from "@mattstack/tui-kit"; import { Sidebar } from "./Sidebar.tsx"; import { Controls } from "./Controls.tsx"; +import { TabBar } from "./TabBar.tsx"; import { SelectionBar } from "./SelectionBar.tsx"; import { RowView } from "./RowView.tsx"; import { GridView } from "./GridView.tsx"; @@ -69,7 +71,10 @@ export function Board() { }; const update = (patch: Partial) => { const clearsSelection = tabChangeClearsSelection(patch, state.tab); - const next = { ...state, ...patch }; + // Each tab has its own roster (a codeowners tab's is inferred from the rows + // in view), so a member picked on one tab usually does not exist on the + // next: carrying it over would land on an empty board. + const next = { ...state, ...patch, ...(clearsSelection ? { member: "all" } : {}) }; localStorage.setItem(STATE_KEY, JSON.stringify(next)); history.replaceState(null, "", serializeViewState(next) || location.pathname); setState(next); @@ -95,9 +100,26 @@ export function Board() { } // Tab validated once here, same as member -- never re-derived from the // URL on later polls, so a live tab pick survives the 60s refresh cycle. - setState(parseViewState(location.search, stored, usernames, d.defaultMember, d.tabs.map((t) => t.id))); + // Two passes because the member's valid set depends on which tab wins: + // a codeowners tab's roster is its own authors, so a stored pick there + // would otherwise be dropped as "not on the team" on every reload. + const tabIds = d.tabs.map((t) => t.id); + const firstPass = parseViewState(location.search, stored, usernames, d.defaultMember, tabIds); + const tab = d.tabs.find((t) => t.id === firstPass.tab) ?? d.tabs[0]; + const validMembers = [...rosterUsernamesFor(d.mrs, tab, usernames)]; + setState(parseViewState(location.search, stored, validMembers, d.defaultMember, tabIds)); } else { - setState((prev) => (prev.member === "all" || usernames.includes(prev.member) ? prev : { ...prev, member: "all" })); + // Validated against the ACTIVE TAB's roster: a codeowners tab's is + // inferred from the rows in view, so checking the config roster alone + // would drop a legitimately picked author on the next poll. + setState((prev) => { + // A tab dropped in the settings modal must not linger as the active + // id, or re-adding one with that id would silently jump to it. + const tab = d.tabs.find((t) => t.id === prev.tab) ?? d.tabs[0]!; + const next = tab.id === prev.tab ? prev : { ...prev, tab: tab.id }; + if (next.member === "all") return next; + return rosterUsernamesFor(d.mrs, tab, usernames).has(next.member) ? next : { ...next, member: "all" }; + }); } }, []); @@ -422,17 +444,22 @@ export function Board() { const tabFiltered = filterByTab(mrs, activeTab, rosterUsernames); // Codeowners tabs bypass member filtering entirely (and the sidebar that // drives it) -- the queue is scoped by section, not by roster author. - const filtered = isCodeownersTab ? tabFiltered : filterByMember(tabFiltered, state.member); + // A codeowners tab lists other teams' MRs, so the configured roster has + // nothing to drive there. Inferring one from the rows in view keeps the + // author filter (and the settings gears that live in this panel) available. + const roster = isCodeownersTab ? inferRoster(tabFiltered) : data.members; + const rosterTotal = isCodeownersTab ? tabFiltered.length : total; + const filtered = filterByMember(tabFiltered, state.member); const groups = groupMRs(filtered, state.group, data.members.map((m) => m.username), now).map((g) => ({ label: g.label, mrs: sortMRs(g.mrs, state.sort), })); const activeMember = - !isCodeownersTab && state.member !== "all" ? data.members.find((m) => m.username === state.member) ?? null : null; + state.member !== "all" ? roster.find((m) => m.username === state.member) ?? null : null; // Show each row's author only when the view mixes authors: the All view // grouped by anything but author (where the group header isn't the name), // or a codeowners tab, which is never narrowed to one author. - const showAuthor = isCodeownersTab || (state.member === "all" && state.group !== "author"); + const showAuthor = state.member === "all" && state.group !== "author"; // Under author grouping the header IS the name, so rows normally drop the // author tag -- but a stack pulled to its root's group can carry a // co-author's MR under someone else's header. Tag the rows whenever a group @@ -484,28 +511,25 @@ export function Board() { canPostSummary: data.slackEnabled && data.local && postableMrs.length > 0, postingSummary, onPostSummary: () => handlePostSummary(postableMrs), - tabs: data.tabs, - tabSyncing, }; return (
{/* Desktop roster (hidden on mobile, where it moves into the drawer). Also hidden on a codeowners tab: it isn't filtered by member, so the roster has nothing to drive. */} - {!isCodeownersTab && ( - update({ member })} - onSettings={openSettings} - onConfig={openConfig} - scopeUncovered={data.scopeUncovered} - /> - )} + update({ member })} + onSettings={openSettings} + onConfig={openConfig} + scopeUncovered={data.scopeUncovered} + note={isCodeownersTab ? "authors in this queue" : undefined} + />
@@ -527,6 +551,13 @@ export function Board() {
+ update({ tab })} + syncing={tabSyncing} + /> + {selectedMrs.length > 0 && (
- {!isCodeownersTab && ( - { - update({ member }); - setMenuOpen(false); - }} - onSettings={openSettings} - onConfig={openConfig} - scopeUncovered={data.scopeUncovered} - /> - )} + { + update({ member }); + setMenuOpen(false); + }} + onSettings={openSettings} + onConfig={openConfig} + scopeUncovered={data.scopeUncovered} + note={isCodeownersTab ? "authors in this queue" : undefined} + />
@@ -630,6 +660,8 @@ export function Board() { {showConfig && ( load()} onClose={() => setShowConfig(false)} onOpenRoster={() => { setShowConfig(false); diff --git a/src/client/board/ConfigModal.tsx b/src/client/board/ConfigModal.tsx index 863d092..1ef9e7b 100644 --- a/src/client/board/ConfigModal.tsx +++ b/src/client/board/ConfigModal.tsx @@ -1,6 +1,13 @@ import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import { Modal } from "@mattstack/tui-kit"; -import { useSettingsScope, type SettingsScopeState } from "@mattstack/settings-kit/react"; +import { + useSettingsScope, + type SettingsScopeState, +} from "@mattstack/settings-kit/react"; +import { postAction } from "../api.ts"; +import type { TabConfig } from "../../config.ts"; +import { InfoTip } from "./InfoTip.tsx"; +import { Disclosure, DisclosureHead } from "./Disclosure.tsx"; import { COMPOSITE_SHAPES, addToList, @@ -15,6 +22,7 @@ import { rowKind, scopeLabel, setLeaf, + slugTabId, type CompositeShape, type ConfigDef, type LeafType, @@ -100,7 +108,15 @@ function TextField({ ); } -function ScalarControl({ def, value, row }: { def: ConfigDef; value: unknown; row: ReturnType }) { +function ScalarControl({ + def, + value, + row, +}: { + def: ConfigDef; + value: unknown; + row: ReturnType; +}) { if (def.type === "boolean") { return ( }) { +function ChipControl({ + def, + list, + row, +}: { + def: ConfigDef; + list: string[]; + row: ReturnType; +}) { const [draft, setDraft] = useState(""); const add = () => { const next = addToList(list, draft); @@ -187,7 +211,8 @@ function LeavesControl({ fields: Record; row: ReturnType; }) { - const commit = (path: string, leaf: unknown) => void row.save(setLeaf(value, path, leaf)); + const commit = (path: string, leaf: unknown) => + void row.save(setLeaf(value, path, leaf)); return (
{Object.entries(fields).map(([path, type]) => { @@ -211,7 +236,9 @@ function LeavesControl({ value={typeof leaf === "string" ? leaf : ""} disabled={row.busy} aria-label={label} - onChange={(e) => commit(path, e.target.value === "" ? undefined : e.target.value)} + onChange={(e) => + commit(path, e.target.value === "" ? undefined : e.target.value) + } > {type.enum.map((opt) => ( @@ -260,7 +287,10 @@ function PairsControl({ row: ReturnType; }) { const [a, b] = fields; - const [draft, setDraft] = useState<{ a: string; b: string }>({ a: "", b: "" }); + const [draft, setDraft] = useState<{ a: string; b: string }>({ + a: "", + b: "", + }); const addIfComplete = (next: { a: string; b: string }) => { setDraft(next); if (next.a.trim() === "" || next.b.trim() === "") return; @@ -274,8 +304,20 @@ function PairsControl({
{pairs.map((p, i) => (
- update(i, a, t)} /> - update(i, b, t)} /> + update(i, a, t)} + /> + update(i, b, t)} + />
@@ -308,69 +362,693 @@ function CompositeControl({ }) { switch (shape.kind) { case "stringList": - return ; + return ( + + ); case "pairList": - return []) : []} fields={shape.fields} row={row} />; + return ( + []) : [] + } + fields={shape.fields} + row={row} + /> + ); case "leaves": - return ; + return ( + + ); case "roster": return null; } } -function SettingRow({ def, store, onOpenRoster }: { def: ConfigDef; store: SettingsScopeState; onOpenRoster: () => void }) { +/** The roster row's editor. Roster edits go through POST /roster rather than + the settings store directly: the server is the single writer that also + swaps its in-memory roster, so /data.json agrees immediately instead of + waiting for a restart. It honors the same ownership latch either way. + Checking people in and out stays in the roster panel, which is what the + link beside this is for. */ +function RosterControl({ + members, + hidden, + self, + onSaved, + onOpenRoster, +}: { + members: unknown; + hidden: unknown; + /** defaultMember: the board's own identity, which cannot be dropped. */ + self: string | null; + /** Re-read the store so the list reflects the write. */ + onSaved: () => void; + onOpenRoster: () => void; +}) { + const [adding, setAdding] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Dropping arms on the first click and sends on the second, keyed by + // username since every row carries the button. + const [armed, setArmed] = useState(null); + + const roster = Array.isArray(members) + ? (members as Array<{ + username?: unknown; + name?: unknown; + hidden?: unknown; + }>) + : []; + // Checked out is either the user overlay or a hidden flag on the entry + // itself, the same union rosterSummary counts. + const hiddenSet = new Set([ + ...(Array.isArray(hidden) + ? (hidden as unknown[]).filter((u): u is string => typeof u === "string") + : []), + ...roster + .filter((m) => m.hidden === true && typeof m.username === "string") + .map((m) => m.username as string), + ]); + + const edit = async (action: "add" | "remove", username: string) => { + setBusy(true); + setError(null); + const res = await postAction("/roster", { action, username }); + setBusy(false); + if (!res.ok) { + setError(res.text || `could not ${action} ${username}`); + return; + } + setArmed(null); + if (action === "add") setAdding(""); + // The write went through /roster (server-validated), so the kit's cached + // defs are stale until told otherwise. + onSaved(); + }; + + return ( +
+
+ + {rosterSummary(members, hidden)} + + +
+
    + {roster.map((m, i) => { + const username = typeof m.username === "string" ? m.username : ""; + if (!username) return null; + const name = typeof m.name === "string" ? m.name : null; + return ( +
  • + + {name ?? username} + {name && @{username}} + {hiddenSet.has(username) && ( + checked out + )} + + {username === self ? ( + + you + + ) : armed === username ? ( + + ) : ( + + )} +
  • + ); + })} +
+
{ + e.preventDefault(); + const handle = adding.trim(); + if (handle && !busy) void edit("add", handle); + }} + > + setAdding(e.target.value)} + placeholder="gitlab username" + aria-label="add a teammate by gitlab username" + disabled={busy} + /> + +
+ {error &&

{error}

} +
+ ); +} + +type TabSource = TabConfig["source"]; + +/** The tabs row's editor. Edits go through POST /tabs for the same reason + roster edits go through /roster: the server validates the whole list and + swaps its in-memory copy, so the board re-declares the new sections to rt + at once instead of on the next restart. `tabs` is the effective list from + /data.json, which is right whichever side of the ownership latch owns it. */ +function TabsControl({ + tabs, + defaultChannel, + onSaved, +}: { + tabs: TabConfig[]; + /** board.slack.channel, what a tab without its own slackChannel inherits. */ + defaultChannel: string | null; + onSaved: () => void; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + // Dropping a tab discards its config, so the armed row demands the tab's + // label typed back before the drop button enables. + const [armed, setArmed] = useState(null); + const [dropText, setDropText] = useState(""); + const [newLabel, setNewLabel] = useState(""); + const [newKind, setNewKind] = useState("codeowners"); + const [newSection, setNewSection] = useState(""); + + const write = async (next: TabConfig[]) => { + setBusy(true); + setError(null); + const res = await postAction("/tabs", { tabs: next }); + setBusy(false); + if (!res.ok) { + setError(res.text || "could not save tabs"); + return false; + } + setArmed(null); + setDropText(""); + onSaved(); + return true; + }; + + const arm = (id: string | null) => { + setArmed(id); + setDropText(""); + }; + + const patch = (id: string, change: (tab: TabConfig) => TabConfig) => + void write(tabs.map((t) => (t.id === id ? change(t) : t))); + const optional = (text: string) => + text.trim() === "" ? undefined : text.trim(); + + const add = async () => { + const label = newLabel.trim(); + if (!label || busy) return; + const source: TabSource = + newKind === "authors" + ? { kind: "authors" } + : { + kind: "codeowners", + section: newSection.trim(), + excludeMembers: true, + }; + const ok = await write([ + ...tabs, + { + id: slugTabId( + label, + tabs.map((t) => t.id), + ), + label, + source, + }, + ]); + if (ok) { + setNewLabel(""); + setNewSection(""); + } + }; + + return ( +
+
    + {tabs.map((tab) => ( +
  • +
    + + patch(tab.id, (t) => ({ ...t, label: text.trim() })) + } + /> + {tab.source.kind} + {tabs.length === 1 ? ( + + only tab + + ) : armed === tab.id ? ( + + ) : ( + + )} +
    + {armed === tab.id && ( +
    { + e.preventDefault(); + if (dropText === tab.label && !busy) + void write(tabs.filter((t) => t.id !== tab.id)); + }} + > + + dropping this tab discards its section, channel, and skill + settings + + setDropText(e.target.value)} + placeholder={`type ${tab.label} to confirm`} + aria-label={`type the label of tab ${tab.id} to confirm dropping it`} + disabled={busy} + /> + +
    + )} +
    + {tab.source.kind === "codeowners" && ( + <> + + + + )} + + +
    +
  • + ))} +
+
{ + e.preventDefault(); + void add(); + }} + > + setNewLabel(e.target.value)} + placeholder="new tab label" + aria-label="new tab label" + disabled={busy} + /> + + {newKind === "codeowners" && ( + setNewSection(e.target.value)} + placeholder="CODEOWNERS section" + aria-label="new tab codeowners section" + disabled={busy} + /> + )} + +
+ {error &&

{error}

} +
+ ); +} + +/** Control-specific caveats the registry description cannot know, shown in + the same info tip as the description. */ +const ROW_HINTS: Record = { + "board.members": "A new teammate's MRs land once rt has synced them.", + "board.tabs": + 'A new section\'s MRs land once rt has backfilled it; the tab shows "syncing" until then.', +}; + +const OPEN_ROWS_KEY = "board.config.openRows"; + +/** Which composite rows are expanded, remembered per browser so the modal + reopens the way it was left. Storage is a convenience only: any failure + reads as "all collapsed". */ +function useOpenRows(): [Set, (key: string) => void] { + const [open, setOpen] = useState>(() => { + try { + const raw = localStorage.getItem(OPEN_ROWS_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + return new Set( + Array.isArray(parsed) + ? parsed.filter((k): k is string => typeof k === "string") + : [], + ); + } catch { + return new Set(); + } + }); + const toggle = (key: string) => { + setOpen((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + useEffect(() => { + try { + localStorage.setItem(OPEN_ROWS_KEY, JSON.stringify([...open])); + } catch { + // storage unavailable: the open set still works for this mount + } + }, [open]); + return [open, toggle]; +} + +function SettingRow({ + def, + store, + tabs, + open, + onToggle, + onOpenRoster, + onTabsSaved, +}: { + def: ConfigDef; + store: SettingsScopeState; + tabs: TabConfig[]; + /** Expanded, for a collapsible row; ignored otherwise. */ + open: boolean; + onToggle: () => void; + onOpenRoster: () => void; + onTabsSaved: () => void; +}) { const kind = rowKind(def); const row = useRowSave(store, def); const value = def.effective.value; const shape = COMPOSITE_SHAPES[def.key]; const set = isSet(def); - const malformed = shape !== undefined && shape.kind !== "roster" && value !== undefined && !matchesShape(shape, value); + const malformed = + shape !== undefined && + shape.kind !== "roster" && + value !== undefined && + !matchesShape(shape, value); let control; - if (kind === "roster") { - const members = store.defs.find((d) => d.key === "board.members")?.effective.value; - const hidden = store.defs.find((d) => d.key === "board.hiddenMembers")?.effective.value; + if (kind === "tabs" && !malformed) { + const channel = getLeaf( + store.defs.find((d) => d.key === "board.slack")?.effective.value, + "channel", + ); control = ( - <> - {rosterSummary(members, hidden)} - - + { + store.refresh(); + onTabsSaved(); + }} + /> ); + } else if (kind === "roster") { + const members = store.defs.find((d) => d.key === "board.members")?.effective + .value; + const hidden = store.defs.find((d) => d.key === "board.hiddenMembers") + ?.effective.value; + const self = store.defs.find((d) => d.key === "board.defaultMember") + ?.effective.value; + control = + def.key === "board.members" ? ( +