Skip to content

Commit 2d880f1

Browse files
authored
feat: add country pipe (#7)
1 parent 972ec55 commit 2d880f1

14 files changed

+237
-8
lines changed

Diff for: README.md

+19-1
Original file line numberDiff line numberDiff line change
@@ -151,14 +151,32 @@ their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/G
151151

152152
With the `INTL_LANGUAGE_PIPE_DEFAULT_OPTIONS` injection token you can specify default options.
153153

154+
### Country pipe
155+
156+
Use the country pipe like the following:
157+
158+
```
159+
{{'US' | intlCountry: options}}
160+
```
161+
162+
The input can be one of the following:
163+
164+
* string (must be two-letter ISO 639-1 language code or a three-letter ISO 639-2 language code)
165+
* null
166+
* undefined
167+
168+
The options are the same as the options for `new Intl.DisplayNames()`. For a list of the options, see
169+
their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DisplayNames/DisplayNames).
170+
171+
With the `INTL_COUNTRY_PIPE_DEFAULT_OPTIONS` injection token you can specify default options.
172+
154173
## Background
155174

156175
For more context, see the following [GitHub issue](https://github.com/angular/angular/issues/49143)
157176

158177
## Feature Roadmap
159178

160179
* Performance: Prepare Intl.* object with default options, only construct new object when necessary
161-
* Country pipe
162180
* Relative time
163181
* Migration Schematics for usages of Angular pipes
164182

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import {InjectionToken} from "@angular/core";
2+
3+
export const INTL_COUNTRY_PIPE_DEFAULT_OPTIONS = new InjectionToken<Partial<Intl.DisplayNamesOptions>>('IntlCountryPipeDefaultOptions');
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import {IntlCountryPipe} from './intl-country.pipe';
2+
import {TestBed} from "@angular/core/testing";
3+
import {INTL_LOCALES} from "../locale";
4+
import {INTL_COUNTRY_PIPE_DEFAULT_OPTIONS} from "./intl-country-pipe-default-options";
5+
6+
describe('IntlCountryPipe', () => {
7+
let testUnit: IntlCountryPipe;
8+
9+
describe('parsing', () => {
10+
beforeEach(() => {
11+
testUnit = new IntlCountryPipe('en-US');
12+
});
13+
14+
it('should create an instance', () => {
15+
expect(testUnit).toBeTruthy();
16+
});
17+
18+
it('should handle null values', () => {
19+
expect(testUnit.transform(null)).toBeNull();
20+
});
21+
22+
it('should handle undefined values', () => {
23+
expect(testUnit.transform(undefined)).toBeNull();
24+
});
25+
26+
it('should handle empty strings', () => {
27+
expect(testUnit.transform('')).toBeNull();
28+
});
29+
30+
it('should transform numbers', () => {
31+
expect(testUnit.transform('US')).toEqual('United States');
32+
});
33+
34+
it('should handle missing Intl.DisplayNames browser API', () => {
35+
// @ts-expect-error Intl APIs are not expected to be undefined
36+
spyOn(Intl, 'DisplayNames').and.returnValue(undefined);
37+
const consoleError = spyOn(console, 'error');
38+
39+
expect(testUnit.transform('US')).toBeNull();
40+
expect(consoleError).toHaveBeenCalledTimes(1);
41+
});
42+
});
43+
44+
describe('internationalization', () => {
45+
it('should respect the set locale', () => {
46+
TestBed.configureTestingModule({
47+
providers: [
48+
IntlCountryPipe,
49+
{
50+
provide: INTL_LOCALES,
51+
useValue: 'de-DE',
52+
},
53+
],
54+
});
55+
testUnit = TestBed.inject(IntlCountryPipe);
56+
57+
expect(testUnit.transform('AT')).toEqual('Österreich');
58+
});
59+
60+
it('should fall back to the browser default locale', () => {
61+
TestBed.configureTestingModule({providers: [IntlCountryPipe]});
62+
63+
const result1 = TestBed.inject(IntlCountryPipe).transform('US');
64+
const result2 = new IntlCountryPipe(navigator.language).transform('US');
65+
66+
expect(result1).toEqual(result2);
67+
});
68+
});
69+
70+
describe('options', () => {
71+
it('should not override the type option', () => {
72+
TestBed.configureTestingModule({
73+
providers: [
74+
IntlCountryPipe,
75+
{
76+
provide: INTL_LOCALES,
77+
useValue: 'de-DE',
78+
},
79+
{
80+
provide: INTL_COUNTRY_PIPE_DEFAULT_OPTIONS,
81+
useValue: {
82+
type: 'language',
83+
},
84+
},
85+
],
86+
});
87+
testUnit = TestBed.inject(IntlCountryPipe);
88+
89+
expect(testUnit.transform('DE', {type: 'language'})).toEqual('Deutschland');
90+
});
91+
});
92+
93+
it('should respect locale option', () => {
94+
TestBed.configureTestingModule({
95+
providers: [
96+
IntlCountryPipe,
97+
{
98+
provide: INTL_LOCALES,
99+
useValue: 'en-US',
100+
},
101+
],
102+
});
103+
testUnit = TestBed.inject(IntlCountryPipe);
104+
105+
expect(testUnit.transform('US', {locale: 'de-DE'})).toEqual('Vereinigte Staaten');
106+
});
107+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {Inject, Optional, Pipe, PipeTransform} from '@angular/core';
2+
import {INTL_LOCALES} from "../locale";
3+
import {INTL_COUNTRY_PIPE_DEFAULT_OPTIONS} from "./intl-country-pipe-default-options";
4+
import {IntlPipeOptions} from "../intl-pipe-options";
5+
6+
export type IntlCountryPipeOptions = Partial<Intl.DisplayNamesOptions> & IntlPipeOptions;
7+
8+
@Pipe({
9+
name: 'intlCountry',
10+
standalone: true,
11+
})
12+
export class IntlCountryPipe implements PipeTransform {
13+
14+
constructor(@Optional() @Inject(INTL_LOCALES) readonly locale?: string | string[] | null,
15+
@Optional() @Inject(INTL_COUNTRY_PIPE_DEFAULT_OPTIONS) readonly defaultOptions?: Partial<Intl.DisplayNamesOptions> | null) {
16+
}
17+
18+
transform(value: string | null | undefined, options?: IntlCountryPipeOptions): string | null {
19+
if (!value) {
20+
return null;
21+
}
22+
23+
const {locale, ...intlOptions} = options ?? {};
24+
25+
try {
26+
return new Intl.DisplayNames(locale ?? this.locale ?? undefined, {
27+
...this.defaultOptions, ...intlOptions,
28+
type: 'region',
29+
}).of(value) ?? null;
30+
} catch (e) {
31+
console.error('Error while transforming the country', e);
32+
return null;
33+
}
34+
}
35+
36+
}

Diff for: projects/angular-ecmascript-intl/src/lib/intl.module.ts

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {IntlLanguagePipe} from './language/intl-language.pipe';
44
import {IntlDecimalPipe} from "./decimal/intl-decimal.pipe";
55
import {IntlPercentPipe} from "./percent/intl-percent.pipe";
66
import {IntlCurrencyPipe} from "./currency/intl-currency.pipe";
7+
import {IntlCountryPipe} from "./country/intl-country.pipe";
78

89
@NgModule({
910
imports: [
@@ -12,13 +13,15 @@ import {IntlCurrencyPipe} from "./currency/intl-currency.pipe";
1213
IntlDecimalPipe,
1314
IntlPercentPipe,
1415
IntlCurrencyPipe,
16+
IntlCountryPipe,
1517
],
1618
exports: [
1719
IntlDatePipe,
1820
IntlLanguagePipe,
1921
IntlDecimalPipe,
2022
IntlPercentPipe,
2123
IntlCurrencyPipe,
24+
IntlCountryPipe,
2225
],
2326
})
2427
export class IntlModule {
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
export const getNumericValue = (value: string | number) => {
2-
if (typeof value === 'string') {
3-
if (isNaN(Number(value) - parseFloat(value))) {
4-
throw new Error(`${value} is not a number!`);
5-
}
1+
export const getNumericValue = (value: string | number): number => {
2+
if (typeof value === 'number') {
3+
return value;
4+
}
65

7-
return Number(value);
6+
if (isNaN(Number(value) - parseFloat(value))) {
7+
throw new Error(`${value} is not a number!`);
88
}
99

10-
return value;
10+
return Number(value);
1111
}

Diff for: projects/angular-ecmascript-intl/src/public-api.ts

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* Public API Surface of angular-ecmascript-intl
33
*/
44

5+
export * from './lib/country/intl-country.pipe';
6+
export * from './lib/country/intl-country-pipe-default-options';
57
export * from './lib/currency/intl-currency.pipe';
68
export * from './lib/currency/intl-currency-pipe-default-options';
79
export * from './lib/date/intl-date.pipe';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const countries = [
2+
'AT',
3+
'CA',
4+
'CH',
5+
'DE',
6+
'GB',
7+
'KR',
8+
'SE',
9+
'UA',
10+
'US',
11+
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<div class="fields-container">
2+
<mat-form-field>
3+
<mat-label>Country to transform</mat-label>
4+
<mat-select [(ngModel)]="selectedCountry">
5+
<mat-option *ngFor="let country of countries" [value]="country">{{country}}</mat-option>
6+
</mat-select>
7+
</mat-form-field>
8+
9+
<mat-form-field>
10+
<mat-label>Locale</mat-label>
11+
<mat-select [(ngModel)]="locale">
12+
<mat-option [value]="undefined">Browser default</mat-option>
13+
<mat-option *ngFor="let language of languages" [value]="language">{{language}}</mat-option>
14+
</mat-select>
15+
</mat-form-field>
16+
</div>
17+
18+
<p>{{selectedCountry | intlCountry: {locale} }}</p>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.fields-container {
2+
display: flex;
3+
gap: 16px;
4+
flex-wrap: wrap;
5+
align-items: center;
6+
margin-bottom: 16px;
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {Component} from '@angular/core';
2+
import {languages} from "../../languages";
3+
import {countries} from "./countries";
4+
5+
@Component({
6+
selector: 'app-country',
7+
templateUrl: './country.component.html',
8+
styleUrls: ['./country.component.scss'],
9+
})
10+
export class CountryComponent {
11+
languages = languages;
12+
countries = countries;
13+
selectedCountry = 'DE';
14+
locale?: string;
15+
}

Diff for: projects/angular-intl-demo/src/app/pipes/pipes-routing.module.ts

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {DecimalComponent} from "./decimal/decimal.component";
66
import {PercentComponent} from "./percent/percent.component";
77
import {CurrencyComponent} from "./currency/currency.component";
88
import {LanguageComponent} from "./language/language.component";
9+
import {CountryComponent} from "./country/country.component";
910

1011
const routes: Routes = [
1112
{
@@ -32,6 +33,10 @@ const routes: Routes = [
3233
path: 'language',
3334
component: LanguageComponent,
3435
},
36+
{
37+
path: 'country',
38+
component: CountryComponent,
39+
},
3540
{
3641
path: '',
3742
redirectTo: 'date',

Diff for: projects/angular-intl-demo/src/app/pipes/pipes.component.html

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
routerLinkActive>Currency</a>
1010
<a #languageActive="routerLinkActive" [active]="languageActive.isActive" mat-tab-link routerLink="language"
1111
routerLinkActive>Language</a>
12+
<a #countryActive="routerLinkActive" [active]="countryActive.isActive" mat-tab-link routerLink="country"
13+
routerLinkActive>Country</a>
1214
</nav>
1315
<mat-tab-nav-panel #tabPanel></mat-tab-nav-panel>
1416

Diff for: projects/angular-intl-demo/src/app/pipes/pipes.module.ts

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {MatSelectModule} from "@angular/material/select";
1212
import {FormsModule} from "@angular/forms";
1313
import {MatInputModule} from "@angular/material/input";
1414
import {PipesRoutingModule} from "./pipes-routing.module";
15+
import {CountryComponent} from "./country/country.component";
1516

1617
@NgModule({
1718
declarations: [
@@ -21,6 +22,7 @@ import {PipesRoutingModule} from "./pipes-routing.module";
2122
PercentComponent,
2223
CurrencyComponent,
2324
PipesComponent,
25+
CountryComponent,
2426
],
2527
imports: [
2628
CommonModule,

0 commit comments

Comments
 (0)