From cd4f752dafd768a46e27ea302d65c79587b927c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:55:50 +0000 Subject: [PATCH 1/2] feat(console): settings info_banner reads the platform's banners slot, draws warning amber, renders its body as Markdown (objectui#12671) `useSettingsLabel` gains `bannerTitle` / `bannerText`, reading `settings.NAMESPACE.banners.BANNER_ID.title` / `.text` through the hook's existing `resolve()` with the manifest literal as the fallback, and the docblock lists both paths. The `info_banner` arm asks them when `spec.id` is set, as the `group` arm asks `groupTitle`; `bannerSeverity: 'warning'` lays the shell's amber callout over the `default` Alert; `bannerText` renders through the docs portal's `MarkdownRenderer`, loaded lazily so the plugin's chunk stays out of the eager closure. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 --- .../src/pages/settings/SettingsField.tsx | 61 ++++++++++++++++--- .../src/pages/settings/useSettingsLabel.ts | 14 +++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/apps/console/src/pages/settings/SettingsField.tsx b/apps/console/src/pages/settings/SettingsField.tsx index dcb14d10b0..ae42adc701 100644 --- a/apps/console/src/pages/settings/SettingsField.tsx +++ b/apps/console/src/pages/settings/SettingsField.tsx @@ -6,7 +6,7 @@ * (which carries provenance and lock state). */ -import { cloneElement, isValidElement, useId, type ReactElement } from 'react'; +import { cloneElement, isValidElement, lazy, Suspense, useId, type ReactElement } from 'react'; import { Input, Textarea, @@ -27,14 +27,44 @@ import { Separator, Badge, Button, + cn, getLazyIcon, } from '@object-ui/components'; import { useObjectTranslation } from '@object-ui/i18n'; -import { ChevronRight, Info } from 'lucide-react'; +import { AlertTriangle, ChevronRight, Info } from 'lucide-react'; import { EnvLockBadge } from './EnvLockBadge'; import { resolveLabel, type Specifier, type ResolvedSettingValue } from './types'; import type { SettingsLabelHelpers } from './useSettingsLabel'; +/** + * An `info_banner`'s body is a Markdown body: the spec declares `bannerText` + * with `.describe('Markdown body (info_banner)')` in `settings-manifest.zod.ts` + * (objectui#12671). It is drawn by the docs portal's renderer, the + * `MarkdownRenderer` that `DocPage` renders a package doc with: the same kind + * of text (an installed package's own words) through the same unconditional + * `rehype-sanitize` pass, used as it is. + * + * Lazy on purpose. This module is in the EAGER closure (`AppContent` imports + * `WorkspaceTimezonePrompt`, which imports it), and a static import would put + * `plugin-markdown`'s whole chunk, its syntax highlighter included, on every + * console page load. Until the chunk arrives the body shows as its own plain + * text. + */ +const BannerMarkdown = lazy(() => + import('@object-ui/plugin-markdown').then((m) => ({ default: m.MarkdownRenderer })), +); + +/** + * `bannerSeverity: 'warning'` draws the amber callout (objectui#12610 target 2, + * objectui#12671). The `Alert` primitive declares only `default` and + * `destructive`, and `packages/components/src/ui/` is not edited here, so the + * warning is the shell's existing amber callout spelling laid over `default` + * (the colours `PermissionMatrixEditor`'s and `SuggestedBindingsPanel`'s + * notices use), with the icon in the amber of the shell's warning glyphs. + */ +const WARNING_BANNER_CLASSES = + 'border-amber-300/60 bg-amber-50 text-amber-800 dark:border-amber-700/50 dark:bg-amber-950/30 dark:text-amber-200 [&>svg]:text-amber-600 dark:[&>svg]:text-amber-400'; + export interface SettingsFieldProps { spec: Specifier; resolved?: ResolvedSettingValue; @@ -271,12 +301,29 @@ export function SettingsField(props: SettingsFieldProps) { } if (spec.type === 'info_banner') { - const variant = spec.bannerSeverity === 'error' ? 'destructive' : 'default'; + // objectui#12671 — the title and body read the platform's + // `settings..banners.` slot, keyed by `id` exactly as the + // `group` arm above keys `groups.`; a miss is the manifest literal. + const bannerTitle = spec.id && labels ? labels.bannerTitle(spec.id, literalLabel) : literalLabel; + const bannerText = spec.id && labels + ? labels.bannerText(spec.id, spec.bannerText) + : spec.bannerText; + const warning = spec.bannerSeverity === 'warning'; + const BannerIcon = warning ? AlertTriangle : Info; return ( - - - {literalLabel} - {spec.bannerText ? {spec.bannerText} : null} + + + {bannerTitle} + {bannerText ? ( + + + + + + ) : null} ); } diff --git a/apps/console/src/pages/settings/useSettingsLabel.ts b/apps/console/src/pages/settings/useSettingsLabel.ts index 85a8e320fd..73e1cbe51c 100644 --- a/apps/console/src/pages/settings/useSettingsLabel.ts +++ b/apps/console/src/pages/settings/useSettingsLabel.ts @@ -12,6 +12,13 @@ * settings..keys..label / .help / .placeholder * settings..keys..options. * settings..actions..label / .confirmText / .successMessage + * settings..banners..title / .text + * + * `banners.` is keyed by an `info_banner` specifier's `id`: `title` + * translates its `label` and `text` its `bannerText` (objectstack#23886, read + * by `resolveSettingsBannerTitle` / `resolveSettingsBannerText` in the spec's + * `i18n-resolver.ts`). That slot is the ONE spelling: no other leaf or branch + * name for a banner is read here (objectui#12671). * * All lookups fall back to the literal authored in the manifest, so an * unconfigured locale gracefully degrades to English (or whatever the @@ -42,6 +49,10 @@ export interface SettingsLabelHelpers { actionLabel: (actionId: string, fallback: string) => string; actionConfirm: (actionId: string, fallback: string | undefined) => string | undefined; actionSuccess: (actionId: string, fallback: string | undefined) => string | undefined; + /** An `info_banner` title, keyed by the specifier's `id`; falls back to its `label`. */ + bannerTitle: (bannerId: string, fallback: string) => string; + /** An `info_banner` body, keyed by the specifier's `id`; falls back to its `bannerText`. */ + bannerText: (bannerId: string, fallback: string | undefined) => string | undefined; /** Human label for a `ResolvedSettingValue.source` (env/global/tenant/user/default). */ sourceLabel: (source: 'env' | 'global' | 'tenant' | 'user' | 'default') => string; } @@ -148,6 +159,9 @@ export function useSettingsLabel(namespace: string): SettingsLabelHelpers { resolve(`actions.${actionId}.confirmText`, fallback), actionSuccess: (actionId, fallback) => resolve(`actions.${actionId}.successMessage`, fallback), + bannerTitle: (bannerId, fallback) => + resolve(`banners.${bannerId}.title`, fallback) ?? fallback, + bannerText: (bannerId, fallback) => resolve(`banners.${bannerId}.text`, fallback), sourceLabel: (source) => resolveCommon(`sourceLabels.${source}`, SOURCE_FALLBACKS[source] ?? source), }; From 66556f0945ec0216427a23b431c64643d41b9d94 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 14:59:39 +0000 Subject: [PATCH 2/2] test(console): pin the info_banner's banners slot, amber warning and Markdown body; changeset (objectui#12671) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 --- .changeset/12671-settings-banner-i18n.md | 9 + .../SettingsField.infoBanner-12671.test.tsx | 238 ++++++++++++++++++ .../useSettingsLabel.banners-12671.test.tsx | 113 +++++++++ 3 files changed, 360 insertions(+) create mode 100644 .changeset/12671-settings-banner-i18n.md create mode 100644 apps/console/src/pages/settings/__tests__/SettingsField.infoBanner-12671.test.tsx create mode 100644 apps/console/src/pages/settings/__tests__/useSettingsLabel.banners-12671.test.tsx diff --git a/.changeset/12671-settings-banner-i18n.md b/.changeset/12671-settings-banner-i18n.md new file mode 100644 index 0000000000..864e78556f --- /dev/null +++ b/.changeset/12671-settings-banner-i18n.md @@ -0,0 +1,9 @@ +--- +'@object-ui/console': minor +--- + +A settings page's `info_banner` row speaks the session locale, draws a warning in amber, and renders its body as Markdown (objectui#12671, the objectui half of objectstack#23855). + +- The banner's title and body read the platform's `settings.NAMESPACE.banners.BANNER_ID` slot (`@objectstack/spec`, objectstack#23886): `title` translates the specifier's `label` and `text` its `bannerText`, keyed by the specifier's `id`, the same way a group's title reads `groups.GROUP_ID.title`. When the bundle has no entry, the manifest's own words still render. No other spelling of the slot is read. +- `bannerSeverity: 'warning'` draws an amber callout with a warning icon. `error` stays the destructive callout, and `info`, `success` and an unset severity keep the neutral one. +- `bannerText` renders as the Markdown body the spec declares, through the docs portal's Markdown renderer and its sanitizer, so inline code, emphasis and links draw as such instead of as their source characters. The renderer loads on first use, so it adds nothing to the console's first page load. diff --git a/apps/console/src/pages/settings/__tests__/SettingsField.infoBanner-12671.test.tsx b/apps/console/src/pages/settings/__tests__/SettingsField.infoBanner-12671.test.tsx new file mode 100644 index 0000000000..e5c4f935ad --- /dev/null +++ b/apps/console/src/pages/settings/__tests__/SettingsField.infoBanner-12671.test.tsx @@ -0,0 +1,238 @@ +/** + * 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 settings `info_banner` speaks the session locale, draws a warning in amber, + * and renders its body as Markdown (objectui#12671). + * + * ## What was broken + * + * The `info_banner` arm of `SettingsField` drew `` from the + * manifest's `label` and the body from the manifest's `bannerText`, both as + * literals, while the `group` arm two blocks up already asked + * `labels.groupTitle(spec.id, …)`. objectstack#23886 declares the slot a + * bundle translates a banner through, `settings.NAMESPACE.banners.BANNER_ID` + * with `title` (for `label`) and `text` (for `bannerText`), so a zh-CN admin + * read every settings banner in the manifest's English even once the bundle + * carried the translation. Two more gaps sat on the same arm: + * + * - only `error` was mapped (to `destructive`); a `warning`, which is every + * banner the platform ships, drew the neutral `default` Alert; + * - the spec declares `bannerText` a Markdown body, and the arm drew it as a + * text child, so the Feature Flags notice showed its backticks. + * + * ## How the bundle reaches the hook + * + * Through `transformSpecTranslations`, the transform `loadLanguage.ts` applies + * to the server's translations, so the tree lands under `app` as it does in + * the running console (the same reason `useSettingsLabel.conventionProbe-11801` + * gives for doing it this way). + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render, screen, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { + createI18n, + I18nProvider, + transformSpecTranslations, + type SpecTranslationData, +} from '@object-ui/i18n'; +// The banner body renders behind two lazy boundaries: `SettingsField`'s own +// `import('@object-ui/plugin-markdown')` and the plugin's +// `import('./MarkdownImpl')`. Importing both here, by the specifiers that +// resolve to the same modules, puts their load in the import phase, outside +// any `findBy` budget (AGENTS.md, test discipline: a lazy boundary is a race +// against the module loader, not a timeout to raise). +import '@object-ui/plugin-markdown'; +import '../../../../../../packages/plugin-markdown/src/MarkdownImpl'; +import { SettingsField } from '../SettingsField'; +import { useSettingsLabel } from '../useSettingsLabel'; +import type { Specifier } from '../types'; + +afterEach(cleanup); + +/** The Feature Flags manifest's banner, as the server declares it. */ +const BETA_NOTICE: Specifier = { + type: 'info_banner', + id: 'beta_notice', + label: 'Heads up', + required: false, + bannerText: + 'Beta features may change without notice. Pin via env vars (e.g. `OS_FEATURE_FLAGS_AI_ENABLED=true`) to lock for the whole deployment.', + bannerSeverity: 'warning', +}; + +const ZH_TITLE = '注意'; +const ZH_TEXT = '测试版功能可能随时变更。通过环境变量(例如 `OS_FEATURE_FLAGS_AI_ENABLED=true`)固定后对整个部署生效。'; + +function servedWith(featureFlags: Record): SpecTranslationData { + return { settings: { feature_flags: featureFlags } }; +} + +function BannerRow({ spec, namespace }: { spec: Specifier; namespace: string }) { + const labels = useSettingsLabel(namespace); + return {}} labels={labels} />; +} + +function renderBanner(spec: Specifier, served: SpecTranslationData | null, namespace = 'feature_flags') { + const instance = createI18n({ + defaultLanguage: 'zh', + detectBrowserLanguage: false, + warnMissingKeys: false, + resources: served ? { zh: transformSpecTranslations(served) } : {}, + }); + return render( + + + , + ); +} + +/** The banner's body once the Markdown renderer has drawn it. */ +async function renderedBody() { + const alert = screen.getByRole('alert'); + // `data-slot="markdown"` is the renderer's own container (`MarkdownImpl`); + // finding it is what says the body went through Markdown, not a text child. + return within(alert).findByText((_, el) => el?.getAttribute('data-slot') === 'markdown'); +} + +describe('SettingsField info_banner (objectui#12671)', () => { + describe('the title and the body read settings.NAMESPACE.banners.BANNER_ID', () => { + it('draws the translated pair a bundle carries under the banner id', async () => { + renderBanner( + BETA_NOTICE, + servedWith({ banners: { beta_notice: { title: ZH_TITLE, text: ZH_TEXT } } }), + ); + const alert = screen.getByRole('alert'); + expect(within(alert).getByRole('heading', { level: 5 })).toHaveTextContent(ZH_TITLE); + const body = await renderedBody(); + expect(body).toHaveTextContent('测试版功能可能随时变更。'); + expect(alert).not.toHaveTextContent('Heads up'); + expect(alert).not.toHaveTextContent('Beta features may change without notice.'); + }); + + it('draws the manifest literals when the bundle has no entry for the banner', async () => { + renderBanner(BETA_NOTICE, servedWith({ title: '功能开关' })); + const alert = screen.getByRole('alert'); + expect(within(alert).getByRole('heading', { level: 5 })).toHaveTextContent('Heads up'); + expect(await renderedBody()).toHaveTextContent('Beta features may change without notice.'); + }); + + it('translates each half on its own: a title-only entry keeps the literal body', async () => { + renderBanner(BETA_NOTICE, servedWith({ banners: { beta_notice: { title: ZH_TITLE } } })); + const alert = screen.getByRole('alert'); + expect(within(alert).getByRole('heading', { level: 5 })).toHaveTextContent(ZH_TITLE); + expect(await renderedBody()).toHaveTextContent('Beta features may change without notice.'); + }); + + it.each([ + ['a singular `banner` branch', { banner: { beta_notice: { title: ZH_TITLE, text: ZH_TEXT } } }], + ['a `notices` branch', { notices: { beta_notice: { title: ZH_TITLE, text: ZH_TEXT } } }], + ['a `bannerText` leaf', { banners: { beta_notice: { title: 'x', bannerText: ZH_TEXT } } }], + ['a `label` leaf for the title', { banners: { beta_notice: { label: ZH_TITLE } } }], + ])('reads no other spelling: %s is not consulted', async (_name, featureFlags) => { + renderBanner(BETA_NOTICE, servedWith(featureFlags)); + const alert = screen.getByRole('alert'); + expect(alert).not.toHaveTextContent(ZH_TITLE); + expect(await renderedBody()).toHaveTextContent('Beta features may change without notice.'); + }); + + it('asks no helper when the specifier has no id, as the group arm does', async () => { + const { id: _drop, ...noId } = BETA_NOTICE; + renderBanner( + noId as Specifier, + servedWith({ banners: { undefined: { title: ZH_TITLE, text: ZH_TEXT } } }), + ); + const alert = screen.getByRole('alert'); + expect(within(alert).getByRole('heading', { level: 5 })).toHaveTextContent('Heads up'); + expect(await renderedBody()).toHaveTextContent('Beta features may change without notice.'); + }); + }); + + describe('bannerSeverity picks the callout', () => { + it('warning draws the amber callout over the default variant', () => { + render( {}} />); + const alert = screen.getByRole('alert'); + expect(alert).toHaveClass('bg-amber-50', 'text-amber-800', 'border-amber-300/60'); + // tailwind-merge drops the default variant's colours, so amber is not + // merely appended under a background that wins. + expect(alert).not.toHaveClass('bg-background'); + expect(alert).not.toHaveClass('text-foreground'); + }); + + it('error stays destructive, and is not amber', () => { + render( + {}} + />, + ); + const alert = screen.getByRole('alert'); + expect(alert).toHaveClass('text-destructive'); + expect(alert).not.toHaveClass('bg-amber-50'); + }); + + it.each(['info', 'success', undefined] as const)('%s draws the default variant, not amber', (severity) => { + render( + {}} + />, + ); + const alert = screen.getByRole('alert'); + expect(alert).toHaveClass('bg-background'); + expect(alert).not.toHaveClass('bg-amber-50'); + expect(alert).not.toHaveClass('text-destructive'); + }); + }); + + describe('bannerText renders as the Markdown body the spec declares', () => { + it('draws inline code, emphasis and a link as elements, not as their source', async () => { + render( + {}} + />, + ); + const body = await renderedBody(); + expect(within(body).getByText('OS_FEATURE_FLAGS_AI_ENABLED=true').tagName).toBe('CODE'); + expect(within(body).getByText('before').tagName).toBe('STRONG'); + expect(within(body).getByRole('link', { name: 'read the guide' })).toHaveAttribute( + 'href', + 'https://example.com/flags', + ); + expect(body.textContent).not.toContain('`'); + expect(body.textContent).not.toContain('**'); + }); + + it('draws a translated body through the same renderer', async () => { + renderBanner( + BETA_NOTICE, + servedWith({ banners: { beta_notice: { title: ZH_TITLE, text: ZH_TEXT } } }), + ); + const body = await renderedBody(); + expect(within(body).getByText('OS_FEATURE_FLAGS_AI_ENABLED=true').tagName).toBe('CODE'); + }); + + it('draws no body when the banner declares none', () => { + const { bannerText: _drop, ...titleOnly } = BETA_NOTICE; + render( {}} />); + const alert = screen.getByRole('alert'); + expect(within(alert).getByRole('heading', { level: 5 })).toHaveTextContent('Heads up'); + expect(alert.querySelector('[data-slot="markdown"]')).toBeNull(); + expect(alert.textContent).toBe('Heads up'); + }); + }); +}); diff --git a/apps/console/src/pages/settings/__tests__/useSettingsLabel.banners-12671.test.tsx b/apps/console/src/pages/settings/__tests__/useSettingsLabel.banners-12671.test.tsx new file mode 100644 index 0000000000..f874addbf0 --- /dev/null +++ b/apps/console/src/pages/settings/__tests__/useSettingsLabel.banners-12671.test.tsx @@ -0,0 +1,113 @@ +/** + * 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. + */ + +/** + * `useSettingsLabel`'s banner helpers read the platform's + * `settings.NAMESPACE.banners.BANNER_ID.{title,text}` slot (objectui#12671). + * + * The slot is declared by objectstack#23886 and read on the platform by + * `resolveSettingsBannerTitle` / `resolveSettingsBannerText`: the title + * translates an `info_banner`'s `label`, the text its `bannerText`, and a miss + * is the manifest literal. The helpers go through the hook's one `resolve()`, + * the lookup every other helper uses, so they inherit its namespace discovery + * and its exists-first probe (objectui#11801). `SettingsField`'s own suite + * (`SettingsField.infoBanner-12671`) pins what the arm draws; this one pins the + * two paths and their fallbacks at the hook. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { + createI18n, + I18nProvider, + transformSpecTranslations, + type SpecTranslationData, +} from '@object-ui/i18n'; +import { useSettingsLabel } from '../useSettingsLabel'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const SERVED: SpecTranslationData = { + settings: { + storage: { + banners: { + adapter_switch: { + title: 'Adapter-Wechsel', + text: 'Beim Wechsel werden vorhandene Dateien **nicht** migriert.', + }, + }, + }, + knowledge: { + banners: { adapter_switch: { title: 'Knowledge title', text: 'Knowledge text' } }, + }, + }, +}; + +function instanceFor(served: SpecTranslationData | null) { + return createI18n({ + defaultLanguage: 'de', + detectBrowserLanguage: false, + warnMissingKeys: true, + resources: served ? { de: transformSpecTranslations(served) } : {}, + }); +} + +function helpersFor(namespace: string, served: SpecTranslationData | null, instance = instanceFor(served)) { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useSettingsLabel(namespace), { wrapper }).result.current; +} + +describe('useSettingsLabel banner helpers (objectui#12671)', () => { + it('bannerTitle reads banners.BANNER_ID.title and bannerText reads banners.BANNER_ID.text', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const labels = helpersFor('storage', SERVED); + expect(labels.bannerTitle('adapter_switch', 'Switching adapter')).toBe('Adapter-Wechsel'); + expect(labels.bannerText('adapter_switch', 'Existing files are not migrated.')).toBe( + 'Beim Wechsel werden vorhandene Dateien **nicht** migriert.', + ); + }); + + it('stays inside its own namespace', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(helpersFor('knowledge', SERVED).bannerTitle('adapter_switch', 'x')).toBe('Knowledge title'); + expect(helpersFor('mail', SERVED).bannerTitle('adapter_switch', 'Literal')).toBe('Literal'); + }); + + it('falls back to the manifest literal on a miss, an absent body included', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const labels = helpersFor('storage', SERVED); + expect(labels.bannerTitle('other_banner', 'Heads up')).toBe('Heads up'); + expect(labels.bannerText('other_banner', 'Literal body')).toBe('Literal body'); + expect(labels.bannerText('other_banner', undefined)).toBeUndefined(); + const bare = helpersFor('storage', null); + expect(bare.bannerTitle('adapter_switch', 'Heads up')).toBe('Heads up'); + expect(bare.bannerText('adapter_switch', undefined)).toBeUndefined(); + }); + + it('a probe that misses by design logs no missing-translation warning (the objectui#11801 property)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const bannerMisses = () => + warn.mock.calls + .map((args) => String(args[0])) + .filter((line) => line.includes('Missing translation') && line.includes('.banners.other_banner.')); + const instance = instanceFor(SERVED); + const labels = helpersFor('storage', SERVED, instance); + labels.bannerTitle('other_banner', 'Heads up'); + labels.bannerText('other_banner', undefined); + expect(bannerMisses()).toEqual([]); + // Control: the same key asked with a plain `t()` does warn, so the empty + // list above is a reading of a live channel, not a muted one. + instance.t('app.settings.storage.banners.other_banner.title'); + expect(bannerMisses()).toHaveLength(1); + }); +});