Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ const MemberEmailsEditor: React.FC<MemberEmailsEditorProps> = ({
// but the API expects a JSON string
const handleChange = useCallback((data: unknown) => {
if (onChange && data && typeof data === 'object') {
onChange(JSON.stringify(data));
const stringified = JSON.stringify(data);
if (stringified !== value) {
Comment thread
troyciesco marked this conversation as resolved.
onChange(stringified);
}
}
}, [onChange]);
}, [onChange, value]);

return (
<KoenigEditorBase
Expand Down
Original file line number Diff line number Diff line change
@@ -1,96 +1,82 @@
import NiceModal from '@ebay/nice-modal-react';
import validator from 'validator';
import {useEffect, useRef, useState} from 'react';

import MemberEmailEditor from './member-email-editor';
import {Button, Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
import {Button, Hint, Modal, TextField} from '@tryghost/admin-x-design-system';
import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';

import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {useCurrentUser} from '@tryghost/admin-x-framework/api/current-user';
import {useEditAutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails';
import {useGlobalData} from '../../../../components/providers/global-data-provider';
import {useRouting} from '@tryghost/admin-x-framework/routing';
import type {AutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails';

interface WelcomeEmailModalProps {
emailType?: 'free' | 'paid';
automatedEmail?: AutomatedEmail;
emailType: 'free' | 'paid';
automatedEmail: AutomatedEmail;
}

const isEmptyLexical = (lexical: string | null | undefined): boolean => {
Comment thread
troyciesco marked this conversation as resolved.
if (!lexical) {
return true;
}

try {
const parsed = JSON.parse(lexical);
const children = parsed?.root?.children;

// Empty if no children or only an empty paragraph
if (!children || children.length === 0) {
return true;
}
if (children.length === 1 &&
children[0].type === 'paragraph' &&
(!children[0].children || children[0].children.length === 0)) {
return true;
}

return false;
} catch {
return true;
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const WelcomeEmailModal = NiceModal.create<WelcomeEmailModalProps>(({emailType = 'free', automatedEmail}) => {
const modal = NiceModal.useModal();
const {updateRoute} = useRouting();
const {data: currentUser} = useCurrentUser();
const {mutateAsync: editAutomatedEmail} = useEditAutomatedEmail();
const [showTestDropdown, setShowTestDropdown] = useState(false);
const [testEmail, setTestEmail] = useState(currentUser?.email || '');
const dropdownRef = useRef<HTMLDivElement>(null);
const handleError = useHandleError();
const {settings} = useGlobalData();
const [siteTitle, defaultEmailAddress] = getSettingValues<string>(settings, ['title', 'default_email_address']);

const {formState, saveState, updateForm, handleSave, okProps, errors} = useForm({
initialState: {
subject: automatedEmail?.subject || 'Welcome',
lexical: automatedEmail?.lexical || ''
},
savingDelay: 500,
onSave: async (state) => {
await editAutomatedEmail({...automatedEmail, ...state});
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
onSaveError: handleError,
onValidate: (state) => {
const newErrors: Record<string, string> = {};

if (!state.subject) {
newErrors.subject = 'A subject is required';
}

// Form state for editable fields
const [formData, setFormData] = useState({
subject: automatedEmail?.subject || 'Welcome',
lexical: automatedEmail?.lexical || '',
sender_name: automatedEmail?.sender_name || '',
sender_email: automatedEmail?.sender_email || '',
sender_reply_to: automatedEmail?.sender_reply_to || ''
});
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<{sender_email?: string; sender_reply_to?: string}>({});

// Track if form has changes
const hasChanges = automatedEmail && (
formData.subject !== (automatedEmail.subject || '') ||
formData.lexical !== (automatedEmail.lexical || '') ||
formData.sender_name !== (automatedEmail.sender_name || '') ||
formData.sender_email !== (automatedEmail.sender_email || '') ||
formData.sender_reply_to !== (automatedEmail.sender_reply_to || '')
);

const updateFormData = (key: keyof typeof formData, value: string) => {
setFormData(prev => ({...prev, [key]: value}));
// Clear error when user starts typing
if (key === 'sender_email' || key === 'sender_reply_to') {
setErrors(prev => ({...prev, [key]: undefined}));
}
};

const validateForm = (): boolean => {
const newErrors: typeof errors = {};

if (formData.sender_email && !validator.isEmail(formData.sender_email)) {
newErrors.sender_email = 'Enter a valid email address';
}

if (formData.sender_reply_to && !validator.isEmail(formData.sender_reply_to)) {
newErrors.sender_reply_to = 'Enter a valid email address';
}

setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};

const handleSave = async () => {
if (!automatedEmail || !validateForm()) {
return;
}
if (isEmptyLexical(state.lexical)) {
newErrors.lexical = 'Email content is required';
}

setIsSaving(true);
try {
await editAutomatedEmail({
...automatedEmail,
subject: formData.subject,
lexical: formData.lexical || null,
sender_name: formData.sender_name || null,
sender_email: formData.sender_email || null,
sender_reply_to: formData.sender_reply_to || null
});
modal.remove();
} catch (error) {
showToast({
type: 'error',
message: 'Failed to save welcome email'
});
} finally {
setIsSaving(false);
return newErrors;
}
};
});

// Update test email when current user data loads
useEffect(() => {
Expand All @@ -116,18 +102,36 @@ const WelcomeEmailModal = NiceModal.create<WelcomeEmailModalProps>(({emailType =
};
}, [showTestDropdown]);

const handleSaveRef = useRef(handleSave);
useEffect(() => {
Comment thread
troyciesco marked this conversation as resolved.
handleSaveRef.current = handleSave;
}, [handleSave]);
Comment thread
troyciesco marked this conversation as resolved.

useEffect(() => {
const handleCMDS = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
handleSaveRef.current({fakeWhenUnchanged: true});
}
};
window.addEventListener('keydown', handleCMDS);
return () => {
window.removeEventListener('keydown', handleCMDS);
};
}, []);

const senderEmail = automatedEmail?.sender_email || defaultEmailAddress;
const replyToEmail = automatedEmail?.sender_reply_to || defaultEmailAddress;

return (
<Modal
afterClose={() => {
updateRoute('memberemails');
}}
dirty={hasChanges}
dirty={saveState === 'unsaved'}
footer={false}
header={false}
testId='welcome-email-modal'
onOk={() => {
modal.remove();
}}
>
<div className='-mx-8 h-[calc(100vh-16vmin)] overflow-y-auto'>
<div className='sticky top-0 z-10 flex flex-col gap-2 border-b border-grey-100 bg-white p-5'>
Expand Down Expand Up @@ -168,73 +172,55 @@ const WelcomeEmailModal = NiceModal.create<WelcomeEmailModalProps>(({emailType =
)}
</div>
<Button
color="black"
disabled={!hasChanges || isSaving}
label={isSaving ? 'Saving...' : 'Save'}
onClick={handleSave}
color={okProps.color}
disabled={okProps.disabled}
label={okProps.label || 'Save'}
onClick={async () => await handleSave({fakeWhenUnchanged: true})}
Comment thread
troyciesco marked this conversation as resolved.
/>
</div>
</div>
<div className='flex items-center'>
<div className='w-20 font-semibold'>From:</div>
<div className='flex grow items-center gap-2'>
<TextField
className='!h-[34px]'
maxLength={191}
placeholder='Sender name'
value={formData.sender_name}
onChange={e => updateFormData('sender_name', e.target.value)}
/>
<TextField
className='!h-[34px] grow'
error={Boolean(errors.sender_email)}
hint={errors.sender_email}
maxLength={191}
placeholder='noreply@example.com'
value={formData.sender_email}
onChange={e => updateFormData('sender_email', e.target.value)}
/>
<div className='flex grow items-center gap-1'>
<span>{automatedEmail?.sender_name || siteTitle}</span>
<span className='text-grey-700'>{`<${senderEmail}>`}</span>
Comment thread
troyciesco marked this conversation as resolved.
</div>
</div>

<div className='flex items-center'>
<div className='w-20 font-semibold'>Reply-to:</div>
<div className='grow'>
<TextField
className='!h-[34px] w-full'
error={Boolean(errors.sender_reply_to)}
hint={errors.sender_reply_to}
maxLength={191}
placeholder='reply@example.com'
value={formData.sender_reply_to}
onChange={e => updateFormData('sender_reply_to', e.target.value)}
/>
{replyToEmail !== senderEmail && (
Comment thread
troyciesco marked this conversation as resolved.
<div className='flex items-center py-0.5'>
<div className='w-20 font-semibold'>Reply-to:</div>
<div className='grow text-grey-700'>
{replyToEmail}
</div>
</div>
</div>

<div className='-mt-1 flex items-center'>
Comment thread
troyciesco marked this conversation as resolved.
)}
<div className='flex items-center'>
<div className='w-20 font-semibold'>Subject:</div>
<div className='grow'>
<TextField
className='!h-[34px] w-full'
Comment thread
troyciesco marked this conversation as resolved.
className='w-full'
error={Boolean(errors.subject)}
hint={errors.subject || ''}
maxLength={300}
value={formData.subject}
onChange={e => updateFormData('subject', e.target.value)}
placeholder={`Welcome to ${siteTitle}`}
value={formState.subject}
onChange={e => updateForm(state => ({...state, subject: e.target.value}))}
/>
</div>
</div>
</div>
<div className='bg-grey-50 p-6'>
<div className='mx-auto max-w-[600px] rounded border border-grey-200 bg-white p-8 text-[1.6rem] leading-[1.6] tracking-[-0.01em] shadow-sm [&_a]:text-black [&_a]:underline [&_p]:mb-4 [&_strong]:font-semibold'>
<div className={`mx-auto max-w-[600px] rounded border bg-white p-8 text-[1.6rem] leading-[1.6] tracking-[-0.01em] shadow-sm [&_a]:text-black [&_a]:underline [&_p]:mb-4 [&_strong]:font-semibold ${errors.lexical ? 'border-red' : 'border-grey-200'}`}>
<MemberEmailEditor
key={automatedEmail?.id || 'new'}
nodes='DEFAULT_NODES'
placeholder='Write your welcome email content...'
singleParagraph={false}
value={formData.lexical}
onChange={lexical => updateFormData('lexical', lexical)}
value={formState.lexical}
onChange={lexical => updateForm(state => ({...state, lexical}))}
/>
</div>
{errors.lexical && <Hint className='ml-8 mr-auto mt-2 max-w-[600px]' color='red'>{errors.lexical}</Hint>}
</div>
</div>
</Modal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,8 @@ export class MemberWelcomeEmailsSection extends BasePage {
// Modal locators
readonly welcomeEmailModal: Locator;
readonly modalSubjectInput: Locator;
readonly modalSenderNameInput: Locator;
readonly modalSenderEmailInput: Locator;
readonly modalReplyToInput: Locator;
readonly modalSaveButton: Locator;
readonly modalSavedButton: Locator;
readonly modalLexicalEditor: Locator;

constructor(page: Page) {
Expand All @@ -27,11 +25,9 @@ export class MemberWelcomeEmailsSection extends BasePage {

// Modal locators
this.welcomeEmailModal = page.getByTestId('welcome-email-modal');
this.modalSubjectInput = this.welcomeEmailModal.locator('input').nth(3); // Subject is the 4th input
this.modalSenderNameInput = this.welcomeEmailModal.locator('input').nth(0); // Sender name is 1st input
this.modalSenderEmailInput = this.welcomeEmailModal.locator('input').nth(1); // Sender email is 2nd input
this.modalReplyToInput = this.welcomeEmailModal.locator('input').nth(2); // Reply-to is 3rd input
this.modalSubjectInput = this.welcomeEmailModal.locator('input').first();
this.modalSaveButton = this.welcomeEmailModal.getByRole('button', {name: 'Save'});
this.modalSavedButton = this.welcomeEmailModal.getByRole('button', {name: 'Saved'});
this.modalLexicalEditor = this.welcomeEmailModal.locator('[contenteditable="true"]');
}

Expand Down Expand Up @@ -95,6 +91,6 @@ export class MemberWelcomeEmailsSection extends BasePage {

async saveWelcomeEmail(): Promise<void> {
await this.modalSaveButton.click();
await this.welcomeEmailModal.waitFor({state: 'hidden'});
await this.modalSavedButton.waitFor({state: 'visible'});
}
}
28 changes: 0 additions & 28 deletions e2e/tests/admin/settings/member-welcome-emails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,34 +108,6 @@ test.describe('Ghost Admin - Member Welcome Emails', () => {
expect(freeWelcomeEmail?.subject).toBe('Custom Welcome Subject');
});

test('can edit free welcome email sender details', async ({page}) => {
const welcomeEmailsSection = new MemberWelcomeEmailsSection(page);

// Enable free welcome email first
await welcomeEmailsSection.goto();
await welcomeEmailsSection.enableFreeWelcomeEmail();

// Open the modal and edit sender details
await welcomeEmailsSection.openFreeWelcomeEmailModal();
await welcomeEmailsSection.modalSenderNameInput.clear();
await welcomeEmailsSection.modalSenderNameInput.fill('Test Sender');
await welcomeEmailsSection.modalSenderEmailInput.clear();
await welcomeEmailsSection.modalSenderEmailInput.fill('sender@example.com');
await welcomeEmailsSection.modalReplyToInput.clear();
await welcomeEmailsSection.modalReplyToInput.fill('reply@example.com');
await welcomeEmailsSection.saveWelcomeEmail();

// Verify via API that the sender details were saved
const response = await page.request.get('/ghost/api/admin/automated_emails/');
expect(response.ok()).toBe(true);

const data = await response.json() as AutomatedEmailsResponse;
const freeWelcomeEmail = data.automated_emails.find(email => email.slug === 'member-welcome-email-free');
expect(freeWelcomeEmail?.sender_name).toBe('Test Sender');
expect(freeWelcomeEmail?.sender_email).toBe('sender@example.com');
expect(freeWelcomeEmail?.sender_reply_to).toBe('reply@example.com');
});

test('edited welcome email persists after page reload', async ({page}) => {
const welcomeEmailsSection = new MemberWelcomeEmailsSection(page);

Expand Down
Loading