Streamline issue wizard and make selections explicit
Magent CI/CD / verify (push) Successful in 10m37s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 2m2s

This commit is contained in:
2026-09-06 21:44:44 +12:00
parent 1851fa9753
commit 8d720de500
6 changed files with 681 additions and 321 deletions
+2
View File
@@ -8,6 +8,7 @@
- Keep technical IDs and pipeline labels monospace. Use sentence case for ordinary labels and descriptions.
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
- The guided issue form uses `portal/IssueFlowStep.tsx`: show one expanded step, collapse completed answers into Change rows, and keep repairs behind the final submit action. Movies use the selected title's managed file directly; only TV needs a season/episode picker. Multi-select controls must expose `aria-pressed` and a visible selected state.
## Browser checks
@@ -15,6 +16,7 @@ Build the frontend before reviewing. The scripts in `scripts/` run using Node an
- `review_layout_ui.cjs`: page alignment, consistent headings, overflow, redirects, pipeline layout, invite tabs, and fixture-only recovery forms.
- `review_account_ui.cjs`: fixture-only login and profile interaction checks.
- `review_issue_flow_ui.cjs`: fixture-only movie/TV issue flow, multi-device report payloads, subtitle routing, permissions, and collapsed-step navigation.
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
+1
View File
@@ -3,6 +3,7 @@ import './ops-redesign.css'
import './admin/config.css'
import './account.css'
import './workspace.css'
import './portal/issue-flow.css'
import type { ReactNode } from 'react'
import BrandingFavicon from './ui/BrandingFavicon'
import ApplicationChrome from './ui/ApplicationChrome'
+43
View File
@@ -0,0 +1,43 @@
'use client'
import { useEffect, useRef, type ReactNode } from 'react'
export default function IssueFlowStep({
number, title, summary, active, complete, onEdit, children,
}: {
number: number
title: string
summary: string
active: boolean
complete: boolean
onEdit: () => void
children: ReactNode
}) {
const heading = useRef<HTMLHeadingElement>(null)
useEffect(() => {
if (!active || number === 1) return
heading.current?.focus({ preventScroll: true })
heading.current?.scrollIntoView({ block: 'nearest', behavior: 'instant' })
}, [active, number])
if (!active && !complete) return null
return (
<section className={`issue-procedure-step ${active ? 'is-current' : 'is-complete'}`} aria-label={title}>
{active ? (
<>
<div className="issue-flow-heading">
<span className="issue-step-number" aria-hidden="true">{String(number).padStart(2, '0')}</span>
<h2 ref={heading} tabIndex={-1}>{title}</h2>
</div>
<div className="issue-procedure-content">{children}</div>
</>
) : (
<button type="button" className="issue-step-summary" onClick={onEdit} aria-label={`Change ${title}: ${summary}`}>
<span className="issue-step-number" aria-hidden="true"></span>
<span className="issue-step-summary-copy"><small>{title}</small><strong>{summary}</strong></span>
<span className="issue-step-change">Change</span>
</button>
)}
</section>
)
}
+375 -321
View File
@@ -1,9 +1,10 @@
'use client'
import PageHeading from '../ui/PageHeading'
import IssueFlowStep from './IssueFlowStep'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type PortalPermissions = {
@@ -270,6 +271,7 @@ const ISSUE_SYMPTOMS: Record<IssueCategoryId, string[]> = {
}
const DEVICE_OPTIONS = ['TV app', 'Web browser', 'Phone or tablet', 'Multiple devices'] as const
type IssueStep = 'problem' | 'media' | 'symptoms' | 'targets' | 'devices' | 'review'
const ISSUE_TYPE_LABELS: Record<string, string> = {
general: 'General',
@@ -502,7 +504,8 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const [issueOptions, setIssueOptions] = useState<IssueTargetOptions | null>(null)
const [issueOptionsLoading, setIssueOptionsLoading] = useState(false)
const [issueOptionsMessage, setIssueOptionsMessage] = useState<string | null>(null)
const [movieTargetSelected, setMovieTargetSelected] = useState(false)
const [issueStep, setIssueStep] = useState<IssueStep>('problem')
const issueOptionsVersion = useRef(0)
const [activeSeasonNumber, setActiveSeasonNumber] = useState<number | null>(null)
const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState<number[]>([])
const [selectedEpisodeIds, setSelectedEpisodeIds] = useState<number[]>([])
@@ -513,7 +516,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const workspaceLabelPlural = workspace === 'request' ? 'requests' : 'issues'
const selectedIssueDefinition = ISSUE_CATEGORIES.find((category) => category.id === issueCategory) ?? null
const issueNeedsServerCheck = issueCategory === 'playback' || issueCategory === 'service_unavailable'
const issueNeedsMediaTitle = Boolean(issueCategory)
const issueNeedsDevices = issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle'
const issueRequiresExistingFile =
issueCategory === 'broken_media' ||
issueCategory === 'wrong_content' ||
@@ -549,7 +552,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
issueSymptoms.length > 0 &&
(
issueOptions.request_type === 'movie'
? movieTargetSelected && movieTargetAvailable
? movieTargetAvailable
: issueCategory === 'missing_content'
? (
missingEntireTitle ||
@@ -559,6 +562,26 @@ export default function PortalClient({ workspace }: PortalClientProps) {
: selectedEpisodeIds.length > 0
)
)
const issueNeedsTvTargets = issueOptions?.request_type === 'tv' && !missingEntireTitle
const issueSteps: IssueStep[] = [
'problem', 'media', 'symptoms',
...(issueNeedsTvTargets ? ['targets' as const] : []),
...(issueNeedsDevices ? ['devices' as const] : []),
'review',
]
const stepProps = (step: IssueStep) => ({
number: issueSteps.indexOf(step) + 1,
active: issueStep === step,
complete: issueSteps.indexOf(issueStep) > issueSteps.indexOf(step),
onEdit: () => {
if (issueOptionsLoading) {
issueOptionsVersion.current += 1
setIssueOptionsLoading(false)
}
setIssueStep(step)
},
})
const afterTargets: IssueStep = issueNeedsDevices ? 'devices' : 'review'
useEffect(() => {
if (typeof window === 'undefined') return
@@ -807,8 +830,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
}
const loadIssueOptions = async (media: DiscoveryResult) => {
const version = ++issueOptionsVersion.current
setIssueOptionsLoading(false)
setIssueOptions(null)
setMovieTargetSelected(false)
setActiveSeasonNumber(null)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
@@ -834,17 +858,20 @@ export default function PortalClient({ workspace }: PortalClientProps) {
throw new Error(text || 'Could not load seasons and episodes from Sonarr/Radarr.')
}
const payload = await response.json() as IssueTargetOptions
if (version !== issueOptionsVersion.current) return
setIssueOptions(payload)
setIssueOptionsMessage(payload.message ?? null)
const firstSeason = payload.seasons.find((season) => season.season_number > 0) ?? payload.seasons[0]
setActiveSeasonNumber(firstSeason?.season_number ?? null)
setIssueStep('symptoms')
} catch (err) {
if (version !== issueOptionsVersion.current) return
console.error(err)
setIssueOptionsMessage(
err instanceof Error ? err.message : 'Could not load seasons and episodes from Sonarr/Radarr.'
)
} finally {
setIssueOptionsLoading(false)
if (version === issueOptionsVersion.current) setIssueOptionsLoading(false)
}
}
@@ -858,10 +885,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setIssueMediaSearching(true)
setError(null)
setIssueMediaResults([])
issueOptionsVersion.current += 1
setIssueOptionsLoading(false)
setIssueSelectedMedia(null)
setIssueMediaTitle('')
setIssueOptions(null)
setMovieTargetSelected(false)
setActiveSeasonNumber(null)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
@@ -907,6 +935,8 @@ export default function PortalClient({ workspace }: PortalClientProps) {
}
const selectIssueMedia = (media: DiscoveryResult) => {
setIssueSymptoms([])
setIssueDevices([])
setIssueSelectedMedia(media)
setIssueMediaTitle(media.title)
setIssueMediaType(media.type === 'tv' ? 'tv' : 'movie')
@@ -950,7 +980,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setMediaServerError(null)
setError(null)
setStatus(null)
setMovieTargetSelected(false)
setIssueStep(issueSelectedMedia && issueOptions ? 'symptoms' : 'media')
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
if (category === 'playback' || category === 'service_unavailable') {
@@ -960,10 +990,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const toggleStringChoice = (
value: string,
selected: string[],
setter: React.Dispatch<React.SetStateAction<string[]>>,
) => {
setter(selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value])
setter((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value])
}
const runIssueAction = async (path: string, body: Record<string, unknown>): Promise<string> => {
@@ -988,6 +1017,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const createGuidedIssue = async (event: React.FormEvent) => {
event.preventDefault()
if (creating || issueStep !== 'review') return
if (!selectedIssueDefinition || !issueCategory) {
setError('Choose the problem that best matches what you are seeing.')
return
@@ -1002,8 +1032,8 @@ export default function PortalClient({ workspace }: PortalClientProps) {
return
}
const isMovie = issueOptions.request_type === 'movie'
if (isMovie && !movieTargetSelected) {
setError('Select the movie to continue.')
if (isMovie && !movieTargetAvailable) {
setError('There is no managed movie file available for this repair. Report it as missing instead.')
return
}
if (!isMovie && issueCategory === 'missing_content' && missingSeasons && selectedSeasonNumbers.length === 0) {
@@ -1142,7 +1172,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setIssueSelectedMedia(null)
setIssueOptions(null)
setIssueOptionsMessage(null)
setMovieTargetSelected(false)
setIssueStep('problem')
setActiveSeasonNumber(null)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
@@ -1565,346 +1595,370 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
</section>
) : (
<section className="issue-flow">
<div className="issue-flow-heading">
<span className="issue-step-number">01</span>
<div>
<span className="section-kicker">Choose a symptom</span>
<h2>Which best describes the problem?</h2>
<p>Only the questions needed for that problem will appear next.</p>
</div>
</div>
<div className="issue-category-grid">
{ISSUE_CATEGORIES.map((category) => (
<button
key={category.id}
type="button"
className={`issue-category-card ${issueCategory === category.id ? 'is-selected' : ''}`}
onClick={() => chooseIssueCategory(category.id)}
>
<span className="issue-category-marker">{category.marker}</span>
<strong>{category.label}</strong>
<p>{category.description}</p>
</button>
))}
</div>
{selectedIssueDefinition && issueCategory ? (
<form className="issue-guided-form" onSubmit={createGuidedIssue}>
<div className="issue-flow-heading">
<span className="issue-step-number">02</span>
<div>
<span className="section-kicker">Narrow it down</span>
<h2>Tell us what is affected</h2>
<p>Magent will attach these details to the issue so nobody has to ask for them again.</p>
<section className="issue-flow issue-flow-progressive">
<form className="issue-guided-form" onSubmit={createGuidedIssue}>
<fieldset className="issue-wizard-fields" disabled={creating}>
<IssueFlowStep {...stepProps('problem')} title="What is wrong?" summary={selectedIssueDefinition?.label ?? ''}>
<div className="issue-category-grid">
{ISSUE_CATEGORIES.map((category) => (
<button
key={category.id}
type="button"
className={`issue-category-card ${issueCategory === category.id ? 'is-selected' : ''}`}
onClick={() => chooseIssueCategory(category.id)}
>
<span className="issue-category-marker">{category.marker}</span>
<strong>{category.label}</strong>
<p>{category.description}</p>
</button>
))}
</div>
</div>
</IssueFlowStep>
<div className="issue-question-grid">
{issueNeedsMediaTitle ? (
<div className="issue-media-finder issue-field-span-2">
<label>
<span>Find the exact movie or TV show</span>
<div className="issue-media-search-row">
<input
value={issueMediaQuery}
onChange={(event) => {
setIssueMediaQuery(event.target.value)
if (issueSelectedMedia) {
setIssueSelectedMedia(null)
setIssueMediaTitle('')
setIssueOptions(null)
setMovieTargetSelected(false)
setActiveSeasonNumber(null)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
}
}}
placeholder="Search the Grizzlyflix catalogue"
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void searchIssueMedia()
}
}}
/>
<button type="button" onClick={() => void searchIssueMedia()} disabled={issueMediaSearching}>
{issueMediaSearching ? 'Searching...' : 'Search'}
</button>
</div>
</label>
{issueMediaResults.length > 0 ? (
<div className="issue-media-results">
{issueMediaResults.map((media, index) => {
const poster = resolveTmdbArtworkUrl(media.posterPath, 'w185')
return (
<button
key={`${media.type}:${media.tmdbId ?? index}`}
type="button"
className="issue-media-result"
onClick={() => selectIssueMedia(media)}
>
<span className="issue-media-poster">
{poster ? <img src={poster} alt="" loading="lazy" /> : <i>No artwork</i>}
</span>
<span>
<small>{media.type === 'tv' ? 'TV show' : 'Movie'}{media.year ? ` · ${media.year}` : ''}</small>
<strong>{media.title}</strong>
<b>{media.requestId ? media.statusLabel || `Request #${media.requestId}` : 'Not currently requested'}</b>
</span>
</button>
)
})}
</div>
) : null}
{issueSelectedMedia ? (
<div className="issue-selected-media">
<div>
<span className="section-kicker">Selected title</span>
<strong>{issueSelectedMedia.title}{issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ''}</strong>
<small>
{issueSelectedMedia.type === 'tv' ? 'TV show' : 'Movie'}
{issueSelectedMedia.requestId
? ` · Magent request #${issueSelectedMedia.requestId} · ${issueSelectedMedia.statusLabel ?? 'Tracked'}`
: ' · No existing Magent request'}
</small>
</div>
{issueSelectedMedia.requestId ? (
<button type="button" className="ghost-button" onClick={() => router.push(`/requests/${issueSelectedMedia.requestId}`)}>
Open request
{selectedIssueDefinition && issueCategory ? (
<>
<IssueFlowStep {...stepProps('media')} title="Which title is affected?" summary={`${issueSelectedMedia?.title ?? ''}${issueSelectedMedia?.year ? ` (${issueSelectedMedia.year})` : ''}`}>
<div className="issue-media-finder">
<label>
<span>Find the exact movie or TV show</span>
<div className="issue-media-search-row">
<input
value={issueMediaQuery}
onChange={(event) => {
setIssueMediaQuery(event.target.value)
if (issueSelectedMedia) {
setIssueSelectedMedia(null)
setIssueMediaTitle('')
setIssueOptions(null)
issueOptionsVersion.current += 1
setIssueOptionsLoading(false)
setIssueSymptoms([])
setIssueDevices([])
setActiveSeasonNumber(null)
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
}
}}
placeholder="Search the Grizzlyflix catalogue"
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
void searchIssueMedia()
}
}}
/>
<button type="button" onClick={() => void searchIssueMedia()} disabled={issueMediaSearching}>
{issueMediaSearching ? 'Searching...' : 'Search'}
</button>
</div>
</label>
{issueMediaResults.length > 0 ? (
<div className="issue-media-results">
{issueMediaResults.map((media, index) => {
const poster = resolveTmdbArtworkUrl(media.posterPath, 'w185')
return (
<button
key={`${media.type}:${media.tmdbId ?? index}`}
type="button"
className="issue-media-result"
onClick={() => selectIssueMedia(media)}
>
<span className="issue-media-poster">
{poster ? <img src={poster} alt="" loading="lazy" /> : <i>No artwork</i>}
</span>
<span>
<small>{media.type === 'tv' ? 'TV show' : 'Movie'}{media.year ? ` · ${media.year}` : ''}</small>
<strong>{media.title}</strong>
<b>{media.requestId ? media.statusLabel || `Request #${media.requestId}` : 'Not currently requested'}</b>
</span>
</button>
)
})}
</div>
) : null}
{issueSelectedMedia ? (
<div className="issue-selected-media">
<div>
<span className="section-kicker">Selected title</span>
<strong>{issueSelectedMedia.title}{issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ''}</strong>
<small>
{issueSelectedMedia.type === 'tv' ? 'TV show' : 'Movie'}
{issueSelectedMedia.requestId
? ` · Magent request #${issueSelectedMedia.requestId} · ${issueSelectedMedia.statusLabel ?? 'Tracked'}`
: ' · No existing Magent request'}
</small>
</div>
{issueSelectedMedia.requestId ? (
<button type="button" className="ghost-button" onClick={() => router.push(`/requests/${issueSelectedMedia.requestId}`)}>
Open request
</button>
) : null}
</div>
) : null}
{issueOptionsLoading ? (
<div className="issue-live-scan"><i /><span>{issueSelectedMedia?.type === 'movie' ? 'Checking the movie file' : 'Reading seasons and episodes'}</span></div>
) : null}
{issueOptionsMessage && !issueOptions ? <div className="status-banner">{issueOptionsMessage}</div> : null}
</div>
{issueSelectedMedia && !issueOptionsLoading ? (
<div className="issue-procedure-actions">
{issueOptions ? (
<button type="button" onClick={() => setIssueStep('symptoms')}>Continue with this title</button>
) : issueSelectedMedia.requestId ? (
<button type="button" onClick={() => void loadIssueOptions(issueSelectedMedia)}>Try again</button>
) : null}
</div>
) : null}
</IssueFlowStep>
{issueOptionsLoading ? (
<div className="issue-live-scan"><i /><span>Reading seasons and episodes</span></div>
) : null}
{issueOptionsMessage && !issueOptions ? <div className="status-banner">{issueOptionsMessage}</div> : null}
{issueOptions ? (
<div className="issue-target-picker">
{issueOptions ? (
<>
<IssueFlowStep {...stepProps('symptoms')} title="What needs to be corrected?" summary={issueSymptoms.join(', ')}>
<fieldset className="issue-choice-field">
<legend>What needs to be corrected?</legend>
<legend>Choose all that apply</legend>
<div className="issue-choice-grid">
{(issueOptions.request_type === 'movie' && issueCategory === 'missing_content'
? ['Entire title is missing']
: ISSUE_SYMPTOMS[issueCategory]).map((symptom) => {
const selected = issueSymptoms.includes(symptom)
return (
<button
key={symptom}
type="button"
className={selected ? 'is-selected' : ''}
onClick={() => {
if (symptom === 'Entire title is missing') {
setIssueSymptoms(selected ? [] : [symptom])
setSelectedSeasonNumbers([])
setSelectedEpisodeIds([])
} else {
const withoutEntireTitle = issueSymptoms.filter((item) => item !== 'Entire title is missing')
const nextSymptoms = selected
? withoutEntireTitle.filter((item) => item !== symptom)
: [...withoutEntireTitle, symptom]
setIssueSymptoms(nextSymptoms)
if (symptom === 'Season is missing' && selected) {
const selected = issueSymptoms.includes(symptom)
return (
<button
key={symptom}
type="button"
aria-pressed={selected}
className={selected ? 'is-selected' : ''}
onClick={() => {
if (symptom === 'Entire title is missing') {
setIssueSymptoms(selected ? [] : [symptom])
setSelectedSeasonNumbers([])
}
if (
(symptom === 'Episode is missing' || symptom === 'Part or edition is missing') &&
selected &&
!nextSymptoms.includes('Episode is missing') &&
!nextSymptoms.includes('Part or edition is missing')
) {
setSelectedEpisodeIds([])
} else {
const withoutEntireTitle = issueSymptoms.filter((item) => item !== 'Entire title is missing')
const nextSymptoms = selected
? withoutEntireTitle.filter((item) => item !== symptom)
: [...withoutEntireTitle, symptom]
setIssueSymptoms(nextSymptoms)
if (symptom === 'Season is missing' && selected) {
setSelectedSeasonNumbers([])
}
if (
(symptom === 'Episode is missing' || symptom === 'Part or edition is missing') &&
selected &&
!nextSymptoms.includes('Episode is missing') &&
!nextSymptoms.includes('Part or edition is missing')
) {
setSelectedEpisodeIds([])
}
}
}
}}
>
<span>{selected ? '✓' : '+'}</span>
<strong>{issueOptions.request_type === 'movie' && symptom === 'Entire title is missing' ? 'Movie is missing' : symptom}</strong>
</button>
)
})}
}}
>
<span aria-hidden="true">{selected ? '✓' : '+'}</span>
<strong>{issueOptions.request_type === 'movie' && symptom === 'Entire title is missing' ? 'Movie is missing' : symptom}</strong>
</button>
)
})}
</div>
</fieldset>
{issueSymptoms.length > 0 && issueOptions.request_type === 'movie' ? (
<button
type="button"
className={`issue-movie-target ${movieTargetSelected ? 'is-selected' : ''}`}
disabled={!movieTargetAvailable}
onClick={() => setMovieTargetSelected((current) => !current)}
>
<span>{movieTargetSelected ? '✓' : 'MOVIE'}</span>
<div>
<strong>{issueOptions.title}</strong>
<small>
{!movieTargetAvailable
? 'No managed file is available for this repair'
: issueOptions.movie?.best_fit
? 'This is the best fit'
: issueOptions.movie?.has_file
? 'Ready to select'
: 'Missing in Radarr'}
</small>
</div>
</button>
{issueOptions.request_type === 'movie' && !movieTargetAvailable ? (
<div className="status-banner">
No managed movie file is available to repair.
<button type="button" className="ghost-button" onClick={() => chooseIssueCategory('missing_content')}>Report missing movie instead</button>
</div>
) : null}
<div className="issue-procedure-actions">
<button type="button"
disabled={!issueSymptoms.length || issueOptions.request_type === 'movie' && !movieTargetAvailable}
onClick={() => setIssueStep(issueNeedsTvTargets ? 'targets' : afterTargets)}>
Continue
</button>
</div>
</IssueFlowStep>
{issueSymptoms.length > 0 && issueOptions.request_type === 'tv' && !missingEntireTitle ? (
<div className="issue-tv-targets">
<div className="issue-target-heading">
<strong>{missingSeasons ? 'Choose the missing seasons' : 'Choose a season'}</strong>
<small>You can select more than one.</small>
</div>
{missingSeasons ? (
<div className="issue-season-grid">
{issueOptions.seasons.map((season) => {
const selected = selectedSeasonNumbers.includes(season.season_number)
return (
<button
key={season.season_number}
type="button"
className={selected ? 'is-selected' : ''}
onClick={() => setSelectedSeasonNumbers((current) => current.includes(season.season_number)
? current.filter((value) => value !== season.season_number)
: [...current, season.season_number])}
>
<strong>{season.label}</strong>
<small>{season.missing_count} missing · {season.available_count} available</small>
{season.best_fit ? <b>This is the best fit</b> : null}
</button>
)
})}
{issueNeedsTvTargets ? (
<IssueFlowStep {...stepProps('targets')} title="Which seasons or episodes?" summary={[
...selectedSeasonNumbers.map((season) => `Season ${season}`),
...selectedEpisodeOptions.map((episode) => episode.code),
].join(', ')}>
{issueSymptoms.length > 0 && issueOptions.request_type === 'tv' && !missingEntireTitle ? (
<div className="issue-tv-targets">
<div className="issue-target-heading">
<strong>{missingSeasons ? 'Choose the missing seasons' : 'Choose a season'}</strong>
<small>You can select more than one.</small>
</div>
) : null}
{(issueCategory !== 'missing_content' || missingEpisodes) ? (
<div className="issue-season-grid issue-season-tabs">
{issueOptions.seasons.map((season) => (
<button
key={season.season_number}
type="button"
className={activeSeasonNumber === season.season_number ? 'is-selected' : ''}
onClick={() => setActiveSeasonNumber(season.season_number)}
>
<strong>{season.label}</strong>
<small>{season.episode_count} episodes</small>
</button>
))}
</div>
) : null}
{(issueCategory !== 'missing_content' || missingEpisodes) && activeSeasonNumber !== null ? (
<div className="issue-episode-grid">
{issueOptions.episodes
.filter((episode) => episode.season_number === activeSeasonNumber && episode.released)
.map((episode) => {
const selected = selectedEpisodeIds.includes(episode.id)
const disabled = issueRequiresExistingFile && !episode.has_file
{missingSeasons ? (
<div className="issue-season-grid">
{issueOptions.seasons.map((season) => {
const selected = selectedSeasonNumbers.includes(season.season_number)
return (
<button
key={episode.id}
key={season.season_number}
type="button"
disabled={disabled}
aria-pressed={selected}
className={selected ? 'is-selected' : ''}
onClick={() => setSelectedEpisodeIds((current) => current.includes(episode.id)
? current.filter((value) => value !== episode.id)
: [...current, episode.id])}
onClick={() => setSelectedSeasonNumbers((current) => current.includes(season.season_number)
? current.filter((value) => value !== season.season_number)
: [...current, season.season_number])}
>
<span>{selected ? '✓' : episode.code}</span>
<strong>{episode.title}</strong>
<small>{episode.missing ? 'Missing in Sonarr' : episode.has_file ? 'Ready to select' : 'No file in Sonarr'}</small>
{episode.best_fit ? <b>This is the best fit</b> : null}
<strong>{season.label}</strong>
<small>{season.missing_count} missing · {season.available_count} available</small>
{season.best_fit ? <b>This is the best fit</b> : null}
</button>
)
})}
</div>
) : null}
</div>
) : null}
</div>
) : null}
</div>
) : null}
{issueTargetReady && (issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? (
<fieldset className="issue-choice-field issue-field-span-2">
<legend>Where did it happen? <small>Choose all that apply</small></legend>
<div className="issue-choice-row">
{DEVICE_OPTIONS.map((device) => (
<button
key={device}
type="button"
className={issueDevices.includes(device) ? 'is-selected' : ''}
onClick={() => toggleStringChoice(device, issueDevices, setIssueDevices)}
>
{device}
</button>
))}
</div>
</fieldset>
) : null}
</div>
</div>
) : null}
{issueNeedsServerCheck && issueTargetReady ? (
<section className={`media-status-check media-status-${mediaServerStatus?.status ?? 'checking'}`}>
<div className="media-status-heading">
<div>
<span className="section-kicker">Live media-server check</span>
<h3>
{mediaServerChecking
? 'Checking Jellyfin now...'
: mediaServerStatus?.headline ?? 'Server check unavailable'}
</h3>
</div>
<button type="button" className="ghost-button" onClick={() => void checkMediaServer()} disabled={mediaServerChecking}>
{mediaServerChecking ? 'Checking...' : 'Check again'}
</button>
</div>
{mediaServerChecking ? <div className="issue-live-scan"><i /><span>Contacting the media server</span></div> : null}
{mediaServerError ? <div className="error-banner">{mediaServerError}</div> : null}
{mediaServerStatus ? (
<>
<p>{mediaServerStatus.message}</p>
<div className="media-status-metrics">
<div><span>Server API</span><strong>{mediaServerStatus.status === 'down' ? 'Unavailable' : 'Responding'}</strong></div>
<div><span>Response</span><strong>{mediaServerStatus.latency_ms ?? '--'} ms</strong></div>
<div><span>Active streams</span><strong>{mediaServerStatus.activity?.available ? mediaServerStatus.activity.active_streams ?? 0 : '--'}</strong></div>
<div><span>Transcoding</span><strong>{mediaServerStatus.activity?.available ? mediaServerStatus.activity.transcoding_streams ?? 0 : '--'}</strong></div>
</div>
{(issueCategory !== 'missing_content' || missingEpisodes) ? (
<div className="issue-season-grid issue-season-tabs">
{issueOptions.seasons.map((season) => (
<button
key={season.season_number}
type="button"
aria-pressed={activeSeasonNumber === season.season_number}
className={activeSeasonNumber === season.season_number ? 'is-selected' : ''}
onClick={() => setActiveSeasonNumber(season.season_number)}
>
<strong>{season.label}</strong>
<small>{season.episode_count} episodes</small>
</button>
))}
</div>
) : null}
{(issueCategory !== 'missing_content' || missingEpisodes) && activeSeasonNumber !== null ? (
<div className="issue-episode-grid">
{issueOptions.episodes
.filter((episode) => episode.season_number === activeSeasonNumber && episode.released)
.map((episode) => {
const selected = selectedEpisodeIds.includes(episode.id)
const disabled = issueRequiresExistingFile && !episode.has_file
return (
<button
key={episode.id}
type="button"
disabled={disabled}
aria-pressed={selected}
className={selected ? 'is-selected' : ''}
onClick={() => setSelectedEpisodeIds((current) => current.includes(episode.id)
? current.filter((value) => value !== episode.id)
: [...current, episode.id])}
>
<span>{selected ? '✓' : episode.code}</span>
<strong>{episode.title}</strong>
<small>{episode.missing ? 'Missing in Sonarr' : episode.has_file ? 'Ready to select' : 'No file in Sonarr'}</small>
{episode.best_fit ? <b>This is the best fit</b> : null}
</button>
)
})}
</div>
) : null}
</div>
) : null}
<div className="issue-procedure-actions">
<span className="issue-selection-count" role="status">
{selectedSeasonNumbers.length ? `${selectedSeasonNumbers.length} season(s) selected` : ''}
{selectedSeasonNumbers.length && selectedEpisodeIds.length ? ' · ' : ''}
{selectedEpisodeIds.length ? `${selectedEpisodeIds.length} episode(s) selected` : ''}
</span>
<button type="button" disabled={!issueTargetReady} onClick={() => setIssueStep(afterTargets)}>Continue</button>
</div>
</IssueFlowStep>
) : null}
{issueNeedsDevices ? (
<IssueFlowStep {...stepProps('devices')} title="Where did it happen?" summary={issueDevices.length ? issueDevices.join(', ') : 'Not specified'}>
{issueTargetReady && (issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? (
<fieldset className="issue-choice-field">
<legend>Choose all that apply</legend>
<div className="issue-choice-row">
{DEVICE_OPTIONS.map((device) => (
<button
key={device}
type="button"
aria-pressed={issueDevices.includes(device)}
className={issueDevices.includes(device) ? 'is-selected' : ''}
onClick={() => toggleStringChoice(device, setIssueDevices)}
>
<span className="issue-device-check" aria-hidden="true">{issueDevices.includes(device) ? '✓' : '+'}</span>
{device}
</button>
))}
</div>
<p className="issue-device-feedback" role="status">
{issueDevices.length ? `Selected: ${issueDevices.join(', ')}` : 'Optional — these choices will be included in your report.'}
</p>
</fieldset>
) : null}
<div className="issue-procedure-actions">
<button type="button" className="ghost-button" onClick={() => { setIssueDevices([]); setIssueStep('review') }}>Skip</button>
<button type="button" disabled={!issueDevices.length} onClick={() => setIssueStep('review')}>Review issue</button>
</div>
</IssueFlowStep>
) : null}
<IssueFlowStep {...stepProps('review')} title="Review and submit" summary="">
{issueNeedsServerCheck && issueTargetReady ? (
<section className={`media-status-check media-status-${mediaServerStatus?.status ?? 'checking'}`}>
<div className="media-status-heading">
<div>
<span className="section-kicker">Live media-server check</span>
<h3>
{mediaServerChecking
? 'Checking Jellyfin now...'
: mediaServerStatus?.headline ?? 'Server check unavailable'}
</h3>
</div>
<button type="button" className="ghost-button" onClick={() => void checkMediaServer()} disabled={mediaServerChecking}>
{mediaServerChecking ? 'Checking...' : 'Check again'}
</button>
</div>
{mediaServerChecking ? <div className="issue-live-scan"><i /><span>Contacting the media server</span></div> : null}
{mediaServerError ? <div className="error-banner">{mediaServerError}</div> : null}
{mediaServerStatus ? (
<>
<p>{mediaServerStatus.message}</p>
<div className="media-status-metrics">
<div><span>Server API</span><strong>{mediaServerStatus.status === 'down' ? 'Unavailable' : 'Responding'}</strong></div>
<div><span>Response</span><strong>{mediaServerStatus.latency_ms ?? '--'} ms</strong></div>
<div><span>Active streams</span><strong>{mediaServerStatus.activity?.available ? mediaServerStatus.activity.active_streams ?? 0 : '--'}</strong></div>
<div><span>Transcoding</span><strong>{mediaServerStatus.activity?.available ? mediaServerStatus.activity.transcoding_streams ?? 0 : '--'}</strong></div>
</div>
</>
) : null}
</section>
) : null}
{issueTargetReady ? <section className="issue-resolution-card">
<div>
<span className="section-kicker">What will happen</span>
<h3>
{issueOptions?.can_act === false
? 'The selected details will be sent to support.'
: selectedIssueDefinition.outcome}
</h3>
<p>
{issueOptions?.can_act === false
? 'The issue and every selection will be sent to support. Automatic fixes are not enabled for this account.'
: issueCategory === 'subtitle'
? 'The issue will be logged, then Bazarr will search for fresh subtitles for every selection.'
: issueCategory === 'missing_content'
? 'The issue will be logged, then the selected movie, seasons, or episodes will be sent back to the collection pipeline.'
: issueSupportsReplacement
? 'The issue will be logged, then the selected content will be sent to Sonarr or Radarr for replacement.'
: 'The live Jellyfin check will be attached so support can see the server state immediately.'}
</p>
</div>
<button type="submit" disabled={creating || mediaServerChecking}>
{creating ? 'Working...' : issueOptions?.can_act === false || !issueSupportsReplacement && issueCategory !== 'missing_content' && issueCategory !== 'subtitle' ? 'Submit issue' : 'Submit and start fix'}
</button>
</section> : null}
</IssueFlowStep>
</>
) : null}
</section>
</>
) : null}
{issueTargetReady ? <section className="issue-resolution-card">
<span className="issue-step-number">03</span>
<div>
<span className="section-kicker">What will happen</span>
<h2>
{issueOptions?.can_act === false
? 'The selected details will be sent to support.'
: selectedIssueDefinition.outcome}
</h2>
<p>
{issueOptions?.can_act === false
? 'The issue and every selection will be sent to support. Automatic fixes are not enabled for this account.'
: issueCategory === 'subtitle'
? 'The issue will be logged, then Bazarr will search for fresh subtitles for every selection.'
: issueCategory === 'missing_content'
? 'The issue will be logged, then the selected movie, seasons, or episodes will be sent back to the collection pipeline.'
: issueSupportsReplacement
? 'The issue will be logged, then the selected content will be sent to Sonarr or Radarr for replacement.'
: 'The live Jellyfin check will be attached so support can see the server state immediately.'}
</p>
</div>
<button type="submit" disabled={creating || mediaServerChecking}>
{creating ? 'Working...' : 'Submit and start fix'}
</button>
</section> : null}
</form>
) : null}
</fieldset>
</form>
</section>
)}
+56
View File
@@ -0,0 +1,56 @@
/* One expanded procedure at a time; completed steps become editable summaries. */
.issue-flow-progressive .issue-guided-form { padding: 0; border: 0; }
.issue-wizard-fields { display: grid; gap: 10px; min-width: 0; margin: 0; padding: 0; border: 0; }
.issue-procedure-step { min-width: 0; border-bottom: 1px solid var(--ops-line-soft); }
.issue-procedure-step:last-child { border-bottom: 0; }
.issue-procedure-step.is-current { padding: 16px 0 8px; }
.issue-procedure-step .issue-flow-heading { align-items: center; margin-bottom: 18px; }
.issue-procedure-step h2 { scroll-margin-top: 130px; }
.issue-procedure-content { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-width: 0; animation: issue-step-enter 150ms ease-out; }
.issue-flow-progressive .issue-category-card { min-height: 122px; gap: 6px; padding: 14px; }
.issue-flow-progressive .issue-media-finder { padding: 0; border: 0; background: transparent; }
.page .issue-flow-progressive .issue-step-summary {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center;
gap: 12px; width: 100%; padding: 10px 0; border: 0 !important;
background: transparent !important; text-align: left; color: var(--ops-text) !important;
box-shadow: none; text-transform: none;
}
.issue-step-summary .issue-step-number { width: 28px; height: 28px; color: var(--ops-primary-2); }
.issue-step-summary-copy { display: grid; gap: 3px; min-width: 0; }
.issue-step-summary-copy small { color: var(--ops-muted); font-size: 11px; font-weight: 500; }
.issue-step-summary-copy strong { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.issue-step-change { color: var(--ops-primary-2); font-size: 12px; }
.issue-step-summary:hover .issue-step-change { text-decoration: underline; }
.issue-procedure-actions { display: flex; grid-column: 1 / -1; justify-content: flex-end; align-items: center; flex-wrap: wrap; gap: 10px; }
.issue-procedure-actions button { min-height: 44px; }
.page .issue-procedure-actions > button:not(.ghost-button) {
background: #c7bdff !important; border-color: #c7bdff !important; color: #1c172c !important;
}
.issue-procedure-actions button:disabled { opacity: .4; }
.issue-selection-count { margin-right: auto; color: var(--ops-muted); font-size: 12px; }
.issue-flow-progressive .issue-choice-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.issue-flow-progressive .issue-choice-row button { display: flex; align-items: center; justify-content: flex-start; gap: 10px; min-height: 48px; }
.issue-device-check { display: grid; place-items: center; width: 22px; height: 22px; flex: 0 0 22px; border: 1px solid currentColor; border-radius: 6px; }
/* Legacy global button colours are !important; scoped overrides keep toggles visible. */
.page .issue-flow-progressive button[aria-pressed='true'] {
border-color: #c7bdff !important; background: #373147 !important; color: #f5f0ff !important;
box-shadow: inset 0 0 0 1px #c7bdff;
}
.issue-device-feedback { margin: 4px 0 0; color: var(--ops-muted); font-size: 12px; line-height: 1.5; }
.issue-flow-progressive .issue-resolution-card { grid-template-columns: minmax(0, 1fr) auto; padding: 0; border: 0; }
.issue-flow-progressive .issue-resolution-card h3 { margin: 0; font-size: 19px; line-height: 1.4; }
.issue-flow-progressive .issue-resolution-card p { margin-top: 8px; font-size: 13px; }
.issue-flow-progressive .status-banner { display: grid; gap: 10px; }
.issue-flow-progressive .status-banner button { justify-self: start; }
@keyframes issue-step-enter { from { opacity: .5; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 680px) {
.issue-flow-progressive .issue-category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.issue-flow-progressive .issue-category-card { min-height: 112px; padding: 12px; }
.issue-flow-progressive .issue-category-card p { display: none; }
.issue-flow-progressive .issue-category-card strong { font-size: 13px; }
.issue-procedure-step .issue-flow-heading h2 { font-size: 19px; }
.issue-flow-progressive .issue-step-summary { gap: 8px; }
.issue-flow-progressive .issue-choice-row button { font-size: 12px; padding: 10px; }
.issue-flow-progressive .issue-resolution-card { grid-template-columns: minmax(0, 1fr); }
}
@media (prefers-reduced-motion: reduce) { .issue-procedure-content { animation: none; } }
+204
View File
@@ -0,0 +1,204 @@
// All application APIs are mocked. No real issues, emails or media repairs are created.
const assert = require('node:assert/strict')
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || 'playwright')
const base = process.env.REVIEW_BASE || 'http://127.0.0.1:3101'
const output = process.env.REVIEW_DIR
const movie = { title: 'The Matrix Reloaded', year: 2003, type: 'movie', tmdbId: 604, requestId: 1424, statusLabel: 'Ready to watch' }
const series = { title: 'Family Guy', year: 1999, type: 'tv', tmdbId: 1434, requestId: 113, statusLabel: 'Partially ready' }
const options = (media, hasFile = true, canAct = true) => ({
request_id: String(media.requestId), request_type: media.type, title: media.title, can_act: canAct,
movie: media.type === 'movie' ? { selected_label: media.title, has_file: hasFile, missing: !hasFile, file_id: hasFile ? 77 : null } : null,
seasons: media.type === 'tv' ? [5, 6].map(n => ({ season_number: n, label: `Season ${n}`, episode_count: 2, available_count: hasFile ? 2 : 0, missing_count: hasFile ? 0 : 2 })) : [],
episodes: media.type === 'tv' ? [5, 6].flatMap(n => [9, 10].map(e => ({
id: n * 100 + e, season_number: n, episode_number: e, code: `S0${n}E${e.toString().padStart(2, '0')}`,
title: `Episode ${n}-${e}`, released: true, monitored: true, has_file: hasFile, missing: !hasFile, file_id: hasFile ? n * 1000 + e : null,
}))) : [],
})
;(async () => {
const browser = await chromium.launch({ headless: true })
try {
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }])
let currentMedia = movie
let hasFile = true
let canAct = true
let serverDown = false
let optionDelay = 0
const calls = []
const errors = []
await context.route('**/api/**', async route => {
const request = route.request()
const path = new URL(request.url()).pathname
const reply = json => route.fulfill({ json })
if (request.method() !== 'GET') calls.push({ path, body: request.postDataJSON() })
if (path === '/api/auth/me') return reply({ username: 'Member', role: 'user' })
if (path === '/api/requests/search') return reply({ results: [currentMedia] })
if (path.endsWith('/issue-options')) {
const result = options(currentMedia, hasFile, canAct)
if (optionDelay) await new Promise(resolve => setTimeout(resolve, optionDelay))
return reply(result)
}
if (path === '/api/portal/issues/media-status') return reply({ status: serverDown ? 'down' : 'up', headline: serverDown ? 'Server unavailable' : 'Server is responding', message: 'Media-server check attached.', checked_at: '2026-09-06T00:00:00Z', latency_ms: 12 })
if (path === '/api/portal/items' && request.method() === 'POST') return reply({ item: { id: 900 } })
if (path.includes('/actions/')) return reply({ status: 'ok', message: 'Fixture action accepted.' })
if (path.includes('/events/stream')) return route.fulfill({ contentType: 'text/event-stream', body: ': fixture\n\n' })
if (path.includes('/branding/')) return route.fulfill({ status: 404 })
return reply({ navigation: { showRequests: true }, items: [], total: 0, services: [] })
})
const page = await context.newPage()
page.on('pageerror', error => errors.push(error.message))
const active = () => page.locator('.issue-procedure-step.is-current')
const button = name => active().getByRole('button', { name, exact: true })
const visibleStep = async title => {
await active().getByRole('heading', { name: title, exact: true }).waitFor()
assert.equal(await page.locator('.issue-procedure-step.is-current').count(), 1)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth), 0)
assert.equal(await page.locator('.issue-movie-target').count(), 0)
}
const screenshot = async name => {
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }))
if (output) await page.screenshot({ path: `${output}/issue-flow-${name}.png`, fullPage: true, animations: 'disabled' })
}
const start = async (category, media = movie) => {
calls.length = 0
currentMedia = media
await page.goto(base + '/portal/issues')
await visibleStep('What is wrong?')
await active().getByRole('button').filter({ hasText: category }).click()
await visibleStep('Which title is affected?')
assert.equal(await page.locator('.issue-category-grid').count(), 0)
await active().getByPlaceholder('Search the Grizzlyflix catalogue').fill(media.title)
await button('Search').click()
await active().locator('.issue-media-result').click()
await visibleStep('What needs to be corrected?')
assert.equal(await page.getByPlaceholder('Search the Grizzlyflix catalogue').count(), 0)
}
const submit = async (path, body) => {
await visibleStep('Review and submit')
assert.equal(calls.length, 0, 'Choices must not start repairs before submission')
await active().getByRole('button', { name: /Submit/ }).click()
await visibleStep('What is wrong?')
assert.equal(calls[0].path, '/api/portal/items')
if (path) {
assert.equal(calls.length, 2)
assert.equal(calls[1].path, `/api/requests/${currentMedia.requestId}/actions/${path}`)
assert.deepEqual(calls[1].body, { issue_id: 900, ...body })
} else assert.equal(calls.length, 1)
return calls[0].body
}
// Movie selection is automatic; device toggles visibly change, persist through Change, and reach the report.
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 1000 })
await start('Audio is wrong')
await button('Wrong language').click()
await button('No audio').click()
assert.equal(await button('Wrong language').getAttribute('aria-pressed'), 'true')
await button('Continue').click()
await visibleStep('Where did it happen?')
const before = await button('TV app').evaluate(el => getComputedStyle(el).backgroundColor)
await button('TV app').click()
await button('Web browser').click()
await button('Phone or tablet').click()
await button('Phone or tablet').click()
assert.equal(await button('TV app').getAttribute('aria-pressed'), 'true')
assert.equal(await button('Phone or tablet').getAttribute('aria-pressed'), 'false')
assert.notEqual(await button('TV app').evaluate(el => getComputedStyle(el).backgroundColor), before)
await active().getByRole('status').filter({ hasText: 'Selected: TV app, Web browser' }).waitFor()
await screenshot(`${width}-devices`)
await button('Review issue').click()
await visibleStep('Review and submit')
await screenshot(`${width}-review`)
await page.getByRole('button', { name: /^Change Where did it happen/ }).click()
await visibleStep('Where did it happen?')
assert.equal(await button('TV app').getAttribute('aria-pressed'), 'true')
await button('Review issue').click()
const report = await submit('replace', { file_ids: [77], confirmed: true })
assert.ok(report.description.includes('Devices: TV app, Web browser'))
assert.ok(report.description.includes('What needs correction: Wrong language, No audio'))
assert.ok(!report.description.includes('Phone or tablet'))
}
await page.setViewportSize({ width: 1440, height: 1000 })
// TV selection remains multi-season / multi-episode and sends only the chosen files.
await start('Picture or file is broken', series)
await button('Visual artefacts or corruption').click()
await button('Continue').click()
await visibleStep('Which seasons or episodes?')
await active().locator('.issue-episode-grid button').filter({ hasText: 'Episode 5-9' }).click()
await active().locator('.issue-season-tabs button').filter({ hasText: 'Season 6' }).click()
await active().locator('.issue-episode-grid button').filter({ hasText: 'Episode 6-10' }).click()
await screenshot('tv-targets')
await button('Continue').click()
let report = await submit('replace', { file_ids: [5009, 6010], confirmed: true })
assert.ok(report.description.includes('Episodes: S05E09, S06E10'))
// Subtitle issues must never replace a video; optional device choices can be skipped.
await start('Subtitles are wrong')
await button('Subtitles are missing').click()
await button('Continue').click()
await button('TV app').click()
await button('Skip').click()
report = await submit('repair-subtitles', { episode_ids: [], forced: false })
assert.ok(!report.description.includes('Devices:'))
// A missing movie does not require a nonexistent movie file or a second movie selection.
hasFile = false
await start('Movie or episode is missing')
await button('Movie is missing').click()
await button('Continue').click()
await submit('search-missing', { episode_ids: [], season_numbers: [] })
await start('Movie or episode is missing', series)
await button('Season is missing').click()
await button('Continue').click()
for (const season of ['Season 5', 'Season 6']) await active().locator('.issue-season-grid button').filter({ hasText: season }).click()
await button('Continue').click()
await submit('search-missing', { episode_ids: [509, 510, 609, 610], season_numbers: [5, 6] })
// A repair without a file is blocked, with a click-through to the missing-content path.
await start('Audio is wrong')
await button('No audio').click()
assert.equal(await button('Continue').isDisabled(), true)
await button('Report missing movie instead').click()
await button('Movie is missing').click()
await button('Continue').click()
await submit('search-missing', { episode_ids: [], season_numbers: [] })
hasFile = true
canAct = false
await start('Wrong thing downloaded')
await button('Different movie or show').click()
await button('Continue').click()
await submit(null)
canAct = true
// Device choices + a down server are attached to the report, without replacing media.
serverDown = true
await start('Playback or transcoding problem')
await button('Transcode error').click()
await button('Continue').click()
await button('Multiple devices').click()
await button('Review issue').click()
report = await submit(null)
assert.equal(report.issue_type, 'transcode')
assert.ok(report.description.includes('Devices: Multiple devices'))
assert.ok(report.description.includes('Media server check: Server unavailable'))
// Returning to an earlier step while a lookup is pending cannot reopen a stale result.
optionDelay = 500
await page.goto(base + '/portal/issues')
await active().getByRole('button').filter({ hasText: 'Audio is wrong' }).click()
await active().getByPlaceholder('Search the Grizzlyflix catalogue').fill(movie.title)
currentMedia = movie
await button('Search').click()
await active().locator('.issue-media-result').click()
await page.getByRole('button', { name: /^Change What is wrong/ }).click()
await page.waitForTimeout(700)
await visibleStep('What is wrong?')
assert.deepEqual(errors, [])
console.log('PASS: progressive issue flow, movie auto-selection, visible multi-device toggles, saved report data, TV multi-selection, subtitle-only fixes, permissions, missing files, and stale lookup protection. All APIs mocked.')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })