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 = {
|
const COMMAND_TYPESCRIPT = {
|
||||||
name: 'ts',
|
name: 'ts',
|
||||||
command: 'nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/bookshelf-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations,ghost/email-addresses -- nx run \\$NX_PROJECT_NAME:build:ts',
|
command: 'while [ 1 ]; do nx watch --projects=ghost/collections,ghost/in-memory-repository,ghost/bookshelf-repository,ghost/mail-events,ghost/model-to-domain-event-interceptor,ghost/post-revisions,ghost/nql-filter-expansions,ghost/post-events,ghost/donations,ghost/recommendations,ghost/email-addresses -- nx run \\$NX_PROJECT_NAME:build:ts; done',
|
||||||
cwd: path.resolve(__dirname, '../../'),
|
cwd: path.resolve(__dirname, '../../'),
|
||||||
prefixColor: 'cyan',
|
prefixColor: 'cyan',
|
||||||
env: {}
|
env: {}
|
||||||
@ -55,7 +55,7 @@ const adminXApps = '@tryghost/admin-x-demo,@tryghost/admin-x-settings';
|
|||||||
|
|
||||||
const COMMANDS_ADMINX = [{
|
const COMMANDS_ADMINX = [{
|
||||||
name: 'adminXDeps',
|
name: 'adminXDeps',
|
||||||
command: 'nx watch --projects=apps/admin-x-design-system,apps/admin-x-framework -- nx run \\$NX_PROJECT_NAME:build --skip-nx-cache',
|
command: 'while [ 1 ]; do nx watch --projects=apps/admin-x-design-system,apps/admin-x-framework -- nx run \\$NX_PROJECT_NAME:build --skip-nx-cache; done',
|
||||||
cwd: path.resolve(__dirname, '../..'),
|
cwd: path.resolve(__dirname, '../..'),
|
||||||
prefixColor: '#C35831',
|
prefixColor: '#C35831',
|
||||||
env: {}
|
env: {}
|
||||||
|
19
.github/scripts/docker-compose.yml
vendored
Normal file
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": {
|
"devDependencies": {
|
||||||
"@codemirror/lang-html": "^6.4.5",
|
"@codemirror/lang-html": "^6.4.5",
|
||||||
"@storybook/addon-essentials": "7.6.3",
|
"@storybook/addon-essentials": "7.6.4",
|
||||||
"@storybook/addon-interactions": "7.6.3",
|
"@storybook/addon-interactions": "7.6.4",
|
||||||
"@storybook/addon-links": "7.6.3",
|
"@storybook/addon-links": "7.6.4",
|
||||||
"@storybook/addon-styling": "1.3.7",
|
"@storybook/addon-styling": "1.3.7",
|
||||||
"@storybook/blocks": "7.6.3",
|
"@storybook/blocks": "7.6.4",
|
||||||
"@storybook/react": "7.6.3",
|
"@storybook/react": "7.6.4",
|
||||||
"@storybook/react-vite": "7.6.3",
|
"@storybook/react-vite": "7.6.4",
|
||||||
"@storybook/testing-library": "0.2.2",
|
"@storybook/testing-library": "0.2.2",
|
||||||
"@testing-library/react": "14.1.0",
|
"@testing-library/react": "14.1.0",
|
||||||
"@vitejs/plugin-react": "4.2.1",
|
"@vitejs/plugin-react": "4.2.1",
|
||||||
@ -46,9 +46,9 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"rollup-plugin-node-builtins": "2.1.2",
|
"rollup-plugin-node-builtins": "2.1.2",
|
||||||
"sinon": "17.0.0",
|
"sinon": "17.0.0",
|
||||||
"storybook": "7.6.3",
|
"storybook": "7.6.4",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"typescript": "5.3.2",
|
"typescript": "5.3.3",
|
||||||
"vite": "4.5.1",
|
"vite": "4.5.1",
|
||||||
"vite-plugin-svgr": "3.3.0"
|
"vite-plugin-svgr": "3.3.0"
|
||||||
},
|
},
|
||||||
|
@ -5,6 +5,8 @@ export type Tab<ID = string> = {
|
|||||||
id: ID;
|
id: ID;
|
||||||
title: string;
|
title: string;
|
||||||
counter?: number | null;
|
counter?: number | null;
|
||||||
|
tabWrapperClassName?: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional, so you can just use the tabs to other views
|
* Optional, so you can just use the tabs to other views
|
||||||
@ -102,6 +104,7 @@ export interface TabViewProps<ID = string> {
|
|||||||
border?: boolean;
|
border?: boolean;
|
||||||
buttonBorder?: boolean;
|
buttonBorder?: boolean;
|
||||||
width?: TabWidth;
|
width?: TabWidth;
|
||||||
|
containerClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabView<ID extends string = string>({
|
function TabView<ID extends string = string>({
|
||||||
@ -110,7 +113,8 @@ function TabView<ID extends string = string>({
|
|||||||
selectedTab,
|
selectedTab,
|
||||||
border = true,
|
border = true,
|
||||||
buttonBorder = border,
|
buttonBorder = border,
|
||||||
width = 'normal'
|
width = 'normal',
|
||||||
|
containerClassName
|
||||||
}: TabViewProps<ID>) {
|
}: TabViewProps<ID>) {
|
||||||
if (tabs.length !== 0 && selectedTab === undefined) {
|
if (tabs.length !== 0 && selectedTab === undefined) {
|
||||||
selectedTab = tabs[0].id;
|
selectedTab = tabs[0].id;
|
||||||
@ -126,7 +130,7 @@ function TabView<ID extends string = string>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className={containerClassName}>
|
||||||
<TabList
|
<TabList
|
||||||
border={border}
|
border={border}
|
||||||
buttonBorder={buttonBorder}
|
buttonBorder={buttonBorder}
|
||||||
@ -139,8 +143,8 @@ function TabView<ID extends string = string>({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{tab.contents &&
|
{tab.contents &&
|
||||||
<div key={tab.id} className={`${selectedTab === tab.id ? 'block' : 'hidden'}`} role='tabpanel'>
|
<div key={tab.id} className={`${selectedTab === tab.id ? 'block' : 'hidden'} ${tab.tabWrapperClassName}`} role='tabpanel'>
|
||||||
<div>{tab.contents}</div>
|
<div className={tab.containerClassName}>{tab.contents}</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</>
|
</>
|
||||||
|
@ -78,7 +78,7 @@
|
|||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"sinon": "17.0.0",
|
"sinon": "17.0.0",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"typescript": "5.3.2"
|
"typescript": "5.3.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/react": "7.85.0",
|
"@sentry/react": "7.85.0",
|
||||||
|
@ -176,6 +176,10 @@
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\"monthly\",\"yearly\",\"free\"]"
|
"value": "[\"monthly\",\"yearly\",\"free\"]"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]"
|
"value": "[]"
|
||||||
|
@ -1,93 +1,26 @@
|
|||||||
import CodeModal from './code/CodeModal';
|
import CodeModal from './code/CodeModal';
|
||||||
import NiceModal from '@ebay/nice-modal-react';
|
import NiceModal from '@ebay/nice-modal-react';
|
||||||
import React, {useMemo, useRef, useState} from 'react';
|
import React from 'react';
|
||||||
import TopLevelGroup from '../../TopLevelGroup';
|
import TopLevelGroup from '../../TopLevelGroup';
|
||||||
import useSettingGroup from '../../../hooks/useSettingGroup';
|
import {Button, SettingGroupHeader, withErrorBoundary} from '@tryghost/admin-x-design-system';
|
||||||
import {Button, CodeEditor, TabView, withErrorBoundary} from '@tryghost/admin-x-design-system';
|
|
||||||
import {ReactCodeMirrorRef} from '@uiw/react-codemirror';
|
|
||||||
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
|
|
||||||
|
|
||||||
const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||||
const {
|
|
||||||
localSettings,
|
|
||||||
isEditing,
|
|
||||||
saveState,
|
|
||||||
handleSave,
|
|
||||||
handleCancel,
|
|
||||||
updateSetting,
|
|
||||||
handleEditingChange
|
|
||||||
} = useSettingGroup();
|
|
||||||
|
|
||||||
const [headerContent, footerContent] = getSettingValues<string>(localSettings, ['codeinjection_head', 'codeinjection_foot']);
|
|
||||||
|
|
||||||
const [selectedTab, setSelectedTab] = useState<'header' | 'footer'>('header');
|
|
||||||
|
|
||||||
const headerEditorRef = useRef<ReactCodeMirrorRef>(null);
|
|
||||||
const footerEditorRef = useRef<ReactCodeMirrorRef>(null);
|
|
||||||
|
|
||||||
const html = useMemo(() => import('@codemirror/lang-html').then(module => module.html()), []);
|
|
||||||
|
|
||||||
const headerProps = {
|
|
||||||
extensions: [html],
|
|
||||||
hint: 'Code here will be injected into the {{ghost_head}} tag on every page of the site',
|
|
||||||
value: headerContent || '',
|
|
||||||
onChange: (value: string) => updateSetting('codeinjection_head', value)
|
|
||||||
};
|
|
||||||
|
|
||||||
const footerProps = {
|
|
||||||
extensions: [html],
|
|
||||||
hint: 'Code here will be injected into the {{ghost_foot}} tag on every page of the site',
|
|
||||||
value: footerContent || '',
|
|
||||||
onChange: (value: string) => updateSetting('codeinjection_foot', value)
|
|
||||||
};
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{
|
|
||||||
id: 'header',
|
|
||||||
title: 'Site header',
|
|
||||||
contents: (<CodeEditor {...headerProps} ref={headerEditorRef} className='mt-2' data-testid='header-code' autoFocus />)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'footer',
|
|
||||||
title: 'Site footer',
|
|
||||||
contents: (<CodeEditor {...footerProps} ref={footerEditorRef} className='mt-2' data-testid='footer-code' />)
|
|
||||||
}
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopLevelGroup
|
<TopLevelGroup
|
||||||
|
customHeader={
|
||||||
|
<div className='z-10 flex items-start justify-between'>
|
||||||
|
<SettingGroupHeader description='Add custom code to your publication.' title='Code injection' />
|
||||||
|
<Button color='green' label='Open' link linkWithPadding onClick={() => {
|
||||||
|
NiceModal.show(CodeModal);
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
description="Add custom code to your publication"
|
description="Add custom code to your publication"
|
||||||
isEditing={isEditing}
|
|
||||||
keywords={keywords}
|
keywords={keywords}
|
||||||
navid='code-injection'
|
navid='code-injection'
|
||||||
saveState={saveState}
|
|
||||||
testId='code-injection'
|
testId='code-injection'
|
||||||
title="Code injection"
|
title="Code injection"
|
||||||
onCancel={handleCancel}
|
/>
|
||||||
onEditingChange={handleEditingChange}
|
|
||||||
onSave={handleSave}
|
|
||||||
>
|
|
||||||
{isEditing && (
|
|
||||||
<div className='relative'>
|
|
||||||
<TabView<'header' | 'footer'> selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
|
|
||||||
<Button
|
|
||||||
className='absolute right-0 top-1 text-sm'
|
|
||||||
label='Fullscreen'
|
|
||||||
unstyled
|
|
||||||
onClick={() => NiceModal.show(CodeModal, {
|
|
||||||
...(selectedTab === 'header' ? headerProps : footerProps),
|
|
||||||
afterClose: () => {
|
|
||||||
if (selectedTab === 'header') {
|
|
||||||
headerEditorRef.current?.view?.focus();
|
|
||||||
} else {
|
|
||||||
footerEditorRef.current?.view?.focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TopLevelGroup>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||||
import React, {useMemo} from 'react';
|
import React, {useMemo, useRef, useState} from 'react';
|
||||||
import {CodeEditor, Modal} from '@tryghost/admin-x-design-system';
|
import useSettingGroup from '../../../../hooks/useSettingGroup';
|
||||||
|
import {ButtonGroup, CodeEditor, Heading, Modal, TabView} from '@tryghost/admin-x-design-system';
|
||||||
|
import {ReactCodeMirrorRef} from '@uiw/react-codemirror';
|
||||||
|
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
|
||||||
|
import {useSaveButton} from '../../../../hooks/useSaveButton';
|
||||||
|
|
||||||
interface CodeModalProps {
|
interface CodeModalProps {
|
||||||
hint?: React.ReactNode;
|
hint?: React.ReactNode;
|
||||||
@ -9,18 +13,91 @@ interface CodeModalProps {
|
|||||||
afterClose?: () => void
|
afterClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodeModal: React.FC<CodeModalProps> = ({hint, value, onChange, afterClose}) => {
|
const CodeModal: React.FC<CodeModalProps> = ({afterClose}) => {
|
||||||
|
const {
|
||||||
|
localSettings,
|
||||||
|
handleSave,
|
||||||
|
updateSetting
|
||||||
|
} = useSettingGroup();
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
|
|
||||||
|
const [headerContent, footerContent] = getSettingValues<string>(localSettings, ['codeinjection_head', 'codeinjection_foot']);
|
||||||
|
|
||||||
|
const [selectedTab, setSelectedTab] = useState<'header' | 'footer'>('header');
|
||||||
|
|
||||||
|
const headerEditorRef = useRef<ReactCodeMirrorRef>(null);
|
||||||
|
const footerEditorRef = useRef<ReactCodeMirrorRef>(null);
|
||||||
|
|
||||||
const html = useMemo(() => import('@codemirror/lang-html').then(module => module.html()), []);
|
const html = useMemo(() => import('@codemirror/lang-html').then(module => module.html()), []);
|
||||||
|
|
||||||
const onOk = () => {
|
const headerProps = {
|
||||||
modal.remove();
|
extensions: [html],
|
||||||
afterClose?.();
|
hint: 'Code here will be injected into the {{ghost_head}} tag on every page of the site',
|
||||||
|
value: headerContent || '',
|
||||||
|
onChange: (value: string) => updateSetting('codeinjection_head', value)
|
||||||
};
|
};
|
||||||
|
|
||||||
return <Modal afterClose={afterClose} cancelLabel='' okColor='grey' okLabel='Done' size='full' testId='modal-code' onOk={onOk}>
|
const footerProps = {
|
||||||
<CodeEditor extensions={[html]} height='full' hint={hint} value={value} autoFocus onChange={onChange} />
|
extensions: [html],
|
||||||
|
hint: 'Code here will be injected into the {{ghost_foot}} tag on every page of the site',
|
||||||
|
value: footerContent || '',
|
||||||
|
onChange: (value: string) => updateSetting('codeinjection_foot', value)
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
id: 'header',
|
||||||
|
title: 'Site header',
|
||||||
|
contents: (<CodeEditor height='full' {...headerProps} ref={headerEditorRef} className='mt-2' data-testid='header-code' autoFocus />),
|
||||||
|
tabWrapperClassName: 'flex-auto',
|
||||||
|
containerClassName: 'h-full'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'footer',
|
||||||
|
title: 'Site footer',
|
||||||
|
contents: (<CodeEditor height='full' {...footerProps} ref={footerEditorRef} className='mt-2' data-testid='footer-code' />),
|
||||||
|
tabWrapperClassName: 'flex-auto',
|
||||||
|
containerClassName: 'h-full'
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const {savingTitle, isSaving, onSaveClick} = useSaveButton(handleSave, true);
|
||||||
|
|
||||||
|
return <Modal
|
||||||
|
afterClose={afterClose}
|
||||||
|
cancelLabel='Close'
|
||||||
|
footer={<></>}
|
||||||
|
height='full'
|
||||||
|
size='full'
|
||||||
|
testId='modal-code-injection'
|
||||||
|
>
|
||||||
|
<div className='flex h-full flex-col'>
|
||||||
|
<div className='mb-4 flex items-center justify-between'>
|
||||||
|
<Heading level={2}>Code injection</Heading>
|
||||||
|
<ButtonGroup buttons={[
|
||||||
|
{
|
||||||
|
label: 'Close',
|
||||||
|
color: 'outline',
|
||||||
|
onClick: () => {
|
||||||
|
modal.remove();
|
||||||
|
afterClose?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
disabled: isSaving,
|
||||||
|
label: savingTitle,
|
||||||
|
color: savingTitle === 'Saved' ? 'green' : 'black',
|
||||||
|
onClick: onSaveClick
|
||||||
|
}
|
||||||
|
]} />
|
||||||
|
</div>
|
||||||
|
<TabView<'header' | 'footer'>
|
||||||
|
containerClassName='flex-auto flex flex-col mb-16'
|
||||||
|
selectedTab={selectedTab}
|
||||||
|
tabs={tabs}
|
||||||
|
onTabChange={setSelectedTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Modal>;
|
</Modal>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -73,18 +73,12 @@ const ReplyToEmailField: React.FC<{
|
|||||||
setSenderReplyTo(rendered);
|
setSenderReplyTo(rendered);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hint = (
|
|
||||||
<>
|
|
||||||
If left empty, replies go to {newsletterAddress}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pro users without custom sending domains
|
// Pro users without custom sending domains
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
error={Boolean(errors.sender_reply_to)}
|
error={Boolean(errors.sender_reply_to)}
|
||||||
hint={errors.sender_reply_to || hint}
|
hint={errors.sender_reply_to}
|
||||||
placeholder={''}
|
placeholder={newsletterAddress || ''}
|
||||||
title="Reply-to email"
|
title="Reply-to email"
|
||||||
value={senderReplyTo}
|
value={senderReplyTo}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
@ -219,8 +213,9 @@ const Sidebar: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
error={Boolean(errors.sender_email)}
|
error={Boolean(errors.sender_email)}
|
||||||
hint={errors.sender_email || `If left empty, ${defaultEmailAddress} will be used`}
|
hint={errors.sender_email}
|
||||||
rightPlaceholder={`@${sendingDomain(config)}`}
|
placeholder={defaultEmailAddress}
|
||||||
|
rightPlaceholder={sendingEmailUsername ? `@${sendingDomain(config)}` : `` }
|
||||||
title="Sender email address"
|
title="Sender email address"
|
||||||
value={sendingEmailUsername || ''}
|
value={sendingEmailUsername || ''}
|
||||||
onBlur={validate}
|
onBlur={validate}
|
||||||
@ -257,7 +252,7 @@ const Sidebar: React.FC<{
|
|||||||
/>
|
/>
|
||||||
<TextArea rows={2} title="Description" value={newsletter.description || ''} onChange={e => updateNewsletter({description: e.target.value})} />
|
<TextArea rows={2} title="Description" value={newsletter.description || ''} onChange={e => updateNewsletter({description: e.target.value})} />
|
||||||
</Form>
|
</Form>
|
||||||
<Form className='mt-6' gap='sm' margins='lg' title='Email addresses'>
|
<Form className='mt-6' gap='sm' margins='lg' title='Email info'>
|
||||||
<TextField placeholder={siteTitle} title="Sender name" value={newsletter.sender_name || ''} onChange={e => updateNewsletter({sender_name: e.target.value})} />
|
<TextField placeholder={siteTitle} title="Sender name" value={newsletter.sender_name || ''} onChange={e => updateNewsletter({sender_name: e.target.value})} />
|
||||||
{renderSenderEmailField()}
|
{renderSenderEmailField()}
|
||||||
<ReplyToEmailField clearError={clearError} errors={errors} newsletter={newsletter} updateNewsletter={updateNewsletter} validate={validate} />
|
<ReplyToEmailField clearError={clearError} errors={errors} newsletter={newsletter} updateNewsletter={updateNewsletter} validate={validate} />
|
||||||
@ -538,17 +533,18 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
|
|||||||
savingDelay: 500,
|
savingDelay: 500,
|
||||||
onSave: async () => {
|
onSave: async () => {
|
||||||
const {newsletters: [updatedNewsletter], meta: {sent_email_verification: [emailToVerify] = []} = {}} = await editNewsletter(formState); ``;
|
const {newsletters: [updatedNewsletter], meta: {sent_email_verification: [emailToVerify] = []} = {}} = await editNewsletter(formState); ``;
|
||||||
|
const previousFrom = renderSenderEmail(updatedNewsletter, config, defaultEmailAddress);
|
||||||
|
const previousReplyTo = renderReplyToEmail(updatedNewsletter, config, supportEmailAddress, defaultEmailAddress) || previousFrom;
|
||||||
|
|
||||||
let title;
|
let title;
|
||||||
let prompt;
|
let prompt;
|
||||||
|
|
||||||
if (emailToVerify && emailToVerify === 'sender_email') {
|
if (emailToVerify && emailToVerify === 'sender_email') {
|
||||||
const previousFrom = renderSenderEmail(updatedNewsletter, config, defaultEmailAddress);
|
|
||||||
title = 'Confirm newsletter email address';
|
title = 'Confirm newsletter email address';
|
||||||
prompt = <>We‘ve sent a confirmation email to <strong>{formState.sender_email}</strong>. Until the address has been verified, newsletters will be sent from the {updatedNewsletter.sender_email ? ' previous' : ' default'} email address{previousFrom ? ` (${previousFrom})` : ''}.</>;
|
prompt = <>We‘ve sent a confirmation email to <strong>{formState.sender_email}</strong>. Until the address has been verified, newsletters will be sent from the {updatedNewsletter.sender_email ? ' previous' : ' default'} email address{previousFrom ? ` (${previousFrom})` : ''}.</>;
|
||||||
} else if (emailToVerify && emailToVerify === 'sender_reply_to') {
|
} else if (emailToVerify && emailToVerify === 'sender_reply_to') {
|
||||||
const previousReplyTo = renderReplyToEmail(updatedNewsletter, config, supportEmailAddress, defaultEmailAddress);
|
|
||||||
title = 'Confirm reply-to address';
|
title = 'Confirm reply-to address';
|
||||||
prompt = <>We‘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) {
|
if (title && prompt) {
|
||||||
|
@ -48,17 +48,21 @@ const Offers: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
|
|
||||||
const latestThree = activeOffers.slice(0, 3);
|
const latestThree = activeOffers.slice(0, 3);
|
||||||
|
|
||||||
const openModal = () => {
|
const openOfferListModal = () => {
|
||||||
updateRoute('offers/edit');
|
updateRoute('offers/edit');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAddModal = () => {
|
||||||
|
updateRoute('offers/new');
|
||||||
|
};
|
||||||
|
|
||||||
const goToOfferEdit = (offerId: string) => {
|
const goToOfferEdit = (offerId: string) => {
|
||||||
updateRoute(`offers/edit/${offerId}`);
|
updateRoute(`offers/edit/${offerId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopLevelGroup
|
<TopLevelGroup
|
||||||
customButtons={<Button color='green' disabled={!checkStripeEnabled(settings, config)} label='Manage offers' link linkWithPadding onClick={openModal}/>}
|
customButtons={<Button color='green' disabled={!checkStripeEnabled(settings, config)} label={allOffers.length > 0 ? 'Manage offers' : 'Add offers'} link linkWithPadding onClick={allOffers.length > 0 ? openOfferListModal : openAddModal}/>}
|
||||||
description={<>Create discounts & coupons to boost new subscriptions. {allOffers.length === 0 && <a className='text-green' href="https://ghost.org/help/offers" rel="noopener noreferrer" target="_blank">Learn more</a>}</>}
|
description={<>Create discounts & coupons to boost new subscriptions. {allOffers.length === 0 && <a className='text-green' href="https://ghost.org/help/offers" rel="noopener noreferrer" target="_blank">Learn more</a>}</>}
|
||||||
keywords={keywords}
|
keywords={keywords}
|
||||||
navid='offers'
|
navid='offers'
|
||||||
|
@ -128,7 +128,7 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
|
|||||||
handleDurationInMonthsInput,
|
handleDurationInMonthsInput,
|
||||||
handleAmountInput,
|
handleAmountInput,
|
||||||
handleCodeInput,
|
handleCodeInput,
|
||||||
validate,
|
clearError,
|
||||||
errors,
|
errors,
|
||||||
testId,
|
testId,
|
||||||
handleTrialAmountInput,
|
handleTrialAmountInput,
|
||||||
@ -169,11 +169,16 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
|
|||||||
maxLength={40}
|
maxLength={40}
|
||||||
placeholder='Black Friday'
|
placeholder='Black Friday'
|
||||||
title='Offer name'
|
title='Offer name'
|
||||||
onBlur={validate}
|
onBlur={(e) => {
|
||||||
|
if (!e.target.value && e.target.value.length === 0) {
|
||||||
|
errors.name = 'Name is required';
|
||||||
|
}
|
||||||
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleNameInput(e);
|
handleNameInput(e);
|
||||||
setNameLength(e.target.value.length);
|
setNameLength(e.target.value.length);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={() => clearError('name')}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
error={Boolean(errors.displayTitle)}
|
error={Boolean(errors.displayTitle)}
|
||||||
@ -181,10 +186,15 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
|
|||||||
placeholder='Black Friday Special'
|
placeholder='Black Friday Special'
|
||||||
title='Display title'
|
title='Display title'
|
||||||
value={overrides.displayTitle.value}
|
value={overrides.displayTitle.value}
|
||||||
onBlur={validate}
|
onBlur={(e) => {
|
||||||
|
if (!e.target.value && e.target.value.length === 0) {
|
||||||
|
errors.displayTitle = 'Display title is required';
|
||||||
|
}
|
||||||
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleDisplayTitleInput(e);
|
handleDisplayTitleInput(e);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={() => clearError('displayTitle')}
|
||||||
/>
|
/>
|
||||||
<TextArea
|
<TextArea
|
||||||
placeholder='Take advantage of this limited-time offer.'
|
placeholder='Take advantage of this limited-time offer.'
|
||||||
@ -229,10 +239,23 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
|
|||||||
? (overrides.fixedAmount === 0 ? '' : overrides.fixedAmount?.toString())
|
? (overrides.fixedAmount === 0 ? '' : overrides.fixedAmount?.toString())
|
||||||
: (overrides.percentAmount === 0 ? '' : overrides.percentAmount?.toString())
|
: (overrides.percentAmount === 0 ? '' : overrides.percentAmount?.toString())
|
||||||
}
|
}
|
||||||
onBlur={validate}
|
onBlur={() => {
|
||||||
|
if (overrides.type === 'percent' && overrides.percentAmount === 0) {
|
||||||
|
errors.amount = 'Enter an amount greater than 0.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overrides.type === 'percent' && overrides.percentAmount && (overrides.percentAmount < 0 || overrides.percentAmount >= 100)) {
|
||||||
|
errors.amount = 'Amount must be between 0 and 100%.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overrides.type === 'fixed' && overrides.fixedAmount && overrides.fixedAmount <= 0) {
|
||||||
|
errors.amount = 'Enter an amount greater than 0.';
|
||||||
|
}
|
||||||
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleAmountInput(e);
|
handleAmountInput(e);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={() => clearError('amount')}
|
||||||
/>
|
/>
|
||||||
<div className='absolute right-1.5 top-6 z-10'>
|
<div className='absolute right-1.5 top-6 z-10'>
|
||||||
<Select
|
<Select
|
||||||
@ -268,10 +291,16 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
|
|||||||
title='Trial duration'
|
title='Trial duration'
|
||||||
type='number'
|
type='number'
|
||||||
value={overrides.trialAmount?.toString()}
|
value={overrides.trialAmount?.toString()}
|
||||||
onBlur={validate}
|
onBlur={(e) => {
|
||||||
|
if (Number(e.target.value) < 1) {
|
||||||
|
errors.amount = 'Free trial must be at least 1 day.';
|
||||||
|
}
|
||||||
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleTrialAmountInput(e);
|
handleTrialAmountInput(e);
|
||||||
}} />
|
}}
|
||||||
|
onKeyDown={() => clearError('amount')}/>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
@ -280,10 +309,15 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
|
|||||||
placeholder='black-friday'
|
placeholder='black-friday'
|
||||||
title='Offer code'
|
title='Offer code'
|
||||||
value={overrides.code.value}
|
value={overrides.code.value}
|
||||||
onBlur={validate}
|
onBlur={(e) => {
|
||||||
|
if (!e.target.value && e.target.value.length === 0) {
|
||||||
|
errors.code = 'Code is required';
|
||||||
|
}
|
||||||
|
}}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleCodeInput(e);
|
handleCodeInput(e);
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={() => clearError('code')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -630,7 +664,7 @@ const AddOfferModal = () => {
|
|||||||
onCancel={cancelAddOffer}
|
onCancel={cancelAddOffer}
|
||||||
onOk={async () => {
|
onOk={async () => {
|
||||||
validate();
|
validate();
|
||||||
const isErrorsEmpty = Object.keys(errors).length === 0;
|
const isErrorsEmpty = Object.values(errors).every(error => !error);
|
||||||
if (!isErrorsEmpty) {
|
if (!isErrorsEmpty) {
|
||||||
showToast({
|
showToast({
|
||||||
type: 'pageError',
|
type: 'pageError',
|
||||||
|
@ -14,9 +14,9 @@ const SignupOptions: React.FC<{
|
|||||||
setError: (key: string, error: string | undefined) => void
|
setError: (key: string, error: string | undefined) => void
|
||||||
}> = ({localSettings, updateSetting, localTiers, updateTier, errors, setError}) => {
|
}> = ({localSettings, updateSetting, localTiers, updateTier, errors, setError}) => {
|
||||||
const {config} = useGlobalData();
|
const {config} = useGlobalData();
|
||||||
|
const hasPortalImprovements = useFeatureFlag('portalImprovements');
|
||||||
const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson] = getSettingValues(
|
const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson, portalDefaultPlan] = getSettingValues(
|
||||||
localSettings, ['members_signup_access', 'portal_name', 'portal_signup_terms_html', 'portal_signup_checkbox_required', 'portal_plans']
|
localSettings, ['members_signup_access', 'portal_name', 'portal_signup_terms_html', 'portal_signup_checkbox_required', 'portal_plans', 'portal_default_plan']
|
||||||
);
|
);
|
||||||
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];
|
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];
|
||||||
|
|
||||||
@ -50,6 +50,20 @@ const SignupOptions: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateSetting('portal_plans', JSON.stringify(portalPlans));
|
updateSetting('portal_plans', JSON.stringify(portalPlans));
|
||||||
|
|
||||||
|
// Check default plan is included
|
||||||
|
if (hasPortalImprovements) {
|
||||||
|
if (portalDefaultPlan === 'yearly') {
|
||||||
|
if (!portalPlans.includes('yearly') && portalPlans.includes('monthly')) {
|
||||||
|
updateSetting('portal_default_plan', 'monthly');
|
||||||
|
}
|
||||||
|
} else if (portalDefaultPlan === 'monthly') {
|
||||||
|
if (!portalPlans.includes('monthly')) {
|
||||||
|
// If both yearly and monthly are missing from plans, still set it to yearly
|
||||||
|
updateSetting('portal_default_plan', 'yearly');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// This is a bit unclear in current admin, maybe we should add a message if the settings are disabled?
|
// This is a bit unclear in current admin, maybe we should add a message if the settings are disabled?
|
||||||
@ -86,13 +100,10 @@ const SignupOptions: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const paidActiveTiersResult = getPaidActiveTiers(localTiers) || [];
|
const paidActiveTiersResult = getPaidActiveTiers(localTiers) || [];
|
||||||
const hasPortalImprovements = useFeatureFlag('portalImprovements');
|
|
||||||
|
|
||||||
// TODO: Hook up with actual values and then delete this
|
|
||||||
const selectOptions: SelectOption[] = [
|
|
||||||
{value: 'default-yearly', label: 'Yearly'},
|
|
||||||
{value: 'default-monthly', label: 'Monthly'}
|
|
||||||
|
|
||||||
|
const defaultPlanOptions: SelectOption[] = [
|
||||||
|
{value: 'yearly', label: 'Yearly'},
|
||||||
|
{value: 'monthly', label: 'Monthly'}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (paidActiveTiersResult.length > 0 && isStripeEnabled) {
|
if (paidActiveTiersResult.length > 0 && isStripeEnabled) {
|
||||||
@ -145,9 +156,17 @@ const SignupOptions: React.FC<{
|
|||||||
]}
|
]}
|
||||||
title='Prices available at signup'
|
title='Prices available at signup'
|
||||||
/>
|
/>
|
||||||
{hasPortalImprovements && <Select disabled={(portalPlans.includes('yearly') && portalPlans.includes('monthly')) ? false : true} options={selectOptions} selectedOption={selectOptions.find(option => option.value === (portalPlans.includes('yearly') ? 'default-yearly' : 'default-monthly'))} title='Price shown by default' onSelect={(value) => {
|
{hasPortalImprovements &&
|
||||||
alert(value);
|
<Select
|
||||||
}} />}
|
disabled={(portalPlans.includes('yearly') && portalPlans.includes('monthly')) ? false : true}
|
||||||
|
options={defaultPlanOptions}
|
||||||
|
selectedOption={defaultPlanOptions.find(option => option.value === portalDefaultPlan)}
|
||||||
|
title='Price shown by default'
|
||||||
|
onSelect={(option) => {
|
||||||
|
updateSetting('portal_default_plan', option?.value ?? 'yearly');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framewor
|
|||||||
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
|
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
|
||||||
import {Tier, useAddTier, useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers';
|
import {Tier, useAddTier, useBrowseTiers, useEditTier} from '@tryghost/admin-x-framework/api/tiers';
|
||||||
import {currencies, currencySelectGroups, validateCurrencyAmount} from '../../../../utils/currency';
|
import {currencies, currencySelectGroups, validateCurrencyAmount} from '../../../../utils/currency';
|
||||||
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
|
import {getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings';
|
||||||
import {toast} from 'react-hot-toast';
|
import {toast} from 'react-hot-toast';
|
||||||
|
|
||||||
export type TierFormState = Partial<Omit<Tier, 'trial_days'>> & {
|
export type TierFormState = Partial<Omit<Tier, 'trial_days'>> & {
|
||||||
@ -22,12 +22,14 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
|||||||
const {updateRoute} = useRouting();
|
const {updateRoute} = useRouting();
|
||||||
const {mutateAsync: updateTier} = useEditTier();
|
const {mutateAsync: updateTier} = useEditTier();
|
||||||
const {mutateAsync: createTier} = useAddTier();
|
const {mutateAsync: createTier} = useAddTier();
|
||||||
|
const {mutateAsync: editSettings} = useEditSettings();
|
||||||
const [hasFreeTrial, setHasFreeTrial] = React.useState(!!tier?.trial_days);
|
const [hasFreeTrial, setHasFreeTrial] = React.useState(!!tier?.trial_days);
|
||||||
const handleError = useHandleError();
|
const handleError = useHandleError();
|
||||||
const {localSettings, siteData} = useSettingGroup();
|
const {localSettings, siteData} = useSettingGroup();
|
||||||
const siteTitle = getSettingValues(localSettings, ['title']) as string[];
|
const [siteTitle, portalPlansJson] = getSettingValues(localSettings, ['title', 'portal_plans']) as string[];
|
||||||
const hasPortalImprovements = useFeatureFlag('portalImprovements');
|
const hasPortalImprovements = useFeatureFlag('portalImprovements');
|
||||||
const allowNameChange = !isFreeTier || hasPortalImprovements;
|
const allowNameChange = !isFreeTier || hasPortalImprovements;
|
||||||
|
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];
|
||||||
|
|
||||||
const validators: {[key in keyof Tier]?: () => string | undefined} = {
|
const validators: {[key in keyof Tier]?: () => string | undefined} = {
|
||||||
name: () => (formState.name ? undefined : 'You must specify a name'),
|
name: () => (formState.name ? undefined : 'You must specify a name'),
|
||||||
@ -70,6 +72,31 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
|||||||
} else {
|
} else {
|
||||||
await createTier(values);
|
await createTier(values);
|
||||||
}
|
}
|
||||||
|
if (isFreeTier && hasPortalImprovements) {
|
||||||
|
// If we changed the visibility, we also need to update Portal settings in some situations
|
||||||
|
// Like the free tier is a special case, and should also be present/absent in portal_plans
|
||||||
|
const visible = formState.visibility === 'public';
|
||||||
|
let save = false;
|
||||||
|
|
||||||
|
if (portalPlans.includes('free') && !visible) {
|
||||||
|
portalPlans.splice(portalPlans.indexOf('free'), 1);
|
||||||
|
save = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!portalPlans.includes('free') && visible) {
|
||||||
|
portalPlans.push('free');
|
||||||
|
save = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (save) {
|
||||||
|
await editSettings([
|
||||||
|
{
|
||||||
|
key: 'portal_plans',
|
||||||
|
value: JSON.stringify(portalPlans)
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSavedStateReset: () => {
|
onSavedStateReset: () => {
|
||||||
modal.remove();
|
modal.remove();
|
||||||
@ -335,8 +362,19 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className='sticky top-[96px] hidden shrink-0 basis-[380px] min-[920px]:!visible min-[920px]:!block'>
|
<div className='sticky top-[96px] hidden shrink-0 basis-[380px] min-[920px]:!visible min-[920px]:!block'>
|
||||||
<TierDetailPreview isFreeTier={isFreeTier} tier={formState} />
|
<TierDetailPreview isFreeTier={isFreeTier} tier={formState} />
|
||||||
|
|
||||||
{!tier && hasPortalImprovements && <Form className=' mt-3' gap='none'><Checkbox checked={false} hint='You can always change this in portal settings' label='Show tier in portal for new signups' value='fakeShow' onChange={() => {}}></Checkbox></Form>}
|
{hasPortalImprovements &&
|
||||||
|
<Form className=' mt-3' gap='none'>
|
||||||
|
<Checkbox
|
||||||
|
checked={formState.visibility === 'public'}
|
||||||
|
hint='You can always change this in portal settings' label='Show tier in portal for new signups'
|
||||||
|
value='fakeShow'
|
||||||
|
onChange={(checked) => {
|
||||||
|
updateForm(state => ({...state, visibility: checked ? 'public' : 'none'}));
|
||||||
|
}}
|
||||||
|
></Checkbox>
|
||||||
|
</Form>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>;
|
</Modal>;
|
||||||
|
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('allowSelfSignup', allowSelfSignup ? 'true' : 'false');
|
||||||
settingsParam.append('signupTermsHtml', getSettingValue(settings, 'portal_signup_terms_html') || '');
|
settingsParam.append('signupTermsHtml', getSettingValue(settings, 'portal_signup_terms_html') || '');
|
||||||
settingsParam.append('signupCheckboxRequired', getSettingValue(settings, 'portal_signup_checkbox_required') ? 'true' : 'false');
|
settingsParam.append('signupCheckboxRequired', getSettingValue(settings, 'portal_signup_checkbox_required') ? 'true' : 'false');
|
||||||
settingsParam.append('portalProducts', encodeURIComponent(portalTiers.join(','))); // assuming that it might be more than 1
|
settingsParam.append('portalProducts', portalTiers.join(',')); // assuming that it might be more than 1
|
||||||
|
|
||||||
|
const portalDefaultPlan = getSettingValue<string>(settings, 'portal_default_plan');
|
||||||
|
if (portalDefaultPlan) {
|
||||||
|
settingsParam.append('portalDefaultPlan', portalDefaultPlan);
|
||||||
|
}
|
||||||
|
|
||||||
if (portalPlans && portalPlans.length) {
|
if (portalPlans && portalPlans.length) {
|
||||||
settingsParam.append('portalPrices', encodeURIComponent(portalPlans.join(',')));
|
settingsParam.append('portalPrices', encodeURIComponent(portalPlans.join(',')));
|
||||||
|
@ -21,23 +21,24 @@ test.describe('Code injection settings', async () => {
|
|||||||
|
|
||||||
const section = page.getByTestId('code-injection');
|
const section = page.getByTestId('code-injection');
|
||||||
|
|
||||||
await section.getByRole('button', {name: 'Edit'}).click();
|
await section.getByRole('button', {name: 'Open'}).click();
|
||||||
|
|
||||||
|
const modal = page.getByTestId('modal-code-injection');
|
||||||
// Click on the CodeMirror content to make sure it's loaded
|
// Click on the CodeMirror content to make sure it's loaded
|
||||||
await section.getByTestId('header-code').locator('.cm-content').click();
|
await modal.getByTestId('header-code').locator('.cm-content').click();
|
||||||
|
|
||||||
for (const character of (PADDING + 'testhead').split('')) {
|
for (const character of (PADDING + 'testhead').split('')) {
|
||||||
await page.keyboard.press(character);
|
await page.keyboard.press(character);
|
||||||
}
|
}
|
||||||
|
|
||||||
await section.getByRole('tab', {name: 'Site footer'}).click();
|
await modal.getByRole('tab', {name: 'Site footer'}).click();
|
||||||
await section.getByTestId('footer-code').locator('.cm-content').click();
|
await modal.getByTestId('footer-code').locator('.cm-content').click();
|
||||||
|
|
||||||
for (const character of (PADDING + 'testfoot').split('')) {
|
for (const character of (PADDING + 'testfoot').split('')) {
|
||||||
await page.keyboard.press(character);
|
await page.keyboard.press(character);
|
||||||
}
|
}
|
||||||
|
|
||||||
await section.getByRole('button', {name: 'Save'}).click();
|
await modal.getByRole('button', {name: 'Save'}).click();
|
||||||
await expect(section.getByRole('button', {name: 'Save'})).toBeHidden();
|
|
||||||
|
|
||||||
expect(lastApiRequests.editSettings?.body).toMatchObject({
|
expect(lastApiRequests.editSettings?.body).toMatchObject({
|
||||||
settings: [
|
settings: [
|
||||||
@ -46,50 +47,4 @@ test.describe('Code injection settings', async () => {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Supports continuing editing in fullscreen', async ({page}) => {
|
|
||||||
const {lastApiRequests} = await mockApi({page, requests: {
|
|
||||||
...globalDataRequests,
|
|
||||||
editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([
|
|
||||||
{key: 'codeinjection_head', value: '<1 /><2 /><3 />'}
|
|
||||||
])}
|
|
||||||
}});
|
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
const section = page.getByTestId('code-injection');
|
|
||||||
|
|
||||||
await section.getByRole('button', {name: 'Edit'}).click();
|
|
||||||
|
|
||||||
for (const character of PADDING.split('')) {
|
|
||||||
await page.keyboard.press(character);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const character of '<1>'.split('')) {
|
|
||||||
await page.keyboard.press(character);
|
|
||||||
}
|
|
||||||
|
|
||||||
await section.getByRole('button', {name: 'Fullscreen'}).click();
|
|
||||||
|
|
||||||
await page.keyboard.press('End');
|
|
||||||
for (const character of '<2>'.split('')) {
|
|
||||||
await page.keyboard.press(character);
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.getByTestId('modal-code').getByRole('button', {name: 'Done'}).click();
|
|
||||||
|
|
||||||
await page.keyboard.press('End');
|
|
||||||
for (const character of '<3>'.split('')) {
|
|
||||||
await page.keyboard.press(character);
|
|
||||||
}
|
|
||||||
|
|
||||||
await section.getByRole('button', {name: 'Save'}).click();
|
|
||||||
await expect(section.getByRole('button', {name: 'Save'})).toBeHidden();
|
|
||||||
|
|
||||||
expect(lastApiRequests.editSettings?.body).toMatchObject({
|
|
||||||
settings: [
|
|
||||||
{key: 'codeinjection_head', value: /<1><2><3>$/}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -247,12 +247,12 @@ test.describe('Newsletter settings', async () => {
|
|||||||
await expect(page.getByTestId('confirmation-modal')).toHaveCount(1);
|
await expect(page.getByTestId('confirmation-modal')).toHaveCount(1);
|
||||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/Confirm reply-to address/);
|
await expect(page.getByTestId('confirmation-modal')).toHaveText(/Confirm reply-to address/);
|
||||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/sent a confirmation email to test@test.com/);
|
await expect(page.getByTestId('confirmation-modal')).toHaveText(/sent a confirmation email to test@test.com/);
|
||||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/previous reply-to address \(support@example.com\)/);
|
await expect(page.getByTestId('confirmation-modal')).toHaveText(/replies will continue to go to support@example.com/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('For Ghost (Pro) users with custom domain', () => {
|
test.describe('For Ghost (Pro) users with custom sending domain', () => {
|
||||||
test('Allow sender and reply-to addresses to be changed without verification, but not their domain name', async ({page}) => {
|
test('Allow sender address to be changed partially (username but not domain name)', async ({page}) => {
|
||||||
await mockApi({page, requests: {
|
await mockApi({page, requests: {
|
||||||
...globalDataRequests,
|
...globalDataRequests,
|
||||||
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters},
|
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters},
|
||||||
@ -283,25 +283,17 @@ test.describe('Newsletter settings', async () => {
|
|||||||
await section.getByText('Awesome newsletter').click();
|
await section.getByText('Awesome newsletter').click();
|
||||||
const modal = page.getByTestId('newsletter-modal');
|
const modal = page.getByTestId('newsletter-modal');
|
||||||
const senderEmail = modal.getByLabel('Sender email');
|
const senderEmail = modal.getByLabel('Sender email');
|
||||||
const replyToEmail = modal.getByLabel('Reply-to email');
|
|
||||||
|
|
||||||
// The sending domain is rendered as placeholder text
|
|
||||||
expect(modal).toHaveText(/@customdomain\.com/);
|
|
||||||
|
|
||||||
// The sender email field should keep the username part of the email address
|
// The sender email field should keep the username part of the email address
|
||||||
await senderEmail.fill('harry@potter.com');
|
await senderEmail.fill('harry@potter.com');
|
||||||
expect(await senderEmail.inputValue()).toBe('harry');
|
expect(await senderEmail.inputValue()).toBe('harry');
|
||||||
|
|
||||||
// Full flexibility for the reply-to address
|
|
||||||
await replyToEmail.fill('hermione@customdomain.com');
|
|
||||||
expect(await replyToEmail.inputValue()).toBe('hermione@customdomain.com');
|
|
||||||
|
|
||||||
// The new username is saved without a confirmation popup
|
// The new username is saved without a confirmation popup
|
||||||
await modal.getByRole('button', {name: 'Save'}).click();
|
await modal.getByRole('button', {name: 'Save'}).click();
|
||||||
await expect(page.getByTestId('confirmation-modal')).toHaveCount(0);
|
await expect(page.getByTestId('confirmation-modal')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Reply-To addresses without a matching domain require verification', async ({page}) => {
|
test('Allow full customisation of the reply-to address, with verification', async ({page}) => {
|
||||||
await mockApi({page, requests: {
|
await mockApi({page, requests: {
|
||||||
...globalDataRequests,
|
...globalDataRequests,
|
||||||
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters},
|
browseNewsletters: {method: 'GET', path: '/newsletters/?include=count.active_members%2Ccount.posts&limit=50', response: responseFixtures.newsletters},
|
||||||
@ -331,26 +323,18 @@ test.describe('Newsletter settings', async () => {
|
|||||||
const section = page.getByTestId('newsletters');
|
const section = page.getByTestId('newsletters');
|
||||||
await section.getByText('Awesome newsletter').click();
|
await section.getByText('Awesome newsletter').click();
|
||||||
const modal = page.getByTestId('newsletter-modal');
|
const modal = page.getByTestId('newsletter-modal');
|
||||||
const senderEmail = modal.getByLabel('Sender email');
|
|
||||||
const replyToEmail = modal.getByLabel('Reply-to email');
|
const replyToEmail = modal.getByLabel('Reply-to email');
|
||||||
|
|
||||||
// The sending domain is rendered as placeholder text
|
|
||||||
expect(modal).toHaveText(/@customdomain\.com/);
|
|
||||||
|
|
||||||
// The sender email field should keep the username part of the email address
|
|
||||||
await senderEmail.fill('harry@potter.com');
|
|
||||||
expect(await senderEmail.inputValue()).toBe('harry');
|
|
||||||
|
|
||||||
// Full flexibility for the reply-to address
|
// Full flexibility for the reply-to address
|
||||||
await replyToEmail.fill('hermione@granger.com');
|
await replyToEmail.fill('hermione@granger.com');
|
||||||
expect(await replyToEmail.inputValue()).toBe('hermione@granger.com');
|
expect(await replyToEmail.inputValue()).toBe('hermione@granger.com');
|
||||||
|
|
||||||
// The new username is saved without a confirmation popup
|
// There is a verification popup for the new reply-to address
|
||||||
await modal.getByRole('button', {name: 'Save'}).click();
|
await modal.getByRole('button', {name: 'Save'}).click();
|
||||||
await expect(page.getByTestId('confirmation-modal')).toHaveCount(1);
|
await expect(page.getByTestId('confirmation-modal')).toHaveCount(1);
|
||||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/Confirm reply-to address/);
|
await expect(page.getByTestId('confirmation-modal')).toHaveText(/Confirm reply-to address/);
|
||||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/sent a confirmation email to hermione@granger.com/);
|
await expect(page.getByTestId('confirmation-modal')).toHaveText(/sent a confirmation email to hermione@granger.com/);
|
||||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/previous reply-to address \(support@example.com\)/);
|
await expect(page.getByTestId('confirmation-modal')).toHaveText(/replies will continue to go to support@example.com/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -14,16 +14,16 @@ test.describe('Offers Modal', () => {
|
|||||||
}});
|
}});
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const section = page.getByTestId('offers');
|
const section = page.getByTestId('offers');
|
||||||
await section.getByRole('button', {name: 'Manage offers'}).click();
|
await section.getByRole('button', {name: 'Add offers'}).click();
|
||||||
const modal = page.getByTestId('offers-modal');
|
const addModal = page.getByTestId('add-offer-modal');
|
||||||
await expect(modal).toBeVisible();
|
await expect(addModal).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Offers Add Modal is available', async ({page}) => {
|
test('Offers Add Modal is available', async ({page}) => {
|
||||||
await mockApi({page, requests: {
|
await mockApi({page, requests: {
|
||||||
|
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
|
||||||
...globalDataRequests,
|
...globalDataRequests,
|
||||||
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
||||||
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
|
|
||||||
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
|
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
|
||||||
addOffer: {method: 'POST', path: '/offers/', response: {
|
addOffer: {method: 'POST', path: '/offers/', response: {
|
||||||
offers: [{
|
offers: [{
|
||||||
@ -45,9 +45,9 @@ test.describe('Offers Modal', () => {
|
|||||||
|
|
||||||
test('Can add a new offer', async ({page}) => {
|
test('Can add a new offer', async ({page}) => {
|
||||||
const {lastApiRequests} = await mockApi({page, requests: {
|
const {lastApiRequests} = await mockApi({page, requests: {
|
||||||
|
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
|
||||||
...globalDataRequests,
|
...globalDataRequests,
|
||||||
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
||||||
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
|
|
||||||
browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers},
|
browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers},
|
||||||
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
|
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
|
||||||
addOffer: {method: 'POST', path: `/offers/`, response: {
|
addOffer: {method: 'POST', path: `/offers/`, response: {
|
||||||
@ -85,9 +85,9 @@ test.describe('Offers Modal', () => {
|
|||||||
|
|
||||||
test('Errors if required fields are missing', async ({page}) => {
|
test('Errors if required fields are missing', async ({page}) => {
|
||||||
await mockApi({page, requests: {
|
await mockApi({page, requests: {
|
||||||
|
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
|
||||||
...globalDataRequests,
|
...globalDataRequests,
|
||||||
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
||||||
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
|
|
||||||
browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers},
|
browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers},
|
||||||
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
|
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
|
||||||
addOffer: {method: 'POST', path: `/offers/`, response: {
|
addOffer: {method: 'POST', path: `/offers/`, response: {
|
||||||
@ -112,9 +112,9 @@ test.describe('Offers Modal', () => {
|
|||||||
|
|
||||||
test('Shows validation hints', async ({page}) => {
|
test('Shows validation hints', async ({page}) => {
|
||||||
await mockApi({page, requests: {
|
await mockApi({page, requests: {
|
||||||
|
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
|
||||||
...globalDataRequests,
|
...globalDataRequests,
|
||||||
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
||||||
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
|
|
||||||
browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers},
|
browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers},
|
||||||
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
|
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
|
||||||
addOffer: {method: 'POST', path: `/offers/`, response: {
|
addOffer: {method: 'POST', path: `/offers/`, response: {
|
||||||
@ -144,9 +144,10 @@ test.describe('Offers Modal', () => {
|
|||||||
|
|
||||||
test('Can view active offers', async ({page}) => {
|
test('Can view active offers', async ({page}) => {
|
||||||
await mockApi({page, requests: {
|
await mockApi({page, requests: {
|
||||||
|
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
|
||||||
...globalDataRequests,
|
...globalDataRequests,
|
||||||
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
||||||
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
|
browseAllOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
|
||||||
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers}
|
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers}
|
||||||
}});
|
}});
|
||||||
|
|
||||||
@ -161,9 +162,10 @@ test.describe('Offers Modal', () => {
|
|||||||
|
|
||||||
test('Can view archived offers', async ({page}) => {
|
test('Can view archived offers', async ({page}) => {
|
||||||
await mockApi({page, requests: {
|
await mockApi({page, requests: {
|
||||||
|
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
|
||||||
...globalDataRequests,
|
...globalDataRequests,
|
||||||
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
||||||
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
|
browseAllOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
|
||||||
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers}
|
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers}
|
||||||
}});
|
}});
|
||||||
|
|
||||||
@ -178,9 +180,10 @@ test.describe('Offers Modal', () => {
|
|||||||
|
|
||||||
test('Supports updating an offer', async ({page}) => {
|
test('Supports updating an offer', async ({page}) => {
|
||||||
const {lastApiRequests} = await mockApi({page, requests: {
|
const {lastApiRequests} = await mockApi({page, requests: {
|
||||||
|
browseOffers: {method: 'GET', path: '/offers/', response: responseFixtures.offers},
|
||||||
...globalDataRequests,
|
...globalDataRequests,
|
||||||
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
browseSettings: {...globalDataRequests.browseSettings, response: settingsWithStripe},
|
||||||
browseOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
|
browseAllOffers: {method: 'GET', path: '/offers/?limit=all', response: responseFixtures.offers},
|
||||||
browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers},
|
browseOffersById: {method: 'GET', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: responseFixtures.offers},
|
||||||
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
|
browseTiers: {method: 'GET', path: '/tiers/', response: responseFixtures.tiers},
|
||||||
editOffer: {method: 'PUT', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: {
|
editOffer: {method: 'PUT', path: `/offers/${responseFixtures.offers.offers![0].id}/`, response: {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@tryghost/portal",
|
"name": "@tryghost/portal",
|
||||||
"version": "2.36.5",
|
"version": "2.37.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -311,7 +311,10 @@ export default class App extends React.Component {
|
|||||||
// Handle the query params key/value pairs
|
// Handle the query params key/value pairs
|
||||||
for (let pair of qsParams.entries()) {
|
for (let pair of qsParams.entries()) {
|
||||||
const key = pair[0];
|
const key = pair[0];
|
||||||
|
|
||||||
|
// Note: this needs to be cleaned up, there is no reason why we need to double encode/decode
|
||||||
const value = decodeURIComponent(pair[1]);
|
const value = decodeURIComponent(pair[1]);
|
||||||
|
|
||||||
if (key === 'button') {
|
if (key === 'button') {
|
||||||
data.site.portal_button = JSON.parse(value);
|
data.site.portal_button = JSON.parse(value);
|
||||||
} else if (key === 'name') {
|
} else if (key === 'name') {
|
||||||
@ -357,6 +360,8 @@ export default class App extends React.Component {
|
|||||||
data.site.allow_self_signup = JSON.parse(value);
|
data.site.allow_self_signup = JSON.parse(value);
|
||||||
} else if (key === 'membersSignupAccess' && value) {
|
} else if (key === 'membersSignupAccess' && value) {
|
||||||
data.site.members_signup_access = value;
|
data.site.members_signup_access = value;
|
||||||
|
} else if (key === 'portalDefaultPlan' && value) {
|
||||||
|
data.site.portal_default_plan = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
data.site.portal_plans = allowedPlans;
|
data.site.portal_plans = allowedPlans;
|
||||||
@ -389,6 +394,7 @@ export default class App extends React.Component {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -894,7 +894,7 @@ function getSelectedPrice({products, selectedProduct, selectedInterval}) {
|
|||||||
return selectedPrice;
|
return selectedPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActiveInterval({portalPlans, selectedInterval = 'year'}) {
|
function getActiveInterval({portalPlans, portalDefaultPlan, selectedInterval}) {
|
||||||
if (selectedInterval === 'month' && portalPlans.includes('monthly')) {
|
if (selectedInterval === 'month' && portalPlans.includes('monthly')) {
|
||||||
return 'month';
|
return 'month';
|
||||||
}
|
}
|
||||||
@ -903,26 +903,32 @@ function getActiveInterval({portalPlans, selectedInterval = 'year'}) {
|
|||||||
return 'year';
|
return 'year';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (portalPlans.includes('monthly')) {
|
if (portalDefaultPlan) {
|
||||||
return 'month';
|
if (portalDefaultPlan === 'monthly' && portalPlans.includes('monthly')) {
|
||||||
|
return 'month';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (portalPlans.includes('yearly')) {
|
if (portalPlans.includes('yearly')) {
|
||||||
return 'year';
|
return 'year';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (portalPlans.includes('monthly')) {
|
||||||
|
return 'month';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProductsSection({onPlanSelect, products, type = null, handleChooseSignup, errors}) {
|
function ProductsSection({onPlanSelect, products, type = null, handleChooseSignup, errors}) {
|
||||||
const {site, member, t} = useContext(AppContext);
|
const {site, member, t} = useContext(AppContext);
|
||||||
const {portal_plans: portalPlans} = site;
|
const {portal_plans: portalPlans, portal_default_plan: portalDefaultPlan} = site;
|
||||||
const defaultInterval = getActiveInterval({portalPlans});
|
|
||||||
|
|
||||||
const defaultProductId = products.length > 0 ? products[0].id : 'free';
|
const defaultProductId = products.length > 0 ? products[0].id : 'free';
|
||||||
const [selectedInterval, setSelectedInterval] = useState(defaultInterval);
|
|
||||||
|
// Note: by default we set it to null, so that it changes reactively in the preview version of Portal
|
||||||
|
const [selectedInterval, setSelectedInterval] = useState(null);
|
||||||
const [selectedProduct, setSelectedProduct] = useState(defaultProductId);
|
const [selectedProduct, setSelectedProduct] = useState(defaultProductId);
|
||||||
|
|
||||||
const selectedPrice = getSelectedPrice({products, selectedInterval, selectedProduct});
|
const selectedPrice = getSelectedPrice({products, selectedInterval, selectedProduct});
|
||||||
const activeInterval = getActiveInterval({portalPlans, selectedInterval});
|
const activeInterval = getActiveInterval({portalPlans, portalDefaultPlan, selectedInterval});
|
||||||
|
|
||||||
const isComplimentary = isComplimentaryMember({member});
|
const isComplimentary = isComplimentaryMember({member});
|
||||||
|
|
||||||
|
@ -451,11 +451,6 @@ export function getFreeProductBenefits({site}) {
|
|||||||
|
|
||||||
export function getFreeTierTitle({site}) {
|
export function getFreeTierTitle({site}) {
|
||||||
const freeProduct = getFreeProduct({site});
|
const freeProduct = getFreeProduct({site});
|
||||||
|
|
||||||
if (freeProduct?.name === 'Free' && hasOnlyFreeProduct({site})) {
|
|
||||||
return 'Free membership';
|
|
||||||
}
|
|
||||||
|
|
||||||
return freeProduct?.name || 'Free';
|
return freeProduct?.name || 'Free';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,13 +40,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.38.1",
|
"@playwright/test": "1.38.1",
|
||||||
"@storybook/addon-essentials": "7.6.3",
|
"@storybook/addon-essentials": "7.6.4",
|
||||||
"@storybook/addon-interactions": "7.6.3",
|
"@storybook/addon-interactions": "7.6.4",
|
||||||
"@storybook/addon-links": "7.6.3",
|
"@storybook/addon-links": "7.6.4",
|
||||||
"@storybook/addon-styling": "1.3.7",
|
"@storybook/addon-styling": "1.3.7",
|
||||||
"@storybook/blocks": "7.6.3",
|
"@storybook/blocks": "7.6.4",
|
||||||
"@storybook/react": "7.6.3",
|
"@storybook/react": "7.6.4",
|
||||||
"@storybook/react-vite": "7.6.3",
|
"@storybook/react-vite": "7.6.4",
|
||||||
"@storybook/testing-library": "0.2.2",
|
"@storybook/testing-library": "0.2.2",
|
||||||
"@tailwindcss/line-clamp": "0.4.4",
|
"@tailwindcss/line-clamp": "0.4.4",
|
||||||
"@types/react": "18.2.42",
|
"@types/react": "18.2.42",
|
||||||
@ -61,7 +61,7 @@
|
|||||||
"postcss-import": "15.1.0",
|
"postcss-import": "15.1.0",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"rollup-plugin-node-builtins": "2.1.2",
|
"rollup-plugin-node-builtins": "2.1.2",
|
||||||
"storybook": "7.6.3",
|
"storybook": "7.6.4",
|
||||||
"stylelint": "15.10.3",
|
"stylelint": "15.10.3",
|
||||||
"tailwindcss": "3.3.6",
|
"tailwindcss": "3.3.6",
|
||||||
"vite": "4.5.1",
|
"vite": "4.5.1",
|
||||||
|
@ -50,7 +50,7 @@
|
|||||||
{{svg-jar "koenig/kg-trash"}}
|
{{svg-jar "koenig/kg-trash"}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between align-center">
|
<div class="relative flex justify-between align-center">
|
||||||
{{#if this.isEditingAlt}}
|
{{#if this.isEditingAlt}}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -70,6 +70,8 @@
|
|||||||
@placeholderText={{if this.captionInputFocused "" "Add a caption to the feature image"}}
|
@placeholderText={{if this.captionInputFocused "" "Add a caption to the feature image"}}
|
||||||
@onFocus={{fn (mut this.captionInputFocused) true}}
|
@onFocus={{fn (mut this.captionInputFocused) true}}
|
||||||
@onBlur={{this.handleCaptionBlur}}
|
@onBlur={{this.handleCaptionBlur}}
|
||||||
|
@onTKCountChange={{this.onTKCountChange}}
|
||||||
|
@registerAPI={{this.registerEditorAPI}}
|
||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<button
|
<button
|
||||||
@ -78,6 +80,12 @@
|
|||||||
>
|
>
|
||||||
Alt
|
Alt
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{{#if (and this.tkCount (not this.isEditingAlt))}}
|
||||||
|
<div class="tk-indicator" data-testid="feature-image-tk-indicator" role="button" {{on "click" this.focusCaptionEditor}}>
|
||||||
|
TK
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{!-- no-image state --}}
|
{{!-- no-image state --}}
|
||||||
|
@ -18,6 +18,7 @@ export default class GhEditorFeatureImageComponent extends Component {
|
|||||||
@tracked captionInputFocused = false;
|
@tracked captionInputFocused = false;
|
||||||
@tracked showUnsplashSelector = false;
|
@tracked showUnsplashSelector = false;
|
||||||
@tracked canDrop = false;
|
@tracked canDrop = false;
|
||||||
|
@tracked tkCount = 0;
|
||||||
|
|
||||||
get caption() {
|
get caption() {
|
||||||
const content = this.args.caption;
|
const content = this.args.caption;
|
||||||
@ -34,6 +35,18 @@ export default class GhEditorFeatureImageComponent extends Component {
|
|||||||
this.args.updateCaption(cleanedHtml);
|
this.args.updateCaption(cleanedHtml);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
registerEditorAPI(API) {
|
||||||
|
this.editorAPI = API;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
focusCaptionEditor() {
|
||||||
|
if (this.editorAPI) {
|
||||||
|
this.editorAPI.focusEditor({position: 'bottom'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleCaptionBlur() {
|
handleCaptionBlur() {
|
||||||
this.captionInputFocused = false;
|
this.captionInputFocused = false;
|
||||||
@ -116,4 +129,12 @@ export default class GhEditorFeatureImageComponent extends Component {
|
|||||||
this.canDrop = false;
|
this.canDrop = false;
|
||||||
setFiles([imageFile]);
|
setFiles([imageFile]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
onTKCountChange(count) {
|
||||||
|
if (this.args.onTKCountChange) {
|
||||||
|
this.tkCount = count;
|
||||||
|
this.args.onTKCountChange(count);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
@handleCaptionBlur={{@handleFeatureImageCaptionBlur}}
|
@handleCaptionBlur={{@handleFeatureImageCaptionBlur}}
|
||||||
@forceButtonDisplay={{or (not @title) (eq @title "(Untitled)") this.titleIsHovered this.titleIsFocused}}
|
@forceButtonDisplay={{or (not @title) (eq @title "(Untitled)") this.titleIsHovered this.titleIsFocused}}
|
||||||
@isHidden={{or (not @cardOptions.post.showTitleAndFeatureImage) false}}
|
@isHidden={{or (not @cardOptions.post.showTitleAndFeatureImage) false}}
|
||||||
|
@onTKCountChange={{@updateFeatureImageTkCount}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="gh-editor-title-container page-improvements">
|
<div class="gh-editor-title-container page-improvements">
|
||||||
|
@ -14,6 +14,18 @@ class ErrorHandler extends React.Component {
|
|||||||
return {hasError: true};
|
return {hasError: true};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error) {
|
||||||
|
if (this.props.config.sentry_dsn) {
|
||||||
|
Sentry.captureException(error, {
|
||||||
|
tags: {
|
||||||
|
lexical: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error, errorInfo); // eslint-disable-line
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
@ -45,6 +57,11 @@ const EmojiPickerPlugin = ({editorResource, ...props}) => {
|
|||||||
return <_EmojiPickerPlugin {...props} />;
|
return <_EmojiPickerPlugin {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TKCountPlugin = ({editorResource, ...props}) => {
|
||||||
|
const {TKCountPlugin: _TKCountPlugin} = editorResource.read();
|
||||||
|
return <_TKCountPlugin {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
export default class KoenigLexicalEditorInput extends Component {
|
export default class KoenigLexicalEditorInput extends Component {
|
||||||
@service ajax;
|
@service ajax;
|
||||||
@service feature;
|
@service feature;
|
||||||
@ -82,12 +99,13 @@ export default class KoenigLexicalEditorInput extends Component {
|
|||||||
ReactComponent = (props) => {
|
ReactComponent = (props) => {
|
||||||
return (
|
return (
|
||||||
<div className={['koenig-react-editor', this.args.className].filter(Boolean).join(' ')}>
|
<div className={['koenig-react-editor', this.args.className].filter(Boolean).join(' ')}>
|
||||||
<ErrorHandler>
|
<ErrorHandler config={this.config}>
|
||||||
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
|
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
|
||||||
<KoenigComposer
|
<KoenigComposer
|
||||||
editorResource={this.editorResource}
|
editorResource={this.editorResource}
|
||||||
initialEditorState={this.args.lexical}
|
initialEditorState={this.args.lexical}
|
||||||
onError={this.onError}
|
onError={this.onError}
|
||||||
|
isTKEnabled={this.args.onTKCountChange ? true : false}
|
||||||
>
|
>
|
||||||
<KoenigComposableEditor
|
<KoenigComposableEditor
|
||||||
editorResource={this.editorResource}
|
editorResource={this.editorResource}
|
||||||
@ -100,9 +118,11 @@ export default class KoenigLexicalEditorInput extends Component {
|
|||||||
className={`koenig-lexical-editor-input ${this.feature.nightShift ? 'dark' : ''}`}
|
className={`koenig-lexical-editor-input ${this.feature.nightShift ? 'dark' : ''}`}
|
||||||
placeholderText={props.placeholderText}
|
placeholderText={props.placeholderText}
|
||||||
placeholderClassName="koenig-lexical-editor-input-placeholder"
|
placeholderClassName="koenig-lexical-editor-input-placeholder"
|
||||||
|
registerAPI={this.args.registerAPI}
|
||||||
>
|
>
|
||||||
<HtmlOutputPlugin editorResource={this.editorResource} html={props.html} setHtml={props.onChangeHtml} />
|
<HtmlOutputPlugin editorResource={this.editorResource} html={props.html} setHtml={props.onChangeHtml} />
|
||||||
{this.emojiPicker ? <EmojiPickerPlugin editorResource={this.editorResource} /> : null}
|
{this.emojiPicker ? <EmojiPickerPlugin editorResource={this.editorResource} /> : null}
|
||||||
|
{this.args.onTKCountChange ? <TKCountPlugin editorResource={this.editorResource} onChange={this.args.onTKCountChange} /> : null}
|
||||||
</KoenigComposableEditor>
|
</KoenigComposableEditor>
|
||||||
</KoenigComposer>
|
</KoenigComposer>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
@ -49,6 +49,18 @@ class ErrorHandler extends React.Component {
|
|||||||
return {hasError: true};
|
return {hasError: true};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error) {
|
||||||
|
if (this.props.config.sentry_dsn) {
|
||||||
|
Sentry.captureException(error, {
|
||||||
|
tags: {
|
||||||
|
lexical: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error, errorInfo); // eslint-disable-line
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
@ -75,9 +87,9 @@ const WordCountPlugin = ({editorResource, ...props}) => {
|
|||||||
return <_WordCountPlugin {...props} />;
|
return <_WordCountPlugin {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TKPlugin = ({editorResource, ...props}) => {
|
const TKCountPlugin = ({editorResource, ...props}) => {
|
||||||
const {TKPlugin: _TKPlugin} = editorResource.read();
|
const {TKCountPlugin: _TKCountPlugin} = editorResource.read();
|
||||||
return <_TKPlugin {...props} />;
|
return <_TKCountPlugin {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class KoenigLexicalEditor extends Component {
|
export default class KoenigLexicalEditor extends Component {
|
||||||
@ -513,7 +525,7 @@ export default class KoenigLexicalEditor extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={['koenig-react-editor', 'koenig-lexical', this.args.className].filter(Boolean).join(' ')}>
|
<div className={['koenig-react-editor', 'koenig-lexical', this.args.className].filter(Boolean).join(' ')}>
|
||||||
<ErrorHandler>
|
<ErrorHandler config={this.config}>
|
||||||
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
|
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
|
||||||
<KoenigComposer
|
<KoenigComposer
|
||||||
editorResource={this.editorResource}
|
editorResource={this.editorResource}
|
||||||
@ -526,6 +538,7 @@ export default class KoenigLexicalEditor extends Component {
|
|||||||
multiplayerEndpoint={multiplayerEndpoint}
|
multiplayerEndpoint={multiplayerEndpoint}
|
||||||
onError={this.onError}
|
onError={this.onError}
|
||||||
darkMode={this.feature.nightShift}
|
darkMode={this.feature.nightShift}
|
||||||
|
isTKEnabled={this.feature.tkReminders}
|
||||||
>
|
>
|
||||||
<KoenigEditor
|
<KoenigEditor
|
||||||
editorResource={this.editorResource}
|
editorResource={this.editorResource}
|
||||||
@ -536,7 +549,7 @@ export default class KoenigLexicalEditor extends Component {
|
|||||||
registerAPI={this.args.registerAPI}
|
registerAPI={this.args.registerAPI}
|
||||||
/>
|
/>
|
||||||
<WordCountPlugin editorResource={this.editorResource} onChange={this.args.updateWordCount} />
|
<WordCountPlugin editorResource={this.editorResource} onChange={this.args.updateWordCount} />
|
||||||
{this.feature.tkReminders && <TKPlugin editorResource={this.editorResource} onCountChange={this.args.updatePostTkCount} />}
|
{this.feature.tkReminders && <TKCountPlugin editorResource={this.editorResource} onChange={this.args.updatePostTkCount} />}
|
||||||
</KoenigComposer>
|
</KoenigComposer>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</ErrorHandler>
|
</ErrorHandler>
|
||||||
|
@ -131,6 +131,7 @@ export default class LexicalEditorController extends Controller {
|
|||||||
// koenig related properties
|
// koenig related properties
|
||||||
wordCount = 0;
|
wordCount = 0;
|
||||||
postTkCount = 0;
|
postTkCount = 0;
|
||||||
|
featureImageTkCount = 0;
|
||||||
|
|
||||||
/* private properties ----------------------------------------------------*/
|
/* private properties ----------------------------------------------------*/
|
||||||
|
|
||||||
@ -262,9 +263,9 @@ export default class LexicalEditorController extends Controller {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@computed('titleHasTk', 'postTkCount')
|
@computed('titleHasTk', 'postTkCount', 'featureImageTkCount')
|
||||||
get tkCount() {
|
get tkCount() {
|
||||||
return (this.titleHasTk ? 1 : 0) + this.postTkCount;
|
return (this.titleHasTk ? 1 : 0) + this.postTkCount + this.featureImageTkCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@ -368,6 +369,11 @@ export default class LexicalEditorController extends Controller {
|
|||||||
this.set('postTkCount', count);
|
this.set('postTkCount', count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateFeatureImageTkCount(count) {
|
||||||
|
this.set('featureImageTkCount', count);
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
setFeatureImage(url) {
|
setFeatureImage(url) {
|
||||||
this.post.set('featureImage', url);
|
this.post.set('featureImage', url);
|
||||||
@ -1135,6 +1141,7 @@ export default class LexicalEditorController extends Controller {
|
|||||||
this.set('showSettingsMenu', false);
|
this.set('showSettingsMenu', false);
|
||||||
this.set('wordCount', 0);
|
this.set('wordCount', 0);
|
||||||
this.set('postTkCount', 0);
|
this.set('postTkCount', 0);
|
||||||
|
this.set('featureImageTkCount', 0);
|
||||||
|
|
||||||
// remove the onbeforeunload handler as it's only relevant whilst on
|
// remove the onbeforeunload handler as it's only relevant whilst on
|
||||||
// the editor route
|
// the editor route
|
||||||
|
@ -635,6 +635,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
|
|||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
transition: border-color .15s linear;
|
transition: border-color .15s linear;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
overflow: hidden; /* Hides any indicators such as TK */
|
||||||
}
|
}
|
||||||
|
|
||||||
.gh-editor-feature-image-alttext::placeholder,
|
.gh-editor-feature-image-alttext::placeholder,
|
||||||
@ -683,7 +684,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
|
|||||||
opacity: .5;
|
opacity: .5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gh-editor-title-container .tk-indicator {
|
.gh-editor .tk-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 15px;
|
top: 15px;
|
||||||
right: -5.6rem;
|
right: -5.6rem;
|
||||||
@ -691,7 +692,12 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
|
|||||||
color: #95A1AD;
|
color: #95A1AD;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: default;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gh-editor-feature-image-container .tk-indicator {
|
||||||
|
top: 0;
|
||||||
|
padding: 0 .4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gh-editor-back-button {
|
.gh-editor-back-button {
|
||||||
|
@ -79,6 +79,7 @@
|
|||||||
@onEditorCreated={{this.setKoenigEditor}}
|
@onEditorCreated={{this.setKoenigEditor}}
|
||||||
@updateWordCount={{this.updateWordCount}}
|
@updateWordCount={{this.updateWordCount}}
|
||||||
@updatePostTkCount={{this.updatePostTkCount}}
|
@updatePostTkCount={{this.updatePostTkCount}}
|
||||||
|
@updateFeatureImageTkCount={{if (feature "tkReminders") this.updateFeatureImageTkCount}}
|
||||||
@featureImage={{this.post.featureImage}}
|
@featureImage={{this.post.featureImage}}
|
||||||
@featureImageAlt={{this.post.featureImageAlt}}
|
@featureImageAlt={{this.post.featureImageAlt}}
|
||||||
@featureImageCaption={{this.post.featureImageCaption}}
|
@featureImageCaption={{this.post.featureImageCaption}}
|
||||||
|
@ -77,6 +77,7 @@ export default [
|
|||||||
setting('portal', 'portal_name', true),
|
setting('portal', 'portal_name', true),
|
||||||
setting('portal', 'portal_button', true),
|
setting('portal', 'portal_button', true),
|
||||||
setting('portal', 'portal_plans', JSON.stringify(['free'])),
|
setting('portal', 'portal_plans', JSON.stringify(['free'])),
|
||||||
|
setting('portal', 'portal_default_plan', 'yearly'),
|
||||||
setting('portal', 'portal_products', JSON.stringify([])),
|
setting('portal', 'portal_products', JSON.stringify([])),
|
||||||
setting('portal', 'portal_button_style', 'icon-and-text'),
|
setting('portal', 'portal_button_style', 'icon-and-text'),
|
||||||
setting('portal', 'portal_button_icon', null),
|
setting('portal', 'portal_button_icon', null),
|
||||||
|
@ -44,9 +44,9 @@
|
|||||||
"@tryghost/color-utils": "0.2.0",
|
"@tryghost/color-utils": "0.2.0",
|
||||||
"@tryghost/ember-promise-modals": "2.0.1",
|
"@tryghost/ember-promise-modals": "2.0.1",
|
||||||
"@tryghost/helpers": "1.1.88",
|
"@tryghost/helpers": "1.1.88",
|
||||||
"@tryghost/kg-clean-basic-html": "3.0.41",
|
"@tryghost/kg-clean-basic-html": "4.0.0",
|
||||||
"@tryghost/kg-converters": "0.0.22",
|
"@tryghost/kg-converters": "0.0.22",
|
||||||
"@tryghost/koenig-lexical": "0.5.27",
|
"@tryghost/koenig-lexical": "1.0.3",
|
||||||
"@tryghost/limit-service": "1.2.12",
|
"@tryghost/limit-service": "1.2.12",
|
||||||
"@tryghost/members-csv": "0.0.0",
|
"@tryghost/members-csv": "0.0.0",
|
||||||
"@tryghost/nql": "0.12.0",
|
"@tryghost/nql": "0.12.0",
|
||||||
@ -201,4 +201,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-sup
|
|||||||
import {beforeEach, describe, it} from 'mocha';
|
import {beforeEach, describe, it} from 'mocha';
|
||||||
import {blur, click, currentRouteName, currentURL, fillIn, find, findAll, triggerEvent, typeIn} from '@ember/test-helpers';
|
import {blur, click, currentRouteName, currentURL, fillIn, find, findAll, triggerEvent, typeIn} from '@ember/test-helpers';
|
||||||
import {datepickerSelect} from 'ember-power-datepicker/test-support';
|
import {datepickerSelect} from 'ember-power-datepicker/test-support';
|
||||||
|
import {enableLabsFlag} from '../helpers/labs-flag';
|
||||||
import {expect} from 'chai';
|
import {expect} from 'chai';
|
||||||
import {selectChoose} from 'ember-power-select/test-support';
|
import {selectChoose} from 'ember-power-select/test-support';
|
||||||
import {setupApplicationTest} from 'ember-mocha';
|
import {setupApplicationTest} from 'ember-mocha';
|
||||||
@ -570,5 +571,38 @@ describe('Acceptance: Editor', function () {
|
|||||||
'breadcrumb link'
|
'breadcrumb link'
|
||||||
).to.equal(`/ghost/posts/analytics/${post.id}`);
|
).to.equal(`/ghost/posts/analytics/${post.id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles TKs in title', async function () {
|
||||||
|
enableLabsFlag(this.server, 'tkReminders');
|
||||||
|
let post = this.server.create('post', {authors: [author]});
|
||||||
|
|
||||||
|
await visit(`/editor/post/${post.id}`);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find('[data-test-editor-title-input]').value,
|
||||||
|
'initial title'
|
||||||
|
).to.equal('Post 0');
|
||||||
|
|
||||||
|
await fillIn('[data-test-editor-title-input]', 'Test TK Title');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find('[data-test-editor-title-input]').value,
|
||||||
|
'title after typing'
|
||||||
|
).to.equal('Test TK Title');
|
||||||
|
|
||||||
|
// check for TK indicator
|
||||||
|
expect(
|
||||||
|
find('[data-testid="tk-indicator"]'),
|
||||||
|
'TK indicator text'
|
||||||
|
).to.exist;
|
||||||
|
|
||||||
|
// click publish to see if confirmation comes up
|
||||||
|
await click('[data-test-button="publish-flow"]');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find('[data-test-modal="tk-reminder"]'),
|
||||||
|
'TK reminder modal'
|
||||||
|
).to.exist;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -162,4 +162,22 @@ describe('Unit: Controller: lexical-editor', function () {
|
|||||||
expect(controller.get('post.slug')).to.not.be.ok;
|
expect(controller.get('post.slug')).to.not.be.ok;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TK count in title', function () {
|
||||||
|
it('should have count 0 for no TK', async function () {
|
||||||
|
let controller = this.owner.lookup('controller:lexical-editor');
|
||||||
|
|
||||||
|
controller.set('post', EmberObject.create({titleScratch: 'this is a title'}));
|
||||||
|
|
||||||
|
expect(controller.get('tkCount')).to.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should count TK reminders in the title', async function () {
|
||||||
|
let controller = this.owner.lookup('controller:lexical-editor');
|
||||||
|
|
||||||
|
controller.set('post', EmberObject.create({titleScratch: 'this is a TK'}));
|
||||||
|
|
||||||
|
expect(controller.get('tkCount')).to.equal(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -30,7 +30,9 @@ const maintenanceMiddleware = (req, res, next) => {
|
|||||||
const rootApp = () => {
|
const rootApp = () => {
|
||||||
const app = express('root');
|
const app = express('root');
|
||||||
app.use(sentry.requestHandler);
|
app.use(sentry.requestHandler);
|
||||||
|
if (config.get('sentry')?.tracing?.enabled === true) {
|
||||||
|
app.use(sentry.tracingHandler);
|
||||||
|
}
|
||||||
app.enable('maintenance');
|
app.enable('maintenance');
|
||||||
app.use(maintenanceMiddleware);
|
app.use(maintenanceMiddleware);
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ module.exports = class DataGeneratorCommand extends Command {
|
|||||||
this.argument('--clear-database', {type: 'boolean', defaultValue: false, desc: 'Clear all entries in the database before importing'});
|
this.argument('--clear-database', {type: 'boolean', defaultValue: false, desc: 'Clear all entries in the database before importing'});
|
||||||
this.argument('--tables', {type: 'string', desc: 'Only import the specified list of tables, where quantities can be specified by appending a colon followed by the quantity for each table. Example: --tables=members:1000,posts,tags,members_login_events'});
|
this.argument('--tables', {type: 'string', desc: 'Only import the specified list of tables, where quantities can be specified by appending a colon followed by the quantity for each table. Example: --tables=members:1000,posts,tags,members_login_events'});
|
||||||
this.argument('--with-default', {type: 'boolean', defaultValue: false, desc: 'Include default tables as well as those specified (simply override quantities)'});
|
this.argument('--with-default', {type: 'boolean', defaultValue: false, desc: 'Include default tables as well as those specified (simply override quantities)'});
|
||||||
|
this.argument('--print-dependencies', {type: 'boolean', defaultValue: false, desc: 'Prints the dependency tree for the data generator and exits'});
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeContext(context) {
|
initializeContext(context) {
|
||||||
@ -51,7 +52,8 @@ module.exports = class DataGeneratorCommand extends Command {
|
|||||||
baseUrl: config.getSiteUrl(),
|
baseUrl: config.getSiteUrl(),
|
||||||
clearDatabase: argv['clear-database'],
|
clearDatabase: argv['clear-database'],
|
||||||
tables,
|
tables,
|
||||||
withDefault: argv['with-default']
|
withDefault: argv['with-default'],
|
||||||
|
printDependencies: argv['print-dependencies']
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await dataGenerator.importData();
|
await dataGenerator.importData();
|
||||||
|
@ -38,6 +38,7 @@ const EDITABLE_SETTINGS = [
|
|||||||
'portal_name',
|
'portal_name',
|
||||||
'portal_button',
|
'portal_button',
|
||||||
'portal_plans',
|
'portal_plans',
|
||||||
|
'portal_default_plan',
|
||||||
'portal_button_style',
|
'portal_button_style',
|
||||||
'firstpromoter',
|
'firstpromoter',
|
||||||
'firstpromoter_id',
|
'firstpromoter_id',
|
||||||
|
@ -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\"]",
|
"defaultValue": "[\"free\"]",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"portal_default_plan": {
|
||||||
|
"defaultValue": "yearly",
|
||||||
|
"validations": {
|
||||||
|
"isEmpty": false,
|
||||||
|
"isIn": [["yearly", "monthly"]]
|
||||||
|
},
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"portal_products": {
|
"portal_products": {
|
||||||
"defaultValue": "[]",
|
"defaultValue": "[]",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
|
@ -182,7 +182,7 @@
|
|||||||
},
|
},
|
||||||
"portal": {
|
"portal": {
|
||||||
"url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js",
|
"url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js",
|
||||||
"version": "2.36"
|
"version": "2.37"
|
||||||
},
|
},
|
||||||
"sodoSearch": {
|
"sodoSearch": {
|
||||||
"url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js",
|
"url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js",
|
||||||
|
@ -59,13 +59,22 @@ if (sentryConfig && !sentryConfig.disabled) {
|
|||||||
const Sentry = require('@sentry/node');
|
const Sentry = require('@sentry/node');
|
||||||
const version = require('@tryghost/version').full;
|
const version = require('@tryghost/version').full;
|
||||||
const environment = config.get('env');
|
const environment = config.get('env');
|
||||||
Sentry.init({
|
const sentryInitConfig = {
|
||||||
dsn: sentryConfig.dsn,
|
dsn: sentryConfig.dsn,
|
||||||
release: 'ghost@' + version,
|
release: 'ghost@' + version,
|
||||||
environment: environment,
|
environment: environment,
|
||||||
maxValueLength: 1000,
|
maxValueLength: 1000,
|
||||||
beforeSend: beforeSend
|
integrations: [],
|
||||||
});
|
beforeSend
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enable tracing if sentry.tracing.enabled is true
|
||||||
|
if (sentryConfig.tracing?.enabled === true) {
|
||||||
|
sentryInitConfig.integrations.push(new Sentry.Integrations.Http({tracing: true}));
|
||||||
|
sentryInitConfig.integrations.push(new Sentry.Integrations.Express());
|
||||||
|
sentryInitConfig.tracesSampleRate = parseFloat(sentryConfig.tracing.sampleRate) || 0.0;
|
||||||
|
}
|
||||||
|
Sentry.init(sentryInitConfig);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
requestHandler: Sentry.Handlers.requestHandler(),
|
requestHandler: Sentry.Handlers.requestHandler(),
|
||||||
@ -82,6 +91,7 @@ if (sentryConfig && !sentryConfig.disabled) {
|
|||||||
return (error.statusCode === 500);
|
return (error.statusCode === 500);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
tracingHandler: Sentry.Handlers.tracingHandler(),
|
||||||
captureException: Sentry.captureException,
|
captureException: Sentry.captureException,
|
||||||
beforeSend: beforeSend
|
beforeSend: beforeSend
|
||||||
};
|
};
|
||||||
@ -95,6 +105,7 @@ if (sentryConfig && !sentryConfig.disabled) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
requestHandler: expressNoop,
|
requestHandler: expressNoop,
|
||||||
errorHandler: expressNoop,
|
errorHandler: expressNoop,
|
||||||
|
tracingHandler: expressNoop,
|
||||||
captureException: noop
|
captureException: noop
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,7 @@ module.exports = {
|
|||||||
portal_signup_terms_html: 'portal_signup_terms_html',
|
portal_signup_terms_html: 'portal_signup_terms_html',
|
||||||
portal_signup_checkbox_required: 'portal_signup_checkbox_required',
|
portal_signup_checkbox_required: 'portal_signup_checkbox_required',
|
||||||
portal_plans: 'portal_plans',
|
portal_plans: 'portal_plans',
|
||||||
|
portal_default_plan: 'portal_default_plan',
|
||||||
portal_name: 'portal_name',
|
portal_name: 'portal_name',
|
||||||
portal_button: 'portal_button',
|
portal_button: 'portal_button',
|
||||||
comments_enabled: 'comments_enabled',
|
comments_enabled: 'comments_enabled',
|
||||||
|
@ -98,14 +98,14 @@
|
|||||||
"@tryghost/importer-handler-content-files": "0.0.0",
|
"@tryghost/importer-handler-content-files": "0.0.0",
|
||||||
"@tryghost/importer-revue": "0.0.0",
|
"@tryghost/importer-revue": "0.0.0",
|
||||||
"@tryghost/job-manager": "0.0.0",
|
"@tryghost/job-manager": "0.0.0",
|
||||||
"@tryghost/kg-card-factory": "4.0.15",
|
"@tryghost/kg-card-factory": "5.0.0",
|
||||||
"@tryghost/kg-converters": "0.0.22",
|
"@tryghost/kg-converters": "0.0.22",
|
||||||
"@tryghost/kg-default-atoms": "4.0.3",
|
"@tryghost/kg-default-atoms": "5.0.0",
|
||||||
"@tryghost/kg-default-cards": "9.1.9",
|
"@tryghost/kg-default-cards": "10.0.0",
|
||||||
"@tryghost/kg-default-nodes": "0.2.12",
|
"@tryghost/kg-default-nodes": "1.0.0",
|
||||||
"@tryghost/kg-html-to-lexical": "0.1.14",
|
"@tryghost/kg-html-to-lexical": "1.0.0",
|
||||||
"@tryghost/kg-lexical-html-renderer": "0.3.51",
|
"@tryghost/kg-lexical-html-renderer": "1.0.0",
|
||||||
"@tryghost/kg-mobiledoc-html-renderer": "6.0.15",
|
"@tryghost/kg-mobiledoc-html-renderer": "7.0.0",
|
||||||
"@tryghost/limit-service": "1.2.12",
|
"@tryghost/limit-service": "1.2.12",
|
||||||
"@tryghost/link-redirects": "0.0.0",
|
"@tryghost/link-redirects": "0.0.0",
|
||||||
"@tryghost/link-replacer": "0.0.0",
|
"@tryghost/link-replacer": "0.0.0",
|
||||||
@ -209,7 +209,7 @@
|
|||||||
"multer": "1.4.4",
|
"multer": "1.4.4",
|
||||||
"mysql2": "3.6.5",
|
"mysql2": "3.6.5",
|
||||||
"nconf": "0.12.1",
|
"nconf": "0.12.1",
|
||||||
"newrelic": "11.6.0",
|
"newrelic": "11.6.1",
|
||||||
"node-jose": "2.2.0",
|
"node-jose": "2.2.0",
|
||||||
"path-match": "1.2.4",
|
"path-match": "1.2.4",
|
||||||
"probe-image-size": "7.2.3",
|
"probe-image-size": "7.2.3",
|
||||||
|
@ -180,6 +180,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
@ -602,6 +606,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
@ -967,6 +975,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
@ -1143,7 +1155,7 @@ exports[`Settings API Edit Can edit a setting 2: [headers] 1`] = `
|
|||||||
Object {
|
Object {
|
||||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||||
"content-length": "4487",
|
"content-length": "4534",
|
||||||
"content-type": "application/json; charset=utf-8",
|
"content-type": "application/json; charset=utf-8",
|
||||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||||
@ -1333,6 +1345,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
@ -1698,6 +1714,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
@ -2526,6 +2546,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
@ -2892,6 +2916,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
@ -3258,6 +3286,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
@ -3628,6 +3660,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
@ -3993,6 +4029,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
@ -4363,6 +4403,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
@ -5097,6 +5141,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
@ -5463,6 +5511,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
@ -5893,6 +5945,10 @@ Object {
|
|||||||
"key": "portal_plans",
|
"key": "portal_plans",
|
||||||
"value": "[\\"free\\"]",
|
"value": "[\\"free\\"]",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"key": "portal_default_plan",
|
||||||
|
"value": "yearly",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"key": "portal_products",
|
"key": "portal_products",
|
||||||
"value": "[]",
|
"value": "[]",
|
||||||
|
@ -9,15 +9,17 @@ const models = require('../../../core/server/models');
|
|||||||
const {mockLabsDisabled, mockLabsEnabled} = require('../../utils/e2e-framework-mock-manager');
|
const {mockLabsDisabled, mockLabsEnabled} = require('../../utils/e2e-framework-mock-manager');
|
||||||
const {anyErrorId} = matchers;
|
const {anyErrorId} = matchers;
|
||||||
|
|
||||||
const CURRENT_SETTINGS_COUNT = 86;
|
const CURRENT_SETTINGS_COUNT = 87;
|
||||||
|
|
||||||
const settingsMatcher = {};
|
const settingsMatcher = {};
|
||||||
|
|
||||||
const publicHashSettingMatcher = {
|
const publicHashSettingMatcher = {
|
||||||
|
key: 'public_hash',
|
||||||
value: stringMatching(/[a-z0-9]{30}/)
|
value: stringMatching(/[a-z0-9]{30}/)
|
||||||
};
|
};
|
||||||
|
|
||||||
const labsSettingMatcher = {
|
const labsSettingMatcher = {
|
||||||
|
key: 'labs',
|
||||||
value: stringMatching(/\{[^\s]+\}/)
|
value: stringMatching(/\{[^\s]+\}/)
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -30,10 +32,10 @@ const matchSettingsArray = (length) => {
|
|||||||
settingsArray[26] = publicHashSettingMatcher;
|
settingsArray[26] = publicHashSettingMatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (length > 60) {
|
if (length > 61) {
|
||||||
// Added a setting that is alphabetically before 'labs'? then you need to increment this counter.
|
// Added a setting that is alphabetically before 'labs'? then you need to increment this counter.
|
||||||
// Item at index x is the lab settings, which changes as we add and remove features
|
// Item at index x is the lab settings, which changes as we add and remove features
|
||||||
settingsArray[60] = labsSettingMatcher;
|
settingsArray[61] = labsSettingMatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
return settingsArray;
|
return settingsArray;
|
||||||
|
@ -54,6 +54,7 @@ Object {
|
|||||||
"portal_button_icon": null,
|
"portal_button_icon": null,
|
||||||
"portal_button_signup_text": "Subscribe",
|
"portal_button_signup_text": "Subscribe",
|
||||||
"portal_button_style": "icon-and-text",
|
"portal_button_style": "icon-and-text",
|
||||||
|
"portal_default_plan": "yearly",
|
||||||
"portal_name": true,
|
"portal_name": true,
|
||||||
"portal_plans": Array [
|
"portal_plans": Array [
|
||||||
"free",
|
"free",
|
||||||
|
@ -1406,6 +1406,7 @@ Object {
|
|||||||
"portal_button_icon": null,
|
"portal_button_icon": null,
|
||||||
"portal_button_signup_text": "Subscribe",
|
"portal_button_signup_text": "Subscribe",
|
||||||
"portal_button_style": "icon-and-text",
|
"portal_button_style": "icon-and-text",
|
||||||
|
"portal_default_plan": "yearly",
|
||||||
"portal_name": true,
|
"portal_name": true,
|
||||||
"portal_plans": Array [
|
"portal_plans": Array [
|
||||||
"free",
|
"free",
|
||||||
@ -1507,6 +1508,7 @@ Object {
|
|||||||
"portal_button_icon": null,
|
"portal_button_icon": null,
|
||||||
"portal_button_signup_text": "Subscribe",
|
"portal_button_signup_text": "Subscribe",
|
||||||
"portal_button_style": "icon-and-text",
|
"portal_button_style": "icon-and-text",
|
||||||
|
"portal_default_plan": "yearly",
|
||||||
"portal_name": true,
|
"portal_name": true,
|
||||||
"portal_plans": Array [
|
"portal_plans": Array [
|
||||||
"free",
|
"free",
|
||||||
|
@ -5,7 +5,7 @@ const db = require('../../../core/server/data/db');
|
|||||||
// Stuff we are testing
|
// Stuff we are testing
|
||||||
const models = require('../../../core/server/models');
|
const models = require('../../../core/server/models');
|
||||||
|
|
||||||
const SETTINGS_LENGTH = 93;
|
const SETTINGS_LENGTH = 94;
|
||||||
|
|
||||||
describe('Settings Model', function () {
|
describe('Settings Model', function () {
|
||||||
before(models.init);
|
before(models.init);
|
||||||
|
@ -236,7 +236,7 @@ describe('Exporter', function () {
|
|||||||
|
|
||||||
// NOTE: if default settings changed either modify the settings keys blocklist or increase allowedKeysLength
|
// NOTE: if default settings changed either modify the settings keys blocklist or increase allowedKeysLength
|
||||||
// This is a reminder to think about the importer/exporter scenarios ;)
|
// This is a reminder to think about the importer/exporter scenarios ;)
|
||||||
const allowedKeysLength = 85;
|
const allowedKeysLength = 86;
|
||||||
totalKeysLength.should.eql(SETTING_KEYS_BLOCKLIST.length + allowedKeysLength);
|
totalKeysLength.should.eql(SETTING_KEYS_BLOCKLIST.length + allowedKeysLength);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -37,7 +37,7 @@ describe('DB version integrity', function () {
|
|||||||
// Only these variables should need updating
|
// Only these variables should need updating
|
||||||
const currentSchemaHash = '34a9fa4dc1223ef6c45f8ed991d25de5';
|
const currentSchemaHash = '34a9fa4dc1223ef6c45f8ed991d25de5';
|
||||||
const currentFixturesHash = '4db87173699ad9c9d8a67ccab96dfd2d';
|
const currentFixturesHash = '4db87173699ad9c9d8a67ccab96dfd2d';
|
||||||
const currentSettingsHash = '3128d4ec667a50049486b0c21f04be07';
|
const currentSettingsHash = '5c957ceb48c4878767d7d3db484c592d';
|
||||||
const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01';
|
const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01';
|
||||||
|
|
||||||
// If this test is failing, then it is likely a change has been made that requires a DB version bump,
|
// If this test is failing, then it is likely a change has been made that requires a DB version bump,
|
||||||
|
@ -326,6 +326,14 @@
|
|||||||
"defaultValue": "[\"free\"]",
|
"defaultValue": "[\"free\"]",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"portal_default_plan": {
|
||||||
|
"defaultValue": "yearly",
|
||||||
|
"validations": {
|
||||||
|
"isEmpty": false,
|
||||||
|
"isIn": [["yearly", "monthly"]]
|
||||||
|
},
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"portal_products": {
|
"portal_products": {
|
||||||
"defaultValue": "[]",
|
"defaultValue": "[]",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
|
@ -338,6 +338,14 @@
|
|||||||
"defaultValue": "[\"free\"]",
|
"defaultValue": "[\"free\"]",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
},
|
},
|
||||||
|
"portal_default_plan": {
|
||||||
|
"defaultValue": "yearly",
|
||||||
|
"validations": {
|
||||||
|
"isEmpty": false,
|
||||||
|
"isIn": [["yearly", "monthly"]]
|
||||||
|
},
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"portal_products": {
|
"portal_products": {
|
||||||
"defaultValue": "[]",
|
"defaultValue": "[]",
|
||||||
"type": "array"
|
"type": "array"
|
||||||
|
@ -18,6 +18,7 @@ class DataGenerator {
|
|||||||
baseDataPack = '',
|
baseDataPack = '',
|
||||||
baseUrl,
|
baseUrl,
|
||||||
logger,
|
logger,
|
||||||
|
printDependencies,
|
||||||
withDefault
|
withDefault
|
||||||
}) {
|
}) {
|
||||||
this.knex = knex;
|
this.knex = knex;
|
||||||
@ -28,6 +29,7 @@ class DataGenerator {
|
|||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.withDefault = withDefault;
|
this.withDefault = withDefault;
|
||||||
|
this.printDependencies = printDependencies;
|
||||||
}
|
}
|
||||||
|
|
||||||
sortTableList() {
|
sortTableList() {
|
||||||
@ -91,7 +93,7 @@ class DataGenerator {
|
|||||||
}
|
}
|
||||||
let baseData = {};
|
let baseData = {};
|
||||||
try {
|
try {
|
||||||
baseData = JSON.parse(await (await fs.readFile(baseDataPack)).toString());
|
baseData = JSON.parse((await fs.readFile(baseDataPack)).toString());
|
||||||
this.logger.info('Read base data pack');
|
this.logger.info('Read base data pack');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Failed to read data pack: ', error);
|
this.logger.error('Failed to read data pack: ', error);
|
||||||
@ -158,6 +160,14 @@ class DataGenerator {
|
|||||||
|
|
||||||
this.sortTableList();
|
this.sortTableList();
|
||||||
|
|
||||||
|
if (this.printDependencies) {
|
||||||
|
this.logger.info('Table dependencies:');
|
||||||
|
for (const table of this.tableList) {
|
||||||
|
this.logger.info(`\t${table.name}: ${table.dependencies.join(', ')}`);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.willClearData) {
|
if (this.willClearData) {
|
||||||
await this.clearData(transaction);
|
await this.clearData(transaction);
|
||||||
}
|
}
|
||||||
|
@ -71,7 +71,7 @@ class MembersImporter extends TableImporter {
|
|||||||
id,
|
id,
|
||||||
uuid: faker.datatype.uuid(),
|
uuid: faker.datatype.uuid(),
|
||||||
transient_id: faker.datatype.uuid(),
|
transient_id: faker.datatype.uuid(),
|
||||||
email: `${name.replace(' ', '.').replace(/[^a-zA-Z0-9]/g, '').toLowerCase()}${faker.datatype.number({min: 1000, max: 9999})}@example.com`,
|
email: `${name.replace(' ', '.').replace(/[^a-zA-Z0-9]/g, '').toLowerCase()}${faker.datatype.number({min: 0, max: 999999})}@example.com`,
|
||||||
status: luck(5) ? 'comped' : luck(25) ? 'paid' : 'free',
|
status: luck(5) ? 'comped' : luck(25) ? 'paid' : 'free',
|
||||||
name: name,
|
name: name,
|
||||||
expertise: luck(30) ? faker.name.jobTitle() : undefined,
|
expertise: luck(30) ? faker.name.jobTitle() : undefined,
|
||||||
|
@ -5,7 +5,7 @@ const dateToDatabaseString = require('../utils/database-date');
|
|||||||
|
|
||||||
class MembersSubscribeEventsImporter extends TableImporter {
|
class MembersSubscribeEventsImporter extends TableImporter {
|
||||||
static table = 'members_subscribe_events';
|
static table = 'members_subscribe_events';
|
||||||
static dependencies = ['members', 'newsletters', 'subscriptions'];
|
static dependencies = ['members', 'newsletters'/*, 'subscriptions'*/];
|
||||||
|
|
||||||
constructor(knex, transaction) {
|
constructor(knex, transaction) {
|
||||||
super(MembersSubscribeEventsImporter.table, knex, transaction);
|
super(MembersSubscribeEventsImporter.table, knex, transaction);
|
||||||
@ -14,9 +14,9 @@ class MembersSubscribeEventsImporter extends TableImporter {
|
|||||||
async import(quantity) {
|
async import(quantity) {
|
||||||
const members = await this.transaction.select('id', 'created_at', 'status').from('members');
|
const members = await this.transaction.select('id', 'created_at', 'status').from('members');
|
||||||
this.newsletters = await this.transaction.select('id').from('newsletters').orderBy('sort_order');
|
this.newsletters = await this.transaction.select('id').from('newsletters').orderBy('sort_order');
|
||||||
this.subscriptions = await this.transaction.select('member_id', 'created_at').from('subscriptions');
|
//this.subscriptions = await this.transaction.select('member_id', 'created_at').from('subscriptions');
|
||||||
|
|
||||||
await this.importForEach(members, quantity ? quantity / members.length : 2);
|
await this.importForEach(members, quantity ? quantity / members.length : this.newsletters.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
setReferencedModel(model) {
|
setReferencedModel(model) {
|
||||||
@ -32,22 +32,15 @@ class MembersSubscribeEventsImporter extends TableImporter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let createdAt = dateToDatabaseString(faker.date.between(new Date(this.model.created_at), new Date()));
|
|
||||||
let subscribed = luck(80);
|
let subscribed = luck(80);
|
||||||
|
|
||||||
// Free newsletter by default
|
|
||||||
let newsletterId = this.newsletters[1].id;
|
|
||||||
if (this.model.status === 'paid' && count === 0) {
|
|
||||||
// Paid newsletter
|
|
||||||
newsletterId = this.newsletters[0].id;
|
|
||||||
createdAt = this.subscriptions.find(s => s.member_id === this.model.id).created_at;
|
|
||||||
subscribed = luck(98);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subscribed) {
|
if (!subscribed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createdAt = dateToDatabaseString(faker.date.between(new Date(this.model.created_at), new Date()));
|
||||||
|
const newsletterId = this.newsletters[count % this.newsletters.length].id;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: faker.database.mongodbObjectId(),
|
id: faker.database.mongodbObjectId(),
|
||||||
member_id: this.model.id,
|
member_id: this.model.id,
|
||||||
|
@ -6,21 +6,23 @@ const {slugify} = require('@tryghost/string');
|
|||||||
class NewslettersImporter extends TableImporter {
|
class NewslettersImporter extends TableImporter {
|
||||||
static table = 'newsletters';
|
static table = 'newsletters';
|
||||||
static dependencies = [];
|
static dependencies = [];
|
||||||
|
|
||||||
defaultQuantity = 2;
|
defaultQuantity = 2;
|
||||||
|
|
||||||
|
sortOrder = 0;
|
||||||
|
|
||||||
constructor(knex, transaction) {
|
constructor(knex, transaction) {
|
||||||
super(NewslettersImporter.table, knex, transaction);
|
super(NewslettersImporter.table, knex, transaction);
|
||||||
this.sortOrder = 0;
|
|
||||||
// TODO: Use random names if we ever need more than 2 newsletters
|
|
||||||
this.names = ['Regular premium', 'Occasional freebie'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
generate() {
|
generate() {
|
||||||
const name = this.names.shift();
|
const name = `${faker.commerce.productAdjective()} ${faker.word.noun()}`;
|
||||||
const sortOrder = this.sortOrder;
|
const sortOrder = this.sortOrder;
|
||||||
this.sortOrder = this.sortOrder + 1;
|
this.sortOrder = this.sortOrder + 1;
|
||||||
|
|
||||||
const weekAfter = new Date(blogStartDate);
|
const weekAfter = new Date(blogStartDate);
|
||||||
weekAfter.setDate(weekAfter.getDate() + 7);
|
weekAfter.setDate(weekAfter.getDate() + 7);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: faker.database.mongodbObjectId(),
|
id: faker.database.mongodbObjectId(),
|
||||||
uuid: faker.datatype.uuid(),
|
uuid: faker.datatype.uuid(),
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
const debug = require('@tryghost/debug')('TableImporter');
|
||||||
|
|
||||||
class TableImporter {
|
class TableImporter {
|
||||||
/**
|
/**
|
||||||
* @type {object|undefined} model Referenced model when generating data
|
* @type {object|undefined} model Referenced model when generating data
|
||||||
@ -21,27 +23,29 @@ class TableImporter {
|
|||||||
this.transaction = transaction;
|
this.transaction = transaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
async import(amount = this.defaultQuantity) {
|
async #generateData(amount = this.defaultQuantity) {
|
||||||
const batchSize = 500;
|
let data = [];
|
||||||
let batch = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < amount; i++) {
|
for (let i = 0; i < amount; i++) {
|
||||||
const model = await this.generate();
|
const model = await this.generate();
|
||||||
if (model) {
|
if (model) {
|
||||||
batch.push(model);
|
data.push(model);
|
||||||
} else {
|
|
||||||
// After first null assume that there is no more data
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (batch.length === batchSize) {
|
|
||||||
await this.knex.batchInsert(this.name, batch, batchSize).transacting(this.transaction);
|
|
||||||
batch = [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process final batch
|
return data;
|
||||||
if (batch.length > 0) {
|
}
|
||||||
await this.knex.batchInsert(this.name, batch, batchSize).transacting(this.transaction);
|
|
||||||
|
async import(amount = this.defaultQuantity) {
|
||||||
|
const generateNow = Date.now();
|
||||||
|
const data = await this.#generateData(amount);
|
||||||
|
debug(`${this.name} generated ${data.length} records in ${Date.now() - generateNow}ms`);
|
||||||
|
|
||||||
|
if (data.length > 0) {
|
||||||
|
debug (`Importing ${data.length} records into ${this.name}`);
|
||||||
|
const now = Date.now();
|
||||||
|
await this.knex.batchInsert(this.name, data).transacting(this.transaction);
|
||||||
|
debug(`${this.name} imported ${data.length} records in ${Date.now() - now}ms`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,8 +54,10 @@ class TableImporter {
|
|||||||
* @param {Number|function} amount Number of records to import per model
|
* @param {Number|function} amount Number of records to import per model
|
||||||
*/
|
*/
|
||||||
async importForEach(models = [], amount) {
|
async importForEach(models = [], amount) {
|
||||||
const batchSize = 500;
|
const data = [];
|
||||||
let batch = [];
|
|
||||||
|
debug (`Generating data for ${models.length} models for ${this.name}`);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
this.setReferencedModel(model);
|
this.setReferencedModel(model);
|
||||||
@ -59,24 +65,19 @@ class TableImporter {
|
|||||||
if (!Number.isInteger(currentAmount)) {
|
if (!Number.isInteger(currentAmount)) {
|
||||||
currentAmount = Math.floor(currentAmount) + ((Math.random() < currentAmount % 1) ? 1 : 0);
|
currentAmount = Math.floor(currentAmount) + ((Math.random() < currentAmount % 1) ? 1 : 0);
|
||||||
}
|
}
|
||||||
for (let i = 0; i < currentAmount; i++) {
|
|
||||||
const data = await this.generate();
|
const generatedData = await this.#generateData(currentAmount);
|
||||||
if (data) {
|
if (generatedData.length > 0) {
|
||||||
batch.push(data);
|
data.push(...generatedData);
|
||||||
} else {
|
|
||||||
// After first null assume that there is no more data for this model
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (batch.length === batchSize) {
|
|
||||||
await this.knex.batchInsert(this.name, batch, batchSize).transacting(this.transaction);
|
|
||||||
batch = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process final batch
|
debug(`${this.name} generated ${data.length} records in ${Date.now() - now}ms`);
|
||||||
if (batch.length > 0) {
|
|
||||||
await this.knex.batchInsert(this.name, batch, batchSize).transacting(this.transaction);
|
if (data.length > 0) {
|
||||||
|
const now2 = Date.now();
|
||||||
|
await this.knex.batchInsert(this.name, data).transacting(this.transaction);
|
||||||
|
debug(`${this.name} imported ${data.length} records in ${Date.now() - now2}ms`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@
|
|||||||
"lib"
|
"lib"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tryghost/debug": "0.1.27",
|
||||||
"c8": "8.0.1",
|
"c8": "8.0.1",
|
||||||
"knex": "2.4.2",
|
"knex": "2.4.2",
|
||||||
"mocha": "10.2.0",
|
"mocha": "10.2.0",
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
"mocha": "10.2.0",
|
"mocha": "10.2.0",
|
||||||
"sinon": "15.2.0",
|
"sinon": "15.2.0",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"typescript": "5.3.2"
|
"typescript": "5.3.3"
|
||||||
},
|
},
|
||||||
"dependencies": {}
|
"dependencies": {}
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
"mocha": "10.2.0",
|
"mocha": "10.2.0",
|
||||||
"sinon": "15.2.0",
|
"sinon": "15.2.0",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"typescript": "5.3.2"
|
"typescript": "5.3.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nodemailer": "^6.6.3"
|
"nodemailer": "^6.6.3"
|
||||||
|
@ -29,7 +29,7 @@
|
|||||||
"@tryghost/email-events": "0.0.0",
|
"@tryghost/email-events": "0.0.0",
|
||||||
"@tryghost/errors": "1.2.26",
|
"@tryghost/errors": "1.2.26",
|
||||||
"@tryghost/html-to-plaintext": "0.0.0",
|
"@tryghost/html-to-plaintext": "0.0.0",
|
||||||
"@tryghost/kg-default-cards": "9.1.9",
|
"@tryghost/kg-default-cards": "10.0.0",
|
||||||
"@tryghost/logging": "2.4.8",
|
"@tryghost/logging": "2.4.8",
|
||||||
"@tryghost/tpl": "0.1.26",
|
"@tryghost/tpl": "0.1.26",
|
||||||
"@tryghost/validator": "0.2.6",
|
"@tryghost/validator": "0.2.6",
|
||||||
|
@ -30,6 +30,6 @@
|
|||||||
"mocha": "10.2.0"
|
"mocha": "10.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "23.7.7"
|
"i18next": "23.7.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tryghost/debug": "0.1.26",
|
"@tryghost/debug": "0.1.26",
|
||||||
"@tryghost/kg-default-cards": "9.1.9",
|
"@tryghost/kg-default-cards": "10.0.0",
|
||||||
"@tryghost/string": "0.2.10",
|
"@tryghost/string": "0.2.10",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"papaparse": "5.3.2",
|
"papaparse": "5.3.2",
|
||||||
|
@ -28,7 +28,7 @@
|
|||||||
"@tryghost/errors": "1.2.26",
|
"@tryghost/errors": "1.2.26",
|
||||||
"@tryghost/tpl": "0.1.26",
|
"@tryghost/tpl": "0.1.26",
|
||||||
"csso": "5.0.5",
|
"csso": "5.0.5",
|
||||||
"terser": "5.25.0",
|
"terser": "5.26.0",
|
||||||
"tiny-glob": "0.2.9"
|
"tiny-glob": "0.2.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,15 +30,15 @@
|
|||||||
"cheerio": "0.22.0",
|
"cheerio": "0.22.0",
|
||||||
"iconv-lite": "0.6.3",
|
"iconv-lite": "0.6.3",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"metascraper": "5.39.0",
|
"metascraper": "5.41.0",
|
||||||
"metascraper-author": "5.39.0",
|
"metascraper-author": "5.40.0",
|
||||||
"metascraper-description": "5.39.0",
|
"metascraper-description": "5.40.0",
|
||||||
"metascraper-image": "5.39.0",
|
"metascraper-image": "5.40.0",
|
||||||
"metascraper-logo": "5.39.0",
|
"metascraper-logo": "5.40.0",
|
||||||
"metascraper-logo-favicon": "5.39.0",
|
"metascraper-logo-favicon": "5.40.0",
|
||||||
"metascraper-publisher": "5.39.0",
|
"metascraper-publisher": "5.40.0",
|
||||||
"metascraper-title": "5.39.0",
|
"metascraper-title": "5.40.0",
|
||||||
"metascraper-url": "5.39.0",
|
"metascraper-url": "5.40.0",
|
||||||
"tough-cookie": "4.1.3"
|
"tough-cookie": "4.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
"mocha": "10.2.0",
|
"mocha": "10.2.0",
|
||||||
"sinon": "15.2.0",
|
"sinon": "15.2.0",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"typescript": "5.3.2"
|
"typescript": "5.3.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tryghost/tpl": "0.1.26",
|
"@tryghost/tpl": "0.1.26",
|
||||||
|
@ -29,7 +29,8 @@
|
|||||||
"dev": "node .github/scripts/dev.js",
|
"dev": "node .github/scripts/dev.js",
|
||||||
"fix": "yarn cache clean && rm -rf node_modules && yarn",
|
"fix": "yarn cache clean && rm -rf node_modules && yarn",
|
||||||
"knex-migrator": "yarn workspace ghost run knex-migrator",
|
"knex-migrator": "yarn workspace ghost run knex-migrator",
|
||||||
"setup": "yarn && git submodule update --init",
|
"setup": "yarn && git submodule update --init && NODE_ENV=development node .github/scripts/setup.js",
|
||||||
|
"docker:reset": "docker-compose -f .github/scripts/docker-compose.yml down -v && docker-compose -f .github/scripts/docker-compose.yml up -d --wait",
|
||||||
"lint": "nx run-many -t lint",
|
"lint": "nx run-many -t lint",
|
||||||
"test": "nx run-many -t test",
|
"test": "nx run-many -t test",
|
||||||
"test:unit": "nx run-many -t test:unit",
|
"test:unit": "nx run-many -t test:unit",
|
||||||
@ -115,6 +116,6 @@
|
|||||||
"nx": "16.8.1",
|
"nx": "16.8.1",
|
||||||
"rimraf": "5.0.5",
|
"rimraf": "5.0.5",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"typescript": "5.3.2"
|
"typescript": "5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user