feat: route ready-title issues through guided workflow
This commit is contained in:
@@ -3656,48 +3656,35 @@ textarea:focus {
|
||||
margin-top: 5px;
|
||||
}
|
||||
.request-problem-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
border: 1px solid rgba(126, 215, 255, 0.36);
|
||||
border-radius: var(--ops-radius);
|
||||
background: rgba(79, 70, 229, 0.18);
|
||||
color: var(--ops-text);
|
||||
font-weight: 750;
|
||||
text-decoration: none;
|
||||
}
|
||||
.request-problem-button:hover { border-color: rgba(126, 215, 255, 0.7); background: rgba(79, 70, 229, 0.28); }
|
||||
.request-ready-unavailable { margin-top: 5px; padding: 12px; border: 1px solid var(--ops-line); border-radius: var(--ops-radius); color: var(--ops-muted); font-size: 0.75rem; }
|
||||
|
||||
.request-issue-dialog {
|
||||
width: min(620px, calc(100vw - 28px));
|
||||
max-height: min(760px, calc(100vh - 28px));
|
||||
margin: auto;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--ops-line);
|
||||
border-radius: 12px;
|
||||
background: #201f21;
|
||||
color: var(--ops-text);
|
||||
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.58);
|
||||
.issue-prefilled-request {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(126, 215, 255, 0.3);
|
||||
border-radius: 9px;
|
||||
background: rgba(14, 165, 233, 0.07);
|
||||
}
|
||||
.request-issue-dialog::backdrop { background: rgba(5, 5, 8, 0.76); backdrop-filter: blur(5px); }
|
||||
.request-issue-dialog form { display: grid; gap: 18px; padding: 22px; }
|
||||
.request-issue-dialog form > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||
.request-issue-dialog h2 { margin: 3px 0 0; font-size: 1.55rem; }
|
||||
.request-issue-media { display: grid; gap: 4px; padding: 14px; border: 1px solid var(--ops-line); border-radius: 9px; background: var(--ops-panel-2); }
|
||||
.request-issue-media > span,
|
||||
.request-issue-media > small { color: var(--ops-muted); font-size: 0.7rem; }
|
||||
.request-issue-media > strong { font-size: 1rem; }
|
||||
.request-issue-dialog label { display: grid; gap: 8px; color: var(--ops-text); font-size: 0.8rem; font-weight: 750; }
|
||||
.request-issue-dialog textarea { width: 100%; resize: vertical; border-color: var(--ops-line); background: var(--ops-panel-2); color: var(--ops-text); line-height: 1.5; }
|
||||
.request-issue-help { margin: -10px 0 0; color: var(--ops-muted); font-size: 0.72rem; }
|
||||
.request-issue-dialog footer { display: flex; justify-content: flex-end; }
|
||||
.request-issue-dialog footer > button,
|
||||
.request-issue-success a { min-height: 42px; padding: 10px 18px; }
|
||||
.request-issue-success { display: grid; justify-items: center; gap: 12px; padding: 34px 24px; text-align: center; }
|
||||
.request-issue-success > span { display: grid; width: 52px; height: 52px; place-items: center; border: 1px solid rgba(72, 224, 178, 0.5); border-radius: 50%; color: var(--request-green); font-size: 1.5rem; }
|
||||
.request-issue-success h2,
|
||||
.request-issue-success p { margin: 0; }
|
||||
.request-issue-success p { max-width: 50ch; color: var(--ops-muted); line-height: 1.55; }
|
||||
.request-issue-success > div { display: flex; gap: 10px; margin-top: 8px; }
|
||||
.request-issue-success a { display: inline-flex; align-items: center; border-radius: var(--ops-radius); background: var(--ops-primary); color: var(--ops-primary-2); font-weight: 750; text-decoration: none; }
|
||||
.issue-prefilled-request > div { display: grid; gap: 4px; }
|
||||
.issue-prefilled-request strong { color: var(--ops-text); font-size: 1rem; }
|
||||
.issue-prefilled-request small { color: var(--ops-muted); }
|
||||
.issue-prefilled-request > a { flex: 0 0 auto; text-decoration: none; }
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.global-search { flex-basis: 190px; width: 190px; }
|
||||
@@ -3726,9 +3713,8 @@ textarea:focus {
|
||||
.request-ready-actions { grid-template-columns: 1fr; }
|
||||
.request-ready-actions > section + section { border-top: 1px solid var(--ops-line-soft); border-left: 0; }
|
||||
.request-ready-actions > section > p { min-height: 0; }
|
||||
.request-issue-dialog form { padding: 18px; }
|
||||
.request-issue-success > div { width: 100%; flex-direction: column; }
|
||||
.request-issue-success a { justify-content: center; }
|
||||
.issue-prefilled-request { align-items: stretch; flex-direction: column; }
|
||||
.issue-prefilled-request > a { text-align: center; }
|
||||
}
|
||||
|
||||
/* Guided issue reporting */
|
||||
|
||||
@@ -586,13 +586,30 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
const raw = new URLSearchParams(window.location.search).get('item')
|
||||
if (!raw) {
|
||||
setPreselectedItemId(null)
|
||||
return
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const raw = params.get('item')
|
||||
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN
|
||||
setPreselectedItemId(Number.isNaN(parsed) || parsed <= 0 ? null : parsed)
|
||||
|
||||
const requestId = Number.parseInt(params.get('reportRequest') ?? '', 10)
|
||||
const title = params.get('title')?.trim()
|
||||
const type = params.get('type')
|
||||
const rawYear = Number.parseInt(params.get('year') ?? '', 10)
|
||||
if (requestId > 0 && title && (type === 'movie' || type === 'tv')) {
|
||||
const media: DiscoveryResult = {
|
||||
title,
|
||||
year: rawYear >= 1870 && rawYear <= 2200 ? rawYear : null,
|
||||
type,
|
||||
requestId,
|
||||
statusLabel: 'Ready to watch',
|
||||
accessible: true,
|
||||
}
|
||||
setIssueSelectedMedia(media)
|
||||
setIssueMediaTitle(media.title)
|
||||
setIssueMediaType(type)
|
||||
setIssueMediaQuery(`${media.title}${media.year ? ` (${media.year})` : ''}`)
|
||||
setIssueStep('problem')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadMe = async () => {
|
||||
@@ -860,6 +877,14 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
}
|
||||
const payload = await response.json() as IssueTargetOptions
|
||||
if (version !== issueOptionsVersion.current) return
|
||||
const verifiedMedia: DiscoveryResult = {
|
||||
...media,
|
||||
title: payload.title,
|
||||
type: payload.request_type,
|
||||
}
|
||||
setIssueSelectedMedia(verifiedMedia)
|
||||
setIssueMediaTitle(verifiedMedia.title)
|
||||
setIssueMediaType(payload.request_type)
|
||||
setIssueOptions(payload)
|
||||
setIssueOptionsMessage(payload.message ?? null)
|
||||
const firstSeason = payload.seasons.find((season) => season.season_number > 0) ?? payload.seasons[0]
|
||||
@@ -981,12 +1006,14 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
setMediaServerError(null)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
const shouldLoadSelectedRequest = Boolean(issueSelectedMedia?.requestId && !issueOptions)
|
||||
setIssueStep(issueSelectedMedia && issueOptions ? 'symptoms' : 'media')
|
||||
setSelectedSeasonNumbers([])
|
||||
setSelectedEpisodeIds([])
|
||||
if (category === 'playback' || category === 'service_unavailable') {
|
||||
void checkMediaServer()
|
||||
}
|
||||
if (shouldLoadSelectedRequest && issueSelectedMedia) void loadIssueOptions(issueSelectedMedia)
|
||||
}
|
||||
|
||||
const toggleStringChoice = (
|
||||
@@ -1607,6 +1634,12 @@ export default function PortalClient({ workspace }: PortalClientProps) {
|
||||
<form className="issue-guided-form" onSubmit={createGuidedIssue}>
|
||||
<fieldset className="issue-wizard-fields" disabled={creating}>
|
||||
<IssueFlowStep {...stepProps('problem')} title="What is wrong?" summary={selectedIssueDefinition?.label ?? ''}>
|
||||
{issueSelectedMedia?.requestId ? (
|
||||
<div className="issue-prefilled-request">
|
||||
<div><span className="section-kicker">Reporting a problem with</span><strong>{issueSelectedMedia.title}{issueSelectedMedia.year ? ` (${issueSelectedMedia.year})` : ''}</strong><small>Request #{issueSelectedMedia.requestId} is already selected.</small></div>
|
||||
<a className="ghost-button" href={`/requests/${issueSelectedMedia.requestId}`}>Back to request</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="issue-category-grid">
|
||||
{ISSUE_CATEGORIES.map((category) => (
|
||||
<button
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
import { lockBodyScroll } from '../../lib/scrollLock'
|
||||
|
||||
type Props = {
|
||||
requestId: string
|
||||
title: string
|
||||
year?: number
|
||||
requestType: string
|
||||
}
|
||||
|
||||
const readError = async (response: Response) => {
|
||||
try {
|
||||
const payload = await response.json()
|
||||
if (typeof payload?.detail === 'string' && payload.detail.trim()) return payload.detail
|
||||
} catch {
|
||||
// Use the friendly fallback below.
|
||||
}
|
||||
return 'Your report could not be submitted. Please try again.'
|
||||
}
|
||||
|
||||
export default function RequestIssueDialog({ requestId, title, year, requestType }: Props) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const detailsInput = useRef<HTMLTextAreaElement>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [details, setDetails] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [issueId, setIssueId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const previous = document.activeElement as HTMLElement | null
|
||||
dialog.current?.showModal()
|
||||
const unlock = lockBodyScroll()
|
||||
window.setTimeout(() => detailsInput.current?.focus(), 0)
|
||||
return () => {
|
||||
dialog.current?.close()
|
||||
unlock()
|
||||
previous?.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const close = () => {
|
||||
if (submitting) return
|
||||
setOpen(false)
|
||||
setError(null)
|
||||
if (issueId) {
|
||||
setIssueId(null)
|
||||
setDetails('')
|
||||
}
|
||||
}
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const description = details.trim()
|
||||
if (!description) {
|
||||
setError('Tell us what is wrong so we know what to check.')
|
||||
detailsInput.current?.focus()
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/portal/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kind: 'issue',
|
||||
title: `Problem with ${title}`,
|
||||
description,
|
||||
media_type: requestType === 'tv' ? 'tv' : 'movie',
|
||||
year: year ?? null,
|
||||
external_ref: `/requests/${requestId}`,
|
||||
issue_type: 'general',
|
||||
priority: 'normal',
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(await readError(response))
|
||||
const payload = await response.json()
|
||||
const createdId = Number(payload?.item?.id)
|
||||
if (!Number.isInteger(createdId) || createdId <= 0) throw new Error('The issue was created, but its reference could not be loaded.')
|
||||
setIssueId(createdId)
|
||||
} catch (caught) {
|
||||
console.error(caught)
|
||||
setError(caught instanceof Error ? caught.message : 'Your report could not be submitted. Please try again.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<button type="button" className="request-problem-button" onClick={() => setOpen(true)}>Tell us what's wrong</button>
|
||||
<dialog ref={dialog} className="request-issue-dialog" aria-labelledby="request-issue-title" onCancel={(event) => { event.preventDefault(); close() }}>
|
||||
{issueId ? (
|
||||
<div className="request-issue-success" role="status">
|
||||
<span aria-hidden="true">✓</span>
|
||||
<h2 id="request-issue-title">Thanks, we've got it</h2>
|
||||
<p>Your report is now issue #{issueId}. You can follow its progress from the Issues page.</p>
|
||||
<div><a href={`/portal/issues?item=${issueId}`}>View reported issue</a><button type="button" className="ghost-button" onClick={close}>Done</button></div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit}>
|
||||
<header>
|
||||
<div><span className="section-kicker">Report a problem</span><h2 id="request-issue-title">What's wrong?</h2></div>
|
||||
<button type="button" className="ghost-button" onClick={close} disabled={submitting}>Close</button>
|
||||
</header>
|
||||
<div className="request-issue-media"><span>{requestType === 'tv' ? 'TV show' : 'Movie'}</span><strong>{title}{year ? ` (${year})` : ''}</strong><small>Request #{requestId} · Ready to watch</small></div>
|
||||
<label htmlFor="request-issue-details">Tell us what happened<textarea ref={detailsInput} id="request-issue-details" rows={6} maxLength={10000} value={details} onChange={(event) => setDetails(event.target.value)} placeholder="For example: it won’t play, the audio is wrong, or the wrong version is showing…" disabled={submitting} /></label>
|
||||
<p className="request-issue-help">The title and request details are included automatically.</p>
|
||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||
<footer><button type="submit" disabled={submitting || !details.trim()}>{submitting ? 'Sending report…' : 'Send report'}</button></footer>
|
||||
</form>
|
||||
)}
|
||||
</dialog>
|
||||
</>
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import PageHeading from '../../ui/PageHeading'
|
||||
import RequestLanguage from './RequestLanguage'
|
||||
import { lockBodyScroll } from '../../lib/scrollLock'
|
||||
import LatestActivity from './LatestActivity'
|
||||
import RequestIssueDialog from './RequestIssueDialog'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
@@ -542,6 +541,13 @@ export default function RequestTimelinePage() {
|
||||
? availableStage.link
|
||||
: null
|
||||
const requestComplete = ['COMPLETED', 'AVAILABLE'].includes(snapshot.state) || availableStage?.state === 'complete'
|
||||
const issueReportParams = new URLSearchParams({
|
||||
reportRequest: snapshot.request_id,
|
||||
title: snapshot.title,
|
||||
type: snapshot.request_type === 'tv' ? 'tv' : 'movie',
|
||||
})
|
||||
if (snapshot.year) issueReportParams.set('year', String(snapshot.year))
|
||||
const issueReportLink = `/portal/issues?${issueReportParams.toString()}`
|
||||
const statusLabel = presentation.status?.label ?? fallbackStatusLabel(snapshot.state)
|
||||
const statusMeaning = presentation.status?.meaning ?? snapshot.state_reason ?? 'Magent is checking this request.'
|
||||
const download = presentation.download
|
||||
@@ -833,7 +839,7 @@ export default function RequestTimelinePage() {
|
||||
<span className="request-overview-label">Need help?</span>
|
||||
<strong>Is there a problem with this?</strong>
|
||||
<p>Let us know what is wrong and we'll attach the title and request details automatically.</p>
|
||||
{canReportIssues ? <RequestIssueDialog requestId={snapshot.request_id} title={snapshot.title} year={snapshot.year} requestType={snapshot.request_type} /> : <span className="request-ready-unavailable">Issue reporting is not enabled for your account.</span>}
|
||||
{canReportIssues ? <a className="request-problem-button" href={issueReportLink}>Start issue report <span aria-hidden="true">→</span></a> : <span className="request-ready-unavailable">Issue reporting is not enabled for your account.</span>}
|
||||
</section>
|
||||
</div>
|
||||
) : <>
|
||||
|
||||
@@ -7,7 +7,7 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114';
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
|
||||
const writes = [];
|
||||
const calls = [];
|
||||
const errors = [];
|
||||
await context.route('**/api/**', async route => {
|
||||
const request = route.request();
|
||||
@@ -28,15 +28,31 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114';
|
||||
},
|
||||
});
|
||||
if (path === '/api/requests/42/language') return reply({ language: null });
|
||||
if (path === '/api/requests/42/issue-options') return reply({
|
||||
request_id: '42', request_type: 'movie', title: 'Drive', can_act: true,
|
||||
movie: { selected_label: 'Drive', has_file: true, missing: false, best_fit: true, file_id: 77 },
|
||||
seasons: [], episodes: [],
|
||||
});
|
||||
if (path === '/api/portal/items' && request.method() === 'POST') {
|
||||
writes.push(request.postDataJSON());
|
||||
calls.push({ path, body: request.postDataJSON() });
|
||||
return reply({ item: { id: 73 } });
|
||||
}
|
||||
return reply({});
|
||||
if (path === '/api/requests/42/actions/replace' && request.method() === 'POST') {
|
||||
calls.push({ path, body: request.postDataJSON() });
|
||||
return reply({ status: 'ok', message: 'Replacement queued.' });
|
||||
}
|
||||
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 visibleStep = async title => {
|
||||
await active().getByRole('heading', { name: title, exact: true }).waitFor();
|
||||
assert.equal(await page.locator('.issue-procedure-step.is-current').count(), 1);
|
||||
};
|
||||
for (const width of [1440, 390]) {
|
||||
await page.setViewportSize({ width, height: 950 });
|
||||
await page.goto(base + '/requests/42');
|
||||
@@ -50,13 +66,20 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114';
|
||||
await page.getByRole('option', { name: /Drive.*Ready to watch/ }).waitFor();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.getByRole('button', { name: "Tell us what's wrong" }).click();
|
||||
const dialog = page.getByRole('dialog', { name: "What's wrong?" });
|
||||
await dialog.getByText('Drive (2011)', { exact: true }).waitFor();
|
||||
await dialog.getByLabel('Tell us what happened').fill('The movie freezes at 42 minutes.');
|
||||
await dialog.getByRole('button', { name: 'Send report' }).click();
|
||||
await page.getByRole('heading', { name: "Thanks, we've got it" }).waitFor();
|
||||
await page.getByRole('button', { name: 'Done' }).click();
|
||||
await page.getByRole('link', { name: /Start issue report/ }).click();
|
||||
await page.waitForURL('**/portal/issues?*');
|
||||
assert.equal(new URL(page.url()).searchParams.get('reportRequest'), '42');
|
||||
await visibleStep('What is wrong?');
|
||||
await active().locator('.issue-prefilled-request').getByText('Drive (2011)', { exact: true }).waitFor();
|
||||
await active().locator('.issue-prefilled-request').getByText(/Request #42 is already selected/).waitFor();
|
||||
|
||||
await active().getByRole('button').filter({ hasText: 'Picture or file is broken' }).click();
|
||||
await visibleStep('What needs to be corrected?');
|
||||
await active().getByRole('button', { name: 'Visual artefacts or corruption', exact: true }).click();
|
||||
await active().getByRole('button', { name: 'Continue', exact: true }).click();
|
||||
await visibleStep('Review and submit');
|
||||
await active().getByRole('button', { name: /Submit/ }).click();
|
||||
await visibleStep('What is wrong?');
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
|
||||
if (width === 1440) {
|
||||
@@ -70,10 +93,22 @@ const base = process.env.REVIEW_BASE || 'http://localhost:3114';
|
||||
await page.getByRole('heading', { name: 'View stats & requests' }).waitFor();
|
||||
await page.getByText('Check your viewing stats, follow your requests, or make a new one.', { exact: true }).waitFor();
|
||||
|
||||
assert.equal(writes.length, 2);
|
||||
assert(writes.every(write => write.kind === 'issue' && write.title === 'Problem with Drive' && write.external_ref === '/requests/42' && write.description === 'The movie freezes at 42 minutes.'));
|
||||
const issueCalls = calls.filter(call => call.path === '/api/portal/items');
|
||||
const replaceCalls = calls.filter(call => call.path === '/api/requests/42/actions/replace');
|
||||
assert.equal(issueCalls.length, 2);
|
||||
assert.equal(replaceCalls.length, 2);
|
||||
assert(issueCalls.every(({ body }) =>
|
||||
body.kind === 'issue'
|
||||
&& body.title === 'Replace media: Drive'
|
||||
&& body.issue_type === 'broken_media'
|
||||
&& body.external_ref === '/requests/42'
|
||||
&& body.description.includes('Problem: Picture or file is broken')
|
||||
&& body.description.includes('What needs correction: Visual artefacts or corruption')
|
||||
&& body.description.includes('Magent request: #42')));
|
||||
assert(replaceCalls.every(({ body }) =>
|
||||
JSON.stringify(body) === JSON.stringify({ issue_id: 73, file_ids: [77], confirmed: true })));
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('Passed: completed request actions, linked issue dialog, global search, navigation order, welcome copy, and desktop/mobile overflow. APIs intercepted.');
|
||||
console.log('Passed: completed requests hand off to the guided issue pipeline, create linked issues, start repairs, and preserve global navigation on desktop/mobile. APIs intercepted.');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user