Merge branch 'main' into main
This commit is contained in:
commit
c1ae3ad712
4
.github/scripts/dev.js
vendored
4
.github/scripts/dev.js
vendored
@ -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
19
.github/scripts/docker-compose.yml
vendored
Normal 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
0
.github/scripts/mysql-preload/.keep
vendored
Normal file
94
.github/scripts/setup.js
vendored
Normal file
94
.github/scripts/setup.js
vendored
Normal 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`));
|
||||
}
|
||||
})();
|
@ -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"
|
||||
},
|
||||
|
@ -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>
|
||||
}
|
||||
</>
|
||||
|
@ -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",
|
||||
|
@ -176,6 +176,10 @@
|
||||
"key": "portal_plans",
|
||||
"value": "[\"monthly\",\"yearly\",\"free\"]"
|
||||
},
|
||||
{
|
||||
"key": "portal_default_plan",
|
||||
"value": "yearly"
|
||||
},
|
||||
{
|
||||
"key": "portal_products",
|
||||
"value": "[]"
|
||||
|
@ -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>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>;
|
||||
};
|
||||
|
||||
|
@ -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‘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‘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‘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) {
|
||||
|
@ -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'
|
||||
|
@ -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',
|
||||
|
@ -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');
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
@ -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();
|
||||
@ -335,8 +362,19 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
</div>
|
||||
<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>;
|
||||
|
32
apps/admin-x-settings/src/hooks/useSaveButton.ts
Normal file
32
apps/admin-x-settings/src/hooks/useSaveButton.ts
Normal 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
|
||||
};
|
||||
};
|
@ -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(',')));
|
||||
|
@ -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>$/}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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: {
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@tryghost/portal",
|
||||
"version": "2.36.5",
|
||||
"version": "2.37.0",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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});
|
||||
|
||||
|
@ -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';
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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 --}}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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}}
|
||||
|
@ -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),
|
||||
|
@ -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",
|
||||
@ -201,4 +201,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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();
|
||||
|
@ -38,6 +38,7 @@ const EDITABLE_SETTINGS = [
|
||||
'portal_name',
|
||||
'portal_button',
|
||||
'portal_plans',
|
||||
'portal_default_plan',
|
||||
'portal_button_style',
|
||||
'firstpromoter',
|
||||
'firstpromoter_id',
|
||||
|
@ -0,0 +1,8 @@
|
||||
const {addSetting} = require('../../utils');
|
||||
|
||||
module.exports = addSetting({
|
||||
key: 'portal_default_plan',
|
||||
value: 'yearly',
|
||||
type: 'string',
|
||||
group: 'portal'
|
||||
});
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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",
|
||||
|
@ -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": "[]",
|
||||
|
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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(),
|
||||
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -18,6 +18,7 @@
|
||||
"lib"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@tryghost/debug": "0.1.27",
|
||||
"c8": "8.0.1",
|
||||
"knex": "2.4.2",
|
||||
"mocha": "10.2.0",
|
||||
|
@ -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": {}
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -30,6 +30,6 @@
|
||||
"mocha": "10.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "23.7.7"
|
||||
"i18next": "23.7.8"
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user