diff --git a/services/platform/app/features/automations/components/project-bindings-section.test.tsx b/services/platform/app/features/automations/components/project-bindings-section.test.tsx
index 480972d391..48e8a1c8c1 100644
--- a/services/platform/app/features/automations/components/project-bindings-section.test.tsx
+++ b/services/platform/app/features/automations/components/project-bindings-section.test.tsx
@@ -4,7 +4,7 @@ import { ActiveEditorProvider, EditorGroup } from '@tale/ui/editor';
import React, { type ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
-import { render, screen } from '@/tests/utils/render';
+import { act, render, screen, waitFor } from '@/tests/utils/render';
// The section reads the bound set and the org's projects reactively and saves
// through the reconcile mutation; the tests stub all three seams.
@@ -22,6 +22,7 @@ vi.mock('@/app/features/projects/hooks/queries', () => ({
projects: [
{ _id: 'proj_1', name: 'Document desk' },
{ _id: 'proj_2', name: 'Getting started' },
+ { _id: 'proj_3', name: 'Field service' },
],
isLoading: false,
}),
@@ -51,6 +52,27 @@ function GeneralTab({ children }: { children: ReactNode }) {
const saveButton = () =>
screen.getByRole('button', { name: 'common.actions.save' });
+/** The selected projects, in chip order — each chip carries a remove button. */
+const selectedProjects = () =>
+ screen
+ .queryAllByRole('button', { name: /^Remove / })
+ .map((button) =>
+ button.getAttribute('aria-label')?.replace(/^Remove /, ''),
+ );
+
+/** An author's section, rendered again as each query answer arrives. */
+function section() {
+ return (
+
+
+
+ );
+}
+
describe('ProjectBindingsSection', () => {
it('shows the hint and no count for an unbound automation, and waits for an edit', () => {
boundData = [];
@@ -106,15 +128,6 @@ describe('ProjectBindingsSection', () => {
it('keeps a selection in progress when a refetch answers the same set', async () => {
boundData = ['proj_1'];
- const section = () => (
-
-
-
- );
const { user, rerender } = render(section());
await user.click(screen.getByRole('combobox'));
@@ -127,6 +140,145 @@ describe('ProjectBindingsSection', () => {
expect(saveButton()).toBeEnabled();
});
+ // A set another session saved reaches a selection holding an edit (#3620):
+ // the author's unsaved adds and removes are replayed onto it.
+ it('keeps an unsaved project when another session changes the bound set', async () => {
+ boundData = ['proj_1'];
+ setProjects.mutateAsync.mockResolvedValue(undefined);
+ const { user, rerender } = render(section());
+
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: /Getting started/ }));
+ await user.keyboard('{Escape}');
+ boundData = ['proj_1', 'proj_3'];
+ rerender(section());
+
+ expect(selectedProjects()).toEqual([
+ 'Document desk',
+ 'Field service',
+ 'Getting started',
+ ]);
+ expect(saveButton()).toBeEnabled();
+ await user.click(saveButton());
+ expect(setProjects.mutateAsync).toHaveBeenLastCalledWith(
+ expect.objectContaining({ projectIds: ['proj_1', 'proj_3', 'proj_2'] }),
+ );
+ });
+
+ it('keeps an unsaved removal when another session adds a project', async () => {
+ boundData = ['proj_1', 'proj_2'];
+ const { user, rerender } = render(section());
+
+ await user.click(
+ screen.getByRole('button', { name: 'Remove Document desk' }),
+ );
+ boundData = ['proj_1', 'proj_2', 'proj_3'];
+ rerender(section());
+
+ expect(selectedProjects()).toEqual(['Getting started', 'Field service']);
+ expect(saveButton()).toBeEnabled();
+ });
+
+ it('settles on the set its own save wrote', async () => {
+ boundData = ['proj_1'];
+ setProjects.mutateAsync.mockResolvedValue(undefined);
+ const { user, rerender } = render(section());
+
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: /Getting started/ }));
+ await user.keyboard('{Escape}');
+ await user.click(saveButton());
+ // The store answers with the saved set, in its own order.
+ boundData = ['proj_2', 'proj_1'];
+ rerender(section());
+
+ expect(selectedProjects()).toEqual(['Getting started', 'Document desk']);
+ // Nothing is left to save (Save reads "Saved" for a moment).
+ expect(
+ screen.getByRole('button', { name: 'common.actions.discard' }),
+ ).toBeDisabled();
+ expect(
+ screen.getByRole('button', { name: /^common\.actions\.saved?$/ }),
+ ).toBeDisabled();
+ });
+
+ /** Pick Getting started and press Save, whose answer waits for `answer`. */
+ async function addAndSaveDeferred(
+ user: ReturnType['user'],
+ ): Promise<() => void> {
+ let answer: () => void = () => {};
+ setProjects.mutateAsync.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ answer = resolve;
+ }),
+ );
+ await user.click(screen.getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: /Getting started/ }));
+ await user.keyboard('{Escape}');
+ await user.click(saveButton());
+ await waitFor(() =>
+ expect(setProjects.mutateAsync).toHaveBeenLastCalledWith(
+ expect.objectContaining({ projectIds: ['proj_1', 'proj_2'] }),
+ ),
+ );
+ return () => answer();
+ }
+
+ // The picker stays editable while a save is out (#4321 review B2): a
+ // project dropped after Save stays dropped, whichever of the save's set
+ // and its answer comes first.
+ it.each([
+ ['before the save answers', true],
+ ['after the save answers', false],
+ ])(
+ 'keeps a project dropped during the save when its set lands %s',
+ async (_when, setFirst) => {
+ boundData = ['proj_1'];
+ const { user, rerender } = render(section());
+ const answer = await addAndSaveDeferred(user);
+ await user.click(
+ screen.getByRole('button', { name: 'Remove Getting started' }),
+ );
+ if (setFirst) {
+ boundData = ['proj_1', 'proj_2'];
+ rerender(section());
+ }
+ await act(async () => {
+ answer();
+ });
+ if (!setFirst) {
+ boundData = ['proj_1', 'proj_2'];
+ rerender(section());
+ }
+
+ expect(selectedProjects()).toEqual(['Document desk']);
+ expect(
+ screen.getByRole('button', { name: 'common.actions.discard' }),
+ ).toBeEnabled();
+ },
+ );
+
+ it('settles on its own save when another session’s set lands first', async () => {
+ boundData = ['proj_1'];
+ const { user, rerender } = render(section());
+ const answer = await addAndSaveDeferred(user);
+ // Another session unbinds Document desk; then this save, written after
+ // it, lands with both projects bound.
+ boundData = [];
+ rerender(section());
+ boundData = ['proj_1', 'proj_2'];
+ rerender(section());
+ await act(async () => {
+ answer();
+ });
+
+ expect(selectedProjects()).toEqual(['Document desk', 'Getting started']);
+ expect(
+ screen.getByRole('button', { name: 'common.actions.discard' }),
+ ).toBeDisabled();
+ });
+
it('discards the selection back to the stored set', async () => {
boundData = ['proj_1'];
const { user } = render(
diff --git a/services/platform/app/features/automations/components/project-bindings-section.tsx b/services/platform/app/features/automations/components/project-bindings-section.tsx
index 1244417936..3bf85545e9 100644
--- a/services/platform/app/features/automations/components/project-bindings-section.tsx
+++ b/services/platform/app/features/automations/components/project-bindings-section.tsx
@@ -28,6 +28,26 @@ function sameSelection(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
+/**
+ * `incoming` with the author's unsaved edits replayed on it: a project they
+ * picked or dropped — since `loaded`, or since the save in flight sent
+ * `sent` — keeps their choice, and every other project follows `incoming`.
+ */
+function keepEdits(
+ loaded: readonly string[],
+ current: readonly string[],
+ incoming: readonly string[],
+ sent: readonly string[] = loaded,
+): string[] {
+ const edited = (id: string): boolean =>
+ current.includes(id) !== loaded.includes(id) ||
+ current.includes(id) !== sent.includes(id);
+ return [
+ ...incoming.filter((id) => !edited(id) || current.includes(id)),
+ ...current.filter((id) => edited(id) && !incoming.includes(id)),
+ ];
+}
+
/**
* The automation's project bindings: which projects' task boards see it.
*
@@ -64,17 +84,33 @@ export function ProjectBindingsSection({
);
const [selection, setSelection] = useState([]);
- // The rows are the truth; local state only carries unsaved edits. Keyed on
- // the set's content, not on the array, so a refetch that answers the same
- // set never wipes a selection in progress; and the functional update
- // returns the CURRENT array when the content already matches, so React
- // bails out of the re-render.
+ // The rows are the truth; local state only carries unsaved edits, which a
+ // set another session saved does not erase: they are replayed on it. Keyed
+ // on the set's content, not on the array, so a refetch that answers the
+ // same set loads nothing; and the functional update returns the CURRENT
+ // array when the content already matches, so React bails out of the
+ // re-render.
const storedRef = useRef(stored);
storedRef.current = stored;
+ // The set the selection last loaded (or saved), and while a save is out
+ // the set it sent: what tells the author's edits apart.
+ const loadedRef = useRef([]);
+ const sentRef = useRef(null);
const storedKey = stored.join(',');
useEffect(() => {
const next = storedRef.current;
- setSelection((current) => (sameSelection(current, next) ? current : next));
+ const loaded = loadedRef.current;
+ const sent = sentRef.current ?? loaded;
+ loadedRef.current = next;
+ // A project the save in flight left as loaded follows the set from now
+ // on, as the selection does.
+ if (sentRef.current !== null) {
+ sentRef.current = keepEdits(loaded, sentRef.current, next);
+ }
+ setSelection((current) => {
+ const rebased = keepEdits(loaded, current, next, sent);
+ return sameSelection(current, rebased) ? current : rebased;
+ });
}, [storedKey]);
const dirty = useMemo(() => {
@@ -90,6 +126,7 @@ export function ProjectBindingsSection({
/** Write the selection as the automation's binding set. */
const persist = async (): Promise => {
+ sentRef.current = selection;
try {
await setProjects.mutateAsync({
organizationId,
@@ -97,10 +134,16 @@ export function ProjectBindingsSection({
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- every value came from the projects listing
projectIds: selection,
});
+ // The store holds the selection as sent: a project still as sent
+ // follows the set it answers with, while one changed during the save
+ // keeps the change.
+ loadedRef.current = sentRef.current ?? selection;
} catch (error) {
// The store's refusal names the problem and the fix; the cluster
// raises it as the save's one failure toast.
throw new Error(automationErrorMessage(error), { cause: error });
+ } finally {
+ sentRef.current = null;
}
};
diff --git a/services/platform/app/features/automations/components/trigger-editor.test.tsx b/services/platform/app/features/automations/components/trigger-editor.test.tsx
index 9ddb1dd782..0f9cb07195 100644
--- a/services/platform/app/features/automations/components/trigger-editor.test.tsx
+++ b/services/platform/app/features/automations/components/trigger-editor.test.tsx
@@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event';
import type { AnchorHTMLAttributes, ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, waitFor } from '@/tests/utils/render';
+import { act, render, screen, waitFor } from '@/tests/utils/render';
import { AutomationEditorActions } from './automation-editor-actions';
import { TriggerEditor } from './trigger-editor';
@@ -129,6 +129,8 @@ function renderTrigger(
const saveButton = () => screen.getByRole('button', { name: 'Save' });
const discardButton = () => screen.getByRole('button', { name: 'Discard' });
+/** Save, which reads "Saved" for a moment after a save went through. */
+const savedButton = () => screen.getByRole('button', { name: /^Saved?$/ });
describe('TriggerEditor', () => {
beforeEach(() => {
@@ -200,6 +202,196 @@ describe('TriggerEditor', () => {
expect(saveButton()).toBeEnabled();
});
+ // A row another session saved reaches a form holding an edit (#3620): the
+ // fields the author changed keep the edit, the ones they left alone follow
+ // the row — and a save's own row still settles the form.
+ describe('a row saved while the form holds an edit', () => {
+ const WEBHOOK_ROW = {
+ name: 'gmail-triage-inbox',
+ kind: 'webhook',
+ hasToken: true,
+ enabled: true,
+ };
+
+ function rerenderWith(
+ rerender: ReturnType['rerender'],
+ row: NonNullable[number],
+ ) {
+ triggersData = [row];
+ rerender(
+
+
+ ,
+ );
+ }
+
+ async function editCron() {
+ const cron = screen.getByLabelText('Cron');
+ await userEvent.clear(cron);
+ await userEvent.paste('0 9 * * 1');
+ return cron;
+ }
+
+ /** Edit the cron, make the binding a webhook, and press Save. */
+ async function saveCronEditAsWebhook() {
+ await editCron();
+ await userEvent.click(
+ screen.getByRole('combobox', { name: 'Trigger type' }),
+ );
+ await userEvent.click(screen.getByRole('option', { name: 'Webhook' }));
+ await userEvent.click(saveButton());
+ await waitFor(() => expect(mockSetTrigger).toHaveBeenCalledTimes(1));
+ }
+
+ it('keeps an edited cron when another session switches the trigger off', async () => {
+ const { rerender } = renderTrigger();
+ const cron = await editCron();
+ rerenderWith(rerender, { ...SCHEDULE_ROW, enabled: false });
+
+ expect(cron).toHaveValue('0 9 * * 1');
+ // The switch the author left alone takes the other session's answer.
+ expect(screen.getByRole('switch', { name: 'Enabled' })).not.toBeChecked();
+ expect(saveButton()).toBeEnabled();
+
+ await userEvent.click(saveButton());
+ expect(mockSetTrigger).toHaveBeenCalledWith({
+ organizationId: 'org-1',
+ name: 'gmail-triage-inbox',
+ trigger: {
+ kind: 'schedule',
+ cron: '0 9 * * 1',
+ timezone: 'UTC',
+ enabled: false,
+ },
+ });
+ });
+
+ it('keeps an edited cron when another session saved a different one', async () => {
+ const { rerender } = renderTrigger();
+ const cron = await editCron();
+ rerenderWith(rerender, { ...SCHEDULE_ROW, cron: '0 7 * * *' });
+
+ expect(cron).toHaveValue('0 9 * * 1');
+ expect(saveButton()).toBeEnabled();
+ });
+
+ it('keeps the edit when the save is refused while another session’s row arrives', async () => {
+ let refuse: (error: Error) => void = () => {};
+ mockSetTrigger.mockImplementation(
+ () =>
+ new Promise((_resolve, reject) => {
+ refuse = reject;
+ }),
+ );
+ const { rerender } = renderTrigger();
+ const cron = await editCron();
+ await userEvent.click(saveButton());
+ await waitFor(() => expect(mockSetTrigger).toHaveBeenCalledTimes(1));
+ rerenderWith(rerender, { ...SCHEDULE_ROW, enabled: false });
+ await act(async () => {
+ refuse(new Error('The store refused the trigger.'));
+ });
+
+ expect(cron).toHaveValue('0 9 * * 1');
+ expect(screen.getByRole('switch', { name: 'Enabled' })).not.toBeChecked();
+ expect(saveButton()).toBeEnabled();
+ });
+
+ it('settles on the row its own save wrote, cron left behind', async () => {
+ const { rerender } = renderTrigger();
+ await saveCronEditAsWebhook();
+ // The store holds the webhook now, with no cron.
+ rerenderWith(rerender, WEBHOOK_ROW);
+
+ // Nothing is left to save: the cluster reads clean, not dirty.
+ expect(discardButton()).toBeDisabled();
+ expect(savedButton()).toBeDisabled();
+ });
+
+ it('settles on its own save when the row lands before the save answers', async () => {
+ let answer: (value: object) => void = () => {};
+ mockSetTrigger.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ answer = resolve;
+ }),
+ );
+ const { rerender } = renderTrigger();
+ await saveCronEditAsWebhook();
+ rerenderWith(rerender, WEBHOOK_ROW);
+ await act(async () => {
+ answer({});
+ });
+
+ expect(discardButton()).toBeDisabled();
+ expect(savedButton()).toBeDisabled();
+ });
+
+ // The fields stay editable while a save is out (#4321 review B1): a
+ // change made after Save is the author's newest word, even one back to
+ // the value the form loaded, whichever of the save's row and its answer
+ // comes first.
+ it.each([
+ ['before the save answers', true],
+ ['after the save answers', false],
+ ])(
+ 'keeps a cron changed back during the save when its row lands %s',
+ async (_when, rowFirst) => {
+ let answer: (value: object) => void = () => {};
+ mockSetTrigger.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ answer = resolve;
+ }),
+ );
+ const { rerender } = renderTrigger();
+ const cron = await editCron();
+ await userEvent.click(saveButton());
+ await waitFor(() => expect(mockSetTrigger).toHaveBeenCalledTimes(1));
+ await userEvent.clear(cron);
+ await userEvent.paste('0 */6 * * *');
+ const saved = { ...SCHEDULE_ROW, cron: '0 9 * * 1' };
+ if (rowFirst) rerenderWith(rerender, saved);
+ await act(async () => {
+ answer({});
+ });
+ if (!rowFirst) rerenderWith(rerender, saved);
+
+ expect(cron).toHaveValue('0 */6 * * *');
+ expect(discardButton()).toBeEnabled();
+ expect(savedButton()).toBeEnabled();
+ },
+ );
+
+ it('settles on its own save when another session’s row lands first', async () => {
+ let answer: (value: object) => void = () => {};
+ mockSetTrigger.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ answer = resolve;
+ }),
+ );
+ const { rerender } = renderTrigger();
+ await editCron();
+ await userEvent.click(saveButton());
+ await waitFor(() => expect(mockSetTrigger).toHaveBeenCalledTimes(1));
+ // Another session switches the trigger off; then this save, written
+ // after it, lands with the switch on again.
+ rerenderWith(rerender, { ...SCHEDULE_ROW, enabled: false });
+ rerenderWith(rerender, { ...SCHEDULE_ROW, cron: '0 9 * * 1' });
+ await act(async () => {
+ answer({});
+ });
+
+ expect(screen.getByRole('switch', { name: 'Enabled' })).toBeChecked();
+ expect(discardButton()).toBeDisabled();
+ });
+ });
+
it('holds Save while the cron cannot be read', async () => {
renderTrigger();
diff --git a/services/platform/app/features/automations/components/trigger-editor.tsx b/services/platform/app/features/automations/components/trigger-editor.tsx
index 90524b3848..34c34a29c4 100644
--- a/services/platform/app/features/automations/components/trigger-editor.tsx
+++ b/services/platform/app/features/automations/components/trigger-editor.tsx
@@ -60,6 +60,52 @@ type StoredTrigger = NonNullable<
ReturnType['data']
>[number];
+/** The fields the form edits, as a binding holds them. */
+interface TriggerFields {
+ kind: string;
+ cron: string;
+ timezone: string;
+ event: string;
+ enabled: boolean;
+}
+
+/** The empty form a new binding starts from. */
+const NO_TRIGGER: TriggerFields = {
+ kind: 'schedule',
+ cron: '',
+ timezone: 'UTC',
+ event: '',
+ enabled: false,
+};
+
+function triggerFields(row: StoredTrigger | undefined): TriggerFields {
+ if (row === undefined) return NO_TRIGGER;
+ return {
+ kind: row.kind,
+ cron: row.cron ?? '',
+ timezone: row.timezone ?? 'UTC',
+ event: row.event ?? '',
+ enabled: row.enabled,
+ };
+}
+
+/** `sent` with each field it left as `loaded` moved on to `incoming`. */
+function keepSentEdits(
+ loaded: TriggerFields,
+ sent: TriggerFields,
+ incoming: TriggerFields,
+): TriggerFields {
+ const pick = (key: K): TriggerFields[K] =>
+ sent[key] === loaded[key] ? incoming[key] : sent[key];
+ return {
+ kind: pick('kind'),
+ cron: pick('cron'),
+ timezone: pick('timezone'),
+ event: pick('event'),
+ enabled: pick('enabled'),
+ };
+}
+
const NO_DIRTY_KEYS: ReadonlySet = new Set();
/** What the General tab's strip lights its unsaved dot for. */
const TRIGGER_DIRTY_KEYS: ReadonlySet = new Set([TRIGGER_DIRTY_KEY]);
@@ -144,44 +190,73 @@ export function TriggerEditor({
// empty schedule that looks armed.
const [adding, setAdding] = useState(false);
- /** Put a binding (none: the empty form) into the fields. */
- const applyStored = useCallback((row: typeof stored) => {
- setAdding(false);
- if (row === undefined) {
- setKind('schedule');
- setCron('');
- setTimezone('UTC');
- setEventName('');
- setEnabled(false);
- return;
- }
- if (isTriggerKind(row.kind)) setKind(row.kind);
- setCron(row.cron ?? '');
- setTimezone(row.timezone ?? 'UTC');
- setEventName(row.event ?? '');
- setEnabled(row.enabled);
- }, []);
+ // The fields as the form last loaded them (or saved them): what tells an
+ // edit from a field the author left alone.
+ const loadedRef = useRef(NO_TRIGGER);
+ // While a save is out, the fields it sent: a field changed since then is
+ // an edit too, even one changed back to its loaded value.
+ const sentRef = useRef(null);
+
+ /**
+ * Put a binding (none: the empty form) into the fields. With `keepEdits`,
+ * a field the author changed — since the form last loaded, or since the
+ * save in flight sent it — keeps the edit, and only the fields left alone
+ * take the binding's values: a row another session saved, or a save's
+ * own row, must not erase a draft in progress.
+ */
+ const applyStored = useCallback(
+ (row: StoredTrigger | undefined, keepEdits = false) => {
+ const loaded = loadedRef.current;
+ const sent = sentRef.current ?? loaded;
+ const next = triggerFields(row);
+ loadedRef.current = next;
+ // A field the save in flight left as loaded follows the row from now
+ // on, as the form does.
+ if (sentRef.current !== null) {
+ sentRef.current = keepSentEdits(loaded, sentRef.current, next);
+ }
+ const take = (
+ current: T,
+ wasLoaded: unknown,
+ wasSent: unknown,
+ incoming: T,
+ ): T =>
+ keepEdits && (current !== wasLoaded || current !== wasSent)
+ ? current
+ : incoming;
+ setAdding(false);
+ const nextKind = next.kind;
+ if (isTriggerKind(nextKind)) {
+ setKind((current) => take(current, loaded.kind, sent.kind, nextKind));
+ }
+ setCron((current) => take(current, loaded.cron, sent.cron, next.cron));
+ setTimezone((current) =>
+ take(current, loaded.timezone, sent.timezone, next.timezone),
+ );
+ setEventName((current) =>
+ take(current, loaded.event, sent.event, next.event),
+ );
+ setEnabled((current) =>
+ take(current, loaded.enabled, sent.enabled, next.enabled),
+ );
+ },
+ [],
+ );
// Load the stored binding into the form whenever it changes under us —
- // the row is the truth; local state only carries unsaved edits. Keyed on
- // the fields the form edits, not on the row object: a refetch that only
- // moves `lastFiredAt` (the trigger just fired) must not wipe an edit in
- // progress.
+ // the row is the truth; local state only carries unsaved edits, which
+ // the load keeps. Keyed on the fields the form edits, not on the row
+ // object: a refetch that only moves `lastFiredAt` (the trigger just
+ // fired) loads nothing.
const storedRef = useRef(stored);
storedRef.current = stored;
const storedFields =
- stored === undefined
- ? null
- : JSON.stringify([
- stored.kind,
- stored.cron ?? '',
- stored.timezone ?? 'UTC',
- stored.event ?? '',
- stored.enabled,
- ]);
+ stored === undefined ? null : JSON.stringify(triggerFields(stored));
+ const storedFieldsRef = useRef(storedFields);
+ storedFieldsRef.current = storedFields;
useEffect(() => {
if (storedFields === null) return;
- applyStored(storedRef.current);
+ applyStored(storedRef.current, true);
}, [storedFields, applyStored]);
const dirty = useMemo(() => {
@@ -251,23 +326,48 @@ export function TriggerEditor({
const persist = async (rotateToken?: boolean): Promise => {
setRefusal(null);
setMintedToken(null);
- const result = await setTrigger.mutateAsync({
- organizationId,
- name,
- trigger: {
- kind,
- ...(kind === 'schedule' && cron !== '' && { cron }),
- ...(kind === 'schedule' && timezone !== '' && { timezone }),
- ...(kind === 'event' && eventName !== '' && { event: eventName }),
- enabled,
- },
- ...(rotateToken === true && { rotateToken: true }),
- });
- if (result.token !== undefined) setMintedToken(result.token);
- // The server names the live URL this bind stopped answering on — say so,
- // since nothing on the page shows the old URL any more.
- if (result.revoked === 'webhook') {
- toast({ title: t('trigger.revokedToast') });
+ const sent: TriggerFields = {
+ kind,
+ cron,
+ timezone,
+ event: eventName,
+ enabled,
+ };
+ const storedFieldsBefore = storedFieldsRef.current;
+ sentRef.current = sent;
+ try {
+ const result = await setTrigger.mutateAsync({
+ organizationId,
+ name,
+ trigger: {
+ kind,
+ ...(kind === 'schedule' && cron !== '' && { cron }),
+ ...(kind === 'schedule' && timezone !== '' && { timezone }),
+ ...(kind === 'event' && eventName !== '' && { event: eventName }),
+ enabled,
+ },
+ ...(rotateToken === true && { rotateToken: true }),
+ });
+ // The store holds the form as sent: a field still as sent takes the
+ // row it answers with, which drops what the kind leaves out (a
+ // webhook keeps no cron), while an edit made during the save stays.
+ loadedRef.current = sentRef.current ?? sent;
+ if (result.token !== undefined) setMintedToken(result.token);
+ // The server names the live URL this bind stopped answering on — say
+ // so, since nothing on the page shows the old URL any more.
+ if (result.revoked === 'webhook') {
+ toast({ title: t('trigger.revokedToast') });
+ }
+ } finally {
+ sentRef.current = null;
+ // A row that arrived while the save was out loaded against the form
+ // as it stood before; load it again against what the store holds now.
+ if (
+ storedFieldsRef.current !== null &&
+ storedFieldsRef.current !== storedFieldsBefore
+ ) {
+ applyStored(storedRef.current, true);
+ }
}
};