Skip to content

Commit

Permalink
Introduce NumberParser (#74, #75)
Browse files Browse the repository at this point in the history
  • Loading branch information
antoniave authored Nov 19, 2024
1 parent 69185e9 commit e3802fb
Show file tree
Hide file tree
Showing 10 changed files with 240 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/friendly-insects-breathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@open-pioneer/runtime": minor
---

Introduce `NumberParserService` for parsing strings to numbers in the application's current locale.
12 changes: 12 additions & 0 deletions .changeset/tricky-dingos-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@open-pioneer/core": minor
---

Introduce the `NumberParser` class for parsing strings in a specified locale.

```js
import { NumberParser } from "@open-pioneer/core";

const parser = new NumberParser("de-DE");
const number = parser.parse("1.234,56");
```
40 changes: 40 additions & 0 deletions src/packages/core/NumberParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
// SPDX-License-Identifier: Apache-2.0
import { it, expect } from "vitest";
import { NumberParser } from "./NumberParser";

it("correctly formats number in en-US", function () {
const parser = new NumberParser("en-US");
const parsedNumber = parser.parse("12,345.6");
expect(parsedNumber).toBe(12345.6);
});

it("correctly formats number in de-DE", function () {
const parser = new NumberParser("de-DE");
const parsedNumber = parser.parse("12.345,6");
expect(parsedNumber).toBe(12345.6);
});

it("correctly formats number in fr-FR", function () {
const parser = new NumberParser("fr-FR");
const parsedNumber = parser.parse("12 345,6");
expect(parsedNumber).toBe(12345.6);
});

it("returns NaN for invalid number", function () {
const parser = new NumberParser("en-US");
const parsedNumber = parser.parse("abc");
expect(parsedNumber).toBeNaN();
});

it("returns NaN for empty string", function () {
const parser = new NumberParser("en-US");
const parsedNumber = parser.parse("");
expect(parsedNumber).toBeNaN();
});

it("returns NaN for whitespace in languages that do not use whitespace as a separator", function () {
const parser = new NumberParser("en-US");
const parsedNumber = parser.parse("12 345.6");
expect(parsedNumber).toBeNaN();
});
56 changes: 56 additions & 0 deletions src/packages/core/NumberParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
// SPDX-License-Identifier: Apache-2.0

/**
* This class allows to parse numbers from strings according to the given locale.
* Currently, in JavaScript there is no built-in way to parse numbers according to the current locale.
* Only arabic numerals are supported.
*/
export class NumberParser {
private readonly decimalRegexPattern: RegExp;
private readonly groupingRegexPattern: RegExp;

constructor(locales: Intl.LocalesArgument) {
const numberFormatOptions: Intl.NumberFormatOptions = {
useGrouping: true
};
const formattedParts = new Intl.NumberFormat(locales, numberFormatOptions).formatToParts(
12345.6
);

const decimalSeparator = formattedParts.find((part) => part.type === "decimal")?.value;
if (!decimalSeparator) {
throw new Error("Could not determine decimal separator.");
}

const groupingSeparator = formattedParts.find((part) => part.type === "group")?.value;
if (!groupingSeparator) {
throw new Error("Could not determine grouping separator.");
}

this.decimalRegexPattern = new RegExp(`${this.escapeRegExp(decimalSeparator)}`, "g");
if (groupingSeparator.trim() === "") {
// if a language uses a whitespace as grouping separator, remove all whitespaces (as numbers may not contain whitespaces)
this.groupingRegexPattern = /\s/g;
} else {
this.groupingRegexPattern = new RegExp(`${this.escapeRegExp(groupingSeparator)}`, "g");
}
}

/**
* Parses a number from a string considering the locale.
*/
parse(numberString: string): number {
numberString = numberString.trim();

const parsedNumberString = numberString
.replace(this.groupingRegexPattern, "")
.replace(this.decimalRegexPattern, ".");
return !parsedNumberString ? NaN : +parsedNumberString;
}

// see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
private escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
}
}
1 change: 1 addition & 0 deletions src/packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ export { EventEmitter, type EventSource, type EventNames } from "./events";
export { destroyResource, destroyResources, type Resource } from "./resources";
export { createLogger, type Logger, type LogLevel, type LogMethod } from "./Logger";
export { createManualPromise, type ManualPromise } from "./utils";
export { NumberParser } from "./NumberParser";
16 changes: 15 additions & 1 deletion src/packages/runtime/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# @open-pioneer/runtime

Implements the runtime environment for Open Pioneer Trails apps.
Additionally, the package provides some services that provide useful functionalities for app development.

## Quick start

Expand All @@ -21,7 +22,7 @@ customElements.define("my-app", Element);
```

In this example, `Element` is a custom web component class registered as `<my-app>`.
The application renders the `AppUI` (a react component) and automatically contains services, styles etc. its package dependencies.
The application renders the `AppUI` (a React component) and automatically contains services, styles etc. its package dependencies.
HTML sites or JavaScript code can now instantiate the application by creating a DOM-Element:

```html
Expand All @@ -44,6 +45,19 @@ The error screen is available in english (fallback) and german.

If the application was started in DEV-mode, the error screen shows additional information about the error and the stack trace.

## Provided services

The package contains services that provide different functionalities useful for app development.
For details, see the API description of the respective service.

### ApplicationContext

The ApplicationContext provides access to global application values. E.g. the web component's host element and the current locale.

### NumberParserService

The NumberParserService provides a method to parse a string to a number according to the current locale.

## License

Apache-2.0 (see `LICENSE` file)
10 changes: 10 additions & 0 deletions src/packages/runtime/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,13 @@ export interface ApplicationLifecycleListener
*/
beforeApplicationStop?(): void;
}

/**
* A service that allows to parse numbers from strings according to the current locale.
*/
export interface NumberParserService extends DeclaredService<"runtime.NumberParserService"> {
/**
* Parses a number from a string according to the current locale.
*/
parseNumber(numberString: string): number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
// SPDX-License-Identifier: Apache-2.0
import { expect, it } from "vitest";
import { ServiceOptions } from "../Service";
import { createCustomElement } from "../CustomElement";
import { renderComponentShadowDOM } from "@open-pioneer/test-utils/web-components";
import { NumberParserServiceImpl } from "./NumberParserServiceImpl";

it("can use the NumberParserService to parse a string to a number", async function () {
const parsedNumber = await parseNumberWithLocale("123.45", "en-US");
expect(parsedNumber).toBe(123.45);
});

it("can use the NumberParserService to parse a string to a number if local is 'de'", async function () {
const parsedNumber = await parseNumberWithLocale("123.123,2", "de");
expect(parsedNumber).toBe(123123.2);
});

async function parseNumberWithLocale(numberString: string, locale: string) {
let parsedNumber: number | undefined;

class TestService {
constructor(options: ServiceOptions<{ numberParser: NumberParserServiceImpl }>) {
const numberParser = options.references.numberParser;
parsedNumber = numberParser.parseNumber(numberString);
}
}

const elem = createCustomElement({
appMetadata: {
packages: {
test: {
name: "test",
services: {
testService: {
name: "testService",
clazz: TestService,
references: {
numberParser: {
name: "runtime.NumberParserService"
}
},
provides: [{ name: "runtime.AutoStart" }]
}
}
}
},
locales: [locale]
},
config: {
locale: locale
}
});

await renderComponentShadowDOM(elem);
return parsedNumber;
}
27 changes: 27 additions & 0 deletions src/packages/runtime/builtin-services/NumberParserServiceImpl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer)
// SPDX-License-Identifier: Apache-2.0
import { NumberParserService } from "../api";
import { ServiceOptions } from "../Service";
import { createLogger, NumberParser } from "@open-pioneer/core";

const LOG = createLogger("runtime:NumberParserService");

export class NumberParserServiceImpl implements NumberParserService {
private numberParser: NumberParser;

constructor(serviceOptions: ServiceOptions, locale: string) {
try {
this.numberParser = new NumberParser(locale);
} catch (e) {
LOG.warn(
"Failed to create NumberParserImpl with locale. Retrying with default 'en-US' locale.",
e
);
this.numberParser = new NumberParser("en-US");
}
}

parseNumber(numberString: string): number {
return this.numberParser.parse(numberString);
}
}
18 changes: 17 additions & 1 deletion src/packages/runtime/builtin-services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import {
import { ApiServiceImpl } from "./ApiServiceImpl";
import { ApplicationContextImpl, ApplicationContextProperties } from "./ApplicationContextImpl";
import { ApplicationLifecycleEventService } from "./ApplicationLifecycleEventService";
import { NumberParserServiceImpl } from "./NumberParserServiceImpl";

export const RUNTIME_PACKAGE_NAME = "@open-pioneer/runtime";
export const RUNTIME_API_EXTENSION = "integration.ApiExtension";
export const RUNTIME_API_SERVICE = "runtime.ApiService";
export const RUNTIME_APPLICATION_CONTEXT = "runtime.ApplicationContext";
export const RUNTIME_APPLICATION_LIFECYCLE_EVENT_SERVICE =
"runtime.ApplicationLifecycleEventService";
export const RUNTIME_NUMBER_PARSER_SERVICE = "runtime.NumberParserService";
export const RUNTIME_AUTO_START = "runtime.AutoStart";

export type BuiltinPackageProperties = ApplicationContextProperties;
Expand Down Expand Up @@ -85,10 +87,24 @@ export function createBuiltinPackage(properties: BuiltinPackageProperties): Pack
}
]
});
const numberParserService = new ServiceRepr({
name: "NumberParserServiceImpl",
packageName: RUNTIME_PACKAGE_NAME,
factory: createFunctionFactory(
(options) => new NumberParserServiceImpl(options, properties.locale)
),
intl: i18n,
interfaces: [
{
interfaceName: RUNTIME_NUMBER_PARSER_SERVICE,
qualifier: "builtin"
}
]
});

return new PackageRepr({
name: RUNTIME_PACKAGE_NAME,
services: [apiService, appContext, lifecycleEventService],
services: [apiService, appContext, lifecycleEventService, numberParserService],
intl: i18n
});
}

0 comments on commit e3802fb

Please sign in to comment.