Skip to content

feat: add unit pipe #11

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,17 +170,38 @@ their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/G

With the `INTL_COUNTRY_PIPE_DEFAULT_OPTIONS` injection token you can specify default options.

### Unit pipe

Use the unit pipe like the following:

```
{{1.2 | intlUnit: 'hour': options}}
```

The input can be one of the following:

* number
* string (must be parseable as number)
* null
* undefined

The unit parameter is required, see
the [specification](https://tc39.es/proposal-unified-intl-numberformat/section6/locales-currencies-tz_proposed_out.html#sec-issanctionedsimpleunitidentifier)
for a full list of possible values. If you want to transform a decimal number instead, use the `intlDecimal` pipe.

The options are the same as the options for `new Intl.NumberFormat()`. For a list of the options, see
their [docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat).

With the `INTL_UNIT_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)

## Feature Roadmap

* Performance: Prepare Intl.* object with default options, only construct new object when necessary
* Relative time
* Limit options to only what is allowed by Intl API
* List pipe
* Relative time pipe
* Migration Schematics for usages of Angular pipes

## Chore Roadmap

* Automatic npm publishing
* Automatic changelog generation
3 changes: 3 additions & 0 deletions projects/angular-ecmascript-intl/src/lib/intl.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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";
import {IntlUnitPipe} from "./unit/intl-unit.pipe";

@NgModule({
imports: [
Expand All @@ -14,6 +15,7 @@ import {IntlCountryPipe} from "./country/intl-country.pipe";
IntlPercentPipe,
IntlCurrencyPipe,
IntlCountryPipe,
IntlUnitPipe,
],
exports: [
IntlDatePipe,
Expand All @@ -22,6 +24,7 @@ import {IntlCountryPipe} from "./country/intl-country.pipe";
IntlPercentPipe,
IntlCurrencyPipe,
IntlCountryPipe,
IntlUnitPipe,
],
})
export class IntlModule {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {InjectionToken} from "@angular/core";

export const INTL_UNIT_PIPE_DEFAULT_OPTIONS = new InjectionToken<Partial<Intl.NumberFormatOptions>>('IntlUnitPipeDefaultOptions');
158 changes: 158 additions & 0 deletions projects/angular-ecmascript-intl/src/lib/unit/intl-unit.pipe.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import {IntlUnitPipe} from './intl-unit.pipe';
import {TestBed} from "@angular/core/testing";
import {INTL_LOCALES} from "../locale";
import {INTL_UNIT_PIPE_DEFAULT_OPTIONS} from "./intl-unit-pipe-default-options";

describe('IntlUnitPipe', () => {
let testUnit: IntlUnitPipe;

describe('parsing', () => {
beforeEach(() => {
testUnit = new IntlUnitPipe('en-US');
});

it('should create an instance', () => {
expect(testUnit).toBeTruthy();
});

it('should handle null values', () => {
expect(testUnit.transform(null, undefined)).toBeNull();
});

it('should handle undefined values', () => {
expect(testUnit.transform(undefined, undefined)).toBeNull();
});

it('should handle empty strings', () => {
expect(testUnit.transform('', undefined)).toBeNull();
});

it('should transform numbers', () => {
expect(testUnit.transform(1, 'hour')).toEqual('1 hr');
});

it('should transform strings', () => {
expect(testUnit.transform('2', 'hour')).toEqual('2 hr');
});

it('should handle invalid strings', () => {
expect(() => testUnit.transform('invalid number', undefined)).toThrow();
});

it('should handle missing Intl.NumberFormat browser API', () => {
// @ts-expect-error Intl APIs are not expected to be undefined
spyOn(Intl, 'NumberFormat').and.returnValue(undefined);
const consoleError = spyOn(console, 'error');
expect(testUnit.transform('1', 'hour')).toBeNull();

expect(consoleError).toHaveBeenCalledTimes(1);
});
});

describe('internationalization', () => {
it('should respect the set locale', () => {
TestBed.configureTestingModule({
providers: [
IntlUnitPipe,
{
provide: INTL_LOCALES,
useValue: 'de-DE',
},
],
});
testUnit = TestBed.inject(IntlUnitPipe);

expect(testUnit.transform(1, 'hour')).toEqual('1 Std.');
});

it('should fall back to the browser default locale', () => {
TestBed.configureTestingModule({providers: [IntlUnitPipe]});

const result1 = TestBed.inject(IntlUnitPipe).transform(1, 'hour');
const result2 = new IntlUnitPipe(navigator.language).transform(1, 'hour');

expect(result1).toEqual(result2);
});
});

describe('options', () => {
it('should respect the setting from default config', () => {
TestBed.configureTestingModule({
providers: [
IntlUnitPipe,
{
provide: INTL_LOCALES,
useValue: 'en-US',
},
{
provide: INTL_UNIT_PIPE_DEFAULT_OPTIONS,
useValue: {
unitDisplay: 'narrow',
},
},
],
});
testUnit = TestBed.inject(IntlUnitPipe);

expect(testUnit.transform(1, 'liter')).toEqual('1L');

});

it('should give the user options a higher priority', () => {
TestBed.configureTestingModule({
providers: [
IntlUnitPipe,
{
provide: INTL_LOCALES,
useValue: 'en-US',
},
{
provide: INTL_UNIT_PIPE_DEFAULT_OPTIONS,
useValue: {
unitDisplay: 'short',
},
},
],
});
testUnit = TestBed.inject(IntlUnitPipe);

expect(testUnit.transform(1, 'liter', {unitDisplay: 'narrow'})).toEqual('1L');
});
});

it('should respect locale option', () => {
TestBed.configureTestingModule({
providers: [
IntlUnitPipe,
{
provide: INTL_LOCALES,
useValue: 'en-US',
},
],
});
testUnit = TestBed.inject(IntlUnitPipe);

expect(testUnit.transform(1, 'hour', {locale: 'de-DE'})).toEqual('1 Std.');
});

it('should not override the style option', () => {
TestBed.configureTestingModule({
providers: [
IntlUnitPipe,
{
provide: INTL_LOCALES,
useValue: 'en-US',
},
{
provide: INTL_UNIT_PIPE_DEFAULT_OPTIONS,
useValue: {
style: 'decimal',
},
},
],
});
testUnit = TestBed.inject(IntlUnitPipe);

expect(testUnit.transform(1, 'hour', {style: 'decimal'})).toEqual('1 hr');
});
});
39 changes: 39 additions & 0 deletions projects/angular-ecmascript-intl/src/lib/unit/intl-unit.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {Inject, Optional, Pipe, PipeTransform} from '@angular/core';
import {IntlPipeOptions} from "../intl-pipe-options";
import {INTL_LOCALES} from "../locale";
import {getNumericValue} from "../utils/number-utils";
import {INTL_UNIT_PIPE_DEFAULT_OPTIONS} from "./intl-unit-pipe-default-options";

export type IntlUnitPipeOptions = Partial<Intl.NumberFormatOptions> & IntlPipeOptions;

@Pipe({
name: 'intlUnit',
standalone: true,
})
export class IntlUnitPipe implements PipeTransform {

constructor(@Optional() @Inject(INTL_LOCALES) readonly locale?: string | string[] | null,
@Optional() @Inject(INTL_UNIT_PIPE_DEFAULT_OPTIONS) readonly defaultOptions?: Partial<Intl.NumberFormatOptions> | null) {
}

transform(value: number | string | null | undefined, unit: string | undefined, options?: IntlUnitPipeOptions): string | null {
if (typeof value !== 'number' && !value) {
return null;
}

const numericValue = getNumericValue(value);

const {locale, ...intlOptions} = options ?? {};

try {
return new Intl.NumberFormat(
locale ?? this.locale ?? undefined,
{...this.defaultOptions, ...intlOptions, unit, style: 'unit'},
).format(numericValue);
} catch (e) {
console.error('Error while transforming the percent value', e);
return null;
}
}

}
2 changes: 2 additions & 0 deletions projects/angular-ecmascript-intl/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ export * from './lib/language/intl-language-pipe-default-options';
export * from './lib/locale';
export * from './lib/percent/intl-percent.pipe';
export * from './lib/percent/intl-percent-pipe-default-options';
export * from './lib/unit/intl-unit.pipe';
export * from './lib/unit/intl-unit-pipe-default-options';
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {PercentComponent} from "./percent/percent.component";
import {CurrencyComponent} from "./currency/currency.component";
import {LanguageComponent} from "./language/language.component";
import {CountryComponent} from "./country/country.component";
import {UnitComponent} from "./unit/unit.component";

const routes: Routes = [
{
Expand All @@ -29,6 +30,10 @@ const routes: Routes = [
path: 'currency',
component: CurrencyComponent,
},
{
path: 'unit',
component: UnitComponent,
},
{
path: 'language',
component: LanguageComponent,
Expand Down
2 changes: 2 additions & 0 deletions projects/angular-intl-demo/src/app/pipes/pipes.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
routerLinkActive>Percent</a>
<a #currencyActive="routerLinkActive" [active]="currencyActive.isActive" mat-tab-link routerLink="currency"
routerLinkActive>Currency</a>
<a #unitActive="routerLinkActive" [active]="unitActive.isActive" mat-tab-link routerLink="unit"
routerLinkActive>Unit</a>
<a #languageActive="routerLinkActive" [active]="languageActive.isActive" mat-tab-link routerLink="language"
routerLinkActive>Language</a>
<a #countryActive="routerLinkActive" [active]="countryActive.isActive" mat-tab-link routerLink="country"
Expand Down
2 changes: 2 additions & 0 deletions projects/angular-intl-demo/src/app/pipes/pipes.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {PipesRoutingModule} from "./pipes-routing.module";
import {CountryComponent} from "./country/country.component";
import {NgxMatDatetimePickerModule, NgxMatNativeDateModule} from "@angular-material-components/datetime-picker";
import {MatDatepickerModule} from "@angular/material/datepicker";
import {UnitComponent} from "./unit/unit.component";

@NgModule({
declarations: [
Expand All @@ -25,6 +26,7 @@ import {MatDatepickerModule} from "@angular/material/datepicker";
CurrencyComponent,
PipesComponent,
CountryComponent,
UnitComponent,
],
imports: [
CommonModule,
Expand Down
Loading