Skip to content

Commit 5e080c6

Browse files
authored
Merge pull request #344 from SwissDataScienceCenter/feat/frontend-user-social-links
Feat/frontend user social links
2 parents c316123 + 4f073df commit 5e080c6

15 files changed

Lines changed: 608 additions & 32 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ written while it was being built. See [RELEASING.md](RELEASING.md).
2525
the sign-in service, where the same form also lets you correct your name, and
2626
the new address is what hackathon organizers see as soon as you are back.
2727
Nothing is sent to the new address to confirm it, so a typo is saved as typed.
28-
- A user profile can hold links to the person's GitHub, LinkedIn and Renku
29-
accounts. People can set their own links and administrators can set anyone's;
30-
only links to those three sites are accepted. No page shows or edits them yet.
28+
- You can add links to your GitHub, LinkedIn and Renku profiles on your Account
29+
page; leave a box empty to remove that link. Other participants see them on
30+
the Participants page and on your profile there. Only links to those three
31+
sites are accepted, and each is saved in a tidied form, so a link may look
32+
slightly different from what you typed.
3133
- Organizers have a Manage Submissions page: every team and where it stands with
3234
its submission — nothing yet, drafts only, or final — with the teams still
3335
owing an entry first. Tabs filter by standing, a search finds a team by its

‎components/frontend/src/lib/components/hackathon/ParticipantCard.svelte‎

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<script lang="ts">
2+
import ProfileLinksRow from '$lib/components/hackathon/ProfileLinks.svelte';
23
import RoundMedia from '$lib/components/hackathon/RoundMedia.svelte';
4+
import type { ProfileLinks } from '$lib/utils/profileLinks';
35
import type { Snippet } from 'svelte';
46
57
let {
@@ -8,23 +10,24 @@
810
avatarUrl,
911
role: roleProp,
1012
skills = [],
11-
linkedinUrl,
13+
links,
1214
actions,
1315
}: {
1416
name: string;
1517
/**
1618
* TODO(backend: user-profile-fields): unset by the participants page —
1719
* User carries only username, displayName, email and keycloakId, so
18-
* there is no affiliation to pass. Same for `avatarUrl`, `skills` and
19-
* `linkedinUrl`: the card simply omits those lines until the fields
20-
* exist, at which point the page passes them straight through.
20+
* there is no affiliation to pass. Same for `avatarUrl` and `skills`:
21+
* the card simply omits those lines until the fields exist, at which
22+
* point the page passes them straight through.
2123
*/
2224
affiliation?: string;
2325
avatarUrl?: string;
2426
/** Job title; if omitted, first skills are shown on the role line. */
2527
role?: string;
2628
skills?: string[];
27-
linkedinUrl?: string;
29+
/** The person's own GitHub, LinkedIn and Renku links; omitted, or all empty, shows none. */
30+
links?: ProfileLinks;
2831
/**
2932
* The card's controls — e.g. an owner's Approve/Remove buttons. Left to
3033
* the caller so this card stays ignorant of hackathon roles and
@@ -73,18 +76,8 @@
7376
{#if affiliation}
7477
<p class="m-0 text-xs leading-snug text-ink-3">{affiliation}</p>
7578
{/if}
76-
{#if linkedinUrl}
77-
<!-- eslint-disable svelte/no-navigation-without-resolve -- external LinkedIn URL -->
78-
<a
79-
href={linkedinUrl}
80-
target="_blank"
81-
rel="noopener noreferrer"
82-
class="w-fit text-xs leading-snug text-accent-ink
83-
hover:underline"
84-
>
85-
LinkedIn Profile
86-
</a>
87-
<!-- eslint-enable svelte/no-navigation-without-resolve -->
79+
{#if links}
80+
<ProfileLinksRow {links} />
8881
{/if}
8982
</div>
9083
</div>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<script lang="ts">
2+
import Github from 'lucide-svelte/icons/github';
3+
import Link from 'lucide-svelte/icons/link';
4+
import Linkedin from 'lucide-svelte/icons/linkedin';
5+
import { PROFILE_LINK_KINDS, type ProfileLinks } from '$lib/utils/profileLinks';
6+
7+
let {
8+
links,
9+
variant = 'compact',
10+
}: {
11+
links: ProfileLinks;
12+
/**
13+
* `compact` is the icon and site name, for rows about someone else.
14+
* `full` adds the URL itself, one link per line — for the person's own
15+
* Account page, where the point is to check which address was saved.
16+
*/
17+
variant?: 'compact' | 'full';
18+
} = $props();
19+
20+
// Renku has no mark in lucide, so it gets the generic link.
21+
const ICON = { github: Github, linkedin: Linkedin, renku: Link };
22+
23+
const shown = $derived(PROFILE_LINK_KINDS.filter((kind) => links[kind.key] !== ''));
24+
</script>
25+
26+
<!--
27+
A person's GitHub, LinkedIn and Renku links, the ones they set themselves on
28+
the Account page. Renders nothing when none is set, so a caller can drop it in
29+
unconditionally.
30+
31+
Each href is safe as given: the backend builds it from a fixed https prefix
32+
and an escaped username, never from what was typed (see $lib/utils/profileLinks).
33+
-->
34+
{#if shown.length > 0}
35+
<ul
36+
class="m-0 flex list-none gap-x-3 gap-y-1 p-0
37+
{variant === 'full' ? 'flex-col' : 'flex-wrap items-center'}"
38+
>
39+
{#each shown as kind (kind.key)}
40+
{@const Icon = ICON[kind.key]}
41+
<li class="min-w-0">
42+
<!-- eslint-disable svelte/no-navigation-without-resolve -- external profile URL -->
43+
<a
44+
href={links[kind.key]}
45+
target="_blank"
46+
rel="noopener noreferrer"
47+
class="inline-flex max-w-full items-center gap-1 text-xs leading-snug
48+
text-accent-ink hover:underline"
49+
>
50+
<Icon class="size-3.5 shrink-0" aria-hidden="true" />
51+
{kind.label}
52+
{#if variant === 'full'}
53+
<!-- Truncated, not wrapped: a URL broken across lines is
54+
harder to check than one cut short, and the href and
55+
the tooltip still carry all of it. -->
56+
<span class="truncate text-ink-3" title={links[kind.key]}
57+
>{links[kind.key]}</span
58+
>
59+
{/if}
60+
</a>
61+
<!-- eslint-enable svelte/no-navigation-without-resolve -->
62+
</li>
63+
{/each}
64+
</ul>
65+
{/if}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { render, screen } from "@testing-library/svelte"
2+
import { describe, expect, it } from "vitest"
3+
4+
import ProfileLinks from "./ProfileLinks.svelte"
5+
6+
/*
7+
* Only the links that are set, each opening off-site without handing this page
8+
* to the profile it opens.
9+
*/
10+
11+
describe("ProfileLinks", () => {
12+
it("renders only the links that are set", () => {
13+
render(ProfileLinks, {
14+
links: {
15+
github: "https://github.com/octo",
16+
linkedin: "",
17+
renku: "https://renkulab.io/u/jane",
18+
},
19+
})
20+
21+
const links = screen.getAllByRole("link")
22+
expect(links.map((a) => a.textContent?.trim())).toEqual(["GitHub", "Renku"])
23+
expect(
24+
screen.getByRole("link", { name: "GitHub" }).getAttribute("href"),
25+
).toBe("https://github.com/octo")
26+
})
27+
28+
it("opens each in a new tab without an opener", () => {
29+
render(ProfileLinks, {
30+
links: { github: "https://github.com/octo", linkedin: "", renku: "" },
31+
})
32+
33+
const link = screen.getByRole("link", { name: "GitHub" })
34+
expect(link.getAttribute("target")).toBe("_blank")
35+
expect(link.getAttribute("rel")).toBe("noopener noreferrer")
36+
})
37+
38+
// The Account page's own view: which address was saved is the point there.
39+
it("shows the URL itself in the full variant, and only there", () => {
40+
const links = { github: "https://github.com/octo", linkedin: "", renku: "" }
41+
42+
const { unmount } = render(ProfileLinks, { links })
43+
expect(screen.queryByText("https://github.com/octo")).toBeNull()
44+
unmount()
45+
46+
render(ProfileLinks, { links, variant: "full" })
47+
expect(screen.getByText("https://github.com/octo")).toBeTruthy()
48+
})
49+
50+
it("renders nothing when no link is set", () => {
51+
const { container } = render(ProfileLinks, {
52+
links: { github: "", linkedin: "", renku: "" },
53+
})
54+
55+
expect(container.querySelector("ul")).toBeNull()
56+
})
57+
})
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { describe, expect, it } from "vitest"
2+
import { ClientError, Status } from "nice-grpc-common"
3+
import type { User } from "$lib/server/grpc/generated/user/entities/user"
4+
import {
5+
editRequestFor,
6+
failForLinks,
7+
profileLinksFrom,
8+
profileLinksOf,
9+
} from "./profileLinks"
10+
11+
const formOf = (fields: Record<string, string>) => {
12+
const form = new FormData()
13+
for (const [name, value] of Object.entries(fields)) form.set(name, value)
14+
return form
15+
}
16+
17+
const grpcError = (code: Status) =>
18+
new ClientError("/user.UserService/Edit", code, "invalid GitHub URL 'x'")
19+
20+
describe("profileLinksOf", () => {
21+
it("reads the rendered URLs off the user", () => {
22+
const user = {
23+
githubUrl: "https://github.com/octo",
24+
linkedinUrl: "",
25+
renkuUrl: "https://renkulab.io/u/jane",
26+
} as User
27+
28+
expect(profileLinksOf(user)).toEqual({
29+
github: "https://github.com/octo",
30+
linkedin: "",
31+
renku: "https://renkulab.io/u/jane",
32+
})
33+
})
34+
35+
// No platform user when the backend was down for WhoAmI.
36+
it("gives empty links for no user", () => {
37+
expect(profileLinksOf(undefined)).toEqual({
38+
github: "",
39+
linkedin: "",
40+
renku: "",
41+
})
42+
})
43+
})
44+
45+
describe("profileLinksFrom", () => {
46+
it("trims what was typed", () => {
47+
const form = formOf({
48+
github: " https://github.com/octo ",
49+
linkedin: "",
50+
renku: "\thttps://renkulab.io/u/jane\n",
51+
})
52+
53+
expect(profileLinksFrom(form)).toEqual({
54+
github: "https://github.com/octo",
55+
linkedin: "",
56+
renku: "https://renkulab.io/u/jane",
57+
})
58+
})
59+
60+
// The distinction the backend acts on: "" removes, undefined keeps.
61+
it("leaves a field the form did not post undefined, not empty", () => {
62+
const links = profileLinksFrom(formOf({ github: "" }))
63+
64+
expect(links.github).toBe("")
65+
expect(links.linkedin).toBeUndefined()
66+
expect(links.renku).toBeUndefined()
67+
})
68+
})
69+
70+
describe("editRequestFor", () => {
71+
it("sends an emptied box as an empty string, which removes the link", () => {
72+
expect(
73+
editRequestFor("u-1", {
74+
github: "",
75+
linkedin: "https://linkedin.com/in/jane",
76+
renku: "",
77+
}),
78+
).toEqual({
79+
userId: "u-1",
80+
githubUrl: "",
81+
linkedinUrl: "https://linkedin.com/in/jane",
82+
renkuUrl: "",
83+
})
84+
})
85+
86+
it("leaves out a link it was not given, which keeps it", () => {
87+
const req = editRequestFor("u-1", { github: "https://github.com/octo" })
88+
89+
expect(req.linkedinUrl).toBeUndefined()
90+
expect(req.renkuUrl).toBeUndefined()
91+
})
92+
})
93+
94+
describe("failForLinks", () => {
95+
const values = { github: "x" }
96+
97+
it("passes the backend's reason through for an invalid link", () => {
98+
const failure = failForLinks(grpcError(Status.INVALID_ARGUMENT), values)
99+
100+
expect(failure.status).toBe(400)
101+
expect(failure.data).toEqual({
102+
message: "invalid GitHub URL 'x'",
103+
values,
104+
})
105+
})
106+
107+
it.each([
108+
[Status.PERMISSION_DENIED, 403],
109+
[Status.NOT_FOUND, 404],
110+
])("maps gRPC %s to %s and keeps the typed values", (code, status) => {
111+
const failure = failForLinks(grpcError(code), values)
112+
113+
expect(failure.status).toBe(status)
114+
expect(failure.data.values).toBe(values)
115+
})
116+
117+
// Anything else is not the user's to fix, so it goes to handleError.
118+
it("rethrows other errors", () => {
119+
const unavailable = grpcError(Status.UNAVAILABLE)
120+
const plain = new Error("boom")
121+
122+
expect(() => failForLinks(unavailable, values)).toThrow(unavailable)
123+
expect(() => failForLinks(plain, values)).toThrow(plain)
124+
})
125+
})

0 commit comments

Comments
 (0)