mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-11 21:56:11 -05:00
Add customizability to weapon widget
This commit is contained in:
@@ -269,7 +269,7 @@ export function nzapWidgets(): StoredWidget[] {
|
||||
settings: { controller: "s2-pro-con", motionSens: 50, stickSens: 5 },
|
||||
},
|
||||
{ id: "social-links" },
|
||||
{ id: "weapon-pool" },
|
||||
{ id: "weapon-pool", settings: { weaponPool: [] } },
|
||||
{ id: "map-mode-preferences" },
|
||||
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
|
||||
{ id: "trophies-owned" },
|
||||
|
||||
@@ -54,7 +54,9 @@ describe("PlusVotingRepository.findAllUsersForVoting", () => {
|
||||
});
|
||||
|
||||
test("returns no bio for a user without a bio widget", async () => {
|
||||
const bio = await bioOf([{ id: "weapon-pool" }]);
|
||||
const bio = await bioOf([
|
||||
{ id: "weapon-pool", settings: { weaponPool: [] } },
|
||||
]);
|
||||
|
||||
expect(bio).toBeNull();
|
||||
});
|
||||
|
||||
@@ -82,6 +82,17 @@ export async function isPlayerLinkedByUserId(userId: number): Promise<boolean> {
|
||||
return Boolean(player);
|
||||
}
|
||||
|
||||
/** Weapons the user has a ten-star badge for, from their linked X Rank placements. */
|
||||
export async function findTenStarWeaponSplIdsByUserId(userId: number) {
|
||||
const rows = await db
|
||||
.selectFrom("TenStarWeapon")
|
||||
.select("TenStarWeapon.weaponSplId")
|
||||
.where("TenStarWeapon.userId", "=", userId)
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => row.weaponSplId);
|
||||
}
|
||||
|
||||
/** From the linked player's denormalized `SplatoonPlayer.peakXp` (see {@link refreshAllPeakXp}); `null` without one. */
|
||||
export async function findPeakVerifiedXpByUserId(
|
||||
userId: Tables["User"]["id"],
|
||||
|
||||
@@ -519,7 +519,7 @@ describe("UserRepository", () => {
|
||||
describe("UserRepository.findStoredWidgetsByUserId", () => {
|
||||
const sixMainWidgets: Parameters<typeof UserFactory.create>[1] = {
|
||||
widgets: [
|
||||
{ id: "weapon-pool" },
|
||||
{ id: "weapon-pool", settings: { weaponPool: [] } },
|
||||
{ id: "trophies-owned" },
|
||||
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
|
||||
{ id: "badges-authored" },
|
||||
|
||||
@@ -85,6 +85,8 @@ function WidgetFormFields({ widgetId }: { widgetId: string }) {
|
||||
);
|
||||
case "peak-xp-weapon":
|
||||
return <FormField name="weaponSplId" />;
|
||||
case "weapon-pool":
|
||||
return <FormField name="weaponPool" />;
|
||||
case "sens":
|
||||
return <SensFields />;
|
||||
case "art":
|
||||
|
||||
@@ -299,8 +299,22 @@ export const WIDGET_LOADERS = {
|
||||
commissions: async (userId: number) => {
|
||||
return UserRepository.findCommissionsByUserId(userId);
|
||||
},
|
||||
"weapon-pool": async (userId: number) => {
|
||||
return MatchProfileRepository.findWeaponPoolByUserId(userId);
|
||||
"weapon-pool": async (
|
||||
userId: number,
|
||||
settings: ExtractWidgetSettings<"weapon-pool">,
|
||||
) => {
|
||||
if (settings.weaponPool.length === 0) {
|
||||
return MatchProfileRepository.findWeaponPoolByUserId(userId);
|
||||
}
|
||||
|
||||
const tenStarWeaponSplIds =
|
||||
await XRankPlacementRepository.findTenStarWeaponSplIdsByUserId(userId);
|
||||
|
||||
return settings.weaponPool.map((weapon) => ({
|
||||
weaponSplId: weapon.id,
|
||||
isFavorite: weapon.isFavorite ? 1 : 0,
|
||||
isTenStar: tenStarWeaponSplIds.includes(weapon.id) ? 1 : 0,
|
||||
}));
|
||||
},
|
||||
"social-links": async (userId: number) => {
|
||||
return UserRepository.findSocialLinksByUserId(userId);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { widgetsAvailableTo } from "./portfolio";
|
||||
import type { StoredWidget } from "./types";
|
||||
|
||||
const MAIN_WIDGETS: StoredWidget[] = [
|
||||
{ id: "weapon-pool" },
|
||||
{ id: "weapon-pool", settings: { weaponPool: [] } },
|
||||
{ id: "trophies-owned" },
|
||||
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
|
||||
{ id: "badges-authored" },
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
sensSchema,
|
||||
tierListSchema,
|
||||
timezoneSchema,
|
||||
weaponPoolWidgetSchema,
|
||||
xRankPeaksSchema,
|
||||
} from "./widget-form-schemas";
|
||||
|
||||
@@ -54,7 +55,12 @@ export const ALL_WIDGETS = {
|
||||
schema: favoriteStageSchema,
|
||||
defaultSettings: { stageId: 1 },
|
||||
}),
|
||||
defineWidget({ id: "weapon-pool", slot: "main" }),
|
||||
defineWidget({
|
||||
id: "weapon-pool",
|
||||
slot: "main",
|
||||
schema: weaponPoolWidgetSchema,
|
||||
defaultSettings: { weaponPool: [] },
|
||||
}),
|
||||
defineWidget({ id: "lfg-posts", slot: "main", navItem: "lfg" }),
|
||||
defineWidget({
|
||||
id: "sens",
|
||||
@@ -239,7 +245,7 @@ export const ALL_WIDGETS = {
|
||||
* showed before it was widget based.
|
||||
*/
|
||||
export const DEFAULT_WIDGETS: StoredWidget[] = [
|
||||
{ id: "weapon-pool" },
|
||||
{ id: "weapon-pool", settings: { weaponPool: [] } },
|
||||
{ id: "x-rank-peaks", settings: { division: "both" } },
|
||||
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
|
||||
{ id: "bio", settings: { bio: null } },
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
textArea,
|
||||
textAreaOptional,
|
||||
textField,
|
||||
weaponPool,
|
||||
weaponSelect,
|
||||
} from "~/form/fields";
|
||||
import type { FormObjectSchema, SelectOption } from "~/form/types";
|
||||
@@ -94,6 +95,14 @@ export const peakXpWeaponSchema = v.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const weaponPoolWidgetSchema = v.object({
|
||||
weaponPool: weaponPool({
|
||||
label: "labels.weaponPool",
|
||||
bottomText: "bottomTexts.weaponPoolWidget",
|
||||
maxCount: USER.WEAPON_POOL_WIDGET_MAX,
|
||||
}),
|
||||
});
|
||||
|
||||
const CONTROLLERS = [
|
||||
"s1-pro-con",
|
||||
"s2-pro-con",
|
||||
@@ -203,6 +212,7 @@ const WIDGET_FORM_SCHEMAS: Record<string, FormObjectSchema> = {
|
||||
"favorite-stage": favoriteStageSchema,
|
||||
"peak-xp-unverified": peakXpUnverifiedSchema,
|
||||
"peak-xp-weapon": peakXpWeaponSchema,
|
||||
"weapon-pool": weaponPoolWidgetSchema,
|
||||
sens: sensSchema,
|
||||
art: artSchema,
|
||||
links: linksSchema,
|
||||
|
||||
@@ -14,6 +14,7 @@ export const USER = {
|
||||
MAX_SIDE_WIDGETS_SUPPORTER: 7,
|
||||
GAME_BADGES_MAX: 8,
|
||||
GAME_BADGES_SMALL_MAX: 4,
|
||||
WEAPON_POOL_WIDGET_MAX: 7,
|
||||
COUNTDOWN_TITLE_MAX_LENGTH: 50,
|
||||
MARKDOWN_WIDGET_MAX_LENGTH: 2000,
|
||||
PEAK_XP_MIN: 1000,
|
||||
|
||||
@@ -54,6 +54,30 @@ describe("widgetsEditSchema", () => {
|
||||
settings: { controller: "s1-pro-con", motionSens: 51, stickSens: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
why: "the same weapon twice in the weapon pool widget",
|
||||
widget: {
|
||||
id: "weapon-pool",
|
||||
settings: {
|
||||
weaponPool: [
|
||||
{ id: 40, isFavorite: false },
|
||||
{ id: 40, isFavorite: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
why: "more weapons than the weapon pool widget allows",
|
||||
widget: {
|
||||
id: "weapon-pool",
|
||||
settings: {
|
||||
weaponPool: [0, 10, 20, 30, 40, 50, 60, 70].map((id) => ({
|
||||
id,
|
||||
isFavorite: false,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
])("rejects $why", ({ widget }) => {
|
||||
const result = v.safeParse(widgetsEditSchema(true), {
|
||||
widgets: JSON.stringify([widget]),
|
||||
@@ -81,6 +105,17 @@ describe("widgetsEditSchema", () => {
|
||||
settings: { controller: "s1-pro-con", motionSens: -25, stickSens: 5 },
|
||||
},
|
||||
},
|
||||
{
|
||||
why: "an empty weapon pool widget list",
|
||||
widget: { id: "weapon-pool", settings: { weaponPool: [] } },
|
||||
},
|
||||
{
|
||||
why: "a weapon pool widget list with a favorite",
|
||||
widget: {
|
||||
id: "weapon-pool",
|
||||
settings: { weaponPool: [{ id: 40, isFavorite: true }] },
|
||||
},
|
||||
},
|
||||
])("accepts $why", ({ widget }) => {
|
||||
const result = v.safeParse(widgetsEditSchema(true), {
|
||||
widgets: JSON.stringify([widget]),
|
||||
|
||||
5
changelog/2026-09-08-weapon-pool-widget-custom-list.md
Normal file
5
changelog/2026-09-08-weapon-pool-widget-custom-list.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: u
|
||||
type: feature
|
||||
---
|
||||
Weapon pool widget: choose the weapons to show (up to 7), or leave it empty to keep showing your match profile pool
|
||||
@@ -52,6 +52,16 @@ export class UserEditWidgetsPage {
|
||||
await this.locators.badgesSelector.selectOption(String(badgeId));
|
||||
}
|
||||
|
||||
/** Adds one weapon to the weapon pool widget's settings, expanded after opening them. */
|
||||
async selectWeaponPoolWeapon(weaponName: string) {
|
||||
await this.page.getByTestId("weapon-select").click();
|
||||
await this.page.getByPlaceholder("Search weapons...").fill(weaponName);
|
||||
await this.page
|
||||
.getByRole("listbox")
|
||||
.getByTestId(`weapon-select-option-${weaponName}`)
|
||||
.click();
|
||||
}
|
||||
|
||||
/** Fills the bio widget's settings, expanded after adding it or opening them. */
|
||||
async fillBio(text: string) {
|
||||
await this.page.getByLabel("Bio").fill(text);
|
||||
|
||||
@@ -215,6 +215,33 @@ test.describe("User page", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the weapon pool widget's own list over the match profile pool", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
await factories.UserFactory.grant(ADMIN_ID, {
|
||||
matchProfile: {
|
||||
weaponPool: [{ id: 1100, isFavorite: false }],
|
||||
},
|
||||
});
|
||||
|
||||
await impersonate(page);
|
||||
|
||||
const editWidgetsPage = new UserEditWidgetsPage(page);
|
||||
await editWidgetsPage.goto(ADMIN_DISCORD_ID);
|
||||
await editWidgetsPage.openWidgetSettings("weapon-pool");
|
||||
await editWidgetsPage.selectWeaponPoolWeapon("Luna Blaster");
|
||||
await editWidgetsPage.selectWeaponPoolWeapon("Splattershot");
|
||||
await editWidgetsPage.save();
|
||||
|
||||
const userPage = new UserPage(page);
|
||||
await userPage.goto(ADMIN_DISCORD_ID);
|
||||
|
||||
await expect(userPage.weaponPoolImage(200, 1)).toBeVisible();
|
||||
await expect(userPage.weaponPoolImage(40, 2)).toBeVisible();
|
||||
await isNotVisible(userPage.weaponPoolImage(1100, 1));
|
||||
});
|
||||
|
||||
test("chooses result highlights which the results list then shows by default", async ({
|
||||
page,
|
||||
factories,
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Våbenpulje",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Waffenpool",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "Name can't be only special characters",
|
||||
"errors.customRoleRequired": "Enter a name for the custom role",
|
||||
"labels.weaponPool": "Weapon pool",
|
||||
"bottomTexts.weaponPoolWidget": "Leave empty to show your match profile weapon pool",
|
||||
"placeholders.chatMessage": "Press enter to send",
|
||||
"placeholders.weaponPoolFull": "Pool full - remove a weapon to add more",
|
||||
"placeholders.vodStartTimestamp": "10:22",
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
"widgets.description.top-500-weapons-splatanas": "Show splatanas you've reached top 500 with",
|
||||
"widgets.description.x-rank-peaks": "Show your peak X Rank placement for each mode, optionally select division",
|
||||
"widgets.description.builds": "Display your 3 most recent builds",
|
||||
"widgets.description.weapon-pool": "Display your match profile weapon pool (edit on settings page)",
|
||||
"widgets.description.weapon-pool": "Display your match profile weapon pool or a custom list of weapons",
|
||||
"widgets.description.sens": "Show your sensitivity settings and controller of choice",
|
||||
"widgets.description.art": "Display your 3 most recent art pieces",
|
||||
"widgets.description.commissions": "Show your commission status and details",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "El nombre no puede ser solo caracteres especiales",
|
||||
"errors.customRoleRequired": "Introduce un nombre para el rol personalizado",
|
||||
"labels.weaponPool": "Selección de armas",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "Presiona Enter para enviar",
|
||||
"placeholders.weaponPoolFull": "Selección llena - elimina un arma para añadir más",
|
||||
"placeholders.vodStartTimestamp": "10:22",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "El nombre no puede ser solo caracteres especiales",
|
||||
"errors.customRoleRequired": "Introduce un nombre para el rol personalizado",
|
||||
"labels.weaponPool": "Grupo de armas",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "Presionar 'enter' para enviar",
|
||||
"placeholders.weaponPoolFull": "Selección llena - elimina un arma para añadir más",
|
||||
"placeholders.vodStartTimestamp": "10:22",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Armes jouées",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Armes jouées",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "Appuyer sur entrer pour envoyer",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "מאגר נשקים",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Pool armi",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "Premi Invio per inviare",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "使用ブキ",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "送信するには enter を押してください",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Pula broni",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Seleção de armas",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "Aperte enter para enviar",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "",
|
||||
"errors.customRoleRequired": "",
|
||||
"labels.weaponPool": "Используемое оружие",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "Нажмите enter, чтобы отправить",
|
||||
"placeholders.weaponPoolFull": "",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"errors.noOnlySpecialCharacters": "名称不能仅由特殊字符组成",
|
||||
"errors.customRoleRequired": "请输入自定义职责的名称",
|
||||
"labels.weaponPool": "武器池",
|
||||
"bottomTexts.weaponPoolWidget": "",
|
||||
"placeholders.chatMessage": "按回车键发送",
|
||||
"placeholders.weaponPoolFull": "武器池已满。请移除一个武器以添加新武器",
|
||||
"placeholders.vodStartTimestamp": "",
|
||||
|
||||
18
migrations/20260908162417-weapon-pool-widget-settings.ts
Normal file
18
migrations/20260908162417-weapon-pool-widget-settings.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { type Kysely, sql } from "kysely";
|
||||
|
||||
/**
|
||||
* The weapon pool widget gained its own optional weapon list. Rows saved before
|
||||
* that have no settings, so they get an empty list which keeps showing the match
|
||||
* profile pool as before.
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
update "UserWidget"
|
||||
set "widget" = json_object(
|
||||
'id', 'weapon-pool',
|
||||
'settings', json_object('weaponPool', json('[]'))
|
||||
)
|
||||
where json_extract("widget", '$.id') = 'weapon-pool'
|
||||
and json_extract("widget", '$.settings') is null
|
||||
`.execute(db);
|
||||
}
|
||||
@@ -880,6 +880,12 @@ export function buildCases(fx: Fixtures): {
|
||||
add("XRankPlacementRepository.isPlayerLinkedByUserId", fx.xrank, (xrank) =>
|
||||
XRankPlacementRepository.isPlayerLinkedByUserId(xrank.userId),
|
||||
);
|
||||
add(
|
||||
"XRankPlacementRepository.findTenStarWeaponSplIdsByUserId",
|
||||
fx.xrank,
|
||||
(xrank) =>
|
||||
XRankPlacementRepository.findTenStarWeaponSplIdsByUserId(xrank.userId),
|
||||
);
|
||||
add(
|
||||
"XRankPlacementRepository.findPeakVerifiedXpByUserId",
|
||||
fx.xrank,
|
||||
|
||||
Reference in New Issue
Block a user