User preferences - ability sorting disabling

This commit is contained in:
Kalle 2025-02-08 11:22:54 +02:00
parent 986355050d
commit e43e8eefb9
15 changed files with 229 additions and 24 deletions

View File

@ -52,6 +52,7 @@ interface BuildProps {
| "private"
> & {
abilities: BuildAbilitiesTuple;
unsortedAbilities: BuildAbilitiesTuple;
modes: ModeShort[] | null;
weapons: Array<{
weaponSplId: BuildWeapon["weaponSplId"];
@ -61,9 +62,15 @@ interface BuildProps {
};
owner?: Pick<UserWithPlusTier, "discordId" | "username" | "plusTier">;
canEdit?: boolean;
withAbilitySorting?: boolean;
}
export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
export function BuildCard({
build,
owner,
canEdit = false,
withAbilitySorting = true,
}: BuildProps) {
const user = useUser();
const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]);
const { i18n } = useTranslation();
@ -77,11 +84,14 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
headGearSplId,
shoesGearSplId,
updatedAt,
abilities,
modes,
weapons,
} = build;
const abilities = withAbilitySorting
? build.abilities
: build.unsortedAbilities;
const isNoGear = [headGearSplId, clothesGearSplId, shoesGearSplId].some(
(id) => id === -1,
);

View File

@ -754,6 +754,10 @@ export const BUILD_SORT_IDENTIFIERS = [
export type BuildSort = (typeof BUILD_SORT_IDENTIFIERS)[number];
export interface UserPreferences {
disableBuildAbilitySorting?: boolean;
}
export interface User {
/** 1 = permabanned, timestamp = ban active till then */
banned: Generated<number | null>;
@ -798,6 +802,7 @@ export interface User {
plusSkippedForSeasonNth: number | null;
noScreen: Generated<number>;
buildSorting: ColumnType<BuildSort[] | null, string | null, string | null>;
preferences: ColumnType<UserPreferences | null, string | null, string | null>;
}
export interface UserResultHighlight {

View File

@ -175,15 +175,18 @@ function augmentBuild<T>({
const weapons = (
JSON.parse(rawWeapons) as Array<BuildWeaponWithTop500Info>
).sort((a, b) => a.weaponSplId - b.weaponSplId);
const abilities = JSON.parse(rawAbilities) as Array<
Pick<BuildAbility, "ability" | "gearType" | "slotIndex">
>;
const abilities = dbAbilitiesToArrayOfArrays(
JSON.parse(rawAbilities) as Array<
Pick<BuildAbility, "ability" | "gearType" | "slotIndex">
>,
);
return {
...row,
modes,
weapons,
abilities: sortAbilities(dbAbilitiesToArrayOfArrays(abilities)),
abilities: sortAbilities(abilities),
unsortedAbilities: abilities,
};
}

View File

@ -23,6 +23,7 @@ import {
BUILDS_PAGE_MAX_BUILDS,
PATCHES,
} from "~/constants";
import { useUser } from "~/features/auth/core/user";
import { safeJSONParse } from "~/utils/json";
import { isRevalidation, metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -163,6 +164,8 @@ const BuildCards = React.memo(function BuildCards({
}: {
data: SerializeFrom<typeof loader>;
}) {
const user = useUser();
return (
<div className="builds-container">
{data.builds.map((build) => {
@ -172,6 +175,7 @@ const BuildCards = React.memo(function BuildCards({
build={build}
owner={build}
canEdit={false}
withAbilitySorting={!user?.preferences.disableBuildAbilitySorting}
/>
);
})}

View File

@ -0,0 +1,31 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { settingsEditSchema } from "../settings-schemas";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUser(request);
const data = await parseRequestPayload({
request,
schema: settingsEditSchema,
});
switch (data._action) {
case "UPDATE_DISABLE_BUILD_ABILITY_SORTING": {
await UserRepository.updatePreferences(user.id, {
disableBuildAbilitySorting: data.newValue,
});
break;
}
case "PLACEHOLDER": {
break;
}
default: {
assertUnreachable(data);
}
}
return null;
};

View File

@ -1,13 +1,18 @@
import type { MetaFunction } from "@remix-run/node";
import { useNavigate, useSearchParams } from "@remix-run/react";
import { useFetcher, useNavigate, useSearchParams } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SendouSwitch } from "~/components/elements/Switch";
import { useUser } from "~/features/auth/core/user";
import { Theme, useTheme } from "~/features/theme/core/provider";
import { languages } from "~/modules/i18n/config";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { SETTINGS_PAGE, navIconUrl } from "~/utils/urls";
import { action } from "../actions/settings.server";
export { action };
export const handle: SendouRouteHandle = {
breadcrumb: () => ({
@ -18,6 +23,7 @@ export const handle: SendouRouteHandle = {
};
export default function SettingsPage() {
const user = useUser();
const { t } = useTranslation(["common"]);
return (
@ -26,6 +32,20 @@ export default function SettingsPage() {
<h2 className="text-lg">{t("common:pages.settings")}</h2>
<LanguageSelector />
<ThemeSelector />
<div className="mt-6 stack md">
<PreferenceSelectorSwitch
_action="UPDATE_DISABLE_BUILD_ABILITY_SORTING"
defaultSelected={
user?.preferences.disableBuildAbilitySorting ?? false
}
label={t(
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.label",
)}
bottomText={t(
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText",
)}
/>
</div>
</div>
</Main>
);
@ -103,3 +123,38 @@ function ThemeSelector() {
</div>
);
}
function PreferenceSelectorSwitch({
_action,
label,
bottomText,
defaultSelected,
}: {
_action: string;
label: string;
bottomText: string;
defaultSelected: boolean;
}) {
const fetcher = useFetcher();
const onChange = (isSelected: boolean) => {
fetcher.submit(
{ _action, newValue: isSelected },
{ method: "post", encType: "application/json" },
);
};
return (
<div>
<SendouSwitch
defaultSelected={defaultSelected}
onChange={onChange}
isDisabled={fetcher.state !== "idle"}
data-testid={`${_action}-switch`}
>
{label}
</SendouSwitch>
<FormMessage type="info">{bottomText}</FormMessage>
</div>
);
}

View File

@ -0,0 +1,12 @@
import { z } from "zod";
import { _action } from "~/utils/zod";
export const settingsEditSchema = z.union([
z.object({
_action: _action("UPDATE_DISABLE_BUILD_ABILITY_SORTING"),
newValue: z.boolean(),
}),
z.object({
_action: _action("PLACEHOLDER"),
}),
]);

View File

@ -2,7 +2,12 @@ import type { ExpressionBuilder, FunctionModule, NotNull } from "kysely";
import { sql } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db, sql as dbDirect } from "~/db/sql";
import type { BuildSort, DB, TablesInsertable } from "~/db/tables";
import type {
BuildSort,
DB,
TablesInsertable,
UserPreferences,
} from "~/db/tables";
import type { User } from "~/db/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
@ -291,6 +296,7 @@ export function findLeanById(id: number) {
"User.favoriteBadgeId",
"User.languages",
"User.inGameName",
"User.preferences",
"PlusTier.tier as plusTier",
eb
.selectFrom("UserFriendCode")
@ -659,6 +665,35 @@ export function updateProfile(args: UpdateProfileArgs) {
});
}
export function updatePreferences(
userId: number,
newPreferences: UserPreferences,
) {
return db.transaction().execute(async (trx) => {
const current =
(
await trx
.selectFrom("User")
.select("User.preferences")
.where("id", "=", userId)
.executeTakeFirstOrThrow()
).preferences ?? {};
const mergedPreferences = {
...current,
...newPreferences,
};
await trx
.updateTable("User")
.set({
preferences: JSON.stringify(mergedPreferences),
})
.where("id", "=", userId)
.execute();
});
}
type UpdateResultHighlightsArgs = {
userId: number;
resultTeamIds: Array<number>;

View File

@ -20,26 +20,19 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
showPrivate: loggedInUser?.id === user.id,
});
const skippedViaSearchParams =
new URL(request.url).searchParams.get("exact") === "true";
const skipAbilitySorting =
loggedInUser?.id === user.id || skippedViaSearchParams;
const buildsWithAbilitiesSorted = skipAbilitySorting
? builds
: builds.map((build) => ({
...build,
abilities: sortAbilities(build.abilities),
}));
if (buildsWithAbilitiesSorted.length === 0 && loggedInUser?.id !== user.id) {
if (builds.length === 0 && loggedInUser?.id !== user.id) {
throw new Response(null, { status: 404 });
}
const sortedBuilds = sortBuilds({
builds: buildsWithAbilitiesSorted,
builds,
buildSorting: user.buildSorting,
weaponPool: user.weapons,
});
}).map((build) => ({
...build,
abilities: sortAbilities(build.abilities),
unsortedAbilities: build.abilities,
}));
return privatelyCachedJson({
buildSorting: user.buildSorting,

View File

@ -98,7 +98,14 @@ export default function UserBuildsPage() {
{builds.length > 0 ? (
<div className="builds-container">
{builds.map((build) => (
<BuildCard key={build.id} build={build} canEdit={isOwnPage} />
<BuildCard
key={build.id}
build={build}
canEdit={isOwnPage}
withAbilitySorting={
!isOwnPage && !user?.preferences.disableBuildAbilitySorting
}
/>
))}
</div>
) : (

View File

@ -108,6 +108,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
isTournamentOrganizer: user.isTournamentOrganizer,
inGameName: user.inGameName,
friendCode: user.friendCode,
preferences: user.preferences ?? {},
languages: user.languages ? user.languages.split(",") : [],
}
: undefined,

Binary file not shown.

41
e2e/settings.spec.ts Normal file
View File

@ -0,0 +1,41 @@
import test, { expect } from "@playwright/test";
import { impersonate, navigate, seed } from "~/utils/playwright";
import { SETTINGS_PAGE } from "~/utils/urls";
test.describe("Settings", () => {
test("updates 'disableBuildAbilitySorting'", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({
page,
url: "/builds/luna-blaster",
});
const oldContents = await page
.getByTestId("build-card")
.first()
.innerHTML();
await navigate({
page,
url: SETTINGS_PAGE,
});
await page
.getByTestId("UPDATE_DISABLE_BUILD_ABILITY_SORTING-switch")
.click();
await navigate({
page,
url: "/builds/luna-blaster",
});
const newContents = await page
.getByTestId("build-card")
.first()
.innerHTML();
expect(newContents).not.toBe(oldContents);
});
});

View File

@ -242,5 +242,8 @@
"chat.systemMsg.userLeft": "{{name}} left the group",
"chat.newMessages": "New messages",
"fc.title": "Friend code"
"fc.title": "Friend code",
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.label": "Builds: Disable automatic ability sorting",
"settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText": "Outside of your profile page, build abilities are sorted so that same abilities are next to each other. This setting allows you to see the abilities in the order they were authored everywhere."
}

View File

@ -0,0 +1,5 @@
export function up(db) {
db.transaction(() => {
db.prepare(/* sql */ `alter table "User" add "preferences" text`).run();
})();
}