Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/11689-boolean-greeting-locale.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/fields': minor
'@object-ui/plugin-detail': minor
'@object-ui/plugin-grid': minor
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
---

Read-only boolean values, the boolean list face and the Home greeting's punctuation follow the language (objectui#11689).

Under zh-CN a record showed its boolean values as "Yes" / "No", and the Home greeting joined a Chinese greeting to the person's name with an ASCII comma and closed it with an ASCII period.

- **Booleans.** The read-only surfaces that draw a boolean as a word now read the existing `common.yes` / `common.no` keys: `BooleanField`'s readonly display, a `FormulaField` whose `returnType` is `boolean`, the lookup column's plain-text fallback, and the record-detail highlights chip. zh-CN shows the Chinese words; English is unchanged. Without an `I18nProvider` the words stay "Yes" / "No".
- **The boolean list face.** `BooleanCellRenderer`, which every list surface draws, spelled its status badge ("Active — Off") and its completion indicator's accessible names ("Completed" / "Not completed") in English. All three now come from the language packs. The grid also handed that face the authored field label while its header printed the translated one, so the badge named the column in the authored language under a translated header. The face now receives the label the header prints.
- **Greeting.** The comma before the name and the closing mark are two new keys, so zh-CN reads the full-width comma and full stop, Japanese its own marks and Arabic its own comma. English is unchanged, and the name keeps its own colour.

**Widened public surface.**

- `@object-ui/i18n`: five new keys in all ten language packs, so the exported `en` pack and the `TranslationKeys` type derived from it gain `home.greetingSeparator`, `home.greetingEnd`, `fields.boolean.offBadge`, `fields.boolean.completed` and `fields.boolean.notCompleted`.
- `@object-ui/fields`: a new export, `useBooleanValueLabel()`, with its type `BooleanValueLabel`. It returns the current language's word for a boolean value, and `@object-ui/plugin-detail`'s highlights chip reads it.
8 changes: 6 additions & 2 deletions packages/app-shell/src/console/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -429,13 +429,17 @@ export function HomePage() {
{/* Greeting + global search + AI */}
<div className="mb-7 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
{/* The joiners are the locale's punctuation (objectui#11689):
zh-CN and ja write the full-width comma and full stop, ar its
own comma. Two keys rather than one interpolated sentence so
the name keeps its own coloured span. */}
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-pretty">
<span className="text-foreground">
{greeting}
{displayName ? ', ' : ''}
{displayName ? t('home.greetingSeparator', { defaultValue: ', ' }) : ''}
</span>
{displayName && <span className="text-primary">{displayName}</span>}
<span className="text-foreground/40">.</span>
<span className="text-foreground/40">{t('home.greetingEnd', { defaultValue: '.' })}</span>
</h1>
<p className="mt-1 text-sm sm:text-base text-muted-foreground">
{t('home.heroTagline', { defaultValue: 'Pick up where you left off, or explore something new.' })}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The Home greeting joins with the locale's punctuation (objectui#11689).
*
* Under zh-CN the heading was a translated greeting held together by the
* ASCII `, ` and `.` the markup spelled in code. The two joiners are now
* `home.greetingSeparator` and `home.greetingEnd`, in every pack, so zh-CN
* writes the full-width comma and full stop while en stays byte-identical.
*
* Two keys rather than one interpolated sentence: the name keeps its own
* coloured span, which a single translated string could not carry. The
* coloured-span assertion below pins that the markup survived.
*
* Every case renders the REAL `HomePage` under a REAL `I18nProvider` over the
* real packs — the surfaces unrelated to the heading are stubbed exactly as
* the sibling `HomePage.*.test.tsx` files stub them, but translation is not:
* a stand-in `t` would pass against a heading that never asked the locale.
* The clock is pinned to 02:30 local time so the greeting is the night one,
* the case the card measured.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { render, screen, cleanup } from '@testing-library/react';
import { I18nProvider } from '@object-ui/i18n';

vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
}));

let userFixture: { id: string; name: string; email: string } = {
id: 'u1',
name: 'Dev Admin',
email: 'dev.admin@example.com',
};

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ user: userFixture }),
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
}));

vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-chatbot')>()),
useAgents: () => ({ agents: [] }),
isAskAgent: () => false,
agentHasCapability: () => false,
}));

// A non-empty app list: with none, Home renders its welcome state, which has
// no greeting heading.
vi.mock('../../../providers/MetadataProvider', () => ({
useMetadata: () => ({ apps: [{ name: 'crm', label: 'CRM' }], loading: false }),
}));

vi.mock('../../../context/NavigationContext', () => ({
useNavigationContext: () => ({ currentAppName: undefined }),
}));

// --- surfaces unrelated to the heading -------------------------------------
vi.mock('../../../hooks/useRecentItems', () => ({ useRecentItems: () => ({ recentItems: [] }) }));
vi.mock('../../../hooks/useFavorites', () => ({ useFavorites: () => ({ favorites: [] }) }));
vi.mock('../../../hooks/useHomeInbox', () => ({
useHomeInbox: () => ({
pendingApprovalsCount: 0,
notifications: [],
unreadTopicCount: 0,
activities: [],
}),
}));
vi.mock('../../../hooks/useAiSurface', () => ({ resolveAiApiBase: () => '' }));
vi.mock('../../../views/metadata-admin/useMetadata', () => ({
useMetadataClient: () => ({ listDrafts: async () => [] }),
}));
vi.mock('../../../preview/usePublishAllDrafts', () => ({
usePublishAllDrafts: () => ({ publishAll: async () => ({ ok: true }), publishing: false }),
}));
vi.mock('../../../runtime-config', () => ({
getRuntimeConfig: () => ({ branding: { productName: 'ObjectStack' } }),
// An explicit factory replaces the whole module, so both gates Home reads
// must be listed; `true` keeps the page on its ordinary shape. The gates
// themselves are covered by `HomePage.marketplaceDisabled.test.tsx` and
// `HomePage.aiStudioDisabled.test.tsx`.
isMarketplaceEnabled: () => true,
isAiStudioEnabled: () => true,
}));

import { HomePage } from '../HomePage';

/* ── The `_drafts` double ─────────────────────────────────────────────────────
* Every render mounts `PendingDraftsBanner`, whose pending-draft count is read
* with the GLOBAL `fetch` from `GET /api/v1/meta/_drafts`. Answered from the
* same RECORDING double the sibling files use (copied from
* `HomePage.notificationDeepLink.test.tsx`): a known-empty ledger, so no banner
* renders, and `afterEach` fails on any url outside the one route it serves.
* ─────────────────────────────────────────────────────────────────────────── */

const DRAFTS_ROUTE = '/api/v1/meta/_drafts';
let fetchCalls: string[] = [];
const routeOf = (url: string) => url.split('?')[0];

beforeEach(() => {
fetchCalls = [];
vi.stubGlobal(
'fetch',
vi.fn(async (input: unknown) => {
const url = String(
input && typeof input === 'object' && 'url' in input ? (input as { url: unknown }).url : input,
);
fetchCalls.push(url);
if (routeOf(url) !== DRAFTS_ROUTE) return { ok: false, status: 404, json: async () => ({}) };
return { ok: true, status: 200, json: async () => ({ drafts: [] }) };
}),
);
// Only `Date` is faked: timers stay real for the page's mount effects.
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(2026, 9, 6, 2, 30, 0));
userFixture = { id: 'u1', name: 'Dev Admin', email: 'dev.admin@example.com' };
});

afterEach(() => {
expect(fetchCalls.filter((url) => routeOf(url) !== DRAFTS_ROUTE)).toEqual([]);
// Unmount before restoring the real `fetch` (objectui#7439).
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});

function renderHome(language: string) {
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
<HomePage />
</I18nProvider>,
);
}

const heading = () => screen.getByRole('heading', { level: 1 });

describe('the Home greeting joins with the locale punctuation (objectui#11689)', () => {
it('zh renders the full-width comma and full stop: 夜深了,Dev Admin。', () => {
renderHome('zh');
expect(heading().textContent).toBe('夜深了,Dev Admin。');
});

it('en is unchanged: Working late, Dev Admin.', () => {
renderHome('en');
expect(heading().textContent).toBe('Working late, Dev Admin.');
});

it('the name keeps its own coloured span', () => {
renderHome('zh');
const name = screen.getByText('Dev Admin');
expect(name.tagName).toBe('SPAN');
expect(name).toHaveClass('text-primary');
});

it('with no name there is no separator, only the closing mark', () => {
userFixture = { id: 'u1', name: '', email: '' };
renderHome('zh');
expect(heading().textContent).toBe('夜深了。');
cleanup();
renderHome('en');
expect(heading().textContent).toBe('Working late.');
});
});
187 changes: 187 additions & 0 deletions packages/fields/src/__tests__/booleanValueLabel.locale-11689.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* 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 read-only boolean is drawn with the locale's word (objectui#11689).
*
* Under zh-CN a record showed its boolean values as "Yes" / "No": the
* read-only surfaces that draw a boolean as text spelled the English words in
* code. They now read `common.yes` / `common.no` through `useBooleanValueLabel`
* (`widgets/booleanValueLabel.tsx` says why that pair and not `grid.*` or
* `lookup.yes`).
*
* Every case renders through a REAL `I18nProvider` over the real packs, so a
* pin goes red if the word stops coming from the pack — a stand-in `t` would
* pass against a widget that never asked the locale anything. The provider-less
* cases pin the other half of the contract: an embed with no provider still
* says `Yes` / `No`, through `useFieldTranslation`'s defaults table.
*/
import React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
import { I18nProvider } from '@object-ui/i18n';
import { builtInLocales } from '@object-ui/i18n/locales';

import { BooleanField } from '../widgets/BooleanField';
import { FormulaField } from '../widgets/FormulaField';
import { renderLookupColumnValue } from '../widgets/lookupColumnDisplay';
import { BooleanCellRenderer } from '../index';

afterEach(() => cleanup());

const noop = () => {};

type Lang = keyof typeof builtInLocales;
const LANGS = Object.keys(builtInLocales) as Lang[];

function inLocale(language: Lang | null, node: React.ReactElement) {
if (language === null) return render(node);
return render(
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}>
{node}
</I18nProvider>,
);
}

/** The three read-only boolean drawers in this package, each as a host renders it. */
const DRAWERS: Record<string, (value: boolean) => React.ReactElement> = {
'BooleanField (readonly)': (value) => (
<BooleanField
value={value}
onChange={noop}
field={{ type: 'boolean', name: 'email_verified' } as never}
readonly={true}
/>
),
'FormulaField (returnType boolean)': (value) => (
<FormulaField
value={value}
onChange={noop}
field={{ type: 'formula', name: 'is_overdue', returnType: 'boolean' } as never}
readonly={true}
/>
),
// No descriptor and no resolver: the plain-text fallback, the branch that
// spelled the word.
'renderLookupColumnValue (plain-text fallback)': (value) => (
<span>{renderLookupColumnValue({ active: value }, { field: 'active' } as never, { descriptors: {} })}</span>
),
};

describe('read-only boolean words come from the locale (objectui#11689)', () => {
for (const [name, draw] of Object.entries(DRAWERS)) {
describe(name, () => {
it('zh renders 是 / 否', () => {
expect(inLocale('zh', draw(true)).container.textContent).toBe('是');
cleanup();
expect(inLocale('zh', draw(false)).container.textContent).toBe('否');
});

it('en is unchanged: Yes / No', () => {
expect(inLocale('en', draw(true)).container.textContent).toBe('Yes');
cleanup();
expect(inLocale('en', draw(false)).container.textContent).toBe('No');
});

it("every pack's own common.yes / common.no is the word drawn", () => {
for (const lang of LANGS) {
expect(inLocale(lang, draw(true)).container.textContent, `${lang}: true`).toBe(
builtInLocales[lang].common.yes,
);
cleanup();
expect(inLocale(lang, draw(false)).container.textContent, `${lang}: false`).toBe(
builtInLocales[lang].common.no,
);
cleanup();
}
});

it('with no I18nProvider it still says Yes / No (the defaults table)', () => {
expect(inLocale(null, draw(true)).container.textContent).toBe('Yes');
cleanup();
expect(inLocale(null, draw(false)).container.textContent).toBe('No');
});
});
}
});

/**
* The list face's own words (objectui#11689, the family site seat 1 recorded
* from objectui#11721): the status column's "LABEL — Off" badge and the
* completion indicator's two accessible names. Every list surface draws this
* face, so these were English on every list under every pack.
*/
describe('BooleanCellRenderer words come from the locale (objectui#11689)', () => {
const badgeOf = (root: HTMLElement) =>
root.querySelector<HTMLElement>('[data-testid="boolean-warning-badge"]')?.textContent;
const indicatorName = (root: HTMLElement) =>
root.querySelector<HTMLElement>('[data-testid="completion-indicator"]')?.getAttribute('aria-label');

it('zh: the status badge reads the column label and the pack word', () => {
const { container } = inLocale(
'zh',
<BooleanCellRenderer value={false} field={{ name: 'active', type: 'boolean', label: '启用' } as never} />,
);
expect(badgeOf(container)).toBe('启用 — 已关闭');
});

it('zh: the completion indicator is named in the pack', () => {
const field = { name: 'completed', type: 'boolean', label: 'Completed' } as never;
expect(indicatorName(inLocale('zh', <BooleanCellRenderer value={true} field={field} />).container)).toBe('已完成');
cleanup();
expect(indicatorName(inLocale('zh', <BooleanCellRenderer value={false} field={field} />).container)).toBe('未完成');
});

it('en is unchanged: Active — Off, Completed, Not completed', () => {
expect(
badgeOf(
inLocale('en', <BooleanCellRenderer value={false} field={{ name: 'active', type: 'boolean', label: 'Active' } as never} />)
.container,
),
).toBe('Active — Off');
cleanup();
const field = { name: 'completed', type: 'boolean' } as never;
expect(indicatorName(inLocale('en', <BooleanCellRenderer value={true} field={field} />).container)).toBe('Completed');
cleanup();
expect(indicatorName(inLocale('en', <BooleanCellRenderer value={false} field={field} />).container)).toBe('Not completed');
});

it("every pack's own words are the ones drawn", () => {
for (const lang of LANGS) {
const words = builtInLocales[lang].fields.boolean;
expect(
badgeOf(
inLocale(lang, <BooleanCellRenderer value={false} field={{ name: 'active', type: 'boolean', label: 'L' } as never} />)
.container,
),
`${lang}: badge`,
).toBe(words.offBadge.replace('{{label}}', 'L'));
cleanup();
expect(
indicatorName(inLocale(lang, <BooleanCellRenderer value={true} field={{ name: 'done', type: 'boolean' } as never} />).container),
`${lang}: completed`,
).toBe(words.completed);
cleanup();
expect(
indicatorName(inLocale(lang, <BooleanCellRenderer value={false} field={{ name: 'done', type: 'boolean' } as never} />).container),
`${lang}: not completed`,
).toBe(words.notCompleted);
cleanup();
}
});

it('with no I18nProvider it still says the English words (the defaults table)', () => {
expect(
badgeOf(inLocale(null, <BooleanCellRenderer value={false} field={{ name: 'active', type: 'boolean', label: 'Active' } as never} />).container),
).toBe('Active — Off');
cleanup();
expect(indicatorName(inLocale(null, <BooleanCellRenderer value={true} field={{ name: 'done', type: 'boolean' } as never} />).container)).toBe(
'Completed',
);
});
});
Loading
Loading