From 6b92014af973ea09ebb224e951e9610ace7b259e Mon Sep 17 00:00:00 2001 From: Daniel Kimmich Date: Wed, 22 Feb 2023 16:42:05 +0100 Subject: [PATCH 1/2] feat: add country pipe --- .../intl-country-pipe-default-options.ts | 3 + .../src/lib/country/intl-country.pipe.spec.ts | 107 ++++++++++++++++++ .../src/lib/country/intl-country.pipe.ts | 36 ++++++ .../src/lib/intl.module.ts | 3 + .../src/lib/utils/number-utils.ts | 14 +-- .../angular-ecmascript-intl/src/public-api.ts | 2 + .../src/app/pipes/country/countries.ts | 11 ++ .../app/pipes/country/country.component.html | 18 +++ .../app/pipes/country/country.component.scss | 7 ++ .../app/pipes/country/country.component.ts | 15 +++ .../src/app/pipes/pipes-routing.module.ts | 5 + .../src/app/pipes/pipes.component.html | 2 + .../src/app/pipes/pipes.module.ts | 2 + 13 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 projects/angular-ecmascript-intl/src/lib/country/intl-country-pipe-default-options.ts create mode 100644 projects/angular-ecmascript-intl/src/lib/country/intl-country.pipe.spec.ts create mode 100644 projects/angular-ecmascript-intl/src/lib/country/intl-country.pipe.ts create mode 100644 projects/angular-intl-demo/src/app/pipes/country/countries.ts create mode 100644 projects/angular-intl-demo/src/app/pipes/country/country.component.html create mode 100644 projects/angular-intl-demo/src/app/pipes/country/country.component.scss create mode 100644 projects/angular-intl-demo/src/app/pipes/country/country.component.ts diff --git a/projects/angular-ecmascript-intl/src/lib/country/intl-country-pipe-default-options.ts b/projects/angular-ecmascript-intl/src/lib/country/intl-country-pipe-default-options.ts new file mode 100644 index 00000000..fece9940 --- /dev/null +++ b/projects/angular-ecmascript-intl/src/lib/country/intl-country-pipe-default-options.ts @@ -0,0 +1,3 @@ +import {InjectionToken} from "@angular/core"; + +export const INTL_COUNTRY_PIPE_DEFAULT_OPTIONS = new InjectionToken>('IntlCountryPipeDefaultOptions'); diff --git a/projects/angular-ecmascript-intl/src/lib/country/intl-country.pipe.spec.ts b/projects/angular-ecmascript-intl/src/lib/country/intl-country.pipe.spec.ts new file mode 100644 index 00000000..1190fda2 --- /dev/null +++ b/projects/angular-ecmascript-intl/src/lib/country/intl-country.pipe.spec.ts @@ -0,0 +1,107 @@ +import {IntlCountryPipe} from './intl-country.pipe'; +import {TestBed} from "@angular/core/testing"; +import {INTL_LOCALES} from "../locale"; +import {INTL_COUNTRY_PIPE_DEFAULT_OPTIONS} from "./intl-country-pipe-default-options"; + +describe('IntlCountryPipe', () => { + let testUnit: IntlCountryPipe; + + describe('parsing', () => { + beforeEach(() => { + testUnit = new IntlCountryPipe('en-US'); + }); + + it('should create an instance', () => { + expect(testUnit).toBeTruthy(); + }); + + it('should handle null values', () => { + expect(testUnit.transform(null)).toBeNull(); + }); + + it('should handle undefined values', () => { + expect(testUnit.transform(undefined)).toBeNull(); + }); + + it('should handle empty strings', () => { + expect(testUnit.transform('')).toBeNull(); + }); + + it('should transform numbers', () => { + expect(testUnit.transform('US')).toEqual('United States'); + }); + + it('should handle missing Intl.DisplayNames browser API', () => { + // @ts-expect-error Intl APIs are not expected to be undefined + spyOn(Intl, 'DisplayNames').and.returnValue(undefined); + const consoleError = spyOn(console, 'error'); + + expect(testUnit.transform('US')).toBeNull(); + expect(consoleError).toHaveBeenCalledTimes(1); + }); + }); + + describe('internationalization', () => { + it('should respect the set locale', () => { + TestBed.configureTestingModule({ + providers: [ + IntlCountryPipe, + { + provide: INTL_LOCALES, + useValue: 'de-DE', + }, + ], + }); + testUnit = TestBed.inject(IntlCountryPipe); + + expect(testUnit.transform('AT')).toEqual('Österreich'); + }); + + it('should fall back to the browser default locale', () => { + TestBed.configureTestingModule({providers: [IntlCountryPipe]}); + + const result1 = TestBed.inject(IntlCountryPipe).transform('US'); + const result2 = new IntlCountryPipe(navigator.language).transform('US'); + + expect(result1).toEqual(result2); + }); + }); + + describe('options', () => { + it('should not override the type option', () => { + TestBed.configureTestingModule({ + providers: [ + IntlCountryPipe, + { + provide: INTL_LOCALES, + useValue: 'de-DE', + }, + { + provide: INTL_COUNTRY_PIPE_DEFAULT_OPTIONS, + useValue: { + type: 'language', + }, + }, + ], + }); + testUnit = TestBed.inject(IntlCountryPipe); + + expect(testUnit.transform('DE', {type: 'language'})).toEqual('Deutschland'); + }); + }); + + it('should respect locale option', () => { + TestBed.configureTestingModule({ + providers: [ + IntlCountryPipe, + { + provide: INTL_LOCALES, + useValue: 'en-US', + }, + ], + }); + testUnit = TestBed.inject(IntlCountryPipe); + + expect(testUnit.transform('US', {locale: 'de-DE'})).toEqual('Vereinigte Staaten'); + }); +}); diff --git a/projects/angular-ecmascript-intl/src/lib/country/intl-country.pipe.ts b/projects/angular-ecmascript-intl/src/lib/country/intl-country.pipe.ts new file mode 100644 index 00000000..4a89566f --- /dev/null +++ b/projects/angular-ecmascript-intl/src/lib/country/intl-country.pipe.ts @@ -0,0 +1,36 @@ +import {Inject, Optional, Pipe, PipeTransform} from '@angular/core'; +import {INTL_LOCALES} from "../locale"; +import {INTL_COUNTRY_PIPE_DEFAULT_OPTIONS} from "./intl-country-pipe-default-options"; +import {IntlPipeOptions} from "../intl-pipe-options"; + +export type IntlCountryPipeOptions = Partial & IntlPipeOptions; + +@Pipe({ + name: 'intlCountry', + standalone: true, +}) +export class IntlCountryPipe implements PipeTransform { + + constructor(@Optional() @Inject(INTL_LOCALES) readonly locale?: string | string[] | null, + @Optional() @Inject(INTL_COUNTRY_PIPE_DEFAULT_OPTIONS) readonly defaultOptions?: Partial | null) { + } + + transform(value: string | null | undefined, options?: IntlCountryPipeOptions): string | null { + if (!value) { + return null; + } + + const {locale, ...intlOptions} = options ?? {}; + + try { + return new Intl.DisplayNames(locale ?? this.locale ?? undefined, { + ...this.defaultOptions, ...intlOptions, + type: 'region', + }).of(value) ?? null; + } catch (e) { + console.error('Error while transforming the country', e); + return null; + } + } + +} diff --git a/projects/angular-ecmascript-intl/src/lib/intl.module.ts b/projects/angular-ecmascript-intl/src/lib/intl.module.ts index ca75edb0..4541fd47 100644 --- a/projects/angular-ecmascript-intl/src/lib/intl.module.ts +++ b/projects/angular-ecmascript-intl/src/lib/intl.module.ts @@ -4,6 +4,7 @@ import {IntlLanguagePipe} from './language/intl-language.pipe'; import {IntlDecimalPipe} from "./decimal/intl-decimal.pipe"; import {IntlPercentPipe} from "./percent/intl-percent.pipe"; import {IntlCurrencyPipe} from "./currency/intl-currency.pipe"; +import {IntlCountryPipe} from "./country/intl-country.pipe"; @NgModule({ imports: [ @@ -12,6 +13,7 @@ import {IntlCurrencyPipe} from "./currency/intl-currency.pipe"; IntlDecimalPipe, IntlPercentPipe, IntlCurrencyPipe, + IntlCountryPipe, ], exports: [ IntlDatePipe, @@ -19,6 +21,7 @@ import {IntlCurrencyPipe} from "./currency/intl-currency.pipe"; IntlDecimalPipe, IntlPercentPipe, IntlCurrencyPipe, + IntlCountryPipe, ], }) export class IntlModule { diff --git a/projects/angular-ecmascript-intl/src/lib/utils/number-utils.ts b/projects/angular-ecmascript-intl/src/lib/utils/number-utils.ts index b392a351..5a35223b 100644 --- a/projects/angular-ecmascript-intl/src/lib/utils/number-utils.ts +++ b/projects/angular-ecmascript-intl/src/lib/utils/number-utils.ts @@ -1,11 +1,11 @@ -export const getNumericValue = (value: string | number) => { - if (typeof value === 'string') { - if (isNaN(Number(value) - parseFloat(value))) { - throw new Error(`${value} is not a number!`); - } +export const getNumericValue = (value: string | number): number => { + if (typeof value === 'number') { + return value; + } - return Number(value); + if (isNaN(Number(value) - parseFloat(value))) { + throw new Error(`${value} is not a number!`); } - return value; + return Number(value); } diff --git a/projects/angular-ecmascript-intl/src/public-api.ts b/projects/angular-ecmascript-intl/src/public-api.ts index b1cfd02b..bd3c3dad 100644 --- a/projects/angular-ecmascript-intl/src/public-api.ts +++ b/projects/angular-ecmascript-intl/src/public-api.ts @@ -2,6 +2,8 @@ * Public API Surface of angular-ecmascript-intl */ +export * from './lib/country/intl-country.pipe'; +export * from './lib/country/intl-country-pipe-default-options'; export * from './lib/currency/intl-currency.pipe'; export * from './lib/currency/intl-currency-pipe-default-options'; export * from './lib/date/intl-date.pipe'; diff --git a/projects/angular-intl-demo/src/app/pipes/country/countries.ts b/projects/angular-intl-demo/src/app/pipes/country/countries.ts new file mode 100644 index 00000000..3fda4c3b --- /dev/null +++ b/projects/angular-intl-demo/src/app/pipes/country/countries.ts @@ -0,0 +1,11 @@ +export const countries = [ + 'AT', + 'CA', + 'CH', + 'DE', + 'GB', + 'KR', + 'SE', + 'UA', + 'US', +]; diff --git a/projects/angular-intl-demo/src/app/pipes/country/country.component.html b/projects/angular-intl-demo/src/app/pipes/country/country.component.html new file mode 100644 index 00000000..88790e93 --- /dev/null +++ b/projects/angular-intl-demo/src/app/pipes/country/country.component.html @@ -0,0 +1,18 @@ +
+ + Country to transform + + {{country}} + + + + + Locale + + Browser default + {{language}} + + +
+ +

{{selectedCountry | intlCountry: {locale} }}

diff --git a/projects/angular-intl-demo/src/app/pipes/country/country.component.scss b/projects/angular-intl-demo/src/app/pipes/country/country.component.scss new file mode 100644 index 00000000..d1ecbe60 --- /dev/null +++ b/projects/angular-intl-demo/src/app/pipes/country/country.component.scss @@ -0,0 +1,7 @@ +.fields-container { + display: flex; + gap: 16px; + flex-wrap: wrap; + align-items: center; + margin-bottom: 16px; +} diff --git a/projects/angular-intl-demo/src/app/pipes/country/country.component.ts b/projects/angular-intl-demo/src/app/pipes/country/country.component.ts new file mode 100644 index 00000000..8ff3f846 --- /dev/null +++ b/projects/angular-intl-demo/src/app/pipes/country/country.component.ts @@ -0,0 +1,15 @@ +import {Component} from '@angular/core'; +import {languages} from "../../languages"; +import {countries} from "./countries"; + +@Component({ + selector: 'app-country', + templateUrl: './country.component.html', + styleUrls: ['./country.component.scss'], +}) +export class CountryComponent { + languages = languages; + countries = countries; + selectedCountry = 'DE'; + locale?: string; +} diff --git a/projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts b/projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts index 9cc71680..7ed7e3e4 100644 --- a/projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts +++ b/projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts @@ -6,6 +6,7 @@ import {DecimalComponent} from "./decimal/decimal.component"; import {PercentComponent} from "./percent/percent.component"; import {CurrencyComponent} from "./currency/currency.component"; import {LanguageComponent} from "./language/language.component"; +import {CountryComponent} from "./country/country.component"; const routes: Routes = [ { @@ -32,6 +33,10 @@ const routes: Routes = [ path: 'language', component: LanguageComponent, }, + { + path: 'country', + component: CountryComponent, + }, { path: '', redirectTo: 'date', diff --git a/projects/angular-intl-demo/src/app/pipes/pipes.component.html b/projects/angular-intl-demo/src/app/pipes/pipes.component.html index 3f9e0a3c..8225086a 100644 --- a/projects/angular-intl-demo/src/app/pipes/pipes.component.html +++ b/projects/angular-intl-demo/src/app/pipes/pipes.component.html @@ -9,6 +9,8 @@ routerLinkActive>Currency Language + Country diff --git a/projects/angular-intl-demo/src/app/pipes/pipes.module.ts b/projects/angular-intl-demo/src/app/pipes/pipes.module.ts index 21c0d967..94aeb2d3 100644 --- a/projects/angular-intl-demo/src/app/pipes/pipes.module.ts +++ b/projects/angular-intl-demo/src/app/pipes/pipes.module.ts @@ -12,6 +12,7 @@ import {MatSelectModule} from "@angular/material/select"; import {FormsModule} from "@angular/forms"; import {MatInputModule} from "@angular/material/input"; import {PipesRoutingModule} from "./pipes-routing.module"; +import {CountryComponent} from "./country/country.component"; @NgModule({ declarations: [ @@ -21,6 +22,7 @@ import {PipesRoutingModule} from "./pipes-routing.module"; PercentComponent, CurrencyComponent, PipesComponent, + CountryComponent, ], imports: [ CommonModule, From 17287a596f417f3051ebd061d711275aa9b740e9 Mon Sep 17 00:00:00 2001 From: Daniel Kimmich Date: Wed, 22 Feb 2023 16:45:03 +0100 Subject: [PATCH 2/2] docs: update documentation --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8c279445..0d77e149 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,25 @@ their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/G With the `INTL_LANGUAGE_PIPE_DEFAULT_OPTIONS` injection token you can specify default options. +### Country pipe + +Use the country pipe like the following: + +``` +{{'US' | intlCountry: options}} +``` + +The input can be one of the following: + +* string (must be two-letter ISO 639-1 language code or a three-letter ISO 639-2 language code) +* null +* undefined + +The options are the same as the options for `new Intl.DisplayNames()`. For a list of the options, see +their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DisplayNames/DisplayNames). + +With the `INTL_COUNTRY_PIPE_DEFAULT_OPTIONS` injection token you can specify default options. + ## Background For more context, see the following [GitHub issue](https://github.com/angular/angular/issues/49143) @@ -158,7 +177,6 @@ For more context, see the following [GitHub issue](https://github.com/angular/an ## Feature Roadmap * Performance: Prepare Intl.* object with default options, only construct new object when necessary -* Country pipe * Relative time * Migration Schematics for usages of Angular pipes