Skip to content
Merged
7 changes: 7 additions & 0 deletions .changeset/10024-search-results-count-plural-forms.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,10 @@ the device both packs already use where two slots are all there are — Russian'
two-key convention. The singular half, the call site, the key set and the other
eight packs are unchanged; `zh`, `ja` and `ko` keep one string in both halves
because their languages have a single plural category.

⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.**
Later in this same release the `search.resultsCount` / `search.resultsCountPlural`
switch gave way to a `search.resultsCount` count family in all ten packs:
`ru` and `ar` now write a noun form for each CLDR category instead of the count
label above (`2 результата`, `21 результат`), and `search.resultsCountPlural` left
every pack. The rest of this entry is kept as the reading of this change.
9 changes: 9 additions & 0 deletions .changeset/10242-ru-ar-count-labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,12 @@ That is the device objectui#10024 used for `search.resultsCountPlural`, and the
Russian's `collaboration.commentCount` and `collaboration.reactionCount` already
use. The singular halves, the key set, the call sites and the other eight packs are
unchanged.

⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.**
Later in this same release the `…One` / plain-key switch on `common.itemCount`,
`detail.reactionCount`, `collaboration.commentCount` and
`collaboration.reactionCount` gave way to one count family per key in all ten
packs: a numeric count picks the noun form its CLDR category needs (`ru`
`2 элемента`, `21 элемент`), the `…One` siblings left every pack, and the count
labels above remain only on the base key, which answers a call made without a
count. The rest of this entry is kept as the reading of this change.
9 changes: 9 additions & 0 deletions .changeset/10425-ru-ar-count-labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,12 @@ correctly at any number:
That is the device objectui#10024 and objectui#10242 used, and the one Russian's
`lookup.recordCount` already uses. The singular halves, the key set, the call sites
and the other eight packs are unchanged.

⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.**
Later in this same release the `…One` / plain-key switch on `list.recordCount`,
`lookup.recordCount`, `detail.reactionCount`, `collaboration.presentUserCount`,
`collaboration.moreUserCount` and `search.itemsAvailable` gave way to one count
family per key in all ten packs: a numeric count picks the noun form its CLDR
category needs (`ru` `2 записи`, `ar` `3 سجلات`), the `…One` siblings left every
pack, and the count labels above remain only on the base key, which answers a
call made without a count. The rest of this entry is kept as the reading of this change.
8 changes: 8 additions & 0 deletions .changeset/10636-objectview-record-count-singular.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,11 @@ value held one noun form: Russian read `2 записей` at two and `21 зап
twenty-one, and Arabic used the singular noun at every count. Both now use the
count label their list bar already uses, `Записей: N` and `عدد السجلات: N`,
which is right at any number. At one record they read `1 запись` and `1 سجل`.

⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.**
Later in this same release the `console.objectView.recordCountOne` /
`console.objectView.recordCount` switch gave way to a
`console.objectView.recordCount` count family in all ten packs, worded like
`list.recordCount`'s: the footer calls the family key with `{ count }`,
`console.objectView.recordCountOne` left every pack, and `ru` and `ar` read a
noun form at each count (`2 записи`, `3 سجلات`) where the count label stood. The rest of this entry is kept as the reading of this change.
5 changes: 5 additions & 0 deletions .changeset/11445-app-shell-count-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': patch
---

The search results line and the object view's record-count footer read the right noun form in every language at every count (objectui#11445). Each passes `count` to one i18next count family instead of choosing between a `…One` / `…Plural` key on `=== 1`, which gave Russian and Arabic only two forms: Russian now reads `2 результата` and `21 запись`, and Arabic uses its dual at 2.
5 changes: 5 additions & 0 deletions .changeset/11445-collaboration-count-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/collaboration': patch
---

The comment thread's header, its reaction tooltip and the presence stack's labels read the right noun form in every language at every count (objectui#11445). Each passes `count` to one i18next count family as a number — not a string, which i18next does not plural-select — instead of choosing between a `…CountOne` key and its pair on `=== 1`. The relative comment timestamps pass a number too, so Arabic reads `قبل 5 دقائق` rather than `قبل 5 دقيقة`. `COLLAB_DEFAULT_TRANSLATIONS` carries each family's `_one` / `_other` rows, mirroring the `en` pack.
5 changes: 5 additions & 0 deletions .changeset/11445-components-count-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/components': patch
---

The data table's "N rows modified" line and the `page:tabs` count badge's accessible name read the right noun form in every language at every count (objectui#11445): both are i18next count families now, so English no longer reads `3 row modified` and Russian reads `2 элемента` rather than a count label. The tab badge passes its number as `count` below 1000; a shortened `1.2k` is passed as the string the badge shows, which i18next does not plural-select, so the base key — count-invariant in every pack — answers it.
5 changes: 5 additions & 0 deletions .changeset/11445-fields-count-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/fields': patch
---

The record picker's record count reads the right noun form in every language at every count (objectui#11445): it passes `count` to the `lookup.recordCount` count family instead of choosing `lookup.recordCountOne` on `=== 1`, so Russian reads `2 записи` and Arabic uses its dual at 2. The provider-less defaults table carries the family's `_one` / `_other` rows.
17 changes: 17 additions & 0 deletions .changeset/11445-i18n-count-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/i18n': patch
---

Count labels read the right noun form in every language at every count: each label a word must agree with is now an i18next count family in all ten packs (objectui#11445).

**What was wrong.** objectui#11432 gave every count family every CLDR category its language selects. Two other spellings of the same defect sat outside that rule. Fifteen labels were code-selected pairs (`detail.attachmentCount` / `detail.attachmentCountPlural`, `common.itemCount` / `common.itemCountOne`, and thirteen more): the component chose between two keys on `=== 1`, which gives a language two forms, so Russian read `3 вложений` and Arabic `2 مرفقات`. Fifty-three labels were one string at every count, so Russian read `Согласовать 3 запросов?`, Arabic `منذ 5 دقيقة`, Spanish `1 seleccionados` and English `3 row modified`.

**What changes.** Each of those 68 keys is a family: `en` `_one` / `_other`, `ru` `_one` / `_few` / `_many` / `_other`, `ar` all six categories, and each other pack the categories its language selects. The base key keeps its value and answers a call made without a count.

**Removed keys.** The fifteen code-selected siblings leave all ten packs: `lookup.recordCountOne`, `common.itemCountOne`, `list.recordCountOne`, `detail.reactionCountOne`, `detail.attachmentCountPlural`, `detail.replyCountPlural`, `console.objectView.recordCountOne`, `search.resultsCountPlural`, `search.itemsAvailableOne`, `ai.formAssist.suggestionCountOne`, `ai.formAssist.appliedCountOne`, `collaboration.commentCountOne`, `collaboration.reactionCountOne`, `collaboration.presentUserCountOne` and `collaboration.moreUserCountOne`. A host that looked one up itself now gets the key back instead of a label: call the family key with `{ count }` instead (`t('detail.replyCount', { count: n })`), and i18next picks the form.

**Provider-less fallback.** `createSafeTranslation` now reads a count family's `_one` / `_other` row for a numeric `count` before the base row, in the order i18next reads the `en` pack, so a host with no `I18nProvider` keeps rendering "1 reply" / "3 replies". A string `count` still reads the base row, as it does in i18next.

**Supersedes, in this release.** The notes for objectui#9664, objectui#10024, objectui#10242, objectui#10425 and objectui#10636 describe the `…One` / `…Plural` switch these families replace; each now carries a dated note saying so. Their `ru` / `ar` count labels (`Записей: N`, `عدد السجلات: N`) stay only on the base key, which answers a call made without a count.

**Pinned.** `count-families-11445.test.ts` fails on any `…Plural` key in a pack and on any `{{count}}` value outside a family unless its count-neutral list names the key with the reason no word agrees with the number.
5 changes: 5 additions & 0 deletions .changeset/11445-plugin-ai-count-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/plugin-ai': patch
---

The AI form assistant's suggestion and applied counts read the right noun form in every language at every count (objectui#11445): each passes `count` to one i18next count family instead of choosing a `…CountOne` key on `=== 1`, so Arabic reads `3 اقتراحات` rather than a count label and Russian `2 предложения`.
5 changes: 5 additions & 0 deletions .changeset/11445-plugin-detail-count-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/plugin-detail': patch
---

The comment attachment count, the threaded-reply count and the reaction chip's accessible name read the right noun form in every language at every count (objectui#11445). Each passes `count` to one i18next count family instead of choosing between a `…Plural` / `…One` key on `=== 1`, which gave Russian `3 вложений` and Arabic `2 مرفقات`; they now read `3 вложения` and the Arabic dual. The relative timestamps (`detail.minutesAgo` and its two siblings) and the "Show N empty fields" toggle are count families too, so one empty field reads `Show 1 empty field`. The provider-less defaults table carries each family's `_one` / `_other` rows.
5 changes: 5 additions & 0 deletions .changeset/11445-plugin-gantt-count-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/plugin-gantt': patch
---

The schedule-conflict and auto-schedule dialogs' task counts read the right noun form in every language at every count (objectui#11445): `gantt.conflict.body`, `gantt.autoScheduleDlg.body` and `gantt.autoScheduleDlg.skipped` are i18next count families, so German no longer reads `1 betroffene Vorgänge` and Russian reads `3 задачи`. The provider-less fallback reads a family's `_one` / `_other` row for a numeric `count`.
5 changes: 5 additions & 0 deletions .changeset/11445-plugin-grid-count-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/plugin-grid': patch
---

The import wizard's counts read the right noun form in every language at every count (objectui#11445): the auto-match summary, the rows-with-errors and rows-corrected lines, the result counts and the Import button are i18next count families, so Russian reads `Импортировать 3 строки` and English `Import 1 Row`. The auto-match summary passes its count as a number — i18next does not plural-select a string. The wizard's provider-less fallback reads a family's `_one` / `_other` row for a numeric `count`.
5 changes: 5 additions & 0 deletions .changeset/11445-plugin-list-count-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/plugin-list': patch
---

The list view's record-count bar reads the right noun form in every language at every count (objectui#11445): it passes `count` to the `list.recordCount` count family instead of choosing `list.recordCountOne` on `=== 1`, so Russian reads `2 записи` and Arabic uses its dual at 2. `LIST_DEFAULT_TRANSLATIONS` carries the family's `_one` / `_other` rows.
5 changes: 5 additions & 0 deletions .changeset/11445-plugin-view-count-families.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/plugin-view': patch
---

The object view's provider-less defaults table carries the `_one` / `_other` rows of the two count families it serves, `console.objectView.bulkDeleteConfirm` and `objectActions.bulkDeleteSuccess` (objectui#11445), so a host with no `I18nProvider` reads `Delete 1 selected record?` at one record.
7 changes: 7 additions & 0 deletions .changeset/9664-search-items-available-plural.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,10 @@ the singular half of that marker was dead weight while the parentheses still
rendered at every count the key does serve. It now reads as a singular noun at one
item and as a count label at every other count, the shape `ar`'s
`common.itemCount`/`itemCountOne` uses (objectui#10425).

⚠️ **Dated note, 2026-10-02 — superseded in this release — objectui#11445.**
Later in this same release the `search.itemsAvailable` / `search.itemsAvailableOne`
switch gave way to a `search.itemsAvailable` count family in all ten packs: the
browse branch calls the family key with `{ count }`, each pack spells every CLDR
category its language selects (so the base-key fall-through described above no
longer arises), and `search.itemsAvailableOne` left every pack. The rest of this entry is kept as the reading of this change.
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,13 @@
* | `detail.showEmptyRelated{,_one,_other}` suffixed rows | all three in pack, all three agree | compared, AND their key-set mirror is pinned separately (see below) |
* | a map row whose key the pack lacks | 0 rows, all three maps | not an allowed state: it fails, naming itself a finding to file |
*
* The suffixed `showEmptyRelated` rows exist for a reason stated in
* `useDetailTranslation.ts`: `fallbackT` resolves `defaults[key]` LITERALLY and
* never appends a plural suffix, so only the BASE row is reachable through the
* provider-less path — the two suffixed rows are kept purely so the map's key set
* still mirrors the packs'. That rationale is a key-set claim, so the gate
* enforces it as one, on top of comparing all three values.
* The suffixed `showEmptyRelated` rows were kept, before objectui#11445, purely
* so the map's key set mirrored the packs': `fallbackT` read `defaults[key]`
* literally, so only the BASE row was reachable on the provider-less path.
* objectui#11445 taught `fallbackT` i18next's order (`key_<category>` for a
* numeric `count`, then the base row), so all three rows are live now — and
* every converted count family's `_one` / `_other` rows ride the same path. The
* key-set pin below still holds them, on top of comparing all three values.
*
* ## Direction, predicted before running (#4118)
*
Expand Down Expand Up @@ -288,11 +289,11 @@ describe('the plugin defaults maps mirror the en pack (objectui#4401)', () => {
});

it('the showEmptyRelated family keeps mirroring the packs’ key set', () => {
// `useDetailTranslation.ts` keeps two rows it can never serve: `fallbackT`
// indexes `defaults[key]` literally and never appends a plural suffix, so
// only the base row is reachable without a provider (objectui#3863). Their
// stated reason is that the map's key set still mirrors the packs' — a claim
// about KEYS, pinned here as one. Their values are compared by the
// `useDetailTranslation.ts` keeps the family's base and suffixed rows. Until
// objectui#11445 the suffixed two were unreachable (`fallbackT` read
// `defaults[key]` literally, objectui#3863) and were kept so the map's key
// set mirrored the packs'; `fallbackT` now reads them for a numeric `count`.
// The key-set claim is pinned here as one; their values are compared by the
// byte-identity case above like every other row.
for (const key of [
'detail.showEmptyRelated',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,15 @@
* count-not-one half is a count label — a label, a colon, then the number —
* as objectui#10024, objectui#10242 and objectui#10425 ruled. Every pack's pair
* reads the way its `list.recordCount` pair does, so the footer and the list
* bar count the same records in the same words. ⛔ No i18next `_one` / `_few`
* suffixes: `all-locales-key-parity.test.ts` holds every pack to `en`'s key set.
* bar count the same records in the same words.
*
* ## objectui#11445 — one count family
*
* objectui#11432 let a pack hold every CLDR slot its language selects, so
* objectui#11445 made `console.objectView.recordCount` (and `list.recordCount`)
* an i18next count family: the footer passes `count` and i18next picks the slot,
* so `ru` reads «2 записи» and `ar` its dual at 2, and the `…One` sibling left
* all ten packs. The strings below are those.
*
* ## What this file measures
*
Expand Down Expand Up @@ -184,7 +191,7 @@ type Rows = Record<Lang, Array<[number, string]>>;

/**
* Every footer string this file expects. `ru` 2 is few and 21 is one; `ar` 2
* is two and 11 is many — the categories a single count-not-one noun got wrong.
* is two and 11 is many — each the family slot i18next selects (objectui#11445).
*/
const FOOTER: Rows = {
en: [
Expand All @@ -193,13 +200,13 @@ const FOOTER: Rows = {
],
ru: [
[1, '1 запись'],
[2, 'Записей: 2'],
[21, 'Записей: 21'],
[2, '2 записи'],
[21, '21 запись'],
],
ar: [
[1, '1 سجل'],
[2, 'عدد السجلات: 2'],
[11, 'عدد السجلات: 11'],
[2, 'سجلان (2)'],
[11, '11 سجلًا'],
],
};

Expand Down Expand Up @@ -236,20 +243,22 @@ describe('ObjectView record-count footer reads right at one and at every other c
const packs = Object.keys(builtInLocales);
expect(packs.length).toBeGreaterThanOrEqual(10);
for (const lang of packs) {
expect(at(lang, 'console.objectView.recordCount'), `${lang} recordCount`).toBe(
at(lang, 'list.recordCount'),
);
expect(at(lang, 'console.objectView.recordCountOne'), `${lang} recordCountOne`).toBe(
at(lang, 'list.recordCountOne'),
);
for (const slot of ['', ...new Intl.PluralRules(lang).resolvedOptions().pluralCategories.map((c) => `_${c}`)]) {
expect(at(lang, `console.objectView.recordCount${slot}`), `${lang} recordCount${slot}`).toBe(
at(lang, `list.recordCount${slot}`),
);
}
}
});

it('in ru and ar the count-not-one half is count-invariant: it ends in the number, after a colon', () => {
it('in ru and ar the footer is an i18next count family, and its `…One` sibling is gone', () => {
for (const lang of ['ru', 'ar'] as const) {
const value = at(lang, 'console.objectView.recordCount');
expect(value, `${lang} console.objectView.recordCount`).toMatch(/: \{\{count\}\}$/);
expect(value, `${lang} equals its One half`).not.toBe(at(lang, 'console.objectView.recordCountOne'));
for (const category of new Intl.PluralRules(lang).resolvedOptions().pluralCategories) {
expect(at(lang, `console.objectView.recordCount_${category}`), `${lang} _${category}`).toEqual(
expect.any(String),
);
}
expect(at(lang, 'console.objectView.recordCountOne'), `${lang} recordCountOne`).toBeUndefined();
}
});
});
10 changes: 3 additions & 7 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3607,13 +3607,9 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co
</div>
{typeof recordCount === 'number' && (
<div data-testid="record-count-footer" className="border-t px-3 sm:px-4 py-1.5 text-xs text-muted-foreground bg-muted/5 shrink-0">
{/* The two-key switch of `ListView`'s record-count bar
(objectui#10636). Packs whose plurals have more forms
than two write the count-not-one half as a count label
(objectui#10425). */}
{recordCount === 1
? t('console.objectView.recordCountOne', { count: recordCount })
: t('console.objectView.recordCount', { count: recordCount })}
{/* One count family, like `ListView`'s record-count bar
(objectui#11445): i18next picks the CLDR slot. */}
{t('console.objectView.recordCount', { count: recordCount })}
</div>
)}
</div>
Expand Down
Loading
Loading