diff --git a/frontend/UI.md b/frontend/UI.md index ef25e12..b10f540 100644 --- a/frontend/UI.md +++ b/frontend/UI.md @@ -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. diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 5ef0371..d9ebafe 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -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' diff --git a/frontend/app/portal/IssueFlowStep.tsx b/frontend/app/portal/IssueFlowStep.tsx new file mode 100644 index 0000000..2f4a576 --- /dev/null +++ b/frontend/app/portal/IssueFlowStep.tsx @@ -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(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 ( +
+ {active ? ( + <> +
+ +

{title}

+
+
{children}
+ + ) : ( + + )} +
+ ) +} diff --git a/frontend/app/portal/PortalClient.tsx b/frontend/app/portal/PortalClient.tsx index a7e450d..130f5b7 100644 --- a/frontend/app/portal/PortalClient.tsx +++ b/frontend/app/portal/PortalClient.tsx @@ -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 = { } 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 = { general: 'General', @@ -502,7 +504,8 @@ export default function PortalClient({ workspace }: PortalClientProps) { const [issueOptions, setIssueOptions] = useState(null) const [issueOptionsLoading, setIssueOptionsLoading] = useState(false) const [issueOptionsMessage, setIssueOptionsMessage] = useState(null) - const [movieTargetSelected, setMovieTargetSelected] = useState(false) + const [issueStep, setIssueStep] = useState('problem') + const issueOptionsVersion = useRef(0) const [activeSeasonNumber, setActiveSeasonNumber] = useState(null) const [selectedSeasonNumbers, setSelectedSeasonNumbers] = useState([]) const [selectedEpisodeIds, setSelectedEpisodeIds] = useState([]) @@ -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>, ) => { - 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): Promise => { @@ -988,6 +1017,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { const createGuidedIssue = async (event: React.FormEvent) => { event.preventDefault() + if (creating || issueStep !== 'review') return if (!selectedIssueDefinition || !issueCategory) { setError('Choose the problem that best matches what you are seeing.') return @@ -1002,8 +1032,8 @@ export default function PortalClient({ workspace }: PortalClientProps) { return } const isMovie = issueOptions.request_type === 'movie' - if (isMovie && !movieTargetSelected) { - setError('Select the movie to continue.') + if (isMovie && !movieTargetAvailable) { + setError('There is no managed movie file available for this repair. Report it as missing instead.') return } if (!isMovie && issueCategory === 'missing_content' && missingSeasons && selectedSeasonNumbers.length === 0) { @@ -1142,7 +1172,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { setIssueSelectedMedia(null) setIssueOptions(null) setIssueOptionsMessage(null) - setMovieTargetSelected(false) + setIssueStep('problem') setActiveSeasonNumber(null) setSelectedSeasonNumbers([]) setSelectedEpisodeIds([]) @@ -1565,346 +1595,370 @@ export default function PortalClient({ workspace }: PortalClientProps) { ) : ( -
-
- 01 -
- Choose a symptom -

Which best describes the problem?

-

Only the questions needed for that problem will appear next.

-
-
-
- {ISSUE_CATEGORIES.map((category) => ( - - ))} -
- - {selectedIssueDefinition && issueCategory ? ( -
-
- 02 -
- Narrow it down -

Tell us what is affected

-

Magent will attach these details to the issue so nobody has to ask for them again.

+
+ +
+ +
+ {ISSUE_CATEGORIES.map((category) => ( + + ))}
-
+ -
- {issueNeedsMediaTitle ? ( -
- - {issueMediaResults.length > 0 ? ( -
- {issueMediaResults.map((media, index) => { - const poster = resolveTmdbArtworkUrl(media.posterPath, 'w185') - return ( - - ) - })} -
- ) : null} - {issueSelectedMedia ? ( -
-
- Selected title - {issueSelectedMedia.title}{issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ''} - - {issueSelectedMedia.type === 'tv' ? 'TV show' : 'Movie'} - {issueSelectedMedia.requestId - ? ` · Magent request #${issueSelectedMedia.requestId} · ${issueSelectedMedia.statusLabel ?? 'Tracked'}` - : ' · No existing Magent request'} - -
- {issueSelectedMedia.requestId ? ( - +
+ + {issueMediaResults.length > 0 ? ( +
+ {issueMediaResults.map((media, index) => { + const poster = resolveTmdbArtworkUrl(media.posterPath, 'w185') + return ( + + ) + })} +
+ ) : null} + {issueSelectedMedia ? ( +
+
+ Selected title + {issueSelectedMedia.title}{issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ''} + + {issueSelectedMedia.type === 'tv' ? 'TV show' : 'Movie'} + {issueSelectedMedia.requestId + ? ` · Magent request #${issueSelectedMedia.requestId} · ${issueSelectedMedia.statusLabel ?? 'Tracked'}` + : ' · No existing Magent request'} + +
+ {issueSelectedMedia.requestId ? ( + + ) : null} +
+ ) : null} + + {issueOptionsLoading ? ( +
{issueSelectedMedia?.type === 'movie' ? 'Checking the movie file' : 'Reading seasons and episodes'}
+ ) : null} + {issueOptionsMessage && !issueOptions ?
{issueOptionsMessage}
: null} +
+ {issueSelectedMedia && !issueOptionsLoading ? ( +
+ {issueOptions ? ( + + ) : issueSelectedMedia.requestId ? ( + ) : null}
) : null} + - {issueOptionsLoading ? ( -
Reading seasons and episodes
- ) : null} - {issueOptionsMessage && !issueOptions ?
{issueOptionsMessage}
: null} - - {issueOptions ? ( -
+ {issueOptions ? ( + <> +
- What needs to be corrected? + Choose all that apply
{(issueOptions.request_type === 'movie' && issueCategory === 'missing_content' ? ['Entire title is missing'] : ISSUE_SYMPTOMS[issueCategory]).map((symptom) => { - const selected = issueSymptoms.includes(symptom) - return ( - - ) - })} + }} + > + + {issueOptions.request_type === 'movie' && symptom === 'Entire title is missing' ? 'Movie is missing' : symptom} + + ) + })}
- - {issueSymptoms.length > 0 && issueOptions.request_type === 'movie' ? ( - + {issueOptions.request_type === 'movie' && !movieTargetAvailable ? ( +
+ No managed movie file is available to repair. + +
) : null} +
+ +
+
- {issueSymptoms.length > 0 && issueOptions.request_type === 'tv' && !missingEntireTitle ? ( -
-
- {missingSeasons ? 'Choose the missing seasons' : 'Choose a season'} - You can select more than one. -
- {missingSeasons ? ( -
- {issueOptions.seasons.map((season) => { - const selected = selectedSeasonNumbers.includes(season.season_number) - return ( - - ) - })} + {issueNeedsTvTargets ? ( + `Season ${season}`), + ...selectedEpisodeOptions.map((episode) => episode.code), + ].join(', ')}> + {issueSymptoms.length > 0 && issueOptions.request_type === 'tv' && !missingEntireTitle ? ( +
+
+ {missingSeasons ? 'Choose the missing seasons' : 'Choose a season'} + You can select more than one.
- ) : null} - - {(issueCategory !== 'missing_content' || missingEpisodes) ? ( -
- {issueOptions.seasons.map((season) => ( - - ))} -
- ) : null} - - {(issueCategory !== 'missing_content' || missingEpisodes) && activeSeasonNumber !== null ? ( -
- {issueOptions.episodes - .filter((episode) => episode.season_number === activeSeasonNumber && episode.released) - .map((episode) => { - const selected = selectedEpisodeIds.includes(episode.id) - const disabled = issueRequiresExistingFile && !episode.has_file + {missingSeasons ? ( +
+ {issueOptions.seasons.map((season) => { + const selected = selectedSeasonNumbers.includes(season.season_number) return ( ) })} -
- ) : null} -
- ) : null} -
- ) : null} -
- ) : null} - {issueTargetReady && (issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? ( -
- Where did it happen? Choose all that apply -
- {DEVICE_OPTIONS.map((device) => ( - - ))} -
-
- ) : null} -
+
+ ) : null} - {issueNeedsServerCheck && issueTargetReady ? ( -
-
-
- Live media-server check -

- {mediaServerChecking - ? 'Checking Jellyfin now...' - : mediaServerStatus?.headline ?? 'Server check unavailable'} -

-
- -
- {mediaServerChecking ?
Contacting the media server
: null} - {mediaServerError ?
{mediaServerError}
: null} - {mediaServerStatus ? ( - <> -

{mediaServerStatus.message}

-
-
Server API{mediaServerStatus.status === 'down' ? 'Unavailable' : 'Responding'}
-
Response{mediaServerStatus.latency_ms ?? '--'} ms
-
Active streams{mediaServerStatus.activity?.available ? mediaServerStatus.activity.active_streams ?? 0 : '--'}
-
Transcoding{mediaServerStatus.activity?.available ? mediaServerStatus.activity.transcoding_streams ?? 0 : '--'}
-
+ {(issueCategory !== 'missing_content' || missingEpisodes) ? ( +
+ {issueOptions.seasons.map((season) => ( + + ))} +
+ ) : null} + + {(issueCategory !== 'missing_content' || missingEpisodes) && activeSeasonNumber !== null ? ( +
+ {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 ( + + ) + })} +
+ ) : null} +
+ ) : null} +
+ + {selectedSeasonNumbers.length ? `${selectedSeasonNumbers.length} season(s) selected` : ''} + {selectedSeasonNumbers.length && selectedEpisodeIds.length ? ' · ' : ''} + {selectedEpisodeIds.length ? `${selectedEpisodeIds.length} episode(s) selected` : ''} + + +
+ + ) : null} + + {issueNeedsDevices ? ( + + {issueTargetReady && (issueNeedsServerCheck || issueCategory === 'audio' || issueCategory === 'subtitle') ? ( +
+ Choose all that apply +
+ {DEVICE_OPTIONS.map((device) => ( + + ))} +
+

+ {issueDevices.length ? `Selected: ${issueDevices.join(', ')}` : 'Optional — these choices will be included in your report.'} +

+
+ ) : null} +
+ + +
+
+ ) : null} + + + {issueNeedsServerCheck && issueTargetReady ? ( +
+
+
+ Live media-server check +

+ {mediaServerChecking + ? 'Checking Jellyfin now...' + : mediaServerStatus?.headline ?? 'Server check unavailable'} +

+
+ +
+ {mediaServerChecking ?
Contacting the media server
: null} + {mediaServerError ?
{mediaServerError}
: null} + {mediaServerStatus ? ( + <> +

{mediaServerStatus.message}

+
+
Server API{mediaServerStatus.status === 'down' ? 'Unavailable' : 'Responding'}
+
Response{mediaServerStatus.latency_ms ?? '--'} ms
+
Active streams{mediaServerStatus.activity?.available ? mediaServerStatus.activity.active_streams ?? 0 : '--'}
+
Transcoding{mediaServerStatus.activity?.available ? mediaServerStatus.activity.transcoding_streams ?? 0 : '--'}
+
+ + ) : null} +
+ ) : null} + {issueTargetReady ?
+
+ What will happen +

+ {issueOptions?.can_act === false + ? 'The selected details will be sent to support.' + : selectedIssueDefinition.outcome} +

+

+ {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.'} +

+
+ +
: null} +
) : null} -
+ ) : null} - - {issueTargetReady ?
- 03 -
- What will happen -

- {issueOptions?.can_act === false - ? 'The selected details will be sent to support.' - : selectedIssueDefinition.outcome} -

-

- {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.'} -

-
- -
: null} - - ) : null} + + )} diff --git a/frontend/app/portal/issue-flow.css b/frontend/app/portal/issue-flow.css new file mode 100644 index 0000000..7762b54 --- /dev/null +++ b/frontend/app/portal/issue-flow.css @@ -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; } } diff --git a/scripts/review_issue_flow_ui.cjs b/scripts/review_issue_flow_ui.cjs new file mode 100644 index 0000000..5f6ad09 --- /dev/null +++ b/scripts/review_issue_flow_ui.cjs @@ -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 })