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

### Added

- Backend: A submission can now hold a description and a link to a folder of
attachments, next to the link to its repository.
- Teams can now give a submission a description, with formatting, and a link to
a folder of attachments, alongside the link to their repository. A new version
starts out filled in from the previous one, so fixing a single link means
retyping nothing.
- The Manage Submissions CSV now has an "Attachments" column with each entry's
attachments link.
- Manage Submissions now shows each version's attachments link and, folded away
until opened, its description.
- A team's page now shows its entry's attachments link and description, next to
the repository link.

### Changed

- The repository link and the attachments link on a submission must now start
with `https://`. The box that used to take a link or free-text notes now takes
a link only, and notes go in the description.

### Fixed

## [0.12.1](https://github.com/SwissDataScienceCenter/hackagon/compare/v0.12.0...v0.12.1) - 2026-10-27
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<script lang="ts">
import { isHttpUrl } from '$lib/utils/url';
import MarkdownContent from '$lib/components/forms/MarkdownContent.svelte';

// Plain strings, not the generated `Submission`: that type lives behind
// $lib/server and must never be imported into a component.
let {
repositoryUrl,
attachmentFolderUrl,
description,
collapseDescription = false,
}: {
repositoryUrl?: string;
attachmentFolderUrl?: string;
/** Markdown, as the team typed it. */
description?: string;
/**
* Fold the description behind a "Description" toggle — for a list of
* many teams, where one long description would push the rest down.
*/
collapseDescription?: boolean;
} = $props();
</script>

<!--
What a team filed on one submission version. No wrapper element: each line
sits directly in the caller's flex column, so the caller's gap spaces them.
Shared by every page that shows a submission, so they all make the same call
about which links to linkify.
-->

{#snippet linkLine(label: string, value: string)}
<p class="m-0 text-xs leading-snug">
<span class="text-ink-3">{label}:</span>
{#if isHttpUrl(value)}
<!-- eslint-disable svelte/no-navigation-without-resolve -- team-provided external URL -->
<a
href={value}
target="_blank"
rel="noopener noreferrer"
class="break-all text-accent-ink hover:underline"
>
{value}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{:else}
<!-- Notes typed into the link field before it had to be one. -->
<span class="break-words text-ink-2">{value}</span>
{/if}
</p>
{/snippet}

{#if repositoryUrl}
{@render linkLine('Repository', repositoryUrl)}
{/if}
{#if attachmentFolderUrl}
{@render linkLine('Attachments', attachmentFolderUrl)}
{/if}
{#snippet descriptionBody(content: string)}
<div class="min-w-0 text-xs leading-snug text-ink-2">
<MarkdownContent {content} />
</div>
{/snippet}

{#if description && collapseDescription}
<details>
<summary class="cursor-pointer text-xs text-ink-3">Description</summary>
<div class="mt-1">{@render descriptionBody(description)}</div>
</details>
{:else if description}
{@render descriptionBody(description)}
{/if}
{#if !repositoryUrl && !attachmentFolderUrl && !description}
<!-- Said outright: every field is optional on the backend, and a blank
version would otherwise be indistinguishable from a broken render. -->
<p class="m-0 text-xs leading-snug text-ink-3 italic">Nothing filled in on this version.</p>
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { render, screen } from "@testing-library/svelte"
import { describe, expect, it } from "vitest"

import SubmissionContent from "./SubmissionContent.svelte"

/*
* Only the fields that are filled in; a link only for what parses as one, since
* an older version's repository field may hold notes.
*/

describe("SubmissionContent", () => {
it("links both URLs and renders the description as Markdown", () => {
render(SubmissionContent, {
repositoryUrl: "https://github.com/byte-me/floods",
attachmentFolderUrl: "https://drive.example.org/byte-me",
description: "We built **flood maps**.",
})

const links = screen.getAllByRole("link")
expect(links.map((a) => a.getAttribute("href"))).toEqual([
"https://github.com/byte-me/floods",
"https://drive.example.org/byte-me",
])
expect(links[0]?.getAttribute("rel")).toBe("noopener noreferrer")
expect(screen.getByText("flood maps").tagName).toBe("STRONG")
})

it("shows notes from before links were required as text, not a link", () => {
render(SubmissionContent, { repositoryUrl: "Repo is private, ask us" })

expect(screen.queryByRole("link")).toBeNull()
expect(screen.getByText("Repo is private, ask us")).toBeTruthy()
})

it("says so when nothing is filled in", () => {
render(SubmissionContent, {})

expect(screen.getByText("Nothing filled in on this version.")).toBeTruthy()
})

it("folds the description behind a toggle when asked to", () => {
render(SubmissionContent, {
description: "We built **flood maps**.",
collapseDescription: true,
})

const details = screen.getByText("flood maps").closest("details")
expect(details).not.toBeNull()
expect(details?.open).toBe(false)
expect(screen.getByText("Description").tagName).toBe("SUMMARY")
})

it("does not say so when only the description is filled in", () => {
render(SubmissionContent, { description: "Just notes" })

expect(screen.queryByText("Nothing filled in on this version.")).toBeNull()
expect(screen.queryByRole("link")).toBeNull()
})
})
34 changes: 11 additions & 23 deletions components/frontend/src/lib/components/hackathon/TeamCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
submissionStatusBadgeVariant,
} from '$lib/utils/submissionStatus';
import MarkdownContent from '$lib/components/forms/MarkdownContent.svelte';
import { isHttpUrl } from '$lib/utils/url';
import SubmissionContent from '$lib/components/hackathon/SubmissionContent.svelte';
import type { Snippet } from 'svelte';

type TeamMember = {
Expand All @@ -17,7 +17,9 @@
// over a plain object, already narrowed to the one version that counts.
type FinalEntry = {
version: number;
result?: string;
repositoryUrl?: string;
attachmentFolderUrl?: string;
description?: string;
finalizedAt?: Date;
finalizedBy?: string;
};
Expand Down Expand Up @@ -184,27 +186,13 @@
<span class="text-xs text-ink-3">version {entry.version}</span>
</div>

{#if entry.result}
{#if isHttpUrl(entry.result)}
<!-- eslint-disable svelte/no-navigation-without-resolve -- team-provided external URL -->
<a
href={entry.result}
target="_blank"
rel="noopener noreferrer"
class="m-0 block break-all text-xs leading-snug
text-accent-ink hover:underline"
>
{entry.result}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{:else}
<p class="m-0 text-xs leading-snug text-ink-2">{entry.result}</p>
{/if}
{:else}
<p class="m-0 text-xs italic leading-snug text-ink-3">
No link or notes on this version.
</p>
{/if}
<!-- Description in full: this card shows one team, on a page
a voter opens to read that team's entry. -->
<SubmissionContent
repositoryUrl={entry.repositoryUrl}
attachmentFolderUrl={entry.attachmentFolderUrl}
description={entry.description}
/>

{#if entry.finalizedAt}
<span class="text-xs text-ink-3">
Expand Down
103 changes: 103 additions & 0 deletions components/frontend/src/lib/server/hackathon/submissionForm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest"
import { parseSubmissionForm } from "./submissionForm"

function form(fields: Record<string, string>): FormData {
const f = new FormData()
for (const [k, v] of Object.entries(fields)) f.set(k, v)

return f
}

describe("parseSubmissionForm", () => {
it("takes all three fields, trimmed", () => {
expect(
parseSubmissionForm(
form({
repositoryUrl: " https://github.com/byte-me/floods ",
attachmentFolderUrl: "https://drive.example.org/floods",
description: "\nFlood maps.\n",
}),
),
).toEqual({
ok: true,
values: {
repositoryUrl: "https://github.com/byte-me/floods",
attachmentFolderUrl: "https://drive.example.org/floods",
description: "Flood maps.",
},
})
})

it("leaves a blank field out rather than sending an empty string", () => {
expect(
parseSubmissionForm(form({ repositoryUrl: " ", description: "Notes" })),
).toEqual({
ok: true,
values: {
repositoryUrl: undefined,
attachmentFolderUrl: undefined,
description: "Notes",
},
})
})

it("accepts any one field on its own", () => {
expect(
parseSubmissionForm(
form({ attachmentFolderUrl: "https://drive.example.org/x" }),
).ok,
).toBe(true)
})

it("refuses a version with nothing in it", () => {
expect(
parseSubmissionForm(
form({ repositoryUrl: "", attachmentFolderUrl: " ", description: "" }),
),
).toEqual({
ok: false,
message: "Add a repository link, an attachments link or a description",
})
})

it("names the repository link when it is not https", () => {
expect(
parseSubmissionForm(form({ repositoryUrl: "http://github.com/x" })),
).toEqual({
ok: false,
message: "The repository link must start with https://",
})
})

it("names the attachments link when it is not https", () => {
expect(
parseSubmissionForm(
form({
repositoryUrl: "https://github.com/x",
attachmentFolderUrl: "drive.example.org/x",
}),
),
).toEqual({
ok: false,
message: "The attachments link must start with https://",
})
})

it("refuses a link over the backend's limit", () => {
const long = `https://example.org/${"a".repeat(2048)}`

expect(parseSubmissionForm(form({ repositoryUrl: long }))).toEqual({
ok: false,
message: "The repository link must be at most 2048 characters",
})
})

it("refuses a description over the backend's limit", () => {
expect(
parseSubmissionForm(form({ description: "a".repeat(10001) })),
).toEqual({
ok: false,
message: "The description must be at most 10000 characters",
})
})
})
Loading
Loading