diff --git a/.changeset/30500-settings-toggle-confirm-off.md b/.changeset/30500-settings-toggle-confirm-off.md new file mode 100644 index 00000000000..5ea9993911f --- /dev/null +++ b/.changeset/30500-settings-toggle-confirm-off.md @@ -0,0 +1,10 @@ +--- +'@object-ui/console': patch +--- + +feat(console): a settings toggle that declares `confirmOffText` asks before it is turned off + +Clause-②: no +Metadata-protocol: none + +A settings manifest's `toggle` specifier may declare `confirmOffText` (`SpecifierSchema`: "Confirm dialog text shown before a toggle's value changes to off (omit = no confirm)"), and the authentication manifest declares it on `email_password_enabled`. The Console's settings page did not read the key, so Setup turned email and password sign-in off without asking. Now turning such a toggle off opens a confirmation carrying that text, translated through `settings.NAMESPACE.keys.KEY.confirmOffText` when the locale has it (an inline locale map literal is picked for the page's language otherwise). Cancel leaves the toggle on and puts nothing in the page's unsaved changes, so nothing can be saved; Continue turns it off, and Save sends it like any other edit. Turning a toggle on asks nothing, and a toggle without the key changes at once, exactly as before. diff --git a/ui/apps/console/src/pages/settings/SettingsField.tsx b/ui/apps/console/src/pages/settings/SettingsField.tsx index c22d5477729..0b2e4abefbf 100644 --- a/ui/apps/console/src/pages/settings/SettingsField.tsx +++ b/ui/apps/console/src/pages/settings/SettingsField.tsx @@ -6,7 +6,7 @@ * (which carries provenance and lock state). */ -import { cloneElement, isValidElement, lazy, Suspense, useId, useState, type ReactElement } from 'react'; +import { cloneElement, isValidElement, lazy, Suspense, useId, useRef, useState, type ReactElement } from 'react'; import { Input, Textarea, @@ -39,7 +39,7 @@ import { cn, getLazyIcon, } from '@object-ui/components'; -import { useObjectTranslation } from '@object-ui/i18n'; +import { pickLocalized, useObjectTranslation } from '@object-ui/i18n'; import { AlertTriangle, ChevronRight, Info, RotateCcw } from 'lucide-react'; import { EnvLockBadge } from './EnvLockBadge'; import { @@ -402,11 +402,91 @@ function DomainCombobox({ ); } +/** + * — a `toggle` row's control. + * + * A specifier that declares `confirmOffText` (`SpecifierSchema`: "Confirm + * dialog text shown before a toggle's value changes to off (omit = no + * confirm)") asks before the switch goes from on to off. Cancel leaves the + * switch on and hands nothing to `onChange`, so the page's draft never holds + * the off and no Save can send it; Continue hands `false` to `onChange`, and + * the page's Save sends it like any other edit. Turning the switch on asks + * nothing, and a toggle without the key changes at once, as before. + * + * Judged on the DECLARATION (`confirmText != null`), as the `action_button` + * arm judges a handler's `confirmText`. The dialog is opened by state, never by + * an `AlertDialogTrigger`, since only one of the switch's two directions asks; + * so focus is handed back to the switch by hand when it closes. + * + * Props beyond its own reach the ``, so the row's `aria-invalid` / + * `aria-describedby` land on the focusable control. + */ +function ToggleSwitch({ + id, + checked, + disabled, + confirmOffText, + onChange, + ...switchProps +}: { + id: string; + checked: boolean; + disabled?: boolean; + /** The resolved question, or `undefined` when the specifier declares none. */ + confirmOffText?: string; + onChange: (next: boolean) => void; + 'aria-invalid'?: boolean; + 'aria-describedby'?: string; +}) { + const { t } = useObjectTranslation(); + const [asking, setAsking] = useState(false); + const switchRef = useRef(null); + return ( + <> + { + if (checked && !next && confirmOffText !== undefined) { + setAsking(true); + return; + } + onChange(next); + }} + {...switchProps} + /> + {confirmOffText !== undefined ? ( + + { + e.preventDefault(); + switchRef.current?.focus(); + }} + > + + {t('actionConfirm.title')} + {confirmOffText} + + + {t('actionConfirm.cancel')} + onChange(false)}> + {t('actionConfirm.confirm')} + + + + + ) : null} + + ); +} + export function SettingsField(props: SettingsFieldProps) { const { spec, resolved, value, onChange, onAction, locked, saving, labels, error, note, writeScope } = props; const id = useId(); - const { t } = useObjectTranslation(); + const { t, language } = useObjectTranslation(); const disabled = Boolean(locked || saving); const literalLabel = resolveLabel(spec.label); // Field-scoped label/help/placeholder/option resolution. Falls back to the @@ -685,7 +765,17 @@ export function SettingsField(props: SettingsFieldProps) { onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))} />, ); - case 'toggle': + case 'toggle': { + // The question a declared `confirmOffText` asks, through the + // `keys..confirmOffText` slot beside the row's label; a miss is the + // manifest literal, an inline locale map picked for the page's language. + const declaredConfirmOff = spec.confirmOffText; + const literalConfirmOff = + declaredConfirmOff != null ? pickLocalized(declaredConfirmOff, language) : undefined; + const confirmOffText = + literalConfirmOff !== undefined && spec.key && labels + ? labels.fieldConfirmOff(spec.key, literalConfirmOff) + : literalConfirmOff; return (
@@ -707,16 +797,18 @@ export function SettingsField(props: SettingsFieldProps) { )}
- onChange(checked)} + confirmOffText={confirmOffText} + onChange={(checked) => onChange(checked)} />
); + } case 'select': { // Keyed off the DECLARATION, never off the key: a key that gains a // domain server-side gets the right control here with no edit. diff --git a/ui/apps/console/src/pages/settings/__tests__/SettingsView.confirmOff-30500.test.tsx b/ui/apps/console/src/pages/settings/__tests__/SettingsView.confirmOff-30500.test.tsx new file mode 100644 index 00000000000..766fd60d069 --- /dev/null +++ b/ui/apps/console/src/pages/settings/__tests__/SettingsView.confirmOff-30500.test.tsx @@ -0,0 +1,277 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A toggle whose specifier declares `confirmOffText` asks before it goes off + * (objectstack#30500, ruling A on objectstack#30418). + * + * The spec's `SpecifierSchema` declares the key on `toggle` specifiers: + * "Confirm dialog text shown before a toggle's value changes to off (omit = no + * confirm)", and `auth.manifest.ts` declares it on `email_password_enabled`. + * The pins: + * + * - declared, on → off: a confirmation carrying the text opens and the + * switch stays on; Cancel leaves it on with nothing in the draft and no + * PUT; Continue turns it off and the page's Save sends `false`; + * - declared, off → on: no confirmation, the change is drafted at once; + * - not declared: no confirmation, the off is drafted at once, as before; + * - the text resolves through `settings.NAMESPACE.keys.KEY.confirmOffText`, + * and a miss is the manifest literal, an inline locale map picked for the + * page's language. + * + * The whole page over a stubbed `fetch` and the REAL `../api` module, so the + * save assertions read the PUT body as it leaves the client. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { + createI18n, + I18nProvider, + transformSpecTranslations, + type SpecTranslationData, +} from '@object-ui/i18n'; + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +import { SettingsView } from '../SettingsView'; + +/** `auth.manifest.ts`'s own literal for `email_password_enabled`. */ +const CONFIRM_OFF = 'People who sign in with a password will need another way in.'; +const PASSWORD_LABEL = 'Enable email/password login'; +const SIGNUP_LABEL = 'Allow sign-up'; + +type Spec = Record; + +function payloadOf(passwordOn: boolean, extra: Partial = {}) { + return { + manifest: { + namespace: 'auth', + version: 1, + label: 'Authentication', + specifiers: [ + { + type: 'toggle', + key: 'email_password_enabled', + label: PASSWORD_LABEL, + default: true, + confirmOffText: CONFIRM_OFF, + ...extra, + }, + // No `confirmOffText`: behaves as before. + { type: 'toggle', key: 'signup_enabled', label: SIGNUP_LABEL, default: true }, + ], + }, + values: { + email_password_enabled: { value: passwordOn, source: 'global', locked: false }, + signup_enabled: { value: true, source: 'global', locked: false }, + }, + }; +} + +const fetchMock = vi.fn(); + +function ok(body: unknown): Response { + return { ok: true, status: 200, statusText: 'OK', json: async () => body } as unknown as Response; +} + +/** GET answers the page; PUT answers the values it was sent. */ +function serve(payload: unknown) { + fetchMock.mockImplementation(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + const sent = JSON.parse(String(init.body)) as Record; + const values = Object.fromEntries( + Object.entries(sent).map(([k, v]) => [k, { value: v, source: 'global', locked: false }]), + ); + return ok({ success: true, data: { values } }); + } + return ok({ success: true, data: structuredClone(payload) }); + }); +} + +function puts() { + return fetchMock.mock.calls.filter(([, init]) => (init as RequestInit | undefined)?.method === 'PUT'); +} + +function renderView(instance = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false })) { + return render( + + + + } /> + + + , + ); +} + +async function switchFor(label: string): Promise { + return screen.findByRole('switch', { name: label }); +} + +async function save() { + fireEvent.click(await screen.findByRole('button', { name: /save changes/i })); + await waitFor(() => expect(puts()).toHaveLength(1)); +} + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + window.localStorage.clear(); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('a toggle that declares confirmOffText asks before it goes off (objectstack#30500)', () => { + it('turning it off opens a confirmation carrying the text, and the switch stays on', async () => { + serve(payloadOf(true)); + renderView(); + const sw = await switchFor(PASSWORD_LABEL); + expect(sw).toHaveAttribute('aria-checked', 'true'); + + fireEvent.click(sw); + + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent(CONFIRM_OFF); + expect(sw).toHaveAttribute('aria-checked', 'true'); + // Nothing reached the draft while the question is open. + expect(screen.queryByTestId('settings-unsaved-count')).toBeNull(); + }); + + it('Cancel leaves the switch on, drafts nothing and sends nothing', async () => { + serve(payloadOf(true)); + renderView(); + const sw = await switchFor(PASSWORD_LABEL); + + fireEvent.click(sw); + fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(sw).toHaveAttribute('aria-checked', 'true'); + expect(screen.queryByTestId('settings-unsaved-count')).toBeNull(); + expect(screen.queryByRole('button', { name: /save changes/i })).toBeNull(); + expect(puts()).toHaveLength(0); + // Focus goes back to the switch the question was asked about. + expect(document.activeElement).toBe(sw); + }); + + it('Continue turns the switch off, and Save sends false', async () => { + serve(payloadOf(true)); + renderView(); + const sw = await switchFor(PASSWORD_LABEL); + + fireEvent.click(sw); + fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Continue' })); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(sw).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByTestId('settings-unsaved-count')).toHaveTextContent('1'); + + await save(); + expect(JSON.parse(String((puts()[0][1] as RequestInit).body))).toEqual({ + email_password_enabled: false, + }); + }); + + it('turning it on asks nothing and drafts the change at once', async () => { + serve(payloadOf(false)); + renderView(); + const sw = await switchFor(PASSWORD_LABEL); + expect(sw).toHaveAttribute('aria-checked', 'false'); + + fireEvent.click(sw); + + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(sw).toHaveAttribute('aria-checked', 'true'); + await save(); + expect(JSON.parse(String((puts()[0][1] as RequestInit).body))).toEqual({ + email_password_enabled: true, + }); + }); + + it('asks again on the next turn to off after a Continue and a turn back on', async () => { + serve(payloadOf(true)); + renderView(); + const sw = await switchFor(PASSWORD_LABEL); + + fireEvent.click(sw); + fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Continue' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + fireEvent.click(sw); + expect(sw).toHaveAttribute('aria-checked', 'true'); + + fireEvent.click(sw); + expect(await screen.findByRole('alertdialog')).toHaveTextContent(CONFIRM_OFF); + expect(sw).toHaveAttribute('aria-checked', 'true'); + }); +}); + +describe('a toggle without confirmOffText changes at once, as before (objectstack#30500)', () => { + it('turning it off asks nothing, and Save sends false', async () => { + serve(payloadOf(true)); + renderView(); + const sw = await switchFor(SIGNUP_LABEL); + + fireEvent.click(sw); + + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(sw).toHaveAttribute('aria-checked', 'false'); + await save(); + expect(JSON.parse(String((puts()[0][1] as RequestInit).body))).toEqual({ signup_enabled: false }); + }); +}); + +describe('the confirmOffText the dialog shows (objectstack#30500)', () => { + it('reads settings.NAMESPACE.keys.KEY.confirmOffText when the locale carries it', async () => { + const served: SpecTranslationData = { + settings: { + auth: { + keys: { + email_password_enabled: { + label: PASSWORD_LABEL, + confirmOffText: 'Translated: password sign-in goes away.', + }, + }, + }, + }, + }; + serve(payloadOf(true)); + renderView( + createI18n({ + defaultLanguage: 'en', + detectBrowserLanguage: false, + resources: { en: transformSpecTranslations(served) }, + }), + ); + + fireEvent.click(await switchFor(PASSWORD_LABEL)); + + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('Translated: password sign-in goes away.'); + expect(dialog).not.toHaveTextContent(CONFIRM_OFF); + }); + + it('picks an inline locale map literal for the page language when no translation is served', async () => { + serve( + payloadOf(true, { + confirmOffText: { en: CONFIRM_OFF, de: 'Wer sich mit Passwort anmeldet, braucht einen anderen Weg.' }, + }), + ); + renderView(createI18n({ defaultLanguage: 'de', detectBrowserLanguage: false })); + + fireEvent.click(await switchFor(PASSWORD_LABEL)); + + const dialog = await screen.findByRole('alertdialog'); + expect(dialog).toHaveTextContent('Wer sich mit Passwort anmeldet, braucht einen anderen Weg.'); + expect(dialog).not.toHaveTextContent(CONFIRM_OFF); + }); +}); diff --git a/ui/apps/console/src/pages/settings/types.ts b/ui/apps/console/src/pages/settings/types.ts index 3b2213d709c..e2b9a9d7f5b 100644 --- a/ui/apps/console/src/pages/settings/types.ts +++ b/ui/apps/console/src/pages/settings/types.ts @@ -94,6 +94,13 @@ export interface Specifier { pattern?: string; rows?: number; handler?: SpecifierHandler; + /** + * `toggle` only: the question asked before the switch goes from on to off + * (omit = no confirm). Mirrors `SpecifierSchema.confirmOffText`, an + * `I18nLabel`: the default-language string, or an inline locale map + * (`{ en, 'zh-CN' }`). + */ + confirmOffText?: string | Record; childNamespace?: string; bannerText?: string; bannerSeverity?: 'info' | 'success' | 'warning' | 'error'; diff --git a/ui/apps/console/src/pages/settings/useSettingsLabel.ts b/ui/apps/console/src/pages/settings/useSettingsLabel.ts index 73e1cbe51c5..01e01c86740 100644 --- a/ui/apps/console/src/pages/settings/useSettingsLabel.ts +++ b/ui/apps/console/src/pages/settings/useSettingsLabel.ts @@ -9,7 +9,7 @@ * settings..title * settings..description * settings..groups..title / .description - * settings..keys..label / .help / .placeholder + * settings..keys..label / .help / .placeholder / .confirmOffText * settings..keys..options. * settings..actions..label / .confirmText / .successMessage * settings..banners..title / .text @@ -45,6 +45,8 @@ export interface SettingsLabelHelpers { fieldLabel: (key: string, fallback: string) => string; fieldHelp: (key: string, fallback: string | undefined) => string | undefined; fieldPlaceholder: (key: string, fallback: string | undefined) => string | undefined; + /** A `toggle`'s question before it goes off, keyed by the setting key; falls back to its `confirmOffText`. */ + fieldConfirmOff: (key: string, fallback: string) => string; optionLabel: (key: string, optionValue: string, fallback: string) => string; actionLabel: (actionId: string, fallback: string) => string; actionConfirm: (actionId: string, fallback: string | undefined) => string | undefined; @@ -151,6 +153,8 @@ export function useSettingsLabel(namespace: string): SettingsLabelHelpers { fieldLabel: (key, fallback) => resolve(`keys.${key}.label`, fallback) ?? fallback, fieldHelp: (key, fallback) => resolve(`keys.${key}.help`, fallback), fieldPlaceholder: (key, fallback) => resolve(`keys.${key}.placeholder`, fallback), + fieldConfirmOff: (key, fallback) => + resolve(`keys.${key}.confirmOffText`, fallback) ?? fallback, optionLabel: (key, optionValue, fallback) => resolve(`keys.${key}.options.${optionValue}`, fallback) ?? fallback, actionLabel: (actionId, fallback) =>