Tournament org page improvements (#2569)

This commit is contained in:
Kalle
2025-10-11 16:07:35 +03:00
committed by GitHub
parent 98a5ccbe3a
commit 03ad348fdf
27 changed files with 305 additions and 91 deletions

View File

@@ -752,6 +752,7 @@ export interface TournamentOrganizationBannedUser {
userId: number;
privateNote: string | null;
updatedAt: Generated<number>;
expiresAt: number | null;
}
/** Indicates a user trusts another. Allows direct adding to groups/teams without invite links. */

View File

@@ -310,6 +310,7 @@ describe("MapList.generate()", () => {
}
});
// TODO: fix flaky
it("replenishes the stage id pool when exhausted", { retry: 10 }, () => {
const gen = initGenerator(
new MapPool({
@@ -329,30 +330,35 @@ describe("MapList.generate()", () => {
}
});
it("replenishes the stage id pool with different order", () => {
const gen = initGenerator(
new MapPool({
TW: [],
SZ: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
TC: [],
RM: [],
CB: [],
}),
);
const first = gen.next({ amount: 5 }).value.map((m) => m.stageId);
gen.next({ amount: 5 });
const third = gen.next({ amount: 5 }).value.map((m) => m.stageId);
// TODO: fix flaky
it(
"replenishes the stage id pool with different order",
{ retry: 10 },
() => {
const gen = initGenerator(
new MapPool({
TW: [],
SZ: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
TC: [],
RM: [],
CB: [],
}),
);
const first = gen.next({ amount: 5 }).value.map((m) => m.stageId);
gen.next({ amount: 5 });
const third = gen.next({ amount: 5 }).value.map((m) => m.stageId);
let someDifferent = false;
for (let i = 0; i < 5; i++) {
if (first[i] !== third[i]) {
someDifferent = true;
break;
let someDifferent = false;
for (let i = 0; i < 5; i++) {
if (first[i] !== third[i]) {
someDifferent = true;
break;
}
}
}
expect(someDifferent).toBe(true);
});
expect(someDifferent).toBe(true);
},
);
it("replenishes accordingly if considerGuaranteed is true (Bo3)", () => {
for (let i = 0; i < 10; i++) {

View File

@@ -1,8 +1,13 @@
import { isFuture } from "date-fns";
import { sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { Tables, TablesInsertable } from "~/db/tables";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import {
databaseTimestampNow,
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { mySlugify } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
@@ -64,7 +69,12 @@ export async function findBySlug(slug: string) {
"TournamentOrganizationMember.organizationId",
"=",
"TournamentOrganization.id",
),
)
.orderBy(
sql`coalesce(TournamentOrganizationMember.roleDisplayName, TournamentOrganizationMember.role)`,
"asc",
)
.orderBy("User.username", "asc"),
).as("members"),
jsonArrayFrom(
eb
@@ -472,6 +482,7 @@ export function allBannedUsersByOrganizationId(organizationId: number) {
.select([
"TournamentOrganizationBannedUser.privateNote",
"TournamentOrganizationBannedUser.updatedAt",
"TournamentOrganizationBannedUser.expiresAt",
...COMMON_USER_FIELDS,
])
.where(
@@ -495,10 +506,14 @@ export async function isUserBannedByOrganization({
}) {
const result = await db
.selectFrom("TournamentOrganizationBannedUser")
.select("userId")
.select(["userId", "expiresAt"])
.where("organizationId", "=", organizationId)
.where("userId", "=", userId)
.executeTakeFirst();
return Boolean(result);
if (!result) return false;
if (!result.expiresAt) return true;
return isFuture(databaseTimestampToDate(result.expiresAt));
}

View File

@@ -1,6 +1,12 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { isFuture } from "date-fns";
import { requireUser } from "~/features/auth/core/user.server";
import { requirePermission } from "~/modules/permissions/guards.server";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
dayMonthYearToDate,
} from "~/utils/dates";
import { logger } from "~/utils/logger";
import { errorToast, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
@@ -21,12 +27,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
switch (data._action) {
case "BAN_USER": {
const bannedUsers =
const allBannedUsers =
await TournamentOrganizationRepository.allBannedUsersByOrganizationId(
organization.id,
);
const currentlyBannedUsers = allBannedUsers.filter(
(bu) =>
!bu.expiresAt || isFuture(databaseTimestampToDate(bu.expiresAt)),
);
if (bannedUsers.length >= TOURNAMENT_ORGANIZATION.MAX_BANNED_USERS) {
if (
currentlyBannedUsers.length >= TOURNAMENT_ORGANIZATION.MAX_BANNED_USERS
) {
errorToast(
`Organization cannot ban more than ${TOURNAMENT_ORGANIZATION.MAX_BANNED_USERS} users`,
);
@@ -36,6 +48,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
organizationId: organization.id,
userId: data.userId,
privateNote: data.privateNote,
expiresAt: data.expiresAt
? dateToDatabaseTimestamp(dayMonthYearToDate(data.expiresAt))
: null,
});
logger.info(

View File

@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import type { z } from "zod/v4";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { DateFormField } from "~/components/form/DateFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { UserSearchFormField } from "~/components/form/UserSearchFormField";
@@ -29,6 +30,7 @@ export function BanUserModal() {
_action: "BAN_USER",
userId: undefined,
privateNote: null,
expiresAt: null,
}}
>
<UserSearchFormField<FormFields>
@@ -42,6 +44,12 @@ export function BanUserModal() {
maxLength={TOURNAMENT_ORGANIZATION.BAN_REASON_MAX_LENGTH}
bottomText={t("org:banned.banModal.noteHelp")}
/>
<DateFormField<FormFields>
label={t("org:banned.banModal.expiresAt")}
name="expiresAt"
bottomText={t("org:banned.banModal.expiresAtHelp")}
/>
</SendouForm>
</SendouDialog>
);

View File

@@ -9,12 +9,35 @@
.reasonCell {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.noteContainer {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.expandButton {
background: none;
border: none;
color: var(--theme);
cursor: pointer;
font-size: var(--fonts-xxs);
padding: 0;
text-align: left;
text-decoration: underline;
}
.expandButton:hover {
opacity: 0.8;
}
.banPlayerButton {
display: flex;
justify-content: flex-end;
}
.expiredBan {
text-decoration: line-through;
color: var(--text-lighter);
}

View File

@@ -1,5 +1,7 @@
import { Link } from "@remix-run/react";
import clsx from "clsx";
import { isFuture } from "date-fns";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
@@ -11,6 +13,8 @@ import { databaseTimestampToDate } from "~/utils/dates";
import { userPage } from "~/utils/urls";
import styles from "../components/BannedPlayersList.module.css";
const MAX_NOTE_LENGTH = 30;
export function BannedUsersList({
bannedUsers,
}: {
@@ -43,54 +47,75 @@ export function BannedUsersList({
<th>{t("org:banned.player")}</th>
<th>{t("org:banned.note")}</th>
<th>{t("org:banned.date")}</th>
<th>{t("org:banned.expires")}</th>
<th>{t("org:banned.actions")}</th>
</tr>
</thead>
<tbody>
{bannedUsers.map((bannedUser) => (
<tr key={bannedUser.id}>
<td>
<Link
to={userPage(bannedUser)}
className="stack horizontal xs items-center w-max"
{bannedUsers.map((bannedUser) => {
const isExpired =
bannedUser.expiresAt &&
isFuture(databaseTimestampToDate(bannedUser.expiresAt));
return (
<tr key={bannedUser.id}>
<td>
<Link
to={userPage(bannedUser)}
className="stack horizontal xs items-center w-max"
>
<Avatar user={bannedUser} size="xs" />
<span
className={clsx({ [styles.expiredBan]: isExpired })}
>
{bannedUser.username}
</span>
</Link>
</td>
<td
className={clsx("text-sm text-lighter", styles.reasonCell)}
>
<Avatar user={bannedUser} size="xs" />
{bannedUser.username}
</Link>
</td>
<td
className={clsx("text-sm text-lighter", styles.reasonCell)}
title={bannedUser.privateNote ?? undefined}
>
{bannedUser.privateNote ?? "-"}
</td>
<td className="text-sm text-lighter whitespace-nowrap">
{databaseTimestampToDate(
bannedUser.updatedAt,
).toLocaleDateString(i18n.language, {
day: "numeric",
month: "short",
year: "numeric",
})}
</td>
<td className={styles.actionsCell}>
<FormWithConfirm
fields={[
["_action", "UNBAN_USER"],
["userId", bannedUser.id],
]}
dialogHeading={t("org:banned.unbanConfirm", {
username: bannedUser.username,
<BanNote note={bannedUser.privateNote} />
</td>
<td className="text-sm text-lighter whitespace-nowrap">
{databaseTimestampToDate(
bannedUser.updatedAt,
).toLocaleDateString(i18n.language, {
day: "numeric",
month: "short",
year: "numeric",
})}
submitButtonText={t("org:banned.unban")}
>
<SendouButton variant="minimal-destructive" size="small">
{t("org:banned.unban")}
</SendouButton>
</FormWithConfirm>
</td>
</tr>
))}
</td>
<td className="text-sm text-lighter whitespace-nowrap">
{bannedUser.expiresAt
? databaseTimestampToDate(
bannedUser.expiresAt,
).toLocaleDateString(i18n.language, {
day: "numeric",
month: "short",
year: "numeric",
})
: t("org:banned.permanent")}
</td>
<td className={styles.actionsCell}>
<FormWithConfirm
fields={[
["_action", "UNBAN_USER"],
["userId", bannedUser.id],
]}
dialogHeading={t("org:banned.unbanConfirm", {
username: bannedUser.username,
})}
submitButtonText={t("org:banned.unban")}
>
<SendouButton variant="minimal-destructive" size="small">
{t("org:banned.unban")}
</SendouButton>
</FormWithConfirm>
</td>
</tr>
);
})}
</tbody>
</Table>
</div>
@@ -100,3 +125,35 @@ export function BannedUsersList({
</div>
);
}
function BanNote({ note }: { note: string | null }) {
const { t } = useTranslation(["common"]);
const [isExpanded, setIsExpanded] = React.useState(false);
if (!note) {
return "-";
}
const shouldTruncate = note.length > MAX_NOTE_LENGTH;
const displayText =
shouldTruncate && !isExpanded
? `${note.slice(0, MAX_NOTE_LENGTH)}...`
: note;
return (
<div className={styles.noteContainer}>
<span>{displayText}</span>
{shouldTruncate ? (
<button
type="button"
className={styles.expandButton}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded
? t("common:actions.showLess")
: t("common:actions.showMore")}
</button>
) : null}
</div>
);
}

View File

@@ -1,9 +1,12 @@
import { isFuture } from "date-fns";
import { z } from "zod/v4";
import { TOURNAMENT_ORGANIZATION_ROLES } from "~/db/tables";
import { TOURNAMENT_ORGANIZATION } from "~/features/tournament-organization/tournament-organization-constants";
import { dayMonthYearToDate } from "~/utils/dates";
import { mySlugify } from "~/utils/urls";
import {
_action,
dayMonthYear,
falsyToNull,
id,
safeNullableStringSchema,
@@ -93,6 +96,15 @@ export const banUserActionSchema = z.object({
privateNote: safeNullableStringSchema({
max: TOURNAMENT_ORGANIZATION.BAN_REASON_MAX_LENGTH,
}),
expiresAt: dayMonthYear.nullish().refine(
(data) => {
if (!data) return true;
return isFuture(dayMonthYearToDate(data));
},
{
message: "Date must be in the future",
},
),
});
export const unbanUserActionSchema = z.object({

View File

@@ -7,7 +7,8 @@
"!scripts/output/**/*",
"!app/db/seed/placements.json",
"!build/**/*",
"!test-results/**/*"
"!test-results/**/*",
"!playwright-report/**/*"
]
},
"linter": {

View File

@@ -35,8 +35,11 @@ test.describe("Tournament Organization", () => {
await impersonate(page, ADMIN_ID);
await navigate({ page, url });
await editButtonLocator.click();
// Add member as admin
await page.getByLabel("Role").first().selectOption("ADMIN");
// Add member as admin - find the N-ZAP user's fieldset and change their role
const nzapFieldset = page.locator("fieldset").filter({ hasText: "N-ZAP" });
await nzapFieldset
.getByLabel("Role", { exact: true })
.selectOption("ADMIN");
await submit(page);
// 3. As the promoted user, verify edit controls are visible and page can be accessed

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "Player",
"banned.note": "Note",
"banned.date": "Banned on",
"banned.expires": "Expires",
"banned.permanent": "Permanent",
"banned.actions": "Actions",
"banned.unban": "Unban",
"banned.unbanConfirm": "Are you sure you want to unban {{username}}?",
@@ -35,5 +37,7 @@
"banned.banModal.title": "Banning a player",
"banned.banModal.player": "Player",
"banned.banModal.note": "Private note",
"banned.banModal.noteHelp": "This note is only visible to organization admins."
"banned.banModal.noteHelp": "This note is only visible to organization admins.",
"banned.banModal.expiresAt": "Ban expiration date",
"banned.banModal.expiresAtHelp": "Leave empty for a permanent ban"
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -28,6 +28,8 @@
"banned.player": "",
"banned.note": "",
"banned.date": "",
"banned.expires": "",
"banned.permanent": "",
"banned.actions": "",
"banned.unban": "",
"banned.unbanConfirm": "",
@@ -35,5 +37,7 @@
"banned.banModal.title": "",
"banned.banModal.player": "",
"banned.banModal.note": "",
"banned.banModal.noteHelp": ""
"banned.banModal.noteHelp": "",
"banned.banModal.expiresAt": "",
"banned.banModal.expiresAtHelp": ""
}

View File

@@ -0,0 +1,9 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `
alter table "TournamentOrganizationBannedUser" add column "expiresAt" integer
`,
).run();
})();
}