Streamline issue wizard and make selections explicit
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,15 +1595,10 @@ 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>
|
||||
<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
|
||||
@@ -1588,21 +1613,12 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</IssueFlowStep>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="issue-question-grid">
|
||||
{issueNeedsMediaTitle ? (
|
||||
<div className="issue-media-finder issue-field-span-2">
|
||||
<>
|
||||
<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">
|
||||
@@ -1614,7 +1630,10 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
setIssueSelectedMedia(null)
|
||||
setIssueMediaTitle('')
|
||||
setIssueOptions(null)
|
||||
setMovieTargetSelected(false)
|
||||
issueOptionsVersion.current += 1
|
||||
setIssueOptionsLoading(false)
|
||||
setIssueSymptoms([])
|
||||
setIssueDevices([])
|
||||
setActiveSeasonNumber(null)
|
||||
setSelectedSeasonNumbers([])
|
||||
setSelectedEpisodeIds([])
|
||||
@@ -1678,14 +1697,26 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
) : null}
|
||||
|
||||
{issueOptionsLoading ? (
|
||||
<div className="issue-live-scan"><i /><span>Reading seasons and episodes</span></div>
|
||||
<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>
|
||||
|
||||
{issueOptions ? (
|
||||
<div className="issue-target-picker">
|
||||
<>
|
||||
<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']
|
||||
@@ -1695,6 +1726,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
<button
|
||||
key={symptom}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
className={selected ? 'is-selected' : ''}
|
||||
onClick={() => {
|
||||
if (symptom === 'Entire title is missing') {
|
||||
@@ -1721,37 +1753,33 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{selected ? '✓' : '+'}</span>
|
||||
<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>
|
||||
{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>
|
||||
</button>
|
||||
) : 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>
|
||||
|
||||
{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">
|
||||
@@ -1766,6 +1794,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
<button
|
||||
key={season.season_number}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
className={selected ? 'is-selected' : ''}
|
||||
onClick={() => setSelectedSeasonNumbers((current) => current.includes(season.season_number)
|
||||
? current.filter((value) => value !== season.season_number)
|
||||
@@ -1786,6 +1815,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
<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)}
|
||||
>
|
||||
@@ -1808,6 +1838,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
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)
|
||||
@@ -1824,29 +1855,49 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
) : 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}
|
||||
</div>
|
||||
) : 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 issue-field-span-2">
|
||||
<legend>Where did it happen? <small>Choose all that apply</small></legend>
|
||||
<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, issueDevices, setIssueDevices)}
|
||||
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">
|
||||
@@ -1877,16 +1928,14 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
) : 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>
|
||||
<h3>
|
||||
{issueOptions?.can_act === false
|
||||
? 'The selected details will be sent to support.'
|
||||
: selectedIssueDefinition.outcome}
|
||||
</h2>
|
||||
</h3>
|
||||
<p>
|
||||
{issueOptions?.can_act === false
|
||||
? 'The issue and every selection will be sent to support. Automatic fixes are not enabled for this account.'
|
||||
@@ -1900,11 +1949,16 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" disabled={creating || mediaServerChecking}>
|
||||
{creating ? 'Working...' : 'Submit and start fix'}
|
||||
{creating ? 'Working...' : issueOptions?.can_act === false || !issueSupportsReplacement && issueCategory !== 'missing_content' && issueCategory !== 'subtitle' ? 'Submit issue' : 'Submit and start fix'}
|
||||
</button>
|
||||
</section> : null}
|
||||
</form>
|
||||
</IssueFlowStep>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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; } }
|
||||
@@ -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 })
|
||||
Reference in New Issue
Block a user