Skip to content

Commit 4c21df9

Browse files
authored
Added Save button for inactive automations (#28055)
closes https://linear.app/ghost/issue/NY-1257 https://github.com/user-attachments/assets/fc9315d4-f7f4-49f6-ac71-f059bf5c1ce1 This was largely written by GPT-5.5 (High) with the following prompt: > Currently, the Automations editor has no way to save an inactive automation without publishing it. For example, if you're working on an automation and haven't finished but need to walk away, you want to click Save, not Publish. Let's add this. > > When an automation is inactive, add a secondary "Save" button to the left of the publish button in `<AutomationHeader>`. Clicking this button should call `editMutation.mutate()` like we already do in `Automations/editor.tsx`, but it shouldn't change the `status`. > > When an automation is active, I don't want it to look any different from today. There should be no "Save" button or anything different in that part of the UI. > > Use red/green TDD to accomplish this. I manually cleaned up a small amount afterward.
1 parent f1ed307 commit 4c21df9

4 files changed

Lines changed: 83 additions & 8 deletions

File tree

apps/posts/src/views/Automations/components/automation-header.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,29 @@ export type AutomationRequestState = 'idle' | 'loading' | 'error';
1010
interface AutomationHeaderProps {
1111
automation: AutomationDetail | undefined;
1212
isLoadingAutomation: boolean;
13+
isSaveButtonEnabled: boolean;
1314
isPublishButtonEnabled: boolean;
15+
saveButtonVariant: ButtonProps['variant'];
1416
publishButtonVariant: ButtonProps['variant'];
1517
isTurnOffButtonEnabled: boolean;
18+
saveButtonChildren: React.ReactNode;
1619
publishButtonChildren: React.ReactNode;
20+
onSave: () => void;
1721
onPublish: () => void;
1822
onTurnOff: () => void;
1923
}
2024

2125
const AutomationHeader: React.FC<AutomationHeaderProps> = ({
2226
automation,
2327
isLoadingAutomation,
28+
isSaveButtonEnabled,
2429
isPublishButtonEnabled,
30+
saveButtonVariant,
2531
publishButtonVariant,
2632
isTurnOffButtonEnabled,
33+
saveButtonChildren,
2734
publishButtonChildren,
35+
onSave,
2836
onPublish,
2937
onTurnOff
3038
}) => {
@@ -64,6 +72,15 @@ const AutomationHeader: React.FC<AutomationHeaderProps> = ({
6472
</DropdownMenuContent>
6573
</DropdownMenu>
6674
)}
75+
{status === 'inactive' && (
76+
<Button
77+
disabled={!isSaveButtonEnabled}
78+
variant={saveButtonVariant}
79+
onClick={onSave}
80+
>
81+
{saveButtonChildren}
82+
</Button>
83+
)}
6784
<Button
6885
disabled={!isPublishButtonEnabled}
6986
variant={publishButtonVariant}

apps/posts/src/views/Automations/editor.tsx

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,24 @@ const AutomationEditor: React.FC = () => {
4545
const onDraftChange = (next: AutomationDetail) => {
4646
setDraft(next);
4747
setEditState((prev) => {
48-
if (prev === 'failed to publish' || prev === 'failed to unpublish') {
48+
if (prev === 'failed to save' || prev === 'failed to publish' || prev === 'failed to unpublish') {
4949
return 'idle';
5050
}
5151
return prev;
5252
});
5353
};
5454

55-
const editStatus = (status: AutomationStatus): void => {
55+
const save = (statusToSave?: AutomationStatus) => {
5656
if (!draft) {
5757
throw new Error('Cannot edit an automation that has not loaded.');
5858
}
59+
5960
let errorState: AutomationEditState;
60-
switch (status) {
61+
switch (statusToSave) {
62+
case undefined:
63+
setEditState('saving');
64+
errorState = 'failed to save';
65+
break;
6166
case 'active':
6267
setEditState('publishing');
6368
errorState = 'failed to publish';
@@ -67,14 +72,14 @@ const AutomationEditor: React.FC = () => {
6772
errorState = 'failed to unpublish';
6873
break;
6974
default: {
70-
const _exhaustive: never = status;
75+
const _exhaustive: never = statusToSave;
7176
throw new Error(`Unhandled status: ${_exhaustive}`);
7277
}
7378
}
7479
editMutation.mutate(
7580
{
7681
id: draft.id,
77-
status,
82+
status: statusToSave ?? draft.status,
7883
actions: draft.actions,
7984
edges: draft.edges
8085
},
@@ -90,6 +95,9 @@ const AutomationEditor: React.FC = () => {
9095

9196
let isConfirmUnpublishAlertOpen = false;
9297
let isEditRequestActive = false;
98+
let isSaveButtonEnabled = !!draft && draft.actions.length > 0 && draft.status === 'inactive' && hasUnsavedChanges;
99+
let saveButtonVariant: ButtonProps['variant'] = 'secondary';
100+
let saveButtonChildren: React.ReactNode = 'Save';
93101
let isPublishButtonEnabled = !!draft && draft.actions.length > 0 && (draft.status === 'inactive' || hasUnsavedChanges);
94102
let publishButtonVariant: ButtonProps['variant'] = 'default';
95103
let publishButtonChildren: React.ReactNode = draft?.status === 'active'
@@ -98,8 +106,21 @@ const AutomationEditor: React.FC = () => {
98106
let isTurnOffButtonEnabled = true;
99107
let turnOffButtonChildren: React.ReactNode = 'Turn off';
100108
switch (editState) {
109+
case 'saving':
110+
isEditRequestActive = true;
111+
isSaveButtonEnabled = false;
112+
isPublishButtonEnabled = false;
113+
isTurnOffButtonEnabled = false;
114+
saveButtonChildren = (
115+
<>
116+
<LoadingIndicator size='sm' />
117+
<span className='sr-only'>Saving...</span>
118+
</>
119+
);
120+
break;
101121
case 'publishing':
102122
isEditRequestActive = true;
123+
isSaveButtonEnabled = false;
103124
isPublishButtonEnabled = false;
104125
isTurnOffButtonEnabled = false;
105126
publishButtonChildren = (
@@ -112,6 +133,7 @@ const AutomationEditor: React.FC = () => {
112133
case 'unpublishing':
113134
isEditRequestActive = true;
114135
isConfirmUnpublishAlertOpen = true;
136+
isSaveButtonEnabled = false;
115137
isPublishButtonEnabled = false;
116138
isTurnOffButtonEnabled = false;
117139
turnOffButtonChildren = (
@@ -123,9 +145,14 @@ const AutomationEditor: React.FC = () => {
123145
break;
124146
case 'confirming unpublish':
125147
isConfirmUnpublishAlertOpen = true;
148+
isSaveButtonEnabled = false;
126149
isPublishButtonEnabled = false;
127150
isTurnOffButtonEnabled = false;
128151
break;
152+
case 'failed to save':
153+
saveButtonVariant = 'destructive';
154+
saveButtonChildren = 'Retry';
155+
break;
129156
case 'failed to publish':
130157
publishButtonVariant = 'destructive';
131158
publishButtonChildren = 'Retry';
@@ -141,10 +168,12 @@ const AutomationEditor: React.FC = () => {
141168
setEditState((oldEditState) => {
142169
switch (oldEditState) {
143170
case 'idle':
171+
case 'failed to save':
144172
case 'failed to publish':
145173
return open ? 'confirming unpublish' : oldEditState;
146174
case 'failed to unpublish':
147175
return open ? 'confirming unpublish' : 'idle';
176+
case 'saving':
148177
case 'publishing':
149178
throw new Error('It should be impossible to hit this state');
150179
case 'unpublishing':
@@ -167,10 +196,14 @@ const AutomationEditor: React.FC = () => {
167196
automation={draft}
168197
isLoadingAutomation={isLoadingAutomation}
169198
isPublishButtonEnabled={isPublishButtonEnabled}
199+
isSaveButtonEnabled={isSaveButtonEnabled}
170200
isTurnOffButtonEnabled={isTurnOffButtonEnabled}
171201
publishButtonChildren={publishButtonChildren}
172202
publishButtonVariant={publishButtonVariant}
173-
onPublish={() => editStatus('active')}
203+
saveButtonChildren={saveButtonChildren}
204+
saveButtonVariant={saveButtonVariant}
205+
onPublish={() => save('active')}
206+
onSave={() => save()}
174207
onTurnOff={() => setEditState('confirming unpublish')}
175208
/>
176209

@@ -197,7 +230,7 @@ const AutomationEditor: React.FC = () => {
197230
<Button
198231
disabled={isEditRequestActive}
199232
variant={editState === 'failed to unpublish' ? 'destructive' : 'default'}
200-
onClick={() => editStatus('inactive')}
233+
onClick={() => save('inactive')}
201234
>
202235
{turnOffButtonChildren}
203236
</Button>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export type AutomationEditState = 'idle' | 'publishing' | 'unpublishing' | 'confirming unpublish' | 'failed to publish' | 'failed to unpublish';
1+
export type AutomationEditState = 'idle' | 'saving' | 'publishing' | 'unpublishing' | 'confirming unpublish' | 'failed to save' | 'failed to publish' | 'failed to unpublish';

apps/posts/test/unit/views/automations/automation-editor.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,30 @@ describe('AutomationEditor', () => {
351351
);
352352
});
353353

354+
it('saves an inactive automation without publishing it', async () => {
355+
mockUseReadAutomation.mockReturnValue({
356+
data: {automations: [{...automationDetail, status: 'inactive'}]},
357+
isLoading: false,
358+
isError: false
359+
});
360+
361+
renderEditor();
362+
363+
fireEvent.click(screen.getByTestId('add-step-tail-button'));
364+
const picker = await screen.findByTestId('step-picker');
365+
fireEvent.click(within(picker).getByText('Wait'));
366+
367+
const button = screen.getByRole('button', {name: 'Save'});
368+
expect(button).toBeEnabled();
369+
fireEvent.click(button);
370+
371+
const mutateCall = mockEditMutation.mutate.mock.calls.at(-1)![0];
372+
expect(mutateCall.id).toBe('automation-id-1');
373+
expect(mutateCall.status).toBe('inactive');
374+
expect(mutateCall.actions).toHaveLength(3);
375+
expect(mutateCall.edges).toHaveLength(2);
376+
});
377+
354378
it('shows the dropdown for active automations', () => {
355379
mockUseReadAutomation.mockReturnValue({
356380
data: {automations: [automationDetail]},
@@ -362,6 +386,7 @@ describe('AutomationEditor', () => {
362386

363387
expect(screen.getByRole('button', {name: 'Automation options'})).toBeInTheDocument();
364388
expect(screen.getByRole('button', {name: 'Published'})).toBeDisabled();
389+
expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
365390
});
366391

367392
it('hides the dropdown for inactive automations', () => {

0 commit comments

Comments
 (0)