From c3f2f2ccf72f0babdd0e382148324a231f4dbd95 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:00:48 +0000 Subject: [PATCH] Fetch fields when a settings save names one the Build tab lacks A "Group slots by" save shows the settings the server merges, which can name a reminder field another tab or an AI assistant added after this tab loaded. The tab then had no field to put the reminder bell on, and every date field's reminder box was blocked by a field it couldn't name. Now the save fetches the field list first when that happens, inside the same settings queue so a later save's answer still lands last. A failed fetch leaves the list as it was, since the save itself succeeded. Fixes #398. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N4Xf8UF6XA8WQ5rAxo5cYy --- CHANGELOG.md | 1 + .../build-shared/useBuildState.test.ts | 78 +++++++++++++++++++ src/components/build-shared/useBuildState.ts | 23 +++++- 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c92bfca..be92f1c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ All notable changes to OpenSignup are documented here. Format follows [Keep a Ch - SMTP sends reuse their connections (#333). nodemailer used to connect, set up TLS and log in for every email, so a busy reminder run opened one connection per reminder, which was slower and could hit a mail provider's connections-per-minute limit (a `421` reply, then a retried job). The SMTP transport now keeps a pool of up to 3 connections open between emails, each replaced after 100 messages; the web server and the worker each keep their own. A connection the mail server closes while idle is dropped, and the next email opens a new one. An email whose connection closes while it is being sent is tried once more on another connection, then fails as before. The connect timeouts, the STARTTLS requirement and the refusal of file paths and URLs as content apply to every pooled connection as they did to each single one. Console and Resend are unchanged. ### Fixed +- On the Build tab, a settings save that names a field the tab hasn't loaded now fetches the field list first (#398). When another tab or an AI assistant added a date field and moved reminders to it, the next "Group slots by" change made that field the reminder field without the tab knowing it: no field showed the reminder bell, and every date field's reminder box was blocked by a note with a blank where the field's name should be, until a reload. Now the new field appears and shows the bell. - The manual "New signup" form now counts toward the per-organizer create limit, 60 signups an hour, that `POST /api/signups` and the connector's `create_signup` tool already charged (#440). It had no limit, so a script or a stolen session could create signups through it without end. Past the limit, the form comes back with "You’ve created a lot of signups in the last hour. Try again in about N minutes." above it. The form now also shows why a create was refused, which it used to drop silently. - On the Build tab, grouped by a field such as Date, a slot dragged into another group now keeps that group's value (#483). It used to jump back to its old group, and the old value was saved again, while the new order was kept. The value and the order are now saved as one change, the value first, so if another window changed the slots and the order is refused, the slot still stays in the group it was dropped into. - The help page "Connect an AI assistant" and `docs/connect-ai-assistant.md` now give the current steps for ChatGPT and the Claude app, as numbered lists. For ChatGPT they said to turn on developer mode and add a connector; ChatGPT now adds a custom MCP server from Plugins (plus button, **Add custom MCP server**, OAuth, the risk checkbox, **Create as a plugin**), and the plugin has to be installed and picked with `@` in each message before ChatGPT uses it. Developer mode is now only the fallback when **Add custom MCP server** is missing. For Claude they said Settings → Connectors, which is now Customize → Connectors → **Add** → **Add custom connector**; on a Team or Enterprise plan an owner adds it and each member chooses **Connect**. The page also says a work ChatGPT account may need an admin, that the approval page can open the first time the assistant is used rather than when it's added, and, under "Try it", to pick the plugin with `@` in ChatGPT. diff --git a/src/components/build-shared/useBuildState.test.ts b/src/components/build-shared/useBuildState.test.ts index ef83c738..aba32a1d 100644 --- a/src/components/build-shared/useBuildState.test.ts +++ b/src/components/build-shared/useBuildState.test.ts @@ -1557,3 +1557,81 @@ describe('useBuildState setGroupBy', () => { expect(result.current.state.saveStatus.kind).toBe('saved'); }); }); + +describe('useBuildState settings naming a field this tab has not loaded', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const whatField = makeApiField({ id: 'f-what', ref: 'what', label: 'What', sortOrder: 0 }); + const eventDay = makeApiField({ + id: 'f-event', + ref: 'event-day', + label: 'Event day', + fieldType: 'date', + sortOrder: 1, + config: { fieldType: 'date' }, + }); + // An assistant added "Event day" and moved reminders to it after this tab + // opened, so the merged settings the PATCH returns name a field it lacks. + const saved: SignupSettings = { + ...defaultSettings, + sendReminders: true, + reminderFromFieldRef: 'event-day', + groupByFieldRefs: ['what'], + }; + + function routedFetch(fields: () => Response) { + return vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + if (String(input) === '/api/signups/sig_test/fields' && init?.method === 'GET') { + return Promise.resolve(fields()); + } + return Promise.resolve(jsonResponse({ data: { id: 'sig_test', settings: saved } })); + }); + } + + it('fetches the field list, so the reminder field it adopts is one the tab can show', async () => { + const fetchMock = routedFetch(() => jsonResponse({ data: [whatField, eventDay] })); + vi.stubGlobal('fetch', fetchMock); + const { result } = renderHook(() => useBuildState('sig_test', [whatField], [], defaultSettings)); + + await act(async () => { + await result.current.setGroupBy('what'); + }); + + expect(result.current.state.fields.map((f) => f.ref)).toEqual(['what', 'event-day']); + expect(result.current.state.reminderFieldRef).toBe('event-day'); + expect(result.current.state.groupByFieldRef).toBe('what'); + expect(result.current.state.saveStatus.kind).toBe('saved'); + }); + + it('does not fetch fields when every field the settings name is loaded', async () => { + const fetchMock = routedFetch(() => jsonResponse({ data: [whatField, eventDay] })); + vi.stubGlobal('fetch', fetchMock); + const { result } = renderHook(() => + useBuildState('sig_test', [whatField, eventDay], [], defaultSettings), + ); + + await act(async () => { + await result.current.setGroupBy('what'); + }); + + const gets = fetchMock.mock.calls.filter((c) => c[1]?.method === 'GET'); + expect(gets).toHaveLength(0); + expect(result.current.state.reminderFieldRef).toBe('event-day'); + }); + + it('still shows the save as saved when the field fetch fails', async () => { + const fetchMock = routedFetch(() => jsonResponse({ error: { code: 'internal' } }, { status: 500 })); + vi.stubGlobal('fetch', fetchMock); + const { result } = renderHook(() => useBuildState('sig_test', [whatField], [], defaultSettings)); + + await act(async () => { + await result.current.setGroupBy('what'); + }); + + expect(result.current.state.fields.map((f) => f.ref)).toEqual(['what']); + expect(result.current.state.groupByFieldRef).toBe('what'); + expect(result.current.state.saveStatus.kind).toBe('saved'); + }); +}); diff --git a/src/components/build-shared/useBuildState.ts b/src/components/build-shared/useBuildState.ts index c97fe9bc..b155e860 100644 --- a/src/components/build-shared/useBuildState.ts +++ b/src/components/build-shared/useBuildState.ts @@ -745,13 +745,34 @@ export function useBuildState( const save = settingsSaveRef.current.then(() => withSave(async () => { const res = await send('PATCH', `/api/signups/${signupId}`, { settings: changed }); - showSettings((await savedSettings(res)) ?? { ...settingsRef.current, ...changed }); + const next = (await savedSettings(res)) ?? { ...settingsRef.current, ...changed }; + await catchUpOnFields(next); + showSettings(next); }), ); settingsSaveRef.current = save; return save; }; + /** + * Fetches the field list when `next` names a field this tab hasn't loaded, + * which happens when another tab or an AI assistant added a field and + * pointed a setting at it (#398). Without it no field would show the + * reminder bell and every date field's reminder box would be blocked by a + * field the tab can't name. The save itself has already succeeded, so a + * failed fetch leaves the list as it was rather than reporting an error. + */ + async function catchUpOnFields(next: SignupSettings): Promise { + const named = [...next.groupByFieldRefs, ...(next.reminderFromFieldRef ? [next.reminderFromFieldRef] : [])]; + const loaded = new Set(stateRef.current.fields.map((f) => f.ref)); + if (named.every((ref) => loaded.has(ref))) return; + try { + showFields(toBuildFields(await sendForData('GET', fieldsUrl))); + } catch { + // Keep the list the tab has; a reload catches it up. + } + } + const setGroupBy = (ref: string | null): Promise => saveSettings({ groupByFieldRefs: ref ? [ref] : [] });