Skip to content

Commit 421f609

Browse files
committed
Add conference switcher
Shortcake-Parent: 2026-08-06-add-login-page
1 parent 177fb7e commit 421f609

7 files changed

Lines changed: 295 additions & 35 deletions

File tree

backend/dashboard/src/components/app-sidebar.tsx

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { Link } from "@inertiajs/react";
22
import { LayoutDashboard } from "lucide-react";
33

4+
import {
5+
type ConferenceData,
6+
ConferenceSwitcher,
7+
} from "@/components/conference-switcher";
48
import { NavUser, type NavUserData } from "@/components/nav-user";
59

610
import {
@@ -17,32 +21,26 @@ import {
1721
SidebarRail,
1822
} from "@/components/ui/sidebar";
1923

20-
export function AppSidebar({ user }: { user: NavUserData }) {
24+
export function AppSidebar({
25+
conferences,
26+
selectedConference,
27+
user,
28+
}: {
29+
conferences: ConferenceData[];
30+
selectedConference: ConferenceData | null;
31+
user: NavUserData;
32+
}) {
33+
const dashboardUrl = selectedConference
34+
? `/dashboard/${encodeURIComponent(selectedConference.code)}`
35+
: "/dashboard";
36+
2137
return (
2238
<Sidebar collapsible="icon">
2339
<SidebarHeader>
24-
<SidebarMenu>
25-
<SidebarMenuItem>
26-
<SidebarMenuButton asChild size="lg" tooltip="PyCon Italia 2026">
27-
<Link aria-label="PyCon Italia 2026" href="/dashboard">
28-
<div
29-
aria-hidden="true"
30-
className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground tabular-nums"
31-
>
32-
26
33-
</div>
34-
<div className="grid flex-1 text-left text-sm/4">
35-
<div className="truncate font-semibold">
36-
PyCon Italia 2026
37-
</div>
38-
<div className="truncate text-xs text-muted-foreground">
39-
Python Italia
40-
</div>
41-
</div>
42-
</Link>
43-
</SidebarMenuButton>
44-
</SidebarMenuItem>
45-
</SidebarMenu>
40+
<ConferenceSwitcher
41+
conferences={conferences}
42+
selectedConference={selectedConference}
43+
/>
4644
</SidebarHeader>
4745

4846
<SidebarContent>
@@ -52,7 +50,7 @@ export function AppSidebar({ user }: { user: NavUserData }) {
5250
<SidebarMenu>
5351
<SidebarMenuItem>
5452
<SidebarMenuButton asChild isActive tooltip="Dashboard">
55-
<Link href="/dashboard">
53+
<Link href={dashboardUrl}>
5654
<LayoutDashboard aria-hidden="true" />
5755
<span>Dashboard</span>
5856
</Link>
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { Link } from "@inertiajs/react";
2+
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
3+
4+
import {
5+
DropdownMenu,
6+
DropdownMenuContent,
7+
DropdownMenuItem,
8+
DropdownMenuLabel,
9+
DropdownMenuTrigger,
10+
} from "@/components/ui/dropdown-menu";
11+
import {
12+
SidebarMenu,
13+
SidebarMenuButton,
14+
SidebarMenuItem,
15+
useSidebar,
16+
} from "@/components/ui/sidebar";
17+
18+
export type ConferenceData = {
19+
code: string;
20+
name: string;
21+
organizer: string;
22+
year: number | null;
23+
};
24+
25+
function getConferenceMark(conference: ConferenceData) {
26+
if (conference.year) {
27+
return String(conference.year).slice(-2);
28+
}
29+
30+
return conference.name.slice(0, 2).toUpperCase();
31+
}
32+
33+
export function ConferenceSwitcher({
34+
conferences,
35+
selectedConference,
36+
}: {
37+
conferences: ConferenceData[];
38+
selectedConference: ConferenceData | null;
39+
}) {
40+
const { isMobile } = useSidebar();
41+
42+
if (!selectedConference) {
43+
return (
44+
<SidebarMenu>
45+
<SidebarMenuItem>
46+
<SidebarMenuButton disabled size="lg" tooltip="No conferences">
47+
<div
48+
aria-hidden="true"
49+
className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground"
50+
>
51+
--
52+
</div>
53+
<div className="grid flex-1 text-left text-sm/4">
54+
<div className="truncate font-semibold">No conferences</div>
55+
<div className="truncate text-xs text-muted-foreground">
56+
Python Italia
57+
</div>
58+
</div>
59+
</SidebarMenuButton>
60+
</SidebarMenuItem>
61+
</SidebarMenu>
62+
);
63+
}
64+
65+
return (
66+
<SidebarMenu>
67+
<SidebarMenuItem>
68+
<DropdownMenu>
69+
<DropdownMenuTrigger asChild>
70+
<SidebarMenuButton
71+
className="data-open:bg-sidebar-accent data-open:text-sidebar-accent-foreground"
72+
size="lg"
73+
tooltip={selectedConference.name}
74+
>
75+
<div
76+
aria-hidden="true"
77+
className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sm font-semibold text-sidebar-primary-foreground tabular-nums"
78+
>
79+
{getConferenceMark(selectedConference)}
80+
</div>
81+
<div className="grid flex-1 text-left text-sm/4">
82+
<div className="truncate font-semibold">
83+
{selectedConference.name}
84+
</div>
85+
<div className="truncate text-xs text-muted-foreground">
86+
{selectedConference.organizer}
87+
</div>
88+
</div>
89+
<ChevronsUpDownIcon className="ml-auto" />
90+
</SidebarMenuButton>
91+
</DropdownMenuTrigger>
92+
<DropdownMenuContent
93+
align="start"
94+
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
95+
side={isMobile ? "bottom" : "right"}
96+
sideOffset={4}
97+
>
98+
<DropdownMenuLabel>Conferences</DropdownMenuLabel>
99+
{conferences.map((conference) => (
100+
<DropdownMenuItem
101+
asChild
102+
className="gap-2 p-2"
103+
key={conference.code}
104+
>
105+
<Link
106+
href={`/dashboard/${encodeURIComponent(conference.code)}`}
107+
>
108+
<div
109+
aria-hidden="true"
110+
className="flex size-6 shrink-0 items-center justify-center rounded-md border text-xs font-medium tabular-nums"
111+
>
112+
{getConferenceMark(conference)}
113+
</div>
114+
<div className="min-w-0 flex-1">
115+
<div className="truncate font-medium">
116+
{conference.name}
117+
</div>
118+
<div className="truncate text-xs text-muted-foreground">
119+
{conference.organizer}
120+
</div>
121+
</div>
122+
{conference.code === selectedConference.code ? (
123+
<CheckIcon aria-label="Selected" className="ml-auto" />
124+
) : null}
125+
</Link>
126+
</DropdownMenuItem>
127+
))}
128+
</DropdownMenuContent>
129+
</DropdownMenu>
130+
</SidebarMenuItem>
131+
</SidebarMenu>
132+
);
133+
}

backend/dashboard/src/pages/Dashboard.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AppSidebar } from "@/components/app-sidebar";
2+
import type { ConferenceData } from "@/components/conference-switcher";
23
import type { NavUserData } from "@/components/nav-user";
34
import { Separator } from "@/components/ui/separator";
45
import {
@@ -8,11 +9,23 @@ import {
89
} from "@/components/ui/sidebar";
910
import { TooltipProvider } from "@/components/ui/tooltip";
1011

11-
export default function Dashboard({ user }: { user: NavUserData }) {
12+
export default function Dashboard({
13+
conferences,
14+
selectedConference,
15+
user,
16+
}: {
17+
conferences: ConferenceData[];
18+
selectedConference: ConferenceData | null;
19+
user: NavUserData;
20+
}) {
1221
return (
1322
<TooltipProvider>
1423
<SidebarProvider className="antialiased">
15-
<AppSidebar user={user} />
24+
<AppSidebar
25+
conferences={conferences}
26+
selectedConference={selectedConference}
27+
user={user}
28+
/>
1629
<SidebarInset className="isolate min-h-dvh">
1730
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
1831
<SidebarTrigger />

backend/dashboard/tests/test_views.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1+
from datetime import timedelta
2+
3+
from django.utils import timezone
4+
5+
from conferences.tests.factories import ConferenceFactory
6+
7+
18
def test_dashboard_requires_authentication(client):
29
response = client.get("/dashboard")
310
inertia_response = client.get("/dashboard", headers={"X-Inertia": "true"})
11+
conference_response = client.get("/dashboard/pycon-2026")
412

513
assert response.status_code == 302
614
assert response.url == "/dashboard/login?next=/dashboard"
715
assert inertia_response.status_code == 302
816
assert inertia_response.url == "/dashboard/login?next=/dashboard"
17+
assert conference_response.status_code == 302
18+
assert conference_response.url == "/dashboard/login?next=/dashboard/pycon-2026"
919

1020

1121
def test_dashboard_renders_inertia_page(client, user):
@@ -27,6 +37,8 @@ def test_dashboard_supports_inertia_requests(client, user):
2737
assert response.headers["X-Inertia"] == "true"
2838
assert response.json()["component"] == "Dashboard"
2939
assert response.json()["props"]["user"]["email"] == "simulated@user.it"
40+
assert response.json()["props"]["conferences"] == []
41+
assert response.json()["props"]["selectedConference"] is None
3042

3143

3244
def test_dashboard_shares_authenticated_user(client, user):
@@ -41,6 +53,51 @@ def test_dashboard_shares_authenticated_user(client, user):
4153
}
4254

4355

56+
def test_dashboard_redirects_to_latest_conference(client, user):
57+
client.force_login(user)
58+
ConferenceFactory(
59+
code="pycon-2025",
60+
start=timezone.now() - timedelta(days=365),
61+
)
62+
latest_conference = ConferenceFactory(
63+
code="pycon-2026",
64+
start=timezone.now(),
65+
)
66+
67+
response = client.get("/dashboard")
68+
69+
assert response.status_code == 302
70+
assert response.url == f"/dashboard/{latest_conference.code}"
71+
72+
73+
def test_dashboard_uses_conference_from_url(client, user):
74+
client.force_login(user)
75+
selected_conference = ConferenceFactory(code="pycon-2025")
76+
ConferenceFactory(code="pycon-2026")
77+
78+
response = client.get(
79+
f"/dashboard/{selected_conference.code}",
80+
headers={"X-Inertia": "true"},
81+
)
82+
83+
assert response.status_code == 200
84+
assert response.json()["props"]["selectedConference"]["code"] == "pycon-2025"
85+
assert {
86+
conference["code"] for conference in response.json()["props"]["conferences"]
87+
} == {
88+
"pycon-2025",
89+
"pycon-2026",
90+
}
91+
92+
93+
def test_dashboard_returns_404_for_unknown_conference(client, user):
94+
client.force_login(user)
95+
96+
response = client.get("/dashboard/unknown")
97+
98+
assert response.status_code == 404
99+
100+
44101
def test_dashboard_login_renders_inertia_page(client):
45102
response = client.get("/dashboard/login")
46103

backend/dashboard/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
from dashboard.views import dashboard
44

55
urlpatterns = [
6-
path("", dashboard, name="dashboard"),
6+
path("<str:conference_code>", dashboard, name="dashboard-conference"),
77
]

0 commit comments

Comments
 (0)