diff --git a/.github/scripts/dev.js b/.github/scripts/dev.js index f1638b5c03..31c9b14092 100644 --- a/.github/scripts/dev.js +++ b/.github/scripts/dev.js @@ -45,7 +45,7 @@ const COMMAND_ADMIN = { const COMMAND_TYPESCRIPT = { name: 'ts', - command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/bookshelf-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations,ghost/email-addresses -- nx run \\$NX_PROJECT_NAME:build:ts', + command: 'while [ 1 ]; do nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/bookshelf-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations,ghost/email-addresses -- nx run \\$NX_PROJECT_NAME:build:ts; done', cwd: path.resolve(__dirname, '../../'), prefixColor: 'cyan', env: {} @@ -55,7 +55,7 @@ const adminXApps = '@tryghost/admin-x-demo,@tryghost/admin-x-settings'; const COMMANDS_ADMINX = [{ name: 'adminXDeps', - command: 'nx watch --projects=apps/admin-x-design-system,apps/admin-x-framework -- nx run \\$NX_PROJECT_NAME:build --skip-nx-cache', + command: 'while [ 1 ]; do nx watch --projects=apps/admin-x-design-system,apps/admin-x-framework -- nx run \\$NX_PROJECT_NAME:build --skip-nx-cache; done', cwd: path.resolve(__dirname, '../..'), prefixColor: '#C35831', env: {} diff --git a/.github/scripts/docker-compose.yml b/.github/scripts/docker-compose.yml new file mode 100644 index 0000000000..eb2caaeed9 --- /dev/null +++ b/.github/scripts/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0.35 + container_name: ghost-mysql + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: ghost + restart: always + volumes: + # Turns out you can drop .sql or .sql.gz files in here, cool! + - ./mysql-preload:/docker-entrypoint-initdb.d + healthcheck: + test: "mysql -uroot -proot ghost -e 'select 1'" + interval: 1s + retries: 120 diff --git a/.github/scripts/mysql-preload/.keep b/.github/scripts/mysql-preload/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/scripts/setup.js b/.github/scripts/setup.js new file mode 100644 index 0000000000..59fd37b3b5 --- /dev/null +++ b/.github/scripts/setup.js @@ -0,0 +1,94 @@ +const {spawn} = require('child_process'); +const fs = require('fs').promises; +const path = require('path'); + +const chalk = require('chalk'); + +/** + * Run a command and stream output to the console + * + * @param {string} command + * @param {string[]} args + * @param {object} options + */ +async function runAndStream(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + ...options + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(code); + } else { + reject(new Error(`'${command} ${args.join(' ')}' exited with code ${code}`)); + } + }); + + }); +} + +(async () => { + if (process.env.NODE_ENV !== 'development') { + console.log(chalk.yellow(`NODE_ENV is not development, skipping setup`)); + return; + } + + const coreFolder = path.join(__dirname, '../../ghost/core'); + const config = require('../../ghost/core/core/shared/config/loader').loadNconf({ + customConfigPath: coreFolder + }); + + const dbClient = config.get('database:client'); + + if (!dbClient.includes('mysql')) { + let mysqlSetup = false; + console.log(chalk.blue(`Attempting to setup MySQL via Docker`)); + try { + await runAndStream('yarn', ['docker:reset'], {cwd: path.join(__dirname, '../../')}); + mysqlSetup = true; + } catch (err) { + console.error(chalk.red('Failed to run MySQL Docker container'), err); + console.error(chalk.red('Hint: is Docker installed and running?')); + } + + if (mysqlSetup) { + console.log(chalk.blue(`Adding MySQL credentials to config.local.json`)); + const currentConfigPath = path.join(coreFolder, 'config.local.json'); + + let currentConfig; + try { + currentConfig = require(currentConfigPath); + } catch (err) { + currentConfig = {}; + } + + currentConfig.database = { + client: 'mysql', + connection: { + host: '127.0.0.1', + user: 'root', + password: 'root', + database: 'ghost' + } + }; + + try { + await fs.writeFile(currentConfigPath, JSON.stringify(currentConfig, null, 4)); + } catch (err) { + console.error(chalk.red('Failed to write config.local.json'), err); + console.log(chalk.yellow(`Please add the following to config.local.json:\n`), JSON.stringify(currentConfig, null, 4)); + process.exit(1); + } + + console.log(chalk.blue(`Running knex-migrator init`)); + await runAndStream('yarn', ['knex-migrator', 'init'], {cwd: coreFolder}); + + //console.log(chalk.blue(`Running data generator`)); + //await runAndStream('node', ['index.js', 'generate-data'], {cwd: coreFolder}); + } + } else { + console.log(chalk.green(`MySQL already configured, skipping setup`)); + } +})(); diff --git a/apps/admin-x-design-system/package.json b/apps/admin-x-design-system/package.json index 73b279f831..433d2375d8 100644 --- a/apps/admin-x-design-system/package.json +++ b/apps/admin-x-design-system/package.json @@ -27,13 +27,13 @@ ], "devDependencies": { "@codemirror/lang-html": "^6.4.5", - "@storybook/addon-essentials": "7.6.3", - "@storybook/addon-interactions": "7.6.3", - "@storybook/addon-links": "7.6.3", + "@storybook/addon-essentials": "7.6.4", + "@storybook/addon-interactions": "7.6.4", + "@storybook/addon-links": "7.6.4", "@storybook/addon-styling": "1.3.7", - "@storybook/blocks": "7.6.3", - "@storybook/react": "7.6.3", - "@storybook/react-vite": "7.6.3", + "@storybook/blocks": "7.6.4", + "@storybook/react": "7.6.4", + "@storybook/react-vite": "7.6.4", "@storybook/testing-library": "0.2.2", "@testing-library/react": "14.1.0", "@vitejs/plugin-react": "4.2.1", @@ -46,9 +46,9 @@ "react-dom": "^18.2.0", "rollup-plugin-node-builtins": "2.1.2", "sinon": "17.0.0", - "storybook": "7.6.3", + "storybook": "7.6.4", "ts-node": "10.9.1", - "typescript": "5.3.2", + "typescript": "5.3.3", "vite": "4.5.1", "vite-plugin-svgr": "3.3.0" }, diff --git a/apps/admin-x-design-system/src/global/TabView.tsx b/apps/admin-x-design-system/src/global/TabView.tsx index 49b9448e91..23e2ea17b9 100644 --- a/apps/admin-x-design-system/src/global/TabView.tsx +++ b/apps/admin-x-design-system/src/global/TabView.tsx @@ -5,6 +5,8 @@ export type Tab = { id: ID; title: string; counter?: number | null; + tabWrapperClassName?: string; + containerClassName?: string; /** * Optional, so you can just use the tabs to other views @@ -102,6 +104,7 @@ export interface TabViewProps { border?: boolean; buttonBorder?: boolean; width?: TabWidth; + containerClassName?: string; } function TabView({ @@ -110,7 +113,8 @@ function TabView({ selectedTab, border = true, buttonBorder = border, - width = 'normal' + width = 'normal', + containerClassName }: TabViewProps) { if (tabs.length !== 0 && selectedTab === undefined) { selectedTab = tabs[0].id; @@ -126,7 +130,7 @@ function TabView({ }; return ( -
+
({ return ( <> {tab.contents && -
-
{tab.contents}
+
+
{tab.contents}
} diff --git a/apps/admin-x-framework/package.json b/apps/admin-x-framework/package.json index 63cb2cf242..80ae4c7765 100644 --- a/apps/admin-x-framework/package.json +++ b/apps/admin-x-framework/package.json @@ -78,7 +78,7 @@ "react-dom": "18.2.0", "sinon": "17.0.0", "ts-node": "10.9.1", - "typescript": "5.3.2" + "typescript": "5.3.3" }, "dependencies": { "@sentry/react": "7.85.0", diff --git a/apps/admin-x-framework/src/test/responses/settings.json b/apps/admin-x-framework/src/test/responses/settings.json index ea3e443bc5..758c3dc546 100644 --- a/apps/admin-x-framework/src/test/responses/settings.json +++ b/apps/admin-x-framework/src/test/responses/settings.json @@ -176,6 +176,10 @@ "key": "portal_plans", "value": "[\"monthly\",\"yearly\",\"free\"]" }, + { + "key": "portal_default_plan", + "value": "yearly" + }, { "key": "portal_products", "value": "[]" diff --git a/apps/admin-x-settings/src/components/settings/advanced/CodeInjection.tsx b/apps/admin-x-settings/src/components/settings/advanced/CodeInjection.tsx index 335a7c8e59..aba04ba3c0 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/CodeInjection.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/CodeInjection.tsx @@ -1,93 +1,26 @@ import CodeModal from './code/CodeModal'; import NiceModal from '@ebay/nice-modal-react'; -import React, {useMemo, useRef, useState} from 'react'; +import React from 'react'; import TopLevelGroup from '../../TopLevelGroup'; -import useSettingGroup from '../../../hooks/useSettingGroup'; -import {Button, CodeEditor, TabView, withErrorBoundary} from '@tryghost/admin-x-design-system'; -import {ReactCodeMirrorRef} from '@uiw/react-codemirror'; -import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; +import {Button, SettingGroupHeader, withErrorBoundary} from '@tryghost/admin-x-design-system'; const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => { - const { - localSettings, - isEditing, - saveState, - handleSave, - handleCancel, - updateSetting, - handleEditingChange - } = useSettingGroup(); - - const [headerContent, footerContent] = getSettingValues(localSettings, ['codeinjection_head', 'codeinjection_foot']); - - const [selectedTab, setSelectedTab] = useState<'header' | 'footer'>('header'); - - const headerEditorRef = useRef(null); - const footerEditorRef = useRef(null); - - const html = useMemo(() => import('@codemirror/lang-html').then(module => module.html()), []); - - const headerProps = { - extensions: [html], - hint: 'Code here will be injected into the {{ghost_head}} tag on every page of the site', - value: headerContent || '', - onChange: (value: string) => updateSetting('codeinjection_head', value) - }; - - const footerProps = { - extensions: [html], - hint: 'Code here will be injected into the {{ghost_foot}} tag on every page of the site', - value: footerContent || '', - onChange: (value: string) => updateSetting('codeinjection_foot', value) - }; - - const tabs = [ - { - id: 'header', - title: 'Site header', - contents: () - }, - { - id: 'footer', - title: 'Site footer', - contents: () - } - ] as const; - return ( + +
+ } description="Add custom code to your publication" - isEditing={isEditing} keywords={keywords} navid='code-injection' - saveState={saveState} testId='code-injection' title="Code injection" - onCancel={handleCancel} - onEditingChange={handleEditingChange} - onSave={handleSave} - > - {isEditing && ( -
- selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} /> -
- )} - + /> ); }; diff --git a/apps/admin-x-settings/src/components/settings/advanced/code/CodeModal.tsx b/apps/admin-x-settings/src/components/settings/advanced/code/CodeModal.tsx index d4a274127c..d8094d9575 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/code/CodeModal.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/code/CodeModal.tsx @@ -1,6 +1,10 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react'; -import React, {useMemo} from 'react'; -import {CodeEditor, Modal} from '@tryghost/admin-x-design-system'; +import React, {useMemo, useRef, useState} from 'react'; +import useSettingGroup from '../../../../hooks/useSettingGroup'; +import {ButtonGroup, CodeEditor, Heading, Modal, TabView} from '@tryghost/admin-x-design-system'; +import {ReactCodeMirrorRef} from '@uiw/react-codemirror'; +import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; +import {useSaveButton} from '../../../../hooks/useSaveButton'; interface CodeModalProps { hint?: React.ReactNode; @@ -9,18 +13,91 @@ interface CodeModalProps { afterClose?: () => void } -const CodeModal: React.FC = ({hint, value, onChange, afterClose}) => { +const CodeModal: React.FC = ({afterClose}) => { + const { + localSettings, + handleSave, + updateSetting + } = useSettingGroup(); const modal = useModal(); + const [headerContent, footerContent] = getSettingValues(localSettings, ['codeinjection_head', 'codeinjection_foot']); + + const [selectedTab, setSelectedTab] = useState<'header' | 'footer'>('header'); + + const headerEditorRef = useRef(null); + const footerEditorRef = useRef(null); + const html = useMemo(() => import('@codemirror/lang-html').then(module => module.html()), []); - const onOk = () => { - modal.remove(); - afterClose?.(); + const headerProps = { + extensions: [html], + hint: 'Code here will be injected into the {{ghost_head}} tag on every page of the site', + value: headerContent || '', + onChange: (value: string) => updateSetting('codeinjection_head', value) }; - return - + const footerProps = { + extensions: [html], + hint: 'Code here will be injected into the {{ghost_foot}} tag on every page of the site', + value: footerContent || '', + onChange: (value: string) => updateSetting('codeinjection_foot', value) + }; + + const tabs = [ + { + id: 'header', + title: 'Site header', + contents: (), + tabWrapperClassName: 'flex-auto', + containerClassName: 'h-full' + }, + { + id: 'footer', + title: 'Site footer', + contents: (), + tabWrapperClassName: 'flex-auto', + containerClassName: 'h-full' + } + ] as const; + + const {savingTitle, isSaving, onSaveClick} = useSaveButton(handleSave, true); + + return } + height='full' + size='full' + testId='modal-code-injection' + > +
+
+ Code injection + { + modal.remove(); + afterClose?.(); + } + }, + { + disabled: isSaving, + label: savingTitle, + color: savingTitle === 'Saved' ? 'green' : 'black', + onClick: onSaveClick + } + ]} /> +
+ + containerClassName='flex-auto flex flex-col mb-16' + selectedTab={selectedTab} + tabs={tabs} + onTabChange={setSelectedTab} + /> +
; }; diff --git a/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModal.tsx b/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModal.tsx index 467d267026..23341e7b8a 100644 --- a/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModal.tsx +++ b/apps/admin-x-settings/src/components/settings/email/newsletters/NewsletterDetailModal.tsx @@ -73,18 +73,12 @@ const ReplyToEmailField: React.FC<{ setSenderReplyTo(rendered); }; - const hint = ( - <> - If left empty, replies go to {newsletterAddress} - - ); - // Pro users without custom sending domains return (