Skip to content
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

Updates API, adds SKU service #2890

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 3 additions & 1 deletion desktop/src/app/environment/desktop-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { DefaultFormLayoutProvider } from "@azure/bonito-ui/lib/components/form"
import { BrowserDependencyName, BrowserEnvironmentConfig, DefaultBrowserEnvironment } from "@azure/bonito-ui/lib/environment";
import BatchExplorerHttpClient from "@batch-flask/core/batch-explorer-http-client";
import { BatchBrowserDependencyFactories, BatchFormControlResolver } from "@batch/ui-react";
import { LiveNodeService, LivePoolService } from "@batch/ui-service";
import { LiveNodeService, LivePoolService, LiveSkuService } from "@batch/ui-service";
import { BatchDependencyName } from "@batch/ui-service/lib/environment";
import { DesktopLocalizer } from "app/localizer/desktop-localizer";
import { AppTranslationsLoaderService, AuthService, BatchExplorerService } from "app/services";
Expand Down Expand Up @@ -46,6 +46,8 @@ export function initDesktopEnvironment(
new LivePoolService(),
[BatchDependencyName.NodeService]: () =>
new LiveNodeService(),
[BatchDependencyName.SkuService]: () =>
new LiveSkuService(),
[DependencyName.Notifier]: () =>
new AlertNotifier(), // TODO: update with real notification implementation
[DependencyName.ResourceGroupService]: () =>
Expand Down
1 change: 1 addition & 0 deletions packages/bonito-core/i18n/resources.resjson
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"bonito.core.close": "Close",
"bonito.core.form.validation.booleanValueError": "Value must be a boolean",
"bonito.core.form.validation.invalidEnumValue": "Invalid value",
"bonito.core.form.validation.minLengthError": "Value must be at least {length} characters long",
"bonito.core.form.validation.numberValueError": "Value must be a number",
"bonito.core.form.validation.stringListValueError": "All values must be strings",
"bonito.core.form.validation.stringValueError": "Value must be a string",
Expand Down
62 changes: 31 additions & 31 deletions packages/bonito-core/src/__tests__/http-localizer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,42 @@ describe("HttpLocalizer", () => {
let httpLocalizer: HttpLocalizer;
let fetchMock: jest.Mock;

const testTranslations = { hello: "world" };
const testTranslations = {
hello: "world",
parameterized: "Hello, {name}",
};
const frenchTranslations = { bonjour: "monde" };

const setUpTranslations = (
translations: { [key: string]: string } = testTranslations,
locale = "en-US"
) => {
fetchMock.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(translations),
})
);
jest.spyOn(httpLocalizer, "getLocale").mockReturnValue(locale);
};

beforeEach(() => {
httpLocalizer = new HttpLocalizer();
fetchMock = jest.fn();
global.fetch = fetchMock;
});

test("Load the correct translation file based on the locale", async () => {
fetchMock.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(testTranslations),
})
);
setUpTranslations();

jest.spyOn(httpLocalizer, "getLocale").mockReturnValue("en-US");
await httpLocalizer.loadTranslations("/base/url");
expect(fetchMock).toHaveBeenCalledWith("/base/url/resources.en.json");
expect(httpLocalizer.translate("hello")).toEqual("world");
});

test("Load the correct translation file for French locale", async () => {
fetchMock.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(frenchTranslations),
})
);
setUpTranslations(frenchTranslations, "fr-FR");

// Simulate a French locale
jest.spyOn(httpLocalizer, "getLocale").mockReturnValue("fr-FR");
await httpLocalizer.loadTranslations("/resources/i18n");
expect(fetchMock).toHaveBeenCalledWith(
"/resources/i18n/resources.fr.json"
Expand All @@ -45,15 +48,9 @@ describe("HttpLocalizer", () => {
});

test("Default to English if locale not found", async () => {
fetchMock.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(testTranslations),
})
);

// Simulate an invalid locale
jest.spyOn(httpLocalizer, "getLocale").mockReturnValue("abc");
setUpTranslations(testTranslations, "abc");

await httpLocalizer.loadTranslations("/resources/i18n");
expect(fetchMock).toHaveBeenCalledWith(
"/resources/i18n/resources.en.json"
Expand All @@ -68,15 +65,18 @@ describe("HttpLocalizer", () => {
});

test("Return original message if no translation found", async () => {
fetchMock.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(testTranslations),
})
);
setUpTranslations();

jest.spyOn(httpLocalizer, "getLocale").mockReturnValue("en-US");
await httpLocalizer.loadTranslations("/resources/i18n");
expect(httpLocalizer.translate("notFound")).toEqual("notFound");
});

test("Supports parameterized translations", async () => {
setUpTranslations();

await httpLocalizer.loadTranslations("/base/url");
expect(
httpLocalizer.translate("parameterized", { name: "world" })
).toEqual("Hello, world");
});
});
60 changes: 58 additions & 2 deletions packages/bonito-core/src/__tests__/localization.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { initMockEnvironment } from "../environment";
import { translate } from "../localization/localization-util";
import { replaceTokens, translate } from "../localization/localization-util";
import { LocalizedStrings } from "../localization/localized-strings";

describe("Localization utilities", () => {
Expand All @@ -13,9 +13,65 @@ describe("Localization utilities", () => {
).toEqual("Value must be a boolean");
});

test("Throw error if string is unknown", () => {
test("Throws error if string is unknown", () => {
expect(() =>
translate("Invalid" as unknown as keyof LocalizedStrings)
).toThrowError("Unable to translate string Invalid");
});

test("Can translate parameterized strings", () => {
expect(
translate("bonito.core.form.validation.minLengthError", {
length: 5,
})
).toEqual("Value must be at least 5 characters long");
});

describe("Token replacer", () => {
test("Can replace a single token", () => {
expect(replaceTokens("Hello {name}", { name: "World" })).toEqual(
"Hello World"
);
});
test("Ignores unknown tokens", () => {
expect(replaceTokens("Hello {name}", {})).toEqual("Hello {name}");
});
test("Can replace multiple tokens", () => {
expect(
replaceTokens("{emotion} is the {organ} {role}", {
emotion: "Fear",
organ: "mind",
role: "killer",
})
).toEqual("Fear is the mind killer");
});
test("Ignores unparametrized strings", () => {
expect(replaceTokens("Hello World", { name: "World" })).toEqual(
"Hello World"
);
});
test("Handles non-string tokens", () => {
expect(replaceTokens("Who is my #{num}?", { num: 5 })).toEqual(
"Who is my #5?"
);
expect(
replaceTokens("Too good to be {state}", { state: true })
).toEqual("Too good to be true");
expect(
replaceTokens("Bear {truth} witness", { truth: false })
).toEqual("Bear false witness");
});
test("Handles multiple instances of the same token", () => {
expect(
replaceTokens("{name} is the name of my {name}", {
name: "cat",
})
).toEqual("cat is the name of my cat");
});
test("Handles whitespace around tokens", () => {
expect(replaceTokens("Hello { name }", { name: "World" })).toEqual(
"Hello World"
);
});
});
});
1 change: 1 addition & 0 deletions packages/bonito-core/src/form/form.i18n.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ form:
numberValueError: Value must be a number
stringListValueError: All values must be strings
stringValueError: Value must be a string
minLengthError: Value must be at least {length} characters long
8 changes: 6 additions & 2 deletions packages/bonito-core/src/localization/fake-localizer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Localizer } from "./localizer";
import { replaceTokens } from "./localization-util";
import { Localizer, LocalizerTokenMap } from "./localizer";

export class FakeLocalizer implements Localizer {
private locale: string;
Expand All @@ -7,14 +8,17 @@ export class FakeLocalizer implements Localizer {
this.locale = "en";
}

translate(message: string): string {
translate(message: string, tokens?: LocalizerTokenMap): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const value = (globalThis as unknown as any).__TEST_RESOURCE_STRINGS[
message
];
if (value == null) {
throw new Error("Unable to translate string " + message);
}
if (tokens) {
return replaceTokens(value, tokens);
}
return value;
}

Expand Down
25 changes: 23 additions & 2 deletions packages/bonito-core/src/localization/http-localizer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Localizer } from "./localizer";
import { replaceTokens } from "./localization-util";
import { Localizer, LocalizerTokenMap } from "./localizer";

interface Translations {
[key: string]: string;
Expand Down Expand Up @@ -85,12 +86,32 @@ export class HttpLocalizer implements Localizer {
return await response.json();
}

translate(message: string): string {
/**
* Translates a message using loaded translations and optional tokens.
*
* @param {string} message - The message to be translated.
* @param {LocalizerTokenMap} [tokens] - An optional map of tokens to replace in the translation.
* @returns {string} The translated message, or the original message if no translation was found.
* @throws {Error} If the translations have not been loaded.
*
* @example
* ```yaml
* foo: My name is {name}
* ```
*
* ```typescript
* translate("foo", { name: "Lucy Barton" }) // "My name is Lucy Barton"
* ```
*/
translate(message: string, tokens?: LocalizerTokenMap): string {
if (!this.translations) {
throw new Error("Translation strings are not loaded " + message);
}
const translation = this.translations[message];
if (translation != null) {
if (tokens) {
return replaceTokens(translation, tokens);
}
return translation;
} else {
return message;
Expand Down
14 changes: 11 additions & 3 deletions packages/bonito-core/src/localization/localization-util.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { getEnvironment } from "../environment";
import { LocalizedStrings } from "./localized-strings";
import { Localizer } from "./localizer";
import { Localizer, LocalizerTokenMap } from "./localizer";

export function translate(
message: Extract<keyof LocalizedStrings, string>
message: Extract<keyof LocalizedStrings, string>,
tokens?: LocalizerTokenMap
): string {
return getLocalizer().translate(
message as unknown as keyof LocalizedStrings
message as unknown as keyof LocalizedStrings,
tokens
);
}

export function getLocalizer(): Localizer {
return getEnvironment().getLocalizer();
}

export function replaceTokens(text: string, tokens: LocalizerTokenMap): string {
return text.replace(/{\s*([\w.]+)\s*}/g, (match, token) =>
token in tokens ? String(tokens[token]) : match
);
}
4 changes: 3 additions & 1 deletion packages/bonito-core/src/localization/localizer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export type LocalizerTokenMap = Record<string, string | number | boolean>;

export interface Localizer {
translate(message: string): string;
translate(message: string, tokens?: LocalizerTokenMap): string;
getLocale(): string;
}
5 changes: 4 additions & 1 deletion packages/service/i18n/resources.resjson
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
{}
{
"lib.service.sku.eolWarning": "This SKU is scheduled for retirement on {eolDate}",
"lib.service.sku.notFound": "SKU '{skuName}' not found"
}
2 changes: 1 addition & 1 deletion packages/service/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const BatchApiVersion = {
arm: `2023-11-01`,
arm: `2024-02-01`,
data: `2023-05-01.17.0`,
};
3 changes: 3 additions & 0 deletions packages/service/src/environment/batch-dependencies.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { DependencyFactories } from "@azure/bonito-core/lib/environment";
import { NodeService } from "../node";
import { PoolService } from "../pool";
import { SkuService } from "../sku";

export enum BatchDependencyName {
PoolService = "poolService",
NodeService = "nodeService",
SkuService = "skuService",
}

export interface BatchDependencyFactories extends DependencyFactories {
[BatchDependencyName.PoolService]: () => PoolService;
[BatchDependencyName.NodeService]: () => NodeService;
[BatchDependencyName.SkuService]: () => SkuService;
}
2 changes: 2 additions & 0 deletions packages/service/src/environment/environment-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import {
mockEnvironmentConfig,
} from "@azure/bonito-core/lib/environment";
import { FakePoolService } from "../pool";
import { FakeSkuService } from "../sku";
import {
BatchDependencyFactories,
BatchDependencyName,
} from "./batch-dependencies";

export const mockBatchDepFactories: Partial<BatchDependencyFactories> = {
[BatchDependencyName.PoolService]: () => new FakePoolService(),
[BatchDependencyName.SkuService]: () => new FakeSkuService(),
};

/**
Expand Down
1 change: 1 addition & 0 deletions packages/service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from "./certificate";
export * from "./pool";
export * from "./node";
export * from "./constants";
export * from "./sku";
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default function createClient(
options: ClientOptions = {}
): BatchManagementClient {
const baseUrl = options.baseUrl ?? `https://management.azure.com`;
options.apiVersion = options.apiVersion ?? "2023-11-01";
options.apiVersion = options.apiVersion ?? "2024-02-01";
options = {
...options,
credentials: {
Expand Down
Loading
Loading