From f234858fbbecedfbce35e310733ab40fb3ac27e4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:41:08 +0000 Subject: [PATCH 1/2] feat: improve pluralization logic for data table filters - Added `pluralDisplayName` to `ColumnConfig` to allow manual pluralization. - Implemented `pluralize` helper in `lib/i18n` using `Intl.PluralRules`. - Enhanced automatic pluralization for English (y-endings, sibilants) and disabled pluralization for languages without plurals (e.g. CJK). - Updated `FilterValueOptionDisplay` and `FilterValueMultiOptionDisplay` to use the new logic. Co-authored-by: jaruesink <4207065+jaruesink@users.noreply.github.com> --- .../components/filter-value.tsx | 12 +++--- .../src/ui/data-table-filter/core/types.ts | 1 + .../src/ui/data-table-filter/lib/i18n.test.ts | 43 +++++++++++++++++++ .../src/ui/data-table-filter/lib/i18n.ts | 41 ++++++++++++++++-- 4 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 packages/components/src/ui/data-table-filter/lib/i18n.test.ts diff --git a/packages/components/src/ui/data-table-filter/components/filter-value.tsx b/packages/components/src/ui/data-table-filter/components/filter-value.tsx index 192468e4..6c4531cd 100644 --- a/packages/components/src/ui/data-table-filter/components/filter-value.tsx +++ b/packages/components/src/ui/data-table-filter/components/filter-value.tsx @@ -31,7 +31,7 @@ import type { import { useDebounceCallback } from '../hooks/use-debounce-callback'; import { take } from '../lib/array'; import { createNumberRange } from '../lib/helpers'; -import { type Locale, t } from '../lib/i18n'; +import { type Locale, pluralize, t } from '../lib/i18n'; interface FilterValueProps { filter: FilterModel; @@ -137,7 +137,7 @@ export function FilterValueOptionDisplay({ filter, column, actions, - locale: _locale = 'en', + locale = 'en', }: FilterValueDisplayProps) { const options = useMemo(() => column.getOptions(), [column]); const selected = options.filter((o) => filter?.values.includes(o.value)); @@ -160,8 +160,7 @@ export function FilterValueOptionDisplay({ ); } const name = column.displayName.toLowerCase(); - // TODO: Better pluralization for different languages - const pluralName = name.endsWith('s') ? `${name}es` : `${name}s`; + const pluralName = column.pluralDisplayName ?? pluralize(name, locale); const hasOptionIcons = !options?.some((o) => !o.icon); @@ -183,7 +182,7 @@ export function FilterValueMultiOptionDisplay({ filter, column, actions, - locale: _locale = 'en', + locale = 'en', }: FilterValueDisplayProps) { const options = useMemo(() => column.getOptions(), [column]); const selected = options.filter((o) => filter.values.includes(o.value)); @@ -201,6 +200,7 @@ export function FilterValueMultiOptionDisplay({ } const name = column.displayName.toLowerCase(); + const pluralName = column.pluralDisplayName ?? pluralize(name, locale); const hasOptionIcons = !options?.some((o) => !o.icon); @@ -215,7 +215,7 @@ export function FilterValueMultiOptionDisplay({ )} - {selected.length} {name} + {selected.length} {pluralName} ); diff --git a/packages/components/src/ui/data-table-filter/core/types.ts b/packages/components/src/ui/data-table-filter/core/types.ts index ff37994c..f494e47f 100644 --- a/packages/components/src/ui/data-table-filter/core/types.ts +++ b/packages/components/src/ui/data-table-filter/core/types.ts @@ -95,6 +95,7 @@ export type ColumnConfig; displayName: string; + pluralDisplayName?: string; icon: ReactElementType; type: TType; options?: TType extends OptionBasedColumnDataType ? ColumnOption[] : never; diff --git a/packages/components/src/ui/data-table-filter/lib/i18n.test.ts b/packages/components/src/ui/data-table-filter/lib/i18n.test.ts new file mode 100644 index 00000000..b8c54a46 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/lib/i18n.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { pluralize } from './i18n'; + +describe('pluralize', () => { + it('pluralizes English words correctly', () => { + assert.strictEqual(pluralize('box', 'en'), 'boxes'); + assert.strictEqual(pluralize('category', 'en'), 'categories'); + assert.strictEqual(pluralize('dog', 'en'), 'dogs'); + assert.strictEqual(pluralize('watch', 'en'), 'watches'); + assert.strictEqual(pluralize('bus', 'en'), 'buses'); + assert.strictEqual(pluralize('wish', 'en'), 'wishes'); + // Simple words + assert.strictEqual(pluralize('column', 'en'), 'columns'); + }); + + it('does not pluralize for languages with no plural form', () => { + assert.strictEqual(pluralize('数据', 'zh'), '数据'); // Chinese + assert.strictEqual(pluralize('データ', 'ja'), 'データ'); // Japanese + }); + + it('falls back to appending s for other languages', () => { + // Spanish has plurals. "dato" -> "datos". + assert.strictEqual(pluralize('dato', 'es'), 'datos'); + + // "papel" -> "papeles" in proper Spanish, but our fallback is naive + // So we expect "papels" because we only improved 'en'. + // If we passed 'en' it would handle 'papel' -> 'papels' anyway unless it hit sibilant? + // 'papel' ends in 'l', so 's'. + assert.strictEqual(pluralize('papel', 'es'), 'papels'); + }); + + it('handles defaults when locale is missing', () => { + // @ts-ignore + assert.strictEqual(pluralize('cat', undefined), 'cats'); + }); + + it('handles y endings correctly for English', () => { + assert.strictEqual(pluralize('boy', 'en'), 'boys'); // Vowel + y -> s + assert.strictEqual(pluralize('day', 'en'), 'days'); + assert.strictEqual(pluralize('city', 'en'), 'cities'); // Consonant + y -> ies + }); +}); diff --git a/packages/components/src/ui/data-table-filter/lib/i18n.ts b/packages/components/src/ui/data-table-filter/lib/i18n.ts index e89895ac..402ace56 100644 --- a/packages/components/src/ui/data-table-filter/lib/i18n.ts +++ b/packages/components/src/ui/data-table-filter/lib/i18n.ts @@ -1,13 +1,48 @@ import en from '../locales/en.json'; -export type Locale = 'en'; +export type Locale = 'en' | string; type Translations = Record; -const translations: Record = { +const translations: Record = { en, }; export function t(key: string, locale: Locale): string { - return translations[locale][key] ?? key; + return translations[locale]?.[key] ?? translations.en[key] ?? key; +} + +export function pluralize(text: string, locale: Locale): string { + // If no locale provided, default to english behavior + const loc = locale || 'en'; + + try { + const rules = new Intl.PluralRules(loc); + const options = rules.resolvedOptions(); + + // If the language has only one category (e.g. 'other'), it doesn't distinguish plural forms (like Japanese, Chinese) + if (options.pluralCategories.length === 1) { + return text; + } + } catch (e) { + // Fallback if Intl.PluralRules fails or locale is invalid + console.warn(`[i18n] Invalid locale for PluralRules: ${loc}`, e); + } + + // Improved English pluralization + if (loc.startsWith('en')) { + // Handle 'y' ending (e.g. category -> categories, but boy -> boys) + if (text.endsWith('y') && !/[aeiou]y/i.test(text)) { + return `${text.slice(0, -1)}ies`; + } + + // Handle sibilants (e.g. box -> boxes, bus -> buses, watch -> watches) + if (/[sxz]$/i.test(text) || /[cs]h$/i.test(text)) { + return `${text}es`; + } + } + + // Default: append 's' + // This matches the original behavior but is now safer for non-plural languages (checked above) + return `${text}s`; } From 6d099a1863c1fcc1bd4c5b7993ad2ae0760470cc Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:47:32 +0000 Subject: [PATCH 2/2] fix: exclude test files from build to prevent vite externalization errors - Exclude `src/**/*.test.ts` from `vite.config.ts` build inputs. - Add `@types/node` and include `node` in `tsconfig.json` types to support `node:test` and `node:assert` in tests. - Verified build and type-check pass. Co-authored-by: jaruesink <4207065+jaruesink@users.noreply.github.com> --- packages/components/package.json | 1 + packages/components/tsconfig.json | 2 +- packages/components/vite.config.ts | 3 +++ yarn.lock | 17 +++++++++++++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/components/package.json b/packages/components/package.json index 2e817fec..690e85bb 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -87,6 +87,7 @@ "@react-router/dev": "^7.0.0", "@react-router/node": "^7.0.0", "@types/glob": "^8.1.0", + "@types/node": "^25.2.1", "@types/react": "^19.0.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json index 2fc027c0..c76b6d76 100644 --- a/packages/components/tsconfig.json +++ b/packages/components/tsconfig.json @@ -15,7 +15,7 @@ "strict": true, "esModuleInterop": true, "moduleResolution": "bundler", - "types": ["@react-router/node", "vite/client"], + "types": ["@react-router/node", "vite/client", "node"], "rootDirs": [".", "./.react-router/types"] }, "include": ["src", ".react-router/types/**/*"], diff --git a/packages/components/vite.config.ts b/packages/components/vite.config.ts index d53bd131..de97d906 100644 --- a/packages/components/vite.config.ts +++ b/packages/components/vite.config.ts @@ -28,6 +28,9 @@ export default defineConfig({ .sync('src/**/*.{ts,tsx}', { ignore: [ 'src/**/*.d.ts', + 'src/**/*.test.ts', + 'src/**/*.test.tsx', + 'src/**/*.stories.tsx', 'src/**/core/types.ts', // Exclude type-only files to avoid empty chunks ], }) diff --git a/yarn.lock b/yarn.lock index 8a0053a1..89c260a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1737,6 +1737,7 @@ __metadata: "@react-router/node": "npm:^7.0.0" "@tanstack/react-table": "npm:^8.21.2" "@types/glob": "npm:^8.1.0" + "@types/node": "npm:^25.2.1" "@types/react": "npm:^19.0.0" "@typescript-eslint/eslint-plugin": "npm:^6.21.0" "@typescript-eslint/parser": "npm:^6.21.0" @@ -4738,6 +4739,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^25.2.1": + version: 25.2.1 + resolution: "@types/node@npm:25.2.1" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/ce42fa07495093c55b6398e3c4346d644a61b8c4f59d2e0c0ed152ea0e4327c60a41d5fdfa3e0fc4f4776eb925e2b783b6b942501fc044328a44980bc2de4dc6 + languageName: node + linkType: hard + "@types/react-dom@npm:^19": version: 19.1.9 resolution: "@types/react-dom@npm:19.1.9" @@ -11760,6 +11770,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a + languageName: node + linkType: hard + "unicorn-magic@npm:^0.1.0": version: 0.1.0 resolution: "unicorn-magic@npm:0.1.0"