From b7a78b4384f37fbb5c8fae152b6b3719d1d98227 Mon Sep 17 00:00:00 2001 From: lemintare Date: Sun, 19 Jul 2026 03:26:13 +0300 Subject: [PATCH] Enhance hero selection functionality in LiveSeriesPage by adding search and role filter options. Update App.test.tsx to include tests for new search and tab interaction features. Improve translations for hero search-related strings and adjust CSS for new UI elements. This update aims to improve user experience during hero bans in live matches. --- frontend/src/App.test.tsx | 6 +++++- frontend/src/App.tsx | 10 ++++++++-- frontend/src/i18n.tsx | 4 ++++ frontend/src/index.css | 3 ++- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e783944..5798897 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import App from './App' import { demoSeries } from './api/demo' @@ -48,6 +48,10 @@ describe('Mixmaker frontend', () => { render() expect(await screen.findByRole('heading', { name: 'Series control' })).toBeInTheDocument() expect(screen.getByRole('link', { name: /spectator view/i })).toHaveAttribute('href', '/watch/series-1') + fireEvent.click(screen.getByRole('tab', { name: /DPS/i })) + fireEvent.change(screen.getByRole('searchbox', { name: /search hero/i }), { target: { value: 'tRaC' } }) + expect(screen.getByRole('radio', { name: /Tracer/i })).toBeInTheDocument() + expect(screen.queryByRole('radio', { name: /Ana/i })).not.toBeInTheDocument() }) it('does not reuse completed-map hero bans during map pick', () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 392a49a..ee03f20 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState, type DragEvent, type FormEvent, type ReactNode } from 'react' import { CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut, - Menu, Radio, RefreshCw, Shield, Sparkles, Swords, Trophy, UserRound, + Menu, Radio, RefreshCw, Search, Shield, Sparkles, Swords, Trophy, UserRound, UsersRound, X, Zap, } from 'lucide-react' import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -667,6 +667,8 @@ function LiveSeriesPage() { const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined }) const [selected, setSelected] = useState() const [outcome, setOutcome] = useState('TeamAWin') + const [heroRoleFilter, setHeroRoleFilter] = useState<'all' | PlayerRole>('all') + const [heroSearch, setHeroSearch] = useState('') const participant = Boolean(series.data && session.data && [series.data.teamAlpha, series.data.teamBeta].some((team) => team.members.some(({ player }) => player.id === session.data?.player.id))) useEffect(() => { if (series.data && session.data && !participant && !isStaff(session.data.account.role)) { @@ -712,6 +714,10 @@ function LiveSeriesPage() { const activeTeam = activeSide === 'alpha' ? data.teamAlpha : data.teamBeta const canAct = isStaff(session.data.account.role) || activeTeam.captainId === session.data.player.id const canRecord = isStaff(session.data.account.role) || data.teamAlpha.captainId === session.data.player.id || data.teamBeta.captainId === session.data.player.id + const normalizedHeroSearch = heroSearch.trim().toLocaleLowerCase() + const visibleOptions = data.currentStep.kind === 'hero_ban' + ? data.options.filter((option) => (heroRoleFilter === 'all' || option.role === heroRoleFilter) && option.name.toLocaleLowerCase().includes(normalizedHeroSearch)) + : data.options return
Spectator view} /> @@ -720,7 +726,7 @@ function LiveSeriesPage() {
Current step

{data.currentStep.title}

{data.currentStep.instruction}

{data.currentStep.activeTeam &&
Acting team{activeTeam.name}
}
{data.currentStep.kind === 'result' ?
{ event.preventDefault(); if (canRecord) result.mutate() }}>{result.isError &&

{result.error.message}

}
: data.currentStep.kind === 'complete' ?

Series completed and bracket updated.

- : <>
{data.options.map((option) => )}

{canAct ? 'Draft commands use server-side validation.' : `Waiting for ${activeTeam.name} captain.`}

{action.isError &&

{action.error.message}

}} + : <>{data.currentStep.kind === 'hero_ban' &&
{([['all', 'All'], ['tank', 'Tank'], ['damage', 'DPS'], ['support', 'Support']] as const).map(([role, label]) => )}
}
{visibleOptions.length === 0 ?

No heroes found.

: visibleOptions.map((option) => )}

{canAct ? 'Draft commands use server-side validation.' : `Waiting for ${activeTeam.name} captain.`}

{action.isError &&

{action.error.message}

}}
diff --git a/frontend/src/i18n.tsx b/frontend/src/i18n.tsx index 9f41a59..8858b87 100644 --- a/frontend/src/i18n.tsx +++ b/frontend/src/i18n.tsx @@ -92,6 +92,7 @@ const translations: Record = { 'Select your current Overwatch rank for each role.': 'Выберите текущий ранг Overwatch для каждой роли.', 'Tank': 'Танк', 'Damage': 'Урон', + 'DPS': 'Урон', 'Support': 'Поддержка', 'Space, pressure, and frontline': 'Пространство, давление и передовая', 'Eliminations and map control': 'Устранения и контроль карты', @@ -179,6 +180,9 @@ const translations: Record = { 'Previous live match': 'Предыдущий активный матч', 'Next live match': 'Следующий активный матч', 'Hero bans': 'Баны героев', + 'Search hero…': 'Найти героя…', + 'Search hero': 'Поиск героя', + 'No heroes found.': 'Герои не найдены.', 'Bo3 hero ban history': 'История банов героев Bo3', 'A team cannot repeat its own hero ban.': 'Команда не может повторять собственный бан героя.', 'No bans yet': 'Банов пока нет', diff --git a/frontend/src/index.css b/frontend/src/index.css index 076eb5b..07dc8c3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -262,6 +262,7 @@ textarea { min-height: 85px; resize: vertical; } .turn-header { padding: 26px; display: flex; align-items: start; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--border); }.turn-header h2 { font-size: 27px; margin: 7px 0; }.turn-header p { color: var(--muted); font-size: 12px; } .turn-team { text-align: right; display: flex; flex-direction: column; gap: 5px; }.turn-team span { color: var(--muted); font-size: 9px; text-transform: uppercase; }.turn-team strong { color: var(--team); font-size: 12px; } .draft-options { padding: 22px; display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; } +.hero-filter-tools { display: grid; gap: 9px; padding: 18px 22px 0; }.hero-search { height: 38px; display: flex; align-items: center; gap: 9px; padding: 0 11px; border: 1px solid var(--border); border-radius: 7px; background: #0d1014; }.hero-search:focus-within { border-color: var(--orange); }.hero-search svg { width: 16px; color: var(--muted); }.hero-search input { width: 100%; padding: 0; border: 0; outline: 0; background: transparent; }.hero-role-filter { display: flex; gap: 6px; overflow-x: auto; }.hero-role-filter button { min-width: 82px; padding: 8px 11px; display: flex; align-items: center; justify-content: space-between; gap: 9px; border: 1px solid var(--border); border-radius: 6px; background: #11151a; color: var(--muted); cursor: pointer; }.hero-role-filter button.active { border-color: var(--orange); background: var(--orange-soft); color: var(--text); }.hero-role-filter small { min-width: 18px; padding: 2px 4px; border-radius: 8px; background: rgba(255,255,255,.06); font-size: 8px; text-align: center; } .draft-options button { min-height: 74px; padding: 12px; display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 8px; text-align: left; border: 1px solid var(--border); border-radius: 7px; background: #12161b; cursor: pointer; } .draft-options button > svg { color: transparent; }.draft-options button.selected { border-color: var(--team); background: var(--team-bg); }.draft-options button.selected > svg { color: var(--team); } .draft-options button:disabled { opacity: .45; cursor: not-allowed; }.draft-options button small { grid-column: 2/-1; color: #e49765; font-size: 8px; } @@ -317,7 +318,7 @@ textarea { min-height: 85px; resize: vertical; } .workflow-action { align-items: stretch; flex-direction: column; }.workflow-action .button { width: 100%; }.roster-editor-body { grid-template-columns: 1fr; }.roster-slot { grid-template-columns: 24px 1fr auto 30px; } .scoreboard { min-height: 120px; grid-template-columns: 1fr 90px 1fr; }.score-team { padding: 15px 10px; }.score-team h2 { font-size: 13px; }.score-team small { display: none; }.series-score strong { font-size: 30px; }.series-score .badge { display: none; } .team-name-controls { grid-template-columns: 1fr; }.team-name-editor { align-items: stretch; flex-direction: column; } - .turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; } + .turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.hero-filter-tools { padding: 14px 14px 0; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; } .result-form { grid-template-columns: 1fr; } .ban-display { grid-template-columns: repeat(2,1fr); }.spectator-focus { padding: 24px 14px; }.spectator-grid { grid-template-columns: 1fr; } .bracket-scroll { padding: 25px; gap: 70px; }.bracket-match::after { width: 70px; right: -71px; }