diff --git a/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx b/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx index 1b9b9ca7784..76cf1880ab8 100644 --- a/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx @@ -22,9 +22,12 @@ const MemberEmailsEditor: React.FC = ({ // 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) { + onChange(stringified); + } } - }, [onChange]); + }, [onChange, value]); return ( { + 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; + } +}; + const WelcomeEmailModal = NiceModal.create(({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(null); + const handleError = useHandleError(); + const {settings} = useGlobalData(); + const [siteTitle, defaultEmailAddress] = getSettingValues(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}); + }, + onSaveError: handleError, + onValidate: (state) => { + const newErrors: Record = {}; + + 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(() => { @@ -116,18 +102,36 @@ const WelcomeEmailModal = NiceModal.create(({emailType = }; }, [showTestDropdown]); + const handleSaveRef = useRef(handleSave); + useEffect(() => { + handleSaveRef.current = handleSave; + }, [handleSave]); + + 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 ( { updateRoute('memberemails'); }} - dirty={hasChanges} + dirty={saveState === 'unsaved'} footer={false} header={false} testId='welcome-email-modal' - onOk={() => { - modal.remove(); - }} >
@@ -168,73 +172,55 @@ const WelcomeEmailModal = NiceModal.create(({emailType = )}
From:
-
- updateFormData('sender_name', e.target.value)} - /> - updateFormData('sender_email', e.target.value)} - /> +
+ {automatedEmail?.sender_name || siteTitle} + {`<${senderEmail}>`}
- -
-
Reply-to:
-
- updateFormData('sender_reply_to', e.target.value)} - /> + {replyToEmail !== senderEmail && ( +
+
Reply-to:
+
+ {replyToEmail} +
-
- -
+ )} +
Subject:
updateFormData('subject', e.target.value)} + placeholder={`Welcome to ${siteTitle}`} + value={formState.subject} + onChange={e => updateForm(state => ({...state, subject: e.target.value}))} />
-
+
updateFormData('lexical', lexical)} + value={formState.lexical} + onChange={lexical => updateForm(state => ({...state, lexical}))} />
+ {errors.lexical && {errors.lexical}}
diff --git a/e2e/helpers/pages/admin/settings/sections/member-welcome-emails-section.ts b/e2e/helpers/pages/admin/settings/sections/member-welcome-emails-section.ts index ecca06d244e..3b109d62f0e 100644 --- a/e2e/helpers/pages/admin/settings/sections/member-welcome-emails-section.ts +++ b/e2e/helpers/pages/admin/settings/sections/member-welcome-emails-section.ts @@ -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) { @@ -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"]'); } @@ -95,6 +91,6 @@ export class MemberWelcomeEmailsSection extends BasePage { async saveWelcomeEmail(): Promise { await this.modalSaveButton.click(); - await this.welcomeEmailModal.waitFor({state: 'hidden'}); + await this.modalSavedButton.waitFor({state: 'visible'}); } } diff --git a/e2e/tests/admin/settings/member-welcome-emails.test.ts b/e2e/tests/admin/settings/member-welcome-emails.test.ts index 87f799a3ee2..24ed8171a62 100644 --- a/e2e/tests/admin/settings/member-welcome-emails.test.ts +++ b/e2e/tests/admin/settings/member-welcome-emails.test.ts @@ -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);