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
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -880,11 +880,11 @@ jobs:
- name: Determine push strategy
id: strategy
run: |
# Same-org repos (e.g. TryGhost/Ghost, TryGhost/Ghost-Security) push to GHCR.
# External forks and cross-repo PRs use artifact-based image transfer instead.
# Only the canonical repo publishes to GHCR by default.
# Direct clones, external forks, and cross-repo PRs use artifact-based image transfer instead.
USE_ARTIFACT="false"
if [ "${{ github.repository_owner }}" != "TryGhost" ]; then
# External fork — no GHCR push
if [ "${{ github.repository }}" != "TryGhost/Ghost" ]; then
# Non-canonical repo - no GHCR push
USE_ARTIFACT="true"
elif [ "${{ github.event_name }}" = "pull_request" ] && \
[ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then
Expand All @@ -905,9 +905,9 @@ jobs:
IMAGE_FULL_NAME="ghcr.io/${OWNER}/${REPO_NAME}"
fi

# Force push on tag pushes (release images must always be published)
# Force push on canonical tag pushes (release images must always be published)
IS_TAG="${{ startsWith(github.ref, 'refs/tags/v') }}"
if [ "$IS_TAG" = "true" ]; then
if [ "$IS_TAG" = "true" ] && [ "${{ github.repository }}" = "TryGhost/Ghost" ]; then
USE_ARTIFACT="false"
fi

Expand Down
2 changes: 1 addition & 1 deletion apps/admin-x-framework/src/api/automations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ const buildSendEmailAction = (): AutomationSendEmailAction => ({
id: generateActionId(),
type: 'send_email',
data: {
email_subject: 'Untitled email',
email_subject: '',
email_lexical: EMPTY_EMAIL_LEXICAL,
email_sender_name: null,
email_sender_email: null,
Expand Down
2 changes: 1 addition & 1 deletion apps/admin-x-framework/test/unit/api/automations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ describe('automations api helpers', () => {
expect(next.actions).toHaveLength(1);
const newAction = next.actions[0];
expectSendEmailAction(newAction);
expect(newAction.data.email_subject).toBe('Untitled email');
expect(newAction.data.email_subject).toBe('');
expect(() => JSON.parse(newAction.data.email_lexical)).not.toThrow();
expect(JSON.parse(newAction.data.email_lexical).root.children).toEqual([]);
expect(newAction.data.email_design_setting_id).toBe('placeholder');
Expand Down
210 changes: 150 additions & 60 deletions apps/posts/src/views/Automations/components/automation-canvas.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,10 @@ const EmailContentModal: React.FC<EmailContentModalProps> = ({initialMode = 'edi
|| automatedEmails[0]
)?.id || '';

const {formState, saveState, updateForm, setFormState, setErrors, handleSave, okProps, errors, validate} = useForm({
// Saving commits whatever the user has — including an empty subject or body — to the
// automation draft. Completeness is only enforced when publishing the automation or
// sending a test email (see validateForTest below), not when saving a draft.
const {formState, saveState, updateForm, setFormState, setErrors, handleSave, okProps, errors, clearError} = useForm({
initialState: {
subject: initialSubject || '',
lexical: initialLexical || ''
Expand All @@ -120,9 +123,14 @@ const EmailContentModal: React.FC<EmailContentModalProps> = ({initialMode = 'edi
onSave: async (state) => {
onSave({subject: state.subject, lexical: state.lexical});
},
onSaveError: handleError,
onValidate: getEmailValidationErrors
onSaveError: handleError
});

const validateForTest = useCallback((): boolean => {
const newErrors = getEmailValidationErrors(formState);
setErrors(newErrors);
return Object.values(newErrors).every(error => !error);
}, [formState, setErrors]);
const saveButtonLabel = okProps.label || 'Save';
const {previewFrameState, enterPreview, exitPreview} = useEmailPreview({
automatedEmailId: previewAutomatedEmailId,
Expand Down Expand Up @@ -290,7 +298,7 @@ const EmailContentModal: React.FC<EmailContentModalProps> = ({initialMode = 'edi
Test
</Button>
{showTestDropdown && (
<TestEmailDropdown automatedEmailId={previewAutomatedEmailId} lexical={formState.lexical} subject={formState.subject} validateForm={validate} onClose={() => setShowTestDropdown(false)} />
<TestEmailDropdown automatedEmailId={previewAutomatedEmailId} lexical={formState.lexical} subject={formState.subject} validateForm={validateForTest} onClose={() => setShowTestDropdown(false)} />
)}
</div>
</div>
Expand All @@ -313,6 +321,7 @@ const EmailContentModal: React.FC<EmailContentModalProps> = ({initialMode = 'edi
const nextSubject = e.target.value;
setPreviewSubjectOverride(nextSubject);
updateForm(state => ({...state, subject: nextSubject}));
clearError('subject');
}}
/>
{errors.subject && <span className='mt-2 block text-xs text-destructive'>{errors.subject}</span>}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,24 +66,24 @@ export const useEmailPreview = ({automatedEmailId, previewWelcomeEmail, setError
const exitPreview = () => {
previewRequestIdRef.current += 1;
setPreviewState({status: 'idle'});
setErrors({});
};

const enterPreview = async (draft: EmailDraft) => {
const requestId = previewRequestIdRef.current + 1;
previewRequestIdRef.current = requestId;

const validationErrors = getEmailValidationErrors(draft);
setErrors(validationErrors);

const hasValidationErrors = Boolean(validationErrors.subject || validationErrors.lexical);
if (hasValidationErrors) {
if (validationErrors.lexical) {
setErrors({lexical: validationErrors.lexical});
setPreviewState({
status: 'invalid',
message: validationErrors.subject || validationErrors.lexical
message: validationErrors.lexical
});
return;
}

setErrors({});
setPreviewState({status: 'loading'});

try {
Expand Down
108 changes: 103 additions & 5 deletions apps/posts/src/views/Automations/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,34 @@ import React from 'react';
import {AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Button, type ButtonProps, LoadingIndicator} from '@tryghost/shade/components';
import {AutomationDetail, AutomationStatus, useEditAutomation, useReadAutomation} from '@tryghost/admin-x-framework/api/automations';
import {dequal} from 'dequal';
import {toast} from 'sonner';
import {useBlocker} from 'react-router';
import {useConfirmUnload, useParams} from '@tryghost/admin-x-framework';
import type {AutomationEditState} from './types';

const SUBJECT_REQUIRED_MESSAGE = 'Add a subject line.';
const BODY_REQUIRED_MESSAGE = 'Add an email body.';
const SUBJECT_AND_BODY_REQUIRED_MESSAGE = 'Add a subject line and email body.';

const isEmptyEmailLexical = (lexical: string | null | undefined): boolean => {
if (!lexical) {
return true;
}

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

if (!children || children.length === 0) {
return true;
}

return children.length === 1 && children[0].type === 'paragraph' && (!children[0].children || children[0].children.length === 0);
} catch {
return true;
}
};

const editableSlice = (automation: AutomationDetail) => ({
status: automation.status,
actions: automation.actions,
Expand Down Expand Up @@ -36,6 +60,29 @@ const isFailedEditState = (editState: AutomationEditState): boolean => {
}
};

const getActionErrors = (automation: AutomationDetail): Record<string, string> => {
const errors: Record<string, string> = {};

for (const action of automation.actions) {
if (action.type !== 'send_email') {
continue;
}

const missingSubject = !action.data.email_subject.trim();
const missingBody = isEmptyEmailLexical(action.data.email_lexical);

if (missingSubject && missingBody) {
errors[action.id] = SUBJECT_AND_BODY_REQUIRED_MESSAGE;
} else if (missingSubject) {
errors[action.id] = SUBJECT_REQUIRED_MESSAGE;
} else if (missingBody) {
errors[action.id] = BODY_REQUIRED_MESSAGE;
}
}

return errors;
};

const AutomationEditor: React.FC = () => {
const {id = ''} = useParams<{id: string}>();

Expand All @@ -46,6 +93,7 @@ const AutomationEditor: React.FC = () => {

const editMutation = useEditAutomation();
const [editState, setEditState] = React.useState<AutomationEditState>('idle');
const [actionErrors, setActionErrors] = React.useState<Record<string, string>>({});

// Draft is the user-facing, locally mutable copy. The React Query cache stays as server truth;
// staged edits (adding steps, etc.) live here until the user publishes. Seeded once when the
Expand All @@ -67,11 +115,35 @@ const AutomationEditor: React.FC = () => {

const onDraftChange = (next: AutomationDetail) => {
setDraft(next);
setActionErrors((oldErrors) => {
if (Object.keys(oldErrors).length === 0) {
return oldErrors;
}

const nextErrors = getActionErrors(next);
return Object.fromEntries(
Object.entries(oldErrors).filter(([actionId]) => nextErrors[actionId])
);
});
setEditState(prev => (
isFailedEditState(prev) ? 'idle' : prev
));
};

const validateActionErrors = (automationToValidate: AutomationDetail, errorState: AutomationEditState): boolean => {
const nextActionErrors = getActionErrors(automationToValidate);
if (Object.keys(nextActionErrors).length > 0) {
setActionErrors(nextActionErrors);
setEditState(errorState);
toast.error('Automation couldn’t be saved', {
description: 'Fix the highlighted steps and try again.'
});
return false;
}

return true;
};

const save = (statusToSave?: AutomationStatus) => {
if (!draft) {
throw new Error('Cannot edit an automation that has not loaded.');
Expand Down Expand Up @@ -106,6 +178,10 @@ const AutomationEditor: React.FC = () => {
}
}

if (newStatus === 'active' && !validateActionErrors(draft, errorState)) {
return;
}

setEditState(requestState);

editMutation.mutate(
Expand All @@ -118,9 +194,26 @@ const AutomationEditor: React.FC = () => {
{
onSuccess: (response) => {
setDraft(response.automations[0]);
setActionErrors({});
setEditState('idle');
},
onError: () => setEditState(errorState)
onError: (error) => {
void error;
const nextActionErrors = newStatus === 'active' ? getActionErrors(draft) : {};
const hasActionErrors = Object.keys(nextActionErrors).length > 0;
if (hasActionErrors) {
setActionErrors(nextActionErrors);
}

setEditState(errorState);
if (hasActionErrors) {
toast.error('Automation couldn’t be saved', {
description: 'Fix the highlighted steps and try again.'
});
} else {
toast.error('Automation couldn’t be saved');
}
}
}
);
};
Expand Down Expand Up @@ -260,18 +353,22 @@ const AutomationEditor: React.FC = () => {
};

const onPublish = (): void => {
const draftStatus = draft?.status;
switch (draftStatus) {
case undefined:
if (!draft) {
throw new Error('Cannot publish an automation that has not loaded.');
}

switch (draft.status) {
case 'active':
if (!validateActionErrors(draft, 'idle')) {
return;
}
setEditState('confirming re-publish');
break;
case 'inactive':
save('active');
break;
default: {
const _exhaustive: never = draftStatus;
const _exhaustive: never = draft.status;
throw new Error(`Unhandled status: ${_exhaustive}`);
}
}
Expand Down Expand Up @@ -306,6 +403,7 @@ const AutomationEditor: React.FC = () => {
/>

<AutomationCanvas
actionErrors={actionErrors}
automation={draft}
isError={isError}
isLoading={isLoadingAutomation}
Expand Down
Loading