Skip to content

Commit

Permalink
feat: Vote list page (#2040)
Browse files Browse the repository at this point in the history
  • Loading branch information
Ackuq authored Oct 1, 2023
1 parent 0e4a068 commit 6902d8d
Show file tree
Hide file tree
Showing 22 changed files with 707 additions and 16 deletions.
2 changes: 1 addition & 1 deletion apps/web/app/ledamot/[id]/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default function Profile({ member }: ProfileProps) {
<MemberImage
member={member}
className="mx-auto mt-8 h-40 w-40 sm:h-56 sm:w-56"
sizes="(min-width: 640px) 14rem, 10rem"
sizes="(min-width: 640px) 224px, 160px"
/>
<div className="mt-4 text-lg font-medium sm:text-xl">
{member.status}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/ledamot/member-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export default function MemberCard({ member }: Props) {
</div>
<MemberImage
member={member}
sizes="(min-width: 640px) 10rem, 8rem"
sizes="(min-width: 640px) 160px, 128px"
className="mb-1 ml-auto h-32 w-32 sm:h-40 sm:w-40"
>
{member.party !== "-" && (
Expand Down
6 changes: 5 additions & 1 deletion apps/web/app/parti/[party]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export default async function PartyPage({
<main>
<PageTitle
Icon={() => (
<PartyIcon className="mx-auto" size={50} party={partyAbbreviation} />
<PartyIcon
className="mx-auto mb-2 h-12 w-12 sm:h-16 sm:w-16"
sizes="(min-width: 640px) 64px, 48px"
party={partyAbbreviation}
/>
)}
>
{party.name}
Expand Down
26 changes: 26 additions & 0 deletions apps/web/app/votering/[id]/[bet]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Container from "@components/common/container";
import PageTitle from "@components/common/page-title";

interface Props {
params: {
id: string;
bet: string;
};
}

export function generateMetadata({ params: { id, bet } }: Props) {
return {
title: `${id} ${bet} | Votering | Partiguiden`,
description: `Hur har partiernat röstat i voteringen ${id}`,
};
}

export default function Vote() {
// TODO: Implement
return (
<main>
<PageTitle></PageTitle>
<Container></Container>
</main>
);
}
61 changes: 61 additions & 0 deletions apps/web/app/votering/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import PageTitle from "@components/common/page-title";
import type { FilterToggle } from "@components/filter/filter-context";
import { FilterContextProvider } from "@components/filter/filter-context";
import { ScaleIcon } from "@heroicons/react/24/solid";
import { getVotes } from "@lib/api/vote/get-votes";
import {
parseNumberSearchParam,
parseStringArraySearchParam,
parseStringSearchParam,
} from "@lib/utils/search-params";
import VoteList from "./vote-list";
import Filter from "@components/filter";
import { Committee, committeeInfo } from "@lib/committes";

export const metadata = {
title: "Voteringar | Partiguiden",
description: "Hur har partierna röstat i voteringar? Ta reda på det här",
};

function initialFilterToggles(committees: string[]): FilterToggle<Committee> {
return Object.values(Committee).reduce(
(prev, committee) => ({
...prev,
[committee]: {
title: committeeInfo[committee].desc,
value: committees.includes(committee),
},
}),
{} as FilterToggle<Committee>,
);
}

interface Props {
searchParams: {
sok?: string | string[];
sida?: string | string[];
utskott?: string | string[];
};
}

export default async function Votes({ searchParams }: Props) {
const page = parseNumberSearchParam(searchParams.sida) ?? 1;
const search = parseStringSearchParam(searchParams.sok);
const committees = parseStringArraySearchParam(searchParams.utskott);
const votes = await getVotes({ search, page, committees });

const filterToggles = initialFilterToggles(committees);

return (
<main>
<PageTitle Icon={ScaleIcon}>Voteringar</PageTitle>

<div className="mx-4 mb-4 flex gap-2 2xl:container 2xl:mx-auto">
<FilterContextProvider initialToggles={filterToggles}>
<VoteList currentPage={page} votes={votes} />
<Filter />
</FilterContextProvider>
</div>
</main>
);
}
85 changes: 85 additions & 0 deletions apps/web/app/votering/vote-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"use client";
import type { FilterToggle } from "@components/filter/filter-context";
import { useFilterContext } from "@components/filter/filter-context";
import type { VoteList } from "@lib/api/vote/types";
import { routes } from "@lib/navigation";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import Vote from "./vote";
import Pagination from "@components/common/pagination";

interface QueryParameters {
search: string;
toggles: FilterToggle<string>;
page?: number;
}

function buildQueryParameters({ search, toggles, page }: QueryParameters) {
const query = new URLSearchParams();
const activeToggles = Object.entries(toggles)
.filter(([, value]) => value.value)
.map(([key]) => key);
for (const toggle of activeToggles) {
query.append("utskott", toggle);
}
if (search) {
query.set("sok", search);
}

if (page) {
query.set("sida", page.toString());
}

return query;
}

interface Props {
currentPage: number;
votes: VoteList;
}

export default function VoteList({ votes, currentPage }: Props) {
const router = useRouter();
const { search, toggles } = useFilterContext();

useEffect(() => {
const debounce = setTimeout(() => {
const query = buildQueryParameters({ search, toggles });
router.replace(`${routes.votes}?${query}`);
}, 500);
return () => {
clearTimeout(debounce);
};
}, [router, search, toggles]);

function onChangePage(newPage: number) {
const query = buildQueryParameters({ search, toggles, page: newPage });
router.push(`${routes.votes}?${query}`);
}

if (votes.pages === 0) {
return (
<p className="flex-1 text-center text-xl sm:text-2xl">
Inga voteringar hittades
</p>
);
}

return (
<div className="grid flex-1 gap-4">
<Pagination
current={currentPage}
total={votes.pages}
onChange={onChangePage}
/>
{votes.votes.map((vote) => (
<Vote key={`${vote.documentId}:${vote.proposition}`} vote={vote} />
))}
<Pagination
current={currentPage}
total={votes.pages}
onChange={onChangePage}
/>
</div>
);
}
60 changes: 60 additions & 0 deletions apps/web/app/votering/vote-result.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import PartyIcon from "@components/party/icon";
import type { VotingResult } from "@lib/api/vote/types";
import type { Party } from "@partiguiden/party-data/types";

import { twMerge } from "tailwind-merge";

interface ResultColumnProps {
votes: Party[];
title: string;
className: string;
}

function ResultColumn({ votes, title, className }: ResultColumnProps) {
return (
<div className={twMerge("flex-1 px-2 py-4 text-center", className)}>
<p>{title}</p>
<div className="mt-4 flex flex-wrap justify-center gap-2 px-2">
{votes.map((party) => (
<PartyIcon
party={party}
key={party}
className="h-8 w-8 md:h-10 md:w-10"
sizes="(min-width: 768px) 32px, 40px"
/>
))}
</div>
</div>
);
}

interface Props {
votes: VotingResult;
}

export default function VoteResult({ votes }: Props) {
if (!votes.no.length || !votes.yes.length) {
return <p>Ingen voteringsdata hittades</p>;
}

return (
<div className="flex flex-col sm:flex-row">
<ResultColumn
className={twMerge(
"bg-green-200 dark:bg-green-700",
votes.winner !== "yes" && "bg-slate-200 dark:bg-slate-600",
)}
title="JA"
votes={votes.yes}
/>
<ResultColumn
className={twMerge(
"bg-red-200 dark:bg-red-800",
votes.winner !== "no" && "bg-slate-200 dark:bg-slate-600",
)}
title="NEJ"
votes={votes.no}
/>
</div>
);
}
26 changes: 26 additions & 0 deletions apps/web/app/votering/vote.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Card, CommitteeHeader } from "@components/common/card";
import type { VoteListEntry } from "@lib/api/vote/types";
import VoteResult from "./vote-result";
import Link from "next/link";
import { routes } from "@lib/navigation";

interface Props {
vote: VoteListEntry;
}

export default function Vote({ vote }: Props) {
return (
<Link href={routes.vote(vote.documentId, vote.proposition)}>
<Card className="p-0">
<CommitteeHeader committee={vote.committee} />
<div className="p-4">
<p className="text-sm text-slate-600 dark:text-slate-400">
{vote.title}
</p>
<p>{vote.subtitle}</p>
</div>
<VoteResult votes={vote.results} />
</Card>
</Link>
);
}
2 changes: 1 addition & 1 deletion apps/web/components/parliament/member-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default function MemberImage({
member,
className,
children,
sizes = "(min-width: 640px) 10rem, 6rem",
sizes = "(min-width: 640px) 160px, 96px",
}: MemberImageProps) {
const [fallback, setFallback] = useState(false);

Expand Down
21 changes: 12 additions & 9 deletions apps/web/components/party/icon.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
import Image from "next/image";
import { partyLogo } from "@lib/assets";
import type { Party } from "@partiguiden/party-data/types";
import { twMerge } from "tailwind-merge";

interface PartyIconProps {
party: Party;
size?: number;
className?: string;
sizes?: string;
}

export default function PartyIcon({
party,
size = 25,
className,
sizes = "24px",
}: PartyIconProps) {
return (
<Image
src={partyLogo(party)}
width={size}
height={size}
alt={`${party} logo`}
className={className}
/>
<div className={twMerge("relative h-6 w-6", className)}>
<Image
src={partyLogo(party)}
fill
sizes={sizes}
className="object-cover"
alt={`${party} logga`}
/>
</div>
);
}
17 changes: 17 additions & 0 deletions apps/web/lib/api/vote/get-vote-results.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { PARLIAMENT_BASE_URL } from "@lib/constants";
import stripJsonComments from "strip-json-comments";
import type { VoteResultsResponse } from "./types";
import parseVoteResult from "./parsers/vote-result";

export default async function getVoteResult(
id: string,
num: number,
): Promise<VoteResultsResponse> {
const response = await fetch(
`${PARLIAMENT_BASE_URL}/dokumentstatus/${id}.json`,
);
const text = await response.text();
const data = JSON.parse(stripJsonComments(text));

return parseVoteResult(data, num);
}
35 changes: 35 additions & 0 deletions apps/web/lib/api/vote/get-votes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { PARLIAMENT_BASE_URL } from "@lib/constants";
import { parseVotes } from "./parsers/votes";

interface Query {
search?: string;
committees: string[];
page?: number;
}

export async function getVotes({ search, committees, page }: Query) {
const query = new URLSearchParams({
doktyp: "votering",
sortorder: "desc",
utformat: "json",
sok: search || "",
sort: search ? "rel" : "datum",
p: page?.toString() || "",
});
for (const committe of committees) {
query.append("org", committe);
}

const response = await fetch(
`${PARLIAMENT_BASE_URL}/dokumentlista/?${query}`,
{
next: {
revalidate: 60 * 60,
},
},
);

const data = await response.json();

return parseVotes(data);
}
Loading

0 comments on commit 6902d8d

Please sign in to comment.