Merge branch 'main' into main
This commit is contained in:
commit
d1affa6c12
2
LICENSE
2
LICENSE
@ -1,4 +1,4 @@
|
||||
Copyright (c) 2013-2023 Ghost Foundation
|
||||
Copyright (c) 2013-2024 Ghost Foundation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
|
11
README.md
11
README.md
@ -17,7 +17,7 @@
|
||||
<a href="https://twitter.com/ghost">Twitter</a>
|
||||
<br /><br />
|
||||
<a href="https://ghost.org/">
|
||||
<img src="https://img.shields.io/badge/downloads-3M-brightgreen.svg" alt="Downloads" />
|
||||
<img src="https://img.shields.io/badge/downloads-100M+-brightgreen.svg" alt="Downloads" />
|
||||
</a>
|
||||
<a href="https://github.com/TryGhost/Ghost/releases/">
|
||||
<img src="https://img.shields.io/github/release/TryGhost/Ghost.svg" alt="Latest release" />
|
||||
@ -82,7 +82,7 @@ For anyone wishing to contribute to Ghost or to hack/customize core files we rec
|
||||
|
||||
# Ghost sponsors
|
||||
|
||||
We'd like to extend big thanks to our sponsors and partners who make Ghost possible. If you're interested in sponsoring Ghost and supporting the project, please check out our profile on [GitHub sponsors](https://github.com/sponsors/TryGhost) :heart:
|
||||
A big thanks to our sponsors and partners who make Ghost possible. If you're interested in sponsoring Ghost and supporting the project, please check out our profile on [GitHub sponsors](https://github.com/sponsors/TryGhost) :heart:
|
||||
|
||||
**[DigitalOcean](https://m.do.co/c/9ff29836d717)** • **[Fastly](https://www.fastly.com/)**
|
||||
|
||||
@ -90,12 +90,13 @@ We'd like to extend big thanks to our sponsors and partners who make Ghost possi
|
||||
|
||||
# Getting help
|
||||
|
||||
You can find answers to a huge variety of questions, along with a large community of helpful developers over on the [Ghost forum](https://forum.ghost.org/) - replies are generally very quick. **Ghost(Pro)** customers also have access to 24/7 email support.
|
||||
Everyone can get help and support from a large community of developers over on the [Ghost forum](https://forum.ghost.org/). **Ghost(Pro)** customers have access to 24/7 email support.
|
||||
|
||||
To stay up to date with all the latest news and product updates, make sure you [subscribe to our blog](https://ghost.org/blog/) — or you can always follow us [on Twitter](https://twitter.com/Ghost), if you prefer your updates bite-sized and facetious. :saxophone::turtle:
|
||||
To stay up to date with all the latest news and product updates, make sure you [subscribe to our changelog newsletter](https://ghost.org/changelog/) — or follow us [on Twitter](https://twitter.com/Ghost), if you prefer your updates bite-sized and facetious. :saxophone::turtle:
|
||||
|
||||
|
||||
|
||||
# Copyright & license
|
||||
|
||||
Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
|
||||
Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE).
|
||||
Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
|
||||
|
@ -36,10 +36,13 @@
|
||||
"@testing-library/react": "14.1.0",
|
||||
"@tryghost/admin-x-design-system": "0.0.0",
|
||||
"@tryghost/admin-x-framework": "0.0.0",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"jest": "29.7.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
"react-dom": "18.3.1",
|
||||
"ts-jest": "29.1.5"
|
||||
},
|
||||
"nx": {
|
||||
"targets": {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
|
||||
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
|
||||
import {useFollow} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {useQueryClient} from '@tryghost/admin-x-framework';
|
||||
import {useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
@ -16,6 +17,9 @@ const FollowSite = NiceModal.create(() => {
|
||||
const modal = NiceModal.useModal();
|
||||
const mutation = useFollow();
|
||||
const client = useQueryClient();
|
||||
const site = useBrowseSite();
|
||||
const siteData = site.data?.site;
|
||||
const siteUrl = siteData?.url ?? window.location.origin;
|
||||
|
||||
// mutation.isPending
|
||||
// mutation.isError
|
||||
@ -30,8 +34,11 @@ const FollowSite = NiceModal.create(() => {
|
||||
|
||||
const handleFollow = async () => {
|
||||
try {
|
||||
const url = new URL(`.ghost/activitypub/actions/follow/${profileName}`, siteUrl);
|
||||
await fetch(url, {
|
||||
method: 'POST'
|
||||
});
|
||||
// Perform the mutation
|
||||
await mutation.mutateAsync({username: profileName});
|
||||
// If successful, set the success state to true
|
||||
// setSuccess(true);
|
||||
showToast({
|
||||
|
@ -1,10 +1,9 @@
|
||||
// import NiceModal from '@ebay/nice-modal-react';
|
||||
// import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
|
||||
import React, {useState} from 'react';
|
||||
import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import articleBodyStyles from './articleBodyStyles';
|
||||
import getUsername from '../utils/get-username';
|
||||
import {ActorProperties, ObjectProperties, useBrowseFollowersForUser, useBrowseFollowingForUser, useBrowseInboxForUser} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {Avatar, Button, Heading, List, ListItem, Page, SettingValue, ViewContainer, ViewTab} from '@tryghost/admin-x-design-system';
|
||||
import {Avatar, Button, ButtonGroup, Heading, List, ListItem, Page, SelectOption, SettingValue, ViewContainer, ViewTab} from '@tryghost/admin-x-design-system';
|
||||
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
|
||||
import {useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
|
||||
@ -33,6 +32,8 @@ const ActivityPubComponent: React.FC = () => {
|
||||
setArticleContent(null);
|
||||
};
|
||||
|
||||
const [selectedOption, setSelectedOption] = useState<SelectOption>({label: 'Inbox', value: 'inbox'});
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState('inbox');
|
||||
|
||||
const tabs: ViewTab[] = [
|
||||
@ -40,15 +41,15 @@ const ActivityPubComponent: React.FC = () => {
|
||||
id: 'inbox',
|
||||
title: 'Inbox',
|
||||
contents: <div className='grid grid-cols-6 items-start gap-8'>
|
||||
<ul className='order-2 col-span-6 flex flex-col lg:order-1 lg:col-span-4'>
|
||||
<ul className={`order-2 col-span-6 flex flex-col pb-8 lg:order-1 ${selectedOption.value === 'inbox' ? 'lg:col-span-4' : 'lg:col-span-3'}`}>
|
||||
{activities && activities.some(activity => activity.type === 'Create' && activity.object.type === 'Article') ? (activities.slice().reverse().map(activity => (
|
||||
activity.type === 'Create' && activity.object.type === 'Article' &&
|
||||
<li key={activity.id} data-test-view-article onClick={() => handleViewContent(activity.object, activity.actor)}>
|
||||
<ObjectContentDisplay actor={activity.actor} object={activity.object}/>
|
||||
<ObjectContentDisplay actor={activity.actor} layout={selectedOption.value} object={activity.object}/>
|
||||
</li>
|
||||
))) : <div className='flex items-center justify-center text-center'>
|
||||
<div className='flex max-w-[32em] flex-col items-center justify-center gap-4'>
|
||||
{/* <img alt='Ghost site logos' className='w-[220px]' src={ActivityPubWelcomeImage}/> */}
|
||||
<img alt='Ghost site logos' className='w-[220px]' src={ActivityPubWelcomeImage}/>
|
||||
<Heading className='text-balance' level={2}>Welcome to ActivityPub</Heading>
|
||||
<p className='text-pretty text-grey-800'>We’re so glad to have you on board! At the moment, you can follow other Ghost sites and enjoy their content right here inside Ghost.</p>
|
||||
<p className='text-pretty text-grey-800'>You can see all of the users on the right—find your favorite ones and give them a follow.</p>
|
||||
@ -78,7 +79,7 @@ const ActivityPubComponent: React.FC = () => {
|
||||
{activities && activities.slice().reverse().map(activity => (
|
||||
activity.type === 'Create' && activity.object.type === 'Article' &&
|
||||
<li key={activity.id} data-test-view-article onClick={() => handleViewContent(activity.object, activity.actor)}>
|
||||
<ObjectContentDisplay actor={activity.actor} object={activity.object}/>
|
||||
<ObjectContentDisplay actor={activity.actor} layout={selectedOption.value} object={activity.object} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@ -91,6 +92,25 @@ const ActivityPubComponent: React.FC = () => {
|
||||
<Page>
|
||||
{!articleContent ? (
|
||||
<ViewContainer
|
||||
actions={[<ButtonGroup buttons={[
|
||||
{
|
||||
icon: 'listview',
|
||||
size: 'sm',
|
||||
iconColorClass: selectedOption.value === 'inbox' ? 'text-black' : 'text-grey-500',
|
||||
onClick: () => {
|
||||
setSelectedOption({label: 'Inbox', value: 'inbox'});
|
||||
}
|
||||
|
||||
},
|
||||
{
|
||||
icon: 'cardview',
|
||||
size: 'sm',
|
||||
iconColorClass: selectedOption.value === 'feed' ? 'text-black' : 'text-grey-500',
|
||||
onClick: () => {
|
||||
setSelectedOption({label: 'Feed', value: 'feed'});
|
||||
}
|
||||
}
|
||||
]} clearBg={false} link outlineOnMobile />]}
|
||||
firstOnPage={true}
|
||||
primaryAction={{
|
||||
title: 'Follow',
|
||||
@ -102,9 +122,10 @@ const ActivityPubComponent: React.FC = () => {
|
||||
selectedTab={selectedTab}
|
||||
stickyHeader={true}
|
||||
tabs={tabs}
|
||||
toolbarBorder={false}
|
||||
type='page'
|
||||
onTabChange={setSelectedTab}
|
||||
title='ActivityPub'
|
||||
toolbarBorder={true}
|
||||
type='page'
|
||||
onTabChange={setSelectedTab}
|
||||
>
|
||||
</ViewContainer>
|
||||
|
||||
@ -117,7 +138,7 @@ const ActivityPubComponent: React.FC = () => {
|
||||
};
|
||||
|
||||
const Sidebar: React.FC<{followingCount: number, followersCount: number, updateRoute: (route: string) => void}> = ({followingCount, followersCount, updateRoute}) => (
|
||||
<div className='order-1 col-span-6 flex flex-col gap-5 lg:order-2 lg:col-span-2'>
|
||||
<div className='order-1 col-span-6 flex flex-col gap-5 lg:order-2 lg:col-span-2 lg:col-start-5'>
|
||||
<div className='rounded-xl bg-grey-50 p-6' id="ap-sidebar">
|
||||
<div className='mb-4 border-b border-b-grey-200 pb-4'><SettingValue key={'your-username'} heading={'Your username'} value={'@index@localplaceholder.com'}/></div>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
@ -146,17 +167,17 @@ const Sidebar: React.FC<{followingCount: number, followersCount: number, updateR
|
||||
);
|
||||
|
||||
const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => {
|
||||
// const dangerouslySetInnerHTML = {__html: html};
|
||||
// const cssFile = '../index.css';
|
||||
const site = useBrowseSite();
|
||||
const siteData = site.data?.site;
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, ''));
|
||||
|
||||
const htmlContent = `
|
||||
<html>
|
||||
<head>
|
||||
${cssContent}
|
||||
${cssContent}
|
||||
</head>
|
||||
<body>
|
||||
<header class="gh-article-header gh-canvas">
|
||||
@ -174,20 +195,29 @@ ${image &&
|
||||
</html>
|
||||
`;
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe) {
|
||||
iframe.srcdoc = htmlContent;
|
||||
}
|
||||
}, [htmlContent]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className='h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]'
|
||||
height="100%"
|
||||
id="gh-ap-article-iframe"
|
||||
srcDoc={htmlContent}
|
||||
title="Embedded Content"
|
||||
width="100%"
|
||||
>
|
||||
</iframe>
|
||||
<div>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className={`h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]`}
|
||||
height="100%"
|
||||
id="gh-ap-article-iframe"
|
||||
title="Embedded Content"
|
||||
width="100%"
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties }> = ({actor, object}) => {
|
||||
const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties, layout: string }> = ({actor, object, layout}) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(object.content || '', 'text/html');
|
||||
|
||||
@ -205,37 +235,75 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp
|
||||
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{object && (
|
||||
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-5' data-test-activity>
|
||||
<div className='relative z-10 mb-3 grid w-full grid-cols-[20px_auto_1fr_auto] items-center gap-2 text-base'>
|
||||
<img className='w-5' src={actor.icon}/>
|
||||
<span className='truncate font-semibold'>{actor.name}</span>
|
||||
<span className='truncate text-grey-800'>{getUsername(actor)}</span>
|
||||
<span className='ml-auto text-right text-grey-800'>{timestamp}</span>
|
||||
</div>
|
||||
<div className='relative z-10 grid w-full grid-cols-[auto_170px] gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex w-full justify-between gap-4'>
|
||||
<Heading className='mb-2 line-clamp-2 leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>
|
||||
</div>
|
||||
<p className='mb-6 line-clamp-2 max-w-prose text-md text-grey-800'>{plainTextContent}</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id="like" size='md' unstyled={true} onClick={handleLikeClick}/>
|
||||
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
|
||||
if (layout === 'feed') {
|
||||
return (
|
||||
<>
|
||||
{object && (
|
||||
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-6' data-test-activity>
|
||||
|
||||
<div className='relative z-10 mb-3 flex w-full items-center gap-3'>
|
||||
<img className='w-8' src={actor.icon.url}/>
|
||||
<div>
|
||||
<p className='text-base font-bold' data-test-activity-heading>{actor.name}</p>
|
||||
<div className='*:text-base *:text-grey-900'>
|
||||
{/* <span className='truncate before:mx-1 before:content-["·"]'>{getUsername(actor)}</span> */}
|
||||
<span>{timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{object.image && <div className='relative min-w-[33%] grow'>
|
||||
<img className='absolute h-full w-full rounded object-cover' src={object.image}/>
|
||||
</div>}
|
||||
<div className='relative z-10 w-full gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
|
||||
{object.image && <div className='relative mb-4'>
|
||||
<img className='h-[300px] w-full rounded object-cover' src={object.image}/>
|
||||
</div>}
|
||||
<Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>
|
||||
<p className='mb-4 line-clamp-3 max-w-prose text-pretty text-md text-grey-900'>{plainTextContent}</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id="like" size='md' unstyled={true} onClick={handleLikeClick}/>
|
||||
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='absolute -inset-x-3 -inset-y-1 z-0 rounded transition-colors group-hover/article:bg-grey-100'></div>
|
||||
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
|
||||
</div>
|
||||
<div className='absolute -inset-x-3 inset-y-0 z-0 rounded transition-colors group-hover/article:bg-grey-50'></div>
|
||||
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if (layout === 'inbox') {
|
||||
return (
|
||||
<>
|
||||
{object && (
|
||||
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-5' data-test-activity>
|
||||
<div className='relative z-10 mb-3 grid w-full grid-cols-[20px_auto_1fr_auto] items-center gap-2 text-base'>
|
||||
<img className='w-5' src={actor.icon?.url}/>
|
||||
<span className='truncate font-semibold'>{actor.name}</span>
|
||||
{/* <span className='truncate text-grey-800'>{getUsername(actor)}</span> */}
|
||||
<span className='ml-auto text-right text-grey-800'>{timestamp}</span>
|
||||
</div>
|
||||
<div className='relative z-10 grid w-full grid-cols-[auto_170px] gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex w-full justify-between gap-4'>
|
||||
<Heading className='mb-1 line-clamp-2 leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>
|
||||
</div>
|
||||
<p className='mb-6 line-clamp-2 max-w-prose text-pretty text-md text-grey-800'>{object.preview?.content}</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id="like" size='md' unstyled={true} onClick={handleLikeClick}/>
|
||||
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
|
||||
</div>
|
||||
</div>
|
||||
{object.image && <div className='relative min-w-[33%] grow'>
|
||||
<img className='absolute h-full w-full rounded object-cover' height='140px' src={object.image} width='170px'/>
|
||||
</div>}
|
||||
</div>
|
||||
<div className='absolute -inset-x-3 -inset-y-1 z-0 rounded transition-colors group-hover/article:bg-grey-50'></div>
|
||||
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ViewArticle: React.FC<ViewArticleProps> = ({object, onBackToList}) => {
|
||||
|
@ -4,6 +4,7 @@
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client", "jest"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
@ -60,7 +60,7 @@
|
||||
"@sentry/react": "7.118.0",
|
||||
"@tailwindcss/forms": "0.5.7",
|
||||
"@tailwindcss/line-clamp": "0.4.4",
|
||||
"@uiw/react-codemirror": "4.22.1",
|
||||
"@uiw/react-codemirror": "4.23.0",
|
||||
"autoprefixer": "10.4.19",
|
||||
"clsx": "2.1.1",
|
||||
"postcss": "8.4.39",
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {arrayMove} from '@dnd-kit/sortable';
|
||||
import {useEffect, useState} from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
export type SortableIndexedList<Item> = {
|
||||
items: Array<{ item: Item; id: string }>;
|
||||
@ -32,7 +33,7 @@ const useSortableIndexedList = <Item extends unknown>({items, setItems, blank, c
|
||||
allItems.push(newItem);
|
||||
}
|
||||
|
||||
if (JSON.stringify(allItems) !== JSON.stringify(items)) {
|
||||
if (!_.isEqual(JSON.parse(JSON.stringify(allItems)), JSON.parse(JSON.stringify(items)))) {
|
||||
setItems(allItems);
|
||||
}
|
||||
}, [editableItems, newItem, items, setItems, canAddNewItem]);
|
||||
|
@ -61,7 +61,7 @@ const MainContent: React.FC = () => {
|
||||
if (isEditorUser(currentUser)) {
|
||||
return (
|
||||
<Page>
|
||||
<div className='mx-auto w-full max-w-5xl px-[5vmin] tablet:mt-16 xl:mt-10' id="admin-x-settings-scroller">
|
||||
<div className='mx-auto w-full max-w-5xl overflow-y-auto px-[5vmin] tablet:mt-16 xl:mt-10' id="admin-x-settings-scroller">
|
||||
<Heading className='mb-[5vmin]'>Settings</Heading>
|
||||
<Users highlight={false} keywords={[]} />
|
||||
</div>
|
||||
|
@ -15,10 +15,6 @@ const features = [{
|
||||
title: 'Webmentions',
|
||||
description: 'Allows viewing received mentions on the dashboard.',
|
||||
flag: 'webmentions'
|
||||
},{
|
||||
title: 'Websockets',
|
||||
description: <>Test out Websockets functionality at <code>/ghost/#/websockets</code>.</>,
|
||||
flag: 'websockets'
|
||||
},{
|
||||
title: 'Stripe Automatic Tax (private beta)',
|
||||
description: 'Use Stripe Automatic Tax at Stripe Checkout. Needs to be enabled in Stripe',
|
||||
@ -60,10 +56,6 @@ const features = [{
|
||||
description: '(Highly) Experimental support for ActivityPub.',
|
||||
flag: 'ActivityPub'
|
||||
},{
|
||||
title: 'Excerpt in newsletter',
|
||||
description: 'Showing excerpt in newsletter',
|
||||
flag: 'newsletterExcerpt'
|
||||
}, {
|
||||
title: 'Content Visibility',
|
||||
description: 'Enables content visibility in Emails',
|
||||
flag: 'contentVisibility'
|
||||
|
@ -39,7 +39,7 @@ const AddNewsletterModal: React.FC<RoutingModalProps> = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formState.name) {
|
||||
newErrors.name = 'Name is required';
|
||||
newErrors.name = 'A name is required for your newsletter';
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
|
@ -103,7 +103,6 @@ const Sidebar: React.FC<{
|
||||
const {mutateAsync: uploadImage} = useUploadImage();
|
||||
const [selectedTab, setSelectedTab] = useState('generalSettings');
|
||||
const hasEmailCustomization = useFeatureFlag('emailCustomization');
|
||||
const hasNewsletterExcerpt = useFeatureFlag('newsletterExcerpt');
|
||||
const {localSettings} = useSettingGroup();
|
||||
const [siteTitle] = getSettingValues(localSettings, ['title']) as string[];
|
||||
const handleError = useHandleError();
|
||||
@ -418,7 +417,7 @@ const Sidebar: React.FC<{
|
||||
onChange={color => updateNewsletter({title_color: color})}
|
||||
/>}
|
||||
<ToggleGroup gap='lg'>
|
||||
{(hasNewsletterExcerpt && newsletter.show_post_title_section) &&
|
||||
{newsletter.show_post_title_section &&
|
||||
<Toggle
|
||||
checked={newsletter.show_excerpt}
|
||||
direction="rtl"
|
||||
@ -547,17 +546,17 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formState.name) {
|
||||
newErrors.name = 'Name is required';
|
||||
newErrors.name = 'A name is required for your newsletter';
|
||||
}
|
||||
|
||||
if (formState.sender_email && !validator.isEmail(formState.sender_email)) {
|
||||
newErrors.sender_email = 'Invalid email';
|
||||
newErrors.sender_email = 'Enter a valid email address';
|
||||
} else if (formState.sender_email && hasSendingDomain(config) && formState.sender_email.split('@')[1] !== sendingDomain(config)) {
|
||||
newErrors.sender_email = `Email must end with @${sendingDomain(config)}`;
|
||||
newErrors.sender_email = `Email address must end with @${sendingDomain(config)}`;
|
||||
}
|
||||
|
||||
if (formState.sender_reply_to && !validator.isEmail(formState.sender_reply_to) && !['newsletter', 'support'].includes(formState.sender_reply_to)) {
|
||||
newErrors.sender_reply_to = 'Invalid email';
|
||||
newErrors.sender_reply_to = 'Enter a valid email address';
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
|
@ -77,7 +77,6 @@ const NewsletterPreviewContent: React.FC<{
|
||||
const showHeader = headerIcon || headerTitle;
|
||||
const {config} = useGlobalData();
|
||||
const hasNewEmailAddresses = useFeatureFlag('newEmailAddresses');
|
||||
const hasNewsletterExcerpt = useFeatureFlag('newsletterExcerpt');
|
||||
|
||||
const currentDate = new Date().toLocaleDateString('default', {
|
||||
year: 'numeric',
|
||||
@ -154,7 +153,7 @@ const NewsletterPreviewContent: React.FC<{
|
||||
)} style={{color: titleColor}}>
|
||||
Your email newsletter
|
||||
</h2>
|
||||
{(hasNewsletterExcerpt && showExcerpt) && (
|
||||
{showExcerpt && (
|
||||
<p className={excerptClasses}>A subtitle to highlight key points and engage your readers</p>
|
||||
)}
|
||||
<div className={clsx(
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, {FocusEventHandler, useEffect, useState} from 'react';
|
||||
import validator from 'validator';
|
||||
import {Form, TextField} from '@tryghost/admin-x-design-system';
|
||||
import {SettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
|
||||
import {fullEmailAddress, getEmailDomain} from '@tryghost/admin-x-framework/api/site';
|
||||
@ -19,8 +20,9 @@ const AccountPage: React.FC<{
|
||||
let supportAddress = e.target.value;
|
||||
|
||||
if (!supportAddress) {
|
||||
setError('members_support_address', 'Please enter an email address');
|
||||
return;
|
||||
setError('members_support_address', 'Enter an email address');
|
||||
} else if (!validator.isEmail(supportAddress)) {
|
||||
setError('members_support_address', 'Enter a valid email address');
|
||||
} else {
|
||||
setError('members_support_address', '');
|
||||
}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import React, {useCallback, useEffect, useMemo} from 'react';
|
||||
import useFeatureFlag from '../../../../hooks/useFeatureFlag';
|
||||
import {CheckboxGroup, CheckboxProps, Form, HtmlField, Select, SelectOption, Toggle} from '@tryghost/admin-x-design-system';
|
||||
import {Setting, SettingValue, checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
|
||||
import {Tier, getPaidActiveTiers} from '@tryghost/admin-x-framework/api/tiers';
|
||||
@ -14,7 +13,6 @@ const SignupOptions: React.FC<{
|
||||
setError: (key: string, error: string | undefined) => void
|
||||
}> = ({localSettings, updateSetting, localTiers, updateTier, errors, setError}) => {
|
||||
const {config} = useGlobalData();
|
||||
const hasPortalImprovements = useFeatureFlag('portalImprovements');
|
||||
const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson, portalDefaultPlan] = getSettingValues(
|
||||
localSettings, ['members_signup_access', 'portal_name', 'portal_signup_terms_html', 'portal_signup_checkbox_required', 'portal_plans', 'portal_default_plan']
|
||||
);
|
||||
@ -52,16 +50,14 @@ const SignupOptions: React.FC<{
|
||||
updateSetting('portal_plans', JSON.stringify(portalPlans));
|
||||
|
||||
// Check default plan is included
|
||||
if (hasPortalImprovements) {
|
||||
if (portalDefaultPlan === 'yearly') {
|
||||
if (!portalPlans.includes('yearly') && portalPlans.includes('monthly')) {
|
||||
updateSetting('portal_default_plan', 'monthly');
|
||||
}
|
||||
} else if (portalDefaultPlan === 'monthly') {
|
||||
if (!portalPlans.includes('monthly')) {
|
||||
// If both yearly and monthly are missing from plans, still set it to yearly
|
||||
updateSetting('portal_default_plan', 'yearly');
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -79,7 +75,7 @@ const SignupOptions: React.FC<{
|
||||
tiersCheckboxes.push({
|
||||
checked: (portalPlans.includes('free')),
|
||||
disabled: isDisabled,
|
||||
label: hasPortalImprovements ? tier.name : 'Free',
|
||||
label: tier.name,
|
||||
value: 'free',
|
||||
onChange: (checked) => {
|
||||
if (portalPlans.includes('free') && !checked) {
|
||||
@ -158,7 +154,7 @@ const SignupOptions: React.FC<{
|
||||
]}
|
||||
title='Prices available at signup'
|
||||
/>
|
||||
{(hasPortalImprovements && portalPlans.includes('yearly') && portalPlans.includes('monthly')) &&
|
||||
{(portalPlans.includes('yearly') && portalPlans.includes('monthly')) &&
|
||||
<Select
|
||||
options={defaultPlanOptions}
|
||||
selectedOption={defaultPlanOptions.find(option => option.value === portalDefaultPlan)}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import TierDetailPreview from './TierDetailPreview';
|
||||
import useFeatureFlag from '../../../../hooks/useFeatureFlag';
|
||||
import useSettingGroup from '../../../../hooks/useSettingGroup';
|
||||
import {Button, ButtonProps, ConfirmationModal, CurrencyField, Form, Heading, Icon, Modal, Select, SortableList, TextField, Toggle, URLTextField, showToast, useSortableIndexedList} from '@tryghost/admin-x-design-system';
|
||||
import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
|
||||
@ -25,8 +24,6 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
const handleError = useHandleError();
|
||||
const {localSettings, siteData} = useSettingGroup();
|
||||
const [portalPlansJson] = getSettingValues(localSettings, ['portal_plans']) as string[];
|
||||
const hasPortalImprovements = useFeatureFlag('portalImprovements');
|
||||
const allowNameChange = !isFreeTier || hasPortalImprovements;
|
||||
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];
|
||||
|
||||
const validators: {[key in keyof Tier]?: () => string | undefined} = {
|
||||
@ -70,7 +67,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
} else {
|
||||
await createTier(values);
|
||||
}
|
||||
if (isFreeTier && hasPortalImprovements) {
|
||||
if (isFreeTier) {
|
||||
// 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';
|
||||
@ -196,7 +193,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
<div className='-mb-8 mt-8 flex items-start gap-8'>
|
||||
<div className='flex grow flex-col gap-8'>
|
||||
<Form marginBottom={false} title='Basic' grouped>
|
||||
{allowNameChange && <TextField
|
||||
<TextField
|
||||
autoComplete='off'
|
||||
error={Boolean(errors.name)}
|
||||
hint={errors.name}
|
||||
@ -207,7 +204,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
autoFocus
|
||||
onChange={e => updateForm(state => ({...state, name: e.target.value}))}
|
||||
onKeyDown={() => clearError('name')}
|
||||
/>}
|
||||
/>
|
||||
<TextField
|
||||
autoComplete='off'
|
||||
autoFocus={isFreeTier}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import InvalidThemeModal, {FatalErrors} from './InvalidThemeModal';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import React from 'react';
|
||||
import {Button, ButtonProps, ConfirmationModal, List, ListItem, Menu, ModalPage, showToast} from '@tryghost/admin-x-design-system';
|
||||
import {JSONError} from '@tryghost/admin-x-framework/errors';
|
||||
import {Theme, isActiveTheme, isDefaultTheme, isDeletableTheme, isLegacyTheme, useActivateTheme, useDeleteTheme} from '@tryghost/admin-x-framework/api/themes';
|
||||
import {downloadFile, getGhostPaths} from '@tryghost/admin-x-framework/helpers';
|
||||
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
|
||||
@ -57,7 +59,26 @@ const ThemeActions: React.FC<ThemeActionProps> = ({
|
||||
message: <div><span className='capitalize'>{theme.name}</span> is now your active theme</div>
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
let fatalErrors: FatalErrors | null = null;
|
||||
if (e instanceof JSONError && e.response?.status === 422 && e.data?.errors) {
|
||||
fatalErrors = (e.data.errors as any) as FatalErrors;
|
||||
} else {
|
||||
handleError(e);
|
||||
}
|
||||
let title = 'Invalid Theme';
|
||||
let prompt = <>This theme is invalid and cannot be activated. Fix the following errors and re-upload the theme</>;
|
||||
|
||||
if (fatalErrors) {
|
||||
NiceModal.show(InvalidThemeModal, {
|
||||
title,
|
||||
prompt,
|
||||
fatalErrors,
|
||||
onRetry: async (modal) => {
|
||||
modal?.remove();
|
||||
handleActivate();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -31,7 +31,7 @@ const useNavigationEditor = ({items, setItems}: {
|
||||
const list = useSortableIndexedList<Omit<EditableItem, 'id'>>({
|
||||
items: items.map(item => ({...item, errors: {}})),
|
||||
setItems: newItems => setItems(newItems.map(({url, label}) => ({url, label}))),
|
||||
blank: {label: '', url: '/', errors: {}},
|
||||
blank: {url: '/',label: '', errors: {}},
|
||||
canAddNewItem: hasNewItem
|
||||
});
|
||||
|
||||
|
@ -27,7 +27,7 @@ test.describe('Newsletter settings', async () => {
|
||||
const modal = page.getByTestId('add-newsletter-modal');
|
||||
await modal.getByRole('button', {name: 'Create'}).click();
|
||||
|
||||
await expect(modal).toHaveText(/Name is required/);
|
||||
await expect(modal).toHaveText(/A name is required for your newsletter/);
|
||||
|
||||
// Shouldn't be necessary, but without these Playwright doesn't click Create the second time for some reason
|
||||
await modal.getByRole('button', {name: 'Cancel'}).click();
|
||||
@ -69,7 +69,7 @@ test.describe('Newsletter settings', async () => {
|
||||
await modal.getByPlaceholder('Weekly Roundup').fill('');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(modal).toHaveText(/Name is required/);
|
||||
await expect(modal).toHaveText(/A name is required for your newsletter/);
|
||||
|
||||
await modal.getByPlaceholder('Weekly Roundup').fill('Updated newsletter');
|
||||
|
||||
@ -114,7 +114,7 @@ test.describe('Newsletter settings', async () => {
|
||||
await modal.getByLabel('Sender email').fill('not-an-email');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(modal).toHaveText(/Invalid email/);
|
||||
await expect(modal).toHaveText(/Enter a valid email address/);
|
||||
|
||||
await modal.getByLabel('Sender email').fill('test@test.com');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
@ -191,7 +191,7 @@ test.describe('Newsletter settings', async () => {
|
||||
await replyToEmail.fill('not-an-email');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(modal).toHaveText(/Invalid email/);
|
||||
await expect(modal).toHaveText(/Enter a valid email address/);
|
||||
|
||||
await replyToEmail.fill('test@test.com');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
@ -237,12 +237,12 @@ test.describe('Newsletter settings', async () => {
|
||||
// Error case #1: add invalid email address
|
||||
await senderEmail.fill('Harry Potter');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await expect(modal).toHaveText(/Invalid email/);
|
||||
await expect(modal).toHaveText(/Enter a valid email address/);
|
||||
|
||||
// Error case #2: the sender email address doesn't match the custom sending domain
|
||||
await senderEmail.fill('harry@potter.com');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await expect(modal).toHaveText(/Email must end with @customdomain.com/);
|
||||
await expect(modal).toHaveText(/Email address must end with @customdomain.com/);
|
||||
|
||||
// But can have any address on the same domain, without verification
|
||||
await senderEmail.fill('harry@customdomain.com');
|
||||
|
@ -168,7 +168,7 @@ test.describe('Theme settings', async () => {
|
||||
expect(lastApiRequests.uploadTheme).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Limits uploading new themes', async ({page}) => {
|
||||
test('Limits uploading new themes and redirect to /pro', async ({page}) => {
|
||||
await mockApi({page, requests: {
|
||||
...globalDataRequests,
|
||||
...limitRequests,
|
||||
@ -206,6 +206,18 @@ test.describe('Theme settings', async () => {
|
||||
await modal.getByRole('button', {name: 'Upload theme'}).click();
|
||||
|
||||
await expect(page.getByTestId('limit-modal')).toHaveText(/Upgrade to enable custom themes/);
|
||||
|
||||
const limitModal = page.getByTestId('limit-modal');
|
||||
|
||||
await limitModal.getByRole('button', {name: 'Upgrade'}).click();
|
||||
|
||||
// The route should be updated
|
||||
const newPageUrl = page.url();
|
||||
const newPageUrlObject = new URL(newPageUrl);
|
||||
const decodedUrl = decodeURIComponent(newPageUrlObject.pathname);
|
||||
|
||||
// expect the route to be updated to /pro
|
||||
await expect(decodedUrl).toMatch(/\/\{\"route\":\"\/pro\",\"isExternal\":true\}$/);
|
||||
});
|
||||
|
||||
test('Prevents overwriting the default theme', async ({page}) => {
|
||||
|
@ -12,6 +12,22 @@ Comments widget that is embedded at the bottom of posts in Ghost.
|
||||
|
||||
You can automatically start the comments dev server when developing Ghost by running Ghost (in root folder) via `yarn dev --all` or `yarn dev --comments`. This will host the comments JavaScript files, and makes sure that Ghost uses these locally hosted assets instead of the ones from the CDN.
|
||||
|
||||
# Copyright & License
|
||||
## Release
|
||||
|
||||
Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE).
|
||||
A patch release can be rolled out instantly in production, whereas a minor/major release requires the Ghost monorepo to be updated and released. In either case, you need sufficient permissions to release `@tryghost` packages on NPM.
|
||||
|
||||
### Patch release
|
||||
|
||||
1. Run `yarn ship` and select a patch version when prompted
|
||||
2. (Optional) Clear JsDelivr cache to get the new version out instantly ([docs](https://www.notion.so/ghost/How-to-clear-jsDelivr-CDN-cache-2930bdbac02946eca07ac23ab3199bfa?pvs=4)). Typically, you'll need to open `https://purge.jsdelivr.net/ghost/comments-ui@~${COMMENTS_UI_VERSION}/umd/comments-ui.min.js` and
|
||||
`https://purge.jsdelivr.net/ghost/comments-ui@~${COMMENTS_UI_VERSION}/umd/main.css` in your browser, where `COMMENTS_UI_VERSION` is the latest minor version in `ghost/core/core/shared/config/defaults.json` ([code](https://github.com/TryGhost/Ghost/blob/0aef3d3beeebcd79a4bfd3ad27e0ac67554b5744/ghost/core/core/shared/config/defaults.json#L198))
|
||||
|
||||
### Minor / major release
|
||||
|
||||
1. Run `yarn ship` and select a minor or major version when prompted
|
||||
2. Update the Comments UI version in `ghost/core/core/shared/config/defaults.json` to the new minor or major version ([code](https://github.com/TryGhost/Ghost/blob/0aef3d3beeebcd79a4bfd3ad27e0ac67554b5744/ghost/core/core/shared/config/defaults.json#L198))
|
||||
3. Wait until a new version of Ghost is released
|
||||
|
||||
# Copyright & License
|
||||
|
||||
Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE).
|
||||
|
@ -32,6 +32,10 @@ const AccountWelcome = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isComplimentary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscriptionHasFreeTrial({sub: subscription})) {
|
||||
const trialEnd = getDateString(subscription.trial_end_at);
|
||||
return (
|
||||
|
@ -2,51 +2,52 @@
|
||||
|
||||
This is the home of the Ember.js-based Admin app that ships with [Ghost](https://github.com/tryghost/ghost).
|
||||
|
||||
## Running tests
|
||||
## Test
|
||||
|
||||
Build and run tests once:
|
||||
### Running tests in the browser
|
||||
|
||||
Run all tests in the browser by running `yarn dev` in the Ghost monorepo and visiting http://localhost:4200/tests. The code is hotloaded on change and you can filter which tests to run.
|
||||
|
||||
[Testing public documentation](https://ghost.notion.site/Testing-Ember-560cec6700fc4d37a58b3ba9febb4b4b)
|
||||
|
||||
---
|
||||
|
||||
Tip: You can use `await this.pauseTest()` in your tests to temporarily pause the execution of browser tests. Use the browser console to inspect and debug the DOM, then resume tests by running `resumeTest()` directly in the browser console ([docs](https://guides.emberjs.com/v3.28.0/testing/testing-application/#toc_debugging-your-tests))
|
||||
|
||||
|
||||
### Running tests in the CLI
|
||||
|
||||
To build and run tests in the CLI, you can use:
|
||||
|
||||
```bash
|
||||
TZ=UTC yarn test
|
||||
```
|
||||
_Note the `TZ=UTC` environment variable which is currently required to get tests working if your system timezone doesn't match UTC._
|
||||
|
||||
If you are serving the admin app (e.g., when running `yarn serve`, or when running `yarn dev` in the main Ghost project), you can also run the tests in your browser by going to http://localhost:4200/tests.
|
||||
---
|
||||
|
||||
This has the additional benefit that you can use `await this.pauseTest()` in your tests to temporarily pause tests (best to also add `this.timeout(0);` to avoid timeouts). This allows you to inspect the DOM in your browser to debug tests. You can resume tests by running `resumeTest()` in your browser console.
|
||||
|
||||
[More information](https://guides.emberjs.com/v3.28.0/testing/testing-application/#toc_debugging-your-tests)
|
||||
|
||||
|
||||
### Writing tests
|
||||
|
||||
When writing tests and not using the `http://localhost:4200/tests` browser tests, it can be easier to have a separate watching build that builds the project for the test environment (this drastically reduces the time you have to wait when running tests):
|
||||
However, this is very slow when writing tests, as it requires the app to be rebuilt on every change. Instead, create a separate watching build with:
|
||||
|
||||
```bash
|
||||
yarn build --environment=test -w -o="dist-test"
|
||||
```
|
||||
|
||||
After that, you can easily run tests locally:
|
||||
|
||||
Run all tests:
|
||||
Then run tests with:
|
||||
|
||||
```bash
|
||||
TZ=UTC yarn test 1 --path="dist-test"
|
||||
TZ=UTC yarn test 1 --reporter dot --path="dist-test"
|
||||
```
|
||||
|
||||
To have a cleaner output:
|
||||
The `--reporter dot` shows a dot (`.`) for every successful test, and `F` for every failed test. It renders the output of the failed tests only.
|
||||
|
||||
```bash
|
||||
TZ=UTC yarn test 1 --reporter dot --path="dist-test"
|
||||
```
|
||||
|
||||
This shows a dot (`.`) for every successful test, and `F` for every failed test. At the end, it will only show the output of the failed tests.
|
||||
---
|
||||
|
||||
To run a specific test file:
|
||||
```bash
|
||||
TZ=UTC yarn test 1 --reporter dot --path="dist-test" -mp=tests/acceptance/settings/newsletters-test.js
|
||||
TZ=UTC yarn test 1 --reporter dot --path="dist-test" -mp=tests/unit/helpers/gh-count-characters-test.js
|
||||
```
|
||||
_Hint: you can easily copy the path of a test in VSCode by right clicking on the test file and choosing `Copy Relative Path`._
|
||||
|
||||
---
|
||||
|
||||
To have a full list of the available options, run
|
||||
```bash
|
||||
@ -55,4 +56,4 @@ ember exam --help
|
||||
|
||||
# Copyright & License
|
||||
|
||||
Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
|
||||
Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
|
||||
|
@ -9,8 +9,8 @@ export default class GhAlert extends Component {
|
||||
const typeMapping = {
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
warn: 'blue',
|
||||
info: 'blue'
|
||||
warn: 'black',
|
||||
info: 'black'
|
||||
};
|
||||
|
||||
const type = this.args.message.type;
|
||||
|
@ -143,25 +143,21 @@
|
||||
{{else}}
|
||||
{{#if (or (eq sub.price.nickname "Monthly") (eq sub.price.nickname "Yearly"))}}
|
||||
{{else}}
|
||||
<span class="gh-cp-membertier-pricelabel">{{sub.price.nickname}}</span><span class="gh-cp-membertier-renewal"> – </span>
|
||||
<span class="gh-cp-membertier-pricelabel">{{sub.price.nickname}}</span>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if sub.isComplimentary}}
|
||||
{{#if sub.compExpiry}}
|
||||
<span class="gh-cp-membertier-renewal">Expires {{sub.compExpiry}}</span>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{#if sub.hasEnded}}
|
||||
<span class="gh-cp-membertier-renewal">Ended {{sub.validUntil}}</span>
|
||||
{{else if sub.willEndSoon}}
|
||||
<span class="gh-cp-membertier-renewal">Has access until {{sub.validUntil}}</span>
|
||||
{{else if sub.trialUntil}}
|
||||
<span class="gh-cp-membertier-renewal">Ends {{sub.trialUntil}}</span>
|
||||
{{else}}
|
||||
<span class="gh-cp-membertier-renewal">Renews {{sub.validUntil}}</span>
|
||||
{{/if}}
|
||||
{{#if sub.trialUntil}}
|
||||
<span class="gh-cp-membertier-renewal"> – </span>
|
||||
<span class="gh-cp-membertier-renewal">{{sub.validityDetails}}</span>
|
||||
{{/if}}
|
||||
|
||||
{{#if sub.compExpiry}}
|
||||
<span class="gh-cp-membertier-renewal"> – </span>
|
||||
<span class="gh-cp-membertier-renewal">{{sub.validityDetails}}</span>
|
||||
{{/if}}
|
||||
|
||||
|
||||
</div>
|
||||
<Member::SubscriptionDetailBox @sub={{sub}} @index={{index}} />
|
||||
</div>
|
||||
|
@ -1,8 +1,7 @@
|
||||
import Component from '@glimmer/component';
|
||||
import moment from 'moment-timezone';
|
||||
import {action} from '@ember/object';
|
||||
import {didCancel, task} from 'ember-concurrency';
|
||||
import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency';
|
||||
import {getSubscriptionData} from 'ghost-admin/utils/subscription-data';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
@ -60,41 +59,9 @@ export default class extends Component {
|
||||
return typeof value.id !== 'undefined' && self.findIndex(element => (element.tier_id || element.id) === (value.tier_id || value.id)) === index;
|
||||
});
|
||||
|
||||
let subscriptionData = subscriptions.filter((sub) => {
|
||||
return !!sub.price;
|
||||
}).map((sub) => {
|
||||
const periodEnded = sub.current_period_end && new Date(sub.current_period_end) < new Date();
|
||||
const data = {
|
||||
...sub,
|
||||
attribution: {
|
||||
...sub.attribution,
|
||||
referrerSource: sub.attribution?.referrer_source || 'Unknown',
|
||||
referrerMedium: sub.attribution?.referrer_medium || '-'
|
||||
},
|
||||
startDate: sub.start_date ? moment(sub.start_date).format('D MMM YYYY') : '-',
|
||||
validUntil: sub.current_period_end ? moment(sub.current_period_end).format('D MMM YYYY') : '-',
|
||||
hasEnded: sub.status === 'canceled' && periodEnded,
|
||||
willEndSoon: sub.cancel_at_period_end || (sub.status === 'canceled' && !periodEnded),
|
||||
cancellationReason: sub.cancellation_reason,
|
||||
price: {
|
||||
...sub.price,
|
||||
currencySymbol: getSymbol(sub.price.currency),
|
||||
nonDecimalAmount: getNonDecimal(sub.price.amount)
|
||||
},
|
||||
isComplimentary: !sub.id
|
||||
};
|
||||
if (sub.trial_end_at) {
|
||||
const inTrialMode = moment(sub.trial_end_at).isAfter(new Date(), 'day');
|
||||
if (inTrialMode) {
|
||||
data.trialUntil = moment(sub.trial_end_at).format('D MMM YYYY');
|
||||
}
|
||||
}
|
||||
let subsWithPrice = subscriptions.filter(sub => !!sub.price);
|
||||
let subscriptionData = subsWithPrice.map(sub => getSubscriptionData(sub));
|
||||
|
||||
if (!sub.id && sub.tier?.expiry_at) {
|
||||
data.compExpiry = moment(sub.tier.expiry_at).utc().format('D MMM YYYY');
|
||||
}
|
||||
return data;
|
||||
});
|
||||
return tiers.map((tier) => {
|
||||
let tierSubscriptions = subscriptionData.filter((subscription) => {
|
||||
return subscription?.price?.tier?.tier_id === (tier.tier_id || tier.id);
|
||||
|
@ -1,126 +1,54 @@
|
||||
import {MATCH_RELATION_OPTIONS} from './relation-options';
|
||||
|
||||
export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => {
|
||||
if (feature.filterEmailDisabled) {
|
||||
return {
|
||||
label: newsletters.length > 1 ? 'All newsletters' : 'Newsletter subscription',
|
||||
name: 'subscribed',
|
||||
columnLabel: 'Subscribed',
|
||||
relationOptions: MATCH_RELATION_OPTIONS,
|
||||
valueType: 'options',
|
||||
group: newsletters.length > 1 ? 'Newsletters' : group,
|
||||
// Only show the filter for multiple newsletters if feature flag is enabled
|
||||
feature: newsletters.length > 1 ? 'filterEmailDisabled' : undefined,
|
||||
buildNqlFilter: (flt) => {
|
||||
const relation = flt.relation;
|
||||
const value = flt.value;
|
||||
|
||||
if (value === 'email-disabled') {
|
||||
if (relation === 'is') {
|
||||
return '(email_disabled:1)';
|
||||
}
|
||||
return '(email_disabled:0)';
|
||||
}
|
||||
|
||||
if (relation === 'is') {
|
||||
if (value === 'subscribed') {
|
||||
return '(subscribed:true+email_disabled:0)';
|
||||
}
|
||||
return '(subscribed:false+email_disabled:0)';
|
||||
}
|
||||
|
||||
// relation === 'is-not'
|
||||
if (value === 'subscribed') {
|
||||
return '(subscribed:false,email_disabled:1)';
|
||||
}
|
||||
return '(subscribed:true,email_disabled:1)';
|
||||
},
|
||||
parseNqlFilter: (flt) => {
|
||||
const comparator = flt.$and || flt.$or; // $or for legacy filter backwards compatibility
|
||||
|
||||
if (!comparator || comparator.length !== 2) {
|
||||
const filter = flt;
|
||||
if (filter && filter.email_disabled !== undefined) {
|
||||
if (filter.email_disabled) {
|
||||
return {
|
||||
value: 'email-disabled',
|
||||
relation: 'is'
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: 'email-disabled',
|
||||
relation: 'is-not'
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparator[0].subscribed === undefined || comparator[1].email_disabled === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usedOr = flt.$or !== undefined;
|
||||
const subscribed = comparator[0].subscribed;
|
||||
|
||||
if (usedOr) {
|
||||
// Is not
|
||||
return {
|
||||
value: !subscribed ? 'subscribed' : 'unsubscribed',
|
||||
relation: 'is-not'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: subscribed ? 'subscribed' : 'unsubscribed',
|
||||
relation: 'is'
|
||||
};
|
||||
},
|
||||
options: [
|
||||
{label: newsletters.length > 1 ? 'Subscribed to at least one' : 'Subscribed', name: 'subscribed'},
|
||||
{label: newsletters.length > 1 ? 'Unsubscribed from all' : 'Unsubscribed', name: 'unsubscribed'},
|
||||
{label: 'Email disabled', name: 'email-disabled'}
|
||||
],
|
||||
getColumnValue: (member) => {
|
||||
if (member.emailSuppression && member.emailSuppression.suppressed) {
|
||||
return {
|
||||
text: 'Email disabled'
|
||||
};
|
||||
}
|
||||
|
||||
return member.newsletters.length > 0 ? {
|
||||
text: 'Subscribed'
|
||||
} : {
|
||||
text: 'Unsubscribed'
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (newsletters.length > 1) {
|
||||
// Disable
|
||||
// Only show the filter for multiple newsletters if feature flag is enabled
|
||||
return [];
|
||||
}
|
||||
|
||||
export const SUBSCRIBED_FILTER = ({newsletters, group}) => {
|
||||
return {
|
||||
label: 'Newsletter subscription',
|
||||
label: newsletters.length > 1 ? 'All newsletters' : 'Newsletter subscription',
|
||||
name: 'subscribed',
|
||||
columnLabel: 'Subscribed',
|
||||
relationOptions: MATCH_RELATION_OPTIONS,
|
||||
valueType: 'options',
|
||||
group: group,
|
||||
group: newsletters.length > 1 ? 'Newsletters' : group,
|
||||
buildNqlFilter: (flt) => {
|
||||
const relation = flt.relation;
|
||||
const value = flt.value;
|
||||
|
||||
return (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
|
||||
? '(subscribed:true+email_disabled:0)'
|
||||
: '(subscribed:false,email_disabled:1)';
|
||||
if (value === 'email-disabled') {
|
||||
if (relation === 'is') {
|
||||
return '(email_disabled:1)';
|
||||
}
|
||||
return '(email_disabled:0)';
|
||||
}
|
||||
|
||||
if (relation === 'is') {
|
||||
if (value === 'subscribed') {
|
||||
return '(subscribed:true+email_disabled:0)';
|
||||
}
|
||||
return '(subscribed:false+email_disabled:0)';
|
||||
}
|
||||
|
||||
// relation === 'is-not'
|
||||
if (value === 'subscribed') {
|
||||
return '(subscribed:false,email_disabled:1)';
|
||||
}
|
||||
return '(subscribed:true,email_disabled:1)';
|
||||
},
|
||||
parseNqlFilter: (flt) => {
|
||||
const comparator = flt.$and || flt.$or;
|
||||
const comparator = flt.$and || flt.$or; // $or for legacy filter backwards compatibility
|
||||
|
||||
if (!comparator || comparator.length !== 2) {
|
||||
const filter = flt;
|
||||
if (filter && filter.email_disabled !== undefined) {
|
||||
if (filter.email_disabled) {
|
||||
return {
|
||||
value: 'email-disabled',
|
||||
relation: 'is'
|
||||
};
|
||||
}
|
||||
return {
|
||||
value: 'email-disabled',
|
||||
relation: 'is-not'
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -128,31 +56,44 @@ export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const usedOr = flt.$or !== undefined;
|
||||
const subscribed = comparator[0].subscribed;
|
||||
|
||||
if (usedOr) {
|
||||
// Is not
|
||||
return {
|
||||
value: !subscribed ? 'subscribed' : 'unsubscribed',
|
||||
relation: 'is-not'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: subscribed ? 'true' : 'false',
|
||||
value: subscribed ? 'subscribed' : 'unsubscribed',
|
||||
relation: 'is'
|
||||
};
|
||||
},
|
||||
options: [
|
||||
{label: 'Subscribed', name: 'true'},
|
||||
{label: 'Unsubscribed', name: 'false'}
|
||||
{label: newsletters.length > 1 ? 'Subscribed to at least one' : 'Subscribed', name: 'subscribed'},
|
||||
{label: newsletters.length > 1 ? 'Unsubscribed from all' : 'Unsubscribed', name: 'unsubscribed'},
|
||||
{label: 'Email disabled', name: 'email-disabled'}
|
||||
],
|
||||
getColumnValue: (member, flt) => {
|
||||
const relation = flt.relation;
|
||||
const value = flt.value;
|
||||
getColumnValue: (member) => {
|
||||
if (member.emailSuppression && member.emailSuppression.suppressed) {
|
||||
return {
|
||||
text: 'Email disabled'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
|
||||
? 'Subscribed'
|
||||
: 'Unsubscribed'
|
||||
return member.newsletters.length > 0 ? {
|
||||
text: 'Subscribed'
|
||||
} : {
|
||||
text: 'Unsubscribed'
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const NEWSLETTERS_FILTERS = ({newsletters, group, feature}) => {
|
||||
export const NEWSLETTERS_FILTERS = ({newsletters, group}) => {
|
||||
if (newsletters.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
@ -210,12 +151,10 @@ export const NEWSLETTERS_FILTERS = ({newsletters, group, feature}) => {
|
||||
const relation = flt.relation;
|
||||
const value = flt.value;
|
||||
|
||||
if (feature.filterEmailDisabled) {
|
||||
if (member.emailSuppression && member.emailSuppression.suppressed) {
|
||||
return {
|
||||
text: 'Email disabled'
|
||||
};
|
||||
}
|
||||
if (member.emailSuppression && member.emailSuppression.suppressed) {
|
||||
return {
|
||||
text: 'Email disabled'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -1,39 +1,14 @@
|
||||
<MultiList::List @model={{@list}} class="posts-list gh-list {{unless @model "no-posts"}} feature-memberAttribution" as |list| >
|
||||
{{!-- always order as scheduled, draft, remainder --}}
|
||||
{{#if (or @model.scheduledPosts (or @model.draftPosts @model.publishedAndSentPosts))}}
|
||||
{{#if @model.scheduledPosts}}
|
||||
{{#each @model.scheduledPosts as |post|}}
|
||||
<list.item @id={{post.id}} class="gh-posts-list-item-group">
|
||||
<PostsList::ListItem
|
||||
@post={{post}}
|
||||
data-test-post-id={{post.id}}
|
||||
/>
|
||||
</list.item>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{#if (and @model.draftPosts (or (not @model.scheduledPosts) (and @model.scheduledPosts @model.scheduledPosts.reachedInfinity)))}}
|
||||
{{#each @model.draftPosts as |post|}}
|
||||
<list.item @id={{post.id}} class="gh-posts-list-item-group">
|
||||
<PostsList::ListItem
|
||||
@post={{post}}
|
||||
data-test-post-id={{post.id}}
|
||||
/>
|
||||
</list.item>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{#if (and @model.publishedAndSentPosts (and (or (not @model.scheduledPosts) @model.scheduledPosts.reachedInfinity) (or (not @model.draftPosts) @model.draftPosts.reachedInfinity)))}}
|
||||
{{#each @model.publishedAndSentPosts as |post|}}
|
||||
<list.item @id={{post.id}} class="gh-posts-list-item-group">
|
||||
<PostsList::ListItem
|
||||
@post={{post}}
|
||||
data-test-post-id={{post.id}}
|
||||
/>
|
||||
</list.item>
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
{{#each @model as |post|}}
|
||||
<list.item @id={{post.id}} class="gh-posts-list-item-group">
|
||||
<PostsList::ListItem
|
||||
@post={{post}}
|
||||
data-test-post-id={{post.id}}
|
||||
/>
|
||||
</list.item>
|
||||
{{else}}
|
||||
{{yield}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</MultiList::List>
|
||||
|
||||
{{!-- The currently selected item or items are passed to the context menu --}}
|
||||
|
@ -1,8 +0,0 @@
|
||||
<div class="gh-expandable-header">
|
||||
<div>
|
||||
<h4 class="gh-expandable-title">Counter</h4>
|
||||
<p class="gh-expandable-description">Current counter value: <strong>{{this.counter}}</strong></p>
|
||||
<p class="gh-expandable-description">This counter will reset when Ghost reboots.</p>
|
||||
</div>
|
||||
<button type="button" class="gh-btn" {{on "click" this.handleClick}} data-test-button="delete-all"><span>Add One</span></button>
|
||||
</div>
|
@ -1,31 +0,0 @@
|
||||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
export default class Websockets extends Component {
|
||||
@service('socket-io') socketIOService;
|
||||
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
// initialize connection
|
||||
|
||||
// TODO: ensure this works with subdirectories
|
||||
let origin = window.location.origin; // this gives us host:port
|
||||
let socket = this.socketIOService.socketFor(origin);
|
||||
// add listener
|
||||
socket.on('addCount', (value) => {
|
||||
this.counter = value;
|
||||
});
|
||||
}
|
||||
|
||||
// button counter
|
||||
@tracked counter = 0;
|
||||
|
||||
// handle button/event
|
||||
@action handleClick() {
|
||||
let socket = this.socketIOService.socketFor(origin);
|
||||
this.counter = 1 + this.counter;
|
||||
socket.emit('addCount', this.counter);
|
||||
}
|
||||
}
|
@ -62,7 +62,10 @@ export default class ResetController extends Controller.extend(ValidationEngine)
|
||||
password_reset: [{newPassword, ne2Password, token}]
|
||||
}
|
||||
});
|
||||
this.notifications.showAlert(resp.password_reset[0].message, {type: 'warn', delayed: true, key: 'password.reset'});
|
||||
this.notifications.showNotification(
|
||||
resp.password_reset[0].message,
|
||||
{type: 'info', delayed: true, key: 'password.reset'}
|
||||
);
|
||||
this.session.authenticate('authenticator:cookie', email, newPassword);
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
@ -25,6 +25,7 @@ export default class SigninController extends Controller.extend(ValidationEngine
|
||||
|
||||
@tracked submitting = false;
|
||||
@tracked loggingIn = false;
|
||||
@tracked flowNotification = '';
|
||||
@tracked flowErrors = '';
|
||||
@tracked passwordResetEmailSent = false;
|
||||
|
||||
@ -123,21 +124,19 @@ export default class SigninController extends Controller.extend(ValidationEngine
|
||||
let notifications = this.notifications;
|
||||
|
||||
this.flowErrors = '';
|
||||
this.flowNotification = '';
|
||||
// This is a bit dirty, but there's no other way to ensure the properties are set as well as 'forgotPassword'
|
||||
this.hasValidated.addObject('identification');
|
||||
|
||||
try {
|
||||
yield this.validate({property: 'forgotPassword'});
|
||||
yield this.ajax.post(forgottenUrl, {data: {password_reset: [{email}]}});
|
||||
notifications.showAlert(
|
||||
'Please check your email for instructions.',
|
||||
{type: 'info', key: 'forgot-password.send.success'}
|
||||
);
|
||||
this.flowNotification = 'An email with password reset instructions has been sent.';
|
||||
return true;
|
||||
} catch (error) {
|
||||
// ValidationEngine throws "undefined" for failed validation
|
||||
if (!error) {
|
||||
return this.flowErrors = 'We need your email address to reset your password!';
|
||||
return this.flowErrors = 'We need your email address to reset your password.';
|
||||
}
|
||||
|
||||
if (isVersionMismatchError(error)) {
|
||||
|
@ -1,13 +0,0 @@
|
||||
import Controller from '@ember/controller';
|
||||
/* eslint-disable ghost/ember/alias-model-in-controller */
|
||||
import classic from 'ember-classic-decorator';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
@classic
|
||||
export default class WebsocketsController extends Controller {
|
||||
@service feature;
|
||||
|
||||
init() {
|
||||
super.init(...arguments);
|
||||
}
|
||||
}
|
@ -60,9 +60,6 @@ Router.map(function () {
|
||||
this.route('activitypub-x', {path: '/*sub'});
|
||||
});
|
||||
|
||||
// testing websockets
|
||||
this.route('websockets');
|
||||
|
||||
this.route('explore', function () {
|
||||
// actual Ember route, not rendered in iframe
|
||||
this.route('connect');
|
||||
|
@ -1,5 +1,4 @@
|
||||
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
|
||||
import RSVP from 'rsvp';
|
||||
import {action} from '@ember/object';
|
||||
import {assign} from '@ember/polyfills';
|
||||
import {isBlank} from '@ember/utils';
|
||||
@ -47,46 +46,36 @@ export default class PostsRoute extends AuthenticatedRoute {
|
||||
totalPagesParam: 'meta.pagination.pages'
|
||||
};
|
||||
|
||||
// type filters are actually mapping statuses
|
||||
assign(filterParams, this._getTypeFilters(params.type));
|
||||
|
||||
if (params.type === 'featured') {
|
||||
filterParams.featured = true;
|
||||
}
|
||||
|
||||
// authors and contributors can only view their own posts
|
||||
if (user.isAuthor) {
|
||||
// authors can only view their own posts
|
||||
filterParams.authors = user.slug;
|
||||
} else if (user.isContributor) {
|
||||
// Contributors can only view their own draft posts
|
||||
filterParams.authors = user.slug;
|
||||
// otherwise we need to filter by author if present
|
||||
// filterParams.status = 'draft';
|
||||
} else if (params.author) {
|
||||
filterParams.authors = params.author;
|
||||
}
|
||||
|
||||
let filter = this._filterString(filterParams);
|
||||
if (!isBlank(filter)) {
|
||||
queryParams.filter = filter;
|
||||
}
|
||||
|
||||
if (!isBlank(params.order)) {
|
||||
queryParams.order = params.order;
|
||||
}
|
||||
|
||||
let perPage = this.perPage;
|
||||
let paginationSettings = assign({perPage, startingPage: 1}, paginationParams, queryParams);
|
||||
|
||||
const filterStatuses = filterParams.status;
|
||||
let models = {};
|
||||
if (filterStatuses.includes('scheduled')) {
|
||||
let scheduledPostsParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: 'scheduled'})};
|
||||
models.scheduledPosts = this.infinity.model('post', assign({perPage, startingPage: 1}, paginationParams, scheduledPostsParams));
|
||||
}
|
||||
if (filterStatuses.includes('draft')) {
|
||||
let draftPostsParams = {...queryParams, order: params.order || 'updated_at desc', filter: this._filterString({...filterParams, status: 'draft'})};
|
||||
models.draftPosts = this.infinity.model('post', assign({perPage, startingPage: 1}, paginationParams, draftPostsParams));
|
||||
}
|
||||
if (filterStatuses.includes('published') || filterStatuses.includes('sent')) {
|
||||
let publishedAndSentPostsParams;
|
||||
if (filterStatuses.includes('published') && filterStatuses.includes('sent')) {
|
||||
publishedAndSentPostsParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: '[published,sent]'})};
|
||||
} else {
|
||||
publishedAndSentPostsParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: filterStatuses.includes('published') ? 'published' : 'sent'})};
|
||||
}
|
||||
models.publishedAndSentPosts = this.infinity.model('post', assign({perPage, startingPage: 1}, paginationParams, publishedAndSentPostsParams));
|
||||
}
|
||||
|
||||
return RSVP.hash(models);
|
||||
return this.infinity.model(this.modelName, paginationSettings);
|
||||
}
|
||||
|
||||
// trigger a background load of all tags and authors for use in filter dropdowns
|
||||
@ -131,12 +120,6 @@ export default class PostsRoute extends AuthenticatedRoute {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object containing the status filter based on the given type.
|
||||
*
|
||||
* @param {string} type - The type of filter to generate (draft, published, scheduled, sent).
|
||||
* @returns {Object} - An object containing the status filter.
|
||||
*/
|
||||
_getTypeFilters(type) {
|
||||
let status = '[draft,scheduled,published,sent]';
|
||||
|
||||
|
@ -1,18 +0,0 @@
|
||||
import AuthenticatedRoute from './authenticated';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
// need this to be authenticated
|
||||
export default class WebsocketRoute extends AuthenticatedRoute {
|
||||
@service session;
|
||||
@service router;
|
||||
|
||||
beforeModel() {
|
||||
super.beforeModel(...arguments);
|
||||
|
||||
const user = this.session.user;
|
||||
|
||||
if (!user.isAdmin) {
|
||||
return this.router.transitionTo('settings-x.settings-x', `staff/${user.slug}`);
|
||||
}
|
||||
}
|
||||
}
|
@ -243,14 +243,16 @@ export default class DashboardStatsService extends Service {
|
||||
return [];
|
||||
}
|
||||
|
||||
const firstChartDay = moment().add(-this.chartDays, 'days').format('YYYY-MM-DD');
|
||||
|
||||
return this.memberAttributionStats.filter((stat) => {
|
||||
if (this.chartDays === 'all') {
|
||||
return true;
|
||||
}
|
||||
return stat.date >= moment().add(-this.chartDays, 'days').format('YYYY-MM-DD');
|
||||
return stat.date >= firstChartDay;
|
||||
}).reduce((acc, stat) => {
|
||||
const statSource = stat.source ?? '';
|
||||
const existingSource = acc.find(s => s.source === statSource);
|
||||
const existingSource = acc.find(s => s.source.toLowerCase() === statSource.toLowerCase());
|
||||
if (existingSource) {
|
||||
existingSource.signups += stat.signups || 0;
|
||||
existingSource.paidConversions += stat.paidConversions || 0;
|
||||
|
@ -63,7 +63,6 @@ export default class FeatureService extends Service {
|
||||
@feature('lexicalMultiplayer') lexicalMultiplayer;
|
||||
@feature('audienceFeedback') audienceFeedback;
|
||||
@feature('webmentions') webmentions;
|
||||
@feature('websockets') websockets;
|
||||
@feature('stripeAutomaticTax') stripeAutomaticTax;
|
||||
@feature('emailCustomization') emailCustomization;
|
||||
@feature('i18n') i18n;
|
||||
@ -77,13 +76,10 @@ export default class FeatureService extends Service {
|
||||
@feature('tipsAndDonations') tipsAndDonations;
|
||||
@feature('recommendations') recommendations;
|
||||
@feature('lexicalIndicators') lexicalIndicators;
|
||||
@feature('filterEmailDisabled') filterEmailDisabled;
|
||||
@feature('adminXDemo') adminXDemo;
|
||||
@feature('portalImprovements') portalImprovements;
|
||||
@feature('ActivityPub') ActivityPub;
|
||||
@feature('internalLinking') internalLinking;
|
||||
@feature('editorExcerpt') editorExcerpt;
|
||||
@feature('newsletterExcerpt') newsletterExcerpt;
|
||||
@feature('contentVisibility') contentVisibility;
|
||||
|
||||
_user = null;
|
||||
|
@ -312,8 +312,8 @@
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
.gh-alert-black {
|
||||
border-bottom: color-mod(var(--darkgrey) lightness(-10%)) 1px solid;
|
||||
background: var(--darkgrey);
|
||||
border-bottom: 1px solid var(--black);
|
||||
background: var(--black);
|
||||
color: #fff;
|
||||
}
|
||||
.gh-alert-black a {
|
||||
|
@ -271,7 +271,18 @@
|
||||
.gh-setup .gh-flow-content .main-error {
|
||||
margin-top: 16px;
|
||||
color: var(--red);
|
||||
font-size: 1.35rem;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.gh-flow-content .main-notification,
|
||||
.gh-setup .gh-flow-content .main-notification {
|
||||
margin-top: 16px;
|
||||
color: var(--black);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
|
@ -231,14 +231,14 @@ select.error {
|
||||
.gh-input:focus,
|
||||
.gh-input.focus {
|
||||
outline: 0;
|
||||
border-color: color-mod(var(--green)) !important;
|
||||
border-color: var(--green);
|
||||
box-shadow: inset 0 0 0 1px var(--green);
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.error .gh-input:focus,
|
||||
.error .gh-input.focus {
|
||||
border-color: color-mod(var(--red)) !important;
|
||||
border-color: var(--red);
|
||||
box-shadow: inset 0 0 0 1px var(--red);
|
||||
}
|
||||
|
||||
|
@ -30,7 +30,7 @@
|
||||
|
||||
<section class="view-container content-list">
|
||||
<PostsList::List
|
||||
@model={{@model}}
|
||||
@model={{this.postsInfinityModel}}
|
||||
@list={{this.selectionList}}
|
||||
>
|
||||
<li class="no-posts-box" data-test-no-posts-box>
|
||||
@ -51,26 +51,11 @@
|
||||
</li>
|
||||
</PostsList::List>
|
||||
|
||||
{{!-- only show one infinity loader wheel at a time - always order as scheduled, draft, remainder --}}
|
||||
{{#if @model.scheduledPosts}}
|
||||
<GhInfinityLoader
|
||||
@infinityModel={{@model.scheduledPosts}}
|
||||
@infinityModel={{this.postsInfinityModel}}
|
||||
@scrollable=".gh-main"
|
||||
@triggerOffset={{1000}} />
|
||||
{{/if}}
|
||||
{{#if (and @model.draftPosts (or (not @model.scheduledPosts) (and @model.scheduledPosts @model.scheduledPosts.reachedInfinity)))}}
|
||||
<GhInfinityLoader
|
||||
@infinityModel={{@model.draftPosts}}
|
||||
@scrollable=".gh-main"
|
||||
@triggerOffset={{1000}} />
|
||||
{{/if}}
|
||||
{{#if (and @model.publishedAndSentPosts (and (or (not @model.scheduledPosts) @model.scheduledPosts.reachedInfinity) (or (not @model.draftPosts) @model.draftPosts.reachedInfinity)))}}
|
||||
<GhInfinityLoader
|
||||
@infinityModel={{@model.publishedAndSentPosts}}
|
||||
@scrollable=".gh-main"
|
||||
@triggerOffset={{1000}} />
|
||||
{{/if}}
|
||||
|
||||
</section>
|
||||
|
||||
{{outlet}}
|
||||
</section>
|
||||
|
@ -73,7 +73,7 @@
|
||||
data-test-button="sign-in" />
|
||||
</form>
|
||||
|
||||
<p class="main-error">{{if this.flowErrors this.flowErrors}} </p>
|
||||
<p class="{{if this.flowErrors "main-error" "main-notification"}}" data-test-flow-notification>{{if this.flowErrors this.flowErrors this.flowNotification}} </p>
|
||||
{{/if}}
|
||||
</section>
|
||||
</div>
|
||||
|
@ -1,29 +0,0 @@
|
||||
<section class="gh-canvas">
|
||||
<GhCanvasHeader class="gh-canvas-header">
|
||||
<div class="flex flex-column">
|
||||
<h2 class="gh-canvas-title" data-test-screen-title>
|
||||
Testing Websockets
|
||||
</h2>
|
||||
</div>
|
||||
</GhCanvasHeader>
|
||||
{{#if (feature 'websockets')}}
|
||||
<section class="view-container settings-debug">
|
||||
<p class="gh-box gh-box-tip">{{svg-jar "idea"}}This is a testing ground for new or experimental features. They
|
||||
may change, break or inexplicably disappear at any time.</p>
|
||||
<div class="gh-main-section">
|
||||
<h4 class="gh-main-section-header small bn">Secrets</h4>
|
||||
<div class="gh-expandable">
|
||||
<div class="gh-expandable-block">
|
||||
<Websockets />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="view-container settings-debug">
|
||||
<p class="gh-box gh-box-alert">{{svg-jar "warning-stroke"}}This is a testing ground for new or experimental features. You need developer experiments
|
||||
enabled to see the content here.
|
||||
</p>
|
||||
</section>
|
||||
{{/if}}
|
||||
</section>
|
98
ghost/admin/app/utils/subscription-data.js
Normal file
98
ghost/admin/app/utils/subscription-data.js
Normal file
@ -0,0 +1,98 @@
|
||||
import moment from 'moment-timezone';
|
||||
import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency';
|
||||
|
||||
export function getSubscriptionData(sub) {
|
||||
const data = {
|
||||
...sub,
|
||||
attribution: {
|
||||
...sub.attribution,
|
||||
referrerSource: sub.attribution?.referrer_source || 'Unknown',
|
||||
referrerMedium: sub.attribution?.referrer_medium || '-'
|
||||
},
|
||||
startDate: sub.start_date ? moment(sub.start_date).format('D MMM YYYY') : '-',
|
||||
validUntil: validUntil(sub),
|
||||
hasEnded: isCanceled(sub),
|
||||
willEndSoon: isSetToCancel(sub),
|
||||
cancellationReason: sub.cancellation_reason,
|
||||
price: {
|
||||
...sub.price,
|
||||
currencySymbol: getSymbol(sub.price.currency),
|
||||
nonDecimalAmount: getNonDecimal(sub.price.amount)
|
||||
},
|
||||
isComplimentary: isComplimentary(sub),
|
||||
compExpiry: compExpiry(sub),
|
||||
trialUntil: trialUntil(sub)
|
||||
};
|
||||
|
||||
data.validityDetails = validityDetails(data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function validUntil(sub) {
|
||||
// If a subscription has been canceled immediately, don't render the end of validity date
|
||||
// Reason: we don't store the exact cancelation date in the subscription object
|
||||
if (sub.status === 'canceled' && !sub.cancel_at_period_end) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Otherwise, show the current period end date
|
||||
if (sub.current_period_end) {
|
||||
return moment(sub.current_period_end).format('D MMM YYYY');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function isActive(sub) {
|
||||
return ['active', 'trialing', 'past_due', 'unpaid'].includes(sub.status);
|
||||
}
|
||||
|
||||
export function isComplimentary(sub) {
|
||||
return !sub.id;
|
||||
}
|
||||
|
||||
export function isCanceled(sub) {
|
||||
return sub.status === 'canceled';
|
||||
}
|
||||
|
||||
export function isSetToCancel(sub) {
|
||||
return sub.cancel_at_period_end && isActive(sub);
|
||||
}
|
||||
|
||||
export function compExpiry(sub) {
|
||||
if (!sub.id && sub.tier && sub.tier.expiry_at) {
|
||||
return moment(sub.tier.expiry_at).utc().format('D MMM YYYY');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function trialUntil(sub) {
|
||||
const inTrialMode = sub.trial_end_at && moment(sub.trial_end_at).isAfter(new Date(), 'day');
|
||||
if (inTrialMode) {
|
||||
return moment(sub.trial_end_at).format('D MMM YYYY');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function validityDetails(data) {
|
||||
if (data.isComplimentary && data.compExpiry) {
|
||||
return `Expires ${data.compExpiry}`;
|
||||
}
|
||||
|
||||
if (data.hasEnded) {
|
||||
return `Ended ${data.validUntil}`;
|
||||
}
|
||||
|
||||
if (data.willEndSoon) {
|
||||
return `Has access until ${data.validUntil}`;
|
||||
}
|
||||
|
||||
if (data.trialUntil) {
|
||||
return `Ends ${data.trialUntil}`;
|
||||
}
|
||||
|
||||
return `Renews ${data.validUntil}`;
|
||||
}
|
@ -35,10 +35,6 @@ module.exports = function (environment) {
|
||||
|
||||
'ember-simple-auth': { },
|
||||
|
||||
'ember-websockets': {
|
||||
socketIO: true
|
||||
},
|
||||
|
||||
'@sentry/ember': {
|
||||
disablePerformance: true,
|
||||
sentry: {}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghost-admin",
|
||||
"version": "5.87.0",
|
||||
"version": "5.87.1",
|
||||
"description": "Ember.js admin client for Ghost",
|
||||
"author": "Ghost Foundation",
|
||||
"homepage": "http://ghost.org",
|
||||
@ -49,7 +49,7 @@
|
||||
"@tryghost/helpers": "1.1.90",
|
||||
"@tryghost/kg-clean-basic-html": "4.1.1",
|
||||
"@tryghost/kg-converters": "1.0.5",
|
||||
"@tryghost/koenig-lexical": "1.3.2",
|
||||
"@tryghost/koenig-lexical": "1.3.5",
|
||||
"@tryghost/limit-service": "1.2.14",
|
||||
"@tryghost/members-csv": "0.0.0",
|
||||
"@tryghost/nql": "0.12.3",
|
||||
@ -121,7 +121,6 @@
|
||||
"ember-test-selectors": "6.0.0",
|
||||
"ember-tooltips": "3.6.0",
|
||||
"ember-truth-helpers": "3.1.1",
|
||||
"ember-websockets": "10.2.1",
|
||||
"eslint-plugin-babel": "5.3.1",
|
||||
"flexsearch": "0.7.43",
|
||||
"fs-extra": "11.2.0",
|
||||
@ -172,7 +171,7 @@
|
||||
"*.js": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"jose": "4.15.7",
|
||||
"jose": "4.15.9",
|
||||
"path-browserify": "1.0.1",
|
||||
"webpack": "5.92.1"
|
||||
},
|
||||
@ -206,4 +205,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
|
||||
import {beforeEach, describe, it} from 'mocha';
|
||||
import {blur, click, currentURL, fillIn, find, findAll, visit} from '@ember/test-helpers';
|
||||
import {blur, click, currentURL, fillIn, find, findAll, settled, visit} from '@ember/test-helpers';
|
||||
import {clickTrigger, selectChoose} from 'ember-power-select/test-support/helpers';
|
||||
import {expect} from 'chai';
|
||||
import {setupApplicationTest} from 'ember-mocha';
|
||||
@ -41,7 +41,7 @@ describe('Acceptance: Content', function () {
|
||||
return await authenticateSession();
|
||||
});
|
||||
|
||||
it('displays and filters posts', async function () {
|
||||
it.skip('displays and filters posts', async function () {
|
||||
await visit('/posts');
|
||||
// Not checking request here as it won't be the last request made
|
||||
// Displays all posts + pages
|
||||
@ -81,29 +81,38 @@ describe('Acceptance: Content', function () {
|
||||
// show all posts
|
||||
await selectChoose('[data-test-type-select]', 'All posts');
|
||||
|
||||
// Posts are ordered scheduled -> draft -> published/sent
|
||||
// check API request is correct - we submit one request for scheduled, one for drafts, and one for published+sent
|
||||
[lastRequest] = this.server.pretender.handledRequests.slice(-3);
|
||||
expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:scheduled');
|
||||
[lastRequest] = this.server.pretender.handledRequests.slice(-2);
|
||||
expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:draft');
|
||||
// API request is correct
|
||||
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
|
||||
expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:[published,sent]');
|
||||
|
||||
// check order display is correct
|
||||
let postIds = findAll('[data-test-post-id]').map(el => el.getAttribute('data-test-post-id'));
|
||||
expect(postIds, 'post order').to.deep.equal([scheduledPost.id, draftPost.id, publishedPost.id, authorPost.id]);
|
||||
expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:[draft,scheduled,published]');
|
||||
|
||||
// show all posts by editor
|
||||
await selectChoose('[data-test-type-select]', 'Published posts');
|
||||
await selectChoose('[data-test-author-select]', editor.name);
|
||||
|
||||
// API request is correct
|
||||
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
|
||||
expect(lastRequest.queryParams.filter, '"editor" request status filter')
|
||||
.to.have.string('status:published');
|
||||
.to.have.string('status:[draft,scheduled,published]');
|
||||
expect(lastRequest.queryParams.filter, '"editor" request filter param')
|
||||
.to.have.string(`authors:${editor.slug}`);
|
||||
|
||||
// Post status is only visible when members is enabled
|
||||
expect(find('[data-test-visibility-select]'), 'access dropdown before members enabled').to.not.exist;
|
||||
let featureService = this.owner.lookup('service:feature');
|
||||
featureService.set('members', true);
|
||||
await settled();
|
||||
expect(find('[data-test-visibility-select]'), 'access dropdown after members enabled').to.exist;
|
||||
|
||||
await selectChoose('[data-test-visibility-select]', 'Paid members-only');
|
||||
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
|
||||
expect(lastRequest.queryParams.filter, '"visibility" request filter param')
|
||||
.to.have.string('visibility:[paid,tiers]+status:[draft,scheduled,published]');
|
||||
|
||||
// Displays editor post
|
||||
// TODO: implement "filter" param support and fix mirage post->author association
|
||||
// expect(find('[data-test-post-id]').length, 'editor post count').to.equal(1);
|
||||
// expect(find(`[data-test-post-id="${authorPost.id}"]`), 'author post').to.exist;
|
||||
|
||||
// TODO: test tags dropdown
|
||||
});
|
||||
|
||||
// TODO: skipped due to consistently random failures on Travis
|
||||
|
@ -197,41 +197,10 @@ describe('Acceptance: Members filtering', function () {
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows').to.equal(1);
|
||||
});
|
||||
|
||||
it('can filter by specific newsletter subscription', async function () {
|
||||
// add some members to filters
|
||||
const newsletter = this.server.create('newsletter', {status: 'active', slug: 'test-newsletter'});
|
||||
this.server.createList('newsletter', 4);
|
||||
this.server.createList('tier', 4);
|
||||
this.server.createList('member', 4, {subscribed: false});
|
||||
|
||||
await visit('/members');
|
||||
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows')
|
||||
.to.equal(4);
|
||||
|
||||
await click('[data-test-button="members-filter-actions"]');
|
||||
// make sure newsletters are in the filter dropdown
|
||||
const newslettersCount = this.server.schema.newsletters.all().models.length;
|
||||
let options = this.element.querySelectorAll('option');
|
||||
let matchingOptions = [...options].filter(option => option.value.includes('newsletters.slug'));
|
||||
expect(matchingOptions).to.have.length(newslettersCount);
|
||||
|
||||
await visit('/');
|
||||
await visit('/members');
|
||||
// add some members with tiers
|
||||
const tier = this.server.create('tier');
|
||||
const member = this.server.create('member', {tiers: [tier], subscribed: true});
|
||||
member.update({newsletters: [newsletter]});
|
||||
this.server.createList('member', 4, {subscribed: false});
|
||||
|
||||
await visit('/members?filter=' + encodeURIComponent(`newsletters.slug:${newsletter.slug}`));
|
||||
// only 1 member is subscribed so we should only see 1 row
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows')
|
||||
.to.equal(1);
|
||||
});
|
||||
|
||||
it('can filter by newsletter subscription', async function () {
|
||||
// add some members to filter
|
||||
it('can filter by newsletter subscription when there is only one newsletter', async function () {
|
||||
// Create a single newsletter
|
||||
this.server.createList('newsletter', 1);
|
||||
// Add some members to filter
|
||||
this.server.createList('member', 3, {subscribed: true, email_disabled: 0});
|
||||
this.server.createList('member', 4, {subscribed: false, email_disabled: 0});
|
||||
this.server.createList('member', 1, {subscribed: true, email_disabled: 1});
|
||||
@ -255,18 +224,25 @@ describe('Acceptance: Members filtering', function () {
|
||||
|
||||
// has the right values
|
||||
const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`);
|
||||
expect(valueOptions).to.have.length(2);
|
||||
expect(valueOptions[0]).to.have.value('true');
|
||||
expect(valueOptions[1]).to.have.value('false');
|
||||
expect(valueOptions).to.have.length(3);
|
||||
expect(valueOptions[0]).to.have.value('subscribed');
|
||||
expect(valueOptions[1]).to.have.value('unsubscribed');
|
||||
expect(valueOptions[2]).to.have.value('email-disabled');
|
||||
|
||||
// applies default filter immediately
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - true')
|
||||
// applies default filter subscribed immediately
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - subscribed')
|
||||
.to.equal(3);
|
||||
|
||||
// can change filter
|
||||
await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'false');
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - false')
|
||||
.to.equal(5);
|
||||
// can change filter to unsubscribed
|
||||
await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'unsubscribed');
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - unsubscribed')
|
||||
.to.equal(4);
|
||||
expect(find('[data-test-table-column="subscribed"]')).to.exist;
|
||||
|
||||
// can change filter to email-disabled
|
||||
await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'email-disabled');
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - email-disabled')
|
||||
.to.equal(1);
|
||||
expect(find('[data-test-table-column="subscribed"]')).to.exist;
|
||||
|
||||
// can delete filter
|
||||
@ -275,21 +251,99 @@ describe('Acceptance: Members filtering', function () {
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete')
|
||||
.to.equal(8);
|
||||
|
||||
// Can set filter by path
|
||||
// Can set filter to 'subscribed' by path
|
||||
await visit('/');
|
||||
await visit('/members?filter=' + encodeURIComponent('(subscribed:true+email_disabled:0)'));
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - true - from URL')
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - subscribed - from URL')
|
||||
.to.equal(3);
|
||||
await click('[data-test-button="members-filter-actions"]');
|
||||
expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('true');
|
||||
expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('subscribed');
|
||||
|
||||
// Can set filter by path
|
||||
// Can set filter to 'unsubscribed' by path
|
||||
await visit('/');
|
||||
await visit('/members?filter=' + encodeURIComponent('(subscribed:false,email_disabled:1)'));
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - false - from URL')
|
||||
.to.equal(5);
|
||||
await visit('/members?filter=' + encodeURIComponent('(subscribed:false+email_disabled:0)'));
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - unsubscribed - from URL')
|
||||
.to.equal(4);
|
||||
await click('[data-test-button="members-filter-actions"]');
|
||||
expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('false');
|
||||
expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('unsubscribed');
|
||||
|
||||
// Can set filter to 'email-disabled' by path
|
||||
await visit('/');
|
||||
await visit('/members?filter=' + encodeURIComponent('(email_disabled:1)'));
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - email-disabled - from URL')
|
||||
.to.equal(1);
|
||||
await click('[data-test-button="members-filter-actions"]');
|
||||
expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('email-disabled');
|
||||
});
|
||||
|
||||
it('can filter by specific newsletter subscription when there are multiple newsletters', async function () {
|
||||
// Create:
|
||||
// - 1 subscribed member to newsletter
|
||||
// - 1 subscribed member to newsletter with email disabled
|
||||
// - 4 unsubscribed members
|
||||
const newsletter = this.server.create('newsletter', {status: 'active', slug: 'test-newsletter'});
|
||||
const tier = this.server.create('tier');
|
||||
|
||||
const subscribedMember = this.server.create('member', {tiers: [tier], subscribed: true, email_disabled: 0});
|
||||
subscribedMember.update({newsletters: [newsletter]});
|
||||
|
||||
const emailDisabledMember = this.server.create('member', {tiers: [tier], subscribed: true, email_disabled: 1});
|
||||
emailDisabledMember.update({newsletters: [newsletter]});
|
||||
|
||||
this.server.createList('member', 4, {subscribed: false, email_disabled: 0});
|
||||
|
||||
// Test initial member count
|
||||
await visit('/members');
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows')
|
||||
.to.equal(6);
|
||||
|
||||
// Test newsletters options are in the filter dropdown
|
||||
await click('[data-test-button="members-filter-actions"]');
|
||||
const newslettersCount = this.server.schema.newsletters.all().models.length;
|
||||
let options = this.element.querySelectorAll('option');
|
||||
let matchingOptions = [...options].filter(option => option.value.includes('newsletters.slug'));
|
||||
expect(matchingOptions).to.have.length(newslettersCount);
|
||||
|
||||
const filterSelector = `[data-test-members-filter="0"]`;
|
||||
|
||||
// Select first newsletter
|
||||
await fillIn(`${filterSelector} [data-test-select="members-filter"]`, `newsletters.slug:${newsletter.slug}`);
|
||||
|
||||
// Test that the filter has the right operators
|
||||
const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`);
|
||||
expect(operatorOptions[0]).to.have.value('is');
|
||||
expect(operatorOptions[1]).to.have.value('is-not');
|
||||
|
||||
// Test that the filter has the right operators
|
||||
const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`);
|
||||
expect(valueOptions[0]).to.have.value('true');
|
||||
expect(valueOptions[1]).to.have.value('false');
|
||||
|
||||
// applies default filter subscribed immediately, and only count subscribed members without email disabled
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - subscribed')
|
||||
.to.equal(1);
|
||||
|
||||
// can change filter to unsubscribed
|
||||
await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'false');
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - unsubscribed')
|
||||
.to.equal(5);
|
||||
|
||||
// can delete filter
|
||||
await click('[data-test-delete-members-filter="0"]');
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete')
|
||||
.to.equal(6);
|
||||
|
||||
// Can filter members subscribed to that newsletter by path
|
||||
await visit('/');
|
||||
await visit('/members?filter=' + encodeURIComponent(`newsletters.slug:${newsletter.slug}+email_disabled:0`));
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows')
|
||||
.to.equal(1);
|
||||
|
||||
// Can filter members unsubscribed to that newsletter by path
|
||||
await visit('/');
|
||||
await visit('/members?filter=' + encodeURIComponent(`newsletters.slug:-${newsletter.slug},email_disabled:1`));
|
||||
expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows')
|
||||
.to.equal(5);
|
||||
});
|
||||
|
||||
it('can filter by member status', async function () {
|
||||
|
@ -17,7 +17,7 @@ describe('Acceptance: Password Reset', function () {
|
||||
await click('.forgotten-link');
|
||||
|
||||
// an alert with instructions is displayed
|
||||
expect(findAll('.gh-alert-blue').length, 'alert count')
|
||||
expect(findAll('[data-test-flow-notification]').length, 'alert count')
|
||||
.to.equal(1);
|
||||
});
|
||||
|
||||
@ -41,7 +41,7 @@ describe('Acceptance: Password Reset', function () {
|
||||
|
||||
// error message shown
|
||||
expect(find('p.main-error').textContent.trim(), 'error message')
|
||||
.to.equal('We need your email address to reset your password!');
|
||||
.to.equal('We need your email address to reset your password.');
|
||||
|
||||
// invalid email provided
|
||||
await fillIn('input[name="identification"]', 'test');
|
||||
@ -61,7 +61,7 @@ describe('Acceptance: Password Reset', function () {
|
||||
|
||||
// error message
|
||||
expect(find('p.main-error').textContent.trim(), 'error message')
|
||||
.to.equal('We need your email address to reset your password!');
|
||||
.to.equal('We need your email address to reset your password.');
|
||||
|
||||
// unknown email provided
|
||||
await fillIn('input[name="identification"]', 'unknown@example.com');
|
||||
|
@ -45,11 +45,11 @@ describe('Integration: Component: gh-alert', function () {
|
||||
|
||||
this.message.type = 'warn';
|
||||
await settled();
|
||||
expect(alert, 'warn class is yellow').to.have.class('gh-alert-blue');
|
||||
expect(alert, 'warn class is black').to.have.class('gh-alert-black');
|
||||
|
||||
this.message.type = 'info';
|
||||
await settled();
|
||||
expect(alert, 'info class is blue').to.have.class('gh-alert-blue');
|
||||
expect(alert, 'info class is black').to.have.class('gh-alert-black');
|
||||
});
|
||||
|
||||
it('closes notification through notifications service', async function () {
|
||||
|
341
ghost/admin/tests/unit/utils/subscription-data-test.js
Normal file
341
ghost/admin/tests/unit/utils/subscription-data-test.js
Normal file
@ -0,0 +1,341 @@
|
||||
import moment from 'moment-timezone';
|
||||
import {compExpiry, getSubscriptionData, isActive, isCanceled, isComplimentary, isSetToCancel, trialUntil, validUntil, validityDetails} from 'ghost-admin/utils/subscription-data';
|
||||
import {describe, it} from 'mocha';
|
||||
import {expect} from 'chai';
|
||||
|
||||
describe('Unit: Util: subscription-data', function () {
|
||||
describe('validUntil', function () {
|
||||
it('returns the end of the current billing period when the subscription is canceled at the end of the period', function () {
|
||||
let sub = {
|
||||
status: 'canceled',
|
||||
cancel_at_period_end: true,
|
||||
current_period_end: '2021-05-31'
|
||||
};
|
||||
expect(validUntil(sub)).to.equal('31 May 2021');
|
||||
});
|
||||
|
||||
it('returns an empty string when the subscription is canceled immediately', function () {
|
||||
let sub = {
|
||||
status: 'canceled',
|
||||
cancel_at_period_end: false,
|
||||
current_period_end: '2021-05-31'
|
||||
};
|
||||
expect(validUntil(sub)).to.equal('');
|
||||
});
|
||||
|
||||
it('returns the end of the current billing period when the subscription is active', function () {
|
||||
let sub = {
|
||||
status: 'active',
|
||||
cancel_at_period_end: false,
|
||||
current_period_end: '2021-05-31'
|
||||
};
|
||||
expect(validUntil(sub)).to.equal('31 May 2021');
|
||||
});
|
||||
|
||||
it('returns the end of the current billing period when the subscription is in trial', function () {
|
||||
let sub = {
|
||||
status: 'trialing',
|
||||
cancel_at_period_end: false,
|
||||
current_period_end: '2021-05-31'
|
||||
};
|
||||
expect(validUntil(sub)).to.equal('31 May 2021');
|
||||
});
|
||||
|
||||
it('returns the end of the current billing period when the subscription is past_due', function () {
|
||||
let sub = {
|
||||
status: 'past_due',
|
||||
cancel_at_period_end: false,
|
||||
current_period_end: '2021-05-31'
|
||||
};
|
||||
expect(validUntil(sub)).to.equal('31 May 2021');
|
||||
});
|
||||
|
||||
it('returns the end of the current billing period when the subscription is unpaid', function () {
|
||||
let sub = {
|
||||
status: 'unpaid',
|
||||
cancel_at_period_end: false,
|
||||
current_period_end: '2021-05-31'
|
||||
};
|
||||
expect(validUntil(sub)).to.equal('31 May 2021');
|
||||
});
|
||||
|
||||
// Extra data safety check, mainly for imported subscriptions
|
||||
it('returns an empty string if the subcription is canceled immediately and has no current_period_start', function () {
|
||||
let sub = {
|
||||
status: 'canceled',
|
||||
cancel_at_period_end: false
|
||||
};
|
||||
expect(validUntil(sub)).to.equal('');
|
||||
});
|
||||
|
||||
// Extra data safety check, mainly for imported subscriptions
|
||||
it('returns an empty string if the subscription has no current_period_end', function () {
|
||||
let sub = {
|
||||
status: 'active',
|
||||
cancel_at_period_end: false
|
||||
};
|
||||
expect(validUntil(sub)).to.equal('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActive', function () {
|
||||
it('returns true for active subscriptions', function () {
|
||||
let sub = {status: 'active'};
|
||||
expect(isActive(sub)).to.be.true;
|
||||
});
|
||||
|
||||
it('returns true for trialing subscriptions', function () {
|
||||
let sub = {status: 'trialing'};
|
||||
expect(isActive(sub)).to.be.true;
|
||||
});
|
||||
|
||||
it('returns true for past_due subscriptions', function () {
|
||||
let sub = {status: 'past_due'};
|
||||
expect(isActive(sub)).to.be.true;
|
||||
});
|
||||
|
||||
it('returns true for unpaid subscriptions', function () {
|
||||
let sub = {status: 'unpaid'};
|
||||
expect(isActive(sub)).to.be.true;
|
||||
});
|
||||
|
||||
it('returns false for canceled subscriptions', function () {
|
||||
let sub = {status: 'canceled'};
|
||||
expect(isActive(sub)).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('isComplimentary', function () {
|
||||
it('returns true for complimentary subscriptions', function () {
|
||||
let sub = {id: null};
|
||||
expect(isComplimentary(sub)).to.be.true;
|
||||
});
|
||||
|
||||
it('returns false for paid subscriptions', function () {
|
||||
let sub = {id: 'sub_123'};
|
||||
expect(isComplimentary(sub)).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCanceled', function () {
|
||||
it('returns true for canceled subscriptions', function () {
|
||||
let sub = {status: 'canceled'};
|
||||
expect(isCanceled(sub)).to.be.true;
|
||||
});
|
||||
|
||||
it('returns false for active subscriptions', function () {
|
||||
let sub = {status: 'active'};
|
||||
expect(isCanceled(sub)).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSetToCancel', function () {
|
||||
it('returns true for subscriptions set to cancel at the end of the period', function () {
|
||||
let sub = {status: 'active', cancel_at_period_end: true};
|
||||
expect(isSetToCancel(sub)).to.be.true;
|
||||
});
|
||||
|
||||
it('returns false for canceled subscriptions', function () {
|
||||
let sub = {status: 'canceled', cancel_at_period_end: true};
|
||||
expect(isSetToCancel(sub)).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('trialUntil', function () {
|
||||
it('returns the trial end date for subscriptions in trial', function () {
|
||||
let sub = {status: 'trialing', trial_end_at: '2222-05-31'};
|
||||
expect(trialUntil(sub)).to.equal('31 May 2222');
|
||||
});
|
||||
|
||||
it('returns undefined for subscriptions not in trial', function () {
|
||||
let sub = {status: 'active'};
|
||||
expect(trialUntil(sub)).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe('compExpiry', function () {
|
||||
it('returns the complimentary expiry date for complimentary subscriptions', function () {
|
||||
let sub = {id: null, tier: {expiry_at: moment.utc('2021-05-31').toISOString()}};
|
||||
expect(compExpiry(sub)).to.equal('31 May 2021');
|
||||
});
|
||||
|
||||
it('returns undefined for paid subscriptions', function () {
|
||||
let sub = {id: 'sub_123'};
|
||||
expect(compExpiry(sub)).to.be.undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe('validityDetails', function () {
|
||||
it('returns "Expires {compExpiry}" for expired complimentary subscriptions', function () {
|
||||
let data = {
|
||||
isComplimentary: true,
|
||||
compExpiry: '31 May 2021'
|
||||
};
|
||||
expect(validityDetails(data)).to.equal('Expires 31 May 2021');
|
||||
});
|
||||
|
||||
it('returns "Ended {validUntil}" for canceled subscriptions', function () {
|
||||
let data = {
|
||||
hasEnded: true,
|
||||
validUntil: '31 May 2021'
|
||||
};
|
||||
expect(validityDetails(data)).to.equal('Ended 31 May 2021');
|
||||
});
|
||||
|
||||
it('returns "Has access until {validUntil}" for set to cancel subscriptions', function () {
|
||||
let data = {
|
||||
willEndSoon: true,
|
||||
validUntil: '31 May 2021'
|
||||
};
|
||||
expect(validityDetails(data)).to.equal('Has access until 31 May 2021');
|
||||
});
|
||||
|
||||
it('returns "Ends {validUntil}" for trial subscriptions', function () {
|
||||
let data = {
|
||||
trialUntil: '31 May 2021'
|
||||
};
|
||||
expect(validityDetails(data)).to.equal('Ends 31 May 2021');
|
||||
});
|
||||
|
||||
it('returns "Renews {validUntil}" for active subscriptions', function () {
|
||||
let data = {
|
||||
validUntil: '31 May 2021'
|
||||
};
|
||||
expect(validityDetails(data)).to.equal('Renews 31 May 2021');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSubscriptionData', function () {
|
||||
it('returns the correct data for an active subscription', function () {
|
||||
let sub = {
|
||||
id: 'defined',
|
||||
status: 'active',
|
||||
cancel_at_period_end: false,
|
||||
current_period_end: '2021-05-31',
|
||||
trial_end_at: null,
|
||||
tier: null,
|
||||
price: {
|
||||
currency: 'usd',
|
||||
amount: 5000
|
||||
}
|
||||
};
|
||||
let data = getSubscriptionData(sub);
|
||||
|
||||
expect(data).to.include({
|
||||
isComplimentary: false,
|
||||
compExpiry: undefined,
|
||||
hasEnded: false,
|
||||
validUntil: '31 May 2021',
|
||||
willEndSoon: false,
|
||||
trialUntil: undefined,
|
||||
validityDetails: 'Renews 31 May 2021'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the correct data for a trial subscription', function () {
|
||||
let sub = {
|
||||
id: 'defined',
|
||||
status: 'trialing',
|
||||
cancel_at_period_end: false,
|
||||
current_period_end: '2222-05-31',
|
||||
trial_end_at: '2222-05-31',
|
||||
tier: null,
|
||||
price: {
|
||||
currency: 'usd',
|
||||
amount: 5000
|
||||
}
|
||||
};
|
||||
let data = getSubscriptionData(sub);
|
||||
|
||||
expect(data).to.include({
|
||||
isComplimentary: false,
|
||||
compExpiry: undefined,
|
||||
hasEnded: false,
|
||||
validUntil: '31 May 2222',
|
||||
willEndSoon: false,
|
||||
trialUntil: '31 May 2222',
|
||||
validityDetails: 'Ends 31 May 2222'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the correct data for an immediately canceled subscription', function () {
|
||||
let sub = {
|
||||
id: 'defined',
|
||||
status: 'canceled',
|
||||
cancel_at_period_end: false,
|
||||
current_period_end: '2021-05-31',
|
||||
trial_end_at: null,
|
||||
tier: null,
|
||||
price: {
|
||||
currency: 'usd',
|
||||
amount: 5000
|
||||
}
|
||||
};
|
||||
let data = getSubscriptionData(sub);
|
||||
|
||||
expect(data).to.include({
|
||||
isComplimentary: false,
|
||||
compExpiry: undefined,
|
||||
hasEnded: true,
|
||||
validUntil: '',
|
||||
willEndSoon: false,
|
||||
trialUntil: undefined,
|
||||
validityDetails: 'Ended '
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the correct data for a subscription set to cancel at the end of the period', function () {
|
||||
let sub = {
|
||||
id: 'defined',
|
||||
status: 'active',
|
||||
cancel_at_period_end: true,
|
||||
current_period_end: '2021-05-31',
|
||||
trial_end_at: null,
|
||||
tier: null,
|
||||
price: {
|
||||
currency: 'usd',
|
||||
amount: 5000
|
||||
}
|
||||
};
|
||||
let data = getSubscriptionData(sub);
|
||||
|
||||
expect(data).to.include({
|
||||
isComplimentary: false,
|
||||
compExpiry: undefined,
|
||||
hasEnded: false,
|
||||
validUntil: '31 May 2021',
|
||||
willEndSoon: true,
|
||||
trialUntil: undefined,
|
||||
validityDetails: 'Has access until 31 May 2021'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the correct data for a complimentary subscription', function () {
|
||||
let sub = {
|
||||
id: null,
|
||||
status: 'active',
|
||||
cancel_at_period_end: false,
|
||||
current_period_end: '2021-05-31',
|
||||
trial_end_at: null,
|
||||
tier: {
|
||||
expiry_at: moment.utc('2021-05-31').toISOString()
|
||||
},
|
||||
price: {
|
||||
currency: 'usd',
|
||||
amount: 0
|
||||
}
|
||||
};
|
||||
let data = getSubscriptionData(sub);
|
||||
|
||||
expect(data).to.include({
|
||||
isComplimentary: true,
|
||||
compExpiry: '31 May 2021',
|
||||
hasEnded: false,
|
||||
validUntil: '31 May 2021',
|
||||
willEndSoon: false,
|
||||
trialUntil: undefined,
|
||||
validityDetails: 'Expires 31 May 2021'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -1 +1 @@
|
||||
Subproject commit 331257ea2976422fc4a7537bef07bb0c3ef2bd4d
|
||||
Subproject commit f13641dd7e1d39884ebea73065db7b474ef0fd13
|
@ -1 +1 @@
|
||||
Subproject commit 9a2f77a5b9ebe3c53194e3bc1187c0af7c7cc7ae
|
||||
Subproject commit c5dd484544a3e4171d8b47c42f45b71e1ce6acc9
|
@ -559,10 +559,6 @@ async function bootGhost({backend = true, frontend = true, server = true} = {})
|
||||
|
||||
// TODO: move this to the correct place once we figure out where that is
|
||||
if (ghostServer) {
|
||||
// NOTE: changes in this labs setting requires server reboot since we don't re-init services after changes a labs flag
|
||||
const websockets = require('./server/services/websockets');
|
||||
await websockets.init(ghostServer);
|
||||
|
||||
const lexicalMultiplayer = require('./server/services/lexical-multiplayer');
|
||||
await lexicalMultiplayer.init(ghostServer);
|
||||
await lexicalMultiplayer.enable();
|
||||
|
@ -76,6 +76,8 @@ const LIMIT = 15;
|
||||
let sourceParam;
|
||||
let utmSourceParam;
|
||||
let utmMediumParam;
|
||||
let referrerSource;
|
||||
|
||||
try {
|
||||
// Fetch source/medium from query param
|
||||
const url = new URL(window.location.href);
|
||||
@ -83,11 +85,23 @@ const LIMIT = 15;
|
||||
sourceParam = url.searchParams.get('source');
|
||||
utmSourceParam = url.searchParams.get('utm_source');
|
||||
utmMediumParam = url.searchParams.get('utm_medium');
|
||||
|
||||
referrerSource = refParam || sourceParam || utmSourceParam || null;
|
||||
|
||||
// if referrerSource is not set, check to see if the url contains a hash like ghost.org/#/portal/signup?ref=ghost and pull the ref from the hash
|
||||
if (!referrerSource && url.hash && url.hash.includes('#/portal')) {
|
||||
const hashUrl = new URL(window.location.href.replace('/#/portal', ''));
|
||||
refParam = hashUrl.searchParams.get('ref');
|
||||
sourceParam = hashUrl.searchParams.get('source');
|
||||
utmSourceParam = hashUrl.searchParams.get('utm_source');
|
||||
utmMediumParam = hashUrl.searchParams.get('utm_medium');
|
||||
|
||||
referrerSource = refParam || sourceParam || utmSourceParam || null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Member Attribution] Parsing referrer from querystring failed', e);
|
||||
}
|
||||
|
||||
const referrerSource = refParam || sourceParam || utmSourceParam || null;
|
||||
const referrerMedium = utmMediumParam || null;
|
||||
const referrerUrl = window.document.referrer || null;
|
||||
|
||||
|
@ -4,7 +4,7 @@ const debug = require('@tryghost/debug')('api:endpoints:utils:serializers:output
|
||||
|
||||
const messages = {
|
||||
checkEmailForInstructions: 'Check your email for further instructions.',
|
||||
passwordChanged: 'Password changed successfully.',
|
||||
passwordChanged: 'Password updated',
|
||||
invitationAccepted: 'Invitation accepted.'
|
||||
};
|
||||
|
||||
|
@ -211,6 +211,9 @@ class CustomRedirectsAPI {
|
||||
*/
|
||||
async setFromFilePath(filePath, ext = '.json') {
|
||||
const redirectsFilePath = await this.getRedirectsFilePath();
|
||||
const content = await readRedirectsFile(filePath);
|
||||
const parsed = parseRedirectsFile(content, ext);
|
||||
this.validate(parsed);
|
||||
|
||||
if (redirectsFilePath) {
|
||||
const backupRedirectsPath = this.getBackupFilePath(redirectsFilePath);
|
||||
@ -223,10 +226,6 @@ class CustomRedirectsAPI {
|
||||
await fs.move(redirectsFilePath, backupRedirectsPath);
|
||||
}
|
||||
|
||||
const content = await readRedirectsFile(filePath);
|
||||
const parsed = parseRedirectsFile(content, ext);
|
||||
this.validate(parsed);
|
||||
|
||||
if (ext === '.json') {
|
||||
await fs.writeFile(this.createRedirectsFilePath('.json'), JSON.stringify(parsed), 'utf-8');
|
||||
} else if (ext === '.yaml') {
|
||||
|
@ -1,6 +1,7 @@
|
||||
const _ = require('lodash');
|
||||
const tpl = require('@tryghost/tpl');
|
||||
const errors = require('@tryghost/errors');
|
||||
const {isSafePattern} = require('redos-detector');
|
||||
|
||||
const messages = {
|
||||
redirectsWrongFormat: 'Incorrect redirects file format.',
|
||||
@ -33,18 +34,35 @@ const validate = (redirects) => {
|
||||
if (!redirect.from || !redirect.to) {
|
||||
throw new errors.ValidationError({
|
||||
message: tpl(messages.redirectsWrongFormat),
|
||||
context: redirect,
|
||||
help: tpl(messages.redirectsHelp)
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure valid regex
|
||||
try {
|
||||
// each 'from' property should be a valid RegExp string
|
||||
new RegExp(redirect.from);
|
||||
} catch (error) {
|
||||
throw new errors.ValidationError({
|
||||
message: tpl(messages.invalidRedirectsFromRegex),
|
||||
context: redirect,
|
||||
errorDetails: {
|
||||
redirect,
|
||||
invalid: true
|
||||
},
|
||||
help: tpl(messages.redirectsHelp)
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure safe regex
|
||||
const analysis = isSafePattern(redirect.from);
|
||||
|
||||
if (analysis.safe === false) {
|
||||
throw new errors.ValidationError({
|
||||
message: tpl(messages.invalidRedirectsFromRegex),
|
||||
errorDetails: {
|
||||
redirect,
|
||||
unsafe: true,
|
||||
reason: analysis.error
|
||||
},
|
||||
help: tpl(messages.redirectsHelp)
|
||||
});
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
module.exports = require('./service');
|
@ -1,38 +0,0 @@
|
||||
const {Server} = require('socket.io');
|
||||
const debug = require('@tryghost/debug')('websockets');
|
||||
const logging = require('@tryghost/logging');
|
||||
|
||||
const labs = require('../../../shared/labs');
|
||||
|
||||
module.exports = {
|
||||
async init(ghostServer) {
|
||||
debug(`[Websockets] Is labs set? ${labs.isSet('websockets')}`);
|
||||
|
||||
if (labs.isSet('websockets')) {
|
||||
logging.info(`Starting websockets service`);
|
||||
|
||||
const io = new Server(ghostServer.httpServer);
|
||||
let count = 0;
|
||||
|
||||
io.on(`connection`, (socket) => {
|
||||
logging.info(`Websockets client connected (id: ${socket.id})`);
|
||||
|
||||
// on connect, send current value
|
||||
socket.emit('addCount', count);
|
||||
// listen to to changes in value from client
|
||||
socket.on('addCount', () => {
|
||||
count = count + 1;
|
||||
debug(`[Websockets] received addCount from client, count is now ${count}`);
|
||||
socket.broadcast.emit('addCount', count);
|
||||
});
|
||||
});
|
||||
|
||||
ghostServer.registerCleanupTask(async () => {
|
||||
logging.warn(`Stopping websockets service`);
|
||||
await new Promise((resolve) => {
|
||||
io.close(resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
@ -22,10 +22,7 @@ const GA_FEATURES = [
|
||||
'signupForm',
|
||||
'recommendations',
|
||||
'listUnsubscribeHeader',
|
||||
'filterEmailDisabled',
|
||||
'newEmailAddresses',
|
||||
'portalImprovements',
|
||||
'newsletterExcerpt',
|
||||
'internalLinking'
|
||||
];
|
||||
|
||||
@ -45,7 +42,6 @@ const ALPHA_FEATURES = [
|
||||
'NestPlayground',
|
||||
'urlCache',
|
||||
'lexicalMultiplayer',
|
||||
'websockets',
|
||||
'emailCustomization',
|
||||
'mailEvents',
|
||||
'collectionsCard',
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghost",
|
||||
"version": "5.87.0",
|
||||
"version": "5.87.1",
|
||||
"description": "The professional publishing platform",
|
||||
"author": "Ghost Foundation",
|
||||
"homepage": "https://ghost.org",
|
||||
@ -111,9 +111,9 @@
|
||||
"@tryghost/kg-converters": "1.0.5",
|
||||
"@tryghost/kg-default-atoms": "5.0.3",
|
||||
"@tryghost/kg-default-cards": "10.0.6",
|
||||
"@tryghost/kg-default-nodes": "1.1.5",
|
||||
"@tryghost/kg-html-to-lexical": "1.1.5",
|
||||
"@tryghost/kg-lexical-html-renderer": "1.1.6",
|
||||
"@tryghost/kg-default-nodes": "1.1.7",
|
||||
"@tryghost/kg-html-to-lexical": "1.1.8",
|
||||
"@tryghost/kg-lexical-html-renderer": "1.1.8",
|
||||
"@tryghost/kg-mobiledoc-html-renderer": "7.0.4",
|
||||
"@tryghost/limit-service": "1.2.14",
|
||||
"@tryghost/link-redirects": "0.0.0",
|
||||
@ -133,7 +133,7 @@
|
||||
"@tryghost/members-ssr": "0.0.0",
|
||||
"@tryghost/members-stripe-service": "0.0.0",
|
||||
"@tryghost/mentions-email-report": "0.0.0",
|
||||
"@tryghost/metrics": "1.0.31",
|
||||
"@tryghost/metrics": "1.0.34",
|
||||
"@tryghost/milestones": "0.0.0",
|
||||
"@tryghost/minifier": "0.0.0",
|
||||
"@tryghost/model-to-domain-event-interceptor": "0.0.0",
|
||||
@ -208,7 +208,7 @@
|
||||
"keypair": "1.0.4",
|
||||
"knex": "2.4.2",
|
||||
"knex-migrator": "5.2.1",
|
||||
"lib0": "0.2.88",
|
||||
"lib0": "0.2.94",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.4.4",
|
||||
"moment": "2.24.0",
|
||||
@ -219,13 +219,13 @@
|
||||
"node-jose": "2.2.0",
|
||||
"path-match": "1.2.4",
|
||||
"probe-image-size": "7.2.3",
|
||||
"redos-detector": "5.1.0",
|
||||
"rss": "1.2.2",
|
||||
"sanitize-html": "2.13.0",
|
||||
"semver": "7.6.2",
|
||||
"socket.io": "4.7.5",
|
||||
"stoppable": "1.1.0",
|
||||
"uuid": "9.0.1",
|
||||
"ws": "8.17.1",
|
||||
"ws": "8.18.0",
|
||||
"xml": "1.0.1",
|
||||
"y-protocols": "1.0.6",
|
||||
"yjs": "13.6.18"
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1155,7 +1155,7 @@ exports[`Settings API Edit Can edit a setting 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "4614",
|
||||
"content-length": "4530",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -260,7 +260,7 @@ describe('Oembed API', function () {
|
||||
|
||||
const pageMock = nock('http://oembed.test.com')
|
||||
.get('/')
|
||||
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://oembed.test.com/my-embed"></head></html>');
|
||||
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://oembed.test.com/my-embed"><title>Title</title></head></html>');
|
||||
|
||||
const oembedMock = nock('http://oembed.test.com')
|
||||
.get('/my-embed')
|
||||
@ -284,7 +284,7 @@ describe('Oembed API', function () {
|
||||
it('fetches url and follows <link rel="alternate">', async function () {
|
||||
const pageMock = nock('http://test.com')
|
||||
.get('/')
|
||||
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://test.com/oembed"></head></html>');
|
||||
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://test.com/oembed"><title>Title</title></head></html>');
|
||||
|
||||
const oembedMock = nock('http://test.com')
|
||||
.get('/oembed')
|
||||
@ -307,7 +307,7 @@ describe('Oembed API', function () {
|
||||
it('follows redirects when fetching <link rel="alternate">', async function () {
|
||||
const pageMock = nock('http://test.com')
|
||||
.get('/')
|
||||
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://test.com/oembed"></head></html>');
|
||||
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://test.com/oembed"><title>Title</title></head></html>');
|
||||
|
||||
const alternateRedirectMock = nock('http://test.com')
|
||||
.get('/oembed')
|
||||
|
@ -221,7 +221,7 @@ describe('Members API', function () {
|
||||
|
||||
let canceledPaidMember;
|
||||
|
||||
it('Handles cancellation of paid subscriptions correctly', async function () {
|
||||
it('Handles cancellation of paid subscriptions at the end of the billing cycle', async function () {
|
||||
const customer_id = createStripeID('cust');
|
||||
const subscription_id = createStripeID('sub');
|
||||
|
||||
@ -256,8 +256,143 @@ describe('Members API', function () {
|
||||
// Create a new customer in Stripe
|
||||
set(customer, {
|
||||
id: customer_id,
|
||||
name: 'Test Member',
|
||||
email: 'cancel-paid-test@email.com',
|
||||
name: 'Cancel me at the end of the billing cycle',
|
||||
email: 'cancel-me-at-the-end-of-cycle@test.com',
|
||||
subscriptions: {
|
||||
type: 'list',
|
||||
data: [subscription]
|
||||
}
|
||||
});
|
||||
|
||||
// Make sure this customer has a corresponding member in the database
|
||||
// And all the subscriptions are setup correctly
|
||||
const initialMember = await createMemberFromStripe();
|
||||
assert.equal(initialMember.status, 'paid', 'The member initial status should be paid');
|
||||
assert.equal(initialMember.tiers.length, 1, 'The member should have one tier');
|
||||
should(initialMember.subscriptions).match([
|
||||
{
|
||||
status: 'active'
|
||||
}
|
||||
]);
|
||||
|
||||
// Check whether MRR and status has been set
|
||||
await assertSubscription(initialMember.subscriptions[0].id, {
|
||||
subscription_id: subscription.id,
|
||||
status: 'active',
|
||||
cancel_at_period_end: false,
|
||||
plan_amount: 500,
|
||||
plan_interval: 'month',
|
||||
plan_currency: 'usd',
|
||||
mrr: 500
|
||||
});
|
||||
|
||||
// Set the subscription to cancel at the end of the period
|
||||
set(subscription, {
|
||||
...subscription,
|
||||
status: 'active',
|
||||
cancel_at_period_end: true,
|
||||
metadata: {
|
||||
cancellation_reason: 'I want to break free'
|
||||
}
|
||||
});
|
||||
|
||||
// Send the webhook call to announce the cancelation
|
||||
const webhookPayload = JSON.stringify({
|
||||
type: 'customer.subscription.updated',
|
||||
data: {
|
||||
object: subscription
|
||||
}
|
||||
});
|
||||
const webhookSignature = stripe.webhooks.generateTestHeaderString({
|
||||
payload: webhookPayload,
|
||||
secret: process.env.WEBHOOK_SECRET
|
||||
});
|
||||
|
||||
await membersAgent.post('/webhooks/stripe/')
|
||||
.body(webhookPayload)
|
||||
.header('content-type', 'application/json')
|
||||
.header('stripe-signature', webhookSignature)
|
||||
.expectStatus(200);
|
||||
|
||||
// Check that the subscription has been set to cancel and has saved the cancellation reason
|
||||
const {body: body2} = await adminAgent.get('/members/' + initialMember.id + '/');
|
||||
assert.equal(body2.members.length, 1, 'The member does not exist');
|
||||
const updatedMember = body2.members[0];
|
||||
should(updatedMember.subscriptions).match([
|
||||
{
|
||||
status: 'active',
|
||||
cancel_at_period_end: true,
|
||||
cancellation_reason: 'I want to break free'
|
||||
}
|
||||
]);
|
||||
|
||||
// Check whether MRR and cancel_at_period_end has been set
|
||||
await assertSubscription(initialMember.subscriptions[0].id, {
|
||||
subscription_id: subscription.id,
|
||||
status: 'active',
|
||||
cancel_at_period_end: true,
|
||||
plan_amount: 500,
|
||||
plan_interval: 'month',
|
||||
plan_currency: 'usd',
|
||||
mrr: 0
|
||||
});
|
||||
|
||||
// Check that there is a canceled event
|
||||
await assertMemberEvents({
|
||||
eventType: 'MemberPaidSubscriptionEvent',
|
||||
memberId: updatedMember.id,
|
||||
asserts: [
|
||||
{
|
||||
type: 'created',
|
||||
mrr_delta: 500
|
||||
},
|
||||
{
|
||||
type: 'canceled',
|
||||
mrr_delta: -500
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
canceledPaidMember = updatedMember;
|
||||
});
|
||||
|
||||
it('Handles immediate cancellation of paid subscriptions', async function () {
|
||||
const customer_id = createStripeID('cust');
|
||||
const subscription_id = createStripeID('sub');
|
||||
|
||||
// Create a new subscription in Stripe
|
||||
set(subscription, {
|
||||
id: subscription_id,
|
||||
customer: customer_id,
|
||||
status: 'active',
|
||||
items: {
|
||||
type: 'list',
|
||||
data: [{
|
||||
id: 'item_123',
|
||||
price: {
|
||||
id: 'price_123',
|
||||
product: 'product_123',
|
||||
active: true,
|
||||
nickname: 'Monthly',
|
||||
currency: 'usd',
|
||||
recurring: {
|
||||
interval: 'month'
|
||||
},
|
||||
unit_amount: 500,
|
||||
type: 'recurring'
|
||||
}
|
||||
}]
|
||||
},
|
||||
start_date: Date.now() / 1000,
|
||||
current_period_end: Date.now() / 1000 + (60 * 60 * 24 * 31),
|
||||
cancel_at_period_end: false
|
||||
});
|
||||
|
||||
// Create a new customer in Stripe
|
||||
set(customer, {
|
||||
id: customer_id,
|
||||
name: 'Cancel me now',
|
||||
email: 'cancel-me-immediately@test.com',
|
||||
subscriptions: {
|
||||
type: 'list',
|
||||
data: [subscription]
|
||||
@ -289,12 +424,15 @@ describe('Members API', function () {
|
||||
// Cancel the previously created subscription in Stripe
|
||||
set(subscription, {
|
||||
...subscription,
|
||||
status: 'canceled'
|
||||
status: 'canceled',
|
||||
cancellation_details: {
|
||||
reason: 'payment_failed'
|
||||
}
|
||||
});
|
||||
|
||||
// Send the webhook call to announce the cancelation
|
||||
const webhookPayload = JSON.stringify({
|
||||
type: 'customer.subscription.updated',
|
||||
type: 'customer.subscription.deleted',
|
||||
data: {
|
||||
object: subscription
|
||||
}
|
||||
@ -318,7 +456,8 @@ describe('Members API', function () {
|
||||
assert.equal(updatedMember.tiers.length, 0, 'The member should have no products');
|
||||
should(updatedMember.subscriptions).match([
|
||||
{
|
||||
status: 'canceled'
|
||||
status: 'canceled',
|
||||
cancellation_reason: 'Payment failed'
|
||||
}
|
||||
]);
|
||||
|
||||
|
@ -400,5 +400,25 @@ describe('Member Attribution Service', function () {
|
||||
referrerUrl: null
|
||||
}));
|
||||
});
|
||||
|
||||
it('resolves Portal signup URLs', async function () {
|
||||
// NOTE: We cannot test the actual hash URL here; the attribution below is what is receieved when navigating to /#/portal/signup?ref=ghost
|
||||
// TODO: We don't appear to have tests for parsing URLs for params.
|
||||
const attribution = await memberAttributionService.service.getAttribution([
|
||||
{
|
||||
path: '/',
|
||||
time: Date.now(),
|
||||
referrerSource: 'casper'
|
||||
}
|
||||
]);
|
||||
attribution.should.match(({
|
||||
id: null,
|
||||
url: '/',
|
||||
type: 'url',
|
||||
referrerSource: 'casper',
|
||||
referrerMedium: null,
|
||||
referrerUrl: null
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -754,7 +754,7 @@ exports[`Authentication API Password reset reset password 1: [body] 1`] = `
|
||||
Object {
|
||||
"password_reset": Array [
|
||||
Object {
|
||||
"message": "Password changed successfully.",
|
||||
"message": "Password updated",
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -764,7 +764,7 @@ exports[`Authentication API Password reset reset password 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "65",
|
||||
"content-length": "51",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
|
@ -259,5 +259,48 @@ describe('UNIT: redirects CustomRedirectsAPI class', function () {
|
||||
// two redirects in total
|
||||
redirectManager.addRedirect.calledTwice.should.be.true();
|
||||
});
|
||||
|
||||
it('does not create a backup file from a bad redirect yaml file', async function () {
|
||||
const incomingFilePath = path.join(__dirname, '/invalid/path/redirects_incoming.yaml');
|
||||
const backupFilePath = path.join(basePath, 'backup.yaml');
|
||||
|
||||
const invalidYaml = `
|
||||
301:
|
||||
/my-old-blog-post/: /revamped-url/
|
||||
/my-old-blog-post/: /revamped-url/
|
||||
|
||||
302:
|
||||
/another-old-blog-post/: /hello-there/
|
||||
`;
|
||||
|
||||
// redirects.json file already exits
|
||||
fs.pathExists.withArgs(`${basePath}redirects.json`).resolves(false);
|
||||
fs.pathExists.withArgs(`${basePath}redirects.yaml`).resolves(true);
|
||||
// incoming redirects file
|
||||
fs.readFile.withArgs(incomingFilePath, 'utf-8').resolves(invalidYaml);
|
||||
// backup file DOES not exists yet
|
||||
fs.pathExists.withArgs(backupFilePath).resolves(false);
|
||||
// should not be called
|
||||
fs.unlink.withArgs(backupFilePath).resolves(false);
|
||||
fs.move.withArgs(`${basePath}redirects.yaml`, backupFilePath).resolves(true);
|
||||
|
||||
customRedirectsAPI = new CustomRedirectsAPI({
|
||||
basePath,
|
||||
redirectManager,
|
||||
getBackupFilePath: () => backupFilePath,
|
||||
validate: () => {}
|
||||
});
|
||||
|
||||
try {
|
||||
await customRedirectsAPI.setFromFilePath(incomingFilePath, '.yaml');
|
||||
should.fail('setFromFilePath did not throw');
|
||||
} catch (err) {
|
||||
should.exist(err);
|
||||
err.errorType.should.eql('BadRequestError');
|
||||
}
|
||||
|
||||
fs.unlink.called.should.not.be.true();
|
||||
fs.move.called.should.not.be.true();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -39,6 +39,26 @@ describe('UNIT: custom redirects validation', function () {
|
||||
should.fail('should have thrown');
|
||||
} catch (err) {
|
||||
err.message.should.equal('Incorrect RegEx in redirects file.');
|
||||
err.errorDetails.redirect.should.equal(config[0]);
|
||||
err.errorDetails.invalid.should.be.true();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws for an invalid redirects config having unsafe RegExp in from field', function () {
|
||||
const config = [{
|
||||
permanent: true,
|
||||
from: '^\/episodes\/([a-z0-9-]+)+\/$', // Unsafe due to the surplus + at the end causing infinite backtracking
|
||||
to: '/'
|
||||
}];
|
||||
|
||||
try {
|
||||
validate(config);
|
||||
should.fail('should have thrown');
|
||||
} catch (err) {
|
||||
err.message.should.equal('Incorrect RegEx in redirects file.');
|
||||
err.errorDetails.redirect.should.equal(config[0]);
|
||||
err.errorDetails.unsafe.should.be.true();
|
||||
err.errorDetails.reason.should.equal('hitMaxBacktracks');
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -772,13 +772,8 @@ class EmailRenderer {
|
||||
registerHelpers(this.#handlebars, labs);
|
||||
|
||||
// Partials
|
||||
if (this.#labs.isSet('emailCustomization')) {
|
||||
const cssPartialSource = await fs.readFile(path.join(__dirname, './email-templates/partials/', `styles.hbs`), 'utf8');
|
||||
this.#handlebars.registerPartial('styles', cssPartialSource);
|
||||
} else {
|
||||
const cssPartialSource = await fs.readFile(path.join(__dirname, './email-templates/partials/', `styles-old.hbs`), 'utf8');
|
||||
this.#handlebars.registerPartial('styles', cssPartialSource);
|
||||
}
|
||||
const cssPartialSource = await fs.readFile(path.join(__dirname, './email-templates/partials/', `styles.hbs`), 'utf8');
|
||||
this.#handlebars.registerPartial('styles', cssPartialSource);
|
||||
|
||||
const paywallPartial = await fs.readFile(path.join(__dirname, './email-templates/partials/', `paywall.hbs`), 'utf8');
|
||||
this.#handlebars.registerPartial('paywall', paywallPartial);
|
||||
@ -793,13 +788,9 @@ class EmailRenderer {
|
||||
this.#handlebars.registerPartial('latestPosts', latestPostsPartial);
|
||||
|
||||
// Actual template
|
||||
if (this.#labs.isSet('emailCustomization')) {
|
||||
const htmlTemplateSource = await fs.readFile(path.join(__dirname, './email-templates/', `template.hbs`), 'utf8');
|
||||
this.#renderTemplate = this.#handlebars.compile(Buffer.from(htmlTemplateSource).toString());
|
||||
} else {
|
||||
const htmlTemplateSource = await fs.readFile(path.join(__dirname, './email-templates/', `template-old.hbs`), 'utf8');
|
||||
this.#renderTemplate = this.#handlebars.compile(Buffer.from(htmlTemplateSource).toString());
|
||||
}
|
||||
const htmlTemplateSource = await fs.readFile(path.join(__dirname, './email-templates/', `template.hbs`), 'utf8');
|
||||
this.#renderTemplate = this.#handlebars.compile(Buffer.from(htmlTemplateSource).toString());
|
||||
|
||||
return this.#renderTemplate(data);
|
||||
}
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,251 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch><o:AllowPNG/></o:OfficeDocumentSettings></xml><![endif]-->
|
||||
<title>{{post.title}}</title>
|
||||
{{>styles}}
|
||||
</head>
|
||||
<body>
|
||||
<span class="preheader">{{preheader}}</span>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body" width="100%">
|
||||
<!-- Outlook doesn't respect max-width so we need an extra centered table -->
|
||||
<!--[if mso]>
|
||||
<tr>
|
||||
<td>
|
||||
<center>
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="600">
|
||||
<![endif]-->
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class="container">
|
||||
<div class="content">
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main" width="100%">
|
||||
|
||||
<!-- START MAIN CONTENT AREA -->
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
{{#if headerImage}}
|
||||
<tr class="header-image-row">
|
||||
<td class="header-image" width="100%" align="center">
|
||||
<a href="{{site.url}}">
|
||||
<img
|
||||
src="{{headerImage}}"
|
||||
{{#if headerImageWidth}}
|
||||
width="{{headerImageWidth}}"
|
||||
{{/if}}
|
||||
>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
|
||||
{{#if (or showHeaderIcon showHeaderTitle showHeaderName) }}
|
||||
<tr class="site-info-row">
|
||||
<td class="site-info" width="100%" align="center">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
{{#if (and showHeaderIcon site.iconUrl) }}
|
||||
<tr>
|
||||
<td class="site-icon"><a href="{{site.url}}"><img src="{{site.iconUrl}}" alt="{{site.title}}" border="0" width="48" height="48"></a></td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{#if showHeaderTitle }}
|
||||
<tr>
|
||||
<td class="site-url {{#unless showHeaderName}}site-url-bottom-padding{{/unless}}"><div style="width: 100% !important;"><a href="{{site.url}}" class="site-title">{{site.title}}</a></div></td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{#if (and showHeaderName showHeaderTitle) }}
|
||||
<tr>
|
||||
<td class="site-url site-url-bottom-padding"><div style="width: 100% !important;"><a href="{{site.url}}" class="site-subtitle">{{newsletter.name}}</a></div></td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{#if (and showHeaderName (not showHeaderTitle)) }}
|
||||
<tr>
|
||||
<td class="site-url site-url-bottom-padding"><div style="width: 100% !important;"><a href="{{site.url}}" class="site-title">{{newsletter.name}}</a></div></td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
|
||||
{{#if newsletter.showPostTitleSection}}
|
||||
<tr>
|
||||
<td class="{{classes.title}}">
|
||||
<a href="{{post.url}}" class="{{classes.titleLink}}">{{post.title}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{#hasFeature 'newsletterExcerpt'}}
|
||||
{{#if (and newsletter.showExcerpt post.customExcerpt)}}
|
||||
<tr>
|
||||
<td class="post-excerpt-wrapper" style="width: 100%">
|
||||
<p class="{{classes.excerpt}}">{{post.customExcerpt}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{/hasFeature}}
|
||||
<tr>
|
||||
<td style="width: 100%">
|
||||
<table class="post-meta-wrapper" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="padding-bottom: 32px;">
|
||||
<tr>
|
||||
<td height="20" class="{{classes.meta}}" style="padding: 0;">
|
||||
By {{post.authors}} • <span class="post-meta-date">{{post.publishedAt}} </span>
|
||||
</td>
|
||||
<td class="{{classes.meta}} view-online desktop">
|
||||
<a href="{{post.url}}" class="view-online-link">View in browser</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="{{classes.meta}} view-online-mobile">
|
||||
<td height="20" class="view-online">
|
||||
<a href="{{post.url}}" class="view-online-link">View in browser</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
|
||||
{{#if showFeatureImage }}
|
||||
<tr class="feature-image-row">
|
||||
<td class="feature-image
|
||||
{{#if post.feature_image_caption }}
|
||||
feature-image-with-caption
|
||||
{{/if}}
|
||||
" align="center"><img
|
||||
src="{{post.feature_image}}"
|
||||
{{#if post.feature_image_width }}
|
||||
width="{{post.feature_image_width}}"
|
||||
{{/if}}
|
||||
{{#if post.feature_image_alt }}
|
||||
alt="{{post.feature_image_alt}}"
|
||||
{{/if}}
|
||||
></td>
|
||||
</tr>
|
||||
|
||||
{{#if post.feature_image_caption }}
|
||||
<tr>
|
||||
<td align="center">
|
||||
<div class="feature-image-caption">
|
||||
{{{post.feature_image_caption}}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<tr class="post-content-row">
|
||||
<td class="{{classes.body}}">
|
||||
<!-- POST CONTENT START -->
|
||||
{{{html}}}
|
||||
<!-- POST CONTENT END -->
|
||||
|
||||
{{#if paywall}}
|
||||
{{>paywall}}
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- END MAIN CONTENT AREA -->
|
||||
|
||||
{{#if (or feedbackButtons newsletter.showCommentCta) }}
|
||||
<tr>
|
||||
<td dir="ltr" width="100%" style="background-color: #ffffff; text-align: center; padding: 32px 0 24px; border-bottom: 1px solid #e5eff5;" align="center">
|
||||
<table class="feedback-buttons" role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
{{#if feedbackButtons }}
|
||||
{{> feedbackButton feedbackButtons href=feedbackButtons.likeHref buttonText='More like this' iconUrl="https://static.ghost.org/v5.0.0/images/more-like-this.png" width="145" height="36" }}
|
||||
{{> feedbackButton feedbackButtons href=feedbackButtons.dislikeHref buttonText='Less like this' iconUrl="https://static.ghost.org/v5.0.0/images/less-like-this.png" width="142" height="36" }}
|
||||
{{/if}}
|
||||
{{#if newsletter.showCommentCta}}
|
||||
{{> feedbackButton href=post.commentUrl buttonText='Comment' iconUrl="https://static.ghost.org/v5.0.0/images/comment.png" width="122" height="36" }}
|
||||
{{/if}}
|
||||
</tr>
|
||||
</table>
|
||||
<table class="feedback-buttons-mobile" role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
{{#if feedbackButtons }}
|
||||
{{> feedbackButtonMobile feedbackButtons href=feedbackButtons.likeHref buttonText='More like this' iconUrl="https://static.ghost.org/v5.0.0/images/more-like-this-mobile.png" width="42" height="42" }}
|
||||
{{> feedbackButtonMobile feedbackButtons href=feedbackButtons.dislikeHref buttonText='Less like this' iconUrl="https://static.ghost.org/v5.0.0/images/less-like-this-mobile.png" width="42" height="42" }}
|
||||
{{/if}}
|
||||
{{#if newsletter.showCommentCta}}
|
||||
{{> feedbackButtonMobile href=post.commentUrl buttonText='Comment' iconUrl="https://static.ghost.org/v5.0.0/images/comment-mobile.png" width="42" height="42" }}
|
||||
{{/if}}
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
|
||||
{{#if latestPosts.length}}
|
||||
<tr>
|
||||
<td style="padding: 24px 0; border-bottom: 1px solid #e5eff5;">
|
||||
<h3 class="latest-posts-header">Keep reading</h3>
|
||||
{{> latestPosts}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
|
||||
{{#if newsletter.showSubscriptionDetails}}
|
||||
<tr>
|
||||
<td class="subscription-box">
|
||||
<h3>Subscription details</h3>
|
||||
<p style="margin-bottom: 16px;">
|
||||
<span>You are receiving this because you are a <strong>%%{status}%% subscriber</strong> to {{site.title}}.</span> %%{status_text}%%
|
||||
</p>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td class="subscription-details">
|
||||
<p class="%%{name_class}%%">Name: %%{name, "not provided"}%%</p>
|
||||
<p>Email: <a href="#">%%{email}%%</a></p>
|
||||
<p>Member since: %%{created_at}%%</p>
|
||||
</td>
|
||||
<td align="right" valign="bottom" class="manage-subscription">
|
||||
<a href="%%{manage_account_url}%%"> Manage subscription →</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
|
||||
<tr>
|
||||
<td class="wrapper" align="center">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="padding-top: 40px; padding-bottom: 30px;">
|
||||
{{#if footerContent }}
|
||||
<tr><td class="footer">{{{footerContent}}}</td></tr>
|
||||
{{/if}}
|
||||
<tr>
|
||||
<td class="footer">{{site.title}} © {{year}} – <a href="%%{unsubscribe_url}%%">Unsubscribe</a></td>
|
||||
</tr>
|
||||
|
||||
{{#if showBadge }}
|
||||
<tr>
|
||||
<td class="footer-powered"><a href="https://ghost.org/?via=pbg-newsletter"><img src="https://static.ghost.org/v4.0.0/images/powered.png" border="0" width="142" height="30" class="gh-powered" alt="Powered by Ghost"></a></td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
<!-- END CENTERED WHITE CONTAINER -->
|
||||
</div>
|
||||
</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
|
||||
<!--[if mso]>
|
||||
</table>
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
<![endif]-->
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
@ -22,7 +22,7 @@
|
||||
<td class="container">
|
||||
<div class="content">
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="20" class="main" width="100%">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main" width="100%">
|
||||
|
||||
<!-- START MAIN CONTENT AREA -->
|
||||
<tr>
|
||||
@ -44,12 +44,12 @@
|
||||
{{/if}}
|
||||
|
||||
{{#if (or showHeaderIcon showHeaderTitle showHeaderName) }}
|
||||
<tr>
|
||||
<td class="{{#if (and newsletter.showPostTitleSection showHeaderTitle) }}site-info-bordered{{else}}site-info{{/if}}" width="100%" align="center">
|
||||
<tr class="site-info-row">
|
||||
<td class="site-info" width="100%" align="center">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
{{#if (and showHeaderIcon site.iconUrl) }}
|
||||
<tr>
|
||||
<td class="site-icon"><a href="{{site.url}}"><img src="{{site.iconUrl}}" alt="{{site.title}}" border="0"></a></td>
|
||||
<td class="site-icon"><a href="{{site.url}}"><img src="{{site.iconUrl}}" alt="{{site.title}}" border="0" width="48" height="48"></a></td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{#if showHeaderTitle }}
|
||||
@ -79,9 +79,16 @@
|
||||
<a href="{{post.url}}" class="{{classes.titleLink}}">{{post.title}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{#if (and newsletter.showExcerpt post.customExcerpt)}}
|
||||
<tr>
|
||||
<td class="post-excerpt-wrapper" style="width: 100%">
|
||||
<p class="{{classes.excerpt}}">{{post.customExcerpt}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
<tr>
|
||||
<td style="width: 100%">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="padding-bottom: 48px;">
|
||||
<table class="post-meta-wrapper" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="padding-bottom: 32px;">
|
||||
<tr>
|
||||
<td height="20" class="{{classes.meta}}" style="padding: 0;">
|
||||
By {{post.authors}} • <span class="post-meta-date">{{post.publishedAt}} </span>
|
||||
@ -116,9 +123,14 @@
|
||||
{{/if}}
|
||||
></td>
|
||||
</tr>
|
||||
{{#if post.feature_image_caption }}
|
||||
|
||||
{{#if post.feature_image_caption }}
|
||||
<tr>
|
||||
<td class="feature-image-caption" align="center">{{{post.feature_image_caption}}}</td>
|
||||
<td align="center">
|
||||
<div class="feature-image-caption">
|
||||
{{{post.feature_image_caption}}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
@ -141,7 +153,7 @@
|
||||
|
||||
{{#if (or feedbackButtons newsletter.showCommentCta) }}
|
||||
<tr>
|
||||
<td dir="ltr" width="100%" style="background-color: {{backgroundColor}}; text-align: center; padding: 32px 0 24px; border-bottom: 1px solid #e5eff5; border-bottom: 1px solid {{secondaryBorderColor}};" align="center">
|
||||
<td dir="ltr" width="100%" style="background-color: #ffffff; text-align: center; padding: 32px 0 24px; border-bottom: 1px solid #e5eff5;" align="center">
|
||||
<table class="feedback-buttons" role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
{{#if feedbackButtons }}
|
||||
@ -170,7 +182,7 @@
|
||||
|
||||
{{#if latestPosts.length}}
|
||||
<tr>
|
||||
<td style="padding: 24px 0; border-bottom: 1px solid #e5eff5; border-bottom: 1px solid {{secondaryBorderColor}};">
|
||||
<td style="padding: 24px 0; border-bottom: 1px solid #e5eff5;">
|
||||
<h3 class="latest-posts-header">Keep reading</h3>
|
||||
{{> latestPosts}}
|
||||
</td>
|
||||
|
@ -1770,12 +1770,6 @@ describe('Email renderer', function () {
|
||||
});
|
||||
|
||||
describe('show excerpt', function () {
|
||||
beforeEach(function () {
|
||||
labsEnabled = {
|
||||
newsletterExcerpt: true
|
||||
};
|
||||
});
|
||||
|
||||
it('is rendered when enabled and customExcerpt is present', async function () {
|
||||
const post = createModel(Object.assign({}, basePost, {custom_excerpt: 'This is an excerpt'}));
|
||||
const newsletter = createModel({
|
||||
|
@ -20,23 +20,22 @@
|
||||
"build"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "10.3.9",
|
||||
"@nestjs/testing": "10.3.10",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/sinon": "^17.0.3",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"c8": "8.0.1",
|
||||
"mocha": "10.2.0",
|
||||
"nock": "^14.0.0-beta.6",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"sinon": "^17.0.1",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "10.3.9",
|
||||
"@nestjs/core": "10.3.9",
|
||||
"@nestjs/platform-express": "10.3.9",
|
||||
"@nestjs/common": "10.3.10",
|
||||
"@nestjs/core": "10.3.10",
|
||||
"@nestjs/platform-express": "10.3.10",
|
||||
"@tryghost/errors": "1.3.2",
|
||||
"bson-objectid": "2.0.4",
|
||||
"express": "4.19.2",
|
||||
|
@ -105,9 +105,6 @@ class LinkClickTrackingService {
|
||||
* @throws {errors.BadRequestError}
|
||||
*/
|
||||
#parseLinkFilter(filter) {
|
||||
// decode filter to manage any encoded uri components
|
||||
filter = decodeURIComponent(filter);
|
||||
|
||||
try {
|
||||
const filterJson = nql(filter).parse();
|
||||
const postId = filterJson?.$and?.[0]?.post_id;
|
||||
|
@ -26,7 +26,7 @@
|
||||
"dependencies": {
|
||||
"@tryghost/debug": "0.1.30",
|
||||
"@tryghost/logging": "2.4.15",
|
||||
"@tryghost/metrics": "1.0.31",
|
||||
"@tryghost/metrics": "1.0.34",
|
||||
"form-data": "4.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"mailgun.js": "10.2.1"
|
||||
|
@ -992,7 +992,7 @@ module.exports = class MemberRepository {
|
||||
subscription_id: subscription.id,
|
||||
status: subscription.status,
|
||||
cancel_at_period_end: subscription.cancel_at_period_end,
|
||||
cancellation_reason: subscription.metadata && subscription.metadata.cancellation_reason || null,
|
||||
cancellation_reason: this.getCancellationReason(subscription),
|
||||
current_period_end: new Date(subscription.current_period_end * 1000),
|
||||
start_date: new Date(subscription.start_date * 1000),
|
||||
default_payment_card_last4: paymentMethod && paymentMethod.card && paymentMethod.card.last4 || null,
|
||||
@ -1301,6 +1301,19 @@ module.exports = class MemberRepository {
|
||||
}
|
||||
}
|
||||
|
||||
getCancellationReason(subscription) {
|
||||
// Case: manual cancellation in Portal
|
||||
if (subscription.metadata && subscription.metadata.cancellation_reason) {
|
||||
return subscription.metadata.cancellation_reason;
|
||||
|
||||
// Case: Automatic cancellation due to several payment failures
|
||||
} else if (subscription.cancellation_details && subscription.cancellation_details.reason && subscription.cancellation_details.reason === 'payment_failed') {
|
||||
return 'Payment failed';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async getSubscription(data, options) {
|
||||
if (!this._stripeAPIService.configured) {
|
||||
throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'get Stripe Subscription'})});
|
||||
|
@ -28,7 +28,7 @@
|
||||
"@tryghost/errors": "1.3.2",
|
||||
"@tryghost/logging": "2.4.15",
|
||||
"@tryghost/members-csv": "0.0.0",
|
||||
"@tryghost/metrics": "1.0.31",
|
||||
"@tryghost/metrics": "1.0.34",
|
||||
"@tryghost/tpl": "0.1.30",
|
||||
"moment-timezone": "0.5.23"
|
||||
}
|
||||
|
@ -340,6 +340,11 @@ class OEmbedService {
|
||||
];
|
||||
const oembed = _.pick(body, knownFields);
|
||||
|
||||
// Fallback to bookmark if it's a link type
|
||||
if (oembed.type === 'link') {
|
||||
return;
|
||||
}
|
||||
|
||||
// ensure we have required data for certain types
|
||||
if (oembed.type === 'photo' && !oembed.url) {
|
||||
return;
|
||||
|
@ -30,15 +30,15 @@
|
||||
"cheerio": "0.22.0",
|
||||
"iconv-lite": "0.6.3",
|
||||
"lodash": "4.17.21",
|
||||
"metascraper": "5.41.0",
|
||||
"metascraper-author": "5.42.5",
|
||||
"metascraper-description": "5.42.0",
|
||||
"metascraper-image": "5.42.0",
|
||||
"metascraper-logo": "5.42.0",
|
||||
"metascraper": "5.45.15",
|
||||
"metascraper-author": "5.45.10",
|
||||
"metascraper-description": "5.45.10",
|
||||
"metascraper-image": "5.45.10",
|
||||
"metascraper-logo": "5.45.10",
|
||||
"metascraper-logo-favicon": "5.42.0",
|
||||
"metascraper-publisher": "5.42.0",
|
||||
"metascraper-title": "5.42.0",
|
||||
"metascraper-url": "5.40.0",
|
||||
"metascraper-publisher": "5.45.10",
|
||||
"metascraper-title": "5.45.10",
|
||||
"metascraper-url": "5.45.10",
|
||||
"tough-cookie": "4.1.4"
|
||||
}
|
||||
}
|
||||
|
@ -146,5 +146,31 @@ describe('oembed-service', function () {
|
||||
assert.equal(response.url, 'https://www.example.com');
|
||||
assert.equal(response.metadata.title, 'Example');
|
||||
});
|
||||
|
||||
it('should return a bookmark response when the oembed endpoint returns a link type', async function () {
|
||||
nock('https://www.example.com')
|
||||
.get('/')
|
||||
.query(true)
|
||||
.reply(200, `<html><head><link type="application/json+oembed" href="https://www.example.com/oembed"><title>Example</title></head></html>`);
|
||||
|
||||
nock('https://www.example.com')
|
||||
.get('/oembed')
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
type: 'link',
|
||||
version: '1.0',
|
||||
title: 'Test Title',
|
||||
author_name: 'Test Author',
|
||||
author_url: 'https://example.com/user/testauthor',
|
||||
url: 'https://www.example.com'
|
||||
});
|
||||
|
||||
const response = await oembedService.fetchOembedDataFromUrl('https://www.example.com');
|
||||
|
||||
assert.equal(response.version, '1.0');
|
||||
assert.equal(response.type, 'bookmark');
|
||||
assert.equal(response.url, 'https://www.example.com');
|
||||
assert.equal(response.metadata.title, 'Example');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user