Merge branch 'main' into main

This commit is contained in:
Ryan Feigenbaum 2023-12-08 15:10:57 -05:00 committed by GitHub
commit c1ae3ad712
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
71 changed files with 1351 additions and 798 deletions

View File

@ -45,7 +45,7 @@ const COMMAND_ADMIN = {
const COMMAND_TYPESCRIPT = { const COMMAND_TYPESCRIPT = {
name: 'ts', 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, '../../'), cwd: path.resolve(__dirname, '../../'),
prefixColor: 'cyan', prefixColor: 'cyan',
env: {} env: {}
@ -55,7 +55,7 @@ const adminXApps = '@tryghost/admin-x-demo,@tryghost/admin-x-settings';
const COMMANDS_ADMINX = [{ const COMMANDS_ADMINX = [{
name: 'adminXDeps', 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, '../..'), cwd: path.resolve(__dirname, '../..'),
prefixColor: '#C35831', prefixColor: '#C35831',
env: {} env: {}

19
.github/scripts/docker-compose.yml vendored Normal file
View File

@ -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

0
.github/scripts/mysql-preload/.keep vendored Normal file
View File

94
.github/scripts/setup.js vendored Normal file
View File

@ -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`));
}
})();

View File

@ -27,13 +27,13 @@
], ],
"devDependencies": { "devDependencies": {
"@codemirror/lang-html": "^6.4.5", "@codemirror/lang-html": "^6.4.5",
"@storybook/addon-essentials": "7.6.3", "@storybook/addon-essentials": "7.6.4",
"@storybook/addon-interactions": "7.6.3", "@storybook/addon-interactions": "7.6.4",
"@storybook/addon-links": "7.6.3", "@storybook/addon-links": "7.6.4",
"@storybook/addon-styling": "1.3.7", "@storybook/addon-styling": "1.3.7",
"@storybook/blocks": "7.6.3", "@storybook/blocks": "7.6.4",
"@storybook/react": "7.6.3", "@storybook/react": "7.6.4",
"@storybook/react-vite": "7.6.3", "@storybook/react-vite": "7.6.4",
"@storybook/testing-library": "0.2.2", "@storybook/testing-library": "0.2.2",
"@testing-library/react": "14.1.0", "@testing-library/react": "14.1.0",
"@vitejs/plugin-react": "4.2.1", "@vitejs/plugin-react": "4.2.1",
@ -46,9 +46,9 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"rollup-plugin-node-builtins": "2.1.2", "rollup-plugin-node-builtins": "2.1.2",
"sinon": "17.0.0", "sinon": "17.0.0",
"storybook": "7.6.3", "storybook": "7.6.4",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "5.3.2", "typescript": "5.3.3",
"vite": "4.5.1", "vite": "4.5.1",
"vite-plugin-svgr": "3.3.0" "vite-plugin-svgr": "3.3.0"
}, },

View File

@ -5,6 +5,8 @@ export type Tab<ID = string> = {
id: ID; id: ID;
title: string; title: string;
counter?: number | null; counter?: number | null;
tabWrapperClassName?: string;
containerClassName?: string;
/** /**
* Optional, so you can just use the tabs to other views * Optional, so you can just use the tabs to other views
@ -102,6 +104,7 @@ export interface TabViewProps<ID = string> {
border?: boolean; border?: boolean;
buttonBorder?: boolean; buttonBorder?: boolean;
width?: TabWidth; width?: TabWidth;
containerClassName?: string;
} }
function TabView<ID extends string = string>({ function TabView<ID extends string = string>({
@ -110,7 +113,8 @@ function TabView<ID extends string = string>({
selectedTab, selectedTab,
border = true, border = true,
buttonBorder = border, buttonBorder = border,
width = 'normal' width = 'normal',
containerClassName
}: TabViewProps<ID>) { }: TabViewProps<ID>) {
if (tabs.length !== 0 && selectedTab === undefined) { if (tabs.length !== 0 && selectedTab === undefined) {
selectedTab = tabs[0].id; selectedTab = tabs[0].id;
@ -126,7 +130,7 @@ function TabView<ID extends string = string>({
}; };
return ( return (
<section> <section className={containerClassName}>
<TabList <TabList
border={border} border={border}
buttonBorder={buttonBorder} buttonBorder={buttonBorder}
@ -139,8 +143,8 @@ function TabView<ID extends string = string>({
return ( return (
<> <>
{tab.contents && {tab.contents &&
<div key={tab.id} className={`${selectedTab === tab.id ? 'block' : 'hidden'}`} role='tabpanel'> <div key={tab.id} className={`${selectedTab === tab.id ? 'block' : 'hidden'} ${tab.tabWrapperClassName}`} role='tabpanel'>
<div>{tab.contents}</div> <div className={tab.containerClassName}>{tab.contents}</div>
</div> </div>
} }
</> </>

View File

@ -78,7 +78,7 @@
"react-dom": "18.2.0", "react-dom": "18.2.0",
"sinon": "17.0.0", "sinon": "17.0.0",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "5.3.2" "typescript": "5.3.3"
}, },
"dependencies": { "dependencies": {
"@sentry/react": "7.85.0", "@sentry/react": "7.85.0",

View File

@ -176,6 +176,10 @@
"key": "portal_plans", "key": "portal_plans",
"value": "[\"monthly\",\"yearly\",\"free\"]" "value": "[\"monthly\",\"yearly\",\"free\"]"
}, },
{
"key": "portal_default_plan",
"value": "yearly"
},
{ {
"key": "portal_products", "key": "portal_products",
"value": "[]" "value": "[]"

View File

@ -1,93 +1,26 @@
import CodeModal from './code/CodeModal'; import CodeModal from './code/CodeModal';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal from '@ebay/nice-modal-react';
import React, {useMemo, useRef, useState} from 'react'; import React from 'react';
import TopLevelGroup from '../../TopLevelGroup'; import TopLevelGroup from '../../TopLevelGroup';
import useSettingGroup from '../../../hooks/useSettingGroup'; import {Button, SettingGroupHeader, withErrorBoundary} from '@tryghost/admin-x-design-system';
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';
const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => { const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
localSettings,
isEditing,
saveState,
handleSave,
handleCancel,
updateSetting,
handleEditingChange
} = useSettingGroup();
const [headerContent, footerContent] = getSettingValues<string>(localSettings, ['codeinjection_head', 'codeinjection_foot']);
const [selectedTab, setSelectedTab] = useState<'header' | 'footer'>('header');
const headerEditorRef = useRef<ReactCodeMirrorRef>(null);
const footerEditorRef = useRef<ReactCodeMirrorRef>(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: (<CodeEditor {...headerProps} ref={headerEditorRef} className='mt-2' data-testid='header-code' autoFocus />)
},
{
id: 'footer',
title: 'Site footer',
contents: (<CodeEditor {...footerProps} ref={footerEditorRef} className='mt-2' data-testid='footer-code' />)
}
] as const;
return ( return (
<TopLevelGroup <TopLevelGroup
customHeader={
<div className='z-10 flex items-start justify-between'>
<SettingGroupHeader description='Add custom code to your publication.' title='Code injection' />
<Button color='green' label='Open' link linkWithPadding onClick={() => {
NiceModal.show(CodeModal);
}} />
</div>
}
description="Add custom code to your publication" description="Add custom code to your publication"
isEditing={isEditing}
keywords={keywords} keywords={keywords}
navid='code-injection' navid='code-injection'
saveState={saveState}
testId='code-injection' testId='code-injection'
title="Code injection" title="Code injection"
onCancel={handleCancel} />
onEditingChange={handleEditingChange}
onSave={handleSave}
>
{isEditing && (
<div className='relative'>
<TabView<'header' | 'footer'> selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
<Button
className='absolute right-0 top-1 text-sm'
label='Fullscreen'
unstyled
onClick={() => NiceModal.show(CodeModal, {
...(selectedTab === 'header' ? headerProps : footerProps),
afterClose: () => {
if (selectedTab === 'header') {
headerEditorRef.current?.view?.focus();
} else {
footerEditorRef.current?.view?.focus();
}
}
})}
/>
</div>
)}
</TopLevelGroup>
); );
}; };

View File

@ -1,6 +1,10 @@
import NiceModal, {useModal} from '@ebay/nice-modal-react'; import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useMemo} from 'react'; import React, {useMemo, useRef, useState} from 'react';
import {CodeEditor, Modal} from '@tryghost/admin-x-design-system'; 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 { interface CodeModalProps {
hint?: React.ReactNode; hint?: React.ReactNode;
@ -9,18 +13,91 @@ interface CodeModalProps {
afterClose?: () => void afterClose?: () => void
} }
const CodeModal: React.FC<CodeModalProps> = ({hint, value, onChange, afterClose}) => { const CodeModal: React.FC<CodeModalProps> = ({afterClose}) => {
const {
localSettings,
handleSave,
updateSetting
} = useSettingGroup();
const modal = useModal(); const modal = useModal();
const [headerContent, footerContent] = getSettingValues<string>(localSettings, ['codeinjection_head', 'codeinjection_foot']);
const [selectedTab, setSelectedTab] = useState<'header' | 'footer'>('header');
const headerEditorRef = useRef<ReactCodeMirrorRef>(null);
const footerEditorRef = useRef<ReactCodeMirrorRef>(null);
const html = useMemo(() => import('@codemirror/lang-html').then(module => module.html()), []); const html = useMemo(() => import('@codemirror/lang-html').then(module => module.html()), []);
const onOk = () => { const headerProps = {
modal.remove(); extensions: [html],
afterClose?.(); 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 <Modal afterClose={afterClose} cancelLabel='' okColor='grey' okLabel='Done' size='full' testId='modal-code' onOk={onOk}> const footerProps = {
<CodeEditor extensions={[html]} height='full' hint={hint} value={value} autoFocus onChange={onChange} /> 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: (<CodeEditor height='full' {...headerProps} ref={headerEditorRef} className='mt-2' data-testid='header-code' autoFocus />),
tabWrapperClassName: 'flex-auto',
containerClassName: 'h-full'
},
{
id: 'footer',
title: 'Site footer',
contents: (<CodeEditor height='full' {...footerProps} ref={footerEditorRef} className='mt-2' data-testid='footer-code' />),
tabWrapperClassName: 'flex-auto',
containerClassName: 'h-full'
}
] as const;
const {savingTitle, isSaving, onSaveClick} = useSaveButton(handleSave, true);
return <Modal
afterClose={afterClose}
cancelLabel='Close'
footer={<></>}
height='full'
size='full'
testId='modal-code-injection'
>
<div className='flex h-full flex-col'>
<div className='mb-4 flex items-center justify-between'>
<Heading level={2}>Code injection</Heading>
<ButtonGroup buttons={[
{
label: 'Close',
color: 'outline',
onClick: () => {
modal.remove();
afterClose?.();
}
},
{
disabled: isSaving,
label: savingTitle,
color: savingTitle === 'Saved' ? 'green' : 'black',
onClick: onSaveClick
}
]} />
</div>
<TabView<'header' | 'footer'>
containerClassName='flex-auto flex flex-col mb-16'
selectedTab={selectedTab}
tabs={tabs}
onTabChange={setSelectedTab}
/>
</div>
</Modal>; </Modal>;
}; };

View File

@ -73,18 +73,12 @@ const ReplyToEmailField: React.FC<{
setSenderReplyTo(rendered); setSenderReplyTo(rendered);
}; };
const hint = (
<>
If left empty, replies go to {newsletterAddress}
</>
);
// Pro users without custom sending domains // Pro users without custom sending domains
return ( return (
<TextField <TextField
error={Boolean(errors.sender_reply_to)} error={Boolean(errors.sender_reply_to)}
hint={errors.sender_reply_to || hint} hint={errors.sender_reply_to}
placeholder={''} placeholder={newsletterAddress || ''}
title="Reply-to email" title="Reply-to email"
value={senderReplyTo} value={senderReplyTo}
onBlur={onBlur} onBlur={onBlur}
@ -219,8 +213,9 @@ const Sidebar: React.FC<{
return ( return (
<TextField <TextField
error={Boolean(errors.sender_email)} error={Boolean(errors.sender_email)}
hint={errors.sender_email || `If left empty, ${defaultEmailAddress} will be used`} hint={errors.sender_email}
rightPlaceholder={`@${sendingDomain(config)}`} placeholder={defaultEmailAddress}
rightPlaceholder={sendingEmailUsername ? `@${sendingDomain(config)}` : `` }
title="Sender email address" title="Sender email address"
value={sendingEmailUsername || ''} value={sendingEmailUsername || ''}
onBlur={validate} onBlur={validate}
@ -257,7 +252,7 @@ const Sidebar: React.FC<{
/> />
<TextArea rows={2} title="Description" value={newsletter.description || ''} onChange={e => updateNewsletter({description: e.target.value})} /> <TextArea rows={2} title="Description" value={newsletter.description || ''} onChange={e => updateNewsletter({description: e.target.value})} />
</Form> </Form>
<Form className='mt-6' gap='sm' margins='lg' title='Email addresses'> <Form className='mt-6' gap='sm' margins='lg' title='Email info'>
<TextField placeholder={siteTitle} title="Sender name" value={newsletter.sender_name || ''} onChange={e => updateNewsletter({sender_name: e.target.value})} /> <TextField placeholder={siteTitle} title="Sender name" value={newsletter.sender_name || ''} onChange={e => updateNewsletter({sender_name: e.target.value})} />
{renderSenderEmailField()} {renderSenderEmailField()}
<ReplyToEmailField clearError={clearError} errors={errors} newsletter={newsletter} updateNewsletter={updateNewsletter} validate={validate} /> <ReplyToEmailField clearError={clearError} errors={errors} newsletter={newsletter} updateNewsletter={updateNewsletter} validate={validate} />
@ -538,17 +533,18 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
savingDelay: 500, savingDelay: 500,
onSave: async () => { onSave: async () => {
const {newsletters: [updatedNewsletter], meta: {sent_email_verification: [emailToVerify] = []} = {}} = await editNewsletter(formState); ``; const {newsletters: [updatedNewsletter], meta: {sent_email_verification: [emailToVerify] = []} = {}} = await editNewsletter(formState); ``;
const previousFrom = renderSenderEmail(updatedNewsletter, config, defaultEmailAddress);
const previousReplyTo = renderReplyToEmail(updatedNewsletter, config, supportEmailAddress, defaultEmailAddress) || previousFrom;
let title; let title;
let prompt; let prompt;
if (emailToVerify && emailToVerify === 'sender_email') { if (emailToVerify && emailToVerify === 'sender_email') {
const previousFrom = renderSenderEmail(updatedNewsletter, config, defaultEmailAddress);
title = 'Confirm newsletter email address'; title = 'Confirm newsletter email address';
prompt = <>We&lsquo;ve sent a confirmation email to <strong>{formState.sender_email}</strong>. Until the address has been verified, newsletters will be sent from the {updatedNewsletter.sender_email ? ' previous' : ' default'} email address{previousFrom ? ` (${previousFrom})` : ''}.</>; prompt = <>We&lsquo;ve sent a confirmation email to <strong>{formState.sender_email}</strong>. Until the address has been verified, newsletters will be sent from the {updatedNewsletter.sender_email ? ' previous' : ' default'} email address{previousFrom ? ` (${previousFrom})` : ''}.</>;
} else if (emailToVerify && emailToVerify === 'sender_reply_to') { } else if (emailToVerify && emailToVerify === 'sender_reply_to') {
const previousReplyTo = renderReplyToEmail(updatedNewsletter, config, supportEmailAddress, defaultEmailAddress);
title = 'Confirm reply-to address'; title = 'Confirm reply-to address';
prompt = <>We&lsquo;ve sent a confirmation email to <strong>{formState.sender_reply_to}</strong>. Until the address has been verified, newsletters will use the previous reply-to address{previousReplyTo ? ` (${previousReplyTo})` : ''}.</>; prompt = <>We&lsquo;ve sent a confirmation email to <strong>{formState.sender_reply_to}</strong>. Until the address has been verified, replies will continue to go to {previousReplyTo}.</>;
} }
if (title && prompt) { if (title && prompt) {

View File

@ -48,17 +48,21 @@ const Offers: React.FC<{ keywords: string[] }> = ({keywords}) => {
const latestThree = activeOffers.slice(0, 3); const latestThree = activeOffers.slice(0, 3);
const openModal = () => { const openOfferListModal = () => {
updateRoute('offers/edit'); updateRoute('offers/edit');
}; };
const openAddModal = () => {
updateRoute('offers/new');
};
const goToOfferEdit = (offerId: string) => { const goToOfferEdit = (offerId: string) => {
updateRoute(`offers/edit/${offerId}`); updateRoute(`offers/edit/${offerId}`);
}; };
return ( return (
<TopLevelGroup <TopLevelGroup
customButtons={<Button color='green' disabled={!checkStripeEnabled(settings, config)} label='Manage offers' link linkWithPadding onClick={openModal}/>} customButtons={<Button color='green' disabled={!checkStripeEnabled(settings, config)} label={allOffers.length > 0 ? 'Manage offers' : 'Add offers'} link linkWithPadding onClick={allOffers.length > 0 ? openOfferListModal : openAddModal}/>}
description={<>Create discounts & coupons to boost new subscriptions. {allOffers.length === 0 && <a className='text-green' href="https://ghost.org/help/offers" rel="noopener noreferrer" target="_blank">Learn more</a>}</>} description={<>Create discounts & coupons to boost new subscriptions. {allOffers.length === 0 && <a className='text-green' href="https://ghost.org/help/offers" rel="noopener noreferrer" target="_blank">Learn more</a>}</>}
keywords={keywords} keywords={keywords}
navid='offers' navid='offers'

View File

@ -128,7 +128,7 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
handleDurationInMonthsInput, handleDurationInMonthsInput,
handleAmountInput, handleAmountInput,
handleCodeInput, handleCodeInput,
validate, clearError,
errors, errors,
testId, testId,
handleTrialAmountInput, handleTrialAmountInput,
@ -169,11 +169,16 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
maxLength={40} maxLength={40}
placeholder='Black Friday' placeholder='Black Friday'
title='Offer name' title='Offer name'
onBlur={validate} onBlur={(e) => {
if (!e.target.value && e.target.value.length === 0) {
errors.name = 'Name is required';
}
}}
onChange={(e) => { onChange={(e) => {
handleNameInput(e); handleNameInput(e);
setNameLength(e.target.value.length); setNameLength(e.target.value.length);
}} }}
onKeyDown={() => clearError('name')}
/> />
<TextField <TextField
error={Boolean(errors.displayTitle)} error={Boolean(errors.displayTitle)}
@ -181,10 +186,15 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
placeholder='Black Friday Special' placeholder='Black Friday Special'
title='Display title' title='Display title'
value={overrides.displayTitle.value} value={overrides.displayTitle.value}
onBlur={validate} onBlur={(e) => {
if (!e.target.value && e.target.value.length === 0) {
errors.displayTitle = 'Display title is required';
}
}}
onChange={(e) => { onChange={(e) => {
handleDisplayTitleInput(e); handleDisplayTitleInput(e);
}} }}
onKeyDown={() => clearError('displayTitle')}
/> />
<TextArea <TextArea
placeholder='Take advantage of this limited-time offer.' placeholder='Take advantage of this limited-time offer.'
@ -229,10 +239,23 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
? (overrides.fixedAmount === 0 ? '' : overrides.fixedAmount?.toString()) ? (overrides.fixedAmount === 0 ? '' : overrides.fixedAmount?.toString())
: (overrides.percentAmount === 0 ? '' : overrides.percentAmount?.toString()) : (overrides.percentAmount === 0 ? '' : overrides.percentAmount?.toString())
} }
onBlur={validate} onBlur={() => {
if (overrides.type === 'percent' && overrides.percentAmount === 0) {
errors.amount = 'Enter an amount greater than 0.';
}
if (overrides.type === 'percent' && overrides.percentAmount && (overrides.percentAmount < 0 || overrides.percentAmount >= 100)) {
errors.amount = 'Amount must be between 0 and 100%.';
}
if (overrides.type === 'fixed' && overrides.fixedAmount && overrides.fixedAmount <= 0) {
errors.amount = 'Enter an amount greater than 0.';
}
}}
onChange={(e) => { onChange={(e) => {
handleAmountInput(e); handleAmountInput(e);
}} }}
onKeyDown={() => clearError('amount')}
/> />
<div className='absolute right-1.5 top-6 z-10'> <div className='absolute right-1.5 top-6 z-10'>
<Select <Select
@ -268,10 +291,16 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
title='Trial duration' title='Trial duration'
type='number' type='number'
value={overrides.trialAmount?.toString()} value={overrides.trialAmount?.toString()}
onBlur={validate} onBlur={(e) => {
if (Number(e.target.value) < 1) {
errors.amount = 'Free trial must be at least 1 day.';
}
}}
onChange={(e) => { onChange={(e) => {
handleTrialAmountInput(e); handleTrialAmountInput(e);
}} /> }}
onKeyDown={() => clearError('amount')}/>
} }
<TextField <TextField
@ -280,10 +309,15 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
placeholder='black-friday' placeholder='black-friday'
title='Offer code' title='Offer code'
value={overrides.code.value} value={overrides.code.value}
onBlur={validate} onBlur={(e) => {
if (!e.target.value && e.target.value.length === 0) {
errors.code = 'Code is required';
}
}}
onChange={(e) => { onChange={(e) => {
handleCodeInput(e); handleCodeInput(e);
}} }}
onKeyDown={() => clearError('code')}
/> />
</div> </div>
</section> </section>
@ -630,7 +664,7 @@ const AddOfferModal = () => {
onCancel={cancelAddOffer} onCancel={cancelAddOffer}
onOk={async () => { onOk={async () => {
validate(); validate();
const isErrorsEmpty = Object.keys(errors).length === 0; const isErrorsEmpty = Object.values(errors).every(error => !error);
if (!isErrorsEmpty) { if (!isErrorsEmpty) {
showToast({ showToast({
type: 'pageError', type: 'pageError',

View File

@ -14,9 +14,9 @@ const SignupOptions: React.FC<{
setError: (key: string, error: string | undefined) => void setError: (key: string, error: string | undefined) => void
}> = ({localSettings, updateSetting, localTiers, updateTier, errors, setError}) => { }> = ({localSettings, updateSetting, localTiers, updateTier, errors, setError}) => {
const {config} = useGlobalData(); const {config} = useGlobalData();
const hasPortalImprovements = useFeatureFlag('portalImprovements');
const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson] = getSettingValues( const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson, portalDefaultPlan] = getSettingValues(
localSettings, ['members_signup_access', 'portal_name', 'portal_signup_terms_html', 'portal_signup_checkbox_required', 'portal_plans'] localSettings, ['members_signup_access', 'portal_name', 'portal_signup_terms_html', 'portal_signup_checkbox_required', 'portal_plans', 'portal_default_plan']
); );
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[]; const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];
@ -50,6 +50,20 @@ const SignupOptions: React.FC<{
} }
updateSetting('portal_plans', JSON.stringify(portalPlans)); updateSetting('portal_plans', JSON.stringify(portalPlans));
// Check default plan is included
if (hasPortalImprovements) {
if (portalDefaultPlan === 'yearly') {
if (!portalPlans.includes('yearly') && portalPlans.includes('monthly')) {
updateSetting('portal_default_plan', 'monthly');
}
} else if (portalDefaultPlan === 'monthly') {
if (!portalPlans.includes('monthly')) {
// If both yearly and monthly are missing from plans, still set it to yearly
updateSetting('portal_default_plan', 'yearly');
}
}
}
}; };
// This is a bit unclear in current admin, maybe we should add a message if the settings are disabled? // This is a bit unclear in current admin, maybe we should add a message if the settings are disabled?
@ -86,13 +100,10 @@ const SignupOptions: React.FC<{
} }
const paidActiveTiersResult = getPaidActiveTiers(localTiers) || []; const paidActiveTiersResult = getPaidActiveTiers(localTiers) || [];
const hasPortalImprovements = useFeatureFlag('portalImprovements');
// TODO: Hook up with actual values and then delete this
const selectOptions: SelectOption[] = [
{value: 'default-yearly', label: 'Yearly'},
{value: 'default-monthly', label: 'Monthly'}
const defaultPlanOptions: SelectOption[] = [
{value: 'yearly', label: 'Yearly'},
{value: 'monthly', label: 'Monthly'}
]; ];
if (paidActiveTiersResult.length > 0 && isStripeEnabled) { if (paidActiveTiersResult.length > 0 && isStripeEnabled) {
@ -145,9 +156,17 @@ const SignupOptions: React.FC<{
]} ]}
title='Prices available at signup' title='Prices available at signup'
/> />
{hasPortalImprovements && <Select disabled={(portalPlans.includes('yearly') && portalPlans.includes('monthly')) ? false : true} options={selectOptions} selectedOption={selectOptions.find(option => option.value === (portalPlans.includes('yearly') ? 'default-yearly' : 'default-monthly'))} title='Price shown by default' onSelect={(value) => { {hasPortalImprovements &&
alert(value); <Select
}} />} disabled={(portalPlans.includes('yearly') && portalPlans.includes('monthly')) ? false : true}
options={defaultPlanOptions}
selectedOption={defaultPlanOptions.find(option => option.value === portalDefaultPlan)}
title='Price shown by default'
onSelect={(option) => {
updateSetting('portal_default_plan', option?.value ?? 'yearly');
}}
/>
}
</> </>
)} )}

View File

@ -8,7 +8,7 @@ import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framewor
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing'; import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
import {Tier, useAddTier, useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers'; import {Tier, useAddTier, useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers';
import {currencies, currencySelectGroups, validateCurrencyAmount} from '../../../../utils/currency'; import {currencies, currencySelectGroups, validateCurrencyAmount} from '../../../../utils/currency';
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings';
import {toast} from 'react-hot-toast'; import {toast} from 'react-hot-toast';
export type TierFormState = Partial<Omit<Tier, 'trial_days'>> & { export type TierFormState = Partial<Omit<Tier, 'trial_days'>> & {
@ -22,12 +22,14 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
const {updateRoute} = useRouting(); const {updateRoute} = useRouting();
const {mutateAsync: updateTier} = useEditTier(); const {mutateAsync: updateTier} = useEditTier();
const {mutateAsync: createTier} = useAddTier(); const {mutateAsync: createTier} = useAddTier();
const {mutateAsync: editSettings} = useEditSettings();
const [hasFreeTrial, setHasFreeTrial] = React.useState(!!tier?.trial_days); const [hasFreeTrial, setHasFreeTrial] = React.useState(!!tier?.trial_days);
const handleError = useHandleError(); const handleError = useHandleError();
const {localSettings, siteData} = useSettingGroup(); const {localSettings, siteData} = useSettingGroup();
const siteTitle = getSettingValues(localSettings, ['title']) as string[]; const [siteTitle, portalPlansJson] = getSettingValues(localSettings, ['title', 'portal_plans']) as string[];
const hasPortalImprovements = useFeatureFlag('portalImprovements'); const hasPortalImprovements = useFeatureFlag('portalImprovements');
const allowNameChange = !isFreeTier || hasPortalImprovements; const allowNameChange = !isFreeTier || hasPortalImprovements;
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];
const validators: {[key in keyof Tier]?: () => string | undefined} = { const validators: {[key in keyof Tier]?: () => string | undefined} = {
name: () => (formState.name ? undefined : 'You must specify a name'), name: () => (formState.name ? undefined : 'You must specify a name'),
@ -70,6 +72,31 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
} else { } else {
await createTier(values); await createTier(values);
} }
if (isFreeTier && hasPortalImprovements) {
// If we changed the visibility, we also need to update Portal settings in some situations
// Like the free tier is a special case, and should also be present/absent in portal_plans
const visible = formState.visibility === 'public';
let save = false;
if (portalPlans.includes('free') && !visible) {
portalPlans.splice(portalPlans.indexOf('free'), 1);
save = true;
}
if (!portalPlans.includes('free') && visible) {
portalPlans.push('free');
save = true;
}
if (save) {
await editSettings([
{
key: 'portal_plans',
value: JSON.stringify(portalPlans)
}
]);
}
}
}, },
onSavedStateReset: () => { onSavedStateReset: () => {
modal.remove(); modal.remove();
@ -335,8 +362,19 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
</div> </div>
<div className='sticky top-[96px] hidden shrink-0 basis-[380px] min-[920px]:!visible min-[920px]:!block'> <div className='sticky top-[96px] hidden shrink-0 basis-[380px] min-[920px]:!visible min-[920px]:!block'>
<TierDetailPreview isFreeTier={isFreeTier} tier={formState} /> <TierDetailPreview isFreeTier={isFreeTier} tier={formState} />
{!tier && hasPortalImprovements && <Form className=' mt-3' gap='none'><Checkbox checked={false} hint='You can always change this in portal settings' label='Show tier in portal for new signups' value='fakeShow' onChange={() => {}}></Checkbox></Form>} {hasPortalImprovements &&
<Form className=' mt-3' gap='none'>
<Checkbox
checked={formState.visibility === 'public'}
hint='You can always change this in portal settings' label='Show tier in portal for new signups'
value='fakeShow'
onChange={(checked) => {
updateForm(state => ({...state, visibility: checked ? 'public' : 'none'}));
}}
></Checkbox>
</Form>
}
</div> </div>
</div> </div>
</Modal>; </Modal>;

View File

@ -0,0 +1,32 @@
import {SaveHandler} from '@tryghost/admin-x-framework/hooks';
import {useState} from 'react';
export const useSaveButton = (handleSave: SaveHandler, fakeWhenUnchanged?: boolean) => {
const [savingTitle, setSavingTitle] = useState('Save');
const [isSaving, setIsSaving] = useState(false);
const onSaveClick = async () => {
setIsSaving(true);
setSavingTitle('Saving');
// Execute the save operation
await handleSave({fakeWhenUnchanged});
// After a second, change the label to 'Saved'
setTimeout(() => {
setSavingTitle('Saved');
// After yet another second, reset to 'Save'
setTimeout(() => {
setSavingTitle('Save');
setIsSaving(false);
}, 1000);
}, 1000);
};
return {
savingTitle,
isSaving,
onSaveClick
};
};

View File

@ -39,7 +39,12 @@ export const getPortalPreviewUrl = ({settings, config, tiers, siteData, selected
settingsParam.append('allowSelfSignup', allowSelfSignup ? 'true' : 'false'); settingsParam.append('allowSelfSignup', allowSelfSignup ? 'true' : 'false');
settingsParam.append('signupTermsHtml', getSettingValue(settings, 'portal_signup_terms_html') || ''); settingsParam.append('signupTermsHtml', getSettingValue(settings, 'portal_signup_terms_html') || '');
settingsParam.append('signupCheckboxRequired', getSettingValue(settings, 'portal_signup_checkbox_required') ? 'true' : 'false'); settingsParam.append('signupCheckboxRequired', getSettingValue(settings, 'portal_signup_checkbox_required') ? 'true' : 'false');
settingsParam.append('portalProducts', encodeURIComponent(portalTiers.join(','))); // assuming that it might be more than 1 settingsParam.append('portalProducts', portalTiers.join(',')); // assuming that it might be more than 1
const portalDefaultPlan = getSettingValue<string>(settings, 'portal_default_plan');
if (portalDefaultPlan) {
settingsParam.append('portalDefaultPlan', portalDefaultPlan);
}
if (portalPlans && portalPlans.length) { if (portalPlans && portalPlans.length) {
settingsParam.append('portalPrices', encodeURIComponent(portalPlans.join(','))); settingsParam.append('portalPrices', encodeURIComponent(portalPlans.join(',')));

View File

@ -21,23 +21,24 @@ test.describe('Code injection settings', async () => {
const section = page.getByTestId('code-injection'); const section = page.getByTestId('code-injection');
await section.getByRole('button', {name: 'Edit'}).click(); await section.getByRole('button', {name: 'Open'}).click();
const modal = page.getByTestId('modal-code-injection');
// Click on the CodeMirror content to make sure it's loaded // Click on the CodeMirror content to make sure it's loaded
await section.getByTestId('header-code').locator('.cm-content').click(); await modal.getByTestId('header-code').locator('.cm-content').click();
for (const character of (PADDING + 'testhead').split('')) { for (const character of (PADDING + 'testhead').split('')) {
await page.keyboard.press(character); await page.keyboard.press(character);
} }
await section.getByRole('tab', {name: 'Site footer'}).click(); await modal.getByRole('tab', {name: 'Site footer'}).click();
await section.getByTestId('footer-code').locator('.cm-content').click(); await modal.getByTestId('footer-code').locator('.cm-content').click();
for (const character of (PADDING + 'testfoot').split('')) { for (const character of (PADDING + 'testfoot').split('')) {
await page.keyboard.press(character); await page.keyboard.press(character);
} }
await section.getByRole('button', {name: 'Save'}).click(); await modal.getByRole('button', {name: 'Save'}).click();
await expect(section.getByRole('button', {name: 'Save'})).toBeHidden();
expect(lastApiRequests.editSettings?.body).toMatchObject({ expect(lastApiRequests.editSettings?.body).toMatchObject({
settings: [ settings: [
@ -46,50 +47,4 @@ test.describe('Code injection settings', async () => {
] ]
}); });
}); });
test('Supports continuing editing in fullscreen', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([
{key: 'codeinjection_head', value: '<1 /><2 /><3 />'}
])}
}});
await page.goto('/');
const section = page.getByTestId('code-injection');
await section.getByRole('button', {name: 'Edit'}).click();
for (const character of PADDING.split('')) {
await page.keyboard.press(character);
}
for (const character of '<1>'.split('')) {
await page.keyboard.press(character);
}
await section.getByRole('button', {name: 'Fullscreen'}).click();
await page.keyboard.press('End');
for (const character of '<2>'.split('')) {
await page.keyboard.press(character);
}
await page.getByTestId('modal-code').getByRole('button', {name: 'Done'}).click();
await page.keyboard.press('End');
for (const character of '<3>'.split('')) {
await page.keyboard.press(character);
}
await section.getByRole('button', {name: 'Save'}).click();
await expect(section.getByRole('button', {name: 'Save'})).toBeHidden();
expect(lastApiRequests.editSettings?.body).toMatchObject({
settings: [
{key: 'codeinjection_head', value: /<1><2><3>$/}
]
});
});
}); });

View File

@ -247,12 +247,12 @@ test.describe('Newsletter settings', async () => {
await expect(page.getByTestId('confirmation-modal')).toHaveCount(1); await expect(page.getByTestId('confirmation-modal')).toHaveCount(1);
await expect(page.getByTestId('confirmation-modal')).toHaveText(/Confirm reply-to address/); await expect(page.getByTestId('confirmation-modal')).toHaveText(/Confirm reply-to address/);
await expect(page.getByTestId('confirmation-modal')).toHaveText(/sent a confirmation email to test@test.com/); await expect(page.getByTestId('confirmation-modal')).toHaveText(/sent a confirmation email to test@test.com/);
await expect(page.getByTestId('confirmation-modal')).toHaveText(/previous reply-to address \(support@example.com\)/); await expect(page.getByTestId('confirmation-modal')).toHaveText(/replies will continue to go to support@example.com/);
}); });
}); });
test.describe('For Ghost (Pro) users with custom domain', () => { test.describe('For Ghost (Pro) users with custom sending domain', () => {
test('Allow sender and reply-to addresses to be changed without verification, but not their domain name', async ({page}) => { test('Allow sender address to be changed partially (username but not domain name)', async ({page}) => {
await mockApi({page, requests: { await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters}, browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters},
@ -283,25 +283,17 @@ test.describe('Newsletter settings', async () => {
await section.getByText('Awesome newsletter').click(); await section.getByText('Awesome newsletter').click();
const modal = page.getByTestId('newsletter-modal'); const modal = page.getByTestId('newsletter-modal');
const senderEmail = modal.getByLabel('Sender email'); const senderEmail = modal.getByLabel('Sender email');
const replyToEmail = modal.getByLabel('Reply-to email');
// The sending domain is rendered as placeholder text
expect(modal).toHaveText(/@customdomain\.com/);
// The sender email field should keep the username part of the email address // The sender email field should keep the username part of the email address
await senderEmail.fill('harry@potter.com'); await senderEmail.fill('harry@potter.com');
expect(await senderEmail.inputValue()).toBe('harry'); expect(await senderEmail.inputValue()).toBe('harry');
// Full flexibility for the reply-to address
await replyToEmail.fill('hermione@customdomain.com');
expect(await replyToEmail.inputValue()).toBe('hermione@customdomain.com');
// The new username is saved without a confirmation popup // The new username is saved without a confirmation popup
await modal.getByRole('button', {name: 'Save'}).click(); await modal.getByRole('button', {name: 'Save'}).click();
await expect(page.getByTestId('confirmation-modal')).toHaveCount(0); await expect(page.getByTestId('confirmation-modal')).toHaveCount(0);
}); });
test('Reply-To addresses without a matching domain require verification', async ({page}) => { test('Allow full customisation of the reply-to address, with verification', async ({page}) => {
await mockApi({page, requests: { await mockApi({page, requests: {
...globalDataRequests, ...globalDataRequests,
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters}, browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters},
@ -331,26 +323,18 @@ test.describe('Newsletter settings', async () => {
const section = page.getByTestId('newsletters'); const section = page.getByTestId('newsletters');
await section.getByText('Awesome newsletter').click(); await section.getByText('Awesome newsletter').click();
const modal = page.getByTestId('newsletter-modal'); const modal = page.getByTestId('newsletter-modal');
const senderEmail = modal.getByLabel('Sender email');
const replyToEmail = modal.getByLabel('Reply-to email'); const replyToEmail = modal.getByLabel('Reply-to email');
// The sending domain is rendered as placeholder text
expect(modal).toHaveText(/@customdomain\.com/);
// The sender email field should keep the username part of the email address
await senderEmail.fill('harry@potter.com');
expect(await senderEmail.inputValue()).toBe('harry');
// Full flexibility for the reply-to address // Full flexibility for the reply-to address
await replyToEmail.fill('hermione@granger.com'); await replyToEmail.fill('hermione@granger.com');
expect(await replyToEmail.inputValue()).toBe('hermione@granger.com'); expect(await replyToEmail.inputValue()).toBe('hermione@granger.com');
// The new username is saved without a confirmation popup // There is a verification popup for the new reply-to address
await modal.getByRole('button', {name: 'Save'}).click(); await modal.getByRole('button', {name: 'Save'}).click();
await expect(page.getByTestId('confirmation-modal')).toHaveCount(1); await expect(page.getByTestId('confirmation-modal')).toHaveCount(1);
await expect(page.getByTestId('confirmation-modal')).toHaveText(/Confirm reply-to address/); await expect(page.getByTestId('confirmation-modal')).toHaveText(/Confirm reply-to address/);
await expect(page.getByTestId('confirmation-modal')).toHaveText(/sent a confirmation email to hermione@granger.com/); await expect(page.getByTestId('confirmation-modal')).toHaveText(/sent a confirmation email to hermione@granger.com/);
await expect(page.getByTestId('confirmation-modal')).toHaveText(/previous reply-to address \(support@example.com\)/); await expect(page.getByTestId('confirmation-modal')).toHaveText(/replies will continue to go to support@example.com/);
}); });
}); });
}); });

View File

@ -14,16 +14,16 @@ test.describe('Offers Modal', () => {
}}); }});
await page.goto('/'); await page.goto('/');
const section = page.getByTestId('offers'); const section = page.getByTestId('offers');
await section.getByRole('button', {name: 'Manage offers'}).click(); await section.getByRole('button', {name: 'Add offers'}).click();
const modal = page.getByTestId('offers-modal'); const addModal = page.getByTestId('add-offer-modal');
await expect(modal).toBeVisible(); await expect(addModal).toBeVisible();
}); });
test('Offers Add Modal is available', async ({page}) => { test('Offers Add Modal is available', async ({page}) => {
await mockApi({page, requests: { await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests, ...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe}, browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers}, browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
addOffer: {method: 'POST', path: '/offers/', response: { addOffer: {method: 'POST', path: '/offers/', response: {
offers: [{ offers: [{
@ -45,9 +45,9 @@ test.describe('Offers Modal', () => {
test('Can add a new offer', async ({page}) => { test('Can add a new offer', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests, ...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe}, browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers}, browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers}, browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
addOffer: {method: 'POST', path: `/offers/`, response: { addOffer: {method: 'POST', path: `/offers/`, response: {
@ -85,9 +85,9 @@ test.describe('Offers Modal', () => {
test('Errors if required fields are missing', async ({page}) => { test('Errors if required fields are missing', async ({page}) => {
await mockApi({page, requests: { await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests, ...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe}, browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers}, browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers}, browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
addOffer: {method: 'POST', path: `/offers/`, response: { addOffer: {method: 'POST', path: `/offers/`, response: {
@ -112,9 +112,9 @@ test.describe('Offers Modal', () => {
test('Shows validation hints', async ({page}) => { test('Shows validation hints', async ({page}) => {
await mockApi({page, requests: { await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests, ...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe}, browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers}, browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers}, browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
addOffer: {method: 'POST', path: `/offers/`, response: { addOffer: {method: 'POST', path: `/offers/`, response: {
@ -144,9 +144,10 @@ test.describe('Offers Modal', () => {
test('Can view active offers', async ({page}) => { test('Can view active offers', async ({page}) => {
await mockApi({page, requests: { await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests, ...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe}, browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers}, browseAllOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers} browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers}
}}); }});
@ -161,9 +162,10 @@ test.describe('Offers Modal', () => {
test('Can view archived offers', async ({page}) => { test('Can view archived offers', async ({page}) => {
await mockApi({page, requests: { await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests, ...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe}, browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers}, browseAllOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers} browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers}
}}); }});
@ -178,9 +180,10 @@ test.describe('Offers Modal', () => {
test('Supports updating an offer', async ({page}) => { test('Supports updating an offer', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: { const {lastApiRequests} = await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests, ...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe}, browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers}, browseAllOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers}, browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers}, browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
editOffer: {method: 'PUT', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: { editOffer: {method: 'PUT', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: {

View File

@ -1,6 +1,6 @@
{ {
"name": "@tryghost/portal", "name": "@tryghost/portal",
"version": "2.36.5", "version": "2.37.0",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -311,7 +311,10 @@ export default class App extends React.Component {
// Handle the query params key/value pairs // Handle the query params key/value pairs
for (let pair of qsParams.entries()) { for (let pair of qsParams.entries()) {
const key = pair[0]; const key = pair[0];
// Note: this needs to be cleaned up, there is no reason why we need to double encode/decode
const value = decodeURIComponent(pair[1]); const value = decodeURIComponent(pair[1]);
if (key === 'button') { if (key === 'button') {
data.site.portal_button = JSON.parse(value); data.site.portal_button = JSON.parse(value);
} else if (key === 'name') { } else if (key === 'name') {
@ -357,6 +360,8 @@ export default class App extends React.Component {
data.site.allow_self_signup = JSON.parse(value); data.site.allow_self_signup = JSON.parse(value);
} else if (key === 'membersSignupAccess' && value) { } else if (key === 'membersSignupAccess' && value) {
data.site.members_signup_access = value; data.site.members_signup_access = value;
} else if (key === 'portalDefaultPlan' && value) {
data.site.portal_default_plan = value;
} }
} }
data.site.portal_plans = allowedPlans; data.site.portal_plans = allowedPlans;
@ -389,6 +394,7 @@ export default class App extends React.Component {
} }
]; ];
} }
return data; return data;
} }

View File

@ -894,7 +894,7 @@ function getSelectedPrice({products, selectedProduct, selectedInterval}) {
return selectedPrice; return selectedPrice;
} }
function getActiveInterval({portalPlans, selectedInterval = 'year'}) { function getActiveInterval({portalPlans, portalDefaultPlan, selectedInterval}) {
if (selectedInterval === 'month' && portalPlans.includes('monthly')) { if (selectedInterval === 'month' && portalPlans.includes('monthly')) {
return 'month'; return 'month';
} }
@ -903,26 +903,32 @@ function getActiveInterval({portalPlans, selectedInterval = 'year'}) {
return 'year'; return 'year';
} }
if (portalPlans.includes('monthly')) { if (portalDefaultPlan) {
return 'month'; if (portalDefaultPlan === 'monthly' && portalPlans.includes('monthly')) {
return 'month';
}
} }
if (portalPlans.includes('yearly')) { if (portalPlans.includes('yearly')) {
return 'year'; return 'year';
} }
if (portalPlans.includes('monthly')) {
return 'month';
}
} }
function ProductsSection({onPlanSelect, products, type = null, handleChooseSignup, errors}) { function ProductsSection({onPlanSelect, products, type = null, handleChooseSignup, errors}) {
const {site, member, t} = useContext(AppContext); const {site, member, t} = useContext(AppContext);
const {portal_plans: portalPlans} = site; const {portal_plans: portalPlans, portal_default_plan: portalDefaultPlan} = site;
const defaultInterval = getActiveInterval({portalPlans});
const defaultProductId = products.length > 0 ? products[0].id : 'free'; const defaultProductId = products.length > 0 ? products[0].id : 'free';
const [selectedInterval, setSelectedInterval] = useState(defaultInterval);
// Note: by default we set it to null, so that it changes reactively in the preview version of Portal
const [selectedInterval, setSelectedInterval] = useState(null);
const [selectedProduct, setSelectedProduct] = useState(defaultProductId); const [selectedProduct, setSelectedProduct] = useState(defaultProductId);
const selectedPrice = getSelectedPrice({products, selectedInterval, selectedProduct}); const selectedPrice = getSelectedPrice({products, selectedInterval, selectedProduct});
const activeInterval = getActiveInterval({portalPlans, selectedInterval}); const activeInterval = getActiveInterval({portalPlans, portalDefaultPlan, selectedInterval});
const isComplimentary = isComplimentaryMember({member}); const isComplimentary = isComplimentaryMember({member});

View File

@ -451,11 +451,6 @@ export function getFreeProductBenefits({site}) {
export function getFreeTierTitle({site}) { export function getFreeTierTitle({site}) {
const freeProduct = getFreeProduct({site}); const freeProduct = getFreeProduct({site});
if (freeProduct?.name === 'Free' && hasOnlyFreeProduct({site})) {
return 'Free membership';
}
return freeProduct?.name || 'Free'; return freeProduct?.name || 'Free';
} }

View File

@ -40,13 +40,13 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.38.1", "@playwright/test": "1.38.1",
"@storybook/addon-essentials": "7.6.3", "@storybook/addon-essentials": "7.6.4",
"@storybook/addon-interactions": "7.6.3", "@storybook/addon-interactions": "7.6.4",
"@storybook/addon-links": "7.6.3", "@storybook/addon-links": "7.6.4",
"@storybook/addon-styling": "1.3.7", "@storybook/addon-styling": "1.3.7",
"@storybook/blocks": "7.6.3", "@storybook/blocks": "7.6.4",
"@storybook/react": "7.6.3", "@storybook/react": "7.6.4",
"@storybook/react-vite": "7.6.3", "@storybook/react-vite": "7.6.4",
"@storybook/testing-library": "0.2.2", "@storybook/testing-library": "0.2.2",
"@tailwindcss/line-clamp": "0.4.4", "@tailwindcss/line-clamp": "0.4.4",
"@types/react": "18.2.42", "@types/react": "18.2.42",
@ -61,7 +61,7 @@
"postcss-import": "15.1.0", "postcss-import": "15.1.0",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"rollup-plugin-node-builtins": "2.1.2", "rollup-plugin-node-builtins": "2.1.2",
"storybook": "7.6.3", "storybook": "7.6.4",
"stylelint": "15.10.3", "stylelint": "15.10.3",
"tailwindcss": "3.3.6", "tailwindcss": "3.3.6",
"vite": "4.5.1", "vite": "4.5.1",

View File

@ -50,7 +50,7 @@
{{svg-jar "koenig/kg-trash"}} {{svg-jar "koenig/kg-trash"}}
</button> </button>
</div> </div>
<div class="flex justify-between align-center"> <div class="relative flex justify-between align-center">
{{#if this.isEditingAlt}} {{#if this.isEditingAlt}}
<input <input
type="text" type="text"
@ -70,6 +70,8 @@
@placeholderText={{if this.captionInputFocused "" "Add a caption to the feature image"}} @placeholderText={{if this.captionInputFocused "" "Add a caption to the feature image"}}
@onFocus={{fn (mut this.captionInputFocused) true}} @onFocus={{fn (mut this.captionInputFocused) true}}
@onBlur={{this.handleCaptionBlur}} @onBlur={{this.handleCaptionBlur}}
@onTKCountChange={{this.onTKCountChange}}
@registerAPI={{this.registerEditorAPI}}
/> />
{{/if}} {{/if}}
<button <button
@ -78,6 +80,12 @@
> >
Alt Alt
</button> </button>
{{#if (and this.tkCount (not this.isEditingAlt))}}
<div class="tk-indicator" data-testid="feature-image-tk-indicator" role="button" {{on "click" this.focusCaptionEditor}}>
TK
</div>
{{/if}}
</div> </div>
{{else}} {{else}}
{{!-- no-image state --}} {{!-- no-image state --}}

View File

@ -18,6 +18,7 @@ export default class GhEditorFeatureImageComponent extends Component {
@tracked captionInputFocused = false; @tracked captionInputFocused = false;
@tracked showUnsplashSelector = false; @tracked showUnsplashSelector = false;
@tracked canDrop = false; @tracked canDrop = false;
@tracked tkCount = 0;
get caption() { get caption() {
const content = this.args.caption; const content = this.args.caption;
@ -34,6 +35,18 @@ export default class GhEditorFeatureImageComponent extends Component {
this.args.updateCaption(cleanedHtml); this.args.updateCaption(cleanedHtml);
} }
@action
registerEditorAPI(API) {
this.editorAPI = API;
}
@action
focusCaptionEditor() {
if (this.editorAPI) {
this.editorAPI.focusEditor({position: 'bottom'});
}
}
@action @action
handleCaptionBlur() { handleCaptionBlur() {
this.captionInputFocused = false; this.captionInputFocused = false;
@ -116,4 +129,12 @@ export default class GhEditorFeatureImageComponent extends Component {
this.canDrop = false; this.canDrop = false;
setFiles([imageFile]); setFiles([imageFile]);
} }
@action
onTKCountChange(count) {
if (this.args.onTKCountChange) {
this.tkCount = count;
this.args.onTKCountChange(count);
}
}
} }

View File

@ -19,6 +19,7 @@
@handleCaptionBlur={{@handleFeatureImageCaptionBlur}} @handleCaptionBlur={{@handleFeatureImageCaptionBlur}}
@forceButtonDisplay={{or (not @title) (eq @title "(Untitled)") this.titleIsHovered this.titleIsFocused}} @forceButtonDisplay={{or (not @title) (eq @title "(Untitled)") this.titleIsHovered this.titleIsFocused}}
@isHidden={{or (not @cardOptions.post.showTitleAndFeatureImage) false}} @isHidden={{or (not @cardOptions.post.showTitleAndFeatureImage) false}}
@onTKCountChange={{@updateFeatureImageTkCount}}
/> />
<div class="gh-editor-title-container page-improvements"> <div class="gh-editor-title-container page-improvements">

View File

@ -14,6 +14,18 @@ class ErrorHandler extends React.Component {
return {hasError: true}; return {hasError: true};
} }
componentDidCatch(error) {
if (this.props.config.sentry_dsn) {
Sentry.captureException(error, {
tags: {
lexical: true
}
});
}
console.error(error, errorInfo); // eslint-disable-line
}
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
@ -45,6 +57,11 @@ const EmojiPickerPlugin = ({editorResource, ...props}) => {
return <_EmojiPickerPlugin {...props} />; return <_EmojiPickerPlugin {...props} />;
}; };
const TKCountPlugin = ({editorResource, ...props}) => {
const {TKCountPlugin: _TKCountPlugin} = editorResource.read();
return <_TKCountPlugin {...props} />;
};
export default class KoenigLexicalEditorInput extends Component { export default class KoenigLexicalEditorInput extends Component {
@service ajax; @service ajax;
@service feature; @service feature;
@ -82,12 +99,13 @@ export default class KoenigLexicalEditorInput extends Component {
ReactComponent = (props) => { ReactComponent = (props) => {
return ( return (
<div className={['koenig-react-editor', this.args.className].filter(Boolean).join(' ')}> <div className={['koenig-react-editor', this.args.className].filter(Boolean).join(' ')}>
<ErrorHandler> <ErrorHandler config={this.config}>
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}> <Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
<KoenigComposer <KoenigComposer
editorResource={this.editorResource} editorResource={this.editorResource}
initialEditorState={this.args.lexical} initialEditorState={this.args.lexical}
onError={this.onError} onError={this.onError}
isTKEnabled={this.args.onTKCountChange ? true : false}
> >
<KoenigComposableEditor <KoenigComposableEditor
editorResource={this.editorResource} editorResource={this.editorResource}
@ -100,9 +118,11 @@ export default class KoenigLexicalEditorInput extends Component {
className={`koenig-lexical-editor-input ${this.feature.nightShift ? 'dark' : ''}`} className={`koenig-lexical-editor-input ${this.feature.nightShift ? 'dark' : ''}`}
placeholderText={props.placeholderText} placeholderText={props.placeholderText}
placeholderClassName="koenig-lexical-editor-input-placeholder" placeholderClassName="koenig-lexical-editor-input-placeholder"
registerAPI={this.args.registerAPI}
> >
<HtmlOutputPlugin editorResource={this.editorResource} html={props.html} setHtml={props.onChangeHtml} /> <HtmlOutputPlugin editorResource={this.editorResource} html={props.html} setHtml={props.onChangeHtml} />
{this.emojiPicker ? <EmojiPickerPlugin editorResource={this.editorResource} /> : null} {this.emojiPicker ? <EmojiPickerPlugin editorResource={this.editorResource} /> : null}
{this.args.onTKCountChange ? <TKCountPlugin editorResource={this.editorResource} onChange={this.args.onTKCountChange} /> : null}
</KoenigComposableEditor> </KoenigComposableEditor>
</KoenigComposer> </KoenigComposer>
</Suspense> </Suspense>

View File

@ -49,6 +49,18 @@ class ErrorHandler extends React.Component {
return {hasError: true}; return {hasError: true};
} }
componentDidCatch(error) {
if (this.props.config.sentry_dsn) {
Sentry.captureException(error, {
tags: {
lexical: true
}
});
}
console.error(error, errorInfo); // eslint-disable-line
}
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( return (
@ -75,9 +87,9 @@ const WordCountPlugin = ({editorResource, ...props}) => {
return <_WordCountPlugin {...props} />; return <_WordCountPlugin {...props} />;
}; };
const TKPlugin = ({editorResource, ...props}) => { const TKCountPlugin = ({editorResource, ...props}) => {
const {TKPlugin: _TKPlugin} = editorResource.read(); const {TKCountPlugin: _TKCountPlugin} = editorResource.read();
return <_TKPlugin {...props} />; return <_TKCountPlugin {...props} />;
}; };
export default class KoenigLexicalEditor extends Component { export default class KoenigLexicalEditor extends Component {
@ -513,7 +525,7 @@ export default class KoenigLexicalEditor extends Component {
return ( return (
<div className={['koenig-react-editor', 'koenig-lexical', this.args.className].filter(Boolean).join(' ')}> <div className={['koenig-react-editor', 'koenig-lexical', this.args.className].filter(Boolean).join(' ')}>
<ErrorHandler> <ErrorHandler config={this.config}>
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}> <Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
<KoenigComposer <KoenigComposer
editorResource={this.editorResource} editorResource={this.editorResource}
@ -526,6 +538,7 @@ export default class KoenigLexicalEditor extends Component {
multiplayerEndpoint={multiplayerEndpoint} multiplayerEndpoint={multiplayerEndpoint}
onError={this.onError} onError={this.onError}
darkMode={this.feature.nightShift} darkMode={this.feature.nightShift}
isTKEnabled={this.feature.tkReminders}
> >
<KoenigEditor <KoenigEditor
editorResource={this.editorResource} editorResource={this.editorResource}
@ -536,7 +549,7 @@ export default class KoenigLexicalEditor extends Component {
registerAPI={this.args.registerAPI} registerAPI={this.args.registerAPI}
/> />
<WordCountPlugin editorResource={this.editorResource} onChange={this.args.updateWordCount} /> <WordCountPlugin editorResource={this.editorResource} onChange={this.args.updateWordCount} />
{this.feature.tkReminders && <TKPlugin editorResource={this.editorResource} onCountChange={this.args.updatePostTkCount} />} {this.feature.tkReminders && <TKCountPlugin editorResource={this.editorResource} onChange={this.args.updatePostTkCount} />}
</KoenigComposer> </KoenigComposer>
</Suspense> </Suspense>
</ErrorHandler> </ErrorHandler>

View File

@ -131,6 +131,7 @@ export default class LexicalEditorController extends Controller {
// koenig related properties // koenig related properties
wordCount = 0; wordCount = 0;
postTkCount = 0; postTkCount = 0;
featureImageTkCount = 0;
/* private properties ----------------------------------------------------*/ /* private properties ----------------------------------------------------*/
@ -262,9 +263,9 @@ export default class LexicalEditorController extends Controller {
return true; return true;
} }
@computed('titleHasTk', 'postTkCount') @computed('titleHasTk', 'postTkCount', 'featureImageTkCount')
get tkCount() { get tkCount() {
return (this.titleHasTk ? 1 : 0) + this.postTkCount; return (this.titleHasTk ? 1 : 0) + this.postTkCount + this.featureImageTkCount;
} }
@action @action
@ -368,6 +369,11 @@ export default class LexicalEditorController extends Controller {
this.set('postTkCount', count); this.set('postTkCount', count);
} }
@action
updateFeatureImageTkCount(count) {
this.set('featureImageTkCount', count);
}
@action @action
setFeatureImage(url) { setFeatureImage(url) {
this.post.set('featureImage', url); this.post.set('featureImage', url);
@ -1135,6 +1141,7 @@ export default class LexicalEditorController extends Controller {
this.set('showSettingsMenu', false); this.set('showSettingsMenu', false);
this.set('wordCount', 0); this.set('wordCount', 0);
this.set('postTkCount', 0); this.set('postTkCount', 0);
this.set('featureImageTkCount', 0);
// remove the onbeforeunload handler as it's only relevant whilst on // remove the onbeforeunload handler as it's only relevant whilst on
// the editor route // the editor route

View File

@ -635,6 +635,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
background-color: transparent !important; background-color: transparent !important;
transition: border-color .15s linear; transition: border-color .15s linear;
-webkit-appearance: none; -webkit-appearance: none;
overflow: hidden; /* Hides any indicators such as TK */
} }
.gh-editor-feature-image-alttext::placeholder, .gh-editor-feature-image-alttext::placeholder,
@ -683,7 +684,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
opacity: .5; opacity: .5;
} }
.gh-editor-title-container .tk-indicator { .gh-editor .tk-indicator {
position: absolute; position: absolute;
top: 15px; top: 15px;
right: -5.6rem; right: -5.6rem;
@ -691,7 +692,12 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
color: #95A1AD; color: #95A1AD;
font-size: 1.2rem; font-size: 1.2rem;
font-weight: 500; font-weight: 500;
cursor: default; cursor: pointer;
}
.gh-editor-feature-image-container .tk-indicator {
top: 0;
padding: 0 .4rem;
} }
.gh-editor-back-button { .gh-editor-back-button {

View File

@ -79,6 +79,7 @@
@onEditorCreated={{this.setKoenigEditor}} @onEditorCreated={{this.setKoenigEditor}}
@updateWordCount={{this.updateWordCount}} @updateWordCount={{this.updateWordCount}}
@updatePostTkCount={{this.updatePostTkCount}} @updatePostTkCount={{this.updatePostTkCount}}
@updateFeatureImageTkCount={{if (feature "tkReminders") this.updateFeatureImageTkCount}}
@featureImage={{this.post.featureImage}} @featureImage={{this.post.featureImage}}
@featureImageAlt={{this.post.featureImageAlt}} @featureImageAlt={{this.post.featureImageAlt}}
@featureImageCaption={{this.post.featureImageCaption}} @featureImageCaption={{this.post.featureImageCaption}}

View File

@ -77,6 +77,7 @@ export default [
setting('portal', 'portal_name', true), setting('portal', 'portal_name', true),
setting('portal', 'portal_button', true), setting('portal', 'portal_button', true),
setting('portal', 'portal_plans', JSON.stringify(['free'])), setting('portal', 'portal_plans', JSON.stringify(['free'])),
setting('portal', 'portal_default_plan', 'yearly'),
setting('portal', 'portal_products', JSON.stringify([])), setting('portal', 'portal_products', JSON.stringify([])),
setting('portal', 'portal_button_style', 'icon-and-text'), setting('portal', 'portal_button_style', 'icon-and-text'),
setting('portal', 'portal_button_icon', null), setting('portal', 'portal_button_icon', null),

View File

@ -44,9 +44,9 @@
"@tryghost/color-utils": "0.2.0", "@tryghost/color-utils": "0.2.0",
"@tryghost/ember-promise-modals": "2.0.1", "@tryghost/ember-promise-modals": "2.0.1",
"@tryghost/helpers": "1.1.88", "@tryghost/helpers": "1.1.88",
"@tryghost/kg-clean-basic-html": "3.0.41", "@tryghost/kg-clean-basic-html": "4.0.0",
"@tryghost/kg-converters": "0.0.22", "@tryghost/kg-converters": "0.0.22",
"@tryghost/koenig-lexical": "0.5.27", "@tryghost/koenig-lexical": "1.0.3",
"@tryghost/limit-service": "1.2.12", "@tryghost/limit-service": "1.2.12",
"@tryghost/members-csv": "0.0.0", "@tryghost/members-csv": "0.0.0",
"@tryghost/nql": "0.12.0", "@tryghost/nql": "0.12.0",
@ -201,4 +201,4 @@
} }
} }
} }
} }

View File

@ -5,6 +5,7 @@ import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-sup
import {beforeEach, describe, it} from 'mocha'; import {beforeEach, describe, it} from 'mocha';
import {blur, click, currentRouteName, currentURL, fillIn, find, findAll, triggerEvent, typeIn} from '@ember/test-helpers'; import {blur, click, currentRouteName, currentURL, fillIn, find, findAll, triggerEvent, typeIn} from '@ember/test-helpers';
import {datepickerSelect} from 'ember-power-datepicker/test-support'; import {datepickerSelect} from 'ember-power-datepicker/test-support';
import {enableLabsFlag} from '../helpers/labs-flag';
import {expect} from 'chai'; import {expect} from 'chai';
import {selectChoose} from 'ember-power-select/test-support'; import {selectChoose} from 'ember-power-select/test-support';
import {setupApplicationTest} from 'ember-mocha'; import {setupApplicationTest} from 'ember-mocha';
@ -570,5 +571,38 @@ describe('Acceptance: Editor', function () {
'breadcrumb link' 'breadcrumb link'
).to.equal(`/ghost/posts/analytics/${post.id}`); ).to.equal(`/ghost/posts/analytics/${post.id}`);
}); });
it('handles TKs in title', async function () {
enableLabsFlag(this.server, 'tkReminders');
let post = this.server.create('post', {authors: [author]});
await visit(`/editor/post/${post.id}`);
expect(
find('[data-test-editor-title-input]').value,
'initial title'
).to.equal('Post 0');
await fillIn('[data-test-editor-title-input]', 'Test TK Title');
expect(
find('[data-test-editor-title-input]').value,
'title after typing'
).to.equal('Test TK Title');
// check for TK indicator
expect(
find('[data-testid="tk-indicator"]'),
'TK indicator text'
).to.exist;
// click publish to see if confirmation comes up
await click('[data-test-button="publish-flow"]');
expect(
find('[data-test-modal="tk-reminder"]'),
'TK reminder modal'
).to.exist;
});
}); });
}); });

View File

@ -162,4 +162,22 @@ describe('Unit: Controller: lexical-editor', function () {
expect(controller.get('post.slug')).to.not.be.ok; expect(controller.get('post.slug')).to.not.be.ok;
}); });
}); });
describe('TK count in title', function () {
it('should have count 0 for no TK', async function () {
let controller = this.owner.lookup('controller:lexical-editor');
controller.set('post', EmberObject.create({titleScratch: 'this is a title'}));
expect(controller.get('tkCount')).to.equal(0);
});
it('should count TK reminders in the title', async function () {
let controller = this.owner.lookup('controller:lexical-editor');
controller.set('post', EmberObject.create({titleScratch: 'this is a TK'}));
expect(controller.get('tkCount')).to.equal(1);
});
});
}); });

View File

@ -30,7 +30,9 @@ const maintenanceMiddleware = (req, res, next) => {
const rootApp = () => { const rootApp = () => {
const app = express('root'); const app = express('root');
app.use(sentry.requestHandler); app.use(sentry.requestHandler);
if (config.get('sentry')?.tracing?.enabled === true) {
app.use(sentry.tracingHandler);
}
app.enable('maintenance'); app.enable('maintenance');
app.use(maintenanceMiddleware); app.use(maintenanceMiddleware);

View File

@ -10,6 +10,7 @@ module.exports = class DataGeneratorCommand extends Command {
this.argument('--clear-database', {type: 'boolean', defaultValue: false, desc: 'Clear all entries in the database before importing'}); this.argument('--clear-database', {type: 'boolean', defaultValue: false, desc: 'Clear all entries in the database before importing'});
this.argument('--tables', {type: 'string', desc: 'Only import the specified list of tables, where quantities can be specified by appending a colon followed by the quantity for each table. Example: --tables=members:1000,posts,tags,members_login_events'}); this.argument('--tables', {type: 'string', desc: 'Only import the specified list of tables, where quantities can be specified by appending a colon followed by the quantity for each table. Example: --tables=members:1000,posts,tags,members_login_events'});
this.argument('--with-default', {type: 'boolean', defaultValue: false, desc: 'Include default tables as well as those specified (simply override quantities)'}); this.argument('--with-default', {type: 'boolean', defaultValue: false, desc: 'Include default tables as well as those specified (simply override quantities)'});
this.argument('--print-dependencies', {type: 'boolean', defaultValue: false, desc: 'Prints the dependency tree for the data generator and exits'});
} }
initializeContext(context) { initializeContext(context) {
@ -51,7 +52,8 @@ module.exports = class DataGeneratorCommand extends Command {
baseUrl: config.getSiteUrl(), baseUrl: config.getSiteUrl(),
clearDatabase: argv['clear-database'], clearDatabase: argv['clear-database'],
tables, tables,
withDefault: argv['with-default'] withDefault: argv['with-default'],
printDependencies: argv['print-dependencies']
}); });
try { try {
await dataGenerator.importData(); await dataGenerator.importData();

View File

@ -38,6 +38,7 @@ const EDITABLE_SETTINGS = [
'portal_name', 'portal_name',
'portal_button', 'portal_button',
'portal_plans', 'portal_plans',
'portal_default_plan',
'portal_button_style', 'portal_button_style',
'firstpromoter', 'firstpromoter',
'firstpromoter_id', 'firstpromoter_id',

View File

@ -0,0 +1,8 @@
const {addSetting} = require('../../utils');
module.exports = addSetting({
key: 'portal_default_plan',
value: 'yearly',
type: 'string',
group: 'portal'
});

View File

@ -330,6 +330,14 @@
"defaultValue": "[\"free\"]", "defaultValue": "[\"free\"]",
"type": "array" "type": "array"
}, },
"portal_default_plan": {
"defaultValue": "yearly",
"validations": {
"isEmpty": false,
"isIn": [["yearly", "monthly"]]
},
"type": "string"
},
"portal_products": { "portal_products": {
"defaultValue": "[]", "defaultValue": "[]",
"type": "array" "type": "array"

View File

@ -182,7 +182,7 @@
}, },
"portal": { "portal": {
"url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js", "url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js",
"version": "2.36" "version": "2.37"
}, },
"sodoSearch": { "sodoSearch": {
"url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js", "url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js",

View File

@ -59,13 +59,22 @@ if (sentryConfig && !sentryConfig.disabled) {
const Sentry = require('@sentry/node'); const Sentry = require('@sentry/node');
const version = require('@tryghost/version').full; const version = require('@tryghost/version').full;
const environment = config.get('env'); const environment = config.get('env');
Sentry.init({ const sentryInitConfig = {
dsn: sentryConfig.dsn, dsn: sentryConfig.dsn,
release: 'ghost@' + version, release: 'ghost@' + version,
environment: environment, environment: environment,
maxValueLength: 1000, maxValueLength: 1000,
beforeSend: beforeSend integrations: [],
}); beforeSend
};
// Enable tracing if sentry.tracing.enabled is true
if (sentryConfig.tracing?.enabled === true) {
sentryInitConfig.integrations.push(new Sentry.Integrations.Http({tracing: true}));
sentryInitConfig.integrations.push(new Sentry.Integrations.Express());
sentryInitConfig.tracesSampleRate = parseFloat(sentryConfig.tracing.sampleRate) || 0.0;
}
Sentry.init(sentryInitConfig);
module.exports = { module.exports = {
requestHandler: Sentry.Handlers.requestHandler(), requestHandler: Sentry.Handlers.requestHandler(),
@ -82,6 +91,7 @@ if (sentryConfig && !sentryConfig.disabled) {
return (error.statusCode === 500); return (error.statusCode === 500);
} }
}), }),
tracingHandler: Sentry.Handlers.tracingHandler(),
captureException: Sentry.captureException, captureException: Sentry.captureException,
beforeSend: beforeSend beforeSend: beforeSend
}; };
@ -95,6 +105,7 @@ if (sentryConfig && !sentryConfig.disabled) {
module.exports = { module.exports = {
requestHandler: expressNoop, requestHandler: expressNoop,
errorHandler: expressNoop, errorHandler: expressNoop,
tracingHandler: expressNoop,
captureException: noop captureException: noop
}; };
} }

View File

@ -38,6 +38,7 @@ module.exports = {
portal_signup_terms_html: 'portal_signup_terms_html', portal_signup_terms_html: 'portal_signup_terms_html',
portal_signup_checkbox_required: 'portal_signup_checkbox_required', portal_signup_checkbox_required: 'portal_signup_checkbox_required',
portal_plans: 'portal_plans', portal_plans: 'portal_plans',
portal_default_plan: 'portal_default_plan',
portal_name: 'portal_name', portal_name: 'portal_name',
portal_button: 'portal_button', portal_button: 'portal_button',
comments_enabled: 'comments_enabled', comments_enabled: 'comments_enabled',

View File

@ -98,14 +98,14 @@
"@tryghost/importer-handler-content-files": "0.0.0", "@tryghost/importer-handler-content-files": "0.0.0",
"@tryghost/importer-revue": "0.0.0", "@tryghost/importer-revue": "0.0.0",
"@tryghost/job-manager": "0.0.0", "@tryghost/job-manager": "0.0.0",
"@tryghost/kg-card-factory": "4.0.15", "@tryghost/kg-card-factory": "5.0.0",
"@tryghost/kg-converters": "0.0.22", "@tryghost/kg-converters": "0.0.22",
"@tryghost/kg-default-atoms": "4.0.3", "@tryghost/kg-default-atoms": "5.0.0",
"@tryghost/kg-default-cards": "9.1.9", "@tryghost/kg-default-cards": "10.0.0",
"@tryghost/kg-default-nodes": "0.2.12", "@tryghost/kg-default-nodes": "1.0.0",
"@tryghost/kg-html-to-lexical": "0.1.14", "@tryghost/kg-html-to-lexical": "1.0.0",
"@tryghost/kg-lexical-html-renderer": "0.3.51", "@tryghost/kg-lexical-html-renderer": "1.0.0",
"@tryghost/kg-mobiledoc-html-renderer": "6.0.15", "@tryghost/kg-mobiledoc-html-renderer": "7.0.0",
"@tryghost/limit-service": "1.2.12", "@tryghost/limit-service": "1.2.12",
"@tryghost/link-redirects": "0.0.0", "@tryghost/link-redirects": "0.0.0",
"@tryghost/link-replacer": "0.0.0", "@tryghost/link-replacer": "0.0.0",
@ -209,7 +209,7 @@
"multer": "1.4.4", "multer": "1.4.4",
"mysql2": "3.6.5", "mysql2": "3.6.5",
"nconf": "0.12.1", "nconf": "0.12.1",
"newrelic": "11.6.0", "newrelic": "11.6.1",
"node-jose": "2.2.0", "node-jose": "2.2.0",
"path-match": "1.2.4", "path-match": "1.2.4",
"probe-image-size": "7.2.3", "probe-image-size": "7.2.3",

View File

@ -180,6 +180,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",
@ -602,6 +606,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",
@ -967,6 +975,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",
@ -1143,7 +1155,7 @@ exports[`Settings API Edit Can edit a setting 2: [headers] 1`] = `
Object { Object {
"access-control-allow-origin": "http://127.0.0.1:2369", "access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "4487", "content-length": "4534",
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -1333,6 +1345,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",
@ -1698,6 +1714,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",
@ -2526,6 +2546,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",
@ -2892,6 +2916,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",
@ -3258,6 +3286,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",
@ -3628,6 +3660,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",
@ -3993,6 +4029,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",
@ -4363,6 +4403,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",
@ -5097,6 +5141,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",
@ -5463,6 +5511,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",
@ -5893,6 +5945,10 @@ Object {
"key": "portal_plans", "key": "portal_plans",
"value": "[\\"free\\"]", "value": "[\\"free\\"]",
}, },
Object {
"key": "portal_default_plan",
"value": "yearly",
},
Object { Object {
"key": "portal_products", "key": "portal_products",
"value": "[]", "value": "[]",

View File

@ -9,15 +9,17 @@ const models = require('../../../core/server/models');
const {mockLabsDisabled, mockLabsEnabled} = require('../../utils/e2e-framework-mock-manager'); const {mockLabsDisabled, mockLabsEnabled} = require('../../utils/e2e-framework-mock-manager');
const {anyErrorId} = matchers; const {anyErrorId} = matchers;
const CURRENT_SETTINGS_COUNT = 86; const CURRENT_SETTINGS_COUNT = 87;
const settingsMatcher = {}; const settingsMatcher = {};
const publicHashSettingMatcher = { const publicHashSettingMatcher = {
key: 'public_hash',
value: stringMatching(/[a-z0-9]{30}/) value: stringMatching(/[a-z0-9]{30}/)
}; };
const labsSettingMatcher = { const labsSettingMatcher = {
key: 'labs',
value: stringMatching(/\{[^\s]+\}/) value: stringMatching(/\{[^\s]+\}/)
}; };
@ -30,10 +32,10 @@ const matchSettingsArray = (length) => {
settingsArray[26] = publicHashSettingMatcher; settingsArray[26] = publicHashSettingMatcher;
} }
if (length > 60) { if (length > 61) {
// Added a setting that is alphabetically before 'labs'? then you need to increment this counter. // Added a setting that is alphabetically before 'labs'? then you need to increment this counter.
// Item at index x is the lab settings, which changes as we add and remove features // Item at index x is the lab settings, which changes as we add and remove features
settingsArray[60] = labsSettingMatcher; settingsArray[61] = labsSettingMatcher;
} }
return settingsArray; return settingsArray;

View File

@ -54,6 +54,7 @@ Object {
"portal_button_icon": null, "portal_button_icon": null,
"portal_button_signup_text": "Subscribe", "portal_button_signup_text": "Subscribe",
"portal_button_style": "icon-and-text", "portal_button_style": "icon-and-text",
"portal_default_plan": "yearly",
"portal_name": true, "portal_name": true,
"portal_plans": Array [ "portal_plans": Array [
"free", "free",

View File

@ -1406,6 +1406,7 @@ Object {
"portal_button_icon": null, "portal_button_icon": null,
"portal_button_signup_text": "Subscribe", "portal_button_signup_text": "Subscribe",
"portal_button_style": "icon-and-text", "portal_button_style": "icon-and-text",
"portal_default_plan": "yearly",
"portal_name": true, "portal_name": true,
"portal_plans": Array [ "portal_plans": Array [
"free", "free",
@ -1507,6 +1508,7 @@ Object {
"portal_button_icon": null, "portal_button_icon": null,
"portal_button_signup_text": "Subscribe", "portal_button_signup_text": "Subscribe",
"portal_button_style": "icon-and-text", "portal_button_style": "icon-and-text",
"portal_default_plan": "yearly",
"portal_name": true, "portal_name": true,
"portal_plans": Array [ "portal_plans": Array [
"free", "free",

View File

@ -5,7 +5,7 @@ const db = require('../../../core/server/data/db');
// Stuff we are testing // Stuff we are testing
const models = require('../../../core/server/models'); const models = require('../../../core/server/models');
const SETTINGS_LENGTH = 93; const SETTINGS_LENGTH = 94;
describe('Settings Model', function () { describe('Settings Model', function () {
before(models.init); before(models.init);

View File

@ -236,7 +236,7 @@ describe('Exporter', function () {
// NOTE: if default settings changed either modify the settings keys blocklist or increase allowedKeysLength // NOTE: if default settings changed either modify the settings keys blocklist or increase allowedKeysLength
// This is a reminder to think about the importer/exporter scenarios ;) // This is a reminder to think about the importer/exporter scenarios ;)
const allowedKeysLength = 85; const allowedKeysLength = 86;
totalKeysLength.should.eql(SETTING_KEYS_BLOCKLIST.length + allowedKeysLength); totalKeysLength.should.eql(SETTING_KEYS_BLOCKLIST.length + allowedKeysLength);
}); });
}); });

View File

@ -37,7 +37,7 @@ describe('DB version integrity', function () {
// Only these variables should need updating // Only these variables should need updating
const currentSchemaHash = '34a9fa4dc1223ef6c45f8ed991d25de5'; const currentSchemaHash = '34a9fa4dc1223ef6c45f8ed991d25de5';
const currentFixturesHash = '4db87173699ad9c9d8a67ccab96dfd2d'; const currentFixturesHash = '4db87173699ad9c9d8a67ccab96dfd2d';
const currentSettingsHash = '3128d4ec667a50049486b0c21f04be07'; const currentSettingsHash = '5c957ceb48c4878767d7d3db484c592d';
const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01';
// If this test is failing, then it is likely a change has been made that requires a DB version bump, // If this test is failing, then it is likely a change has been made that requires a DB version bump,

View File

@ -326,6 +326,14 @@
"defaultValue": "[\"free\"]", "defaultValue": "[\"free\"]",
"type": "array" "type": "array"
}, },
"portal_default_plan": {
"defaultValue": "yearly",
"validations": {
"isEmpty": false,
"isIn": [["yearly", "monthly"]]
},
"type": "string"
},
"portal_products": { "portal_products": {
"defaultValue": "[]", "defaultValue": "[]",
"type": "array" "type": "array"

View File

@ -338,6 +338,14 @@
"defaultValue": "[\"free\"]", "defaultValue": "[\"free\"]",
"type": "array" "type": "array"
}, },
"portal_default_plan": {
"defaultValue": "yearly",
"validations": {
"isEmpty": false,
"isIn": [["yearly", "monthly"]]
},
"type": "string"
},
"portal_products": { "portal_products": {
"defaultValue": "[]", "defaultValue": "[]",
"type": "array" "type": "array"

View File

@ -18,6 +18,7 @@ class DataGenerator {
baseDataPack = '', baseDataPack = '',
baseUrl, baseUrl,
logger, logger,
printDependencies,
withDefault withDefault
}) { }) {
this.knex = knex; this.knex = knex;
@ -28,6 +29,7 @@ class DataGenerator {
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.logger = logger; this.logger = logger;
this.withDefault = withDefault; this.withDefault = withDefault;
this.printDependencies = printDependencies;
} }
sortTableList() { sortTableList() {
@ -91,7 +93,7 @@ class DataGenerator {
} }
let baseData = {}; let baseData = {};
try { try {
baseData = JSON.parse(await (await fs.readFile(baseDataPack)).toString()); baseData = JSON.parse((await fs.readFile(baseDataPack)).toString());
this.logger.info('Read base data pack'); this.logger.info('Read base data pack');
} catch (error) { } catch (error) {
this.logger.error('Failed to read data pack: ', error); this.logger.error('Failed to read data pack: ', error);
@ -158,6 +160,14 @@ class DataGenerator {
this.sortTableList(); this.sortTableList();
if (this.printDependencies) {
this.logger.info('Table dependencies:');
for (const table of this.tableList) {
this.logger.info(`\t${table.name}: ${table.dependencies.join(', ')}`);
}
process.exit(0);
}
if (this.willClearData) { if (this.willClearData) {
await this.clearData(transaction); await this.clearData(transaction);
} }

View File

@ -71,7 +71,7 @@ class MembersImporter extends TableImporter {
id, id,
uuid: faker.datatype.uuid(), uuid: faker.datatype.uuid(),
transient_id: faker.datatype.uuid(), transient_id: faker.datatype.uuid(),
email: `${name.replace(' ', '.').replace(/[^a-zA-Z0-9]/g, '').toLowerCase()}${faker.datatype.number({min: 1000, max: 9999})}@example.com`, email: `${name.replace(' ', '.').replace(/[^a-zA-Z0-9]/g, '').toLowerCase()}${faker.datatype.number({min: 0, max: 999999})}@example.com`,
status: luck(5) ? 'comped' : luck(25) ? 'paid' : 'free', status: luck(5) ? 'comped' : luck(25) ? 'paid' : 'free',
name: name, name: name,
expertise: luck(30) ? faker.name.jobTitle() : undefined, expertise: luck(30) ? faker.name.jobTitle() : undefined,

View File

@ -5,7 +5,7 @@ const dateToDatabaseString = require('../utils/database-date');
class MembersSubscribeEventsImporter extends TableImporter { class MembersSubscribeEventsImporter extends TableImporter {
static table = 'members_subscribe_events'; static table = 'members_subscribe_events';
static dependencies = ['members', 'newsletters', 'subscriptions']; static dependencies = ['members', 'newsletters'/*, 'subscriptions'*/];
constructor(knex, transaction) { constructor(knex, transaction) {
super(MembersSubscribeEventsImporter.table, knex, transaction); super(MembersSubscribeEventsImporter.table, knex, transaction);
@ -14,9 +14,9 @@ class MembersSubscribeEventsImporter extends TableImporter {
async import(quantity) { async import(quantity) {
const members = await this.transaction.select('id', 'created_at', 'status').from('members'); const members = await this.transaction.select('id', 'created_at', 'status').from('members');
this.newsletters = await this.transaction.select('id').from('newsletters').orderBy('sort_order'); this.newsletters = await this.transaction.select('id').from('newsletters').orderBy('sort_order');
this.subscriptions = await this.transaction.select('member_id', 'created_at').from('subscriptions'); //this.subscriptions = await this.transaction.select('member_id', 'created_at').from('subscriptions');
await this.importForEach(members, quantity ? quantity / members.length : 2); await this.importForEach(members, quantity ? quantity / members.length : this.newsletters.length);
} }
setReferencedModel(model) { setReferencedModel(model) {
@ -32,22 +32,15 @@ class MembersSubscribeEventsImporter extends TableImporter {
return null; return null;
} }
let createdAt = dateToDatabaseString(faker.date.between(new Date(this.model.created_at), new Date()));
let subscribed = luck(80); let subscribed = luck(80);
// Free newsletter by default
let newsletterId = this.newsletters[1].id;
if (this.model.status === 'paid' && count === 0) {
// Paid newsletter
newsletterId = this.newsletters[0].id;
createdAt = this.subscriptions.find(s => s.member_id === this.model.id).created_at;
subscribed = luck(98);
}
if (!subscribed) { if (!subscribed) {
return null; return null;
} }
const createdAt = dateToDatabaseString(faker.date.between(new Date(this.model.created_at), new Date()));
const newsletterId = this.newsletters[count % this.newsletters.length].id;
return { return {
id: faker.database.mongodbObjectId(), id: faker.database.mongodbObjectId(),
member_id: this.model.id, member_id: this.model.id,

View File

@ -6,21 +6,23 @@ const {slugify} = require('@tryghost/string');
class NewslettersImporter extends TableImporter { class NewslettersImporter extends TableImporter {
static table = 'newsletters'; static table = 'newsletters';
static dependencies = []; static dependencies = [];
defaultQuantity = 2; defaultQuantity = 2;
sortOrder = 0;
constructor(knex, transaction) { constructor(knex, transaction) {
super(NewslettersImporter.table, knex, transaction); super(NewslettersImporter.table, knex, transaction);
this.sortOrder = 0;
// TODO: Use random names if we ever need more than 2 newsletters
this.names = ['Regular premium', 'Occasional freebie'];
} }
generate() { generate() {
const name = this.names.shift(); const name = `${faker.commerce.productAdjective()} ${faker.word.noun()}`;
const sortOrder = this.sortOrder; const sortOrder = this.sortOrder;
this.sortOrder = this.sortOrder + 1; this.sortOrder = this.sortOrder + 1;
const weekAfter = new Date(blogStartDate); const weekAfter = new Date(blogStartDate);
weekAfter.setDate(weekAfter.getDate() + 7); weekAfter.setDate(weekAfter.getDate() + 7);
return { return {
id: faker.database.mongodbObjectId(), id: faker.database.mongodbObjectId(),
uuid: faker.datatype.uuid(), uuid: faker.datatype.uuid(),

View File

@ -1,3 +1,5 @@
const debug = require('@tryghost/debug')('TableImporter');
class TableImporter { class TableImporter {
/** /**
* @type {object|undefined} model Referenced model when generating data * @type {object|undefined} model Referenced model when generating data
@ -21,27 +23,29 @@ class TableImporter {
this.transaction = transaction; this.transaction = transaction;
} }
async import(amount = this.defaultQuantity) { async #generateData(amount = this.defaultQuantity) {
const batchSize = 500; let data = [];
let batch = [];
for (let i = 0; i < amount; i++) { for (let i = 0; i < amount; i++) {
const model = await this.generate(); const model = await this.generate();
if (model) { if (model) {
batch.push(model); data.push(model);
} else {
// After first null assume that there is no more data
break;
}
if (batch.length === batchSize) {
await this.knex.batchInsert(this.name, batch, batchSize).transacting(this.transaction);
batch = [];
} }
} }
// Process final batch return data;
if (batch.length > 0) { }
await this.knex.batchInsert(this.name, batch, batchSize).transacting(this.transaction);
async import(amount = this.defaultQuantity) {
const generateNow = Date.now();
const data = await this.#generateData(amount);
debug(`${this.name} generated ${data.length} records in ${Date.now() - generateNow}ms`);
if (data.length > 0) {
debug (`Importing ${data.length} records into ${this.name}`);
const now = Date.now();
await this.knex.batchInsert(this.name, data).transacting(this.transaction);
debug(`${this.name} imported ${data.length} records in ${Date.now() - now}ms`);
} }
} }
@ -50,8 +54,10 @@ class TableImporter {
* @param {Number|function} amount Number of records to import per model * @param {Number|function} amount Number of records to import per model
*/ */
async importForEach(models = [], amount) { async importForEach(models = [], amount) {
const batchSize = 500; const data = [];
let batch = [];
debug (`Generating data for ${models.length} models for ${this.name}`);
const now = Date.now();
for (const model of models) { for (const model of models) {
this.setReferencedModel(model); this.setReferencedModel(model);
@ -59,24 +65,19 @@ class TableImporter {
if (!Number.isInteger(currentAmount)) { if (!Number.isInteger(currentAmount)) {
currentAmount = Math.floor(currentAmount) + ((Math.random() < currentAmount % 1) ? 1 : 0); currentAmount = Math.floor(currentAmount) + ((Math.random() < currentAmount % 1) ? 1 : 0);
} }
for (let i = 0; i < currentAmount; i++) {
const data = await this.generate(); const generatedData = await this.#generateData(currentAmount);
if (data) { if (generatedData.length > 0) {
batch.push(data); data.push(...generatedData);
} else {
// After first null assume that there is no more data for this model
break;
}
if (batch.length === batchSize) {
await this.knex.batchInsert(this.name, batch, batchSize).transacting(this.transaction);
batch = [];
}
} }
} }
// Process final batch debug(`${this.name} generated ${data.length} records in ${Date.now() - now}ms`);
if (batch.length > 0) {
await this.knex.batchInsert(this.name, batch, batchSize).transacting(this.transaction); if (data.length > 0) {
const now2 = Date.now();
await this.knex.batchInsert(this.name, data).transacting(this.transaction);
debug(`${this.name} imported ${data.length} records in ${Date.now() - now2}ms`);
} }
} }

View File

@ -18,6 +18,7 @@
"lib" "lib"
], ],
"devDependencies": { "devDependencies": {
"@tryghost/debug": "0.1.27",
"c8": "8.0.1", "c8": "8.0.1",
"knex": "2.4.2", "knex": "2.4.2",
"mocha": "10.2.0", "mocha": "10.2.0",

View File

@ -26,7 +26,7 @@
"mocha": "10.2.0", "mocha": "10.2.0",
"sinon": "15.2.0", "sinon": "15.2.0",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "5.3.2" "typescript": "5.3.3"
}, },
"dependencies": {} "dependencies": {}
} }

View File

@ -26,7 +26,7 @@
"mocha": "10.2.0", "mocha": "10.2.0",
"sinon": "15.2.0", "sinon": "15.2.0",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "5.3.2" "typescript": "5.3.3"
}, },
"dependencies": { "dependencies": {
"nodemailer": "^6.6.3" "nodemailer": "^6.6.3"

View File

@ -29,7 +29,7 @@
"@tryghost/email-events": "0.0.0", "@tryghost/email-events": "0.0.0",
"@tryghost/errors": "1.2.26", "@tryghost/errors": "1.2.26",
"@tryghost/html-to-plaintext": "0.0.0", "@tryghost/html-to-plaintext": "0.0.0",
"@tryghost/kg-default-cards": "9.1.9", "@tryghost/kg-default-cards": "10.0.0",
"@tryghost/logging": "2.4.8", "@tryghost/logging": "2.4.8",
"@tryghost/tpl": "0.1.26", "@tryghost/tpl": "0.1.26",
"@tryghost/validator": "0.2.6", "@tryghost/validator": "0.2.6",

View File

@ -30,6 +30,6 @@
"mocha": "10.2.0" "mocha": "10.2.0"
}, },
"dependencies": { "dependencies": {
"i18next": "23.7.7" "i18next": "23.7.8"
} }
} }

View File

@ -22,7 +22,7 @@
}, },
"dependencies": { "dependencies": {
"@tryghost/debug": "0.1.26", "@tryghost/debug": "0.1.26",
"@tryghost/kg-default-cards": "9.1.9", "@tryghost/kg-default-cards": "10.0.0",
"@tryghost/string": "0.2.10", "@tryghost/string": "0.2.10",
"lodash": "4.17.21", "lodash": "4.17.21",
"papaparse": "5.3.2", "papaparse": "5.3.2",

View File

@ -28,7 +28,7 @@
"@tryghost/errors": "1.2.26", "@tryghost/errors": "1.2.26",
"@tryghost/tpl": "0.1.26", "@tryghost/tpl": "0.1.26",
"csso": "5.0.5", "csso": "5.0.5",
"terser": "5.25.0", "terser": "5.26.0",
"tiny-glob": "0.2.9" "tiny-glob": "0.2.9"
} }
} }

View File

@ -30,15 +30,15 @@
"cheerio": "0.22.0", "cheerio": "0.22.0",
"iconv-lite": "0.6.3", "iconv-lite": "0.6.3",
"lodash": "4.17.21", "lodash": "4.17.21",
"metascraper": "5.39.0", "metascraper": "5.41.0",
"metascraper-author": "5.39.0", "metascraper-author": "5.40.0",
"metascraper-description": "5.39.0", "metascraper-description": "5.40.0",
"metascraper-image": "5.39.0", "metascraper-image": "5.40.0",
"metascraper-logo": "5.39.0", "metascraper-logo": "5.40.0",
"metascraper-logo-favicon": "5.39.0", "metascraper-logo-favicon": "5.40.0",
"metascraper-publisher": "5.39.0", "metascraper-publisher": "5.40.0",
"metascraper-title": "5.39.0", "metascraper-title": "5.40.0",
"metascraper-url": "5.39.0", "metascraper-url": "5.40.0",
"tough-cookie": "4.1.3" "tough-cookie": "4.1.3"
} }
} }

View File

@ -27,7 +27,7 @@
"mocha": "10.2.0", "mocha": "10.2.0",
"sinon": "15.2.0", "sinon": "15.2.0",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "5.3.2" "typescript": "5.3.3"
}, },
"dependencies": { "dependencies": {
"@tryghost/tpl": "0.1.26", "@tryghost/tpl": "0.1.26",

View File

@ -29,7 +29,8 @@
"dev": "node .github/scripts/dev.js", "dev": "node .github/scripts/dev.js",
"fix": "yarn cache clean && rm -rf node_modules && yarn", "fix": "yarn cache clean && rm -rf node_modules && yarn",
"knex-migrator": "yarn workspace ghost run knex-migrator", "knex-migrator": "yarn workspace ghost run knex-migrator",
"setup": "yarn && git submodule update --init", "setup": "yarn && git submodule update --init && NODE_ENV=development node .github/scripts/setup.js",
"docker:reset": "docker-compose -f .github/scripts/docker-compose.yml down -v && docker-compose -f .github/scripts/docker-compose.yml up -d --wait",
"lint": "nx run-many -t lint", "lint": "nx run-many -t lint",
"test": "nx run-many -t test", "test": "nx run-many -t test",
"test:unit": "nx run-many -t test:unit", "test:unit": "nx run-many -t test:unit",
@ -115,6 +116,6 @@
"nx": "16.8.1", "nx": "16.8.1",
"rimraf": "5.0.5", "rimraf": "5.0.5",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "5.3.2" "typescript": "5.3.3"
} }
} }

993
yarn.lock

File diff suppressed because it is too large Load Diff