From d5e7a4c476ab29eebb507fc396c524f5384ae4e0 Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Thu, 28 May 2026 11:33:01 +0100 Subject: [PATCH] Improved theme validation modals Separates blocking GScan failures from non-blocking theme issues so upload, save, and activation outcomes are clear before optional compatibility details. Adds the shared validation disclosure used by failed and successful theme flows, keeps secondary issues collapsed outside development, and documents the treatment in Shade Storybook. --- .../src/global/modal/confirmation-modal.tsx | 7 +- .../src/global/modal/modal.tsx | 2 +- .../components/settings/site/theme-modal.tsx | 20 +- .../site/theme/advanced-theme-settings.tsx | 4 +- .../site/theme/invalid-theme-modal.tsx | 99 +--- .../site/theme/theme-code-editor-modal.tsx | 3 +- .../site/theme/theme-installed-modal.tsx | 108 ++-- .../site/theme/theme-validation-details.tsx | 226 ++++++++ .../theme-validation-modal.stories.tsx | 511 ++++++++++++++++++ 9 files changed, 832 insertions(+), 148 deletions(-) create mode 100644 apps/admin-x-settings/src/components/settings/site/theme/theme-validation-details.tsx create mode 100644 apps/shade/src/components/patterns/theme-validation-modal.stories.tsx 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 => +};