Files
leadchat/app/javascript/shared/composables/specs/useNumberFormatter.spec.js
Sivin Varghese 26778156dc chore: Improve pagination with compact number formatting and pluralization (#12921)
# 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**
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/9fcf8baa-ae32-4a8a-85b0-24002fd863db"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/3d7138b7-133e-4ae6-b55f-67eff73ff1cc"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/1bbf7070-0681-492d-9308-a33874052d28"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/4e441672-26aa-4e66-965e-9edb807eaa72"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/11836702-1b74-4834-8932-31c20adc2db8"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/d37971bc-09af-4238-8601-ccc2ae69dbe7"
/>


**After**
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/8eaf2a23-beea-486b-b555-37f8b36ab904"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/f44f508a-e39d-45cb-afd8-98deb26920f8"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/d3b90711-bd7e-44ee-8bb3-48e45b799420"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/30dca6cd-f2be-4dcb-8596-924326ebf8c0"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/58896699-1f05-46c9-88cb-908318e71476"
/>
<img width="991" height="69" alt="image"
src="https://github.com/user-attachments/assets/ea0d91b0-077b-4d72-81a7-d38d17742da6"
/>




## 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
2025-11-24 17:49:24 -08:00

241 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useNumberFormatter } from '../useNumberFormatter';
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
vi.mock('vue-i18n');
describe('useNumberFormatter', () => {
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/);
});
});
});