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.
This commit is contained in:
@@ -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(<App />)
|
||||
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', () => {
|
||||
|
||||
@@ -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<string>()
|
||||
const [outcome, setOutcome] = useState<MapOutcome>('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 <div className="page live-page">
|
||||
<PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description={canAct || canRecord ? 'Actions are validated and recorded by the server.' : 'You are playing in this match. Captain actions are read-only for team members.'} actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} />
|
||||
<Scoreboard series={data} />
|
||||
@@ -720,7 +726,7 @@ function LiveSeriesPage() {
|
||||
<div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{activeTeam.name}</strong></div>}</div>
|
||||
{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(event) => { event.preventDefault(); if (canRecord) result.mutate() }}><label>Outcome<select disabled={!canRecord} value={outcome} onChange={(event) => setOutcome(event.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={!canRecord || result.isPending}>{canRecord ? result.isPending ? 'Recording…' : 'Record result' : 'Captain action'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form>
|
||||
: data.currentStep.kind === 'complete' ? <div className="turn-footer"><p><Check />Series completed and bracket updated.</p></div>
|
||||
: <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled || !canAct} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />{canAct ? 'Draft commands use server-side validation.' : `Waiting for ${activeTeam.name} captain.`}</p><button className="button team-action" disabled={!canAct || (data.currentStep.kind !== 'coin_toss' && !selected) || action.isPending} onClick={() => action.mutate()}>{canAct ? action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action' : 'Captain action'} <ChevronRight /></button></div>{action.isError && <p className="error-note">{action.error.message}</p>}</>}
|
||||
: <>{data.currentStep.kind === 'hero_ban' && <div className="hero-filter-tools"><label className="hero-search"><Search /><input type="search" value={heroSearch} placeholder="Search hero…" aria-label="Search hero" onChange={(event) => { setHeroSearch(event.target.value); setSelected(undefined) }} /></label><div className="hero-role-filter" role="tablist" aria-label="Filter heroes by role">{([['all', 'All'], ['tank', 'Tank'], ['damage', 'DPS'], ['support', 'Support']] as const).map(([role, label]) => <button type="button" role="tab" aria-selected={heroRoleFilter === role} className={heroRoleFilter === role ? 'active' : ''} key={role} onClick={() => { setHeroRoleFilter(role); setSelected(undefined) }}>{label}<small>{role === 'all' ? data.options.length : data.options.filter((option) => option.role === role).length}</small></button>)}</div></div>}<div className="draft-options" role="radiogroup" aria-label="Available draft choices">{visibleOptions.length === 0 ? <p className="empty-note">No heroes found.</p> : visibleOptions.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled || !canAct} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />{canAct ? 'Draft commands use server-side validation.' : `Waiting for ${activeTeam.name} captain.`}</p><button className="button team-action" disabled={!canAct || (data.currentStep.kind !== 'coin_toss' && !selected) || action.isPending} onClick={() => action.mutate()}>{canAct ? action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action' : 'Captain action'} <ChevronRight /></button></div>{action.isError && <p className="error-note">{action.error.message}</p>}</>}
|
||||
</section>
|
||||
<aside className="match-sidebar"><HeroBanDisplay series={data} /><SeriesBanHistory series={data} /><MapTimeline series={data} /><AuditLog series={data} /></aside>
|
||||
</div>
|
||||
|
||||
@@ -92,6 +92,7 @@ const translations: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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': 'Банов пока нет',
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user