Implement user result highlights UI

This commit is contained in:
Remmy Cat Stock
2022-10-17 20:07:27 +02:00
parent 3bc75f58d3
commit 76afa161fc
11 changed files with 342 additions and 95 deletions

View File

@@ -1,84 +0,0 @@
import { Link, useMatches } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import invariant from "tiny-invariant";
import { Avatar } from "~/components/Avatar";
import { Placement } from "~/components/Placement";
import { Section } from "~/components/Section";
import { databaseTimestampToDate } from "~/utils/dates";
import { discordFullName } from "~/utils/strings";
import { calendarEventPage, userPage } from "~/utils/urls";
import type { UserPageLoaderData } from "../u.$identifier";
export default function UserResultsPage() {
const { t, i18n } = useTranslation("user");
const [, parentRoute] = useMatches();
invariant(parentRoute);
const data = parentRoute.data as UserPageLoaderData;
return (
<main className="main layout__main">
<Section className="u__results-section">
<table>
<thead>
<tr>
<th>{t("results.placing")}</th>
<th>{t("results.team")}</th>
<th>{t("results.tournament")}</th>
<th>{t("results.participants")}</th>
<th>{t("results.date")}</th>
<th>{t("results.mates")}</th>
</tr>
</thead>
<tbody>
{data.results.map((result) => (
<tr key={result.eventId}>
<td className="pl-4">
<Placement placement={result.placement} />
</td>
<td>{result.teamName}</td>
<td>
<Link to={calendarEventPage(result.eventId)}>
{result.eventName}
</Link>
</td>
<td>{result.participantCount}</td>
<td>
{databaseTimestampToDate(result.startTime).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "numeric",
year: "numeric",
}
)}
</td>
<td>
<ul className="u__results-players">
{result.mates.map((player) => (
<li
key={typeof player === "string" ? player : player.id}
className="flex items-center"
>
{typeof player === "string" ? (
player
) : (
<Link
to={userPage(player)}
className="stack horizontal xs items-center"
>
<Avatar user={player} size="xxs" />{" "}
{discordFullName(player)}
</Link>
)}
</li>
))}
</ul>
</td>
</tr>
))}
</tbody>
</table>
</Section>
</main>
);
}

View File

@@ -0,0 +1,111 @@
import { Link } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { Placement } from "~/components/Placement";
import { type UserPageLoaderData } from "~/routes/u.$identifier";
import { databaseTimestampToDate } from "~/utils/dates";
import { discordFullName } from "~/utils/strings";
import { calendarEventPage, userPage } from "~/utils/urls";
export type UserResultsTableProps = {
results: UserPageLoaderData["results"];
id: string;
hasHighlightCheckboxes?: boolean;
};
export const HIGHLIGHT_CHECKBOX_NAME = "highlightTeamIds";
export function UserResultsTable({
results,
id,
hasHighlightCheckboxes,
}: UserResultsTableProps) {
const { t, i18n } = useTranslation("user");
const placementHeaderId = `${id}-th-placement`;
return (
<table>
<thead>
<tr>
{hasHighlightCheckboxes && <th />}
<th id={placementHeaderId}>{t("results.placing")}</th>
<th>{t("results.team")}</th>
<th>{t("results.tournament")}</th>
<th>{t("results.participants")}</th>
<th>{t("results.date")}</th>
<th>{t("results.mates")}</th>
</tr>
</thead>
<tbody>
{results.map((result) => {
// We are trying to construct a reasonable label for the checkbox
// which shouldn't contain the whole information of the table row as
// that can be also accessed when needed.
// e.g. "20xx Placing 2nd", "Big House 10 Placing 20th"
const placementCellId = `${id}-${result.teamId}-placement`;
const nameCellId = `${id}-${result.teamId}-name`;
const checkboxLabelIds = `${nameCellId} ${placementHeaderId} ${placementCellId}`;
return (
<tr key={result.teamId}>
{hasHighlightCheckboxes && (
<td>
<input
value={result.teamId}
aria-labelledby={checkboxLabelIds}
name={HIGHLIGHT_CHECKBOX_NAME}
type="checkbox"
defaultChecked={result.isHighlight}
/>
</td>
)}
<td className="pl-4" id={placementCellId}>
<Placement placement={result.placement} />
</td>
<td>{result.teamName}</td>
<td id={nameCellId}>
<Link to={calendarEventPage(result.eventId)}>
{result.eventName}
</Link>
</td>
<td>{result.participantCount}</td>
<td>
{databaseTimestampToDate(result.startTime).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "numeric",
year: "numeric",
}
)}
</td>
<td>
<ul className="u__results-players">
{result.mates.map((player) => (
<li
key={typeof player === "string" ? player : player.id}
className="flex items-center"
>
{typeof player === "string" ? (
player
) : (
<Link
to={userPage(player)}
className="stack horizontal xs items-center"
>
<Avatar user={player} size="xxs" />
{discordFullName(player)}
</Link>
)}
</li>
))}
</ul>
</td>
</tr>
);
})}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,79 @@
import { type ActionFunction, redirect } from "@remix-run/node";
import { Form, useMatches, useTransition } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import invariant from "tiny-invariant";
import { z } from "zod";
import { Button } from "~/components/Button";
import { FormErrors } from "~/components/FormErrors";
import { Main } from "~/components/Main";
import { db } from "~/db";
import { requireUser } from "~/modules/auth";
import { type UserPageLoaderData } from "~/routes/u.$identifier";
import { normalizeFormFieldArray } from "~/utils/arrays";
import { parseRequestFormData } from "~/utils/remix";
import { userResultsPage } from "~/utils/urls";
import {
HIGHLIGHT_CHECKBOX_NAME,
UserResultsTable,
} from "./components/UserResultsTable";
const editHighlightsActionSchema = z.object({
[HIGHLIGHT_CHECKBOX_NAME]: z.optional(
z.union([z.array(z.string()), z.string()])
),
});
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
const data = await parseRequestFormData({
request,
schema: editHighlightsActionSchema,
});
const resultTeamIds = normalizeFormFieldArray(
data[HIGHLIGHT_CHECKBOX_NAME]
).map((id) => parseInt(id, 10));
db.users.updateResultHighlights({
userId: user.id,
resultTeamIds,
});
return redirect(userResultsPage(user));
};
export default function ResultHighlightsEditPage() {
const { t } = useTranslation(["common", "user"]);
const [, parentRoute] = useMatches();
const transition = useTransition();
invariant(parentRoute);
const userPageData = parentRoute.data as UserPageLoaderData;
return (
<Main>
<Form method="post" className="stack md items-start">
<h2 className="text-start">{t("user:results.highlights.choose")}</h2>
<div className="u__results-table-wrapper">
<fieldset className="u__results-table-highlights">
<legend>{t("user:results.highlights.explanation")}</legend>
<UserResultsTable
id="user-results-highlight-selection"
results={userPageData.results}
hasHighlightCheckboxes
/>
</fieldset>
</div>
<Button
loadingText={t("common:actions.saving")}
type="submit"
loading={transition.state === "submitting"}
data-cy="submit-button"
>
{t("common:actions.save")}
</Button>
<FormErrors namespace="user" />
</Form>
</Main>
);
}

View File

@@ -0,0 +1,68 @@
import { useMatches } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import invariant from "tiny-invariant";
import { LinkButton } from "~/components/Button";
import { Main } from "~/components/Main";
import { Section } from "~/components/Section";
import { useUser } from "~/modules/auth";
import { userResultsEditHighlightsPage } from "~/utils/urls";
import type { UserPageLoaderData } from "../../u.$identifier";
import { UserResultsTable } from "./components/UserResultsTable";
export default function UserResultsPage() {
const { t } = useTranslation("user");
const [, parentRoute] = useMatches();
invariant(parentRoute);
const userPageData = parentRoute.data as UserPageLoaderData;
const hasResults = userPageData.results.length > 0;
const nonHighlights = userPageData.results.filter((r) => !r.isHighlight);
const hasNonHighlights = nonHighlights.length > 0;
const highlights = userPageData.results.filter((r) => r.isHighlight);
const hasHighlights = highlights.length > 0;
const user = useUser();
const isOwnResultsPage = user?.id === userPageData.id;
const showHighlightsSection =
hasHighlights || (isOwnResultsPage && hasResults);
return (
<Main className="stack lg">
{showHighlightsSection && (
<Section
title={t("results.highlights")}
className="u__results-table-wrapper u__results-table-highlights stack md items-center"
>
{hasHighlights && (
<UserResultsTable
id="user-results-highlight-table"
results={highlights}
/>
)}
{isOwnResultsPage && (
<LinkButton
variant="outlined"
tiny
to={userResultsEditHighlightsPage(userPageData)}
>
{t("results.highlights.choose")}
</LinkButton>
)}
</Section>
)}
{hasNonHighlights && (
<Section
title={
hasHighlights ? t("results.nonHighlights") : t("results.title")
}
className="u__results-table-wrapper"
>
<UserResultsTable id="user-results-table" results={nonHighlights} />
</Section>
)}
</Main>
);
}

View File

@@ -292,6 +292,10 @@ table > tbody > tr > td {
padding-inline: var(--s-1);
}
td > input[type="checkbox"] {
vertical-align: middle;
}
hr {
border-color: var(--theme-transparent);
}

View File

@@ -147,10 +147,21 @@
font-weight: var(--bold);
}
.u__results-section {
.u__results-table-wrapper {
width: 100%;
overflow-x: auto;
}
.u__results-table-highlights {
border: var(--s-2) solid var(--bg-lighter);
padding-inline: 0 !important;
}
.u__results-table-highlights > legend {
margin-inline-start: var(--s-2);
padding-inline: var(--s-1);
}
.u__results-players {
display: flex;
flex-wrap: wrap;

View File

@@ -34,3 +34,9 @@ export function joinListToNaturalString(arg: string[]) {
return last ? `${commaJoined} and ${last}` : commaJoined;
}
export function normalizeFormFieldArray(
value: undefined | null | string | string[]
): string[] {
return value == null ? [] : typeof value === "string" ? [value] : value;
}

View File

@@ -54,6 +54,8 @@ export const userBuildsPage = (user: UserLinkArgs) =>
`${userPage(user)}/builds`;
export const userResultsPage = (user: UserLinkArgs) =>
`${userPage(user)}/results`;
export const userResultsEditHighlightsPage = (user: UserLinkArgs) =>
`${userResultsPage(user)}/highlights`;
export const userNewBuildPage = (user: UserLinkArgs) =>
`${userBuildsPage(user)}/new`;

View File

@@ -10,12 +10,17 @@
"stick": "Stick",
"sens": "Empfindlichkeit",
"results.title": "Ergebnisse",
"results.placing": "Platzierung",
"results.team": "Team",
"results.tournament": "Turnier",
"results.participants": "Teilnehmer",
"results.date": "Datum",
"results.mates": "Mitspieler",
"results.highlights": "Highlights",
"results.nonHighlights": "Weitere Ergebnisse",
"results.highlights.choose": "Highlights wählen",
"results.highlights.explanation": "Wähle Ergebnisse, die du hervorheben möchtest",
"forms.errors.invalidCustomUrl.numbers": "Benutzerdefinierte URL kann nicht nur aus Zahlen bestehen",
"forms.errors.invalidCustomUrl.strangeCharacter": "Benutzerdefinierte URL kann nicht aus speziellen Zeichen bestehen",

View File

@@ -10,12 +10,17 @@
"stick": "Stick",
"sens": "Sens",
"results.title": "Results",
"results.placing": "Placing",
"results.team": "Team",
"results.tournament": "Tournament",
"results.participants": "Participants",
"results.date": "Date",
"results.mates": "Mates",
"results.highlights": "Highlights",
"results.nonHighlights": "Other Results",
"results.highlights.choose": "Choose Highlights",
"results.highlights.explanation": "Select the results you want to highlight",
"forms.errors.invalidCustomUrl.numbers": "Custom URL can't only contain numbers",
"forms.errors.invalidCustomUrl.strangeCharacter": "Custom URL can't contain special characters",

View File

@@ -78,7 +78,7 @@
### 🟡 user.json
**7/20**
**7/25**
<details>
<summary>Missing</summary>
@@ -91,7 +91,12 @@
- motion
- stick
- sens
- results.title
- results.participants
- results.highlights
- results.nonHighlights
- results.highlights.choose
- results.highlights.explanation
- forms.errors.invalidCustomUrl.numbers
- forms.errors.invalidCustomUrl.strangeCharacter
- forms.errors.invalidCustomUrl.duplicate
@@ -179,7 +184,7 @@
### 🟢 user.json
**20/20**
**25/25**
---
@@ -271,7 +276,7 @@
### 🟡 user.json
**7/20**
**7/25**
<details>
<summary>Missing</summary>
@@ -284,7 +289,12 @@
- motion
- stick
- sens
- results.title
- results.participants
- results.highlights
- results.nonHighlights
- results.highlights.choose
- results.highlights.explanation
- forms.errors.invalidCustomUrl.numbers
- forms.errors.invalidCustomUrl.strangeCharacter
- forms.errors.invalidCustomUrl.duplicate
@@ -394,7 +404,7 @@
### 🟡 user.json
**7/20**
**7/25**
<details>
<summary>Missing</summary>
@@ -407,7 +417,12 @@
- motion
- stick
- sens
- results.title
- results.participants
- results.highlights
- results.nonHighlights
- results.highlights.choose
- results.highlights.explanation
- forms.errors.invalidCustomUrl.numbers
- forms.errors.invalidCustomUrl.strangeCharacter
- forms.errors.invalidCustomUrl.duplicate
@@ -468,7 +483,7 @@
### 🔴 user.json
**0/20**
**0/25**
---
@@ -650,7 +665,7 @@
### 🟡 user.json
**7/20**
**7/25**
<details>
<summary>Missing</summary>
@@ -663,7 +678,12 @@
- motion
- stick
- sens
- results.title
- results.participants
- results.highlights
- results.nonHighlights
- results.highlights.choose
- results.highlights.explanation
- forms.errors.invalidCustomUrl.numbers
- forms.errors.invalidCustomUrl.strangeCharacter
- forms.errors.invalidCustomUrl.duplicate
@@ -774,7 +794,7 @@
### 🟡 user.json
**7/20**
**7/25**
<details>
<summary>Missing</summary>
@@ -787,7 +807,12 @@
- motion
- stick
- sens
- results.title
- results.participants
- results.highlights
- results.nonHighlights
- results.highlights.choose
- results.highlights.explanation
- forms.errors.invalidCustomUrl.numbers
- forms.errors.invalidCustomUrl.strangeCharacter
- forms.errors.invalidCustomUrl.duplicate
@@ -876,12 +901,17 @@
### 🟡 user.json
**19/20**
**19/25**
<details>
<summary>Missing</summary>
- results.title
- results.participants
- results.highlights
- results.nonHighlights
- results.highlights.choose
- results.highlights.explanation
</details>
@@ -988,7 +1018,7 @@
### 🟡 user.json
**7/20**
**7/25**
<details>
<summary>Missing</summary>
@@ -1001,7 +1031,12 @@
- motion
- stick
- sens
- results.title
- results.participants
- results.highlights
- results.nonHighlights
- results.highlights.choose
- results.highlights.explanation
- forms.errors.invalidCustomUrl.numbers
- forms.errors.invalidCustomUrl.strangeCharacter
- forms.errors.invalidCustomUrl.duplicate
@@ -1112,7 +1147,7 @@
### 🟡 user.json
**7/20**
**7/25**
<details>
<summary>Missing</summary>
@@ -1125,7 +1160,12 @@
- motion
- stick
- sens
- results.title
- results.participants
- results.highlights
- results.nonHighlights
- results.highlights.choose
- results.highlights.explanation
- forms.errors.invalidCustomUrl.numbers
- forms.errors.invalidCustomUrl.strangeCharacter
- forms.errors.invalidCustomUrl.duplicate