From 26778156dcabc8c554351a936514b9ec4de20ed8 Mon Sep 17 00:00:00 2001
From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Date: Tue, 25 Nov 2025 07:19:24 +0530
Subject: [PATCH] chore: Improve pagination with compact number formatting and
pluralization (#12921)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
# Pull Request Template
## Description
This PR enhances the pagination component with standardized number
formatting and improved i18n handling.
**Includes:**
* Added `formatCompactNumber` and `formatFullNumber` helpers using
`Intl.NumberFormat`.
* `< 1,000`: show exact value (e.g., `999`)
* `1,000–999,999`: show compact format (`1k`, `1k+`)
* `1,000,000+`: show in millions with one decimal (e.g., `1.2M`)
* Updated `PaginationFooter` to use the new formatters for all displayed
numbers.
* Added proper pluralization to pagination i18n strings.
Fixes
https://linear.app/chatwoot/issue/CW-5999/better-display-of-numbers
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Screenshoots
**Before**
**After**
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
.../pagination/PaginationFooter.vue | 32 ++-
.../dashboard/i18n/locale/en/companies.json | 2 +-
.../dashboard/i18n/locale/en/components.json | 4 +-
.../dashboard/i18n/locale/en/contact.json | 2 +-
.../specs/useNumberFormatter.spec.js | 240 ++++++++++++++++++
.../shared/composables/useNumberFormatter.js | 67 +++++
6 files changed, 332 insertions(+), 15 deletions(-)
create mode 100644 app/javascript/shared/composables/specs/useNumberFormatter.spec.js
create mode 100644 app/javascript/shared/composables/useNumberFormatter.js
diff --git a/app/javascript/dashboard/components-next/pagination/PaginationFooter.vue b/app/javascript/dashboard/components-next/pagination/PaginationFooter.vue
index e58b2db86..0a919dbbf 100644
--- a/app/javascript/dashboard/components-next/pagination/PaginationFooter.vue
+++ b/app/javascript/dashboard/components-next/pagination/PaginationFooter.vue
@@ -1,6 +1,7 @@
@@ -91,9 +99,11 @@ const pageInfo = computed(() => {
/>
- {{ currentPage }}
+ {{ formatFullNumber(currentPage) }}
+
+
+ {{ pageInfo }}
- {{ pageInfo }}
{
+ beforeEach(() => {
+ vi.mocked(useI18n).mockReturnValue({
+ locale: ref('en-US'),
+ });
+ });
+
+ describe('formatCompactNumber', () => {
+ it('should return exact numbers for values under 1,000', () => {
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(0)).toBe('0');
+ expect(formatCompactNumber(1)).toBe('1');
+ expect(formatCompactNumber(42)).toBe('42');
+ expect(formatCompactNumber(999)).toBe('999');
+ });
+
+ it('should return "Xk" for exact thousands and "Xk+" for values with remainder', () => {
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(1000)).toBe('1k');
+ expect(formatCompactNumber(1020)).toBe('1k+');
+ expect(formatCompactNumber(1500)).toBe('1k+');
+ expect(formatCompactNumber(1999)).toBe('1k+');
+ expect(formatCompactNumber(2000)).toBe('2k');
+ expect(formatCompactNumber(15000)).toBe('15k');
+ expect(formatCompactNumber(15500)).toBe('15k+');
+ expect(formatCompactNumber(999999)).toBe('999k+');
+ });
+
+ it('should return millions/billion/trillion format for values 1,000,000 and above', () => {
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(1000000)).toBe('1M');
+ expect(formatCompactNumber(1000001)).toBe('1.0M');
+ expect(formatCompactNumber(1200000)).toBe('1.2M');
+ expect(formatCompactNumber(1234000)).toBe('1.2M');
+ expect(formatCompactNumber(2500000)).toBe('2.5M');
+ expect(formatCompactNumber(10000000)).toBe('10M');
+ expect(formatCompactNumber(1000000000)).toBe('1B');
+ expect(formatCompactNumber(1100000000)).toBe('1.1B');
+ expect(formatCompactNumber(10000000000)).toBe('10B');
+ expect(formatCompactNumber(11000000000)).toBe('11B');
+ expect(formatCompactNumber(1000000000000)).toBe('1T');
+ expect(formatCompactNumber(1100000000000)).toBe('1.1T');
+ expect(formatCompactNumber(10000000000000)).toBe('10T');
+ expect(formatCompactNumber(11000000000000)).toBe('11T');
+ });
+
+ it('should handle edge cases gracefully', () => {
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(null)).toBe('0');
+ expect(formatCompactNumber(undefined)).toBe('0');
+ expect(formatCompactNumber(NaN)).toBe('0');
+ expect(formatCompactNumber('string')).toBe('0');
+ });
+
+ it('should handle negative numbers', () => {
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(-500)).toBe('-500');
+ expect(formatCompactNumber(-1000)).toBe('-1k');
+ expect(formatCompactNumber(-1500)).toBe('-1k+');
+ expect(formatCompactNumber(-2000)).toBe('-2k');
+ expect(formatCompactNumber(-1200000)).toBe('-1.2M');
+ });
+
+ it('should format with en-US locale', () => {
+ const mockLocale = ref('en-US');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(1500)).toBe('1k+');
+ });
+
+ it('should format with de-DE locale', () => {
+ const mockLocale = ref('de-DE');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(2000)).toBe('2k');
+ });
+
+ it('should format with fr-FR locale (compact notation)', () => {
+ const mockLocale = ref('fr-FR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ const result = formatCompactNumber(1000000);
+ expect(result).toMatch(/1\s*M/); // French uses space before M
+ });
+
+ it('should format with ja-JP locale', () => {
+ const mockLocale = ref('ja-JP');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(999)).toBe('999');
+ });
+
+ it('should format with ar-SA locale (Arabic numerals)', () => {
+ const mockLocale = ref('ar-SA');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ const result = formatCompactNumber(5000);
+ expect(result).toContain('k');
+ expect(typeof result).toBe('string');
+ });
+
+ it('should format with es-ES locale', () => {
+ const mockLocale = ref('es-ES');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(7500)).toBe('7k+');
+ });
+
+ it('should format with hi-IN locale', () => {
+ const mockLocale = ref('hi-IN');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(100000)).toBe('100k');
+ });
+
+ it('should format with ru-RU locale', () => {
+ const mockLocale = ref('ru-RU');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(3000)).toBe('3k');
+ });
+
+ it('should format with ko-KR locale (uses 만 for 10,000)', () => {
+ const mockLocale = ref('ko-KR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ const result = formatCompactNumber(2500000);
+ expect(result).toContain('만'); // Korean uses 만 (10,000) as a unit, so 2,500,000 should contain 만
+ });
+
+ it('should format with pt-BR locale', () => {
+ const mockLocale = ref('pt-BR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatCompactNumber } = useNumberFormatter();
+ expect(formatCompactNumber(8888)).toBe('8k+');
+ });
+ });
+
+ describe('formatFullNumber', () => {
+ it('should format numbers with locale-specific formatting', () => {
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(1000)).toBe('1,000');
+ expect(formatFullNumber(1234567)).toBe('1,234,567');
+ expect(formatFullNumber(1234567890)).toBe('1,234,567,890');
+ expect(formatFullNumber(1234567890123)).toBe('1,234,567,890,123');
+ });
+
+ it('should handle edge cases gracefully', () => {
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(null)).toBe('0');
+ expect(formatFullNumber(undefined)).toBe('0');
+ expect(formatFullNumber(NaN)).toBe('0');
+ expect(formatFullNumber('string')).toBe('0');
+ });
+
+ it('should format with en-US locale (comma separator)', () => {
+ const mockLocale = ref('en-US');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(1234567)).toBe('1,234,567');
+ });
+
+ it('should format with de-DE locale (period separator)', () => {
+ const mockLocale = ref('de-DE');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(9876543)).toBe('9.876.543');
+ });
+
+ it('should format with es-ES locale (period separator)', () => {
+ const mockLocale = ref('es-ES');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(5555555)).toBe('5.555.555');
+ });
+
+ it('should format with zh-CN locale', () => {
+ const mockLocale = ref('zh-CN');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(1000000)).toBe('1,000,000');
+ });
+
+ it('should format with ar-EG locale (Arabic numerals, RTL)', () => {
+ const mockLocale = ref('ar-EG');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ const result = formatFullNumber(7654321);
+ // Arabic locale uses Eastern Arabic numerals (٠-٩)
+ // Just verify it's formatted (length should be reasonable)
+ expect(result.length).toBeGreaterThan(6);
+ });
+
+ it('should format with fr-FR locale (narrow no-break space)', () => {
+ const mockLocale = ref('fr-FR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ const result = formatFullNumber(3333333);
+ expect(result).toContain('3');
+ expect(result).toContain('333');
+ });
+
+ it('should format with hi-IN locale (Indian numbering system)', () => {
+ const mockLocale = ref('hi-IN');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(9999999)).toBe('99,99,999');
+ });
+
+ it('should format with th-TH locale', () => {
+ const mockLocale = ref('th-TH');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(4444444)).toBe('4,444,444');
+ });
+
+ it('should format with tr-TR locale', () => {
+ const mockLocale = ref('tr-TR');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ expect(formatFullNumber(6666666)).toBe('6.666.666');
+ });
+
+ it('should format with pt-PT locale (space separator)', () => {
+ const mockLocale = ref('pt-PT');
+ vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
+ const { formatFullNumber } = useNumberFormatter();
+ const result = formatFullNumber(2222222);
+ // Portuguese (Portugal) uses narrow no-break space as separator
+ expect(result).toMatch(/2[\s\u202f]222[\s\u202f]222/);
+ });
+ });
+});
diff --git a/app/javascript/shared/composables/useNumberFormatter.js b/app/javascript/shared/composables/useNumberFormatter.js
new file mode 100644
index 000000000..0ca7e3ac2
--- /dev/null
+++ b/app/javascript/shared/composables/useNumberFormatter.js
@@ -0,0 +1,67 @@
+import { useI18n } from 'vue-i18n';
+
+/**
+ * Composable for number formatting with i18n locale support
+ * Provides methods to format numbers in compact and full display formats
+ */
+export function useNumberFormatter() {
+ const { locale } = useI18n();
+
+ /**
+ * Formats numbers for display with clean, minimal formatting
+ * - Up to 1,000: show exact number (e.g., 999)
+ * - 1,000 to 999,999: show as "Xk" for exact thousands or "Xk+" for remainder (e.g., 1000 → "1k", 1500 → "1k+")
+ * - 1,000,000+: show in millions with 1 decimal place (e.g., 1,234,000 → "1.2M")
+ *
+ * Uses browser-native Intl.NumberFormat with proper i18n locale support
+ *
+ * @param {number} num - The number to format
+ * @returns {string} Formatted number string
+ */
+ const formatCompactNumber = num => {
+ if (typeof num !== 'number' || Number.isNaN(num)) {
+ return '0';
+ }
+
+ // For numbers between -1000 and 1000 (exclusive), show exact number with locale formatting
+ if (Math.abs(num) < 1000) {
+ return new Intl.NumberFormat(locale.value).format(num);
+ }
+
+ // For numbers with absolute value above 1,000,000, show in millions with 1 decimal place
+ if (Math.abs(num) >= 1000000) {
+ const millions = num / 1000000;
+ return new Intl.NumberFormat(locale.value, {
+ notation: 'compact',
+ compactDisplay: 'short',
+ maximumFractionDigits: 1,
+ minimumFractionDigits: millions % 1 === 0 ? 0 : 1,
+ }).format(num);
+ }
+
+ // For numbers with absolute value between 1,000 and 1,000,000, show as "Xk" or "Xk+" using floor value
+ // For negative numbers, we want to floor towards zero (truncate), not towards negative infinity
+ const thousands = num >= 0 ? Math.floor(num / 1000) : Math.ceil(num / 1000);
+ const remainder = Math.abs(num) % 1000;
+ const suffix = remainder === 0 ? 'k' : 'k+';
+ return `${new Intl.NumberFormat(locale.value).format(thousands)}${suffix}`;
+ };
+
+ /**
+ * Format a number for full display with locale-specific formatting
+ * @param {number} num - The number to format
+ * @returns {string} Formatted number string with full precision and locale formatting (e.g., 1,234,567)
+ */
+ const formatFullNumber = num => {
+ if (typeof num !== 'number' || Number.isNaN(num)) {
+ return '0';
+ }
+
+ return new Intl.NumberFormat(locale.value).format(num);
+ };
+
+ return {
+ formatCompactNumber,
+ formatFullNumber,
+ };
+}