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.
|
- 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.
|
- 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.
|
- 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
|
## 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_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_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.
|
- `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.
|
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 './admin/config.css'
|
||||||
import './account.css'
|
import './account.css'
|
||||||
import './workspace.css'
|
import './workspace.css'
|
||||||
|
import './portal/issue-flow.css'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import BrandingFavicon from './ui/BrandingFavicon'
|
import BrandingFavicon from './ui/BrandingFavicon'
|
||||||
import ApplicationChrome from './ui/ApplicationChrome'
|
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'
|
'use client'
|
||||||
|
|
||||||
import PageHeading from '../ui/PageHeading'
|
import PageHeading from '../ui/PageHeading'
|
||||||
|
import IssueFlowStep from './IssueFlowStep'
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
|
|
||||||
type PortalPermissions = {
|
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
|
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> = {
|
const ISSUE_TYPE_LABELS: Record<string, string> = {
|
||||||
general: 'General',
|
general: 'General',
|
||||||
@@ -502,7 +504,8 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
const [issueOptions, setIssueOptions] = useState<IssueTargetOptions | null>(null)
|
const [issueOptions, setIssueOptions] = useState<IssueTargetOptions | null>(null)
|
||||||
const [issueOptionsLoading, setIssueOptionsLoading] = useState(false)
|
const [issueOptionsLoading, setIssueOptionsLoading] = useState(false)
|
||||||
const [issueOptionsMessage, setIssueOptionsMessage] = useState<string | null>(null)
|
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 [activeSeasonNumber, setActiveSeasonNumber] = useState<number | null>(null)
|
||||||
const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState<number[]>([])
|
const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState<number[]>([])
|
||||||
const [selectedEpisodeIds, setSelectedEpisodeIds] = useState<number[]>([])
|
const [selectedEpisodeIds, setSelectedEpisodeIds] = useState<number[]>([])
|
||||||
@@ -513,7 +516,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
const workspaceLabelPlural = workspace === 'request' ? 'requests' : 'issues'
|
const workspaceLabelPlural = workspace === 'request' ? 'requests' : 'issues'
|
||||||
const selectedIssueDefinition = ISSUE_CATEGORIES.find((category) => category.id === issueCategory) ?? null
|
const selectedIssueDefinition = ISSUE_CATEGORIES.find((category) => category.id === issueCategory) ?? null
|
||||||
const issueNeedsServerCheck = issueCategory === 'playback' || issueCategory === 'service_unavailable'
|
const issueNeedsServerCheck = issueCategory === 'playback' || issueCategory === 'service_unavailable'
|
||||||
const issueNeedsMediaTitle = Boolean(issueCategory)
|
const issueNeedsDevices = issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle'
|
||||||
const issueRequiresExistingFile =
|
const issueRequiresExistingFile =
|
||||||
issueCategory === 'broken_media' ||
|
issueCategory === 'broken_media' ||
|
||||||
issueCategory === 'wrong_content' ||
|
issueCategory === 'wrong_content' ||
|
||||||
@@ -549,7 +552,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
issueSymptoms.length > 0 &&
|
issueSymptoms.length > 0 &&
|
||||||
(
|
(
|
||||||
issueOptions.request_type === 'movie'
|
issueOptions.request_type === 'movie'
|
||||||
? movieTargetSelected && movieTargetAvailable
|
? movieTargetAvailable
|
||||||
: issueCategory === 'missing_content'
|
: issueCategory === 'missing_content'
|
||||||
? (
|
? (
|
||||||
missingEntireTitle ||
|
missingEntireTitle ||
|
||||||
@@ -559,6 +562,26 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
: selectedEpisodeIds.length > 0
|
: 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(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
@@ -807,8 +830,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadIssueOptions = async (media: DiscoveryResult) => {
|
const loadIssueOptions = async (media: DiscoveryResult) => {
|
||||||
|
const version = ++issueOptionsVersion.current
|
||||||
|
setIssueOptionsLoading(false)
|
||||||
setIssueOptions(null)
|
setIssueOptions(null)
|
||||||
setMovieTargetSelected(false)
|
|
||||||
setActiveSeasonNumber(null)
|
setActiveSeasonNumber(null)
|
||||||
setSelectedSeasonNumbers([])
|
setSelectedSeasonNumbers([])
|
||||||
setSelectedEpisodeIds([])
|
setSelectedEpisodeIds([])
|
||||||
@@ -834,17 +858,20 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
throw new Error(text || 'Could not load seasons and episodes from Sonarr/Radarr.')
|
throw new Error(text || 'Could not load seasons and episodes from Sonarr/Radarr.')
|
||||||
}
|
}
|
||||||
const payload = await response.json() as IssueTargetOptions
|
const payload = await response.json() as IssueTargetOptions
|
||||||
|
if (version !== issueOptionsVersion.current) return
|
||||||
setIssueOptions(payload)
|
setIssueOptions(payload)
|
||||||
setIssueOptionsMessage(payload.message ?? null)
|
setIssueOptionsMessage(payload.message ?? null)
|
||||||
const firstSeason = payload.seasons.find((season) => season.season_number > 0) ?? payload.seasons[0]
|
const firstSeason = payload.seasons.find((season) => season.season_number > 0) ?? payload.seasons[0]
|
||||||
setActiveSeasonNumber(firstSeason?.season_number ?? null)
|
setActiveSeasonNumber(firstSeason?.season_number ?? null)
|
||||||
|
setIssueStep('symptoms')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (version !== issueOptionsVersion.current) return
|
||||||
console.error(err)
|
console.error(err)
|
||||||
setIssueOptionsMessage(
|
setIssueOptionsMessage(
|
||||||
err instanceof Error ? err.message : 'Could not load seasons and episodes from Sonarr/Radarr.'
|
err instanceof Error ? err.message : 'Could not load seasons and episodes from Sonarr/Radarr.'
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
setIssueOptionsLoading(false)
|
if (version === issueOptionsVersion.current) setIssueOptionsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,10 +885,11 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
setIssueMediaSearching(true)
|
setIssueMediaSearching(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setIssueMediaResults([])
|
setIssueMediaResults([])
|
||||||
|
issueOptionsVersion.current += 1
|
||||||
|
setIssueOptionsLoading(false)
|
||||||
setIssueSelectedMedia(null)
|
setIssueSelectedMedia(null)
|
||||||
setIssueMediaTitle('')
|
setIssueMediaTitle('')
|
||||||
setIssueOptions(null)
|
setIssueOptions(null)
|
||||||
setMovieTargetSelected(false)
|
|
||||||
setActiveSeasonNumber(null)
|
setActiveSeasonNumber(null)
|
||||||
setSelectedSeasonNumbers([])
|
setSelectedSeasonNumbers([])
|
||||||
setSelectedEpisodeIds([])
|
setSelectedEpisodeIds([])
|
||||||
@@ -907,6 +935,8 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectIssueMedia = (media: DiscoveryResult) => {
|
const selectIssueMedia = (media: DiscoveryResult) => {
|
||||||
|
setIssueSymptoms([])
|
||||||
|
setIssueDevices([])
|
||||||
setIssueSelectedMedia(media)
|
setIssueSelectedMedia(media)
|
||||||
setIssueMediaTitle(media.title)
|
setIssueMediaTitle(media.title)
|
||||||
setIssueMediaType(media.type === 'tv' ? 'tv' : 'movie')
|
setIssueMediaType(media.type === 'tv' ? 'tv' : 'movie')
|
||||||
@@ -950,7 +980,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
setMediaServerError(null)
|
setMediaServerError(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
setMovieTargetSelected(false)
|
setIssueStep(issueSelectedMedia && issueOptions ? 'symptoms' : 'media')
|
||||||
setSelectedSeasonNumbers([])
|
setSelectedSeasonNumbers([])
|
||||||
setSelectedEpisodeIds([])
|
setSelectedEpisodeIds([])
|
||||||
if (category === 'playback' || category === 'service_unavailable') {
|
if (category === 'playback' || category === 'service_unavailable') {
|
||||||
@@ -960,10 +990,9 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
|
|
||||||
const toggleStringChoice = (
|
const toggleStringChoice = (
|
||||||
value: string,
|
value: string,
|
||||||
selected: string[],
|
|
||||||
setter: React.Dispatch<React.SetStateAction<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> => {
|
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) => {
|
const createGuidedIssue = async (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
if (creating || issueStep !== 'review') return
|
||||||
if (!selectedIssueDefinition || !issueCategory) {
|
if (!selectedIssueDefinition || !issueCategory) {
|
||||||
setError('Choose the problem that best matches what you are seeing.')
|
setError('Choose the problem that best matches what you are seeing.')
|
||||||
return
|
return
|
||||||
@@ -1002,8 +1032,8 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const isMovie = issueOptions.request_type === 'movie'
|
const isMovie = issueOptions.request_type === 'movie'
|
||||||
if (isMovie && !movieTargetSelected) {
|
if (isMovie && !movieTargetAvailable) {
|
||||||
setError('Select the movie to continue.')
|
setError('There is no managed movie file available for this repair. Report it as missing instead.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!isMovie && issueCategory === 'missing_content' && missingSeasons && selectedSeasonNumbers.length === 0) {
|
if (!isMovie && issueCategory === 'missing_content' && missingSeasons && selectedSeasonNumbers.length === 0) {
|
||||||
@@ -1142,7 +1172,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
setIssueSelectedMedia(null)
|
setIssueSelectedMedia(null)
|
||||||
setIssueOptions(null)
|
setIssueOptions(null)
|
||||||
setIssueOptionsMessage(null)
|
setIssueOptionsMessage(null)
|
||||||
setMovieTargetSelected(false)
|
setIssueStep('problem')
|
||||||
setActiveSeasonNumber(null)
|
setActiveSeasonNumber(null)
|
||||||
setSelectedSeasonNumbers([])
|
setSelectedSeasonNumbers([])
|
||||||
setSelectedEpisodeIds([])
|
setSelectedEpisodeIds([])
|
||||||
@@ -1565,346 +1595,370 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<section className="issue-flow">
|
<section className="issue-flow issue-flow-progressive">
|
||||||
<div className="issue-flow-heading">
|
<form className="issue-guided-form" onSubmit={createGuidedIssue}>
|
||||||
<span className="issue-step-number">01</span>
|
<fieldset className="issue-wizard-fields" disabled={creating}>
|
||||||
<div>
|
<IssueFlowStep {...stepProps('problem')} title="What is wrong?" summary={selectedIssueDefinition?.label ?? ''}>
|
||||||
<span className="section-kicker">Choose a symptom</span>
|
<div className="issue-category-grid">
|
||||||
<h2>Which best describes the problem?</h2>
|
{ISSUE_CATEGORIES.map((category) => (
|
||||||
<p>Only the questions needed for that problem will appear next.</p>
|
<button
|
||||||
</div>
|
key={category.id}
|
||||||
</div>
|
type="button"
|
||||||
<div className="issue-category-grid">
|
className={`issue-category-card ${issueCategory === category.id ? 'is-selected' : ''}`}
|
||||||
{ISSUE_CATEGORIES.map((category) => (
|
onClick={() => chooseIssueCategory(category.id)}
|
||||||
<button
|
>
|
||||||
key={category.id}
|
<span className="issue-category-marker">{category.marker}</span>
|
||||||
type="button"
|
<strong>{category.label}</strong>
|
||||||
className={`issue-category-card ${issueCategory === category.id ? 'is-selected' : ''}`}
|
<p>{category.description}</p>
|
||||||
onClick={() => chooseIssueCategory(category.id)}
|
</button>
|
||||||
>
|
))}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</IssueFlowStep>
|
||||||
|
|
||||||
<div className="issue-question-grid">
|
{selectedIssueDefinition && issueCategory ? (
|
||||||
{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})` : ''}`}>
|
||||||
<label>
|
<div className="issue-media-finder">
|
||||||
<span>Find the exact movie or TV show</span>
|
<label>
|
||||||
<div className="issue-media-search-row">
|
<span>Find the exact movie or TV show</span>
|
||||||
<input
|
<div className="issue-media-search-row">
|
||||||
value={issueMediaQuery}
|
<input
|
||||||
onChange={(event) => {
|
value={issueMediaQuery}
|
||||||
setIssueMediaQuery(event.target.value)
|
onChange={(event) => {
|
||||||
if (issueSelectedMedia) {
|
setIssueMediaQuery(event.target.value)
|
||||||
setIssueSelectedMedia(null)
|
if (issueSelectedMedia) {
|
||||||
setIssueMediaTitle('')
|
setIssueSelectedMedia(null)
|
||||||
setIssueOptions(null)
|
setIssueMediaTitle('')
|
||||||
setMovieTargetSelected(false)
|
setIssueOptions(null)
|
||||||
setActiveSeasonNumber(null)
|
issueOptionsVersion.current += 1
|
||||||
setSelectedSeasonNumbers([])
|
setIssueOptionsLoading(false)
|
||||||
setSelectedEpisodeIds([])
|
setIssueSymptoms([])
|
||||||
}
|
setIssueDevices([])
|
||||||
}}
|
setActiveSeasonNumber(null)
|
||||||
placeholder="Search the Grizzlyflix catalogue"
|
setSelectedSeasonNumbers([])
|
||||||
onKeyDown={(event) => {
|
setSelectedEpisodeIds([])
|
||||||
if (event.key === 'Enter') {
|
}
|
||||||
event.preventDefault()
|
}}
|
||||||
void searchIssueMedia()
|
placeholder="Search the Grizzlyflix catalogue"
|
||||||
}
|
onKeyDown={(event) => {
|
||||||
}}
|
if (event.key === 'Enter') {
|
||||||
/>
|
event.preventDefault()
|
||||||
<button type="button" onClick={() => void searchIssueMedia()} disabled={issueMediaSearching}>
|
void searchIssueMedia()
|
||||||
{issueMediaSearching ? 'Searching...' : 'Search'}
|
}
|
||||||
</button>
|
}}
|
||||||
</div>
|
/>
|
||||||
</label>
|
<button type="button" onClick={() => void searchIssueMedia()} disabled={issueMediaSearching}>
|
||||||
{issueMediaResults.length > 0 ? (
|
{issueMediaSearching ? 'Searching...' : 'Search'}
|
||||||
<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>
|
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</IssueFlowStep>
|
||||||
|
|
||||||
{issueOptionsLoading ? (
|
{issueOptions ? (
|
||||||
<div className="issue-live-scan"><i /><span>Reading seasons and episodes</span></div>
|
<>
|
||||||
) : null}
|
<IssueFlowStep {...stepProps('symptoms')} title="What needs to be corrected?" summary={issueSymptoms.join(', ')}>
|
||||||
{issueOptionsMessage && !issueOptions ? <div className="status-banner">{issueOptionsMessage}</div> : null}
|
|
||||||
|
|
||||||
{issueOptions ? (
|
|
||||||
<div className="issue-target-picker">
|
|
||||||
<fieldset className="issue-choice-field">
|
<fieldset className="issue-choice-field">
|
||||||
<legend>What needs to be corrected?</legend>
|
<legend>Choose all that apply</legend>
|
||||||
<div className="issue-choice-grid">
|
<div className="issue-choice-grid">
|
||||||
{(issueOptions.request_type === 'movie' && issueCategory === 'missing_content'
|
{(issueOptions.request_type === 'movie' && issueCategory === 'missing_content'
|
||||||
? ['Entire title is missing']
|
? ['Entire title is missing']
|
||||||
: ISSUE_SYMPTOMS[issueCategory]).map((symptom) => {
|
: ISSUE_SYMPTOMS[issueCategory]).map((symptom) => {
|
||||||
const selected = issueSymptoms.includes(symptom)
|
const selected = issueSymptoms.includes(symptom)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={symptom}
|
key={symptom}
|
||||||
type="button"
|
type="button"
|
||||||
className={selected ? 'is-selected' : ''}
|
aria-pressed={selected}
|
||||||
onClick={() => {
|
className={selected ? 'is-selected' : ''}
|
||||||
if (symptom === 'Entire title is missing') {
|
onClick={() => {
|
||||||
setIssueSymptoms(selected ? [] : [symptom])
|
if (symptom === 'Entire title is missing') {
|
||||||
setSelectedSeasonNumbers([])
|
setIssueSymptoms(selected ? [] : [symptom])
|
||||||
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([])
|
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([])
|
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 aria-hidden="true">{selected ? '✓' : '+'}</span>
|
||||||
<span>{selected ? '✓' : '+'}</span>
|
<strong>{issueOptions.request_type === 'movie' && symptom === 'Entire title is missing' ? 'Movie is missing' : symptom}</strong>
|
||||||
<strong>{issueOptions.request_type === 'movie' && symptom === 'Entire title is missing' ? 'Movie is missing' : symptom}</strong>
|
</button>
|
||||||
</button>
|
)
|
||||||
)
|
})}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
{issueOptions.request_type === 'movie' && !movieTargetAvailable ? (
|
||||||
{issueSymptoms.length > 0 && issueOptions.request_type === 'movie' ? (
|
<div className="status-banner">
|
||||||
<button
|
No managed movie file is available to repair.
|
||||||
type="button"
|
<button type="button" className="ghost-button" onClick={() => chooseIssueCategory('missing_content')}>Report missing movie instead</button>
|
||||||
className={`issue-movie-target ${movieTargetSelected ? 'is-selected' : ''}`}
|
</div>
|
||||||
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>
|
|
||||||
) : null}
|
) : 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 ? (
|
{issueNeedsTvTargets ? (
|
||||||
<div className="issue-tv-targets">
|
<IssueFlowStep {...stepProps('targets')} title="Which seasons or episodes?" summary={[
|
||||||
<div className="issue-target-heading">
|
...selectedSeasonNumbers.map((season) => `Season ${season}`),
|
||||||
<strong>{missingSeasons ? 'Choose the missing seasons' : 'Choose a season'}</strong>
|
...selectedEpisodeOptions.map((episode) => episode.code),
|
||||||
<small>You can select more than one.</small>
|
].join(', ')}>
|
||||||
</div>
|
{issueSymptoms.length > 0 && issueOptions.request_type === 'tv' && !missingEntireTitle ? (
|
||||||
{missingSeasons ? (
|
<div className="issue-tv-targets">
|
||||||
<div className="issue-season-grid">
|
<div className="issue-target-heading">
|
||||||
{issueOptions.seasons.map((season) => {
|
<strong>{missingSeasons ? 'Choose the missing seasons' : 'Choose a season'}</strong>
|
||||||
const selected = selectedSeasonNumbers.includes(season.season_number)
|
<small>You can select more than one.</small>
|
||||||
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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
{missingSeasons ? (
|
||||||
|
<div className="issue-season-grid">
|
||||||
{(issueCategory !== 'missing_content' || missingEpisodes) ? (
|
{issueOptions.seasons.map((season) => {
|
||||||
<div className="issue-season-grid issue-season-tabs">
|
const selected = selectedSeasonNumbers.includes(season.season_number)
|
||||||
{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
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={episode.id}
|
key={season.season_number}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
aria-pressed={selected}
|
||||||
className={selected ? 'is-selected' : ''}
|
className={selected ? 'is-selected' : ''}
|
||||||
onClick={() => setSelectedEpisodeIds((current) => current.includes(episode.id)
|
onClick={() => setSelectedSeasonNumbers((current) => current.includes(season.season_number)
|
||||||
? current.filter((value) => value !== episode.id)
|
? current.filter((value) => value !== season.season_number)
|
||||||
: [...current, episode.id])}
|
: [...current, season.season_number])}
|
||||||
>
|
>
|
||||||
<span>{selected ? '✓' : episode.code}</span>
|
<strong>{season.label}</strong>
|
||||||
<strong>{episode.title}</strong>
|
<small>{season.missing_count} missing · {season.available_count} available</small>
|
||||||
<small>{episode.missing ? 'Missing in Sonarr' : episode.has_file ? 'Ready to select' : 'No file in Sonarr'}</small>
|
{season.best_fit ? <b>This is the best fit</b> : null}
|
||||||
{episode.best_fit ? <b>This is the best fit</b> : null}
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
|
||||||
|
|
||||||
{issueNeedsServerCheck && issueTargetReady ? (
|
{(issueCategory !== 'missing_content' || missingEpisodes) ? (
|
||||||
<section className={`media-status-check media-status-${mediaServerStatus?.status ?? 'checking'}`}>
|
<div className="issue-season-grid issue-season-tabs">
|
||||||
<div className="media-status-heading">
|
{issueOptions.seasons.map((season) => (
|
||||||
<div>
|
<button
|
||||||
<span className="section-kicker">Live media-server check</span>
|
key={season.season_number}
|
||||||
<h3>
|
type="button"
|
||||||
{mediaServerChecking
|
aria-pressed={activeSeasonNumber === season.season_number}
|
||||||
? 'Checking Jellyfin now...'
|
className={activeSeasonNumber === season.season_number ? 'is-selected' : ''}
|
||||||
: mediaServerStatus?.headline ?? 'Server check unavailable'}
|
onClick={() => setActiveSeasonNumber(season.season_number)}
|
||||||
</h3>
|
>
|
||||||
</div>
|
<strong>{season.label}</strong>
|
||||||
<button type="button" className="ghost-button" onClick={() => void checkMediaServer()} disabled={mediaServerChecking}>
|
<small>{season.episode_count} episodes</small>
|
||||||
{mediaServerChecking ? 'Checking...' : 'Check again'}
|
</button>
|
||||||
</button>
|
))}
|
||||||
</div>
|
</div>
|
||||||
{mediaServerChecking ? <div className="issue-live-scan"><i /><span>Contacting the media server</span></div> : null}
|
) : null}
|
||||||
{mediaServerError ? <div className="error-banner">{mediaServerError}</div> : null}
|
|
||||||
{mediaServerStatus ? (
|
{(issueCategory !== 'missing_content' || missingEpisodes) && activeSeasonNumber !== null ? (
|
||||||
<>
|
<div className="issue-episode-grid">
|
||||||
<p>{mediaServerStatus.message}</p>
|
{issueOptions.episodes
|
||||||
<div className="media-status-metrics">
|
.filter((episode) => episode.season_number === activeSeasonNumber && episode.released)
|
||||||
<div><span>Server API</span><strong>{mediaServerStatus.status === 'down' ? 'Unavailable' : 'Responding'}</strong></div>
|
.map((episode) => {
|
||||||
<div><span>Response</span><strong>{mediaServerStatus.latency_ms ?? '--'} ms</strong></div>
|
const selected = selectedEpisodeIds.includes(episode.id)
|
||||||
<div><span>Active streams</span><strong>{mediaServerStatus.activity?.available ? mediaServerStatus.activity.active_streams ?? 0 : '--'}</strong></div>
|
const disabled = issueRequiresExistingFile && !episode.has_file
|
||||||
<div><span>Transcoding</span><strong>{mediaServerStatus.activity?.available ? mediaServerStatus.activity.transcoding_streams ?? 0 : '--'}</strong></div>
|
return (
|
||||||
</div>
|
<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}
|
) : null}
|
||||||
</section>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
</fieldset>
|
||||||
{issueTargetReady ? <section className="issue-resolution-card">
|
</form>
|
||||||
<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}
|
|
||||||
</section>
|
</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