diff --git a/apps/admin-x-design-system/src/global/modal/confirmation-modal.tsx b/apps/admin-x-design-system/src/global/modal/confirmation-modal.tsx index a3dae4bb316..24b2226f9eb 100644 --- a/apps/admin-x-design-system/src/global/modal/confirmation-modal.tsx +++ b/apps/admin-x-design-system/src/global/modal/confirmation-modal.tsx @@ -4,7 +4,7 @@ import React, {useState} from 'react'; import {ButtonColor} from '../button'; export interface ConfirmationModalProps { - title?: string; + title?: React.ReactNode; prompt?: React.ReactNode; cancelLabel?: string; okLabel?: string; @@ -16,6 +16,7 @@ export interface ConfirmationModalProps { }) => void | Promise; customFooter?: boolean | React.ReactNode; formSheet?: boolean; + stickyFooter?: boolean; } export const ConfirmationModalContent: React.FC = ({ @@ -28,7 +29,8 @@ export const ConfirmationModalContent: React.FC = ({ onCancel, onOk, customFooter, - formSheet = true + formSheet = true, + stickyFooter = false }) => { const modal = useModal(); const [taskState, setTaskState] = useState<'running' | ''>(''); @@ -41,6 +43,7 @@ export const ConfirmationModalContent: React.FC = ({ formSheet={formSheet} okColor={okColor} okLabel={taskState === 'running' ? okRunningLabel : okLabel} + stickyFooter={stickyFooter} testId='confirmation-modal' title={title} width={540} diff --git a/apps/admin-x-design-system/src/global/modal/modal.tsx b/apps/admin-x-design-system/src/global/modal/modal.tsx index 98670f4e7c3..43b3718d765 100644 --- a/apps/admin-x-design-system/src/global/modal/modal.tsx +++ b/apps/admin-x-design-system/src/global/modal/modal.tsx @@ -21,7 +21,7 @@ export interface ModalProps { align?: 'center' | 'left' | 'right'; testId?: string; - title?: string; + title?: React.ReactNode; okLabel?: string; okColor?: ButtonColor; okLoading?: boolean; diff --git a/apps/admin-x-settings/src/components/settings/site/theme-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme-modal.tsx index bc8b7e9fba2..75521e4932b 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme-modal.tsx @@ -158,8 +158,8 @@ const ThemeToolbar: React.FC = ({ } if (fatalErrors && !data) { - let title = 'Invalid Theme'; - let prompt = <>This theme is invalid and cannot be activated. Fix the following errors and re-upload the theme; + let title = 'Theme not uploaded'; + let prompt = <>This theme couldn't be uploaded because Ghost found a blocking validation error. Fix the issue below and upload the theme again.; NiceModal.show(InvalidThemeModal, { title, prompt, @@ -190,17 +190,15 @@ const ThemeToolbar: React.FC = ({ } if (uploadedTheme?.errors?.length || uploadedTheme.warnings?.length) { - const hasErrors = uploadedTheme?.errors?.length; - - title = `Upload successful with ${hasErrors ? 'errors' : 'warnings'}`; + title = 'Upload successful'; prompt = <> - The theme "{uploadedTheme.name}" was installed but we detected some {hasErrors ? 'errors' : 'warnings'}. + The theme "{uploadedTheme.name}" was installed successfully. ; if (!uploadedTheme.active) { prompt = <> {prompt} - You are still able to activate and use the theme but it is recommended to fix these {hasErrors ? 'errors' : 'warnings'} before you do so. + You can activate it when you're ready. ; } } @@ -484,17 +482,15 @@ const ChangeThemeModal: React.FC = ({source, themeRef}) = } if (newlyInstalledTheme.errors?.length || newlyInstalledTheme.warnings?.length) { - const hasErrors = newlyInstalledTheme.errors?.length; - - title = `Installed with ${hasErrors ? 'errors' : 'warnings'}`; + title = 'Installed successfully'; prompt = <> - The theme "{newlyInstalledTheme.name}" was installed successfully but we detected some {hasErrors ? 'errors' : 'warnings'}. + The theme "{newlyInstalledTheme.name}" was installed successfully. ; if (!newlyInstalledTheme.active) { prompt = <> {prompt} - You are still able to activate and use the theme but it is recommended to contact the theme developer fix these {hasErrors ? 'errors' : 'warnings'} before you do so. + You can activate it when you're ready. ; } } diff --git a/apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx b/apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx index ec1b16d0d7a..38b54341896 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx @@ -72,8 +72,8 @@ const ThemeActions: React.FC = ({ } else { handleError(e); } - let title = 'Invalid Theme'; - let prompt = <>This theme is invalid and cannot be activated. Fix the following errors and re-upload the theme; + let title = 'Theme not activated'; + let prompt = <>This theme couldn't be activated because Ghost found a blocking validation error. Fix the issue below and try again.; if (fatalErrors) { NiceModal.show(InvalidThemeModal, { diff --git a/apps/admin-x-settings/src/components/settings/site/theme/invalid-theme-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme/invalid-theme-modal.tsx index b1435d1d576..2e2ad8dda88 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/invalid-theme-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/invalid-theme-modal.tsx @@ -1,89 +1,50 @@ import NiceModal from '@ebay/nice-modal-react'; -import React, {type ReactNode, useState} from 'react'; -import {Button, ConfirmationModalContent, Heading, List, ListItem} from '@tryghost/admin-x-design-system'; -import {type ThemeProblem} from '@tryghost/admin-x-framework/api/themes'; +import React, {type ReactNode} from 'react'; +import {ConfirmationModalContent} from '@tryghost/admin-x-design-system'; +import {ErrorTextCard, type FatalErrors, ThemeValidationDetailsDisclosure, ValidationProblemCard, getIssuesFromFatalErrors} from './theme-validation-details'; +import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; -type FatalError = { - details: { - errors: ThemeProblem[]; - }|string; - }; - -export type FatalErrors = FatalError[]; - -export const ThemeProblemView = ({problem}:{problem: ThemeProblem}) => { - const [isExpanded, setExpanded] = useState(false); - - const handleClick = () => { - setExpanded(!isExpanded); - }; - - return -
- { - problem?.fatal ? - Fatal: - : - {problem.level === 'error' ? 'Error: ' : 'Warning: '} - } - -
-
-
- { - isExpanded ? -
-
- Affected files: -
    - {problem.failures.map(failure =>
  • {failure.ref}{failure.message ? `: ${failure.message}` : ''}
  • )} -
-
: - null - } - - } - hideActions - separator - />; -}; +export type {FatalErrors} from './theme-validation-details'; const InvalidThemeModal: React.FC<{ title: string prompt: ReactNode fatalErrors?: FatalErrors; + validationDetailsDefaultOpen?: boolean; onRetry?: (modal?: { remove: () => void; }) => void | Promise; -}> = ({title, prompt, fatalErrors, onRetry}) => { - let warningPrompt = null; - if (fatalErrors) { - warningPrompt =
- - {fatalErrors.map((error) => { - if (typeof error.details === 'object' && error.details.errors && error.details.errors.length > 0) { - return error.details.errors.map(err => ); - } else if (typeof error.details === 'string') { - return ; - } else { - return null; - } - })} - -
; - } +}> = ({title, prompt, fatalErrors, validationDetailsDefaultOpen, onRetry}) => { + const {data: configData} = useBrowseConfig(); + const defaultOpen = validationDetailsDefaultOpen ?? configData?.config?.environment === 'development'; + const {blockingProblems, secondaryProblems, stringErrors} = getIssuesFromFatalErrors(fatalErrors); + const blockingIssueCount = blockingProblems.length + stringErrors.length; + const promptText = prompt ?? <>Ghost found {blockingIssueCount === 1 ? 'a blocking validation error' : `${blockingIssueCount} blocking validation errors`} and did not save your theme. Fix {blockingIssueCount === 1 ? 'the issue' : 'the issues'} below and try again.; return - {prompt} - {warningPrompt} +
+
{promptText}
+ + {(blockingProblems.length > 0 || stringErrors.length > 0) && ( +
+ {blockingProblems.map(problem => ( + + ))} + {stringErrors.map(error => )} +
+ )} + + +
} + stickyFooter={true} title={title} onOk={onRetry} />; diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx index 292f1bb8d0f..410a131a8f6 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx @@ -812,8 +812,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { if (!response.ok) { if (response.status === 422 && data?.errors) { NiceModal.show(InvalidThemeModal, { - title: 'Invalid Theme', - prompt: <>Fix the validation errors below and try saving again., + title: 'Theme not saved', fatalErrors: data.errors as FatalErrors }); return; diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-installed-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-installed-modal.tsx index 316897904ef..36ec458e786 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/theme-installed-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-installed-modal.tsx @@ -1,89 +1,77 @@ import NiceModal from '@ebay/nice-modal-react'; -import React, {type ReactNode, useState} from 'react'; +import React, {type ReactNode} from 'react'; import useCustomFonts from '../../../../hooks/use-custom-fonts'; -import {Button, ConfirmationModalContent, Heading, List, ListItem, showToast} from '@tryghost/admin-x-design-system'; -import {type InstalledTheme, type ThemeProblem, useActivateTheme} from '@tryghost/admin-x-framework/api/themes'; +import {ConfirmationModalContent, showToast} from '@tryghost/admin-x-design-system'; +import {type InstalledTheme, useActivateTheme} from '@tryghost/admin-x-framework/api/themes'; +import {OutcomeBanner, ThemeValidationDetailsDisclosure, getIssuesFromInstalledTheme} from './theme-validation-details'; +import {getHomepageUrl, useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -export const ThemeProblemView = ({problem}:{problem: ThemeProblem}) => { - const [isExpanded, setExpanded] = useState(false); - - return -
- {problem.level === 'error' ? 'Error: ' : 'Warning: '} - -
-
-
- { - isExpanded ? -
-
- Affected files: -
    - {problem.failures.map(failure =>
  • {failure.ref}{failure.message ? `: ${failure.message}` : ''}
  • )} -
-
: - null - } - - } - hideActions - separator - />; -}; - const ThemeInstalledModal: React.FC<{ title: string prompt: ReactNode installedTheme: InstalledTheme; + validationDetailsDefaultOpen?: boolean; onActivate?: () => void; -}> = ({title, prompt, installedTheme, onActivate}) => { +}> = ({title, installedTheme, validationDetailsDefaultOpen, onActivate}) => { const {mutateAsync: activateTheme} = useActivateTheme(); const {refreshActiveThemeData} = useCustomFonts(); const handleError = useHandleError(); + const {data: configData} = useBrowseConfig(); + const {data: siteData} = useBrowseSite(); + const defaultOpen = validationDetailsDefaultOpen ?? configData?.config?.environment === 'development'; + const secondaryProblems = getIssuesFromInstalledTheme(installedTheme); + const homepageUrl = siteData?.site ? getHomepageUrl(siteData.site) : undefined; - /* eslint-disable react/no-array-index-key */ - let errorPrompt = null; - if (installedTheme && installedTheme.errors) { - errorPrompt =
- Highly recommended to fix, functionality could be restricted} title="Errors"> - {installedTheme.errors?.map((error, index) => )} - -
; - } - - let warningPrompt = null; - if (installedTheme && installedTheme.warnings) { - warningPrompt =
- - {installedTheme.warnings?.map((warning, index) => )} - -
; - } - /* eslint-enable react/no-array-index-key */ - - let okLabel = `Activate${installedTheme.errors?.length ? ' with errors' : ''}`; + let okLabel = 'Activate theme'; if (installedTheme.active) { okLabel = 'OK'; } + const modalTitle = installedTheme.active ? It's live! : title; + const outcomeTitle = 'Uploaded successfully'; + const outcomeCopy = installedTheme.active ? ( + <> + Your theme {installedTheme.name} was saved successfully and is now visible to your readers. + {homepageUrl ? <> + {' '}Take a look → + : null} + + ) : ( + <> + {installedTheme.name} has been uploaded. Activate it to make it live. + + ); + return - {prompt} +
+ {installedTheme.active ? ( +
+

{outcomeCopy}

+
+ ) : ( + +
+

{outcomeCopy}

+
+
+ )} - {errorPrompt} - {warningPrompt} + +
} - title={title} + stickyFooter={true} + title={modalTitle} onOk={async (activateModal) => { if (!installedTheme.active) { try { diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-validation-details.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-validation-details.tsx new file mode 100644 index 00000000000..f6a8d84f561 --- /dev/null +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-validation-details.tsx @@ -0,0 +1,226 @@ +import React, {useEffect, useMemo, useState} from 'react'; +import {Badge, Banner} from '@tryghost/shade/components'; +import {type InstalledTheme, type ThemeProblem} from '@tryghost/admin-x-framework/api/themes'; +import {LucideIcon} from '@tryghost/shade/utils'; + +type ThemeValidationErrorDetails = { + errors?: ThemeProblem[]; + warnings?: ThemeProblem[]; +}; + +type ThemeValidationError = { + details: ThemeValidationErrorDetails | string; +}; + +export type FatalErrors = ThemeValidationError[]; + +type IssueSummary = { + blockingProblems: ThemeProblem[]; + secondaryProblems: ThemeProblem[]; + stringErrors: string[]; +}; + +type DisplaySeverity = 'Error' | 'Warning' | 'Recommendation'; + +function isDetailsObject(details: ThemeValidationError['details']): details is ThemeValidationErrorDetails { + return typeof details === 'object' && details !== null; +} + +function allProblemsFromDetails(details: ThemeValidationErrorDetails) { + return [...(details.errors || []), ...(details.warnings || [])]; +} + +export function getIssuesFromFatalErrors(fatalErrors: FatalErrors = []): IssueSummary { + const blockingProblems: ThemeProblem[] = []; + const secondaryProblems: ThemeProblem[] = []; + const stringErrors: string[] = []; + + fatalErrors.forEach((error) => { + if (isDetailsObject(error.details)) { + allProblemsFromDetails(error.details).forEach((problem) => { + if (problem.fatal) { + blockingProblems.push(problem); + } else { + secondaryProblems.push(problem); + } + }); + } else { + stringErrors.push(error.details); + } + }); + + return {blockingProblems, secondaryProblems, stringErrors}; +} + +export function getIssuesFromInstalledTheme(installedTheme: InstalledTheme): ThemeProblem[] { + return [...(installedTheme.errors || []), ...(installedTheme.warnings || [])]; +} + +function getDisplaySeverity(problem: ThemeProblem): DisplaySeverity { + if (problem.fatal) { + return 'Error'; + } + + if (problem.level === 'error') { + return 'Warning'; + } + + return 'Recommendation'; +} + +function getDisplayVariant(problem: ThemeProblem): 'destructive' | 'warning' | 'secondary' { + if (problem.fatal) { + return 'destructive'; + } + + if (problem.level === 'error') { + return 'warning'; + } + + return 'secondary'; +} + +function formatNonBlockingIssueCount(count: number) { + return `${count} non-blocking ${count === 1 ? 'issue' : 'issues'}`; +} + +function ProblemDetails({problem}: {problem: ThemeProblem}) { + return ( +
+
+ {problem.failures?.length > 0 && ( +
+
Affected files
+
    + {problem.failures.map(failure => ( +
  • + {failure.ref} + {failure.message ? : {failure.message} : null} +
  • + ))} +
+
+ )} +
+ ); +} + +export function ValidationProblemCard({problem, prominent = false}: {problem: ThemeProblem; prominent?: boolean}) { + const [expanded, setExpanded] = useState(prominent); + const displaySeverity = getDisplaySeverity(problem); + + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ ); +} + +export function ThemeValidationDetailsDisclosure({ + defaultOpen, + problems +}: { + defaultOpen: boolean; + problems: ThemeProblem[]; +}) { + const [open, setOpen] = useState(defaultOpen); + const count = problems.length; + + useEffect(() => { + setOpen(defaultOpen); + }, [defaultOpen]); + + const sortedProblems = useMemo(() => { + return [...problems].sort((a, b) => { + const severityOrder: Record = {Error: 0, Warning: 1, Recommendation: 2}; + return severityOrder[getDisplaySeverity(a)] - severityOrder[getDisplaySeverity(b)]; + }); + }, [problems]); + + if (!count) { + return null; + } + + return ( +
+ + {open && ( +
+ {sortedProblems.map(problem => ( + + ))} +
+ )} +
+ ); +} + +export function ErrorTextCard({message}: {message: string}) { + return ( +
+
+ +

{message}

+
+
+ ); +} + +export function OutcomeBanner({ + children, + title, + variant +}: { + children: React.ReactNode; + title: string; + variant: 'success' | 'destructive'; +}) { + const Icon = variant === 'success' ? LucideIcon.CheckCircle2 : LucideIcon.AlertTriangle; + const iconClassName = variant === 'success' ? 'text-state-success' : 'text-destructive'; + + return ( + +
+
+ +
+
+

{title}

+
{children}
+
+
+
+ ); +} diff --git a/apps/shade/src/components/patterns/theme-validation-modal.stories.tsx b/apps/shade/src/components/patterns/theme-validation-modal.stories.tsx new file mode 100644 index 00000000000..0b35f1c14f9 --- /dev/null +++ b/apps/shade/src/components/patterns/theme-validation-modal.stories.tsx @@ -0,0 +1,511 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {ChevronDown, X} from 'lucide-react'; +import {Badge} from '@/components/ui/badge'; +import {Button} from '@/components/ui/button'; +import {Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'; +import {cn} from '@/lib/utils'; +import {useEffect, useState} from 'react'; + +type ValidationMode = 'development' | 'production'; +type ValidationScenario = 'custom' | 'fatal-multiple-fatal-errors' | 'fatal-many-affected-files' | 'fatal-many-non-blocking-issues' | 'fatal-one-non-blocking-issue' | 'fatal-no-non-blocking-issues' | 'success-many-non-blocking-issues' | 'success-one-non-blocking-issue' | 'success-no-issues'; +type ValidationOutcome = 'fatal' | 'success'; +type FatalFiles = 'one' | 'many'; +type ValidationSeverity = 'error' | 'warning' | 'recommendation'; + +type StoryArgs = { + additionalIssues: number; + fatalFiles: FatalFiles; + mode: ValidationMode; + numFatals: number; + outcome: ValidationOutcome; + scenario: ValidationScenario; +}; + +type ValidationIssue = { + code: string; + details: string; + fatal?: boolean; + ref: string; + rule: string; + severity: ValidationSeverity; +}; + +const fatalIssue: ValidationIssue = { + code: 'GS005-TPL-COMPILATION-ERR', + details: 'Ghost could not compile this template. Fix the syntax error before saving or activating the theme.', + fatal: true, + ref: 'default.hbs', + rule: 'Template syntax must be valid', + severity: 'error' +}; + +const missingIndexIssue: ValidationIssue = { + code: 'GS020-INDEX-REQ', + details: 'Your theme must have a template file called index.hbs.', + fatal: true, + ref: 'index.hbs', + rule: 'A template file called index.hbs must be present', + severity: 'error' +}; + +const missingPostIssue: ValidationIssue = { + code: 'GS020-POST-REQ', + details: 'Your theme must have a template file called post.hbs.', + fatal: true, + ref: 'post.hbs', + rule: 'A template file called post.hbs must be present', + severity: 'error' +}; + +const fatalManyFilesIssue: ValidationIssue = { + code: 'GS005-TPL-ERR', + details: 'Several templates contain invalid Handlebars syntax and could not be compiled.', + fatal: true, + ref: 'index.hbs, post.hbs, page.hbs, tag.hbs', + rule: 'Templates must contain valid Handlebars', + severity: 'error' +}; + +const fatalIssueTemplates: ValidationIssue[] = [ + fatalIssue, + missingIndexIssue, + missingPostIssue, + { + code: 'GS005-NO-INLINE-DYNAMIC-PARTIAL', + details: 'Inline dynamic partials can throw a page error when the named partial does not exist.', + fatal: true, + ref: 'partials/card.hbs', + rule: 'Use the block form for dynamic partials', + severity: 'error' + }, + { + code: 'GS030-ASSET-SYM', + details: 'Symbolic links in themes are not allowed. Use regular files and the asset helper instead.', + fatal: true, + ref: 'assets/css/screen.css', + rule: 'Symlinks in themes are not allowed', + severity: 'error' + } +]; + +const manyAffectedFileRefs = 'index.hbs, post.hbs, page.hbs, tag.hbs'; + +const secondaryIssues: ValidationIssue[] = [ + { + code: 'GS001-DEPR-TWITTER-URL', + details: 'The twitter URL helper is deprecated. Use the social URL helper instead.', + ref: 'default.hbs', + rule: 'Replace {{twitter_url}} with {{social_url type="twitter"}}', + severity: 'recommendation' + }, + { + code: 'GS040-GH-REQ', + details: 'The ghost_head helper is required for scripts, metadata, and structured data.', + ref: 'default.hbs', + rule: 'The helper {{ghost_head}} should be present', + severity: 'recommendation' + }, + { + code: 'GS040-GF-REQ', + details: 'The ghost_foot helper is required for scripts injected before the closing body tag.', + ref: 'default.hbs', + rule: 'The helper {{ghost_foot}} should be present', + severity: 'recommendation' + }, + { + code: 'GS050-CSS-KGWW', + details: 'Wide Koenig cards may not render correctly without styling for this class.', + ref: 'assets/css/screen.css', + rule: 'The .kg-width-wide CSS class is required to appear styled in your theme', + severity: 'warning' + }, + { + code: 'GS051-CUSTOM-FONTS', + details: 'Add support for Ghost custom font variables so publication typography controls work correctly.', + ref: 'assets/css/screen.css', + rule: 'Missing support for custom fonts', + severity: 'recommendation' + }, + { + code: 'GS110-PAGE-BUILDER-USAGE', + details: 'Respect the page builder title and feature-image visibility setting in page templates.', + ref: 'page.hbs', + rule: 'Support the {{@page.show_title_and_feature_image}} editor setting', + severity: 'recommendation' + } +]; + +const meta = { + title: 'Spikes / Theme Validation Modal', + args: { + additionalIssues: 6, + fatalFiles: 'one', + mode: 'production', + numFatals: 1, + outcome: 'fatal', + scenario: 'custom' + }, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: 'Draft theme validation modal treatments. These are deliberately story-only so we can compare volume, tone, and footer behavior before wiring the production UI.' + } + } + }, + argTypes: { + mode: { + control: {type: 'inline-radio'}, + options: ['development', 'production'] + }, + scenario: { + control: {type: 'select'}, + description: 'Preset scenario. Choose custom to use the controls below.', + options: ['custom', 'fatal-multiple-fatal-errors', 'fatal-many-affected-files', 'fatal-many-non-blocking-issues', 'fatal-one-non-blocking-issue', 'fatal-no-non-blocking-issues', 'success-many-non-blocking-issues', 'success-one-non-blocking-issue', 'success-no-issues'], + labels: { + custom: 'Custom controls', + 'fatal-multiple-fatal-errors': 'Fatal: multiple fatal errors', + 'fatal-many-affected-files': 'Fatal: one fatal error, many affected files', + 'fatal-many-non-blocking-issues': 'Fatal: one fatal error + many non-blocking issues', + 'fatal-one-non-blocking-issue': 'Fatal: one fatal error + one non-blocking issue', + 'fatal-no-non-blocking-issues': 'Fatal: one fatal error only', + 'success-many-non-blocking-issues': 'Success: many non-blocking issues', + 'success-one-non-blocking-issue': 'Success: one non-blocking issue', + 'success-no-issues': 'Success: no issues' + } + }, + outcome: { + control: {type: 'inline-radio'}, + description: 'Used when scenario is custom.', + options: ['fatal', 'success'] + }, + numFatals: { + control: {max: 5, min: 1, step: 1, type: 'range'}, + description: 'Number of blocking errors to show when scenario is custom and outcome is fatal.' + }, + fatalFiles: { + control: {type: 'inline-radio'}, + description: 'Whether each custom fatal error shows one affected file or many affected files.', + options: ['one', 'many'] + }, + additionalIssues: { + control: {max: 10, min: 0, step: 1, type: 'range'}, + description: 'Number of non-blocking issues to show when scenario is custom.' + } + } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function getSecondaryIssues(count: number) { + return Array.from({length: count}, (_, index) => { + const issue = secondaryIssues[index % secondaryIssues.length]; + + if (index < secondaryIssues.length) { + return issue; + } + + return { + ...issue, + code: `${issue.code}-${index + 1}` + }; + }); +} + +function getCustomBlockingIssues(numFatals: number, fatalFiles: FatalFiles) { + return fatalIssueTemplates.slice(0, Math.max(1, Math.min(numFatals, 5))).map((issue, index) => ({ + ...issue, + ref: fatalFiles === 'many' ? manyAffectedFileRefs : issue.ref, + ...(index >= fatalIssueTemplates.length ? {code: `${issue.code}-${index + 1}`} : {}) + })); +} + +function getScenario({additionalIssues = 1, fatalFiles = 'one', numFatals = 1, outcome = 'fatal', scenario = 'custom'}: Partial) { + if (scenario === 'custom') { + const blockingIssues = outcome === 'fatal' ? getCustomBlockingIssues(numFatals, fatalFiles) : []; + + return { + blockingIssues, + secondaryIssues: getSecondaryIssues(Math.max(0, Math.min(additionalIssues, 10))), + status: blockingIssues.length > 0 ? 'fatal' : 'success' + } as const; + } + + const hasFatal = scenario.startsWith('fatal'); + const many = scenario.includes('many-non-blocking'); + const multipleFatal = scenario === 'fatal-multiple-fatal-errors'; + const manyAffectedFiles = scenario === 'fatal-many-affected-files'; + const noNonBlockingIssues = scenario.includes('no-non-blocking') || scenario === 'success-no-issues' || manyAffectedFiles || multipleFatal; + const nonBlockingIssues = noNonBlockingIssues ? [] : secondaryIssues.slice(0, many ? secondaryIssues.length : 1); + + return { + blockingIssues: hasFatal ? (multipleFatal ? [missingIndexIssue, missingPostIssue] : [manyAffectedFiles ? fatalManyFilesIssue : fatalIssue].flat()) : [], + secondaryIssues: nonBlockingIssues, + status: hasFatal ? 'fatal' : 'success' + } as const; +} + +function severityLabel(issue: ValidationIssue) { + if (issue.fatal) { + return 'Error'; + } + + if (issue.severity === 'warning') { + return 'Warning'; + } + + return 'Recommendation'; +} + +function severityVariant(issue: ValidationIssue): 'destructive' | 'warning' | 'secondary' { + if (issue.fatal) { + return 'destructive'; + } + + if (issue.severity === 'warning') { + return 'warning'; + } + + return 'secondary'; +} + +function severitySortValue(issue: ValidationIssue) { + if (issue.fatal) { + return 0; + } + + if (issue.severity === 'warning') { + return 1; + } + + if (issue.severity === 'recommendation') { + return 2; + } + + return 3; +} + +function sortValidationIssues(issues: ValidationIssue[]) { + return [...issues].sort((a, b) => severitySortValue(a) - severitySortValue(b)); +} + +function PreviewLink() { + return Take a look →; +} + +function ModalCloseButton({onClick}: {onClick?: () => void}) { + return ( + + + + ); +} + +function IssueRow({issue, prominent = false}: {issue: ValidationIssue; prominent?: boolean}) { + const [open, setOpen] = useState(prominent); + + return ( +
+ + {open && ( +
+

{issue.details}

+

Affected files

+
+ {issue.ref.split(',').map(ref => ( + {ref.trim()} + ))} +
+
+ )} +
+ ); +} + +function SecondaryIssues({defaultOpen, issues}: {defaultOpen: boolean; issues: ValidationIssue[]}) { + const [open, setOpen] = useState(defaultOpen); + const sortedIssues = sortValidationIssues(issues); + + useEffect(() => { + setOpen(defaultOpen); + }, [defaultOpen]); + + if (!issues.length) { + return null; + } + + return ( +
+ + {open && ( +
+ {sortedIssues.map(issue => )} +
+ )} +
+ ); +} + +function DraftThemeValidationModal({additionalIssues, fatalFiles, mode, numFatals, outcome, scenario}: StoryArgs) { + const {blockingIssues, secondaryIssues: nonBlockingIssues, status} = getScenario({additionalIssues, fatalFiles, numFatals, outcome, scenario}); + const isFatal = status === 'fatal'; + const defaultOpen = mode === 'development'; + const title = isFatal ? 'Theme not saved' : 'It\'s live!'; + const blockingIssueCount = blockingIssues.length; + + return ( + + + + + {title} + + {isFatal ? ( + <>Ghost found {blockingIssueCount === 1 ? 'a blocking validation error' : `${blockingIssueCount} blocking validation errors`} and did not save your theme. Fix {blockingIssueCount === 1 ? 'the issue' : 'the issues'} below and try again. + ) : ( + <>Your theme validation-preview-theme was saved successfully and is now visible to your readers. + )} + + + +
+ {blockingIssues.length > 0 && ( +
+ {blockingIssues.map(issue => )} +
+ )} + + +
+ + + + + +
+
+ ); +} + +function TriggeredDraftThemeValidationModal({additionalIssues, fatalFiles, mode, numFatals, outcome, scenario}: StoryArgs) { + const [open, setOpen] = useState(false); + const {blockingIssues, secondaryIssues: nonBlockingIssues, status} = getScenario({additionalIssues, fatalFiles, numFatals, outcome, scenario}); + const isFatal = status === 'fatal'; + const defaultOpen = mode === 'development'; + const title = isFatal ? 'Theme not saved' : 'It\'s live!'; + const blockingIssueCount = blockingIssues.length; + + return ( +
+ + + + + + setOpen(false)} /> + + {title} + + {isFatal ? <>Ghost found {blockingIssueCount === 1 ? 'a blocking validation error' : `${blockingIssueCount} blocking validation errors`} and did not save your theme. Fix {blockingIssueCount === 1 ? 'the issue' : 'the issues'} below and try again. : <>Your theme validation-preview-theme was saved successfully and is now visible to your readers. } + + +
+ {blockingIssues.length > 0 && ( +
+ {blockingIssues.map(issue => )} +
+ )} + +
+ + + + +
+
+
+ ); +} + +export const Playground: Story = { + args: { + additionalIssues: 6, + fatalFiles: 'one', + mode: 'production', + numFatals: 1, + outcome: 'fatal', + scenario: 'custom' + }, + render: args => +}; + +export const Triggered: Story = { + args: { + additionalIssues: 6, + fatalFiles: 'one', + mode: 'production', + numFatals: 1, + outcome: 'fatal', + scenario: 'custom' + }, + render: args => +}; + +export const FatalMultipleFatalErrors: Story = { + args: { + mode: 'production', + scenario: 'fatal-multiple-fatal-errors' + }, + render: args => +}; + +export const FatalManyAffectedFiles: Story = { + args: { + mode: 'production', + scenario: 'fatal-many-affected-files' + }, + render: args => +}; + +export const FatalManyNonBlockingIssues: Story = { + args: { + mode: 'production', + scenario: 'fatal-many-non-blocking-issues' + }, + render: args => +}; + +export const SuccessManyNonBlockingIssues: Story = { + args: { + mode: 'production', + scenario: 'success-many-non-blocking-issues' + }, + render: args => +}; + +export const SuccessNoIssues: Story = { + args: { + mode: 'production', + scenario: 'success-no-issues' + }, + render: args => +};