Merge branch 'main' into main
This commit is contained in:
commit
60a2b0689c
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": {
|
||||
|
BIN
apps/admin-x-activitypub/src/assets/images/ap-welcome.png
Normal file
BIN
apps/admin-x-activitypub/src/assets/images/ap-welcome.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 80 KiB |
@ -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,9 +1,9 @@
|
||||
// import NiceModal from '@ebay/nice-modal-react';
|
||||
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';
|
||||
|
||||
@ -16,7 +16,7 @@ const ActivityPubComponent: React.FC = () => {
|
||||
const {updateRoute} = useRouting();
|
||||
|
||||
// TODO: Replace with actual user ID
|
||||
const {data: {orderedItems: activities = []} = {}} = useBrowseInboxForUser('index');
|
||||
const {data: {items: activities = []} = {}} = useBrowseInboxForUser('index');
|
||||
const {data: {totalItems: followingCount = 0} = {}} = useBrowseFollowingForUser('index');
|
||||
const {data: {totalItems: followersCount = 0} = {}} = useBrowseFollowersForUser('index');
|
||||
|
||||
@ -32,20 +32,30 @@ const ActivityPubComponent: React.FC = () => {
|
||||
setArticleContent(null);
|
||||
};
|
||||
|
||||
const [selectedOption, setSelectedOption] = useState<SelectOption>({label: 'Inbox', value: 'inbox'});
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState('inbox');
|
||||
|
||||
const tabs: ViewTab[] = [
|
||||
{
|
||||
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'>
|
||||
{activities && activities.slice().reverse().map(activity => (
|
||||
contents: <div className='grid grid-cols-6 items-start gap-8 pt-8'>
|
||||
<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}/>
|
||||
<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>
|
||||
<Button color='green' label='Learn more' link={true}/>
|
||||
</div>
|
||||
</div>}
|
||||
</ul>
|
||||
<Sidebar followersCount={followersCount} followingCount={followingCount} updateRoute={updateRoute} />
|
||||
</div>
|
||||
@ -53,9 +63,9 @@ const ActivityPubComponent: React.FC = () => {
|
||||
{
|
||||
id: 'activity',
|
||||
title: 'Activity',
|
||||
contents: <div className='grid grid-cols-6 items-start gap-8'><List className='col-span-4'>
|
||||
contents: <div className='grid grid-cols-6 items-start gap-8 pt-8'><List className='col-span-4'>
|
||||
{activities && activities.slice().reverse().map(activity => (
|
||||
activity.type === 'Like' && <ListItem avatar={<Avatar image={activity.actor.icon} size='sm' />} id='list-item' title={<div><span className='font-medium'>{activity.actor.name}</span><span className='text-grey-800'> liked your post </span><span className='font-medium'>{activity.object.name}</span></div>}></ListItem>
|
||||
activity.type === 'Like' && <ListItem avatar={<Avatar image={activity.actor.icon?.url} size='sm' />} id='list-item' title={<div><span className='font-medium'>{activity.actor.name}</span><span className='text-grey-800'> liked your post </span><span className='font-medium'>{activity.object.name}</span></div>}></ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Sidebar followersCount={followersCount} followingCount={followingCount} updateRoute={updateRoute} />
|
||||
@ -64,12 +74,12 @@ const ActivityPubComponent: React.FC = () => {
|
||||
{
|
||||
id: 'likes',
|
||||
title: 'Likes',
|
||||
contents: <div className='grid grid-cols-6 items-start gap-8'>
|
||||
contents: <div className='grid grid-cols-6 items-start gap-8 pt-8'>
|
||||
<ul className='order-2 col-span-6 flex flex-col lg:order-1 lg:col-span-4'>
|
||||
{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>
|
||||
@ -82,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={true} link outlineOnMobile />]}
|
||||
firstOnPage={true}
|
||||
primaryAction={{
|
||||
title: 'Follow',
|
||||
@ -93,7 +122,8 @@ const ActivityPubComponent: React.FC = () => {
|
||||
selectedTab={selectedTab}
|
||||
stickyHeader={true}
|
||||
tabs={tabs}
|
||||
toolbarBorder={false}
|
||||
title='ActivityPub'
|
||||
toolbarBorder={true}
|
||||
type='page'
|
||||
onTabChange={setSelectedTab}
|
||||
>
|
||||
@ -108,7 +138,8 @@ 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 rounded-xl bg-grey-50 p-6 lg:order-2 lg:col-span-2' id="ap-sidebar">
|
||||
<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'>
|
||||
<div className='group/stat flex cursor-pointer flex-col gap-1' onClick={() => updateRoute('/view-following')}>
|
||||
@ -121,14 +152,26 @@ const Sidebar: React.FC<{followingCount: number, followersCount: number, updateR
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='rounded-xl bg-grey-50 p-6'>
|
||||
<header className='mb-4 flex items-center justify-between'>
|
||||
<Heading level={5}>Explore</Heading>
|
||||
<Button label='View all' link={true}/>
|
||||
</header>
|
||||
<List>
|
||||
<ListItem action={<Button color='grey' label='Follow' link={true} onClick={() => {}} />} avatar={<Avatar image={`https://ghost.org/favicon.ico`} size='sm' />} detail='829 followers' hideActions={true} title='404 Media' />
|
||||
<ListItem action={<Button color='grey' label='Follow' link={true} onClick={() => {}} />} avatar={<Avatar image={`https://ghost.org/favicon.ico`} size='sm' />} detail='791 followers' hideActions={true} title='The Browser' />
|
||||
<ListItem action={<Button color='grey' label='Follow' link={true} onClick={() => {}} />} avatar={<Avatar image={`https://ghost.org/favicon.ico`} size='sm' />} detail='854 followers' hideActions={true} title='Welcome to Hell World' />
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 = `
|
||||
@ -152,20 +195,29 @@ ${image &&
|
||||
</html>
|
||||
`;
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe) {
|
||||
iframe.srcdoc = htmlContent;
|
||||
}
|
||||
}, [htmlContent]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<iframe
|
||||
className='h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]'
|
||||
ref={iframeRef}
|
||||
className={`h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]`}
|
||||
height="100%"
|
||||
id="gh-ap-article-iframe"
|
||||
srcDoc={htmlContent}
|
||||
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');
|
||||
|
||||
@ -183,37 +235,75 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp
|
||||
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
|
||||
};
|
||||
|
||||
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>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} 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}/>
|
||||
<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='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>
|
||||
<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-md text-grey-800'>{plainTextContent}</p>
|
||||
<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' src={object.image}/>
|
||||
<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-0 z-0 rounded transition-colors group-hover/article:bg-grey-50'></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}) => {
|
||||
@ -241,7 +331,7 @@ const ViewArticle: React.FC<ViewArticleProps> = ({object, onBackToList}) => {
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
</div>
|
||||
<div className='flex justify-end'>
|
||||
<div className='flex items-center justify-end gap-2'>
|
||||
<div className='flex flex-row-reverse items-center gap-3'>
|
||||
<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>
|
||||
|
@ -1,8 +1,8 @@
|
||||
import {} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import getUsernameFromFollowing from '../utils/get-username-from-following';
|
||||
import getUsername from '../utils/get-username';
|
||||
import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system';
|
||||
import {FollowingResponseData, useBrowseFollowersForUser, useUnfollow} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {FollowingResponseData, useBrowseFollowersForUser, useFollow} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
|
||||
interface ViewFollowersModalProps {
|
||||
@ -13,10 +13,11 @@ interface ViewFollowersModalProps {
|
||||
const ViewFollowersModal: React.FC<RoutingModalProps & ViewFollowersModalProps> = ({}) => {
|
||||
const {updateRoute} = useRouting();
|
||||
// const modal = NiceModal.useModal();
|
||||
const mutation = useUnfollow();
|
||||
const mutation = useFollow();
|
||||
|
||||
const {data: {orderedItems: followers = []} = {}} = useBrowseFollowersForUser('inbox');
|
||||
const {data: {items = []} = {}} = useBrowseFollowersForUser('inbox');
|
||||
|
||||
const followers = Array.isArray(items) ? items : [items];
|
||||
return (
|
||||
<Modal
|
||||
afterClose={() => {
|
||||
@ -33,7 +34,7 @@ const ViewFollowersModal: React.FC<RoutingModalProps & ViewFollowersModalProps>
|
||||
<div className='mt-3 flex flex-col gap-4 pb-12'>
|
||||
<List>
|
||||
{followers.map(item => (
|
||||
<ListItem action={<Button color='grey' label='Follow back' link={true} onClick={() => mutation.mutate({username: item.username})} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsernameFromFollowing(item)} id='list-item' title={item.name}></ListItem>
|
||||
<ListItem action={<Button color='grey' label='Follow back' link={true} onClick={() => mutation.mutate({username: getUsername(item)})} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsername(item)} id='list-item' title={item.name}></ListItem>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import getUsernameFromFollowing from '../utils/get-username-from-following';
|
||||
import getUsername from '../utils/get-username';
|
||||
import {Avatar, Button, List, ListItem, Modal} from '@tryghost/admin-x-design-system';
|
||||
import {FollowingResponseData, useBrowseFollowingForUser, useUnfollow} from '@tryghost/admin-x-framework/api/activitypub';
|
||||
import {RoutingModalProps, useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
@ -14,8 +14,9 @@ const ViewFollowingModal: React.FC<RoutingModalProps & ViewFollowingModalProps>
|
||||
const {updateRoute} = useRouting();
|
||||
const mutation = useUnfollow();
|
||||
|
||||
const {data: {orderedItems: following = []} = {}} = useBrowseFollowingForUser('inbox');
|
||||
const {data: {items = []} = {}} = useBrowseFollowingForUser('inbox');
|
||||
|
||||
const following = Array.isArray(items) ? items : [items];
|
||||
return (
|
||||
<Modal
|
||||
afterClose={() => {
|
||||
@ -32,7 +33,7 @@ const ViewFollowingModal: React.FC<RoutingModalProps & ViewFollowingModalProps>
|
||||
<div className='mt-3 flex flex-col gap-4 pb-12'>
|
||||
<List>
|
||||
{following.map(item => (
|
||||
<ListItem action={<Button color='grey' label='Unfollow' link={true} onClick={() => mutation.mutate({username: getUsernameFromFollowing(item)})} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsernameFromFollowing(item)} id='list-item' title={item.name}></ListItem>
|
||||
<ListItem action={<Button color='grey' label='Unfollow' link={true} onClick={() => mutation.mutate({username: getUsername(item)})} />} avatar={<Avatar image={item.icon} size='sm' />} detail={getUsername(item)} id='list-item' title={item.name}></ListItem>
|
||||
))}
|
||||
</List>
|
||||
{/* <Table>
|
||||
|
@ -1,12 +0,0 @@
|
||||
function getUsernameFromFollowing(followItem: {username: string; id: string|null;}) {
|
||||
if (!followItem.username || !followItem.id) {
|
||||
return '@unknown@unknown';
|
||||
}
|
||||
try {
|
||||
return `@${followItem.username}@${(new URL(followItem.id)).hostname}`;
|
||||
} catch (err) {
|
||||
return '@unknown@unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export default getUsernameFromFollowing;
|
@ -4,6 +4,7 @@
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client", "jest"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
|
@ -11,7 +11,8 @@
|
||||
"scripts": {
|
||||
"build": "concurrently \"vite build\" \"tsc -p tsconfig.declaration.json\"",
|
||||
"prepare": "yarn build",
|
||||
"test": "yarn test:types",
|
||||
"test": "yarn test:unit && yarn test:types",
|
||||
"test:unit": "yarn nx build && vitest run",
|
||||
"test:types": "tsc --noEmit",
|
||||
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx src/ --cache",
|
||||
"lint": "yarn lint:code && yarn lint:test",
|
||||
@ -27,15 +28,16 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@codemirror/lang-html": "6.4.9",
|
||||
"@storybook/addon-essentials": "7.6.19",
|
||||
"@storybook/addon-interactions": "7.6.19",
|
||||
"@storybook/addon-links": "7.6.19",
|
||||
"@storybook/addon-essentials": "7.6.20",
|
||||
"@storybook/addon-interactions": "7.6.20",
|
||||
"@storybook/addon-links": "7.6.20",
|
||||
"@storybook/addon-styling": "1.3.7",
|
||||
"@storybook/blocks": "7.6.19",
|
||||
"@storybook/react": "7.6.19",
|
||||
"@storybook/blocks": "7.6.20",
|
||||
"@storybook/react": "7.6.20",
|
||||
"@storybook/react-vite": "7.6.4",
|
||||
"@storybook/testing-library": "0.2.2",
|
||||
"@testing-library/react": "14.1.0",
|
||||
"@testing-library/react-hooks" : "8.0.1",
|
||||
"@vitejs/plugin-react": "4.2.1",
|
||||
"c8": "8.0.1",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
@ -43,11 +45,12 @@
|
||||
"eslint-plugin-tailwindcss": "3.13.0",
|
||||
"jsdom": "24.1.0",
|
||||
"mocha": "10.2.0",
|
||||
"chai": "4.3.8",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"rollup-plugin-node-builtins": "2.1.2",
|
||||
"sinon": "17.0.0",
|
||||
"storybook": "7.6.19",
|
||||
"storybook": "7.6.20",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.4.5",
|
||||
"vite": "4.5.3",
|
||||
@ -60,10 +63,10 @@
|
||||
"@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.38",
|
||||
"postcss": "8.4.39",
|
||||
"postcss-import": "16.1.0",
|
||||
"react-colorful": "5.6.1",
|
||||
"react-hot-toast": "2.4.1",
|
||||
|
@ -18,7 +18,6 @@ export interface TextAreaProps extends HTMLProps<HTMLTextAreaElement> {
|
||||
error?: boolean;
|
||||
placeholder?: string;
|
||||
hint?: React.ReactNode;
|
||||
clearBg?: boolean;
|
||||
fontStyle?: FontStyles;
|
||||
className?: string;
|
||||
onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
|
@ -179,8 +179,8 @@ const ViewContainer: React.FC<ViewContainerProps> = ({
|
||||
|
||||
toolbarContainerClassName = clsx(
|
||||
'flex justify-between gap-5',
|
||||
(type === 'page' && actions?.length) ? 'flex-col md:flex-row md:items-end' : 'items-end',
|
||||
(firstOnPage && type === 'page') ? 'pb-3 tablet:pb-8' : (tabs?.length ? '' : 'pb-2'),
|
||||
(type === 'page' && actions?.length) ? (tabs?.length ? 'flex-col md:flex-row md:items-start' : 'flex-col md:flex-row md:items-end') : 'items-end',
|
||||
(firstOnPage && type === 'page' && !tabs?.length) ? 'pb-3 tablet:pb-8' : (tabs?.length ? '' : 'pb-2'),
|
||||
toolbarBorder && 'border-b border-grey-200 dark:border-grey-900',
|
||||
toolbarContainerClassName
|
||||
);
|
||||
|
@ -27,6 +27,7 @@ export interface ModalProps {
|
||||
cancelLabel?: string;
|
||||
leftButtonProps?: ButtonProps;
|
||||
buttonsDisabled?: boolean;
|
||||
okDisabled?: boolean;
|
||||
footer?: boolean | React.ReactNode;
|
||||
header?: boolean;
|
||||
padding?: boolean;
|
||||
@ -62,6 +63,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
header,
|
||||
leftButtonProps,
|
||||
buttonsDisabled,
|
||||
okDisabled,
|
||||
padding = true,
|
||||
onOk,
|
||||
okColor = 'black',
|
||||
@ -179,7 +181,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
color: okColor,
|
||||
className: 'min-w-[80px]',
|
||||
onClick: onOk,
|
||||
disabled: buttonsDisabled,
|
||||
disabled: buttonsDisabled || okDisabled,
|
||||
loading: okLoading
|
||||
});
|
||||
}
|
||||
|
@ -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]);
|
||||
|
@ -3,6 +3,6 @@ import assert from 'assert/strict';
|
||||
describe('Hello world', function () {
|
||||
it('Runs a test', function () {
|
||||
// TODO: Write me!
|
||||
assert.ok(require('../'));
|
||||
assert.equal(1, 1);
|
||||
});
|
||||
});
|
116
apps/admin-x-design-system/test/unit/hooks/usePagination.test.ts
Normal file
116
apps/admin-x-design-system/test/unit/hooks/usePagination.test.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import {expect} from 'chai';
|
||||
import {renderHook, act} from '@testing-library/react-hooks';
|
||||
import {usePagination, PaginationMeta, PaginationData} from '../../../src/hooks/usePagination';
|
||||
|
||||
describe('usePagination', function () {
|
||||
const initialMeta: PaginationMeta = {
|
||||
limit: 10,
|
||||
pages: 5,
|
||||
total: 50,
|
||||
next: null,
|
||||
prev: null
|
||||
};
|
||||
|
||||
it('should initialize with the given meta and page', function () {
|
||||
const {result} = renderHook(() => usePagination({
|
||||
meta: initialMeta,
|
||||
limit: 10,
|
||||
page: 1,
|
||||
setPage: () => {}
|
||||
})
|
||||
);
|
||||
|
||||
const expectedData: PaginationData = {
|
||||
page: 1,
|
||||
pages: initialMeta.pages,
|
||||
total: initialMeta.total,
|
||||
limit: initialMeta.limit,
|
||||
setPage: result.current.setPage,
|
||||
nextPage: result.current.nextPage,
|
||||
prevPage: result.current.prevPage
|
||||
};
|
||||
|
||||
expect(result.current).to.deep.equal(expectedData);
|
||||
});
|
||||
|
||||
it('should update page correctly when nextPage and prevPage are called', function () {
|
||||
let currentPage = 1;
|
||||
const setPage = (newPage: number) => {
|
||||
currentPage = newPage;
|
||||
};
|
||||
|
||||
const {result} = renderHook(() => usePagination({
|
||||
meta: initialMeta,
|
||||
limit: 10,
|
||||
page: currentPage,
|
||||
setPage
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.nextPage();
|
||||
});
|
||||
|
||||
expect(currentPage).to.equal(2);
|
||||
|
||||
act(() => {
|
||||
result.current.prevPage();
|
||||
});
|
||||
|
||||
expect(currentPage).to.equal(1);
|
||||
});
|
||||
|
||||
it('should update page correctly when setPage is called', function () {
|
||||
let currentPage = 3;
|
||||
const setPage = (newPage: number) => {
|
||||
currentPage = newPage;
|
||||
};
|
||||
|
||||
const {result} = renderHook(() => usePagination({
|
||||
meta: initialMeta,
|
||||
limit: 10,
|
||||
page: currentPage,
|
||||
setPage
|
||||
})
|
||||
);
|
||||
|
||||
const newPage = 5;
|
||||
|
||||
act(() => {
|
||||
result.current.setPage(newPage);
|
||||
});
|
||||
|
||||
expect(currentPage).to.equal(newPage);
|
||||
});
|
||||
|
||||
it('should handle edge cases where meta.pages < page when setting meta', function () {
|
||||
let currentPage = 5;
|
||||
const setPage = (newPage: number) => {
|
||||
currentPage = newPage;
|
||||
};
|
||||
|
||||
const {rerender} = renderHook(
|
||||
({meta}) => usePagination({
|
||||
meta,
|
||||
limit: 10,
|
||||
page: currentPage,
|
||||
setPage
|
||||
}),
|
||||
{initialProps: {meta: initialMeta}}
|
||||
);
|
||||
|
||||
const updatedMeta: PaginationMeta = {
|
||||
limit: 10,
|
||||
pages: 4,
|
||||
total: 40,
|
||||
next: null,
|
||||
prev: null
|
||||
};
|
||||
|
||||
act(() => {
|
||||
rerender({meta: updatedMeta});
|
||||
});
|
||||
|
||||
expect(currentPage).to.equal(4);
|
||||
});
|
||||
});
|
@ -0,0 +1,150 @@
|
||||
import {expect} from 'chai';
|
||||
import {renderHook, act} from '@testing-library/react-hooks';
|
||||
import useSortableIndexedList from '../../../src/hooks/useSortableIndexedList';
|
||||
import sinon from 'sinon';
|
||||
|
||||
describe('useSortableIndexedList', function () {
|
||||
// Mock initial items and blank item
|
||||
const initialItems = [{name: 'Item 1'}, {name: 'Item 2'}];
|
||||
const blankItem = {name: ''};
|
||||
|
||||
// Mock canAddNewItem function
|
||||
const canAddNewItem = (item: { name: string }) => !!item.name;
|
||||
|
||||
it('should initialize with the given items', function () {
|
||||
const setItems = sinon.spy();
|
||||
|
||||
const {result} = renderHook(() => useSortableIndexedList({
|
||||
items: initialItems,
|
||||
setItems,
|
||||
blank: blankItem,
|
||||
canAddNewItem
|
||||
})
|
||||
);
|
||||
|
||||
// Assert initial items setup correctly
|
||||
expect(result.current.items).to.deep.equal(initialItems.map((item, index) => ({item, id: index.toString()})));
|
||||
});
|
||||
|
||||
it('should add a new item', function () {
|
||||
let items = initialItems;
|
||||
const setItems = (newItems: any[]) => {
|
||||
items = newItems;
|
||||
};
|
||||
|
||||
const {result} = renderHook(() => useSortableIndexedList({
|
||||
items,
|
||||
setItems,
|
||||
blank: blankItem,
|
||||
canAddNewItem
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setNewItem({name: 'New Item'});
|
||||
result.current.addItem();
|
||||
});
|
||||
|
||||
// Assert items updated correctly after adding new item
|
||||
expect(items).to.deep.equal([...initialItems, {name: 'New Item'}]);
|
||||
});
|
||||
|
||||
it('should update an item', function () {
|
||||
let items = initialItems;
|
||||
const setItems = (newItems: any[]) => {
|
||||
items = newItems;
|
||||
};
|
||||
|
||||
const {result} = renderHook(() => useSortableIndexedList({
|
||||
items,
|
||||
setItems,
|
||||
blank: blankItem,
|
||||
canAddNewItem
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.updateItem('0', {name: 'Updated Item 1'});
|
||||
});
|
||||
|
||||
// Assert item updated correctly
|
||||
expect(items[0]).to.deep.equal({name: 'Updated Item 1'});
|
||||
});
|
||||
|
||||
it('should remove an item', function () {
|
||||
let items = initialItems;
|
||||
const setItems = (newItems: any[]) => {
|
||||
items = newItems;
|
||||
};
|
||||
|
||||
const {result} = renderHook(() => useSortableIndexedList({
|
||||
items,
|
||||
setItems,
|
||||
blank: blankItem,
|
||||
canAddNewItem
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.removeItem('0');
|
||||
});
|
||||
|
||||
// Assert item removed correctly
|
||||
expect(items).to.deep.equal([initialItems[1]]);
|
||||
});
|
||||
|
||||
it('should move an item', function () {
|
||||
let items = initialItems;
|
||||
const setItems = (newItems: any[]) => {
|
||||
items = newItems;
|
||||
};
|
||||
|
||||
const {result} = renderHook(() => useSortableIndexedList({
|
||||
items,
|
||||
setItems,
|
||||
blank: blankItem,
|
||||
canAddNewItem
|
||||
})
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.moveItem('0', '1');
|
||||
});
|
||||
|
||||
// Assert item moved correctly
|
||||
expect(items).to.deep.equal([initialItems[1], initialItems[0]]);
|
||||
});
|
||||
|
||||
it('should not setItems for deeply equal items regardless of property order', function () {
|
||||
const setItems = sinon.spy();
|
||||
const initialItem = [{name: 'Item 1', url: 'http://example.com'}];
|
||||
const blankItem1 = {name: '', url: ''};
|
||||
|
||||
const {rerender} = renderHook(
|
||||
// eslint-disable-next-line
|
||||
({items, setItems}) => useSortableIndexedList({
|
||||
items,
|
||||
setItems,
|
||||
blank: blankItem1,
|
||||
canAddNewItem
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
items: initialItem,
|
||||
setItems
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
expect(setItems.callCount).to.equal(0);
|
||||
|
||||
// Re-render with items in different order but same content
|
||||
rerender({
|
||||
items: [{url: 'http://example.com', name: 'Item 1'}],
|
||||
setItems
|
||||
});
|
||||
|
||||
// Expect no additional calls because the items are deeply equal
|
||||
expect(setItems.callCount).to.equal(0);
|
||||
});
|
||||
});
|
@ -2,7 +2,7 @@ import {createMutation, createQueryWithId} from '../utils/api/hooks';
|
||||
|
||||
export type FollowItem = {
|
||||
id: string;
|
||||
username: string,
|
||||
preferredUsername: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[x: string]: any
|
||||
};
|
||||
@ -63,7 +63,7 @@ export type InboxResponseData = {
|
||||
summary: string;
|
||||
type: 'OrderedCollection';
|
||||
totalItems: number;
|
||||
orderedItems: Activity[];
|
||||
items: Activity[];
|
||||
}
|
||||
|
||||
export type FollowingResponseData = {
|
||||
@ -72,7 +72,7 @@ export type FollowingResponseData = {
|
||||
summary: string;
|
||||
type: string;
|
||||
totalItems: number;
|
||||
orderedItems: FollowItem[];
|
||||
items: FollowItem[];
|
||||
}
|
||||
|
||||
type FollowRequestProps = {
|
||||
@ -82,19 +82,22 @@ type FollowRequestProps = {
|
||||
export const useFollow = createMutation<object, FollowRequestProps>({
|
||||
method: 'POST',
|
||||
useActivityPub: true,
|
||||
path: data => `/follow/${data.username}`
|
||||
path: data => `/actions/follow/${data.username}`
|
||||
});
|
||||
|
||||
export const useUnfollow = createMutation<object, FollowRequestProps>({
|
||||
method: 'POST',
|
||||
useActivityPub: true,
|
||||
path: data => `/unfollow/${data.username}`
|
||||
path: data => `/actions/unfollow/${data.username}`
|
||||
});
|
||||
|
||||
// This is a frontend root, not using the Ghost admin API
|
||||
export const useBrowseInboxForUser = createQueryWithId<InboxResponseData>({
|
||||
dataType: 'InboxResponseData',
|
||||
useActivityPub: true,
|
||||
headers: {
|
||||
Accept: 'application/activity+json'
|
||||
},
|
||||
path: id => `/inbox/${id}`
|
||||
});
|
||||
|
||||
@ -102,6 +105,9 @@ export const useBrowseInboxForUser = createQueryWithId<InboxResponseData>({
|
||||
export const useBrowseFollowingForUser = createQueryWithId<FollowingResponseData>({
|
||||
dataType: 'FollowingResponseData',
|
||||
useActivityPub: true,
|
||||
headers: {
|
||||
Accept: 'application/activity+json'
|
||||
},
|
||||
path: id => `/following/${id}`
|
||||
});
|
||||
|
||||
@ -109,5 +115,8 @@ export const useBrowseFollowingForUser = createQueryWithId<FollowingResponseData
|
||||
export const useBrowseFollowersForUser = createQueryWithId<FollowingResponseData>({
|
||||
dataType: 'FollowingResponseData',
|
||||
useActivityPub: true,
|
||||
headers: {
|
||||
Accept: 'application/activity+json'
|
||||
},
|
||||
path: id => `/followers/${id}`
|
||||
});
|
||||
|
@ -7,7 +7,7 @@ const escapeNqlString = (value: string) => {
|
||||
};
|
||||
|
||||
const useFilterableApi = <
|
||||
Data extends {id: string} & {[Key in FilterKey]: string},
|
||||
Data extends {id: string} & {[k in FilterKey]: string} & {[k: string]: unknown},
|
||||
ResponseKey extends string = string,
|
||||
FilterKey extends string = string
|
||||
>({path, filterKey, responseKey, limit = 20}: {
|
||||
@ -41,26 +41,27 @@ const useFilterableApi = <
|
||||
return response[responseKey];
|
||||
};
|
||||
|
||||
return {
|
||||
loadData,
|
||||
|
||||
loadInitialValues: async (ids: string[]) => {
|
||||
const loadInitialValues = async (values: string[], key: string) => {
|
||||
await loadData('');
|
||||
|
||||
const data = [...(result.current.data || [])];
|
||||
const missingIds = ids.filter(id => !result.current.data?.find(({id: dataId}) => dataId === id));
|
||||
const missingValues = values.filter(value => !result.current.data?.find(item => item[key] === value));
|
||||
|
||||
if (missingIds.length) {
|
||||
if (missingValues.length) {
|
||||
const additionalData = await fetchApi<{meta?: Meta} & {[k in ResponseKey]: Data[]}>(apiUrl(path, {
|
||||
filter: `id:[${missingIds.join(',')}]`,
|
||||
filter: `${key}:[${missingValues.join(',')}]`,
|
||||
limit: 'all'
|
||||
}));
|
||||
|
||||
data.push(...additionalData[responseKey]);
|
||||
}
|
||||
|
||||
return ids.map(id => data.find(({id: dataId}) => dataId === id)!);
|
||||
}
|
||||
return values.map(value => data.find(item => item[key] === value)!);
|
||||
};
|
||||
|
||||
return {
|
||||
loadData,
|
||||
loadInitialValues
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -76,7 +76,6 @@ const defaultLabFlags = {
|
||||
themeErrorsNotification: false,
|
||||
outboundLinkTagging: false,
|
||||
announcementBar: false,
|
||||
signupForm: false,
|
||||
members: false
|
||||
};
|
||||
|
||||
|
@ -12,7 +12,7 @@ export function getGhostPaths(): IGhostPaths {
|
||||
const adminRoot = `${subdir}/ghost/`;
|
||||
const assetRoot = `${subdir}/ghost/assets/`;
|
||||
const apiRoot = `${subdir}/ghost/api/admin`;
|
||||
const activityPubRoot = `${subdir}/activitypub`;
|
||||
const activityPubRoot = `${subdir}/.ghost/activitypub`;
|
||||
return {subdir, adminRoot, assetRoot, apiRoot, activityPubRoot};
|
||||
}
|
||||
|
||||
|
@ -39,7 +39,7 @@
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "6.4.9",
|
||||
"@tryghost/color-utils": "0.2.2",
|
||||
"@tryghost/kg-unsplash-selector": "0.2.0",
|
||||
"@tryghost/kg-unsplash-selector": "0.2.1",
|
||||
"@tryghost/limit-service": "1.2.14",
|
||||
"@tryghost/nql": "0.12.3",
|
||||
"@tryghost/timezone-data": "0.4.3",
|
||||
|
@ -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>
|
||||
|
@ -111,7 +111,6 @@ const Sidebar: React.FC = () => {
|
||||
};
|
||||
|
||||
const hasTipsAndDonations = useFeatureFlag('tipsAndDonations');
|
||||
const hasRecommendations = useFeatureFlag('recommendations');
|
||||
|
||||
const updateSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFilter(e.target.value);
|
||||
@ -143,7 +142,7 @@ const Sidebar: React.FC = () => {
|
||||
unstyled
|
||||
onChange={updateSearch}
|
||||
/>
|
||||
{filter ? <Button className='absolute right-3 top-3 p-1' icon='close' iconColorClass='text-grey-700 !w-[10px] !h-[10px]' size='sm' unstyled onClick={() => {
|
||||
{filter ? <Button className='absolute top-3 p-1 sm:right-14 tablet:right-3' icon='close' iconColorClass='text-grey-700 !w-[10px] !h-[10px]' size='sm' unstyled onClick={() => {
|
||||
setFilter('');
|
||||
searchInputRef.current?.focus();
|
||||
}} /> : <div className='absolute -right-1/2 top-[9px] hidden rounded border border-grey-400 bg-white px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wider text-grey-600 shadow-[0px_1px_#CED4D9] dark:border-grey-800 dark:bg-grey-900 dark:text-grey-500 dark:shadow-[0px_1px_#626D79] tablet:!visible tablet:right-3 tablet:!block'>/</div>}
|
||||
@ -184,7 +183,7 @@ const Sidebar: React.FC = () => {
|
||||
</SettingNavSection>
|
||||
|
||||
<SettingNavSection isVisible={checkVisible(Object.values(growthSearchKeywords).flat())} title="Growth">
|
||||
{hasRecommendations && <NavItem icon='heart' keywords={growthSearchKeywords.recommendations} navid='recommendations' title="Recommendations" onClick={handleSectionClick} />}
|
||||
<NavItem icon='heart' keywords={growthSearchKeywords.recommendations} navid='recommendations' title="Recommendations" onClick={handleSectionClick} />
|
||||
<NavItem icon='emailfield' keywords={growthSearchKeywords.embedSignupForm} navid='embed-signup-form' title="Embeddable signup form" onClick={handleSectionClick} />
|
||||
{hasStripeEnabled && <NavItem icon='discount' keywords={growthSearchKeywords.offers} navid='offers' title="Offers" onClick={handleSectionClick} />}
|
||||
{hasTipsAndDonations && <NavItem icon='piggybank' keywords={growthSearchKeywords.tips} navid='tips-or-donations' title="Tips or donations" onClick={handleSectionClick} />}
|
||||
|
@ -13,11 +13,11 @@ const AmpModal = NiceModal.create(() => {
|
||||
const {settings} = useGlobalData();
|
||||
const [ampEnabled] = getSettingValues<boolean>(settings, ['amp']);
|
||||
const [ampId] = getSettingValues<string>(settings, ['amp_gtag_id']);
|
||||
const modal = NiceModal.useModal();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [trackingId, setTrackingId] = useState<string | null>('');
|
||||
const {mutateAsync: editSettings} = useEditSettings();
|
||||
const handleError = useHandleError();
|
||||
const [okLabel, setOkLabel] = useState('Save');
|
||||
const [enabled, setEnabled] = useState<boolean>(!!ampEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(ampEnabled || false);
|
||||
@ -30,26 +30,36 @@ const AmpModal = NiceModal.create(() => {
|
||||
{key: 'amp_gtag_id', value: trackingId}
|
||||
];
|
||||
try {
|
||||
await editSettings(updates);
|
||||
setOkLabel('Saving...');
|
||||
await Promise.all([
|
||||
editSettings(updates),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
})
|
||||
]);
|
||||
setOkLabel('Saved');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setTimeout(() => setOkLabel('Save'), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const isDirty = !(enabled === ampEnabled) || !(trackingId === ampId);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
afterClose={() => {
|
||||
updateRoute('integrations');
|
||||
}}
|
||||
dirty={!(enabled === ampEnabled) || !(trackingId === ampId)}
|
||||
okColor='black'
|
||||
okLabel='Save & close'
|
||||
cancelLabel='Close'
|
||||
dirty={isDirty}
|
||||
okColor={okLabel === 'Saved' ? 'green' : 'black'}
|
||||
okLabel={okLabel}
|
||||
testId='amp-modal'
|
||||
title=''
|
||||
onOk={async () => {
|
||||
await handleSave();
|
||||
modal.remove();
|
||||
updateRoute('integrations');
|
||||
}}
|
||||
>
|
||||
<IntegrationHeader
|
||||
|
@ -28,7 +28,6 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
|
||||
await editIntegration(formState);
|
||||
},
|
||||
onSavedStateReset: () => {
|
||||
modal.remove();
|
||||
updateRoute('integrations');
|
||||
},
|
||||
onSaveError: handleError,
|
||||
@ -82,9 +81,10 @@ const CustomIntegrationModalContent: React.FC<{integration: Integration}> = ({in
|
||||
updateRoute('integrations');
|
||||
}}
|
||||
buttonsDisabled={okProps.disabled}
|
||||
cancelLabel='Close'
|
||||
dirty={saveState === 'unsaved'}
|
||||
okColor={okProps.color}
|
||||
okLabel={okProps.label || 'Save & close'}
|
||||
okLabel={okProps.label || 'Save'}
|
||||
size='md'
|
||||
testId='custom-integration-modal'
|
||||
title={formState.name || 'Custom integration'}
|
||||
|
@ -10,18 +10,19 @@ import {useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
|
||||
const FirstpromoterModal = NiceModal.create(() => {
|
||||
const {updateRoute} = useRouting();
|
||||
const modal = NiceModal.useModal();
|
||||
|
||||
const {settings} = useGlobalData();
|
||||
const {mutateAsync: editSettings} = useEditSettings();
|
||||
const handleError = useHandleError();
|
||||
|
||||
const [accountId, setAccountId] = useState<string | null>('');
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
const [firstPromoterEnabled] = getSettingValues<boolean>(settings, ['firstpromoter']);
|
||||
const [firstPromoterId] = getSettingValues<string>(settings, ['firstpromoter_id']);
|
||||
|
||||
const [okLabel, setOkLabel] = useState('Save');
|
||||
const [enabled, setEnabled] = useState<boolean>(!!firstPromoterEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(firstPromoterEnabled || false);
|
||||
setAccountId(firstPromoterId || null);
|
||||
@ -38,8 +39,20 @@ const FirstpromoterModal = NiceModal.create(() => {
|
||||
value: accountId
|
||||
}
|
||||
];
|
||||
|
||||
await editSettings(updates);
|
||||
try {
|
||||
setOkLabel('Saving...');
|
||||
await Promise.all([
|
||||
editSettings(updates),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
})
|
||||
]);
|
||||
setOkLabel('Saved');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setTimeout(() => setOkLabel('Save'), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -47,16 +60,15 @@ const FirstpromoterModal = NiceModal.create(() => {
|
||||
afterClose={() => {
|
||||
updateRoute('integrations');
|
||||
}}
|
||||
cancelLabel='Close'
|
||||
dirty={enabled !== firstPromoterEnabled || accountId !== firstPromoterId}
|
||||
okColor='black'
|
||||
okLabel='Save & close'
|
||||
okColor={okLabel === 'Saved' ? 'green' : 'black'}
|
||||
okLabel={okLabel}
|
||||
testId='firstpromoter-modal'
|
||||
title=''
|
||||
onOk={async () => {
|
||||
try {
|
||||
await handleSave();
|
||||
updateRoute('integrations');
|
||||
modal.remove();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
|
@ -12,8 +12,6 @@ import {useUploadFile} from '@tryghost/admin-x-framework/api/files';
|
||||
|
||||
const PinturaModal = NiceModal.create(() => {
|
||||
const {updateRoute} = useRouting();
|
||||
const modal = NiceModal.useModal();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [uploadingState, setUploadingState] = useState({
|
||||
js: false,
|
||||
css: false
|
||||
@ -29,6 +27,29 @@ const PinturaModal = NiceModal.create(() => {
|
||||
setEnabled(pinturaEnabled || false);
|
||||
}, [pinturaEnabled]);
|
||||
|
||||
const [okLabel, setOkLabel] = useState('Save');
|
||||
const [enabled, setEnabled] = useState<boolean>(!!pinturaEnabled);
|
||||
|
||||
const handleToggleChange = async () => {
|
||||
const updates: Setting[] = [
|
||||
{key: 'pintura', value: (enabled)}
|
||||
];
|
||||
try {
|
||||
setOkLabel('Saving...');
|
||||
await Promise.all([
|
||||
editSettings(updates),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
})
|
||||
]);
|
||||
setOkLabel('Saved');
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
} finally {
|
||||
setTimeout(() => setOkLabel('Save'), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const jsUploadRef = useRef<HTMLInputElement>(null);
|
||||
const cssUploadRef = useRef<HTMLInputElement>(null);
|
||||
const triggerUpload = (form: string) => {
|
||||
@ -70,23 +91,20 @@ const PinturaModal = NiceModal.create(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const isDirty = !(enabled === pinturaEnabled);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
afterClose={() => {
|
||||
updateRoute('integrations');
|
||||
}}
|
||||
cancelLabel=''
|
||||
okColor='black'
|
||||
okLabel='Save'
|
||||
cancelLabel='Close'
|
||||
dirty={isDirty}
|
||||
okColor={okLabel === 'Saved' ? 'green' : 'black'}
|
||||
okLabel={okLabel}
|
||||
testId='pintura-modal'
|
||||
title=''
|
||||
onOk={async () => {
|
||||
modal.remove();
|
||||
updateRoute('integrations');
|
||||
await editSettings([
|
||||
{key: 'pintura', value: enabled}
|
||||
]);
|
||||
}}
|
||||
onOk={handleToggleChange}
|
||||
>
|
||||
<IntegrationHeader
|
||||
detail='Advanced image editing'
|
||||
|
@ -10,9 +10,8 @@ import {useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
|
||||
const SlackModal = NiceModal.create(() => {
|
||||
const {updateRoute} = useRouting();
|
||||
const modal = NiceModal.useModal();
|
||||
|
||||
const {localSettings, updateSetting, handleSave, validate, errors, clearError} = useSettingGroup({
|
||||
const {localSettings, updateSetting, handleSave, validate, errors, clearError, okProps} = useSettingGroup({
|
||||
onValidate: () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
@ -21,7 +20,8 @@ const SlackModal = NiceModal.create(() => {
|
||||
}
|
||||
|
||||
return newErrors;
|
||||
}
|
||||
},
|
||||
savingDelay: 500
|
||||
});
|
||||
const [slackUrl, slackUsername] = getSettingValues<string>(localSettings, ['slack_url', 'slack_username']);
|
||||
|
||||
@ -38,22 +38,22 @@ const SlackModal = NiceModal.create(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const isDirty = localSettings.some(setting => setting.dirty);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
afterClose={() => {
|
||||
updateRoute('integrations');
|
||||
}}
|
||||
dirty={localSettings.some(setting => setting.dirty)}
|
||||
okColor='black'
|
||||
okLabel='Save & close'
|
||||
cancelLabel='Close'
|
||||
dirty={isDirty}
|
||||
okColor={okProps.color}
|
||||
okLabel={okProps.label || 'Save'}
|
||||
testId='slack-modal'
|
||||
title=''
|
||||
onOk={async () => {
|
||||
toast.remove();
|
||||
if (await handleSave()) {
|
||||
modal.remove();
|
||||
updateRoute('integrations');
|
||||
}
|
||||
await handleSave();
|
||||
}}
|
||||
>
|
||||
<IntegrationHeader
|
||||
|
@ -3,42 +3,58 @@ import NiceModal from '@ebay/nice-modal-react';
|
||||
import {Form, Modal, Toggle} from '@tryghost/admin-x-design-system';
|
||||
import {ReactComponent as Icon} from '../../../../assets/icons/unsplash.svg';
|
||||
import {Setting, getSettingValues, useEditSettings} from '@tryghost/admin-x-framework/api/settings';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useGlobalData} from '../../../providers/GlobalDataProvider';
|
||||
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
|
||||
import {useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
|
||||
const UnsplashModal = NiceModal.create(() => {
|
||||
const {updateRoute} = useRouting();
|
||||
const modal = NiceModal.useModal();
|
||||
const {settings} = useGlobalData();
|
||||
const [unsplashEnabled] = getSettingValues<boolean>(settings, ['unsplash']);
|
||||
const {mutateAsync: editSettings} = useEditSettings();
|
||||
const handleError = useHandleError();
|
||||
const [okLabel, setOkLabel] = useState('Save');
|
||||
const [enabled, setEnabled] = useState<boolean>(!!unsplashEnabled);
|
||||
|
||||
const handleToggleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
useEffect(() => {
|
||||
setEnabled(unsplashEnabled || false);
|
||||
}, [unsplashEnabled]);
|
||||
|
||||
const handleToggleChange = async () => {
|
||||
const updates: Setting[] = [
|
||||
{key: 'unsplash', value: (e.target.checked)}
|
||||
{key: 'unsplash', value: (enabled)}
|
||||
];
|
||||
try {
|
||||
await editSettings(updates);
|
||||
setOkLabel('Saving...');
|
||||
await Promise.all([
|
||||
editSettings(updates),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
})
|
||||
]);
|
||||
setOkLabel('Saved');
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
} finally {
|
||||
setTimeout(() => setOkLabel('Save'), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const isDirty = !(enabled === unsplashEnabled);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
afterClose={() => {
|
||||
updateRoute('integrations');
|
||||
}}
|
||||
okColor='black'
|
||||
okLabel='Save & close'
|
||||
cancelLabel='Close'
|
||||
dirty={isDirty}
|
||||
okColor={okLabel === 'Saved' ? 'green' : 'black'}
|
||||
okLabel={okLabel}
|
||||
testId='unsplash-modal'
|
||||
title=''
|
||||
onOk={() => {
|
||||
modal.remove();
|
||||
updateRoute('integrations');
|
||||
}}
|
||||
onOk={handleToggleChange}
|
||||
>
|
||||
<IntegrationHeader
|
||||
detail='Beautiful, free photos'
|
||||
@ -48,11 +64,13 @@ const UnsplashModal = NiceModal.create(() => {
|
||||
<div className='mt-7'>
|
||||
<Form marginBottom={false} grouped>
|
||||
<Toggle
|
||||
checked={unsplashEnabled}
|
||||
checked={enabled}
|
||||
direction='rtl'
|
||||
hint={<>Enable <a className='text-green' href="https://unsplash.com" rel="noopener noreferrer" target="_blank">Unsplash</a> image integration for your posts</>}
|
||||
label='Enable Unsplash'
|
||||
onChange={handleToggleChange}
|
||||
onChange={(e) => {
|
||||
setEnabled(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
</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'
|
||||
|
@ -125,7 +125,7 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
const form = (
|
||||
<SettingGroupContent columns={1}>
|
||||
<Select
|
||||
hint='Who should be able to subscribe to your site?'
|
||||
hint='Who should receive your posts by default?'
|
||||
options={RECIPIENT_FILTER_OPTIONS}
|
||||
selectedOption={RECIPIENT_FILTER_OPTIONS.find(option => option.value === selectedOption)}
|
||||
testId='default-recipients-select'
|
||||
|
@ -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();
|
||||
@ -130,8 +129,8 @@ const Sidebar: React.FC<{
|
||||
NiceModal.show(ConfirmationModal, {
|
||||
title: 'Archive newsletter',
|
||||
prompt: <>
|
||||
<p>Your newsletter <strong>{newsletter.name}</strong> will no longer be visible to members or available as an option when publishing new posts.</p>
|
||||
<p>Existing posts previously sent as this newsletter will remain unchanged.</p>
|
||||
<div className="mb-6">Your newsletter <strong>{newsletter.name}</strong> will no longer be visible to members or available as an option when publishing new posts.</div>
|
||||
<div>Existing posts previously sent as this newsletter will remain unchanged.</div>
|
||||
</>,
|
||||
okLabel: 'Archive',
|
||||
okColor: 'red',
|
||||
@ -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(
|
||||
|
@ -62,11 +62,11 @@ const useDefaultRecipientsOptions = (selectedOption: string, defaultEmailRecipie
|
||||
|
||||
const initSelectedSegments = async () => {
|
||||
const filters = defaultEmailRecipientsFilter?.split(',') || [];
|
||||
const tierIds: string[] = [], labelIds: string[] = [], offerIds: string[] = [];
|
||||
const tierIds: string[] = [], labelSlugs: string[] = [], offerIds: string[] = [];
|
||||
|
||||
for (const filter of filters) {
|
||||
if (filter.startsWith('label:')) {
|
||||
labelIds.push(filter.replace('label:', ''));
|
||||
labelSlugs.push(filter.replace('label:', ''));
|
||||
} else if (filter.startsWith('offer_redemptions:')) {
|
||||
offerIds.push(filter.replace('offer_redemptions:', ''));
|
||||
} else if (isObjectId(filter)) {
|
||||
@ -75,9 +75,9 @@ const useDefaultRecipientsOptions = (selectedOption: string, defaultEmailRecipie
|
||||
}
|
||||
|
||||
const options = await Promise.all([
|
||||
tiers.loadInitialValues(tierIds).then(data => data.map(tierOption)),
|
||||
labels.loadInitialValues(labelIds).then(data => data.map(labelOption)),
|
||||
offers.loadInitialValues(offerIds).then(data => data.map(offerOption))
|
||||
tiers.loadInitialValues(tierIds, 'id').then(data => data.map(tierOption)),
|
||||
labels.loadInitialValues(labelSlugs, 'slug').then(data => data.map(labelOption)),
|
||||
offers.loadInitialValues(offerIds, 'id').then(data => data.map(offerOption))
|
||||
]).then(results => results.flat());
|
||||
|
||||
setSelectedSegments(filters.map(filter => options.find(option => option.value === filter)!));
|
||||
|
@ -113,10 +113,6 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
onSave: async (values) => {
|
||||
await updateUser?.(values);
|
||||
},
|
||||
onSavedStateReset: () => {
|
||||
mainModal.remove();
|
||||
navigateOnClose();
|
||||
},
|
||||
onSaveError: handleError
|
||||
});
|
||||
const setUserData = (newData: User) => updateForm(() => newData);
|
||||
@ -353,9 +349,10 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
|
||||
animate={canAccessSettings(currentUser)}
|
||||
backDrop={canAccessSettings(currentUser)}
|
||||
buttonsDisabled={okProps.disabled}
|
||||
cancelLabel='Close'
|
||||
dirty={saveState === 'unsaved'}
|
||||
okColor={okProps.color}
|
||||
okLabel={okProps.label || 'Save & close'}
|
||||
okLabel={okProps.label || 'Save'}
|
||||
size={canAccessSettings(currentUser) ? 'lg' : 'bleed'}
|
||||
stickyFooter={true}
|
||||
testId='user-detail-modal'
|
||||
|
@ -5,7 +5,6 @@ import {User, hasAdminAccess} from '@tryghost/admin-x-framework/api/users';
|
||||
|
||||
const EmailNotificationsInputs: React.FC<{ user: User; setUserData: (user: User) => void; }> = ({user, setUserData}) => {
|
||||
const hasWebmentions = useFeatureFlag('webmentions');
|
||||
const hasRecommendations = useFeatureFlag('recommendations');
|
||||
|
||||
return (
|
||||
<SettingGroupContent>
|
||||
@ -28,7 +27,7 @@ const EmailNotificationsInputs: React.FC<{ user: User; setUserData: (user: User)
|
||||
setUserData?.({...user, mention_notifications: e.target.checked});
|
||||
}}
|
||||
/>}
|
||||
{hasRecommendations && <Toggle
|
||||
<Toggle
|
||||
checked={user.recommendation_notifications}
|
||||
direction='rtl'
|
||||
hint='Every time another publisher recommends you to their audience'
|
||||
@ -36,7 +35,7 @@ const EmailNotificationsInputs: React.FC<{ user: User; setUserData: (user: User)
|
||||
onChange={(e) => {
|
||||
setUserData?.({...user, recommendation_notifications: e.target.checked});
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
<Toggle
|
||||
checked={user.free_member_signup_notification}
|
||||
direction='rtl'
|
||||
|
@ -33,7 +33,7 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, clearError, user, setUs
|
||||
onKeyDown={() => clearError('email')}
|
||||
/>
|
||||
<TextField
|
||||
hint="https://example.com/author"
|
||||
hint={`https://example.com/author/${user.slug}`}
|
||||
maxLength={191}
|
||||
title="Slug"
|
||||
value={user.slug}
|
||||
|
@ -17,14 +17,12 @@ export const searchKeywords = {
|
||||
|
||||
const GrowthSettings: React.FC = () => {
|
||||
const hasTipsAndDonations = useFeatureFlag('tipsAndDonations');
|
||||
const hasRecommendations = useFeatureFlag('recommendations');
|
||||
// const hasOffersLabs = useFeatureFlag('adminXOffers');
|
||||
const {config, settings} = useGlobalData();
|
||||
const hasStripeEnabled = checkStripeEnabled(settings || [], config || {});
|
||||
|
||||
return (
|
||||
<SearchableSection keywords={Object.values(searchKeywords).flat()} title='Growth'>
|
||||
{hasRecommendations && <Recommendations keywords={searchKeywords.recommendations} />}
|
||||
<Recommendations keywords={searchKeywords.recommendations} />
|
||||
<EmbedSignupForm keywords={searchKeywords.embedSignupForm} />
|
||||
{hasStripeEnabled && <Offers keywords={searchKeywords.offers} />}
|
||||
{hasTipsAndDonations && <TipsOrDonations keywords={searchKeywords.tips} />}
|
||||
|
@ -129,7 +129,6 @@ const EmbedSignupSidebar: React.FC<SidebarProps> = ({selectedLayout,
|
||||
/>
|
||||
<TextArea
|
||||
className='text-grey-800'
|
||||
clearBg={false}
|
||||
fontStyle='mono'
|
||||
hint={`Paste this code onto any website where you'd like your signup to appear.`}
|
||||
title='Embed code'
|
||||
|
@ -27,10 +27,6 @@ const EditRecommendationModal: React.FC<RoutingModalProps & EditRecommendationMo
|
||||
onSave: async (state) => {
|
||||
await editRecommendation(state);
|
||||
},
|
||||
onSavedStateReset: () => {
|
||||
modal.remove();
|
||||
updateRoute('recommendations');
|
||||
},
|
||||
onSaveError: handleError,
|
||||
onValidate: (state) => {
|
||||
const newErrors = validateDescriptionForm(state);
|
||||
@ -76,10 +72,10 @@ const EditRecommendationModal: React.FC<RoutingModalProps & EditRecommendationMo
|
||||
animate={animate ?? true}
|
||||
backDropClick={false}
|
||||
buttonsDisabled={okProps.disabled}
|
||||
cancelLabel={'Cancel'}
|
||||
cancelLabel={'Close'}
|
||||
leftButtonProps={leftButtonProps}
|
||||
okColor={okProps.color}
|
||||
okLabel={okProps.label || 'Save & close'}
|
||||
okLabel={okProps.label || 'Save'}
|
||||
size='sm'
|
||||
testId='edit-recommendation-modal'
|
||||
title={'Edit recommendation'}
|
||||
|
@ -109,7 +109,6 @@ const RecommendationDescriptionForm: React.FC<Props<EditOrAddRecommendation | Re
|
||||
}}
|
||||
/>
|
||||
<TextArea
|
||||
clearBg={true}
|
||||
error={Boolean(errors.description)}
|
||||
// Note: we don't show the error text here, because errors are related to the character count
|
||||
hint={<>Max: <strong>200</strong> characters. You’ve used <strong className={descriptionLengthColor}>{descriptionLength}</strong></>}
|
||||
|
@ -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,7 +50,6 @@ 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');
|
||||
@ -63,7 +60,6 @@ const SignupOptions: React.FC<{
|
||||
updateSetting('portal_default_plan', 'yearly');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// This is a bit unclear in current admin, maybe we should add a message if the settings are disabled?
|
||||
@ -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)}
|
||||
|
@ -136,7 +136,7 @@ const Connect: React.FC = () => {
|
||||
</div>
|
||||
<StripeButton href={stripeConnectUrl} tag='a' target='_blank' />
|
||||
<Heading className='mb-2 mt-8' level={6} grey>Step 2 — <span className='text-black dark:text-white'>Paste secure key</span></Heading>
|
||||
<TextArea clearBg={false} error={Boolean(error)} hint={error || undefined} placeholder='Paste your secure key here' onChange={onTokenChange}></TextArea>
|
||||
<TextArea error={Boolean(error)} hint={error || undefined} placeholder='Paste your secure key here' onChange={onTokenChange}></TextArea>
|
||||
{submitEnabled && <Button className='mt-5' color='green' label='Save Stripe settings' onClick={onSubmit} />}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,7 +1,6 @@
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
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';
|
||||
@ -17,7 +16,6 @@ export type TierFormState = Partial<Omit<Tier, 'trial_days'>> & {
|
||||
const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
const isFreeTier = tier?.type === 'free';
|
||||
|
||||
const modal = useModal();
|
||||
const {updateRoute} = useRouting();
|
||||
const {mutateAsync: updateTier} = useEditTier();
|
||||
const {mutateAsync: createTier} = useAddTier();
|
||||
@ -26,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} = {
|
||||
@ -71,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';
|
||||
@ -97,10 +93,6 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
onSavedStateReset: () => {
|
||||
modal.remove();
|
||||
updateRoute('tiers');
|
||||
},
|
||||
onSaveError: handleError
|
||||
});
|
||||
|
||||
@ -185,10 +177,11 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
|
||||
updateRoute('tiers');
|
||||
}}
|
||||
buttonsDisabled={okProps.disabled}
|
||||
cancelLabel='Close'
|
||||
dirty={saveState === 'unsaved'}
|
||||
leftButtonProps={leftButtonProps}
|
||||
okColor={okProps.color}
|
||||
okLabel={okProps.label || 'Save & close'}
|
||||
okLabel={okProps.label || 'Save'}
|
||||
size='lg'
|
||||
testId='tier-detail-modal'
|
||||
title={(tier ? (tier.active ? 'Edit tier' : 'Edit archived tier') : 'New tier')}
|
||||
@ -200,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}
|
||||
@ -211,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}
|
||||
|
@ -43,8 +43,9 @@ const NavigationModal = NiceModal.create(() => {
|
||||
updateRoute('navigation');
|
||||
}}
|
||||
buttonsDisabled={saveState === 'saving'}
|
||||
cancelLabel='Close'
|
||||
dirty={localSettings.some(setting => setting.dirty)}
|
||||
okLabel={saveState === 'saving' ? 'Saving...' : 'OK'}
|
||||
okLabel={saveState === 'saving' ? 'Saving...' : 'Save'}
|
||||
scrolling={true}
|
||||
size='lg'
|
||||
stickyFooter={true}
|
||||
|
@ -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,8 +59,27 @@ const ThemeActions: React.FC<ThemeActionProps> = ({
|
||||
message: <div><span className='capitalize'>{theme.name}</span> is now your active theme</div>
|
||||
});
|
||||
} catch (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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
|
@ -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
|
||||
});
|
||||
|
||||
|
@ -51,7 +51,7 @@ test.describe('AMP integration', async () => {
|
||||
const ampToggle = ampModal.getByRole('switch');
|
||||
await ampToggle.click();
|
||||
|
||||
await ampModal.getByRole('button', {name: 'Cancel'}).click();
|
||||
await ampModal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/leave/i);
|
||||
|
||||
|
@ -140,7 +140,7 @@ test.describe('Custom integrations', async () => {
|
||||
|
||||
await modal.getByLabel('Description').fill('Test description');
|
||||
|
||||
await modal.getByRole('button', {name: 'Cancel'}).click();
|
||||
await modal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/leave/i);
|
||||
|
||||
@ -190,7 +190,8 @@ test.describe('Custom integrations', async () => {
|
||||
// Edit integration
|
||||
|
||||
await modal.getByLabel('Description').fill('Test description');
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await modal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
await expect(integrationsSection).toHaveText(/Test description/);
|
||||
|
||||
|
@ -51,7 +51,7 @@ test.describe('First Promoter integration', async () => {
|
||||
const fpToggle = fpModal.getByRole('switch');
|
||||
await fpToggle.click();
|
||||
|
||||
await fpModal.getByRole('button', {name: 'Cancel'}).click();
|
||||
await fpModal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/leave/i);
|
||||
|
||||
|
@ -23,14 +23,15 @@ test.describe('Slack integration', async () => {
|
||||
// Failing validation
|
||||
|
||||
await slackModal.getByLabel('Webhook URL').fill('badurl');
|
||||
await slackModal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await slackModal.getByRole('button', {name: 'Save'}).click();
|
||||
await expect(slackModal).toContainText('The URL must be in a format like https://hooks.slack.com/services/<your personal key>');
|
||||
|
||||
// Successful save
|
||||
|
||||
await slackModal.getByLabel('Webhook URL').fill('https://hooks.slack.com/services/123456789/123456789/123456789');
|
||||
await slackModal.getByLabel('Username').fill('My site');
|
||||
await slackModal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await slackModal.getByRole('button', {name: 'Save'}).click();
|
||||
await slackModal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
await expect(slackModal).toHaveCount(0);
|
||||
|
||||
@ -59,7 +60,7 @@ test.describe('Slack integration', async () => {
|
||||
|
||||
await slackModal.getByLabel('Webhook URL').fill('https://hooks.slack.com/services/123456789/123456789/123456789');
|
||||
|
||||
await slackModal.getByRole('button', {name: 'Cancel'}).click();
|
||||
await slackModal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/leave/i);
|
||||
|
||||
@ -90,7 +91,7 @@ test.describe('Slack integration', async () => {
|
||||
// Doesn't send the request when validation fails
|
||||
|
||||
await slackModal.getByLabel('Webhook URL').fill('badurl');
|
||||
await slackModal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await slackModal.getByRole('button', {name: 'Save'}).click();
|
||||
await expect(slackModal).toContainText('The URL must be in a format like https://hooks.slack.com/services/<your personal key>');
|
||||
expect(lastApiRequests.testSlack).toBeUndefined();
|
||||
|
||||
|
@ -21,6 +21,8 @@ test.describe('Unsplash integration', async () => {
|
||||
const unsplashToggle = unsplashModal.getByRole('switch');
|
||||
await unsplashToggle.click();
|
||||
|
||||
await unsplashModal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
expect(lastApiRequests.editSettings?.body).toEqual({
|
||||
settings: [
|
||||
{key: 'unsplash', value: false}
|
||||
|
@ -104,4 +104,33 @@ test.describe('Default recipient settings', async () => {
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
test('renders existing default recipients filters correctly', async ({page}) => {
|
||||
await mockApi({page, requests: {
|
||||
...globalDataRequests,
|
||||
browseTiers: {method: 'GET', path: '/tiers/?filter=&limit=20', response: responseFixtures.tiers},
|
||||
browseLabels: {method: 'GET', path: '/labels/?filter=&limit=20', response: responseFixtures.labels},
|
||||
browseOffers: {method: 'GET', path: '/offers/?filter=&limit=20', response: responseFixtures.offers},
|
||||
browseSettings: {...globalDataRequests.browseSettings, response: updatedSettingsResponse([
|
||||
{
|
||||
key: 'editor_default_email_recipients',
|
||||
value: 'filter'
|
||||
},
|
||||
{
|
||||
key: 'editor_default_email_recipients_filter',
|
||||
value: '645453f4d254799990dd0e22,label:first-label,offer_redemptions:6487ea6464fca78ec2fff5fe'
|
||||
}
|
||||
])}
|
||||
}});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const section = page.getByTestId('default-recipients');
|
||||
await section.getByRole('button', {name: 'Edit'}).click();
|
||||
|
||||
await expect(section.getByText('Specific people')).toHaveCount(1);
|
||||
await expect(section.getByText('Basic Supporter')).toHaveCount(1);
|
||||
await expect(section.getByText('first-label')).toHaveCount(1);
|
||||
await expect(section.getByText('First offer')).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
@ -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');
|
||||
|
@ -35,23 +35,23 @@ test.describe('User profile', async () => {
|
||||
// Validation failures
|
||||
|
||||
await modal.getByLabel('Full name').fill('');
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await expect(modal).toContainText('Name is required');
|
||||
|
||||
await modal.getByLabel('Email').fill('test');
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await expect(modal).toContainText('Enter a valid email address');
|
||||
|
||||
await modal.getByLabel('Location').fill(new Array(195).join('a'));
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await expect(modal).toContainText('Location is too long');
|
||||
|
||||
await modal.getByLabel('Bio').fill(new Array(210).join('a'));
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await expect(modal).toContainText('Bio is too long');
|
||||
|
||||
await modal.getByLabel('Website').fill('not-a-website');
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await expect(modal).toContainText('Enter a valid URL');
|
||||
|
||||
const facebookInput = modal.getByLabel('Facebook profile');
|
||||
@ -151,6 +151,7 @@ test.describe('User profile', async () => {
|
||||
await modal.getByLabel('Full name').fill('New Admin');
|
||||
await modal.getByLabel('Email').fill('newadmin@test.com');
|
||||
await modal.getByLabel('Slug').fill('newadmin');
|
||||
await expect(modal.getByText('https://example.com/author/newadmin')).toBeVisible();
|
||||
await modal.getByLabel('Location').fill('some location');
|
||||
await modal.getByLabel('Website').fill('https://example.com');
|
||||
await modal.getByLabel('Facebook profile').fill('fb');
|
||||
@ -165,9 +166,10 @@ test.describe('User profile', async () => {
|
||||
await modal.getByLabel(/Paid member cancellations/).check();
|
||||
await modal.getByLabel(/Milestones/).uncheck();
|
||||
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(modal.getByRole('button', {name: 'Saved'})).toBeVisible();
|
||||
await modal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
await expect(listItem.getByText('New Admin')).toBeVisible();
|
||||
await expect(listItem.getByText('newadmin@test.com')).toBeVisible();
|
||||
@ -313,7 +315,7 @@ test.describe('User profile', async () => {
|
||||
|
||||
await modal.getByLabel('Full name').fill('Updated');
|
||||
|
||||
await modal.getByRole('button', {name: 'Cancel'}).click();
|
||||
await modal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/leave/i);
|
||||
|
||||
@ -373,7 +375,7 @@ test.describe('User profile', async () => {
|
||||
await listItem.getByRole('button', {name: 'Edit'}).click();
|
||||
|
||||
await expect(modal.getByTestId('api-keys')).toBeHidden();
|
||||
await modal.getByRole('button', {name: 'Cancel'}).click();
|
||||
await modal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
// Can see and regenerate your own staff token
|
||||
|
||||
|
@ -69,10 +69,12 @@ test.describe('User roles', async () => {
|
||||
|
||||
await modal.locator('input[value=editor]').check();
|
||||
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(modal.getByRole('button', {name: 'Saved'})).toBeVisible();
|
||||
|
||||
await modal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
await expect(activeTab).toHaveText(/No authors found./);
|
||||
|
||||
await section.getByRole('tab', {name: 'Editors'}).click();
|
||||
@ -146,6 +148,7 @@ test.describe('User roles', async () => {
|
||||
|
||||
await modal.getByLabel('Full name').fill('New name');
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await modal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
await expect(modal).toBeHidden();
|
||||
});
|
||||
|
@ -1,12 +1,8 @@
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {globalDataRequests} from '../../utils/acceptance';
|
||||
import {mockApi, responseFixtures, toggleLabsFlag} from '@tryghost/admin-x-framework/test/acceptance';
|
||||
import {mockApi, responseFixtures} from '@tryghost/admin-x-framework/test/acceptance';
|
||||
|
||||
test.describe('Recommendations', async () => {
|
||||
test.beforeEach(async () => {
|
||||
toggleLabsFlag('recommendations', true);
|
||||
});
|
||||
|
||||
test('can view recommendations', async ({page}) => {
|
||||
await mockApi({page, requests: {
|
||||
...globalDataRequests,
|
||||
|
@ -18,7 +18,7 @@ test.describe('Tier settings', async () => {
|
||||
|
||||
const modal = page.getByTestId('tier-detail-modal');
|
||||
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(modal).toHaveText(/Enter a name for the tier/);
|
||||
await expect(modal).toHaveText(/Amount must be at least \$1/);
|
||||
@ -51,7 +51,8 @@ test.describe('Tier settings', async () => {
|
||||
browseTiers: {method: 'GET', path: '/tiers/', response: {tiers: [...responseFixtures.tiers.tiers, newTier]}}
|
||||
}});
|
||||
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await modal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
// await expect(section.getByTestId('tier-card').filter({hasText: /Plus/})).toHaveText(/Plus tier/);
|
||||
// await expect(section.getByTestId('tier-card').filter({hasText: /Plus/})).toHaveText(/\$8\/month/);
|
||||
@ -103,7 +104,7 @@ test.describe('Tier settings', async () => {
|
||||
// Failing validations
|
||||
|
||||
await modal.getByLabel('Name').fill('');
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(modal).toHaveText(/Enter a name for the tier/);
|
||||
|
||||
@ -132,7 +133,8 @@ test.describe('Tier settings', async () => {
|
||||
|
||||
// Save changes
|
||||
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await modal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
await expect(section.getByTestId('tier-card').filter({hasText: /Supporter/})).toHaveText(/Supporter updated/);
|
||||
await expect(section.getByTestId('tier-card').filter({hasText: /Supporter/})).toHaveText(/Supporter description/);
|
||||
@ -185,7 +187,8 @@ test.describe('Tier settings', async () => {
|
||||
await modal.getByRole('button', {name: 'Add'}).click();
|
||||
await modal.getByLabel('New benefit').fill('Second benefit');
|
||||
|
||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
await modal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
await expect(section.getByTestId('tier-card').filter({hasText: /Free/})).toHaveText(/Free tier description/);
|
||||
|
||||
|
@ -39,7 +39,7 @@ test.describe('Navigation settings', async () => {
|
||||
await secondaryNavigationTab.getByTestId('new-navigation-item').getByLabel('URL').fill('https://google.com');
|
||||
await secondaryNavigationTab.getByTestId('new-navigation-item').getByLabel('URL').blur();
|
||||
|
||||
await modal.getByRole('button', {name: 'OK'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(modal).not.toBeVisible();
|
||||
|
||||
@ -68,7 +68,7 @@ test.describe('Navigation settings', async () => {
|
||||
await primaryItem.getByLabel('URL').press('Backspace');
|
||||
await primaryItem.getByLabel('URL').fill('google.com');
|
||||
|
||||
await modal.getByRole('button', {name: 'OK'}).click();
|
||||
await modal.getByRole('button', {name: 'Save'}).click();
|
||||
|
||||
await expect(primaryItem.getByText('You must specify a label')).toHaveCount(1);
|
||||
await expect(primaryItem.getByText('You must specify a valid URL or relative path')).toHaveCount(1);
|
||||
@ -149,7 +149,7 @@ test.describe('Navigation settings', async () => {
|
||||
|
||||
await newItem.getByTestId('add-button').click();
|
||||
|
||||
await modal.getByRole('button', {name: 'Cancel'}).click();
|
||||
await modal.getByRole('button', {name: 'Close'}).click();
|
||||
|
||||
await expect(page.getByTestId('confirmation-modal')).toHaveText(/leave/i);
|
||||
|
||||
|
@ -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.
|
||||
|
||||
## Release
|
||||
|
||||
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-2023 Ghost Foundation - Released under the [MIT license](LICENSE).
|
||||
Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE).
|
||||
|
@ -44,16 +44,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "1.7.19",
|
||||
"@tiptap/core": "2.4.0",
|
||||
"@tiptap/extension-blockquote": "2.4.0",
|
||||
"@tiptap/extension-document": "2.4.0",
|
||||
"@tiptap/extension-hard-break": "2.4.0",
|
||||
"@tiptap/extension-link": "2.4.0",
|
||||
"@tiptap/extension-paragraph": "2.4.0",
|
||||
"@tiptap/extension-placeholder": "2.4.0",
|
||||
"@tiptap/extension-text": "2.4.0",
|
||||
"@tiptap/pm": "2.4.0",
|
||||
"@tiptap/react": "2.4.0",
|
||||
"@tiptap/core": "2.5.0",
|
||||
"@tiptap/extension-blockquote": "2.5.0",
|
||||
"@tiptap/extension-document": "2.5.0",
|
||||
"@tiptap/extension-hard-break": "2.5.0",
|
||||
"@tiptap/extension-link": "2.5.0",
|
||||
"@tiptap/extension-paragraph": "2.5.0",
|
||||
"@tiptap/extension-placeholder": "2.5.0",
|
||||
"@tiptap/extension-text": "2.5.0",
|
||||
"@tiptap/pm": "2.5.0",
|
||||
"@tiptap/react": "2.5.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-string-replace": "1.1.1"
|
||||
@ -74,7 +74,7 @@
|
||||
"eslint-plugin-react-refresh": "0.4.3",
|
||||
"eslint-plugin-tailwindcss": "3.13.0",
|
||||
"jsdom": "24.1.0",
|
||||
"postcss": "8.4.38",
|
||||
"postcss": "8.4.39",
|
||||
"tailwindcss": "3.4.4",
|
||||
"vite": "4.5.3",
|
||||
"vite-plugin-css-injected-by-js": "3.3.0",
|
||||
|
@ -118,14 +118,12 @@ const AddDetailsPopup = (props: Props) => {
|
||||
return (
|
||||
<div className="shadow-modal relative h-screen w-screen overflow-hidden rounded-none bg-white p-[28px] text-center sm:h-auto sm:w-[720px] sm:rounded-xl sm:p-0" data-testid="profile-modal" onMouseDown={stopPropagation}>
|
||||
<div className="flex">
|
||||
{!isMobile() &&
|
||||
<div className={`flex w-[40%] flex-col items-center justify-center bg-[#1C1C1C]`}>
|
||||
<div className="mt-[-1px] flex flex-col gap-9">
|
||||
<div className={`hidden w-[50%] flex-col items-center justify-center bg-[#1C1C1C] sm:block sm:p-8`}>
|
||||
<div className="mt-[-1px] flex flex-col gap-9 text-left">
|
||||
{renderExampleProfiles()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className={`${isMobile() ? 'w-full' : 'w-[60%]'} p-0 sm:p-8`}>
|
||||
<div className={`p-0 sm:p-8`}>
|
||||
<h1 className="mb-1 text-center font-sans text-[24px] font-bold tracking-tight text-black sm:text-left">{t('Complete your profile')}<span className="hidden sm:inline">.</span></h1>
|
||||
<p className="pr-0 text-center font-sans text-base leading-9 text-neutral-500 sm:pr-10 sm:text-left">{t('Add context to your comment, share your name and expertise to foster a healthy discussion.')}</p>
|
||||
<section className="mt-8 text-left">
|
||||
|
@ -26,45 +26,49 @@ The script also adds custom class names to this element for open and close state
|
||||
|
||||
Refer the [docs](https://ghost.org/help/setup-members/#customize-portal-settings) to read about ways in which Portal can be customized for your site.
|
||||
|
||||
## Basic Setup
|
||||
## Develop
|
||||
|
||||
This section is mostly relevant for core team only for active Portal development. Always use the unpkg link for testing/using latest released portal script.
|
||||
Run Portal within the Ghost monorepo with:
|
||||
```
|
||||
yarn dev --portal
|
||||
```
|
||||
|
||||
- Run `yarn start:dev` to start Portal in development mode
|
||||
- Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
- To use the local Portal script in a local Ghost site
|
||||
- Update `config.local.json` in Ghost repo to add "portal" config pointing to local dev server url as instructed on terminal.
|
||||
- By default, this uses port `5368` for loading local Portal script on Ghost site. It's also possible to specify a custom port when running the script using - `--port=xxxx`.
|
||||
Alternatively, use `yarn dev --all` to load Portal and other supported apps/services, see [dev.js](https://github.com/TryGhost/Ghost/blob/main/.github/scripts/dev.js) for more information.
|
||||
|
||||
## Available Scripts
|
||||
---
|
||||
|
||||
In the project directory, you can also run:
|
||||
To run Portal in a standalone fashion, use `yarn start` and open [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
### `yarn start`
|
||||
## Build
|
||||
|
||||
Runs the app in the development mode.<br />
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
To create a production minified bundle in `umd/portal.min.js`:
|
||||
```
|
||||
yarn build
|
||||
```
|
||||
|
||||
The page will reload if you make edits.<br />
|
||||
You will also see any lint errors in the console.
|
||||
## Test
|
||||
|
||||
Start the portal server when developing Ghost by running Ghost (in root folder) via `yarn dev --all` or `yarn dev --portal`. This will host the portal JavaScript files, and makes sure that Ghost uses these locally hosted assets instead of the ones from the CDN.
|
||||
To run tests in watch mode:
|
||||
```
|
||||
yarn test
|
||||
```
|
||||
|
||||
### `yarn build`
|
||||
## Release
|
||||
|
||||
Creates the production single minified bundle for external use in `umd/portal.min.js`. <br />
|
||||
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.
|
||||
|
||||
### `yarn test`
|
||||
### Patch release
|
||||
|
||||
Launches the test runner in the interactive watch mode.<br />
|
||||
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/portal@~${PORTAL_VERSION}/umd/portal.min.js` and
|
||||
`https://purge.jsdelivr.net/ghost/portal@~${PORTAL_VERSION}/umd/main.css` in your browser, where `PORTAL_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#L185))
|
||||
|
||||
## Publish
|
||||
### Minor / major release
|
||||
|
||||
Run `yarn ship` to publish new version of script.
|
||||
1. Run `yarn ship` and select a minor or major version when prompted
|
||||
2. Update the Portal 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
|
||||
|
||||
`yarn ship` is an alias for `npm publish`
|
||||
# Copyright & License
|
||||
|
||||
- Builds the script with latest code using `yarn build` (prePublish)
|
||||
- Publishes package on npm as `@tryghost/portal` and creates an unpkg link for script at https://unpkg.com/@tryghost/portal@VERSION
|
||||
|
||||
(Core team only)
|
||||
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 (
|
||||
|
@ -40,12 +40,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.38.1",
|
||||
"@storybook/addon-essentials": "7.6.19",
|
||||
"@storybook/addon-interactions": "7.6.19",
|
||||
"@storybook/addon-links": "7.6.19",
|
||||
"@storybook/addon-essentials": "7.6.20",
|
||||
"@storybook/addon-interactions": "7.6.20",
|
||||
"@storybook/addon-links": "7.6.20",
|
||||
"@storybook/addon-styling": "1.3.7",
|
||||
"@storybook/blocks": "7.6.19",
|
||||
"@storybook/react": "7.6.19",
|
||||
"@storybook/blocks": "7.6.20",
|
||||
"@storybook/react": "7.6.20",
|
||||
"@storybook/react-vite": "7.6.4",
|
||||
"@storybook/testing-library": "0.2.2",
|
||||
"@tailwindcss/line-clamp": "0.4.4",
|
||||
@ -59,11 +59,11 @@
|
||||
"eslint-plugin-react-refresh": "0.4.3",
|
||||
"eslint-plugin-tailwindcss": "3.13.0",
|
||||
"jsdom": "24.1.0",
|
||||
"postcss": "8.4.38",
|
||||
"postcss": "8.4.39",
|
||||
"postcss-import": "16.1.0",
|
||||
"prop-types": "15.8.1",
|
||||
"rollup-plugin-node-builtins": "2.1.2",
|
||||
"storybook": "7.6.19",
|
||||
"storybook": "7.6.20",
|
||||
"stylelint": "15.10.3",
|
||||
"tailwindcss": "3.4.4",
|
||||
"vite": "4.5.3",
|
||||
|
@ -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:
|
||||
|
||||
```bash
|
||||
TZ=UTC yarn test 1 --path="dist-test"
|
||||
```
|
||||
|
||||
To have a cleaner output:
|
||||
Then run tests with:
|
||||
|
||||
```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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
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;
|
||||
|
@ -46,11 +46,11 @@
|
||||
@imageSrc={{@image}}
|
||||
@saveImage={{fn this.saveImage uploader.setFiles}}
|
||||
/>
|
||||
<button type="button" class="image-action image-delete" title="Delete image" {{on "click" @clearImage}}>
|
||||
<button type="button" class="image-action image-delete" data-tooltip="Delete" {{on "click" @clearImage}}>
|
||||
{{svg-jar "koenig/kg-trash"}}
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex justify-between align-center">
|
||||
<div class="gh-editor-feature-image-caption-container">
|
||||
{{#if this.isEditingAlt}}
|
||||
<input
|
||||
type="text"
|
||||
|
@ -236,7 +236,7 @@ export default class GhKoenigEditorLexical extends Component {
|
||||
// otherwise the browser will defocus the editor and the cursor will disappear
|
||||
@action
|
||||
focusEditor(event) {
|
||||
if (!this.skipFocusEditor && event.target.classList.contains('gh-koenig-editor-pane')) {
|
||||
if (!this.skipFocusEditor && event.target.classList.contains('gh-koenig-editor-pane') && this.editorAPI) {
|
||||
let editorCanvas = this.editorAPI.editorInstance.getRootElement();
|
||||
let {bottom} = editorCanvas.getBoundingClientRect();
|
||||
|
||||
|
@ -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.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">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}}
|
||||
<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);
|
||||
|
@ -6,10 +6,10 @@
|
||||
<button
|
||||
type="button"
|
||||
class="{{if @className @className "image-action image-edit"}}"
|
||||
title="Edit image"
|
||||
data-tooltip="Edit"
|
||||
{{on "click" (fn this.handleClick uploader)}}
|
||||
>
|
||||
{{svg-jar "koenig/kg-edit"}}
|
||||
{{svg-jar "koenig/kg-wand"}}
|
||||
</button>
|
||||
</GhUploader>
|
||||
{{/if}}
|
||||
|
@ -448,6 +448,7 @@ export default class KoenigLexicalEditor extends Component {
|
||||
fetchCollectionPosts,
|
||||
fetchEmbed,
|
||||
fetchLabels,
|
||||
renderLabels: !this.session.user.isContributor,
|
||||
feature: {
|
||||
collectionsCard: this.feature.collectionsCard,
|
||||
collections: this.feature.collections,
|
||||
@ -461,7 +462,8 @@ export default class KoenigLexicalEditor extends Component {
|
||||
membersEnabled: this.settings.membersSignupAccess === 'all',
|
||||
searchLinks,
|
||||
siteTitle: this.settings.title,
|
||||
siteDescription: this.settings.description
|
||||
siteDescription: this.settings.description,
|
||||
siteUrl: this.config.getSiteUrl('/')
|
||||
};
|
||||
const cardConfig = Object.assign({}, defaultCardConfig, props.cardConfig, {pinturaConfig: this.pinturaConfig});
|
||||
|
||||
|
@ -11,7 +11,8 @@ const ALL_EVENT_TYPES = [
|
||||
{event: 'email_opened_event', icon: 'filter-dropdown-email-opened', name: 'Email opened', group: 'emails'},
|
||||
{event: 'email_delivered_event', icon: 'filter-dropdown-email-received', name: 'Email received', group: 'emails'},
|
||||
{event: 'email_complaint_event', icon: 'filter-dropdown-email-flagged-as-spam', name: 'Email flagged as spam', group: 'emails'},
|
||||
{event: 'email_failed_event', icon: 'filter-dropdown-email-bounced', name: 'Email bounced', group: 'emails'}
|
||||
{event: 'email_failed_event', icon: 'filter-dropdown-email-bounced', name: 'Email bounced', group: 'emails'},
|
||||
{event: 'email_change_event', icon: 'filter-dropdown-email-address-changed', name: 'Email address changed', group: 'emails'}
|
||||
];
|
||||
|
||||
export default class MembersActivityEventTypeFilter extends Component {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import {MATCH_RELATION_OPTIONS} from './relation-options';
|
||||
|
||||
export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => {
|
||||
if (feature.filterEmailDisabled) {
|
||||
export const SUBSCRIBED_FILTER = ({newsletters, group}) => {
|
||||
return {
|
||||
label: newsletters.length > 1 ? 'All newsletters' : 'Newsletter subscription',
|
||||
name: 'subscribed',
|
||||
@ -9,8 +8,6 @@ export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => {
|
||||
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;
|
||||
@ -94,65 +91,9 @@ export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => {
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (newsletters.length > 1) {
|
||||
// Disable
|
||||
// Only show the filter for multiple newsletters if feature flag is enabled
|
||||
return [];
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Newsletter subscription',
|
||||
name: 'subscribed',
|
||||
columnLabel: 'Subscribed',
|
||||
relationOptions: MATCH_RELATION_OPTIONS,
|
||||
valueType: 'options',
|
||||
group: 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)';
|
||||
},
|
||||
parseNqlFilter: (flt) => {
|
||||
const comparator = flt.$and || flt.$or;
|
||||
|
||||
if (!comparator || comparator.length !== 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (comparator[0].subscribed === undefined || comparator[1].email_disabled === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscribed = comparator[0].subscribed;
|
||||
|
||||
return {
|
||||
value: subscribed ? 'true' : 'false',
|
||||
relation: 'is'
|
||||
};
|
||||
},
|
||||
options: [
|
||||
{label: 'Subscribed', name: 'true'},
|
||||
{label: 'Unsubscribed', name: 'false'}
|
||||
],
|
||||
getColumnValue: (member, flt) => {
|
||||
const relation = flt.relation;
|
||||
const value = flt.value;
|
||||
|
||||
return {
|
||||
text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
|
||||
? 'Subscribed'
|
||||
: 'Unsubscribed'
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const NEWSLETTERS_FILTERS = ({newsletters, group, feature}) => {
|
||||
export const NEWSLETTERS_FILTERS = ({newsletters, group}) => {
|
||||
if (newsletters.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
@ -210,13 +151,11 @@ 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -114,6 +114,10 @@ export default class ParseMemberEventHelper extends Helper {
|
||||
icon = 'subscriptions';
|
||||
}
|
||||
|
||||
if (event.type === 'email_change_event') {
|
||||
icon = 'email-changed';
|
||||
}
|
||||
|
||||
return 'event-' + icon;
|
||||
}
|
||||
|
||||
@ -208,8 +212,15 @@ export default class ParseMemberEventHelper extends Helper {
|
||||
return 'less like this';
|
||||
}
|
||||
|
||||
if (event.type === 'email_change_event') {
|
||||
if (event.data.from_email && event.data.to_email) {
|
||||
return `Email address changed from ${event.data.from_email} to ${event.data.to_email}`;
|
||||
}
|
||||
return 'Email address changed';
|
||||
}
|
||||
|
||||
if (event.type === 'donation_event') {
|
||||
return `Made a one-time payment`;
|
||||
return 'Made a one-time payment';
|
||||
}
|
||||
}
|
||||
|
||||
@ -330,7 +341,7 @@ export default class ParseMemberEventHelper extends Helper {
|
||||
* Get internal route props for a clickable object
|
||||
*/
|
||||
getRoute(event) {
|
||||
if (['comment_event', 'click_event', 'feedback_event'].includes(event.type)) {
|
||||
if (['click_event', 'feedback_event'].includes(event.type)) {
|
||||
if (event.data.post) {
|
||||
return {
|
||||
name: 'posts.analytics',
|
||||
|
@ -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');
|
||||
|
@ -185,6 +185,17 @@ export default Route.extend(ShortcutsRoute, {
|
||||
release: `ghost@${this.config.version}`,
|
||||
beforeSend,
|
||||
ignoreErrors: [
|
||||
// Browser autoplay policies (this regex covers a few)
|
||||
/The play() request was interrupted/,
|
||||
/The request is not allowed by the user agent or the platform in the current context/,
|
||||
|
||||
// Network errors that we don't control
|
||||
/Server was unreachable/,
|
||||
/NetworkError when attempting to fetch resource./,
|
||||
/Failed to fetch/,
|
||||
/Load failed/,
|
||||
/The operation was aborted./,
|
||||
|
||||
// TransitionAborted errors surface from normal application behaviour
|
||||
// - https://github.com/emberjs/ember.js/issues/12505
|
||||
/^TransitionAborted$/,
|
||||
@ -203,7 +214,7 @@ export default Route.extend(ShortcutsRoute, {
|
||||
try {
|
||||
// Session Replay on errors
|
||||
// Docs: https://docs.sentry.io/platforms/javascript/session-replay
|
||||
sentryConfig.replaysOnErrorSampleRate = 1.0;
|
||||
sentryConfig.replaysOnErrorSampleRate = 0.5;
|
||||
sentryConfig.integrations.push(
|
||||
// Replace with `Sentry.replayIntegration()` once we've migrated to @sentry/ember 8.x
|
||||
// Docs: https://docs.sentry.io/platforms/javascript/migration/v7-to-v8/#removal-of-sentryreplay-package
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
@ -3,6 +3,8 @@ import moment from 'moment-timezone';
|
||||
import {task} from 'ember-concurrency';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
import mergeStatsByDate from 'ghost-admin/utils/merge-stats-by-date';
|
||||
|
||||
/**
|
||||
* @typedef MrrStat
|
||||
* @type {Object}
|
||||
@ -241,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;
|
||||
@ -461,39 +465,7 @@ export default class DashboardStatsService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDates(list, entry) {
|
||||
const [current, ...rest] = list;
|
||||
|
||||
if (!current) {
|
||||
return entry ? [entry] : [];
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
return mergeDates(rest, {
|
||||
date: current.date,
|
||||
count: current.count,
|
||||
positiveDelta: current.positive_delta,
|
||||
negativeDelta: current.negative_delta,
|
||||
signups: current.signups,
|
||||
cancellations: current.cancellations
|
||||
});
|
||||
}
|
||||
|
||||
if (current.date === entry.date) {
|
||||
return mergeDates(rest, {
|
||||
date: entry.date,
|
||||
count: entry.count + current.count,
|
||||
positiveDelta: entry.positiveDelta + current.positive_delta,
|
||||
negativeDelta: entry.negativeDelta + current.negative_delta,
|
||||
signups: entry.signups + current.signups,
|
||||
cancellations: entry.cancellations + current.cancellations
|
||||
});
|
||||
}
|
||||
|
||||
return [entry].concat(mergeDates(list));
|
||||
}
|
||||
|
||||
const subscriptionCountStats = mergeDates(result.stats);
|
||||
const subscriptionCountStats = mergeStatsByDate(result.stats);
|
||||
|
||||
this.paidMembersByCadence = paidMembersByCadence;
|
||||
this.paidMembersByTier = paidMembersByTier;
|
||||
|
@ -63,27 +63,22 @@ 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;
|
||||
@feature('announcementBar') announcementBar;
|
||||
@feature('signupCard') signupCard;
|
||||
@feature('signupForm') signupForm;
|
||||
@feature('collections') collections;
|
||||
@feature('mailEvents') mailEvents;
|
||||
@feature('collectionsCard') collectionsCard;
|
||||
@feature('importMemberTier') importMemberTier;
|
||||
@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 {
|
||||
|
@ -842,13 +842,12 @@
|
||||
background: var(--white);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 1px 4px -1px rgba(0,0,0,.1);
|
||||
}
|
||||
|
||||
.gh-post-analytics-resource {
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 3fr;
|
||||
grid-gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gh-post-analytics-resource .thumbnail {
|
||||
@ -866,6 +865,7 @@
|
||||
.gh-post-analytics-resource h3 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.gh-post-analytics-box h4.gh-main-section-header.small {
|
||||
@ -1534,7 +1534,7 @@
|
||||
color: var(--green-d1);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1080px) {
|
||||
@media screen and (max-width: 1200px) {
|
||||
.gh-post-analytics-box.resources {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
@ -229,7 +229,7 @@
|
||||
border-style: solid;
|
||||
border-color: #e1e8ed;
|
||||
color: #292f33;
|
||||
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.3em;
|
||||
background: #fff;
|
||||
@ -283,7 +283,7 @@
|
||||
/* NEW editor
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
.gh-main > section.gh-editor-fullscreen {
|
||||
.gh-main>section.gh-editor-fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@ -449,7 +449,7 @@
|
||||
.gh-editor-feedback-dropdown {
|
||||
min-width: 400px;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,.04), 0 8px 20px -3px rgba(0,0,0,.2);
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, .04), 0 8px 20px -3px rgba(0, 0, 0, .2);
|
||||
padding: 20px;
|
||||
background-color: #fff;
|
||||
background-clip: padding-box;
|
||||
@ -553,13 +553,20 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.gh-editor-feature-image {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.gh-editor-feature-image img {
|
||||
display: block;
|
||||
}
|
||||
.gh-editor-feature-image-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 100px;
|
||||
background-image: linear-gradient(180deg,rgba(0,0,0,.2),transparent 40%,transparent);
|
||||
bottom: 0;
|
||||
background-image: linear-gradient(180deg, rgba(0, 0, 0, .2), transparent 40%, transparent);
|
||||
transition: all .1s ease-in;
|
||||
opacity: 0;
|
||||
}
|
||||
@ -581,7 +588,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
|
||||
padding: 0;
|
||||
background: var(--white);
|
||||
color: var(--darkgrey);
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
transition: all .1s ease-in;
|
||||
opacity: 0;
|
||||
}
|
||||
@ -604,7 +611,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
|
||||
}
|
||||
|
||||
.gh-editor-feature-image .image-action svg path {
|
||||
fill: var(--darkgrey);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.gh-editor-feature-image-add {
|
||||
@ -677,12 +684,19 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
|
||||
stroke: var(--midgrey-l2);
|
||||
}
|
||||
|
||||
.gh-editor-feature-image-caption-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: .8rem 0;
|
||||
}
|
||||
.gh-editor-feature-image-alttext,
|
||||
.gh-editor-feature-image-caption {
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
margin: 0 0 1.2rem 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
padding: 0 3.6rem 0 0;
|
||||
outline: none;
|
||||
border-width: 0;
|
||||
border-style: none;
|
||||
@ -1037,7 +1051,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
|
||||
.gh-editor .editor-preview h3,
|
||||
.gh-editor .editor-preview h4,
|
||||
.gh-editor .editor-preview h5,
|
||||
.gh-editor .editor-preview h6, {
|
||||
.gh-editor .editor-preview h6 {
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
@ -1058,7 +1072,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 2px solid var(--blue);
|
||||
background-color: rgba(255,255,255,0.6);
|
||||
background-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.gh-editor-drop-target .drop-target-message {
|
||||
@ -1109,6 +1123,7 @@ body[data-user-is-dragging] .gh-editor-feature-image-dropzone {
|
||||
position: relative;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.editor-toolbar .fa-check:before {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
@ -1197,8 +1212,8 @@ figure {
|
||||
left: -20px;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
background: rgb(255,255,255);
|
||||
background: linear-gradient(90deg, rgba(255,255,255,1) 0%, rgba(255,255,255,0) 100%);
|
||||
background: rgb(255, 255, 255);
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
z-index: 999;
|
||||
opacity: 0;
|
||||
transition: all 250ms ease-out;
|
||||
@ -1211,22 +1226,22 @@ figure {
|
||||
right: 0;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
background: rgb(255,255,255);
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 100%);
|
||||
background: rgb(255, 255, 255);
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 100%);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.ember-power-select-option[aria-current="true"] .kg-settings-link-url.scroller::before {
|
||||
opacity: 1;
|
||||
left: 0;
|
||||
background: linear-gradient(90deg, rgba(244,245,245,1) 0%, rgba(244,245,245,0) 100%);
|
||||
background: linear-gradient(90deg, rgba(244, 245, 245, 1) 0%, rgba(244, 245, 245, 0) 100%);
|
||||
}
|
||||
|
||||
.ember-power-select-option[aria-current="true"] .kg-settings-link-url::after {
|
||||
background: linear-gradient(90deg, rgba(244,245,245,0) 0%, rgba(244,245,245,1) 100%);
|
||||
background: linear-gradient(90deg, rgba(244, 245, 245, 0) 0%, rgba(244, 245, 245, 1) 100%);
|
||||
}
|
||||
|
||||
.kg-settings-link-url > span {
|
||||
.kg-settings-link-url>span {
|
||||
display: inline-block;
|
||||
font-weight: 400;
|
||||
font-size: 1.2rem;
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -184,9 +184,9 @@ button, .btn-base {
|
||||
bottom: calc(100% + 4px);
|
||||
left: 50%;
|
||||
white-space: nowrap;
|
||||
padding: 3px 7px;
|
||||
border-radius: 3px;
|
||||
background-color: var(--darkgrey);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--black);
|
||||
color: var(--white);
|
||||
content: attr(data-tooltip);
|
||||
text-align: center;
|
||||
|
@ -12,7 +12,7 @@
|
||||
</header>
|
||||
</div>
|
||||
{{else}}
|
||||
<form id="login" method="post" class="gh-signin" novalidate="novalidate" {{on "submit" (perform this.validateAndAuthenticateTask)}}>
|
||||
<form id="login" class="gh-signin" novalidate="novalidate" {{on "submit" (perform this.validateAndAuthenticateTask)}}>
|
||||
<header>
|
||||
<div class="gh-site-icon" style={{site-icon-style}}></div>
|
||||
<h1>{{this.config.blogTitle}}</h1>
|
||||
@ -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>
|
||||
|
@ -7,7 +7,7 @@
|
||||
<h1>Create your account.</h1>
|
||||
</header>
|
||||
|
||||
<form id="signup" class="gh-signup" method="post" novalidate="novalidate" {{on "submit" this.submit}}>
|
||||
<form id="signup" class="gh-signup" novalidate="novalidate" {{on "submit" this.submit}}>
|
||||
<GhFormGroup @errors={{this.signupDetails.errors}} @hasValidated={{this.signupDetails.hasValidated}} @property="name">
|
||||
<label for="name">Full name</label>
|
||||
<span class="gh-input-icon gh-icon-user">
|
||||
|
@ -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>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user