diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4e04eb..26dd35f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ written while it was being built. See [RELEASING.md](RELEASING.md). ### Added +- The Settings page shows when you have changed a switch without saving, as + Manage Teams does: a bar at the top lists what is unsaved, with Save and + Discard, and leaving the page asks first. Saving keeps you on the page. - Changing your registration answers is easier to find. On a hackathon's Participants page your own entry is now at the top, marked "You", and its button reads "View & edit". Your profile there has an "Edit answers" button @@ -77,6 +80,13 @@ written while it was being built. See [RELEASING.md](RELEASING.md). - For organizers, the "Participant View" section of a hackathon's sidebar is now folded every time you open the hackathon. Open it with its arrow when you need it. +- A hackathon's Settings page now walks organizers through the event step by + step: registration, projects, preferences, teams, hacking, voting and results. + Each switch sits at the step where it is turned on, with links to the pages in + between, such as approving participants, forming teams and setting up voting. + A private hackathon links to its invitation links. +- "Set team preferences" is now called "Choose preferred projects", since + participants pick projects, not teammates. - Manage Teams no longer has a "Suggest teams" button. Build teams by dragging people onto them, or by downloading the spreadsheet, filling in the team column and uploading it again. diff --git a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte index 5b2f6612..587230e7 100644 --- a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte +++ b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.svelte @@ -1,12 +1,21 @@ + { + if (dirty && !leaving) e.preventDefault(); + }} +/> + + + +{#snippet toggle(capability: Capability, label: string, description?: string)} + + + {@const unsaved = draft[capability.value] !== capability.enabled} + +{/snippet} + +
{ + pending = true; + return async ({ update }) => { + await update({ reset: false }); + pending = false; + }; + }} + > +
+

+ What participants can do +

- Each one is a permission held by every participant, for the whole - hackathon. Moving between phases never changes them. + These switches decide what participants can do, and this is the only + place to change them. Moving between phases never does.

- -
- {#each capabilities as capability (capability.value)} - - - {/each} +
+ + +
+
+ + {#if pending} + Saving… + {:else if dirty} + Unsaved changes, marked below: {summary} + {:else if saved && !message} + Saved. + {:else} + No unsaved changes + {/if} + + {#if message && !pending} + {message} + {/if} + +
- +
- {#if message} - - {:else if saved} -

Saved.

- {/if} +
+
    + {#each steps as step, i (step.title)} +
  1. +

    + {i + 1}. + {step.title} +

    + {#each step.blocks as block, j (j)} + {#if block.kind === 'switch'} + {@const capability = byValue.get(block.value)} + {#if capability} + {@render toggle(capability, block.label)} + {/if} + {:else} +

    + {block.text} + {#if block.link} + + + {block.link.label} → + + + {/if} +

    + {/if} + {/each} +
  2. + {/each} - - - + {#if unplaced.length > 0} +
  3. +

    Other

    + {#each unplaced as capability (capability.value)} + {@render toggle( + capability, + capabilityLabel(capability.value) ?? 'Unknown', + capabilityDescription(capability.value), + )} + {/each} +
  4. + {/if} +
+
- {:else}

What participants can do diff --git a/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts new file mode 100644 index 00000000..4c663670 --- /dev/null +++ b/components/frontend/src/lib/components/hackathon/CapabilitiesPanel.test.ts @@ -0,0 +1,199 @@ +import { fireEvent, render, screen } from "@testing-library/svelte" +import { describe, expect, it, vi } from "vitest" + +// The leave guard and the enhanced save need the router, which a component +// test has none of; what they do is the browser's business, not this file's. +vi.mock("$app/navigation", () => ({ beforeNavigate: () => {}, goto: () => {} })) +vi.mock("$app/forms", () => ({ enhance: () => ({ destroy: () => {} }) })) + +import CapabilitiesPanel from "./CapabilitiesPanel.svelte" + +/* + * The Settings switches as the steps of running a hackathon, with the screens + * between them. Raw enum numbers: REGISTER=1, PROPOSE_PROJECTS=2, + * SET_TEAM_PREFERENCES=3, CREATE_PROJECT_SUBMISSIONS=4, VOTE=5, + * VIEW_RESULTS=6, VIEW_TEAMS=7. + */ + +const all = [1, 2, 3, 7, 4, 5, 6].map((value) => ({ value, enabled: false })) + +function renderPanel(overrides = {}) { + return render(CapabilitiesPanel, { + hackathonId: "h1", + isPrivate: false, + hasState: true, + capabilities: all, + ...overrides, + }) +} + +const link = (name: string) => screen.queryByRole("link", { name }) +const box = (name: string) => screen.getByRole("checkbox", { name }) + +describe("CapabilitiesPanel", () => { + it("lists the steps in the order a hackathon runs", () => { + renderPanel() + + const titles = screen + .getAllByRole("heading", { level: 4 }) + .map((h) => h.textContent?.replace(/^\s*\d+\.\s*/, "").trim()) + expect(titles).toEqual([ + "Register", + "Projects", + "Project preferences", + "Team assignments", + "Hacking starts", + "Set up voting", + "Voting starts", + "Publish results", + ]) + expect(screen.getAllByRole("checkbox")).toHaveLength(7) + }) + + it("names each switch for the organizer, on the right capability", () => { + renderPanel() + + expect(box("Open registration")).toHaveAttribute("value", "1") + expect(box("Participants can propose projects")).toHaveAttribute( + "value", + "2", + ) + expect(box("Participants can voice preferences")).toHaveAttribute( + "value", + "3", + ) + expect(box("Participants can see teams")).toHaveAttribute("value", "7") + expect(box("Participants can submit for their team")).toHaveAttribute( + "value", + "4", + ) + expect(box("Participants can vote")).toHaveAttribute("value", "5") + expect(box("Publish results")).toHaveAttribute("value", "6") + }) + + it("links the steps that are screens, not switches", () => { + renderPanel() + + expect(link("Registration form →")).toHaveAttribute( + "href", + "/my/hackathon/h1/manage/forms/registration", + ) + expect(link("Manage Projects →")).toHaveAttribute( + "href", + "/my/hackathon/h1/projects/manage", + ) + expect(link("Manage Teams →")).toHaveAttribute( + "href", + "/my/hackathon/h1/teams/manage", + ) + expect(link("Set up voting →")).toHaveAttribute( + "href", + "/my/hackathon/h1/voting/manage", + ) + // Publishing is locked until the results are final, made so there. + expect(link("Manage Voting →")).toHaveAttribute( + "href", + "/my/hackathon/h1/voting/manage", + ) + }) + + // Every join waits for approval, public or private. + it("sends registration to approving people, either way", () => { + for (const isPrivate of [false, true]) { + const { unmount } = renderPanel({ isPrivate }) + + expect(link("Manage Participants →")).toHaveAttribute( + "href", + "/my/hackathon/h1/participants/manage", + ) + unmount() + } + }) + + it("says anyone can join a public hackathon, with no invitation link", () => { + renderPanel() + + expect(screen.getByText(/anyone can find it and join/)).toBeInTheDocument() + expect(link("Invitation links →")).toBeNull() + }) + + it("points a private hackathon's registration at its invitation links", () => { + renderPanel({ isPrivate: true }) + + expect(screen.getByText(/send invitation links/)).toBeInTheDocument() + expect(link("Invitation links →")).toHaveAttribute( + "href", + "/my/hackathon/h1/manage/invites", + ) + }) + + describe("unsaved changes", () => { + const status = () => screen.getAllByRole("status")[0] + const button = (name: string) => screen.getByRole("button", { name }) + + it("says nothing is unsaved, with nothing to save or discard", () => { + renderPanel() + + expect(status()).toHaveTextContent("No unsaved changes") + expect(button("Save")).toBeDisabled() + expect(button("Discard")).toBeDisabled() + }) + + it("names what Save would write, by the organizer's labels", async () => { + renderPanel({ + capabilities: all.map((c) => + c.value === 5 ? { ...c, enabled: true } : c, + ), + }) + + await fireEvent.click(box("Open registration")) + await fireEvent.click(box("Participants can vote")) + + expect(status()).toHaveTextContent( + "Unsaved changes, marked below: Open registration on, Participants can vote off", + ) + expect(button("Save")).toBeEnabled() + }) + + it("is clean again once a switch is put back by hand", async () => { + renderPanel() + + await fireEvent.click(box("Open registration")) + await fireEvent.click(box("Open registration")) + + expect(status()).toHaveTextContent("No unsaved changes") + }) + + it("puts every switch back on Discard", async () => { + renderPanel() + + await fireEvent.click(box("Open registration")) + await fireEvent.click(button("Discard")) + + expect(box("Open registration")).not.toBeChecked() + expect(status()).toHaveTextContent("No unsaved changes") + }) + + it("confirms a save", () => { + renderPanel({ saved: true }) + + expect(status()).toHaveTextContent("Saved.") + }) + + it("shows why a save failed", () => { + renderPanel({ message: "Something went wrong" }) + + expect(screen.getByRole("alert")).toHaveTextContent( + "Something went wrong", + ) + }) + }) + + // A switch the backend adds before a step names it must still be settable. + it("keeps a capability no step names, under Other", () => { + renderPanel({ capabilities: [...all, { value: 99, enabled: false }] }) + + expect(screen.getByText("Other")).toBeInTheDocument() + expect(screen.getAllByRole("checkbox")).toHaveLength(8) + }) +}) diff --git a/components/frontend/src/lib/utils/phase.ts b/components/frontend/src/lib/utils/phase.ts index afcaca6d..2726df21 100644 --- a/components/frontend/src/lib/utils/phase.ts +++ b/components/frontend/src/lib/utils/phase.ts @@ -209,8 +209,10 @@ export function currentAndNextPhase< * the frontend no longer reads or writes it. See * `mydocs/docs/backend-tickets/phase-capabilities-unused.md`. * - * The description is what the label cannot carry: "Set team preferences" reads - * as a setting rather than as a permission handed to everyone in the hackathon. + * The description is what the label cannot carry: "Choose preferred projects" + * reads as a setting rather than as a permission handed to everyone in the + * hackathon. Its label used to be "Set team preferences", which organizers read + * as choosing teammates; the preference is for a project. * * **Pronoun-free throughout**, which is stronger than third person and is what * lets one string serve two surfaces. Capabilities grant to the `Member` role @@ -239,8 +241,8 @@ const CAPABILITIES: { }, { value: 3, - label: "Set team preferences", - description: "Mark preferred projects on the project list.", + label: "Choose preferred projects", + description: "Mark preferred projects on the project list, not teammates.", }, // Out of enum order, and deliberately: this is what the preferences above it // lead to and what the submissions below it are filed against, so it reads diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte index f41d477d..271ddfab 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/manage/+page.svelte @@ -238,6 +238,8 @@