0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-15 03:01:37 -05:00

Added remaining wiring to AdminX Newsletters (#17587)

refs https://github.com/TryGhost/Product/issues/3601

- Wired up add newsletter modal
- Fixed bugs with editing newsletters
- Added archive/reactivate modals
This commit is contained in:
Jono M 2023-08-03 18:26:59 +01:00 committed by GitHub
parent d960b1284d
commit 21f57c5ab5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 239 additions and 125 deletions

View file

@ -1,19 +1,14 @@
import React from 'react';
import React, {HTMLProps} from 'react';
import clsx from 'clsx';
interface TableCellProps {
className?: string;
children?: React.ReactNode;
}
const TableCell: React.FC<TableCellProps> = ({className, children}) => {
const TableCell: React.FC<HTMLProps<HTMLTableCellElement>> = ({className, children, ...props}) => {
const tableCellClasses = clsx(
'!py-3 !pl-0 !pr-6 align-top',
className
);
return (
<td className={tableCellClasses}>
<td className={tableCellClasses} {...props}>
{children}
</td>
);

View file

@ -24,12 +24,12 @@ const Newsletters: React.FC<{ keywords: string[] }> = ({keywords}) => {
{
id: 'active-newsletters',
title: 'Active',
contents: (<NewslettersList newsletters={newsletters?.filter(newsletter => newsletter.status === 'active') || []} tab='active-newsletters' />)
contents: (<NewslettersList newsletters={newsletters?.filter(newsletter => newsletter.status === 'active') || []} />)
},
{
id: 'archived-newsletters',
title: 'Archived',
contents: (<NewslettersList newsletters={newsletters?.filter(newsletter => newsletter.status !== 'active') || []} tab='archive-newsletters' />)
contents: (<NewslettersList newsletters={newsletters?.filter(newsletter => newsletter.status !== 'active') || []} />)
}
];

View file

@ -1,17 +1,56 @@
import Form from '../../../../admin-x-ds/global/form/Form';
import Modal from '../../../../admin-x-ds/global/modal/Modal';
import NiceModal from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import NewsletterDetailModal from './NewsletterDetailModal';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React from 'react';
import TextArea from '../../../../admin-x-ds/global/form/TextArea';
import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import useForm from '../../../../hooks/useForm';
import useRouting from '../../../../hooks/useRouting';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {toast} from 'react-hot-toast';
import {useAddNewsletter} from '../../../../utils/api/newsletters';
import {useBrowseMembers} from '../../../../utils/api/members';
interface AddNewsletterModalProps {}
const AddNewsletterModal: React.FC<AddNewsletterModalProps> = () => {
const modal = useModal();
const {updateRoute} = useRouting();
const [optIn, setOptIn] = useState(true);
const {data: members} = useBrowseMembers({
searchParams: {filter: 'newsletters.status:active+email_disabled:0', limit: '1', page: '1', include: 'newsletters,labels'}
});
const {mutateAsync: addNewsletter} = useAddNewsletter();
const {formState, updateForm, handleSave, errors, validate, clearError} = useForm({
initialState: {
name: '',
description: '',
optInExistingSubscribers: true
},
onSave: async () => {
const response = await addNewsletter({
name: formState.name,
description: formState.description,
opt_in_existing: formState.optInExistingSubscribers
});
NiceModal.show(NewsletterDetailModal, {
newsletter: response.newsletters[0]
});
},
onValidate: () => {
const newErrors: Record<string, string> = {};
if (!formState.name) {
newErrors.name = 'Please enter a name';
}
return newErrors;
}
});
return <Modal
afterClose={() => {
@ -22,30 +61,51 @@ const AddNewsletterModal: React.FC<AddNewsletterModalProps> = () => {
size='sm'
testId='add-newsletter-modal'
title='Create newsletter'
onOk={async () => {
toast.remove();
if (await handleSave()) {
modal.remove();
updateRoute('newsletters');
} else {
showToast({
type: 'pageError',
message: 'Can\'t save newsletter! One or more fields have errors, please doublecheck you filled all mandatory fields'
});
}
}}
>
<Form
marginBottom={false}
marginTop
>
<TextField
error={Boolean(errors.name)}
hint={errors.name}
placeholder='Weekly roundup'
title='Name'
value={formState.name}
onBlur={validate}
onChange={e => updateForm(state => ({...state, name: e.target.value}))}
onKeyDown={() => clearError('name')}
/>
<TextArea
title='Description'
value={formState.description}
onChange={e => updateForm(state => ({...state, description: e.target.value}))}
/>
<Toggle
checked={optIn}
checked={formState.optInExistingSubscribers}
direction='rtl'
hint='This newsletter will be available to all members. Your 1 existing subscriber will also be opted-in to receive it.'
hint={formState.optInExistingSubscribers ?
`This newsletter will be available to all members. Your ${members?.meta?.pagination.total} existing subscriber${members?.meta?.pagination.total === 1 ? '' : 's'} will also be opted-in to receive it.` :
'The newsletter will be available to all new members. Existing members wont be subscribed, but may visit their account area to opt-in to future emails.'
}
label='Opt-in existing subscribers'
labelStyle='heading'
onChange={(e) => {
setOptIn(e.target.checked);
}}
onChange={e => updateForm(state => ({...state, optInExistingSubscribers: e.target.checked}))}
/>
</Form>
</Modal>;
};
export default NiceModal.create(AddNewsletterModal);
export default NiceModal.create(AddNewsletterModal);

View file

@ -1,12 +1,12 @@
import Form from '../../../../admin-x-ds/global/form/Form';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import ButtonGroup from '../../../../admin-x-ds/global/ButtonGroup';
import Form from '../../../../admin-x-ds/global/form/Form';
import Heading from '../../../../admin-x-ds/global/Heading';
import Hint from '../../../../admin-x-ds/global/Hint';
import HtmlField from '../../../../admin-x-ds/global/form/HtmlField';
import Icon from '../../../../admin-x-ds/global/Icon';
import ImageUpload from '../../../../admin-x-ds/global/form/ImageUpload';
import NewsletterPreview from './NewsletterPreview';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import Select, {SelectOption} from '../../../../admin-x-ds/global/form/Select';
import StickyFooter from '../../../../admin-x-ds/global/StickyFooter';
@ -16,10 +16,13 @@ import TextField from '../../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import ToggleGroup from '../../../../admin-x-ds/global/form/ToggleGroup';
import useForm from '../../../../hooks/useForm';
import validator from 'validator';
import {Newsletter} from '../../../../types/api';
import {PreviewModalContent} from '../../../../admin-x-ds/global/modal/PreviewModal';
import {fullEmailAddress, getSettingValues} from '../../../../utils/helpers';
import {getImageUrl, useUploadImage} from '../../../../utils/api/images';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {toast} from 'react-hot-toast';
import {useEditNewsletter} from '../../../../utils/api/newsletters';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
@ -30,8 +33,11 @@ interface NewsletterDetailModalProps {
const Sidebar: React.FC<{
newsletter: Newsletter;
updateNewsletter: (fields: Partial<Newsletter>) => void;
}> = ({newsletter, updateNewsletter}) => {
const {settings, siteData} = useGlobalData();
validate: () => void;
errors: Record<string, string>;
clearError: (field: string) => void;
}> = ({newsletter, updateNewsletter, validate, errors, clearError}) => {
const {settings, siteData, config} = useGlobalData();
const [membersSupportAddress] = getSettingValues<string>(settings, ['members_support_address']);
const {mutateAsync: uploadImage} = useUploadImage();
const [selectedTab, setSelectedTab] = useState('generalSettings');
@ -53,12 +59,30 @@ const Sidebar: React.FC<{
contents:
<>
<Form className='mt-6' gap='sm' margins='lg' title='Name and description'>
<TextField placeholder="Weekly Roundup" title="Name" value={newsletter.name || ''} onChange={e => updateNewsletter({name: e.target.value})} />
<TextField
error={Boolean(errors.name)}
hint={errors.name}
placeholder="Weekly Roundup"
title="Name"
value={newsletter.name || ''}
onBlur={validate}
onChange={e => updateNewsletter({name: e.target.value})}
onKeyDown={() => clearError('name')}
/>
<TextArea rows={2} title="Description" value={newsletter.description || ''} onChange={e => updateNewsletter({description: e.target.value})} />
</Form>
<Form className='mt-6' gap='sm' margins='lg' title='Email addresses'>
<TextField placeholder="Ghost" title="Sender name" value={newsletter.sender_name || ''} onChange={e => updateNewsletter({sender_name: e.target.value})} />
<TextField placeholder="noreply@localhost" title="Sender email address" value={newsletter.sender_email || ''} onChange={e => updateNewsletter({sender_email: e.target.value})} />
<TextField
error={Boolean(errors.sender_email)}
hint={errors.sender_email}
placeholder="noreply@localhost"
title="Sender email address"
value={newsletter.sender_email || ''}
onBlur={validate}
onChange={e => updateNewsletter({sender_email: e.target.value})}
onKeyDown={() => clearError('sender_email')}
/>
<Select options={replyToEmails} selectedOption={newsletter.sender_reply_to} title="Reply-to email" onSelect={value => updateNewsletter({sender_reply_to: value})}/>
</Form>
<Form className='mt-6' gap='sm' margins='lg' title='Member settings'>
@ -209,12 +233,13 @@ const Sidebar: React.FC<{
onChange={e => updateNewsletter({show_subscription_details: e.target.checked})}
/>
</ToggleGroup>
<TextArea
hint="Any extra information or legal text"
rows={2}
title="Email footer"
<HtmlField
config={config}
hint='Any extra information or legal text'
nodes='MINIMAL_NODES'
title='Email footer'
value={newsletter.footer_content || ''}
onChange={e => updateNewsletter({footer_content: e.target.value})}
onChange={html => updateNewsletter({footer_content: html})}
/>
</Form>
</>
@ -255,11 +280,24 @@ const NewsletterDetailModal: React.FC<NewsletterDetailModalProps> = ({newsletter
const modal = useModal();
const {mutateAsync: editNewsletter} = useEditNewsletter();
const {formState, updateForm, handleSave} = useForm({
const {formState, updateForm, handleSave, validate, errors, clearError} = useForm({
initialState: newsletter,
onSave: async () => {
await editNewsletter(formState);
modal.remove();
},
onValidate: () => {
const newErrors: Record<string, string> = {};
if (!formState.name) {
newErrors.name = 'Please enter a name';
}
if (formState.sender_email && !validator.isEmail(formState.sender_email)) {
newErrors.sender_email = 'Invalid email.';
}
return newErrors;
}
});
@ -268,7 +306,7 @@ const NewsletterDetailModal: React.FC<NewsletterDetailModalProps> = ({newsletter
};
const preview = <NewsletterPreview newsletter={formState} />;
const sidebar = <Sidebar newsletter={formState} updateNewsletter={updateNewsletter} />;
const sidebar = <Sidebar clearError={clearError} errors={errors} newsletter={formState} updateNewsletter={updateNewsletter} validate={validate} />;
return <PreviewModalContent
deviceSelector={false}
@ -280,7 +318,17 @@ const NewsletterDetailModal: React.FC<NewsletterDetailModalProps> = ({newsletter
sidebarPadding={false}
testId='newsletter-modal'
title='Newsletter'
onOk={handleSave}
onOk={async () => {
toast.remove();
if (await handleSave()) {
modal.remove();
} else {
showToast({
type: 'pageError',
message: 'Can\'t save newsletter! One or more fields have errors, please doublecheck you filled all mandatory fields'
});
}
}}
/>;
};

View file

@ -1,4 +1,5 @@
import Button from '../../../../admin-x-ds/global/Button';
import ConfirmationModal from '../../../../admin-x-ds/global/modal/ConfirmationModal';
import NewsletterDetailModal from './NewsletterDetailModal';
import NiceModal from '@ebay/nice-modal-react';
import NoValueLabel from '../../../../admin-x-ds/global/NoValueLabel';
@ -7,98 +8,78 @@ import Table from '../../../../admin-x-ds/global/Table';
import TableCell from '../../../../admin-x-ds/global/TableCell';
import TableRow from '../../../../admin-x-ds/global/TableRow';
import {Newsletter} from '../../../../types/api';
import {useEditNewsletter} from '../../../../utils/api/newsletters';
interface NewslettersListProps {
tab?: string;
newsletters: Newsletter[]
}
// We should create a NewsletterItem component based on TableRow and then loop through newsletters
//
// interface NewsletterItemProps {
// name: string;
// description: string;
// subscribers: number;
// emailsSent: number;
// }
const NewsletterItem: React.FC<{newsletter: Newsletter}> = ({newsletter}) => {
const {mutateAsync: editNewsletter} = useEditNewsletter();
// const NewsletterItem: React.FC<NewsletterItemProps> = ({name, description, subscribers, emailsSent}) => {
// const action = tab === 'active-newsletters' ? (
// <Button color='green' label='Archive' link />
// ) : (
// <Button color='green' label='Activate' link />
// );
// return (
// <TableRow
// action={action}
// onClick={() => {
// NiceModal.show(NewsletterDetailModal);
// }}>
// hideActions
// separator
// >
// <TableCell>
// <div className={`flex grow flex-col`}>
// <span className='font-medium'>{name}</span>
// <span className='whitespace-nowrap text-xs text-grey-700'>{description}</span>
// </div>
// </TableCell>
// <TableCell>
// <div className={`flex grow flex-col`}>
// <span>{subscribers}</span>
// <span className='whitespace-nowrap text-xs text-grey-700'>Subscribers</span>
// </div>
// </TableCell>
// <TableCell>
// <div className={`flex grow flex-col`}>
// <span>{emailsSent}</span>
// <span className='whitespace-nowrap text-xs text-grey-700'>Emails sent</span>
// </div>
// </TableCell>
// </TableRow>
// );
// };
const NewslettersList: React.FC<NewslettersListProps> = ({
tab,
newsletters
}) => {
const action = tab === 'active-newsletters' ? (
<Button color='green' label='Archive' link />
const action = newsletter.status === 'active' ? (
<Button color='green' label='Archive' link onClick={() => {
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>
</>,
okLabel: 'Archive',
onOk: async (modal) => {
await editNewsletter({...newsletter, status: 'archived'});
modal?.remove();
}
});
}} />
) : (
<Button color='green' label='Activate' link />
<Button color='green' label='Activate' link onClick={() => {
NiceModal.show(ConfirmationModal, {
title: 'Reactivate newsletter',
prompt: <>
Reactivating <strong>{newsletter.name}</strong> will immediately make it visible to members and re-enable it as an option when publishing new posts.
</>,
okLabel: 'Reactivate',
onOk: async (modal) => {
await editNewsletter({...newsletter, status: 'active'});
modal?.remove();
}
});
}} />
);
const showDetails = () => {
NiceModal.show(NewsletterDetailModal, {newsletter});
};
return (
<TableRow action={action} hideActions>
<TableCell onClick={showDetails}>
<div className={`flex grow flex-col`}>
<span className='font-medium'>{newsletter.name}</span>
<span className='whitespace-nowrap text-xs text-grey-700'>{newsletter.description || 'No description'}</span>
</div>
</TableCell>
<TableCell onClick={showDetails}>
<div className={`flex grow flex-col`}>
<span>{newsletter.count?.active_members}</span>
<span className='whitespace-nowrap text-xs text-grey-700'>Subscribers</span>
</div>
</TableCell>
<TableCell onClick={showDetails}>
<div className={`flex grow flex-col`}>
<span>{newsletter.count?.posts}</span>
<span className='whitespace-nowrap text-xs text-grey-700'>Posts sent</span>
</div>
</TableCell>
</TableRow>
);
};
const NewslettersList: React.FC<NewslettersListProps> = ({newsletters}) => {
if (newsletters.length) {
return <Table>
{newsletters.map(newsletter => (
<TableRow
action={action}
hideActions
onClick={() => {
NiceModal.show(NewsletterDetailModal, {newsletter});
}}>
<TableCell>
<div className={`flex grow flex-col`}>
<span className='font-medium'>{newsletter.name}</span>
<span className='whitespace-nowrap text-xs text-grey-700'>{newsletter.description || 'No description'}</span>
</div>
</TableCell>
<TableCell>
<div className={`flex grow flex-col`}>
<span>{newsletter.count?.active_members}</span>
<span className='whitespace-nowrap text-xs text-grey-700'>Subscribers</span>
</div>
</TableCell>
<TableCell>
<div className={`flex grow flex-col`}>
<span>{newsletter.count?.posts}</span>
<span className='whitespace-nowrap text-xs text-grey-700'>Posts sent</span>
</div>
</TableCell>
</TableRow>
))}
{newsletters.map(newsletter => <NewsletterItem key={newsletter.id} newsletter={newsletter} />)}
</Table>;
} else {
return <NoValueLabel icon='mail-block'>

View file

@ -25,7 +25,7 @@ const Sidebar: React.FC<{
updateBrandSetting: (key: string, value: SettingValue) => void
updateThemeSetting: (updated: CustomThemeSetting) => void
onTabChange: (id: string) => void
handleSave: () => Promise<void>
handleSave: () => Promise<boolean>
}> = ({
brandSettings,
themeSettingSections,

View file

@ -9,7 +9,7 @@ export type SaveState = 'unsaved' | 'saving' | 'saved' | 'error' | '';
export interface FormHook<State> {
formState: State;
saveState: SaveState;
handleSave: () => Promise<void>;
handleSave: () => Promise<boolean>;
/**
* Update the form state and mark the form as dirty. Should be used in input events
*/
@ -19,16 +19,21 @@ export interface FormHook<State> {
*/
setFormState: (updater: (state: State) => State) => void;
reset: () => void;
validate: () => boolean;
clearError: (field: string) => void;
isValid: boolean;
errors: Record<string, string>;
}
// TODO: figure out if we need to extend `any`?
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint
const useForm = <State>({initialState, onSave}: {
const useForm = <State>({initialState, onSave, onValidate}: {
initialState: State,
onSave: () => void | Promise<void>
onValidate?: () => Record<string, string>
}): FormHook<State> => {
const [formState, setFormState] = useState(initialState);
const [saveState, setSaveState] = useState<SaveState>('');
const [errors, setErrors] = useState<Record<string, string>>({});
// Reset saved state after 2 seconds
useEffect(() => {
@ -39,16 +44,33 @@ const useForm = <State>({initialState, onSave}: {
}
}, [saveState]);
const isValid = (errs: Record<string, string>) => Object.values(errs).filter(Boolean).length === 0;
const validate = () => {
if (!onValidate) {
return true;
}
const newErrors = onValidate();
setErrors(newErrors);
return isValid(newErrors);
};
// function to save the changed settings via API
const handleSave = async () => {
if (saveState !== 'unsaved') {
return;
return true;
}
if (!validate()) {
return false;
}
setSaveState('saving');
try {
await onSave();
setSaveState('saved');
return true;
} catch (e) {
setSaveState('unsaved');
throw e;
@ -69,7 +91,13 @@ const useForm = <State>({initialState, onSave}: {
reset() {
setFormState(initialState);
setSaveState('');
}
},
validate,
isValid: isValid(errors),
clearError: (field: string) => {
setErrors(state => ({...state, [field]: ''}));
},
errors
};
};

View file

@ -15,7 +15,7 @@ export interface SettingGroupHook {
saveState: SaveState;
siteData: SiteData | null;
focusRef: React.RefObject<HTMLInputElement>;
handleSave: () => Promise<void>;
handleSave: () => Promise<boolean>;
handleCancel: () => void;
updateSetting: (key: string, value: SettingValue) => void;
handleEditingChange: (newState: boolean) => void;

View file

@ -14,10 +14,12 @@ export const useBrowseNewsletters = createQuery<NewslettersResponseType>({
defaultSearchParams: {include: 'count.active_members,count.posts', limit: 'all'}
});
export const useAddNewsletter = createMutation<NewslettersResponseType, Partial<Newsletter>>({
export const useAddNewsletter = createMutation<NewslettersResponseType, Partial<Newsletter> & {opt_in_existing: boolean}>({
method: 'POST',
path: () => '/newsletters/',
body: newsletter => ({newsletters: [newsletter]}),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
body: ({opt_in_existing: _, ...newsletter}) => ({newsletters: [newsletter]}),
searchParams: payload => ({opt_in_existing: payload.opt_in_existing.toString(), include: 'count.active_members,count.posts', limit: 'all'}),
updateQueries: {
dataType,
update: (newData, currentData) => ({