I18n

Internationalization support using react-intl with ICU syntax implementation.

Running this example

To run this example locally:

  1. If you haven't already, create a new app in the Developer Portal(opens in a new tab or window). For more information, refer to our Quickstart guide.

  2. In your app's configuration on the Developer Portal(opens in a new tab or window), ensure the "Development URL" is set to http://localhost:8080.

  3. Clone the starter kit:

    git clone https://github.com/canva-sdks/canva-apps-sdk-starter-kit.git
    cd canva-apps-sdk-starter-kit
    SHELL
  4. Install dependencies:

    npm install
    SHELL
  5. Run the example:

    npm run start i18n
    SHELL
  6. Click the Preview URL link shown in the terminal to open the example in the Canva editor.

Example app source code

import {
Box,
Button,
Link,
MultilineInput,
Rows,
Slider,
SortIcon,
Text,
Title,
} from "@canva/app-ui-kit";
import * as React from "react";
import * as styles from "styles/components.css";
import { FormattedMessage, useIntl } from "react-intl";
import type {
OpenExternalUrlRequest,
OpenExternalUrlResponse,
} from "@canva/platform";
const DOCS_URL = "https://canva.dev/docs/apps/localization";
const NAME = "Anto";
export const App = ({
requestOpenExternalUrl,
}: {
requestOpenExternalUrl: (
request: OpenExternalUrlRequest,
) => Promise<OpenExternalUrlResponse>;
}) => {
const openExternalUrl = async (url: string) => {
const response = await requestOpenExternalUrl({
url,
});
if (response.status === "aborted") {
// user decided not to navigate to the link
}
};
const intl = useIntl();
const now = new Date();
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
return (
<div className={styles.scrollContainer}>
<Rows spacing="2u">
{/* ==================== Basic message ==================== */}
<Title size="small">
<FormattedMessage
/**
* The defaultMessage string is used as the source of translations. Should be written in English (US).
* This is also displayed to users with an English locale, or a locale for which no translations
* can be found.
*/
defaultMessage="My internationalized app"
/**
* Use the description string to convey as much context as possible to a human translator.
* For guidance on writing translator notes, see here: https://canva.dev/docs/apps/localization/#add-notes-for-translators
*/
description="This is the title of the app that the user sees when they open it. Appears at the top of the page."
/>
</Title>
{/* ==================== Interpolation ==================== */}
<Text>
<FormattedMessage
defaultMessage="Welcome to the world of AI creativity, {firstName}!"
description="Greeting to welcome the user to the AI image generation app"
values={{
firstName: NAME,
}}
/>
</Text>
{/* ==================== Numbers ==================== */}
<Text>
<FormattedMessage
defaultMessage="Image generation is {progress, number, ::percent} complete."
description="Displays the progress of the current image generation task that the user has requested"
values={{
progress: 0.75,
}}
/>
</Text>
{/* ==================== Date and time formatting ==================== */}
<Text>
<FormattedMessage
defaultMessage="Credits refresh on: {refreshDate, date, short} at {refreshTime, time, short}"
description="Informs users when their credits for image generation will refresh, including the time"
values={{
refreshDate: nextWeek,
refreshTime: nextWeek,
}}
/>
</Text>
{/* ==================== Plurals ==================== */}
<CreditUsage creditsCost={5} remainingCredits={50} />
<CreditUsage creditsCost={1} remainingCredits={1} />
<CreditUsage creditsCost={1} remainingCredits={0} />
{/* ==================== Rich Text ==================== */}
<FormattedMessage
defaultMessage="Discover stunning AI-generated example images in our <link>gallery</link> and <callToAction>start exploring now!</callToAction>"
description="A call to action directing the user to explore the AI image gallery"
values={{
link: (chunks) => (
<Link
href={DOCS_URL}
requestOpenExternalUrl={() => openExternalUrl(DOCS_URL)}
>
{chunks}
</Link>
),
callToAction: (chunks) => <strong>{chunks}</strong>,
}}
>
{(chunks) => <Text>{chunks}</Text>}
</FormattedMessage>
{/* ==================== Message as string type ==================== */}
<Button variant="primary">
{intl.formatMessage({
defaultMessage: "Generate image",
description: "A button label to generate an image from a prompt",
})}
</Button>
{/* ==================== Non-visible text usage (aria-label) ==================== */}
<Button
variant="primary"
icon={SortIcon}
ariaLabel={intl.formatMessage({
defaultMessage: "Sort images by creation date (Newest to Oldest)",
description:
"Screenreader text for a button. When pressed, the button will sort the generated images by creation date from newest to oldest.",
})}
/>
{/* ==================== Component that changes between LTR and RTL languages ==================== */}
<Box paddingStart="2u">
<Slider min={0} max={100} />
</Box>
{/* ==================== List formatting ==================== */}
<SelectedEffects />
{/* ==================== Relative time formatting ==================== */}
<LastGeneratedMessage lastGeneratedTime={now} />
{/* ==================== Display name formatting ==================== */}
<Text>
<FormattedMessage
defaultMessage="You are currently viewing this app in {language}"
description="Informs the user about the language in which they are viewing the app"
values={{
language: intl.formatDisplayName(intl.locale, {
type: "language",
}),
}}
/>
</Text>
{/* ==================== Multiline Example ==================== */}
<Text>
<FormattedMessage
defaultMessage="This is a multi-line {breakingLine}text example!"
description="An example text block that carries over multiple lines."
values={{
breakingLine: <br />,
}}
/>
</Text>
<MultilineInput
minRows={2}
maxRows={2}
autoGrow={false}
placeholder={intl.formatMessage(
{
description:
"Placeholder text to a MultilineInput that carries over multiple lines.",
defaultMessage: `This is a multi {breakingLine}line input example!`,
},
{
breakingLine: "\n",
},
)}
/>
</Rows>
</div>
);
};
export const CreditUsage = ({
creditsCost,
remainingCredits,
}: {
creditsCost: number;
remainingCredits: number;
}) => (
<Text>
<FormattedMessage
defaultMessage={`Use {creditsCost, number} of {remainingCredits, plural,
one {# credit}
other {# credits}
}`}
description="Informs the user about the number of credits they will use for the image generation task. Appears below the image generation button."
values={{
creditsCost,
remainingCredits,
}}
/>
</Text>
);
const SelectedEffects = () => {
const intl = useIntl();
// TODO: Make this list change based on user selection!
const selectedEffects = [
intl.formatMessage({
defaultMessage: "black and white",
description:
"An option that when selected, will apply a black and white effect to the generated image",
}),
intl.formatMessage({
defaultMessage: "high contrast",
description:
"An option that when selected, will apply a high contrast effect to the generated image",
}),
intl.formatMessage({
defaultMessage: "cartoon",
description:
"An option that when selected, will apply a cartoon effect to the generated image",
}),
];
return (
<Text>
<FormattedMessage
defaultMessage="You have selected the following image effects: {effects}"
description="Informs the user about the image effects they have selected. effects is a list of effects that will be applied to the generated image."
values={{
effects: intl.formatList(selectedEffects, {
type: "conjunction",
}),
}}
/>
</Text>
);
};
const LastGeneratedMessage = ({
lastGeneratedTime,
}: {
lastGeneratedTime: Date;
}) => {
const intl = useIntl();
const [generatedTimeAgoInSeconds, setGeneratedTimeAgoInSeconds] =
React.useState(
Math.floor((new Date().getTime() - lastGeneratedTime.getTime()) / 1000),
);
React.useEffect(() => {
const interval = setInterval(() => {
setGeneratedTimeAgoInSeconds(
Math.floor((new Date().getTime() - lastGeneratedTime.getTime()) / 1000),
);
}, 1000);
return () => clearInterval(interval);
}, [lastGeneratedTime]);
return (
<Text>
<FormattedMessage
defaultMessage="Last image generated {timeAgo}"
description="Tells the user how long ago they generated their last image. timeAgo is a relative time string. e.g. '5 seconds ago'"
values={{
timeAgo: intl.formatRelativeTime(
-generatedTimeAgoInSeconds,
"seconds",
),
}}
/>
</Text>
);
};
TYPESCRIPT
import { AppUiProvider } from "@canva/app-ui-kit";
import { createRoot } from "react-dom/client";
import { App } from "./app";
import "@canva/app-ui-kit/styles.css";
import { AppI18nProvider } from "@canva/app-i18n-kit";
import { requestOpenExternalUrl } from "@canva/platform";
const root = createRoot(document.getElementById("root") as Element);
function render() {
root.render(
<AppI18nProvider>
<AppUiProvider>
{/* Any Apps SDK method needs to be injected to the component, to avoid the need to mock it in tests */}
<App requestOpenExternalUrl={requestOpenExternalUrl} />
</AppUiProvider>
</AppI18nProvider>,
);
}
render();
if (module.hot) {
module.hot.accept("./app", render);
}
TYPESCRIPT
// Import testing sub-packages
import * as asset from "@canva/asset/test";
import * as design from "@canva/design/test";
import * as error from "@canva/error/test";
import * as platform from "@canva/platform/test";
import * as user from "@canva/user/test";
// Initialize the test environments
asset.initTestEnvironment();
design.initTestEnvironment();
error.initTestEnvironment();
platform.initTestEnvironment();
user.initTestEnvironment();
// Once they're initialized, mock the SDKs
jest.mock("@canva/asset");
jest.mock("@canva/design");
jest.mock("@canva/platform");
jest.mock("@canva/user");
// n.b. @canva/error should not be mocked - use it to simulate API error responses from other mocks by throwing CanvaError
TYPESCRIPT
import type { RenderResult } from "@testing-library/react";
import { fireEvent, render } from "@testing-library/react";
import { TestAppUiProvider } from "@canva/app-ui-kit";
import { TestAppI18nProvider } from "@canva/app-i18n-kit";
import { App, CreditUsage } from "../app";
function renderInTestProvider(node: React.ReactNode): RenderResult {
return render(
// In a test environment, you should wrap your apps in `TestAppI18nProvider` and `TestAppUiProvider`, rather than `AppI18nProvider` and `AppUiProvider`
<TestAppI18nProvider>
<TestAppUiProvider>{node}</TestAppUiProvider>
</TestAppI18nProvider>,
);
}
describe("app", () => {
let requestOpenExternalUrl: jest.Mock;
beforeEach(() => {
requestOpenExternalUrl = jest.fn().mockResolvedValue({});
jest.useFakeTimers({
now: new Date("2024-09-25"), // For consistent snapshots, pretend today is always Canva Extend 2024
});
});
afterEach(() => {
jest.useRealTimers();
});
it("calls openExternalUrl onClick", async () => {
const result = renderInTestProvider(
<App requestOpenExternalUrl={requestOpenExternalUrl} />,
);
// get a reference to the link element
const galleryExternalLink = result.getByText(/gallery/);
// assert its label matches what we expect
expect(galleryExternalLink.textContent).toContain("gallery");
// assert our callback has not yet been called
expect(requestOpenExternalUrl).not.toHaveBeenCalled();
// programmatically simulate clicking the button
fireEvent.click(galleryExternalLink);
// assert our callback was called
expect(requestOpenExternalUrl).toHaveBeenCalledTimes(1);
});
it("Renders token counts consistently 🎉", () => {
const resultToken0 = renderInTestProvider(
<CreditUsage creditsCost={5} remainingCredits={50} />,
);
// The snapshot test can be used to detect unexpected changes in the rendered output.
expect(resultToken0.container).toMatchSnapshot();
const resultToken1 = renderInTestProvider(
<CreditUsage creditsCost={1} remainingCredits={1} />,
);
expect(resultToken1.container).toMatchSnapshot();
const resultToken10 = renderInTestProvider(
<CreditUsage creditsCost={1} remainingCredits={0} />,
);
expect(resultToken10.container).toMatchSnapshot();
});
});
TYPESCRIPT

API Reference

Need Help?