Skip to content

Commit 0093b2c

Browse files
fix(console): a settings value that misses its declared format blocks Save (objectui#13287) (#13298)
Fixes #13287 Clause-②: no ## What changed On Save, the Settings page now judges every visible, unlocked `url`, `email`, `phone` and `color` row whose effective value (draft over stored) is non-empty. A miss blocks the save on the same path as the `required` check from objectui#13239: no request goes out, each row is marked inline through the `fieldErrors` slot (`aria-invalid` plus `aria-describedby` to the message), focus moves to the first blocked row in manifest order, and the existing blocked-save toast names the rows. An empty value is left to `required`. - `apps/console/src/pages/settings/SettingsView.tsx`: `emptyRequiredKeys` becomes `preSaveBlocks`. It runs one pass with the same visibility, lock and non-input skips, and returns `required` or `format` per key. `DECLARED_VALUE_FORMATS` with `missesDeclaredFormat` does the judging, on the trimmed value. - Messages, one per format: `email` reuses `validation.email` and `url` reuses `validation.url`. Two new keys, `console.settingsView.invalidPhone` and `console.settingsView.invalidColor`, name what a phone number and a hex colour take. Both are added to all ten locale packs. - A server `SETTINGS_VALIDATION` refusal renders as before. That branch is untouched. ## Grammars: the settings service's own, copied from objectstack main They come from `DECLARED_VALUE_FORMATS` in `service-settings` (objectstack PR #25022, merged), so the page never blocks a value the server accepts and never passes one it refuses: - `email`: `local@domain` with a dotted domain. - `url`: `scheme://...`, root-relative `/path`, `data:` or `blob:`. **This is wider than the dispatch's gloss "scheme http/https".** The server accepts any `scheme://`. The `branding.logo_url` reader takes absolute or relative values, and platform storage hands back root-relative file URLs. An http(s)-only console would block values the server and the reader both serve. - `phone`: digits, spaces and `+ ( ) - .`, at least 5 characters. The server has this grammar, so `phone` is checked rather than left out. - `color`: `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`. ## Acceptance notes - **Untouched stored values.** The page judges the effective value, so a stored malformed value on a visible row blocks a save that edits a different row. That row is shown marked so the admin can fix it. This is what the dispatch ruling asks ("effective value"), and the `required` check works the same way. The server, by contrast, judges only the keys a save touches. It is pinned by the test "a stored malformed value on a visible row blocks a save that edits another row". - **Colour rows.** On a `color` row, `aria-invalid` and `aria-describedby` land on the wrapper `div`, not on the two inputs. This is how `SettingsField`'s `wrapper` already handles a server refusal on that row type: `cloneElement` applies to the outer child. The message itself still renders in the row, and focus goes to the picker. This was found by reading the code and has not been measured in a browser. It is not changed here. ## Tests (at `a1dd56fed7`) - New: `apps/console/src/pages/settings/__tests__/SettingsView.formatCheck-13287.test.tsx`, 15 tests. - (a) Logo URL `not a url` blocks: no request, row marked, focus on it. `https://...`, `/logo.png` and a padded `/logo.png` all save. - (b) `no-reply@localhost` blocks; a valid email saves. - (c) `red` and `#12` block; `#1a2b3c` saves. - Phone: a value with letters blocks, a valid one saves. Four misses get four distinct messages, with focus on the first. - (d) control: an empty optional url saves. - (e) control: a url hidden by `visible` and an env-locked malformed url do not block. - Editing a marked row clears its message. - `pnpm exec vitest run --maxWorkers=1 apps/console/src/pages/settings/`: `Test Files 28 passed (28)`, `Tests 233 passed (233)`. This covers the family that reads `SettingsView.tsx`, including the required-check suite. - The 56 `packages/i18n/src/__tests__` files that read the locale packs: `Test Files 56 passed (56)`, `Tests 1003 passed (1003)`. The full `apps/console` and `packages/i18n` directories are left to CI. - Ablation via `ablation-replace.mjs`, run at the commit above. The mutation `} else if (missesDeclaredFormat(` became `} else if (false && missesDeclaredFormat(`; anchor count went 1 to 0 and the blob went `4f3bc9f9bb64` to `c0bd531ba548`. Result: 8 failed, 7 passed. Every (a), (b) and (c) block test went red; the save cases and the (d) and (e) controls stayed green. Restore: blob `4f3bc9f9bb64` equals the HEAD blob and `git diff HEAD` is empty. - `type-check` for `@object-ui/i18n` and `@object-ui/console`, after the `@object-ui/console^...` closure build: both `Done`, exit 0. Console `tsc --listFiles` includes the new test. - `eslint` over the 12 changed files (JSON count 12): 0 errors and 3 warnings, all `no-explicit-any` on pre-existing `catch (err: any)` lines. With `--no-inline-config` there is one more, the pre-existing suppressed `react-hooks/static-components`. The base file gives the same four findings. Type-aware linting is not configured in `eslint.config.js`, so this diff cannot change the verdict on any untouched file. - Gates, all exit 0: `check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:doc-example-readers`, `check:handler-key-reads`, `check:side-effects-array`, `check:element-data-source-declaration`, `check:spec-symbols`, `check:action-forward-parity`, `check:designer-field-key-parity`, `check:icon-record-names`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`, `check:i18n-dead-keys`, `check:control-bytes`, `scripts/check-hand-rolled-comment-mask.mjs` and `scripts/check-changeset-fixed.mjs`. Changeset: `.changeset/13287-settings-format-check.md` (`@object-ui/console` patch, `@object-ui/i18n` patch). --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c405b7 commit 0093b2c

13 files changed

Lines changed: 365 additions & 17 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@object-ui/console': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
Settings pages: a `url`, `email`, `phone` or `color` value that misses the format its type declares now blocks Save (objectui#13287). No request goes out, the row is marked inline with a message naming what the format expects, and focus moves to the first such row, on the same path as the `required` check. The grammars are the settings service's own (objectstack#24882), judged on the trimmed value; an empty value is left to `required`, and a hidden or env-locked row is not judged. Adds `console.settingsView.invalidPhone` and `console.settingsView.invalidColor` to every locale pack; `email` and `url` reuse `validation.email` and `validation.url`.

‎apps/console/src/pages/settings/SettingsView.tsx‎

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,52 @@ function isEmptySettingValue(value: unknown): boolean {
148148
}
149149

150150
/**
151-
* The visible `required` keys that hold no value, in manifest order
152-
* (objectui#13239). The spec declares `required` as "renderer + server-side
153-
* validation"; the server judges only the keys a save touches, so a required
154-
* key nobody edited is the renderer's half to check.
151+
* The value format each format-bearing specifier TYPE declares (objectui#13287):
152+
* ADR-0007's specifier table, "`email` / `url` / `phone` | typed input | string
153+
* | format validation" and "`color` | colour picker | hex string".
154+
*
155+
* Each grammar is the settings service's own, `DECLARED_VALUE_FORMATS` in
156+
* objectstack's `service-settings` (objectstack#24882), which refuses a miss at
157+
* the save door with one `FieldError` per key. Kept to the server's grammar so
158+
* the page never blocks a value the server and its readers accept, nor lets
159+
* through one the server will refuse:
160+
*
161+
* - `email` — `local@domain` with a dotted domain.
162+
* - `url` — `scheme://…`, a root-relative `/path`, a `data:` URI or a
163+
* `blob:` URL (`branding.logo_url`'s reader takes absolute or relative).
164+
* - `phone` — digits, spaces and `+ ( ) - .` only, at least 5 characters.
165+
* - `color` — CSS hex, `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`.
166+
*
167+
* A `Map`, so a type spelled like an `Object.prototype` member looks up nothing.
168+
*/
169+
const DECLARED_VALUE_FORMATS: ReadonlyMap<string, RegExp> = new Map([
170+
['email', /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/],
171+
['url', /^(?:[a-z][a-z0-9+.-]*:\/\/\S+|\/\S*|data:\S+|blob:\S+)$/i],
172+
['phone', /^[+()\-\s\d.]{5,}$/],
173+
['color', /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i],
174+
]);
175+
176+
/**
177+
* True when `value` misses the format its specifier `type` declares. A type
178+
* with no declared format, a non-string value, or an empty one passes: "no
179+
* value" is the `required` rule's business. Judged trimmed, as the server
180+
* judges it.
181+
*/
182+
function missesDeclaredFormat(type: string, value: unknown): boolean {
183+
const re = DECLARED_VALUE_FORMATS.get(type);
184+
if (!re || typeof value !== 'string' || value.trim() === '') return false;
185+
return !re.test(value.trim());
186+
}
187+
188+
/** Why the page blocked a save on one key: an empty `required`, or a format miss. */
189+
type PreSaveBlock = { key: string; rule: 'required' | 'format'; type: string };
190+
191+
/**
192+
* The visible keys a save is blocked on, in manifest order: a `required` key
193+
* that holds no value (objectui#13239), or a value that misses the format its
194+
* type declares (objectui#13287). The spec declares `required` as "renderer +
195+
* server-side validation"; the server judges only the keys a save touches, so a
196+
* required key nobody edited is the renderer's half to check.
155197
*
156198
* - Visibility is the same `evalVisibility` reading the page renders by, over
157199
* the live values (draft over stored): a hidden key is not checked.
@@ -160,25 +202,29 @@ function isEmptySettingValue(value: unknown): boolean {
160202
* counts as filled.
161203
* - A locked row is not checked. An env lock reads back as `locked: true` with
162204
* the environment's value in `value`, and the row's control is disabled, so
163-
* nothing on this page could fill it either way.
205+
* nothing on this page could fill or fix it either way.
164206
*/
165-
function emptyRequiredKeys(
207+
function preSaveBlocks(
166208
specifiers: ReadonlyArray<Specifier>,
167209
values: SettingsNamespacePayload['values'],
168210
draft: Record<string, unknown>,
169211
liveValues: Record<string, unknown>,
170-
): string[] {
171-
const empty: string[] = [];
212+
): PreSaveBlock[] {
213+
const blocks: PreSaveBlock[] = [];
172214
for (const spec of specifiers) {
173215
const key = spec.key;
174-
if (!key || !spec.required || NON_INPUT_TYPES.has(spec.type)) continue;
216+
if (!key || NON_INPUT_TYPES.has(spec.type)) continue;
175217
if (!evalVisibility(spec.visible, liveValues)) continue;
176218
const resolved = values[key];
177219
if (resolved?.locked) continue;
178220
const effective = key in draft ? draft[key] : resolved?.value;
179-
if (isEmptySettingValue(effective)) empty.push(key);
221+
if (spec.required && isEmptySettingValue(effective)) {
222+
blocks.push({ key, rule: 'required', type: spec.type });
223+
} else if (missesDeclaredFormat(spec.type, effective)) {
224+
blocks.push({ key, rule: 'format', type: spec.type });
225+
}
180226
}
181-
return empty;
227+
return blocks;
182228
}
183229

184230
export function SettingsView() {
@@ -341,13 +387,21 @@ export function SettingsView() {
341387
// objectui#13239 — a visible required field left empty blocks the save
342388
// here: no request goes out, each such row is marked through the same
343389
// field-error slot a server refusal uses, and focus moves to the first.
344-
const missing = emptyRequiredKeys(manifest.specifiers, values, draft, liveValues);
345-
if (missing.length > 0) {
346-
const message = t('form.fieldRequired');
347-
setFieldErrors(Object.fromEntries(missing.map((key) => [key, message])));
348-
setFocusKey({ key: missing[0] });
390+
// objectui#13287 — a value that misses its type's declared format blocks
391+
// the same way, with one message per format naming what is expected.
392+
const blocked = preSaveBlocks(manifest.specifiers, values, draft, liveValues);
393+
if (blocked.length > 0) {
394+
const messageFor = ({ rule, type }: PreSaveBlock): string => {
395+
if (rule === 'required') return t('form.fieldRequired');
396+
if (type === 'email') return t('validation.email');
397+
if (type === 'url') return t('validation.url');
398+
if (type === 'phone') return t('console.settingsView.invalidPhone');
399+
return t('console.settingsView.invalidColor');
400+
};
401+
setFieldErrors(Object.fromEntries(blocked.map((b) => [b.key, messageFor(b)])));
402+
setFocusKey({ key: blocked[0].key });
349403
const named = refusedSettingLabels(
350-
missing.map((field) => ({ field })),
404+
blocked.map(({ key }) => ({ field: key })),
351405
manifest.specifiers,
352406
labels,
353407
);
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* SettingsView — a value that misses the format its specifier type declares
11+
* blocks Save (objectui#13287).
12+
*
13+
* ADR-0007 declares `email` / `url` / `phone` as "format validation" and
14+
* `color` as a "hex string", and the settings service refuses a miss at the
15+
* save door (objectstack#24882). The page judged nothing before the request:
16+
* Setup → Branding saved Logo URL `not a url` and answered "Settings saved".
17+
* The check rides the same Save path as the `required` one (objectui#13239):
18+
* no request, the row marked through the field-error slot, focus on the first.
19+
*/
20+
21+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
22+
import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react';
23+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
24+
25+
const getSettingsNamespace = vi.fn();
26+
const saveSettingsNamespace = vi.fn();
27+
const runSettingsAction = vi.fn();
28+
29+
vi.mock('../api', async () => {
30+
const actual = await vi.importActual<typeof import('../api')>('../api');
31+
return {
32+
...actual,
33+
getSettingsNamespace: (...a: unknown[]) => getSettingsNamespace(...a),
34+
saveSettingsNamespace: (...a: unknown[]) => saveSettingsNamespace(...a),
35+
runSettingsAction: (...a: unknown[]) => runSettingsAction(...a),
36+
};
37+
});
38+
39+
const toastError = vi.fn();
40+
const toastSuccess = vi.fn();
41+
vi.mock('sonner', () => ({
42+
toast: {
43+
error: (...a: unknown[]) => toastError(...a),
44+
success: (...a: unknown[]) => toastSuccess(...a),
45+
},
46+
}));
47+
48+
import { SettingsView } from '../SettingsView';
49+
50+
const SHOW_LEGACY = '${data.show_legacy === true}';
51+
52+
type Resolved = { value: unknown; locked?: boolean; lockedReason?: string; source?: string };
53+
54+
/**
55+
* Branding's shape (`logo_url`, `accent_color`) plus the Company/Email format
56+
* rows (`support_email`, `support_phone`), an optional `website`, a url hidden
57+
* by `visible`, and a url locked by the environment.
58+
*/
59+
function brandingPayload(overrides: Record<string, Resolved> = {}) {
60+
const values: Record<string, Resolved> = {
61+
logo_url: { value: 'https://cdn.example.com/logo.png' },
62+
accent_color: { value: '#1a2b3c' },
63+
support_email: { value: 'ops@example.com' },
64+
support_phone: { value: '+1 (555) 010-0100' },
65+
website: { value: null },
66+
show_legacy: { value: false },
67+
legacy_url: { value: 'not a url' },
68+
env_url: {
69+
value: 'not a url',
70+
source: 'env',
71+
locked: true,
72+
lockedReason: 'Set via env: OS_BRANDING_ENV_URL',
73+
},
74+
product_name: { value: 'Acme' },
75+
...overrides,
76+
};
77+
const resolved: Record<string, unknown> = {};
78+
for (const [k, v] of Object.entries(values)) resolved[k] = { source: 'default', locked: false, ...v };
79+
return {
80+
manifest: {
81+
namespace: 'branding',
82+
label: 'Branding',
83+
specifiers: [
84+
{ type: 'text', key: 'product_name', label: 'Product name' },
85+
{ type: 'url', key: 'logo_url', label: 'Logo URL' },
86+
{ type: 'color', key: 'accent_color', label: 'Accent color' },
87+
{ type: 'email', key: 'support_email', label: 'Support email' },
88+
{ type: 'phone', key: 'support_phone', label: 'Support phone' },
89+
{ type: 'url', key: 'website', label: 'Website' },
90+
{ type: 'url', key: 'legacy_url', label: 'Legacy URL', visible: SHOW_LEGACY },
91+
{ type: 'url', key: 'env_url', label: 'Env URL' },
92+
],
93+
},
94+
values: resolved,
95+
};
96+
}
97+
98+
function renderView() {
99+
return render(
100+
<MemoryRouter initialEntries={['/settings/branding']}>
101+
<Routes>
102+
<Route path="/settings/:namespace" element={<SettingsView />} />
103+
</Routes>
104+
</MemoryRouter>,
105+
);
106+
}
107+
108+
const rowFor = (key: string) =>
109+
document.querySelector<HTMLElement>(`[data-settings-key="${key}"]`);
110+
/** The row's text box: the last input, which for `color` is the hex text field beside the picker. */
111+
const textInputFor = (key: string) => {
112+
const inputs = rowFor(key)?.querySelectorAll('input') ?? [];
113+
return inputs.length ? inputs[inputs.length - 1] : null;
114+
};
115+
const alertIn = (key: string) => rowFor(key)?.querySelector('[role="alert"]') ?? null;
116+
117+
async function typeAndSave(key: string, value: string) {
118+
await screen.findByText('Product name');
119+
fireEvent.change(textInputFor(key)!, { target: { value } });
120+
fireEvent.click(await screen.findByRole('button', { name: /save/i }));
121+
}
122+
123+
async function expectBlockedOn(key: string) {
124+
await waitFor(() => expect(alertIn(key)).toBeTruthy());
125+
expect(saveSettingsNamespace).not.toHaveBeenCalled();
126+
expect(toastSuccess).not.toHaveBeenCalled();
127+
}
128+
129+
async function expectSaved() {
130+
await waitFor(() => expect(saveSettingsNamespace).toHaveBeenCalledTimes(1));
131+
await waitFor(() => expect(toastSuccess).toHaveBeenCalled());
132+
expect(document.querySelector('[role="alert"]')).toBeNull();
133+
}
134+
135+
beforeEach(() => {
136+
vi.clearAllMocks();
137+
saveSettingsNamespace.mockResolvedValue({ values: {} });
138+
});
139+
afterEach(cleanup);
140+
141+
describe('SettingsView — a value that misses its declared format blocks Save (objectui#13287)', () => {
142+
it('(a) Logo URL `not a url` blocks: no request, the row marked, focus on it', async () => {
143+
getSettingsNamespace.mockResolvedValue(brandingPayload());
144+
renderView();
145+
await typeAndSave('logo_url', 'not a url');
146+
147+
await expectBlockedOn('logo_url');
148+
expect(textInputFor('logo_url')!.getAttribute('aria-invalid')).toBe('true');
149+
expect(textInputFor('logo_url')!.getAttribute('aria-describedby')).toBe(
150+
alertIn('logo_url')!.getAttribute('id'),
151+
);
152+
expect(document.activeElement).toBe(textInputFor('logo_url'));
153+
// One toast, the blocked-save toast the required check raises.
154+
expect(toastError).toHaveBeenCalledTimes(1);
155+
});
156+
157+
it.each(['https://cdn.example.com/brand.svg', '/logo.png', ' /logo.png '])(
158+
'(a) Logo URL %j saves',
159+
async (value) => {
160+
getSettingsNamespace.mockResolvedValue(brandingPayload());
161+
renderView();
162+
await typeAndSave('logo_url', value);
163+
await expectSaved();
164+
expect(saveSettingsNamespace).toHaveBeenCalledWith('branding', { logo_url: value.trim() });
165+
},
166+
);
167+
168+
it('(b) an email with a dotless domain blocks', async () => {
169+
getSettingsNamespace.mockResolvedValue(brandingPayload());
170+
renderView();
171+
await typeAndSave('support_email', 'no-reply@localhost');
172+
await expectBlockedOn('support_email');
173+
expect(document.activeElement).toBe(textInputFor('support_email'));
174+
});
175+
176+
it('(b) a valid email saves', async () => {
177+
getSettingsNamespace.mockResolvedValue(brandingPayload());
178+
renderView();
179+
await typeAndSave('support_email', 'no-reply@mail.example.com');
180+
await expectSaved();
181+
});
182+
183+
it.each(['red', '#12'])('(c) the color %j blocks', async (value) => {
184+
getSettingsNamespace.mockResolvedValue(brandingPayload());
185+
renderView();
186+
await typeAndSave('accent_color', value);
187+
await expectBlockedOn('accent_color');
188+
});
189+
190+
it('(c) the color `#1a2b3c` saves', async () => {
191+
getSettingsNamespace.mockResolvedValue(brandingPayload({ accent_color: { value: '#000' } }));
192+
renderView();
193+
await typeAndSave('accent_color', '#1a2b3c');
194+
await expectSaved();
195+
});
196+
197+
it('a phone number with letters blocks; a valid one saves', async () => {
198+
getSettingsNamespace.mockResolvedValue(brandingPayload());
199+
renderView();
200+
await typeAndSave('support_phone', 'call me');
201+
await expectBlockedOn('support_phone');
202+
203+
cleanup();
204+
vi.clearAllMocks();
205+
saveSettingsNamespace.mockResolvedValue({ values: {} });
206+
renderView();
207+
await typeAndSave('support_phone', '+44 20 7946 0958');
208+
await expectSaved();
209+
});
210+
211+
it('each format names what it expects, one message per format', async () => {
212+
getSettingsNamespace.mockResolvedValue(
213+
brandingPayload({
214+
accent_color: { value: 'red' },
215+
support_email: { value: 'ops@localhost' },
216+
support_phone: { value: 'n/a' },
217+
}),
218+
);
219+
renderView();
220+
await typeAndSave('logo_url', 'not a url');
221+
222+
await expectBlockedOn('logo_url');
223+
const messages = ['logo_url', 'support_email', 'support_phone', 'accent_color'].map(
224+
(key) => alertIn(key)?.textContent ?? '',
225+
);
226+
// Every row marked, each with its own format's message.
227+
expect(messages.every((m) => m.length > 0)).toBe(true);
228+
expect(new Set(messages).size).toBe(4);
229+
// Focus goes to the first in manifest order.
230+
expect(document.activeElement).toBe(textInputFor('logo_url'));
231+
});
232+
233+
it('(d) control: an empty optional url field does not block', async () => {
234+
getSettingsNamespace.mockResolvedValue(brandingPayload({ website: { value: '' } }));
235+
renderView();
236+
await typeAndSave('product_name', 'Acme Cloud');
237+
await expectSaved();
238+
});
239+
240+
it('(e) control: a hidden malformed url and an env-locked malformed url do not block', async () => {
241+
getSettingsNamespace.mockResolvedValue(brandingPayload());
242+
renderView();
243+
await screen.findByText('Product name');
244+
expect(rowFor('legacy_url')).toBeNull();
245+
expect(rowFor('env_url')).toBeTruthy();
246+
await typeAndSave('product_name', 'Acme Cloud');
247+
await expectSaved();
248+
});
249+
250+
it('a stored malformed value on a visible row blocks a save that edits another row', async () => {
251+
getSettingsNamespace.mockResolvedValue(brandingPayload({ logo_url: { value: 'not a url' } }));
252+
renderView();
253+
await typeAndSave('product_name', 'Acme Cloud');
254+
await expectBlockedOn('logo_url');
255+
});
256+
257+
it('editing the marked row clears its message, as for a server refusal', async () => {
258+
getSettingsNamespace.mockResolvedValue(brandingPayload());
259+
renderView();
260+
await typeAndSave('logo_url', 'not a url');
261+
await expectBlockedOn('logo_url');
262+
fireEvent.change(textInputFor('logo_url')!, { target: { value: 'https://x.example.com/l.png' } });
263+
await waitFor(() => expect(alertIn('logo_url')).toBeNull());
264+
});
265+
});

0 commit comments

Comments
 (0)