LFG post language select (#2660)

Co-authored-by: Kalle <38327916+Sendouc@users.noreply.github.com>
This commit is contained in:
Kim Tran 2025-12-14 10:39:43 +02:00 committed by GitHub
parent 04d6ef51c9
commit d7faf2c4e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 210 additions and 26 deletions

View File

@ -321,6 +321,7 @@ export interface LFGPost {
authorId: number;
teamId: number | null;
plusTierVisibility: number | null;
languages: string | null;
updatedAt: Generated<number>;
createdAt: GeneratedAlways<number>;
}

View File

@ -24,6 +24,7 @@ export async function posts(user?: { id: number; plusTier: number | null }) {
"LFGPost.createdAt",
"LFGPost.updatedAt",
"LFGPost.plusTierVisibility",
"LFGPost.languages",
jsonObjectFrom(
eb
.selectFrom("User")
@ -128,6 +129,7 @@ export function updatePost(
timezone: args.timezone,
type: args.type,
plusTierVisibility: args.plusTierVisibility,
languages: args.languages,
updatedAt: dateToDatabaseTimestamp(new Date()),
})
.where("id", "=", postId)

View File

@ -5,7 +5,8 @@ import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { LFG_PAGE } from "~/utils/urls";
import { falsyToNull, id } from "~/utils/zod";
import { falsyToNull, id, noDuplicates, safeJSONParse } from "~/utils/zod";
import { languagesUnified } from "../../../modules/i18n/config";
import * as LFGRepository from "../LFGRepository.server";
import { LFG, TEAM_POST_TYPES, TIMEZONES } from "../lfg-constants";
@ -39,6 +40,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
type: data.type,
teamId: shouldIncludeTeam ? team?.id : null,
plusTierVisibility: data.plusTierVisibility,
languages: data.languages.length > 0 ? data.languages.join(",") : null,
});
} else {
await LFGRepository.insertPost({
@ -48,6 +50,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
teamId: shouldIncludeTeam ? team?.id : null,
authorId: user.id,
plusTierVisibility: data.plusTierVisibility,
languages: data.languages.length > 0 ? data.languages.join(",") : null,
});
}
@ -63,6 +66,15 @@ const schema = z.object({
falsyToNull,
z.coerce.number().int().min(1).max(3).nullish(),
),
languages: z.preprocess(
safeJSONParse,
z
.array(z.string())
.refine(noDuplicates)
.refine((val) =>
val.every((lang) => languagesUnified.some((l) => l.code === lang)),
),
),
});
const validateCanUpdatePost = async ({

View File

@ -56,7 +56,7 @@ function Filter({
return (
<div>
<div className="stack horizontal justify-between">
<Label>
<Label htmlFor={`${filter._tag.toLowerCase()}-filter`}>
{t(`lfg:filters.${filter._tag}`)} {t("lfg:filters.suffix")}
</Label>
<SendouButton
@ -166,6 +166,7 @@ function TypeFilterFields({
return (
<div>
<select
id="type-filter"
className="w-max"
value={value}
onChange={(e) =>
@ -195,6 +196,7 @@ function TimezoneFilterFields({
return (
<div>
<input
id="timezone-filter"
type="number"
value={value}
min={0}
@ -220,6 +222,7 @@ function LanguageFilterFields({
return (
<div>
<select
id="language-filter"
className="w-max"
value={value}
onChange={(e) =>
@ -251,6 +254,7 @@ function PlusTierFilterFields({
return (
<div>
<select
id="plustier-filter"
value={value}
onChange={(e) =>
changeFilter({ _tag: "PlusTier", tier: Number(e.target.value) })
@ -277,6 +281,7 @@ function TierFilterFields({
return (
<div>
<select
id={`${_tag.toLowerCase()}-filter`}
value={value}
onChange={(e) =>
changeFilter({ _tag, tier: e.target.value as TierName })

View File

@ -56,7 +56,7 @@ function UserLFGPost({ post, tiersMap }: { post: Post; tiersMap: TiersMap }) {
/>
<PostTime createdAt={post.createdAt} updatedAt={post.updatedAt} />
<PostPills
languages={post.author.languages}
languages={post.languages}
plusTier={post.author.plusTier}
timezone={post.timezone}
tiers={
@ -104,7 +104,12 @@ function TeamLFGPost({
<div className="stack xs">
<div className="stack horizontal items-center justify-between">
<PostTeamLogoHeader team={post.team} />
{isMounted && <PostTimezonePill timezone={post.timezone} />}
<div className="stack horizontal items-center sm">
{isMounted && <PostTimezonePill timezone={post.timezone} />}
{post.languages && (
<PostLanguagePill languages={post.languages} />
)}
</div>
</div>
<Divider />
<div className="stack horizontal justify-between">
@ -181,7 +186,6 @@ function PostTeamMembersFull({
<div key={member.id} className="stack sm">
<PostUserHeader author={member} includeWeapons />
<PostPills
languages={member.languages}
plusTier={member.plusTier}
tiers={tiersMap.get(member.id)}
postId={postId}

View File

@ -63,9 +63,7 @@ function filterMatchesPost(
);
}
case "Language":
return checkMatchesSomeUserInPost(post, (user) =>
user.languages?.includes(filter.language),
);
return !!post.languages?.includes(filter.language);
case "PlusTier":
return checkMatchesSomeUserInPost(
post,

View File

@ -16,12 +16,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
);
const userQSettingsData = await QSettingsRepository.settingsByUserId(user.id);
const allPosts = await LFGRepository.posts(user);
const postToEdit = searchParamsToBuildToEdit(request, user.id, allPosts);
return {
team: userProfileData?.team,
weaponPool: userProfileData?.weapons,
languages: userQSettingsData.languages,
postToEdit: searchParamsToBuildToEdit(request, user.id, allPosts),
languages: postToEdit?.languages?.split(",") ?? userQSettingsData.languages,
postToEdit,
userPostTypes: userPostTypes(allPosts, user.id),
};
};

View File

@ -1,23 +1,20 @@
import { Link, useFetcher, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { LinkButton } from "~/components/elements/Button";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import { WeaponImage } from "~/components/Image";
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
import { CrossIcon } from "~/components/icons/Cross";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import type { Tables } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { languagesUnified } from "~/modules/i18n/config";
import { useHasRole } from "~/modules/permissions/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
LFG_PAGE,
navIconUrl,
SENDOUQ_SETTINGS_PAGE,
userEditProfilePage,
} from "~/utils/urls";
import { LFG_PAGE, navIconUrl, userEditProfilePage } from "~/utils/urls";
import { action } from "../actions/lfg.new.server";
import { LFG, TEAM_POST_TYPES, TIMEZONES } from "../lfg-constants";
import { loader } from "../loaders/lfg.new.server";
@ -225,19 +222,54 @@ function PlusVisibilitySelect() {
function Languages() {
const { t } = useTranslation(["lfg"]);
const data = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(data.languages ?? []);
return (
<div>
<Label>{t("lfg:new.languages.header")}</Label>
<div className="stack horizontal sm">
{data.languages?.join(" / ").toUpperCase()}
<input type="hidden" name="languages" value={JSON.stringify(value)} />
<Label htmlFor="postLanguage">{t("lfg:new.languages.header")}</Label>
<select
id="postLanguage"
className="w-max"
onChange={(e) => {
const newLanguages = [...value, e.target.value].sort((a, b) =>
a.localeCompare(b),
);
setValue(newLanguages);
}}
>
<option value="">{t("lfg:new.languages.placeholder")}</option>
{languagesUnified
.filter((lang) => !value.includes(lang.code))
.map((option) => {
return (
<option key={option.code} value={option.code}>
{option.name}
</option>
);
})}
</select>
<div className="mt-2">
{value.map((code) => {
const name = languagesUnified.find((l) => l.code === code)?.name;
return (
<div key={code} className="stack horizontal items-center sm">
<span>{name}</span>
<SendouButton
icon={<CrossIcon />}
variant="minimal-destructive"
onPress={() => {
const newLanguages = value.filter(
(codeInArr) => codeInArr !== code,
);
setValue(newLanguages);
}}
/>
</div>
);
})}
</div>
<FormMessage type="info">
{t("lfg:new.editOn")}{" "}
<Link to={SENDOUQ_SETTINGS_PAGE}>
{t("lfg:new.languages.sqSettingsPage")}
</Link>
</FormMessage>
</div>
);
}

View File

@ -22,4 +22,109 @@ test.describe("LFG", () => {
await expect(page.getByTestId("add-filter-button")).toBeVisible();
await expect(page.getByText("looking for a cool team")).toBeVisible();
});
test("creates post with custom languages", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({
page,
url: LFG_PAGE,
});
// create post with Japanese and Korean
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("menu-item-lfgPost").click();
await page.getByLabel("Text").fill("looking for Japanese/Korean team");
const languageSelect = page.getByLabel("Languages");
await languageSelect.selectOption("ja");
await languageSelect.selectOption("ko");
await submit(page);
// verify languages are displayed
await expect(page.getByText("JA / KO")).toBeVisible();
});
test("edits post languages", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({
page,
url: LFG_PAGE,
});
// create post with Dansk
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("menu-item-lfgPost").click();
await page.getByLabel("Text").fill("test post for language editing");
const languageSelect = page.getByLabel("Languages");
await languageSelect.selectOption("da");
await submit(page);
// wait for redirect to LFG page
await expect(page.getByTestId("add-filter-button")).toBeVisible();
await expect(
page.getByText("test post for language editing"),
).toBeVisible();
// remove Dansk and add Spanish
await page.getByRole("link", { name: "Edit" }).first().click();
await page.getByText("Dansk").locator("..").getByRole("button").click();
await languageSelect.selectOption("es");
await submit(page);
// wait for redirect to LFG page
await expect(page.getByTestId("add-filter-button")).toBeVisible();
await expect(
page.getByText("test post for language editing"),
).toBeVisible();
// verify updated language is displayed
await expect(page.getByText(/ES/)).toBeVisible();
await expect(page.getByText(/DA/)).not.toBeVisible();
});
test("filters posts by language", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({
page,
url: LFG_PAGE,
});
// create post with Japanese
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("menu-item-lfgPost").click();
await page.getByLabel("Text").fill("Japanese speaking team");
const languageSelect = page.getByLabel("Languages");
await languageSelect.selectOption("ja");
await submit(page);
// wait for redirect to LFG page
await expect(page.getByTestId("add-filter-button")).toBeVisible();
await expect(page.getByText("Japanese speaking team")).toBeVisible();
// filter by Japanese
await page.getByTestId("add-filter-button").click();
await page.getByText("Spoken language").click();
await page.getByLabel("Spoken language").selectOption("ja");
// verify Japanese post is visible
await expect(page.getByText("Japanese speaking team")).toBeVisible();
// change filter to Spanish
await page.getByLabel("Spoken language").selectOption("es");
// verify Japanese post is not visible
await expect(page.getByText("Japanese speaking team")).not.toBeVisible();
});
});

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "Våbenpulje",
"new.weaponPool.userProfile": "Brugerprofil",
"new.languages.header": "Sprog",
"new.languages.placeholder": "",
"new.languages.sqSettingsPage": "SendouQ opsætnings-side"
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "",
"new.weaponPool.userProfile": "",
"new.languages.header": "",
"new.languages.placeholder": "",
"new.languages.sqSettingsPage": ""
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "Weapon pool",
"new.weaponPool.userProfile": "user profile",
"new.languages.header": "Languages",
"new.languages.placeholder": "Select all that apply",
"new.languages.sqSettingsPage": "SendouQ settings page"
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "Grupo de armas",
"new.weaponPool.userProfile": "perfil de usuario",
"new.languages.header": "Idiomas",
"new.languages.placeholder": "Elegir los que correspondan",
"new.languages.sqSettingsPage": "página de ajustes de SendouQ"
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "Grupo de armas",
"new.weaponPool.userProfile": "perfil de usuario",
"new.languages.header": "Idiomas",
"new.languages.placeholder": "Elegir los que correspondan",
"new.languages.sqSettingsPage": "página de ajustes de SendouQ"
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "",
"new.weaponPool.userProfile": "",
"new.languages.header": "",
"new.languages.placeholder": "",
"new.languages.sqSettingsPage": ""
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "Arme utilisé",
"new.weaponPool.userProfile": "profil",
"new.languages.header": "Langues",
"new.languages.placeholder": "Selectionner",
"new.languages.sqSettingsPage": "page de paramètres SendouQ"
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "",
"new.weaponPool.userProfile": "",
"new.languages.header": "",
"new.languages.placeholder": "",
"new.languages.sqSettingsPage": ""
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "Pool di armi",
"new.weaponPool.userProfile": "profilo utente",
"new.languages.header": "Lingue",
"new.languages.placeholder": "Seleziona quelle che preferisci",
"new.languages.sqSettingsPage": "Pagina impostazioni SendouQ"
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "武器プール",
"new.weaponPool.userProfile": "ユーザープロファイル",
"new.languages.header": "言語",
"new.languages.placeholder": "該当するものを全て選んでください。",
"new.languages.sqSettingsPage": "SendouQ 設定"
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "",
"new.weaponPool.userProfile": "",
"new.languages.header": "",
"new.languages.placeholder": "",
"new.languages.sqSettingsPage": ""
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "",
"new.weaponPool.userProfile": "",
"new.languages.header": "",
"new.languages.placeholder": "",
"new.languages.sqSettingsPage": ""
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "",
"new.weaponPool.userProfile": "",
"new.languages.header": "",
"new.languages.placeholder": "",
"new.languages.sqSettingsPage": ""
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "Pool de armas",
"new.weaponPool.userProfile": "perfil de usuário",
"new.languages.header": "Línguas",
"new.languages.placeholder": "Escolha todos que se aplicam",
"new.languages.sqSettingsPage": "página de configurações do SendouQ"
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "Пул оружия",
"new.weaponPool.userProfile": "профиль пользователя",
"new.languages.header": "Языки",
"new.languages.placeholder": "Выбрать языки",
"new.languages.sqSettingsPage": "Страница настроек SendouQ"
}

View File

@ -28,5 +28,6 @@
"new.weaponPool.header": "武器池",
"new.weaponPool.userProfile": "用户主页",
"new.languages.header": "语言",
"new.languages.placeholder": "选择所有符合的选项",
"new.languages.sqSettingsPage": "SendouQ设定页面"
}

View File

@ -0,0 +1,8 @@
export function up(db) {
db.transaction(() => {
db.prepare(/* sql */ `alter table "LFGPost" add "languages" text`).run();
db.prepare(
/* sql */ `update "LFGPost" set "languages" = (select "languages" from "User" where "User"."id" = "LFGPost"."authorId")`,
).run();
})();
}