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 = {
name: 'ts',
command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/bookshelf-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations,ghost/email-addresses -- nx run \\$NX_PROJECT_NAME:build:ts',
command: 'while [ 1 ]; do nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/bookshelf-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations,ghost/email-addresses -- nx run \\$NX_PROJECT_NAME:build:ts; done',
cwd: path.resolve(__dirname, '../../'),
prefixColor: 'cyan',
env: {}
@ -55,7 +55,7 @@ const adminXApps = '@tryghost/admin-x-demo,@tryghost/admin-x-settings';
const COMMANDS_ADMINX = [{
name: 'adminXDeps',
command: 'nx watch --projects=apps/admin-x-design-system,apps/admin-x-framework -- nx run \\$NX_PROJECT_NAME:build --skip-nx-cache',
command: 'while [ 1 ]; do nx watch --projects=apps/admin-x-design-system,apps/admin-x-framework -- nx run \\$NX_PROJECT_NAME:build --skip-nx-cache; done',
cwd: path.resolve(__dirname, '../..'),
prefixColor: '#C35831',
env: {}

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

View File

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

View File

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

View File

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

View File

@ -1,93 +1,26 @@
import CodeModal from './code/CodeModal';
import NiceModal from '@ebay/nice-modal-react';
import React, {useMemo, useRef, useState} from 'react';
import React from 'react';
import TopLevelGroup from '../../TopLevelGroup';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {Button, CodeEditor, TabView, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {ReactCodeMirrorRef} from '@uiw/react-codemirror';
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {Button, SettingGroupHeader, withErrorBoundary} from '@tryghost/admin-x-design-system';
const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
localSettings,
isEditing,
saveState,
handleSave,
handleCancel,
updateSetting,
handleEditingChange
} = useSettingGroup();
const [headerContent, footerContent] = getSettingValues<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 (
<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"
isEditing={isEditing}
keywords={keywords}
navid='code-injection'
saveState={saveState}
testId='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 React, {useMemo} from 'react';
import {CodeEditor, Modal} from '@tryghost/admin-x-design-system';
import React, {useMemo, useRef, useState} from 'react';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import {ButtonGroup, CodeEditor, Heading, Modal, TabView} from '@tryghost/admin-x-design-system';
import {ReactCodeMirrorRef} from '@uiw/react-codemirror';
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {useSaveButton} from '../../../../hooks/useSaveButton';
interface CodeModalProps {
hint?: React.ReactNode;
@ -9,18 +13,91 @@ interface CodeModalProps {
afterClose?: () => void
}
const CodeModal: React.FC<CodeModalProps> = ({hint, value, onChange, afterClose}) => {
const CodeModal: React.FC<CodeModalProps> = ({afterClose}) => {
const {
localSettings,
handleSave,
updateSetting
} = useSettingGroup();
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 onOk = () => {
modal.remove();
afterClose?.();
const headerProps = {
extensions: [html],
hint: 'Code here will be injected into the {{ghost_head}} tag on every page of the site',
value: headerContent || '',
onChange: (value: string) => updateSetting('codeinjection_head', value)
};
return <Modal afterClose={afterClose} cancelLabel='' okColor='grey' okLabel='Done' size='full' testId='modal-code' onOk={onOk}>
<CodeEditor extensions={[html]} height='full' hint={hint} value={value} autoFocus onChange={onChange} />
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 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>;
};

View File

@ -73,18 +73,12 @@ const ReplyToEmailField: React.FC<{
setSenderReplyTo(rendered);
};
const hint = (
<>
If left empty, replies go to {newsletterAddress}
</>
);
// Pro users without custom sending domains
return (
<TextField
error={Boolean(errors.sender_reply_to)}
hint={errors.sender_reply_to || hint}
placeholder={''}
hint={errors.sender_reply_to}
placeholder={newsletterAddress || ''}
title="Reply-to email"
value={senderReplyTo}
onBlur={onBlur}
@ -219,8 +213,9 @@ const Sidebar: React.FC<{
return (
<TextField
error={Boolean(errors.sender_email)}
hint={errors.sender_email || `If left empty, ${defaultEmailAddress} will be used`}
rightPlaceholder={`@${sendingDomain(config)}`}
hint={errors.sender_email}
placeholder={defaultEmailAddress}
rightPlaceholder={sendingEmailUsername ? `@${sendingDomain(config)}` : `` }
title="Sender email address"
value={sendingEmailUsername || ''}
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})} />
</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})} />
{renderSenderEmailField()}
<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,
onSave: async () => {
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 prompt;
if (emailToVerify && emailToVerify === 'sender_email') {
const previousFrom = renderSenderEmail(updatedNewsletter, config, defaultEmailAddress);
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})` : ''}.</>;
} else if (emailToVerify && emailToVerify === 'sender_reply_to') {
const previousReplyTo = renderReplyToEmail(updatedNewsletter, config, supportEmailAddress, defaultEmailAddress);
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) {

View File

@ -48,17 +48,21 @@ const Offers: React.FC<{ keywords: string[] }> = ({keywords}) => {
const latestThree = activeOffers.slice(0, 3);
const openModal = () => {
const openOfferListModal = () => {
updateRoute('offers/edit');
};
const openAddModal = () => {
updateRoute('offers/new');
};
const goToOfferEdit = (offerId: string) => {
updateRoute(`offers/edit/${offerId}`);
};
return (
<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>}</>}
keywords={keywords}
navid='offers'

View File

@ -128,7 +128,7 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
handleDurationInMonthsInput,
handleAmountInput,
handleCodeInput,
validate,
clearError,
errors,
testId,
handleTrialAmountInput,
@ -169,11 +169,16 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
maxLength={40}
placeholder='Black Friday'
title='Offer name'
onBlur={validate}
onBlur={(e) => {
if (!e.target.value && e.target.value.length === 0) {
errors.name = 'Name is required';
}
}}
onChange={(e) => {
handleNameInput(e);
setNameLength(e.target.value.length);
}}
onKeyDown={() => clearError('name')}
/>
<TextField
error={Boolean(errors.displayTitle)}
@ -181,10 +186,15 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
placeholder='Black Friday Special'
title='Display title'
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) => {
handleDisplayTitleInput(e);
}}
onKeyDown={() => clearError('displayTitle')}
/>
<TextArea
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.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) => {
handleAmountInput(e);
}}
onKeyDown={() => clearError('amount')}
/>
<div className='absolute right-1.5 top-6 z-10'>
<Select
@ -268,10 +291,16 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
title='Trial duration'
type='number'
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) => {
handleTrialAmountInput(e);
}} />
}}
onKeyDown={() => clearError('amount')}/>
}
<TextField
@ -280,10 +309,15 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
placeholder='black-friday'
title='Offer code'
value={overrides.code.value}
onBlur={validate}
onBlur={(e) => {
if (!e.target.value && e.target.value.length === 0) {
errors.code = 'Code is required';
}
}}
onChange={(e) => {
handleCodeInput(e);
}}
onKeyDown={() => clearError('code')}
/>
</div>
</section>
@ -630,7 +664,7 @@ const AddOfferModal = () => {
onCancel={cancelAddOffer}
onOk={async () => {
validate();
const isErrorsEmpty = Object.keys(errors).length === 0;
const isErrorsEmpty = Object.values(errors).every(error => !error);
if (!isErrorsEmpty) {
showToast({
type: 'pageError',

View File

@ -14,9 +14,9 @@ const SignupOptions: React.FC<{
setError: (key: string, error: string | undefined) => void
}> = ({localSettings, updateSetting, localTiers, updateTier, errors, setError}) => {
const {config} = useGlobalData();
const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson] = getSettingValues(
localSettings, ['members_signup_access', 'portal_name', 'portal_signup_terms_html', 'portal_signup_checkbox_required', 'portal_plans']
const hasPortalImprovements = useFeatureFlag('portalImprovements');
const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson, portalDefaultPlan] = getSettingValues(
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[];
@ -50,6 +50,20 @@ const SignupOptions: React.FC<{
}
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?
@ -86,13 +100,10 @@ const SignupOptions: React.FC<{
}
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) {
@ -145,9 +156,17 @@ const SignupOptions: React.FC<{
]}
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) => {
alert(value);
}} />}
{hasPortalImprovements &&
<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 {Tier, useAddTier, useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers';
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';
export type TierFormState = Partial<Omit<Tier, 'trial_days'>> & {
@ -22,12 +22,14 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
const {updateRoute} = useRouting();
const {mutateAsync: updateTier} = useEditTier();
const {mutateAsync: createTier} = useAddTier();
const {mutateAsync: editSettings} = useEditSettings();
const [hasFreeTrial, setHasFreeTrial] = React.useState(!!tier?.trial_days);
const handleError = useHandleError();
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 allowNameChange = !isFreeTier || hasPortalImprovements;
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];
const validators: {[key in keyof Tier]?: () => string | undefined} = {
name: () => (formState.name ? undefined : 'You must specify a name'),
@ -70,6 +72,31 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
} else {
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: () => {
modal.remove();
@ -336,7 +363,18 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
<div className='sticky top-[96px] hidden shrink-0 basis-[380px] min-[920px]:!visible min-[920px]:!block'>
<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>
</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('signupTermsHtml', getSettingValue(settings, 'portal_signup_terms_html') || '');
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) {
settingsParam.append('portalPrices', encodeURIComponent(portalPlans.join(',')));

View File

@ -21,23 +21,24 @@ test.describe('Code injection settings', async () => {
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
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('')) {
await page.keyboard.press(character);
}
await section.getByRole('tab', {name: 'Site footer'}).click();
await section.getByTestId('footer-code').locator('.cm-content').click();
await modal.getByRole('tab', {name: 'Site footer'}).click();
await modal.getByTestId('footer-code').locator('.cm-content').click();
for (const character of (PADDING + 'testfoot').split('')) {
await page.keyboard.press(character);
}
await section.getByRole('button', {name: 'Save'}).click();
await expect(section.getByRole('button', {name: 'Save'})).toBeHidden();
await modal.getByRole('button', {name: 'Save'}).click();
expect(lastApiRequests.editSettings?.body).toMatchObject({
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')).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(/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('Allow sender and reply-to addresses to be changed without verification, but not their domain name', async ({page}) => {
test.describe('For Ghost (Pro) users with custom sending domain', () => {
test('Allow sender address to be changed partially (username but not domain name)', async ({page}) => {
await mockApi({page, requests: {
...globalDataRequests,
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();
const modal = page.getByTestId('newsletter-modal');
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
await senderEmail.fill('harry@potter.com');
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
await modal.getByRole('button', {name: 'Save'}).click();
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: {
...globalDataRequests,
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');
await section.getByText('Awesome newsletter').click();
const modal = page.getByTestId('newsletter-modal');
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
await senderEmail.fill('harry@potter.com');
expect(await senderEmail.inputValue()).toBe('harry');
// Full flexibility for the reply-to address
await replyToEmail.fill('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 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(/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('/');
const section = page.getByTestId('offers');
await section.getByRole('button', {name: 'Manage offers'}).click();
const modal = page.getByTestId('offers-modal');
await expect(modal).toBeVisible();
await section.getByRole('button', {name: 'Add offers'}).click();
const addModal = page.getByTestId('add-offer-modal');
await expect(addModal).toBeVisible();
});
test('Offers Add Modal is available', async ({page}) => {
await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests,
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
addOffer: {method: 'POST', path: '/offers/', response: {
offers: [{
@ -45,9 +45,9 @@ test.describe('Offers Modal', () => {
test('Can add a new offer', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests,
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},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
addOffer: {method: 'POST', path: `/offers/`, response: {
@ -85,9 +85,9 @@ test.describe('Offers Modal', () => {
test('Errors if required fields are missing', async ({page}) => {
await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests,
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},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
addOffer: {method: 'POST', path: `/offers/`, response: {
@ -112,9 +112,9 @@ test.describe('Offers Modal', () => {
test('Shows validation hints', async ({page}) => {
await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests,
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},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
addOffer: {method: 'POST', path: `/offers/`, response: {
@ -144,9 +144,10 @@ test.describe('Offers Modal', () => {
test('Can view active offers', async ({page}) => {
await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests,
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}
}});
@ -161,9 +162,10 @@ test.describe('Offers Modal', () => {
test('Can view archived offers', async ({page}) => {
await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests,
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}
}});
@ -178,9 +180,10 @@ test.describe('Offers Modal', () => {
test('Supports updating an offer', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
...globalDataRequests,
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},
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
editOffer: {method: 'PUT', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: {

View File

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

View File

@ -311,7 +311,10 @@ export default class App extends React.Component {
// Handle the query params key/value pairs
for (let pair of qsParams.entries()) {
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]);
if (key === 'button') {
data.site.portal_button = JSON.parse(value);
} else if (key === 'name') {
@ -357,6 +360,8 @@ export default class App extends React.Component {
data.site.allow_self_signup = JSON.parse(value);
} else if (key === 'membersSignupAccess' && value) {
data.site.members_signup_access = value;
} else if (key === 'portalDefaultPlan' && value) {
data.site.portal_default_plan = value;
}
}
data.site.portal_plans = allowedPlans;
@ -389,6 +394,7 @@ export default class App extends React.Component {
}
];
}
return data;
}

View File

@ -894,7 +894,7 @@ function getSelectedPrice({products, selectedProduct, selectedInterval}) {
return selectedPrice;
}
function getActiveInterval({portalPlans, selectedInterval = 'year'}) {
function getActiveInterval({portalPlans, portalDefaultPlan, selectedInterval}) {
if (selectedInterval === 'month' && portalPlans.includes('monthly')) {
return 'month';
}
@ -903,26 +903,32 @@ function getActiveInterval({portalPlans, selectedInterval = 'year'}) {
return 'year';
}
if (portalPlans.includes('monthly')) {
return 'month';
if (portalDefaultPlan) {
if (portalDefaultPlan === 'monthly' && portalPlans.includes('monthly')) {
return 'month';
}
}
if (portalPlans.includes('yearly')) {
return 'year';
}
if (portalPlans.includes('monthly')) {
return 'month';
}
}
function ProductsSection({onPlanSelect, products, type = null, handleChooseSignup, errors}) {
const {site, member, t} = useContext(AppContext);
const {portal_plans: portalPlans} = site;
const defaultInterval = getActiveInterval({portalPlans});
const {portal_plans: portalPlans, portal_default_plan: portalDefaultPlan} = site;
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 selectedPrice = getSelectedPrice({products, selectedInterval, selectedProduct});
const activeInterval = getActiveInterval({portalPlans, selectedInterval});
const activeInterval = getActiveInterval({portalPlans, portalDefaultPlan, selectedInterval});
const isComplimentary = isComplimentaryMember({member});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,18 @@ class ErrorHandler extends React.Component {
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() {
if (this.state.hasError) {
return (
@ -45,6 +57,11 @@ const EmojiPickerPlugin = ({editorResource, ...props}) => {
return <_EmojiPickerPlugin {...props} />;
};
const TKCountPlugin = ({editorResource, ...props}) => {
const {TKCountPlugin: _TKCountPlugin} = editorResource.read();
return <_TKCountPlugin {...props} />;
};
export default class KoenigLexicalEditorInput extends Component {
@service ajax;
@service feature;
@ -82,12 +99,13 @@ export default class KoenigLexicalEditorInput extends Component {
ReactComponent = (props) => {
return (
<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>}>
<KoenigComposer
editorResource={this.editorResource}
initialEditorState={this.args.lexical}
onError={this.onError}
isTKEnabled={this.args.onTKCountChange ? true : false}
>
<KoenigComposableEditor
editorResource={this.editorResource}
@ -100,9 +118,11 @@ export default class KoenigLexicalEditorInput extends Component {
className={`koenig-lexical-editor-input ${this.feature.nightShift ? 'dark' : ''}`}
placeholderText={props.placeholderText}
placeholderClassName="koenig-lexical-editor-input-placeholder"
registerAPI={this.args.registerAPI}
>
<HtmlOutputPlugin editorResource={this.editorResource} html={props.html} setHtml={props.onChangeHtml} />
{this.emojiPicker ? <EmojiPickerPlugin editorResource={this.editorResource} /> : null}
{this.args.onTKCountChange ? <TKCountPlugin editorResource={this.editorResource} onChange={this.args.onTKCountChange} /> : null}
</KoenigComposableEditor>
</KoenigComposer>
</Suspense>

View File

@ -49,6 +49,18 @@ class ErrorHandler extends React.Component {
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() {
if (this.state.hasError) {
return (
@ -75,9 +87,9 @@ const WordCountPlugin = ({editorResource, ...props}) => {
return <_WordCountPlugin {...props} />;
};
const TKPlugin = ({editorResource, ...props}) => {
const {TKPlugin: _TKPlugin} = editorResource.read();
return <_TKPlugin {...props} />;
const TKCountPlugin = ({editorResource, ...props}) => {
const {TKCountPlugin: _TKCountPlugin} = editorResource.read();
return <_TKCountPlugin {...props} />;
};
export default class KoenigLexicalEditor extends Component {
@ -513,7 +525,7 @@ export default class KoenigLexicalEditor extends Component {
return (
<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>}>
<KoenigComposer
editorResource={this.editorResource}
@ -526,6 +538,7 @@ export default class KoenigLexicalEditor extends Component {
multiplayerEndpoint={multiplayerEndpoint}
onError={this.onError}
darkMode={this.feature.nightShift}
isTKEnabled={this.feature.tkReminders}
>
<KoenigEditor
editorResource={this.editorResource}
@ -536,7 +549,7 @@ export default class KoenigLexicalEditor extends Component {
registerAPI={this.args.registerAPI}
/>
<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>
</Suspense>
</ErrorHandler>

View File

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

View File

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

View File

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

View File

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

View File

@ -44,9 +44,9 @@
"@tryghost/color-utils": "0.2.0",
"@tryghost/ember-promise-modals": "2.0.1",
"@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/koenig-lexical": "0.5.27",
"@tryghost/koenig-lexical": "1.0.3",
"@tryghost/limit-service": "1.2.12",
"@tryghost/members-csv": "0.0.0",
"@tryghost/nql": "0.12.0",

View File

@ -5,6 +5,7 @@ import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-sup
import {beforeEach, describe, it} from 'mocha';
import {blur, click, currentRouteName, currentURL, fillIn, find, findAll, triggerEvent, typeIn} from '@ember/test-helpers';
import {datepickerSelect} from 'ember-power-datepicker/test-support';
import {enableLabsFlag} from '../helpers/labs-flag';
import {expect} from 'chai';
import {selectChoose} from 'ember-power-select/test-support';
import {setupApplicationTest} from 'ember-mocha';
@ -570,5 +571,38 @@ describe('Acceptance: Editor', function () {
'breadcrumb link'
).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;
});
});
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 app = express('root');
app.use(sentry.requestHandler);
if (config.get('sentry')?.tracing?.enabled === true) {
app.use(sentry.tracingHandler);
}
app.enable('maintenance');
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('--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('--print-dependencies', {type: 'boolean', defaultValue: false, desc: 'Prints the dependency tree for the data generator and exits'});
}
initializeContext(context) {
@ -51,7 +52,8 @@ module.exports = class DataGeneratorCommand extends Command {
baseUrl: config.getSiteUrl(),
clearDatabase: argv['clear-database'],
tables,
withDefault: argv['with-default']
withDefault: argv['with-default'],
printDependencies: argv['print-dependencies']
});
try {
await dataGenerator.importData();

View File

@ -38,6 +38,7 @@ const EDITABLE_SETTINGS = [
'portal_name',
'portal_button',
'portal_plans',
'portal_default_plan',
'portal_button_style',
'firstpromoter',
'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\"]",
"type": "array"
},
"portal_default_plan": {
"defaultValue": "yearly",
"validations": {
"isEmpty": false,
"isIn": [["yearly", "monthly"]]
},
"type": "string"
},
"portal_products": {
"defaultValue": "[]",
"type": "array"

View File

@ -182,7 +182,7 @@
},
"portal": {
"url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js",
"version": "2.36"
"version": "2.37"
},
"sodoSearch": {
"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 version = require('@tryghost/version').full;
const environment = config.get('env');
Sentry.init({
const sentryInitConfig = {
dsn: sentryConfig.dsn,
release: 'ghost@' + version,
environment: environment,
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 = {
requestHandler: Sentry.Handlers.requestHandler(),
@ -82,6 +91,7 @@ if (sentryConfig && !sentryConfig.disabled) {
return (error.statusCode === 500);
}
}),
tracingHandler: Sentry.Handlers.tracingHandler(),
captureException: Sentry.captureException,
beforeSend: beforeSend
};
@ -95,6 +105,7 @@ if (sentryConfig && !sentryConfig.disabled) {
module.exports = {
requestHandler: expressNoop,
errorHandler: expressNoop,
tracingHandler: expressNoop,
captureException: noop
};
}

View File

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

View File

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

View File

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

View File

@ -9,15 +9,17 @@ const models = require('../../../core/server/models');
const {mockLabsDisabled, mockLabsEnabled} = require('../../utils/e2e-framework-mock-manager');
const {anyErrorId} = matchers;
const CURRENT_SETTINGS_COUNT = 86;
const CURRENT_SETTINGS_COUNT = 87;
const settingsMatcher = {};
const publicHashSettingMatcher = {
key: 'public_hash',
value: stringMatching(/[a-z0-9]{30}/)
};
const labsSettingMatcher = {
key: 'labs',
value: stringMatching(/\{[^\s]+\}/)
};
@ -30,10 +32,10 @@ const matchSettingsArray = (length) => {
settingsArray[26] = publicHashSettingMatcher;
}
if (length > 60) {
if (length > 61) {
// 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
settingsArray[60] = labsSettingMatcher;
settingsArray[61] = labsSettingMatcher;
}
return settingsArray;

View File

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

View File

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

View File

@ -5,7 +5,7 @@ const db = require('../../../core/server/data/db');
// Stuff we are testing
const models = require('../../../core/server/models');
const SETTINGS_LENGTH = 93;
const SETTINGS_LENGTH = 94;
describe('Settings Model', function () {
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
// 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);
});
});

View File

@ -37,7 +37,7 @@ describe('DB version integrity', function () {
// Only these variables should need updating
const currentSchemaHash = '34a9fa4dc1223ef6c45f8ed991d25de5';
const currentFixturesHash = '4db87173699ad9c9d8a67ccab96dfd2d';
const currentSettingsHash = '3128d4ec667a50049486b0c21f04be07';
const currentSettingsHash = '5c957ceb48c4878767d7d3db484c592d';
const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01';
// 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\"]",
"type": "array"
},
"portal_default_plan": {
"defaultValue": "yearly",
"validations": {
"isEmpty": false,
"isIn": [["yearly", "monthly"]]
},
"type": "string"
},
"portal_products": {
"defaultValue": "[]",
"type": "array"

View File

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

View File

@ -18,6 +18,7 @@ class DataGenerator {
baseDataPack = '',
baseUrl,
logger,
printDependencies,
withDefault
}) {
this.knex = knex;
@ -28,6 +29,7 @@ class DataGenerator {
this.baseUrl = baseUrl;
this.logger = logger;
this.withDefault = withDefault;
this.printDependencies = printDependencies;
}
sortTableList() {
@ -91,7 +93,7 @@ class DataGenerator {
}
let baseData = {};
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');
} catch (error) {
this.logger.error('Failed to read data pack: ', error);
@ -158,6 +160,14 @@ class DataGenerator {
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) {
await this.clearData(transaction);
}

View File

@ -71,7 +71,7 @@ class MembersImporter extends TableImporter {
id,
uuid: 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',
name: name,
expertise: luck(30) ? faker.name.jobTitle() : undefined,

View File

@ -5,7 +5,7 @@ const dateToDatabaseString = require('../utils/database-date');
class MembersSubscribeEventsImporter extends TableImporter {
static table = 'members_subscribe_events';
static dependencies = ['members', 'newsletters', 'subscriptions'];
static dependencies = ['members', 'newsletters'/*, 'subscriptions'*/];
constructor(knex, transaction) {
super(MembersSubscribeEventsImporter.table, knex, transaction);
@ -14,9 +14,9 @@ class MembersSubscribeEventsImporter extends TableImporter {
async import(quantity) {
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.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) {
@ -32,22 +32,15 @@ class MembersSubscribeEventsImporter extends TableImporter {
return null;
}
let createdAt = dateToDatabaseString(faker.date.between(new Date(this.model.created_at), new Date()));
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) {
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 {
id: faker.database.mongodbObjectId(),
member_id: this.model.id,

View File

@ -6,21 +6,23 @@ const {slugify} = require('@tryghost/string');
class NewslettersImporter extends TableImporter {
static table = 'newsletters';
static dependencies = [];
defaultQuantity = 2;
sortOrder = 0;
constructor(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() {
const name = this.names.shift();
const name = `${faker.commerce.productAdjective()} ${faker.word.noun()}`;
const sortOrder = this.sortOrder;
this.sortOrder = this.sortOrder + 1;
const weekAfter = new Date(blogStartDate);
weekAfter.setDate(weekAfter.getDate() + 7);
return {
id: faker.database.mongodbObjectId(),
uuid: faker.datatype.uuid(),

View File

@ -1,3 +1,5 @@
const debug = require('@tryghost/debug')('TableImporter');
class TableImporter {
/**
* @type {object|undefined} model Referenced model when generating data
@ -21,27 +23,29 @@ class TableImporter {
this.transaction = transaction;
}
async import(amount = this.defaultQuantity) {
const batchSize = 500;
let batch = [];
async #generateData(amount = this.defaultQuantity) {
let data = [];
for (let i = 0; i < amount; i++) {
const model = await this.generate();
if (model) {
batch.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 = [];
data.push(model);
}
}
// Process final batch
if (batch.length > 0) {
await this.knex.batchInsert(this.name, batch, batchSize).transacting(this.transaction);
return data;
}
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
*/
async importForEach(models = [], amount) {
const batchSize = 500;
let batch = [];
const data = [];
debug (`Generating data for ${models.length} models for ${this.name}`);
const now = Date.now();
for (const model of models) {
this.setReferencedModel(model);
@ -59,24 +65,19 @@ class TableImporter {
if (!Number.isInteger(currentAmount)) {
currentAmount = Math.floor(currentAmount) + ((Math.random() < currentAmount % 1) ? 1 : 0);
}
for (let i = 0; i < currentAmount; i++) {
const data = await this.generate();
if (data) {
batch.push(data);
} 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 = [];
}
const generatedData = await this.#generateData(currentAmount);
if (generatedData.length > 0) {
data.push(...generatedData);
}
}
// Process final batch
if (batch.length > 0) {
await this.knex.batchInsert(this.name, batch, batchSize).transacting(this.transaction);
debug(`${this.name} generated ${data.length} records in ${Date.now() - now}ms`);
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"
],
"devDependencies": {
"@tryghost/debug": "0.1.27",
"c8": "8.0.1",
"knex": "2.4.2",
"mocha": "10.2.0",

View File

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

View File

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

View File

@ -29,7 +29,7 @@
"@tryghost/email-events": "0.0.0",
"@tryghost/errors": "1.2.26",
"@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/tpl": "0.1.26",
"@tryghost/validator": "0.2.6",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,7 +29,8 @@
"dev": "node .github/scripts/dev.js",
"fix": "yarn cache clean && rm -rf node_modules && yarn",
"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",
"test": "nx run-many -t test",
"test:unit": "nx run-many -t test:unit",
@ -115,6 +116,6 @@
"nx": "16.8.1",
"rimraf": "5.0.5",
"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