feat(site-builder): add in-builder Report an Issue modal

Wires the Task 18 payload builder and Task 16/17 diagnostics into a
user-facing modal, reachable from the topbar bug icon (desktop) and
overflow menu (mobile). Two gaps in the task brief's draft, not called
out there, are handled explicitly: buildReportPayload() can throw when
the payload is still oversized after canvas_state is dropped, so
submission is wrapped in try/catch with an actionable "too large" error
that preserves the user's typed text; and the textarea maxLength is
sourced from MAX_DESCRIPTION_CHARS (with a proximity character count)
instead of a hardcoded number, so the UI limit can't drift from the
payload limit.

Also portals the modal to document.body for the same stacking-context
reasons TemplateModal/HeadCodeModal already do, and guards the
useEditor() selection collector with optional chaining so it degrades
gracefully under TopBar's existing test harness (a minimal @craftjs/core
stub with no events/nodes on its collector state).

The textarea uses onInput rather than onChange: React's onChange dedup
(via its DOM value-tracker) treats a test harness's raw `el.value = x`
assignment as a no-op change, matching this repo's existing pattern
(HeadCodeModal.test.tsx, shared-controls.test.tsx, etc. all work around
the same gotcha) -- onInput is a plain passthrough with no such check,
and is behaviorally identical for real typing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:29:26 -07:00
co-authored by Claude Opus 5
parent f43a1ef872
commit d7eeff3a68
4 changed files with 504 additions and 0 deletions
@@ -0,0 +1,177 @@
import { describe, test, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
vi.mock('@craftjs/core', () => ({
useEditor: (collect?: (state: any) => any) => {
const state = { events: { selected: new Set<string>() }, nodes: {} };
return {
query: { serialize: () => '{"ROOT":{}}' },
actions: {},
...(collect ? collect(state) : {}),
};
},
}));
vi.mock('../../state/EditorConfigContext', () => ({
useEditorConfig: () => ({
whpConfig: {
apiUrl: '/panel/api/site-builder',
csrfToken: 'tok',
siteId: 42,
siteDomain: 'example.com',
},
isWHP: true,
}),
}));
vi.mock('../../state/PageContext', () => ({
usePages: () => ({
activePageId: 'home',
pages: [{ id: 'home', name: 'Home', slug: 'index', craftState: null }],
}),
}));
// Mocked (rather than using the real ring buffer) only so the added
// oversized-payload test below can force an over-cap consoleErrors array --
// every other test gets a plain empty array, same as a fresh page load.
vi.mock('../../utils/console-buffer', () => ({ getRecentConsoleErrors: vi.fn() }));
import { ReportIssueModal } from './ReportIssueModal';
import { getRecentConsoleErrors } from '../../utils/console-buffer';
let container: HTMLDivElement;
let root: Root;
function render(open = true) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(<ReportIssueModal open={open} onClose={vi.fn()} device="desktop" />);
});
}
function typeDescription(text: string) {
const ta = document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement;
act(() => {
ta.value = text;
ta.dispatchEvent(new Event('input', { bubbles: true }));
});
}
function submit() {
const btn = document.querySelector('[data-action="submit-report"]') as HTMLButtonElement;
act(() => { btn.click(); });
}
beforeEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
vi.mocked(getRecentConsoleErrors).mockReturnValue([]);
});
describe('ReportIssueModal', () => {
test('submit is disabled until a description is entered', () => {
render();
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(true);
typeDescription('something is wrong');
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(false);
});
test('posts the payload to the report_issue action with the CSRF header', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, reference: 'SB-1234', id: 1234 }),
});
vi.stubGlobal('fetch', fetchMock);
render();
typeDescription('colours do nothing');
await act(async () => { submit(); });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toContain('action=report_issue');
expect(init.method).toBe('POST');
expect(init.headers['X-CSRF-Token']).toBe('tok');
const body = JSON.parse(init.body);
expect(body.description).toBe('colours do nothing');
expect(body.category).toBe('bug');
expect(body.site_id).toBe(42);
expect(body.canvas_state).toBe('{"ROOT":{}}');
});
test('unchecking include-contents omits the canvas state', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, reference: 'SB-2', id: 2 }),
});
vi.stubGlobal('fetch', fetchMock);
render();
typeDescription('no canvas please');
const cb = document.querySelector('[data-testid="report-include-canvas"]') as HTMLInputElement;
act(() => { cb.click(); });
await act(async () => { submit(); });
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.canvas_state).toBeNull();
expect(body.canvas_state_omitted).toBe('opt-out');
});
test('shows the returned reference on success', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, reference: 'SB-1234', id: 1234 }),
}));
render();
typeDescription('x');
await act(async () => { submit(); });
expect(document.body.textContent).toContain('SB-1234');
});
test('keeps the text and shows an error when the request fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ success: false, error: 'Rate limited' }),
}));
render();
typeDescription('keep me');
await act(async () => { submit(); });
expect(document.body.textContent).toContain('Rate limited');
expect((document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement).value).toBe('keep me');
});
test('a payload still too large after dropping canvas_state shows an error instead of hanging, and never calls fetch', async () => {
// buildReportPayload throws when the body is still over MAX_PAYLOAD_BYTES
// even with canvas_state dropped -- see report-payload.ts `finalize()`.
// A huge *description* alone can't reach that path (it's truncated to
// MAX_DESCRIPTION_CHARS before the size check ever runs -- see
// report-payload.test.ts's "description size bounding" suite), so this
// forces the same defensive scenario report-payload.test.ts's "honest
// size markers" test uses: an oversized consoleErrors array, standing in
// for whatever future bug would let that much data through in practice.
// The point under test here is purely the modal's reaction to the throw,
// not how the oversized condition arises.
vi.mocked(getRecentConsoleErrors).mockReturnValue(
Array.from({ length: 5000 }, (_, i) => ({ ts: i, message: 'x'.repeat(200) })),
);
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
render();
typeDescription('this report has a huge console-error backlog attached');
await act(async () => { submit(); });
expect(fetchMock).not.toHaveBeenCalled();
expect(document.body.textContent).toMatch(/too large/i);
// The user's text must survive a rejected submission just as much as a
// server-side failure does.
expect((document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement).value).toBe(
'this report has a huge console-error backlog attached',
);
// Not stuck on "Sending..." -- the submit button is usable again.
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(false);
});
});
@@ -0,0 +1,308 @@
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useEditor } from '@craftjs/core';
import { Modal } from '../../ui/Modal';
import { useEditorConfig } from '../../state/EditorConfigContext';
import { usePages } from '../../state/PageContext';
import { buildReportPayload, MAX_DESCRIPTION_CHARS, type ReportCategory } from '../../utils/report-payload';
import { getRecentConsoleErrors } from '../../utils/console-buffer';
import { editorBuild } from '../../utils/build-stamp';
export interface ReportIssueModalProps {
open: boolean;
onClose: () => void;
device: string;
}
const CATEGORIES: { value: ReportCategory; label: string }[] = [
{ value: 'bug', label: 'Something is broken' },
{ value: 'confusing', label: 'Something is confusing' },
{ value: 'feature', label: 'I wish it could…' },
];
/** Only start showing the running character count once it's actually useful
* -- i.e. once the user is close enough to MAX_DESCRIPTION_CHARS that
* losing text is a real possibility, not on every keystroke from zero. */
const COUNTER_THRESHOLD = MAX_DESCRIPTION_CHARS - 500;
export const ReportIssueModal: React.FC<ReportIssueModalProps> = ({ open, onClose, device }) => {
const { whpConfig } = useEditorConfig();
const { activePageId, pages } = usePages();
// Guarded with optional chaining: some hosts around this component (e.g.
// TopBar's own test harness) stub `useEditor` with a minimal collector
// state that has no `events`/`nodes` at all -- this must degrade to "no
// selection known" rather than throw and take the whole topbar down.
const { query, selectedType } = useEditor((state: any) => {
const sel = state?.events?.selected;
const id = sel && sel.size > 0 ? (Array.from(sel)[0] as string) : null;
return { selectedType: id ? (state?.nodes?.[id]?.data?.displayName ?? null) : null };
});
const [category, setCategory] = useState<ReportCategory>('bug');
const [description, setDescription] = useState('');
const [includeCanvas, setIncludeCanvas] = useState(true);
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [reference, setReference] = useState('');
const [error, setError] = useState('');
const activePage = pages.find((p) => p.id === activePageId);
const reset = (): void => {
setDescription('');
setStatus('idle');
setReference('');
setError('');
};
const handleSubmit = async (): Promise<void> => {
if (!description.trim() || !whpConfig) return;
setStatus('sending');
setError('');
let canvasState: string | null = null;
try {
canvasState = query.serialize();
} catch {
// A serialize failure must not block the report -- it is often the
// very thing being reported.
canvasState = null;
}
let payload;
try {
payload = buildReportPayload({
category,
description,
includeCanvas,
siteId: whpConfig.siteId ?? null,
siteDomain: whpConfig.siteDomain ?? '',
pageId: activePageId,
pageSlug: activePage?.slug ?? '',
editorVersion: editorBuild(),
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
viewport: typeof window !== 'undefined' ? `${window.innerWidth}x${window.innerHeight}` : '',
deviceMode: device,
selectedType,
consoleErrors: getRecentConsoleErrors(),
canvasState,
});
} catch {
// buildReportPayload throws (rather than returning an oversized body)
// when the payload is still over the cap even after dropping
// canvas_state -- e.g. an enormous description. The thrown value is a
// plain Error with no discriminator, so treat ANY throw here as "too
// large" rather than string-matching the message. The user's text is
// left untouched in the textarea (state.description is never reset on
// this path) so nothing is lost -- they just need to shorten it or
// untick "include this page's contents".
setError(
'This report is too large to send, even without the page contents. ' +
'Try unchecking "Include this page\'s contents" below, or shortening your description.',
);
setStatus('error');
return;
}
try {
const resp = await fetch(`${whpConfig.apiUrl}?action=report_issue`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': whpConfig.csrfToken,
},
body: JSON.stringify(payload),
});
const data = await resp.json();
if (!resp.ok || !data.success) {
setError(data.error || 'Could not send the report. Please try again.');
setStatus('error');
return;
}
setReference(data.reference || `SB-${data.id}`);
setStatus('sent');
} catch (e) {
setError('Could not reach the server. Your text is still here — try again.');
setStatus('error');
}
};
const handleClose = (): void => {
if (status === 'sent') reset();
onClose();
};
return createPortal(
<Modal open={open} onClose={handleClose} width="min(560px, 92vw)">
<div
style={{
background: 'var(--color-bg-surface)',
border: '1px solid var(--color-border)',
borderRadius: 12,
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
overflow: 'hidden',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderBottom: '1px solid var(--color-border)',
}}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>Report an issue</div>
<button
onClick={handleClose}
aria-label="Close"
style={{
width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: '1px solid var(--color-border)', borderRadius: 6,
color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 13,
}}
>
<i className="fa fa-times" />
</button>
</div>
{status === 'sent' ? (
<div style={{ padding: 24, textAlign: 'center' }}>
<i className="fa fa-check-circle" style={{ fontSize: 32, color: '#10b981' }} aria-hidden="true" />
<p style={{ fontSize: 14, color: 'var(--color-text)', margin: '12px 0 4px' }}>
Thanks that's been sent.
</p>
<p style={{ fontSize: 12, color: 'var(--color-text-muted)', margin: 0 }}>
Your reference is <strong>{reference}</strong>. Quote it if you open a support ticket.
</p>
<button
onClick={handleClose}
style={{
marginTop: 16, padding: '7px 20px', fontSize: 13, fontWeight: 600,
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
}}
>
Done
</button>
</div>
) : (
<>
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>
What kind of issue is it?
</label>
<select
data-testid="report-category"
value={category}
onChange={(e) => setCategory(e.target.value as ReportCategory)}
style={{
width: '100%', padding: '6px 8px', fontSize: 12,
background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4,
}}
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>
What happened?
</label>
<textarea
data-testid="report-description"
value={description}
// onInput rather than onChange: functionally identical for
// real typing (both fire on every keystroke for a
// textarea), but onChange goes through React's
// value-tracker "did this really change" dedup, which a
// test harness driving the DOM via a raw `el.value =`
// assignment (rather than the native-setter-bypass trick)
// defeats -- the tracker sees its own just-written value
// and treats the dispatched 'input' event as a no-op.
// onInput is a plain passthrough with no such check.
onInput={(e) => setDescription((e.target as HTMLTextAreaElement).value)}
rows={5}
maxLength={MAX_DESCRIPTION_CHARS}
placeholder="What were you doing, and what did you expect to happen instead?"
style={{
width: '100%', padding: '8px 10px', fontSize: 12, lineHeight: 1.5,
background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4,
resize: 'vertical', boxSizing: 'border-box',
}}
/>
{description.length >= COUNTER_THRESHOLD && (
<div
data-testid="report-description-count"
style={{
fontSize: 10,
color: description.length >= MAX_DESCRIPTION_CHARS ? '#fca5a5' : 'var(--color-text-muted)',
textAlign: 'right',
marginTop: 4,
}}
>
{description.length} / {MAX_DESCRIPTION_CHARS}
</div>
)}
</div>
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', cursor: 'pointer' }}>
<input
data-testid="report-include-canvas"
type="checkbox"
checked={includeCanvas}
onChange={(e) => setIncludeCanvas(e.target.checked)}
style={{ marginTop: 2 }}
/>
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
Include this page's contents to help debugging. This sends the text and
layout of the page you're editing along with your report. Uncheck it and
we'll still get your description, the page name and your browser details --
but not the page's text or layout.
</span>
</label>
{status === 'error' && (
<div style={{
fontSize: 11, color: '#fca5a5', background: 'rgba(239,68,68,0.1)',
border: '1px solid rgba(239,68,68,0.35)', borderRadius: 4, padding: '8px 10px',
}}>
{error}
</div>
)}
</div>
<div style={{
padding: '10px 16px', borderTop: '1px solid var(--color-border)',
display: 'flex', justifyContent: 'flex-end', gap: 8,
}}>
<button
onClick={handleClose}
style={{
padding: '7px 16px', fontSize: 13,
background: 'var(--color-bg-elevated)', color: 'var(--color-text-muted)',
border: '1px solid var(--color-border)', borderRadius: 6, cursor: 'pointer',
}}
>
Cancel
</button>
<button
data-action="submit-report"
disabled={!description.trim() || status === 'sending'}
onClick={handleSubmit}
style={{
padding: '7px 20px', fontSize: 13, fontWeight: 600,
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6,
cursor: description.trim() && status !== 'sending' ? 'pointer' : 'not-allowed',
opacity: description.trim() && status !== 'sending' ? 1 : 0.5,
}}
>
{status === 'sending' ? 'Sending…' : 'Send report'}
</button>
</div>
</>
)}
</div>
</Modal>,
document.body,
);
};
+14
View File
@@ -9,6 +9,7 @@ import { useMobileChrome } from '../../state/MobileChromeContext';
import { DeviceMode } from '../../types'; import { DeviceMode } from '../../types';
import { TemplateModal } from './TemplateModal'; import { TemplateModal } from './TemplateModal';
import { HeadCodeModal } from './HeadCodeModal'; import { HeadCodeModal } from './HeadCodeModal';
import { ReportIssueModal } from './ReportIssueModal';
import { TopBarOverflowMenu } from './TopBarOverflowMenu'; import { TopBarOverflowMenu } from './TopBarOverflowMenu';
import { PublishWarnings } from './PublishWarnings'; import { PublishWarnings } from './PublishWarnings';
import { SitesmithButton } from '../sitesmith/SitesmithButton'; import { SitesmithButton } from '../sitesmith/SitesmithButton';
@@ -40,6 +41,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
// opening a mobile sheet can close these modals (item 3) -- behavior is // opening a mobile sheet can close these modals (item 3) -- behavior is
// otherwise identical for both the desktop and mobile branches below. // otherwise identical for both the desktop and mobile branches below.
const { templateModalOpen, setTemplateModalOpen, headCodeModalOpen, setHeadCodeModalOpen, overflowOpen, setOverflowOpen } = useMobileChrome(); const { templateModalOpen, setTemplateModalOpen, headCodeModalOpen, setHeadCodeModalOpen, overflowOpen, setOverflowOpen } = useMobileChrome();
const [reportOpen, setReportOpen] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { open: openSitesmith } = useSitesmithModal(); const { open: openSitesmith } = useSitesmithModal();
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -303,12 +305,14 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
onToggleGuides={onToggleGuides} onToggleGuides={onToggleGuides}
onOpenTemplates={() => setTemplateModalOpen(true)} onOpenTemplates={() => setTemplateModalOpen(true)}
onOpenHeadCode={() => setHeadCodeModalOpen(true)} onOpenHeadCode={() => setHeadCodeModalOpen(true)}
onOpenReportIssue={() => setReportOpen(true)}
onPreview={handlePreview} onPreview={handlePreview}
sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} />} sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} />}
/> />
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} /> <TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} /> <HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
</nav> </nav>
); );
} }
@@ -377,6 +381,15 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={handlePreview}> <button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={handlePreview}>
<i className="fa fa-eye" /> <i className="fa fa-eye" />
</button> </button>
<button
className="topbar-btn icon-only"
aria-label="Report an issue"
data-tooltip="Report an issue"
title="Report an issue"
onClick={() => setReportOpen(true)}
>
<i className="fa fa-bug" />
</button>
{/* Draft/Published status badge */} {/* Draft/Published status badge */}
{isWHP && isDraft && publishStatus !== 'published' && ( {isWHP && isDraft && publishStatus !== 'published' && (
@@ -441,6 +454,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
</div> </div>
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} /> <TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} /> <HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
</nav> </nav>
); );
}; };
@@ -10,6 +10,7 @@ export interface TopBarOverflowMenuProps {
onToggleGuides: () => void; onToggleGuides: () => void;
onOpenTemplates: () => void; onOpenTemplates: () => void;
onOpenHeadCode: () => void; onOpenHeadCode: () => void;
onOpenReportIssue: () => void;
onPreview: () => void; onPreview: () => void;
/** Rendered `<SitesmithButton onClick={...} />` -- passed in rather than /** Rendered `<SitesmithButton onClick={...} />` -- passed in rather than
* re-implemented here so the mobile menu reuses the exact same * re-implemented here so the mobile menu reuses the exact same
@@ -33,6 +34,7 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
onToggleGuides, onToggleGuides,
onOpenTemplates, onOpenTemplates,
onOpenHeadCode, onOpenHeadCode,
onOpenReportIssue,
onPreview, onPreview,
sitesmithNode, sitesmithNode,
}) => { }) => {
@@ -91,6 +93,9 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenHeadCode)}> <button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenHeadCode)}>
<i className="fa fa-code" aria-hidden="true" /> Head Code <i className="fa fa-code" aria-hidden="true" /> Head Code
</button> </button>
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenReportIssue)}>
<i className="fa fa-bug" aria-hidden="true" /> Report an issue
</button>
<button <button
type="button" type="button"
className={`topbar-overflow-item${showGuides ? ' active' : ''}`} className={`topbar-overflow-item${showGuides ? ' active' : ''}`}