Skip to content
Draft
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
46 changes: 22 additions & 24 deletions backend/dashboard/src/components/app-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Link } from "@inertiajs/react";
import { LayoutDashboard } from "lucide-react";

import {
type ConferenceData,
ConferenceSwitcher,
} from "@/components/conference-switcher";
import { NavUser, type NavUserData } from "@/components/nav-user";

import {
Expand All @@ -17,32 +21,26 @@ import {
SidebarRail,
} from "@/components/ui/sidebar";

export function AppSidebar({ user }: { user: NavUserData }) {
export function AppSidebar({
conferences,
selectedConference,
user,
}: {
conferences: ConferenceData[];
selectedConference: ConferenceData | null;
user: NavUserData;
}) {
const dashboardUrl = selectedConference
? `/dashboard/${encodeURIComponent(selectedConference.code)}`
: "/dashboard";

return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild size="lg" tooltip="PyCon Italia 2026">
<Link aria-label="PyCon Italia 2026" href="/dashboard">
<div
aria-hidden="true"
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"
>
26
</div>
<div className="grid flex-1 text-left text-sm/4">
<div className="truncate font-semibold">
PyCon Italia 2026
</div>
<div className="truncate text-xs text-muted-foreground">
Python Italia
</div>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<ConferenceSwitcher
conferences={conferences}
selectedConference={selectedConference}
/>
</SidebarHeader>

<SidebarContent>
Expand All @@ -52,7 +50,7 @@ export function AppSidebar({ user }: { user: NavUserData }) {
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive tooltip="Dashboard">
<Link href="/dashboard">
<Link href={dashboardUrl}>
<LayoutDashboard aria-hidden="true" />
<span>Dashboard</span>
</Link>
Expand Down
133 changes: 133 additions & 0 deletions backend/dashboard/src/components/conference-switcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { Link } from "@inertiajs/react";
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";

import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";

export type ConferenceData = {
code: string;
name: string;
organizer: string;
year: number | null;
};

function getConferenceMark(conference: ConferenceData) {
if (conference.year) {
return String(conference.year).slice(-2);
}

return conference.name.slice(0, 2).toUpperCase();
}

export function ConferenceSwitcher({
conferences,
selectedConference,
}: {
conferences: ConferenceData[];
selectedConference: ConferenceData | null;
}) {
const { isMobile } = useSidebar();

if (!selectedConference) {
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton disabled size="lg" tooltip="No conferences">
<div
aria-hidden="true"
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"
>
--
</div>
<div className="grid flex-1 text-left text-sm/4">
<div className="truncate font-semibold">No conferences</div>
<div className="truncate text-xs text-muted-foreground">
Python Italia
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
}

return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-open:bg-sidebar-accent data-open:text-sidebar-accent-foreground"
size="lg"
tooltip={selectedConference.name}
>
<div
aria-hidden="true"
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"
>
{getConferenceMark(selectedConference)}
</div>
<div className="grid flex-1 text-left text-sm/4">
<div className="truncate font-semibold">
{selectedConference.name}
</div>
<div className="truncate text-xs text-muted-foreground">
{selectedConference.organizer}
</div>
</div>
<ChevronsUpDownIcon className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel>Conferences</DropdownMenuLabel>
{conferences.map((conference) => (
<DropdownMenuItem
asChild
className="gap-2 p-2"
key={conference.code}
>
<Link
href={`/dashboard/${encodeURIComponent(conference.code)}`}
>
<div
aria-hidden="true"
className="flex size-6 shrink-0 items-center justify-center rounded-md border text-xs font-medium tabular-nums"
>
{getConferenceMark(conference)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">
{conference.name}
</div>
<div className="truncate text-xs text-muted-foreground">
{conference.organizer}
</div>
</div>
{conference.code === selectedConference.code ? (
<CheckIcon aria-label="Selected" className="ml-auto" />
) : null}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
17 changes: 15 additions & 2 deletions backend/dashboard/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AppSidebar } from "@/components/app-sidebar";
import type { ConferenceData } from "@/components/conference-switcher";
import type { NavUserData } from "@/components/nav-user";
import { Separator } from "@/components/ui/separator";
import {
Expand All @@ -8,11 +9,23 @@ import {
} from "@/components/ui/sidebar";
import { TooltipProvider } from "@/components/ui/tooltip";

export default function Dashboard({ user }: { user: NavUserData }) {
export default function Dashboard({
conferences,
selectedConference,
user,
}: {
conferences: ConferenceData[];
selectedConference: ConferenceData | null;
user: NavUserData;
}) {
return (
<TooltipProvider>
<SidebarProvider className="antialiased">
<AppSidebar user={user} />
<AppSidebar
conferences={conferences}
selectedConference={selectedConference}
user={user}
/>
<SidebarInset className="isolate min-h-dvh">
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger />
Expand Down
57 changes: 57 additions & 0 deletions backend/dashboard/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
from datetime import timedelta

from django.utils import timezone

from conferences.tests.factories import ConferenceFactory


def test_dashboard_requires_authentication(client):
response = client.get("/dashboard")
inertia_response = client.get("/dashboard", headers={"X-Inertia": "true"})
conference_response = client.get("/dashboard/pycon-2026")

assert response.status_code == 302
assert response.url == "/dashboard/login?next=/dashboard"
assert inertia_response.status_code == 302
assert inertia_response.url == "/dashboard/login?next=/dashboard"
assert conference_response.status_code == 302
assert conference_response.url == "/dashboard/login?next=/dashboard/pycon-2026"


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


def test_dashboard_shares_authenticated_user(client, user):
Expand All @@ -41,6 +53,51 @@ def test_dashboard_shares_authenticated_user(client, user):
}


def test_dashboard_redirects_to_latest_conference(client, user):
client.force_login(user)
ConferenceFactory(
code="pycon-2025",
start=timezone.now() - timedelta(days=365),
)
latest_conference = ConferenceFactory(
code="pycon-2026",
start=timezone.now(),
)

response = client.get("/dashboard")

assert response.status_code == 302
assert response.url == f"/dashboard/{latest_conference.code}"


def test_dashboard_uses_conference_from_url(client, user):
client.force_login(user)
selected_conference = ConferenceFactory(code="pycon-2025")
ConferenceFactory(code="pycon-2026")

response = client.get(
f"/dashboard/{selected_conference.code}",
headers={"X-Inertia": "true"},
)

assert response.status_code == 200
assert response.json()["props"]["selectedConference"]["code"] == "pycon-2025"
assert {
conference["code"] for conference in response.json()["props"]["conferences"]
} == {
"pycon-2025",
"pycon-2026",
}


def test_dashboard_returns_404_for_unknown_conference(client, user):
client.force_login(user)

response = client.get("/dashboard/unknown")

assert response.status_code == 404


def test_dashboard_login_renders_inertia_page(client):
response = client.get("/dashboard/login")

Expand Down
2 changes: 1 addition & 1 deletion backend/dashboard/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
from dashboard.views import dashboard

urlpatterns = [
path("", dashboard, name="dashboard"),
path("<str:conference_code>", dashboard, name="dashboard-conference"),
]
Loading
Loading