Scrims association filter

This commit is contained in:
Kalle
2026-09-20 16:43:34 +03:00
parent aa1d3f1b42
commit 871093bd17
26 changed files with 343 additions and 48 deletions

View File

@@ -181,3 +181,44 @@ describe("isVisible", () => {
expect(Association.isVisible(args)).toBe(true);
});
});
describe("mentionsAssociation", () => {
test.each([
{ why: "no visibility", visibility: null, expected: false },
{
why: "current association",
visibility: { forAssociation: 1 },
expected: true,
},
{
why: "other association",
visibility: { forAssociation: 2 },
expected: false,
},
{
why: "virtual association",
visibility: { forAssociation: "+1" as const },
expected: false,
},
{
why: "association later in the schedule",
visibility: {
forAssociation: 2,
notFoundInstructions: [{ at: 0, forAssociation: 1 }],
},
expected: true,
},
{
why: "schedule going public only",
visibility: {
forAssociation: 2,
notFoundInstructions: [{ at: 0, forAssociation: null }],
},
expected: false,
},
])("$why", ({ visibility, expected }) => {
expect(
Association.mentionsAssociation({ visibility, associationId: 1 }),
).toBe(expected);
});
});

View File

@@ -53,6 +53,24 @@ export function isVisible(args: IsVisibleArgs) {
);
}
/** Whether the association is in the visibility at any point of its schedule, not only right now. */
export function mentionsAssociation({
visibility,
associationId,
}: {
visibility: AssociationVisibility | null;
associationId: number;
}) {
if (!visibility) return false;
return (
visibility.forAssociation === associationId ||
(visibility.notFoundInstructions ?? []).some(
(instruction) => instruction.forAssociation === associationId,
)
);
}
export function isPublic(args: Omit<IsVisibleArgs, "associations">) {
return isVisible({
associations: null,

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { type MetaFunction, Outlet, useLoaderData } from "react-router";
import { ActionButton } from "~/components/ActionButton";
import { SendouButton } from "~/components/elements/Button";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
@@ -16,6 +16,7 @@ import {
loader,
} from "~/features/associations/loaders/associations.server";
import { useUser } from "~/features/auth/core/user";
import { scrimsByAssociationPage } from "~/features/scrims/scrims-urls";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { useHasPermission } from "~/modules/permissions/hooks";
import { metaTags } from "~/utils/remix";
@@ -133,35 +134,43 @@ function AssociationSection({
?.username,
})}
</div>
{!canManage || newAdmin ? (
<FormWithConfirm
dialogHeading={
newAdmin
? t("scrims:associations.leave.titleWithNewAdmin", {
name: association.name,
username: newAdmin.username,
})
: t("scrims:associations.leave.title", {
name: association.name,
})
}
fields={[
["_action", "LEAVE_ASSOCIATION"],
["associationId", association.id],
]}
submitButtonText={t("scrims:associations.leave.action")}
<div className="stack horizontal sm items-center my-2">
<LinkButton
to={scrimsByAssociationPage(association.id)}
variant="outlined"
size="small"
>
<SendouButton
variant="minimal-destructive"
type="submit"
size="small"
className="my-2"
data-testid="leave-team-button"
{t("scrims:associations.viewScrims")}
</LinkButton>
{!canManage || newAdmin ? (
<FormWithConfirm
dialogHeading={
newAdmin
? t("scrims:associations.leave.titleWithNewAdmin", {
name: association.name,
username: newAdmin.username,
})
: t("scrims:associations.leave.title", {
name: association.name,
})
}
fields={[
["_action", "LEAVE_ASSOCIATION"],
["associationId", association.id],
]}
submitButtonText={t("scrims:associations.leave.action")}
>
{t("scrims:associations.leave.action")}
</SendouButton>
</FormWithConfirm>
) : null}
<SendouButton
variant="minimal-destructive"
type="submit"
size="small"
data-testid="leave-team-button"
>
{t("scrims:associations.leave.action")}
</SendouButton>
</FormWithConfirm>
) : null}
</div>
<div className="stack sm mt-4">
{association.members?.map((member) => (
<AssociationMember

View File

@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, test } from "vitest";
import * as AssociationFactory from "~/db/seed/factories/AssociationFactory";
import * as ScrimPostFactory from "~/db/seed/factories/ScrimPostFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import type { SerializeFrom } from "~/utils/remix";
import { wrappedLoader } from "~/utils/Test";
import { scrimsPage } from "~/utils/urls";
import { scrimsSearchParams } from "../scrims-search-params";
import { loader } from "./scrims.server";
const users = UserFactory.pool();
const viewerId = () => users.id(1);
const otherUserId = () => users.id(2);
const requesterId = () => users.id(3);
const scrimsLoader = wrappedLoader<SerializeFrom<typeof loader>>({ loader });
describe("scrims loader", () => {
beforeEach(async () => {
await users.create(3);
});
const createAssociation = () =>
AssociationFactory.create(
{ userId: otherUserId() },
{ memberUserIds: [viewerId()] },
);
const loadFilteredByAssociation = (associationId: number) =>
scrimsLoader({
user: viewerId(),
url: scrimsSearchParams.href(scrimsPage(), { associationId }),
});
test("only lists posts of the association filtered by", async () => {
const association = await createAssociation();
const { id: associationPostId } = await ScrimPostFactory.create({
users: [{ userId: otherUserId(), isOwner: 1 }],
visibility: { forAssociation: association.id },
});
await ScrimPostFactory.create({
users: [{ userId: otherUserId(), isOwner: 1 }],
});
const data = await loadFilteredByAssociation(association.id);
expect(data.posts.neutral.map((post) => post.id)).toEqual([
associationPostId,
]);
});
test("keeps the viewer's own public post listed while filtering by association", async () => {
const association = await createAssociation();
const { id: ownPostId } = await ScrimPostFactory.create({
users: [{ userId: viewerId(), isOwner: 1 }],
});
const data = await loadFilteredByAssociation(association.id);
expect(data.posts.owned.map((post) => post.id)).toEqual([ownPostId]);
});
test("keeps a booked scrim listed while filtering by association", async () => {
const association = await createAssociation();
const { id: bookedPostId } = await ScrimPostFactory.create(
{
users: [{ userId: viewerId(), isOwner: 1 }],
},
{
requests: [
{
users: [{ userId: requesterId(), isOwner: 1 }],
isAccepted: true,
},
],
},
);
const data = await loadFilteredByAssociation(association.id);
expect(data.posts.booked.map((post) => post.id)).toEqual([bookedPostId]);
});
test("ignores a filter for an association the viewer is not in", async () => {
const otherAssociation = await AssociationFactory.create({
userId: otherUserId(),
});
const { id: postId } = await ScrimPostFactory.create({
users: [{ userId: otherUserId(), isOwner: 1 }],
});
const data = await loadFilteredByAssociation(otherAssociation.id);
expect(data.associationFilter).toBeNull();
expect(data.posts.neutral.map((post) => post.id)).toEqual([postId]);
});
});

View File

@@ -20,7 +20,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
? await AssociationsRepository.findByMemberUserId(user?.id)
: null;
const { weekdayTimes, weekendTimes, divs, useDefaults } =
const { weekdayTimes, weekendTimes, divs, useDefaults, associationId } =
scrimsSearchParams.parse(request);
const filtersFromSearchParams = { weekdayTimes, weekendTimes, divs };
@@ -31,26 +31,46 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
? (user?.preferences?.defaultScrimsFilters ?? Scrim.defaultFilters())
: filtersFromSearchParams;
const posts = (await ScrimPostRepository.findAllRelevant())
.filter(
(post) =>
(user && Scrim.isParticipating(post, user.id)) ||
Association.isVisible({
associations,
visibility: post.visibility,
contentOwnerUserId: post.users.find((u) => u.isOwner)?.id,
}),
)
.map((post) => ({
...post,
visibility: null,
isPrivate: !Association.isPublic({
// a filter for an association the viewer is not in is ignored rather than showing an empty page
const associationFilter =
associations?.actual.find(
(association) => association.id === associationId,
) ?? null;
const visiblePosts = (await ScrimPostRepository.findAllRelevant()).filter(
(post) =>
(user && Scrim.isParticipating(post, user.id)) ||
Association.isVisible({
associations,
visibility: post.visibility,
contentOwnerUserId: post.users.find((u) => u.isOwner)?.id,
}),
}));
);
const divided = dividePosts(visiblePosts, user?.id);
// the association filter narrows browsing only, the viewer's own and booked posts stay listed
const dividedPosts = {
neutral: divided.neutral
.filter(
(post) =>
!associationFilter ||
Association.mentionsAssociation({
visibility: post.visibility,
associationId: associationFilter.id,
}),
)
.map(censorVisibility),
owned: divided.owned.map(censorVisibility),
booked: divided.booked.map(censorVisibility),
};
const cardUserIds = R.unique(
posts.flatMap((post) => [
[
...dividedPosts.neutral,
...dividedPosts.owned,
...dividedPosts.booked,
].flatMap((post) => [
...post.users.map((postUser) => postUser.id),
...post.requests.flatMap((postRequest) =>
postRequest.users.map((requestUser) => requestUser.id),
@@ -58,7 +78,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
]),
);
const dividedPosts = dividePosts(posts, user?.id);
const teams = user ? await TeamRepository.findAllByMemberUserId(user.id) : [];
return {
@@ -73,6 +92,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
viewerId: user?.id ?? null,
}),
filters,
associationFilter: associationFilter
? { id: associationFilter.id, name: associationFilter.name }
: null,
associationOptions:
associations?.actual.map((association) => ({
id: association.id,
name: association.name,
})) ?? [],
canSaveAsDefault:
user != null &&
!R.isDeepEqual(
@@ -82,6 +109,17 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
};
};
/** Replaces the raw visibility with a flag, so that association details do not reach the client. */
function censorVisibility(post: ScrimPost): ScrimPost {
return {
...post,
visibility: null,
isPrivate: !Association.isPublic({
visibility: post.visibility,
}),
};
}
/** How the viewer's teams relate to the requestable posts, one entry per post: what the fit indicators on cards and in the request dialog resolve from. */
async function rosterAvailability({
posts,

View File

@@ -8,6 +8,7 @@ import * as R from "remeda";
import * as v from "valibot";
import { EmptyState } from "~/components/EmptyState";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import { FilterBar } from "~/components/filter-bar/FilterBar";
import { LocaleTime } from "~/components/LocaleTime";
import { associationsPage } from "~/features/associations/associations-urls";
@@ -197,6 +198,35 @@ function Filters() {
setParams({ ...filters, ...partial, useDefaults: false });
};
const associationPill =
data.associationOptions.length > 0
? [
{
key: "association",
name: t("scrims:filters.association"),
formattedValue: data.associationFilter?.name ?? null,
onRemove: () => setParams({ associationId: null }),
testId: "association-filter",
popover: (
<SendouSelect
aria-label={t("scrims:filters.association")}
items={data.associationOptions}
selectedKey={data.associationFilter?.id ?? null}
onSelectionChange={(key) =>
setParams({ associationId: key as number | null })
}
>
{({ id, name }) => (
<SendouSelectItem key={id} id={id}>
{name}
</SendouSelectItem>
)}
</SendouSelect>
),
},
]
: [];
return (
<FilterBar
pills={[
@@ -251,14 +281,17 @@ function Filters() {
/>
),
},
...associationPill,
]}
onReset={
!Scrim.filtersAreDefault(filters)
!Scrim.filtersAreDefault(filters) || data.associationFilter
? () =>
writeFilters({
setParams({
weekdayTimes: null,
weekendTimes: null,
divs: null,
associationId: null,
useDefaults: false,
})
: undefined
}

View File

@@ -25,6 +25,7 @@ describe("scrimsSearchParams", () => {
],
pendingRequestPostId: [null, 1, 987654],
useDefaults: [true, false],
associationId: [null, 1, 42],
});
});
@@ -49,5 +50,11 @@ describe("scrimsSearchParams", () => {
["-2"],
["1.5"],
]);
assertDecodesToDefault(scrimsSearchParams, "associationId", [
["abc"],
["0"],
["-2"],
["1.5"],
]);
});
});

View File

@@ -9,6 +9,11 @@ export const scrimsSearchParams = SearchParams.define({
divs: SP.custom(divsCodec, { loader: true }),
/** False once the user has edited the filters, making the URL win over their saved defaults. */
useDefaults: SP.param(v.boolean(), { default: true, loader: true }),
/** Only posts whose visibility ever mentioned this association, not part of the saved default filters. */
associationId: SP.param(
v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))),
{ loader: true },
),
pendingRequestPostId: SP.param(
v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))),
{

View File

@@ -0,0 +1,5 @@
import { scrimsPage } from "~/utils/urls";
import { scrimsSearchParams } from "./scrims-search-params";
export const scrimsByAssociationPage = (associationId: number) =>
scrimsSearchParams.href(scrimsPage(), { associationId });

View File

@@ -0,0 +1,5 @@
---
navItem: scrims
type: feature
---
Scrims can be filtered by association, with a "View scrims" shortcut on the associations page

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "",
"filters.weekendTimes": "",
"filters.divs": "",
"filters.association": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
@@ -51,6 +52,7 @@
"associations.join.title": "",
"associations.delete.title": "",
"associations.admin": "",
"associations.viewScrims": "",
"associations.leave.title": "",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "",
"filters.weekendTimes": "",
"filters.divs": "",
"filters.association": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
@@ -51,6 +52,7 @@
"associations.join.title": "Assoziation {{name}} beitreten?",
"associations.delete.title": "Assoziation {{name}} löschen?",
"associations.admin": "Admin: {{username}}",
"associations.viewScrims": "",
"associations.leave.title": "Assoziation {{name}} verlassen?",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "Verlassen",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "Weekday times",
"filters.weekendTimes": "Weekend times",
"filters.divs": "Divs",
"filters.association": "Association",
"filters.showFiltered": "Show filtered ({{count}})",
"filters.hideFiltered": "Hide filtered ({{count}})",
"filters.showPendingRequests": "Show pending requests ({{count}})",
@@ -51,6 +52,7 @@
"associations.join.title": "Join {{name}} association?",
"associations.delete.title": "Delete {{name}} association?",
"associations.admin": "Admin: {{username}}",
"associations.viewScrims": "View scrims",
"associations.leave.title": "Leave {{name}} association?",
"associations.leave.titleWithNewAdmin": "Leave {{name}} association? {{username}} will become the new admin",
"associations.leave.action": "Leave",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "Horarios entre semana",
"filters.weekendTimes": "Horarios de fin de semana",
"filters.divs": "Divs",
"filters.association": "",
"filters.showFiltered": "Mostrar filtrados ({{count}})",
"filters.hideFiltered": "Ocultar filtrados ({{count}})",
"filters.showPendingRequests": "Mostrar peticiones pendientes ({{count}})",
@@ -51,6 +52,7 @@
"associations.join.title": "¿Unirse a la asociación {{name}}?",
"associations.delete.title": "¿Eliminar la asociación {{name}}?",
"associations.admin": "Administrador: {{username}}",
"associations.viewScrims": "",
"associations.leave.title": "¿Salir de la asociación {{name}}?",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "Salir",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "Horarios entre semana",
"filters.weekendTimes": "Horarios de fin de semana",
"filters.divs": "Divs",
"filters.association": "",
"filters.showFiltered": "Mostrar filtrados ({{count}})",
"filters.hideFiltered": "Ocultar filtrados ({{count}})",
"filters.showPendingRequests": "Mostrar peticiones pendientes ({{count}})",
@@ -51,6 +52,7 @@
"associations.join.title": "¿Unirse a la asociación {{name}}?",
"associations.delete.title": "¿Eliminar la asociación {{name}}?",
"associations.admin": "Administrador: {{username}}",
"associations.viewScrims": "",
"associations.leave.title": "¿Salir de la asociación {{name}}?",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "Salir",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "",
"filters.weekendTimes": "",
"filters.divs": "",
"filters.association": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
@@ -51,6 +52,7 @@
"associations.join.title": "",
"associations.delete.title": "",
"associations.admin": "",
"associations.viewScrims": "",
"associations.leave.title": "",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "",
"filters.weekendTimes": "",
"filters.divs": "",
"filters.association": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
@@ -51,6 +52,7 @@
"associations.join.title": "Rejoindre l'association {{name}} ?",
"associations.delete.title": "Supprimer l'association {{name}} ?",
"associations.admin": "Admin: {{username}}",
"associations.viewScrims": "",
"associations.leave.title": "Quitter l'association {{name}} ?",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "Quitter",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "",
"filters.weekendTimes": "",
"filters.divs": "",
"filters.association": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
@@ -51,6 +52,7 @@
"associations.join.title": "",
"associations.delete.title": "",
"associations.admin": "",
"associations.viewScrims": "",
"associations.leave.title": "",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "",
"filters.weekendTimes": "",
"filters.divs": "",
"filters.association": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
@@ -51,6 +52,7 @@
"associations.join.title": "",
"associations.delete.title": "",
"associations.admin": "",
"associations.viewScrims": "",
"associations.leave.title": "",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "",
"filters.weekendTimes": "",
"filters.divs": "",
"filters.association": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
@@ -51,6 +52,7 @@
"associations.join.title": "",
"associations.delete.title": "",
"associations.admin": "",
"associations.viewScrims": "",
"associations.leave.title": "",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "",
"filters.weekendTimes": "",
"filters.divs": "",
"filters.association": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
@@ -51,6 +52,7 @@
"associations.join.title": "",
"associations.delete.title": "",
"associations.admin": "",
"associations.viewScrims": "",
"associations.leave.title": "",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "",
"filters.weekendTimes": "",
"filters.divs": "",
"filters.association": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
@@ -51,6 +52,7 @@
"associations.join.title": "",
"associations.delete.title": "",
"associations.admin": "",
"associations.viewScrims": "",
"associations.leave.title": "",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "",
"filters.weekendTimes": "",
"filters.divs": "",
"filters.association": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
@@ -51,6 +52,7 @@
"associations.join.title": "",
"associations.delete.title": "",
"associations.admin": "",
"associations.viewScrims": "",
"associations.leave.title": "",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "",
"filters.weekendTimes": "",
"filters.divs": "",
"filters.association": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
@@ -51,6 +52,7 @@
"associations.join.title": "",
"associations.delete.title": "",
"associations.admin": "",
"associations.viewScrims": "",
"associations.leave.title": "",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "",
"filters.weekendTimes": "",
"filters.divs": "",
"filters.association": "",
"filters.showFiltered": "",
"filters.hideFiltered": "",
"filters.showPendingRequests": "",
@@ -51,6 +52,7 @@
"associations.join.title": "",
"associations.delete.title": "",
"associations.admin": "",
"associations.viewScrims": "",
"associations.leave.title": "",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "",

View File

@@ -14,6 +14,7 @@
"filters.weekdayTimes": "工作日时间",
"filters.weekendTimes": "周末时间",
"filters.divs": "",
"filters.association": "",
"filters.showFiltered": "显示已过滤内容 ({{count}})",
"filters.hideFiltered": "隐藏已过滤内容 ({{count}})",
"filters.showPendingRequests": "显示待处理请求 ({{count}})",
@@ -51,6 +52,7 @@
"associations.join.title": "加入群组 {{name}} ?",
"associations.delete.title": "删除群组 {{name}} ?",
"associations.admin": "管理员: {{username}}",
"associations.viewScrims": "",
"associations.leave.title": "退出群组 {{name}} ?",
"associations.leave.titleWithNewAdmin": "",
"associations.leave.action": "退出",