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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ written while it was being built. See [RELEASING.md](RELEASING.md).

### Added

- Participants can leave a hackathon from their own profile in it. They leave
their team and give up the projects they proposed, and their team keeps its
projects and submission. Somebody still on the waitlist can cancel their
registration from the hackathon's public page instead.

- Every page inside a hackathon starts with a breadcrumb: the hackathon's full
name and the section you are in. It stays visible when the menu is folded
away, and replaces the cut-off name at the top of the menu.
Expand Down
54 changes: 53 additions & 1 deletion components/frontend/src/lib/components/hackathon/JoinCta.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import { UserPlus } from 'lucide-svelte';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import { signIn } from '@auth/sveltekit/client';
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import ConfirmDialog from '$lib/components/layout/ConfirmDialog.svelte';
import { isFinished } from '$lib/utils/hackathonStatus';
import MembershipBadge from './MembershipBadge.svelte';

Expand Down Expand Up @@ -42,8 +44,19 @@
// their hackathon does not stop being theirs when it ends.
const canGoIn = $derived(standing === 'member' || standing === 'admin');
const show = $derived(canGoIn || !isFinished(status));

// Kept here rather than read off the page's `form`: this bar is the only
// thing on the page that posts, and threading `form` through
// PublicHackathonView would hand the organiser's preview a prop it never uses.
let confirmDialog: ReturnType<typeof ConfirmDialog>;
let cancelError: string | null = $state(null);
// Once the load reruns the standing is `none` and the bar offers Register,
// which on its own reads as though nothing had happened.
let cancelled = $state(false);
</script>

<ConfirmDialog bind:this={confirmDialog} />

<!--
One bar, directly under the hero, whatever the reader's standing.

Expand Down Expand Up @@ -90,9 +103,48 @@
>
Review your answers
</a>
<!-- Quieter still: the way off the list, for somebody who cannot
come after all. Only while waiting — a confirmed participant
leaves from their profile inside the hackathon, where it can be
said what they lose. -->
<form
method="POST"
action="?/cancel"
use:enhance={async ({ cancel }) => {
const ok = await confirmDialog.ask({
title: 'Cancel your registration?',
message:
"You come off the organizers' list and your answers are deleted. You can register again while registration is open.",
confirmLabel: 'Cancel registration',
cancelLabel: 'Keep registration',
danger: true,
});
if (!ok) return cancel();
cancelError = null;
return async ({ result, update }) => {
if (result.type === 'failure') {
cancelError =
typeof result.data?.message === 'string'
? result.data.message
: 'Your registration could not be cancelled. Try again.';
}
if (result.type === 'success') cancelled = true;
await update();
};
}}
>
<button type="submit" class="btn btn-sm btn-quiet">Cancel registration</button>
</form>
{#if cancelError}
<p class="m-0 w-full text-center font-sans text-xs text-danger-ink" role="alert">
{cancelError}
</p>
{/if}
{:else if signedIn}
<p class="m-0 font-sans text-sm text-ink-2">
Registering puts you on the organizers' list.
{cancelled
? 'Your registration is cancelled. Registering again puts you back on the list.'
: "Registering puts you on the organizers' list."}
</p>
<!-- Straight to the registration form rather than joining from here.
That page is the one place that knows both halves of signing up:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
message: string;
/** What the confirming button says — the action, never "OK". */
confirmLabel: string;
/**
* What the dismissing button says, "Cancel" unless given. Named when the
* action is itself a cancel — "Cancel" beside "Cancel registration"
* leaves the reader to guess which one keeps things as they are.
*/
cancelLabel?: string;
/** Red confirm button, for something that cannot simply be undone. */
danger?: boolean;
};
Expand Down Expand Up @@ -58,7 +64,7 @@
<div class="flex justify-end gap-2 pt-1">
<!-- First, so it has the focus: a slip of Enter cancels. -->
<button type="button" class="btn btn-sm btn-ghost" onclick={() => answer(false)}>
Cancel
{current.cancelLabel ?? 'Cancel'}
</button>
<button
type="button"
Expand Down
62 changes: 62 additions & 0 deletions components/frontend/src/lib/server/hackathon/leave.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from "vitest"
import { ClientError, Status } from "nice-grpc-common"
import { leaveHackathon } from "./leave"

const HACKATHON = "hackathon-1"

/** A client whose `leave` either succeeds or throws `error`. */
function client(error?: unknown) {
return {
leave: vi.fn(async () => {
if (error) throw error
return {}
}),
}
}

function grpcError(code: Status) {
return new ClientError("/hackathon.HackathonService/Leave", code, "")
}

describe("leaveHackathon", () => {
it("leaves the hackathon it is given", async () => {
const c = client()

expect(await leaveHackathon(c, HACKATHON)).toBeUndefined()
expect(c.leave).toHaveBeenCalledWith({ hackathonId: HACKATHON })
})

it("counts somebody who is already out as out", async () => {
expect(
await leaveHackathon(client(grpcError(Status.NOT_FOUND)), HACKATHON),
).toBeUndefined()
})

it("refuses a finished hackathon with a conflict", async () => {
const refusal = await leaveHackathon(
client(grpcError(Status.FAILED_PRECONDITION)),
HACKATHON,
)

expect(refusal?.status).toBe(409)
expect(refusal?.message).toMatch(/already finished/)
})

it("asks an expired session to sign in again", async () => {
const refusal = await leaveHackathon(
client(grpcError(Status.UNAUTHENTICATED)),
HACKATHON,
)

expect(refusal?.status).toBe(401)
})

it("lets any other failure through", async () => {
await expect(
leaveHackathon(client(grpcError(Status.UNAVAILABLE)), HACKATHON),
).rejects.toBeInstanceOf(ClientError)
await expect(
leaveHackathon(client(new Error("boom")), HACKATHON),
).rejects.toThrow("boom")
})
})
54 changes: 54 additions & 0 deletions components/frontend/src/lib/server/hackathon/leave.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Taking yourself out of a hackathon. Two callers, one RPC: somebody still on
// the waitlist cancels their registration from the public page, and a confirmed
// participant leaves from their own profile inside the hackathon. `Leave` does
// the same thing to either — it deletes the participant row and everything that
// came with it — so only the wording around it differs.
//
// Server-only: it calls the generated client.

import type { HackathonServiceClient } from "$lib/server/grpc/generated/hackathon/hackathon_service"
import { ClientError, Status } from "nice-grpc-common"

/** Why `Leave` refused, as a status and a sentence for the page to show. */
export type LeaveRefusal = { status: number; message: string }

/**
* Calls `Leave` for the signed-in user, and returns the refusal if there is one.
*
* `undefined` means the caller is out, which includes `NOT_FOUND`: `Leave`
* answers that both for a hackathon that is gone and for a caller with no
* participant row (`hackathon_service.go:880`). Either way the person is not in
* it, which is what they asked for, so a second press — or a stale tab — goes
* on as if it had worked.
*
* There is no `PERMISSION_DENIED` case: `Leave` has no casbin check, because the
* only person it can remove is the caller.
*
* @param client takes only `leave`, so a test can hand in a stub.
*/
export async function leaveHackathon(
client: Pick<HackathonServiceClient, "leave">,
hackathonId: string,
): Promise<LeaveRefusal | undefined> {
try {
await client.leave({ hackathonId })
} catch (e) {
if (!(e instanceof ClientError)) throw e
if (e.code === Status.NOT_FOUND) return undefined
// Once it is over, the roster is the record of the event and stays as it is.
if (e.code === Status.FAILED_PRECONDITION)
return {
status: 409,
message:
"This hackathon has already finished, so you stay on its list.",
}
if (e.code === Status.UNAUTHENTICATED)
return {
status: 401,
message: "Your session has expired. Sign in again and try once more.",
}
throw e
}

return undefined
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,22 @@ import {
questionRows,
type ParticipantAnswer,
} from "$lib/server/hackathon/registrationForm"
import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role"
import { leaveHackathon } from "$lib/server/hackathon/leave"
import { listVisibleTeams } from "$lib/server/hackathon/teams"
import {
editRequestFor,
failForLinks,
profileLinksOf,
} from "$lib/server/user/profileLinks"
import { membershipBadgeLabel } from "$lib/utils/hackathonRole"
import { isFinished } from "$lib/utils/hackathonStatus"
import { linkify } from "$lib/utils/linkify"
import {
PROFILE_LINK_KINDS,
profileLinkSuggestion,
} from "$lib/utils/profileLinks"
import { error, fail } from "@sveltejs/kit"
import { error, fail, redirect } from "@sveltejs/kit"

/**
* Who is reading this profile, which decides both how the answers are asked for
Expand Down Expand Up @@ -216,6 +219,20 @@ export const load: PageServerLoad = async (event) => {
teams,
teamsPublished,
teamsFailed,
// Whether to offer "Leave this hackathon" — only an offer, `Leave` decides.
//
// Never to an owner. `Leave` removes the participant row and the `Member`
// role but leaves the `Owner` role alone (`hackathon_service.go:886`), so an
// organizer pressing it would still run the hackathon and still see every
// page, and "you left" would be untrue in every way that shows. Giving up
// ownership is Remove owner, on Manage Participants.
//
// Not once it is finished: `Leave` refuses that, because the roster is the
// record of the event.
canLeave:
isMe &&
member.role !== HackathonRole.HACKATHON_ROLE_OWNER &&
!isFinished(hackathon.status),
}
}

Expand Down Expand Up @@ -258,4 +275,22 @@ export const actions: Actions = {
// the backend now renders it — which is what retires the offer.
return { savedLink: kind.label }
},

/**
* Takes the caller out of this hackathon — "Leave this hackathon" on their own
* profile. Like `saveLink`, it acts on the caller whatever profile the post
* came from: `Leave` takes no user, so there is nobody else it could remove.
*
* To the dashboard on success, because every page under this hackathon now
* answers 403. `leaveError` rather than `message`, so a refusal shows beside
* the Leave button and not under the answers, where `saveLink`'s goes.
*/
leave: async (event) => {
const { hackathon } = requireGrpc(event.locals.grpc)

const refused = await leaveHackathon(hackathon, event.params.id)
if (refused) return fail(refused.status, { leaveError: refused.message })

redirect(303, "/dashboard")
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@
import AnswerText from '$lib/components/hackathon/AnswerText.svelte';
import ProfileLinks from '$lib/components/hackathon/ProfileLinks.svelte';
import BackLink from '$lib/components/layout/BackLink.svelte';
import ConfirmDialog from '$lib/components/layout/ConfirmDialog.svelte';
import InfoButton from '$lib/components/layout/InfoButton.svelte';
import { PROFILE_LINK_KINDS } from '$lib/utils/profileLinks';
import type { ActionData, PageData } from './$types';

let { data, form }: { data: PageData; form: ActionData } = $props();

let confirmDialog: ReturnType<typeof ConfirmDialog>;

const LINK_LABEL = Object.fromEntries(PROFILE_LINK_KINDS.map((k) => [k.key, k.label]));

const hasLinks = $derived(PROFILE_LINK_KINDS.some((k) => data.participant.links[k.key] !== ''));
Expand Down Expand Up @@ -59,7 +62,8 @@
a 21px name above an undifferentiated grey field and no rung between them.

Nothing here acts on the person — except, on your own profile, "Save as your
GitHub link" beside an answer, which edits only your own links. Approve, Remove and the
GitHub link" beside an answer, which edits only your own links, and "Leave this
hackathon" at the foot, which removes only you. Approve, Remove and the
owner controls live on the organizer's own view of this participant,
`participants/manage/<id>` (see $lib/navigation's manageNav), and duplicating
them here would mean two surfaces to keep in step with the same casbin rules.
Expand Down Expand Up @@ -295,5 +299,40 @@
</div>
{/if}
</section>

<!-- Last, and only on your own profile: the way out, below everything it
takes away. The sentence says what goes and what stays before the
button is pressed, and the dialog says it again before anything
happens. -->
{#if data.canLeave}
<section class="card box-border flex w-full flex-col items-start gap-3 px-5 py-4">
<h3 class="m-0 text-section text-ink">Leave this hackathon</h3>
<p class="prose m-0 text-sm text-ink-3">
You leave your team and give up the projects you proposed. Your team keeps
its projects and its submission.
</p>
<form
method="POST"
action="?/leave"
use:enhance={async ({ cancel }) => {
const ok = await confirmDialog.ask({
title: `Leave ${data.hackathon.name}?`,
message:
'You leave your team, give up the projects you proposed, and your project preferences and registration answers are deleted. To come back, you have to register again.',
confirmLabel: 'Leave hackathon',
danger: true,
});
if (!ok) return cancel();
}}
>
<button type="submit" class="btn btn-sm btn-danger">Leave hackathon</button>
</form>
{#if form?.leaveError}
<p class="m-0 text-xs text-danger-ink" role="alert">{form.leaveError}</p>
{/if}
</section>
{/if}
</div>
</div>

<ConfirmDialog bind:this={confirmDialog} />
Loading
Loading