diff --git a/frontend/src/explorer/ModelSelectionPage.stories.tsx b/frontend/src/explorer/ModelSelectionPage.stories.tsx index 1fd5e9a6b..03e5589e4 100644 --- a/frontend/src/explorer/ModelSelectionPage.stories.tsx +++ b/frontend/src/explorer/ModelSelectionPage.stories.tsx @@ -6,9 +6,19 @@ import { getApiUrl } from "src/envService"; const MODELS_URL = getApiUrl() + "/models"; const fakeModels = MockPayload.GET.getPayloadWithArrayOfFakeModelInfo(3); -fakeModels[0] = { ...fakeModels[0], name: "Taxonomy for South Africa", version: "v1.0.0.0" }; -fakeModels[1] = { ...fakeModels[1], name: "Taxonomy for South Africa", version: "v1.0.1.1" }; -fakeModels[2] = { ...fakeModels[2], name: "Tabiya esco-1.1.1", version: "v0.9.0" }; +fakeModels[0] = { + ...fakeModels[0], + name: "Taxonomy for South Africa", + version: "v1.0.0.0", + availableLanguages: ["en"], +}; +fakeModels[1] = { + ...fakeModels[1], + name: "Taxonomy for South Africa", + version: "v1.0.1.1", + availableLanguages: ["en", "fr", "es", "pt", "am"], +}; +fakeModels[2] = { ...fakeModels[2], name: "Tabiya esco-1.1.1", version: "v0.9.0", availableLanguages: ["en"] }; const meta: Meta = { title: "Explorer/ModelSelectionPage", diff --git a/frontend/src/explorer/ModelSelectionPage.test.tsx b/frontend/src/explorer/ModelSelectionPage.test.tsx index 3964f257f..d53050452 100644 --- a/frontend/src/explorer/ModelSelectionPage.test.tsx +++ b/frontend/src/explorer/ModelSelectionPage.test.tsx @@ -2,7 +2,7 @@ import "src/_test_utilities/consoleMock"; import { MemoryRouter, Route, Routes } from "react-router-dom"; -import { render, screen } from "src/_test_utilities/test-utils"; +import { render, screen, within } from "src/_test_utilities/test-utils"; import userEvent from "@testing-library/user-event"; import ModelSelectionPage, { DATA_TEST_ID } from "./ModelSelectionPage"; import ModelInfoService from "src/modelInfo/modelInfo.service"; @@ -10,10 +10,21 @@ import { MODELS_QUERY_KEY } from "src/modelInfo/useModels"; import { queryClient } from "src/app/providers/QueryProvider"; import { getArrayOfFakeModels } from "src/modeldirectory/_test_utilities/mockModelData"; import { routerPaths } from "src/app/routerPaths"; +import { DATA_TEST_ID as AVAILABLE_LANGUAGES_DATA_TEST_ID } from "src/modeldirectory/components/AvailableLanguages/AvailableLanguages"; const givenModels = getArrayOfFakeModels(2); -givenModels[0] = { ...givenModels[0], name: "Taxonomy for South Africa", version: "v1.0.1-rc.1" }; -givenModels[1] = { ...givenModels[1], name: "Tabiya esco-1.1.1", version: "v0.9.0" }; +givenModels[0] = { + ...givenModels[0], + name: "Taxonomy for South Africa", + version: "v1.0.1-rc.1", + availableLanguages: ["en"], +}; +givenModels[1] = { + ...givenModels[1], + name: "Tabiya esco-1.1.1", + version: "v0.9.0", + availableLanguages: ["en", "fr", "es", "pt", "am"], +}; const renderModelSelectionPage = () => render( @@ -82,6 +93,34 @@ describe("ModelSelectionPage", () => { expect(console.warn).not.toHaveBeenCalled(); }); + test("should show the names of the available languages on each model card", async () => { + // GIVEN the models service resolves with a single language model and a model available in five languages + const [givenSingleLanguageModel, givenMultilingualModel] = givenModels; + + // WHEN the page is rendered + renderModelSelectionPage(); + + // THEN expect the card of the multilingual model to show the names of its languages + const actualMultilingualLanguages = await screen.findByTestId( + `${DATA_TEST_ID.MODEL_CARD_AVAILABLE_LANGUAGES}-${givenMultilingualModel.id}` + ); + expect(actualMultilingualLanguages).toHaveTextContent("English, French, Spanish, Portuguese, Amharic"); + // AND the names to be inside the card of the multilingual model + expect(screen.getByTestId(`${DATA_TEST_ID.MODEL_CARD}-${givenMultilingualModel.id}`)).toContainElement( + actualMultilingualLanguages + ); + // AND the card of the single language model to show the name of its language + const actualSingleLanguage = screen.getByTestId( + `${DATA_TEST_ID.MODEL_CARD_AVAILABLE_LANGUAGES}-${givenSingleLanguageModel.id}` + ); + expect( + within(actualSingleLanguage).getByTestId(AVAILABLE_LANGUAGES_DATA_TEST_ID.AVAILABLE_LANGUAGES_NAMES) + ).toHaveTextContent(/^English$/); + // AND expect no errors or warnings to have been logged + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + }); + test("should render the empty state without crashing when fetching the models fails", async () => { // GIVEN fetching the models will fail, and retries are disabled queryClient.setQueryDefaults(MODELS_QUERY_KEY, { retry: false }); diff --git a/frontend/src/explorer/ModelSelectionPage.tsx b/frontend/src/explorer/ModelSelectionPage.tsx index 7b209f40a..71104aa28 100644 --- a/frontend/src/explorer/ModelSelectionPage.tsx +++ b/frontend/src/explorer/ModelSelectionPage.tsx @@ -8,12 +8,14 @@ import { ServiceError } from "src/error/error"; import { writeServiceErrorToLog } from "src/error/logger"; import ContentLayout from "src/theme/ContentLayout/ContentLayout"; import { routerPaths } from "src/app/routerPaths"; +import AvailableLanguages from "src/modeldirectory/components/AvailableLanguages/AvailableLanguages"; const uniqueId = "3f7c1a5b-9e42-4d8f-b6c0-1a2e3f4d5c6b"; export const DATA_TEST_ID = { MODEL_SELECTION_PAGE: `model-selection-page-${uniqueId}`, MODEL_CARD: `model-selection-page-card-${uniqueId}`, + MODEL_CARD_AVAILABLE_LANGUAGES: `model-selection-page-card-available-languages-${uniqueId}`, }; const ModelSelectionPage = () => { @@ -86,6 +88,12 @@ const ModelSelectionPage = () => { {m.locale?.name} ({m.locale?.shortCode}) + + + {m.version && ( diff --git a/frontend/src/modeldirectory/components/AvailableLanguages/AvailableLanguages.stories.tsx b/frontend/src/modeldirectory/components/AvailableLanguages/AvailableLanguages.stories.tsx new file mode 100644 index 000000000..23e0a1345 --- /dev/null +++ b/frontend/src/modeldirectory/components/AvailableLanguages/AvailableLanguages.stories.tsx @@ -0,0 +1,44 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import AvailableLanguages from "./AvailableLanguages"; +import LanguageAPISpecs from "api-specifications/language"; + +const meta: Meta = { + title: "ModelDirectory/AvailableLanguages", + component: AvailableLanguages, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const SingleLanguage: Story = { + args: { + availableLanguages: ["en"], + }, +}; + +export const MultipleLanguages: Story = { + args: { + availableLanguages: ["en", "am", "fr"], + }, +}; + +export const WithLabel: Story = { + args: { + availableLanguages: ["en", "fr"], + showLabel: true, + }, +}; + +export const WithLabelSingleLanguage: Story = { + args: { + availableLanguages: ["en"], + showLabel: true, + }, +}; + +export const AllRegistryLanguages: Story = { + args: { + availableLanguages: LanguageAPISpecs.Constants.Languages.map((language) => language.shortCode), + }, +}; diff --git a/frontend/src/modeldirectory/components/AvailableLanguages/AvailableLanguages.test.tsx b/frontend/src/modeldirectory/components/AvailableLanguages/AvailableLanguages.test.tsx new file mode 100644 index 000000000..e92a165b4 --- /dev/null +++ b/frontend/src/modeldirectory/components/AvailableLanguages/AvailableLanguages.test.tsx @@ -0,0 +1,166 @@ +// mute the console +import "src/_test_utilities/consoleMock"; + +import { render, screen, within } from "src/_test_utilities/test-utils"; +import AvailableLanguages, { DATA_TEST_ID, TEXT, getLanguageNames } from "./AvailableLanguages"; + +describe("AvailableLanguages", () => { + beforeEach(() => { + (console.error as jest.Mock).mockClear(); + (console.warn as jest.Mock).mockClear(); + }); + + test("should render the names of the available languages next to a language icon", () => { + // GIVEN the languages of a model available in three languages + const givenAvailableLanguages = ["en", "am", "fr"]; + + // WHEN the component is rendered + render(); + + // THEN expect no errors or warning to have occurred + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + // AND the names of the languages to be shown in the given order + const actualContainer = screen.getByTestId(DATA_TEST_ID.AVAILABLE_LANGUAGES); + expect(within(actualContainer).getByTestId(DATA_TEST_ID.AVAILABLE_LANGUAGES_NAMES)).toHaveTextContent( + /^English, Amharic, French$/ + ); + // AND no label to be shown + expect(within(actualContainer).queryByTestId(DATA_TEST_ID.AVAILABLE_LANGUAGES_LABEL)).not.toBeInTheDocument(); + // AND the icon to tell screen readers what the names are + expect(within(actualContainer).getByRole("img", { name: "Languages" })).toHaveAttribute( + "data-testid", + DATA_TEST_ID.AVAILABLE_LANGUAGES_ICON + ); + // AND to match the snapshot + expect(actualContainer).toMatchSnapshot(); + }); + + test("should render the label before the names of the available languages when the label is shown", () => { + // GIVEN the languages of a model available in two languages + const givenAvailableLanguages = ["en", "fr"]; + // AND the plural label expected for more than one language + const expectedLabel = "Languages:"; + + // WHEN the component is rendered with the label shown + render(); + + // THEN expect no errors or warning to have occurred + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + // AND the label to be shown + const actualContainer = screen.getByTestId(DATA_TEST_ID.AVAILABLE_LANGUAGES); + expect(within(actualContainer).getByTestId(DATA_TEST_ID.AVAILABLE_LANGUAGES_LABEL)).toHaveTextContent( + expectedLabel + ); + // AND the names of the languages to be shown after the label + expect(actualContainer).toHaveTextContent(`${expectedLabel}English, French`); + // AND the icon to be hidden from screen readers, as the label already tells what the names are + expect(within(actualContainer).getByTestId(DATA_TEST_ID.AVAILABLE_LANGUAGES_ICON)).toHaveAttribute( + "aria-hidden", + "true" + ); + // AND to match the snapshot + expect(actualContainer).toMatchSnapshot(); + }); + + test("should use the singular for the label of a single available language", () => { + // GIVEN the language of a model available in a single language + const givenAvailableLanguages = ["en"]; + + // WHEN the component is rendered with the label shown + render(); + + // THEN expect the label to be in the singular + expect(screen.getByTestId(DATA_TEST_ID.AVAILABLE_LANGUAGES_LABEL)).toHaveTextContent(/^Language:$/); + // AND the name of the language to be shown + expect(screen.getByTestId(DATA_TEST_ID.AVAILABLE_LANGUAGES_NAMES)).toHaveTextContent(/^English$/); + }); + + test("should use the singular for the icon title of a single available language", () => { + // GIVEN the language of a model available in a single language + const givenAvailableLanguages = ["en"]; + + // WHEN the component is rendered without the label + render(); + + // THEN expect the icon to tell screen readers that the name is a language, in the singular + expect(screen.getByRole("img", { name: "Language" })).toHaveAttribute( + "data-testid", + DATA_TEST_ID.AVAILABLE_LANGUAGES_ICON + ); + }); + + test("should render nothing when there are no available languages", () => { + // GIVEN no available languages + const givenAvailableLanguages: string[] = []; + + // WHEN the component is rendered + const { container } = render(); + + // THEN expect no errors or warning to have occurred + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + // AND nothing to be rendered + expect(container).toBeEmptyDOMElement(); + }); + + test("should use the given data-testid for the container", () => { + // GIVEN a custom data-testid + const givenTestId = "foo"; + + // WHEN the component is rendered with the given data-testid + render(); + + // THEN expect the container to have the given data-testid + expect(screen.getByTestId(givenTestId)).toHaveTextContent("English, French"); + }); +}); + +describe("TEXT.getLanguagesTitle", () => { + test.each([ + [1, "Language"], + [2, "Languages"], + [5, "Languages"], + ])("should return the title for %s language(s) as '%s'", (givenCount, expectedTitle) => { + // GIVEN a number of languages + // WHEN getting the title + const actualTitle = TEXT.getLanguagesTitle(givenCount); + + // THEN expect the singular for one language and the plural otherwise + expect(actualTitle).toBe(expectedTitle); + }); +}); + +describe("getLanguageNames", () => { + test("should return the registry names of the languages in the given order", () => { + // GIVEN the short codes of languages in the registry + const givenAvailableLanguages = ["fr", "en", "es"]; + + // WHEN resolving their names + const actualNames = getLanguageNames(givenAvailableLanguages); + + // THEN expect the registry names, in the given order + expect(actualNames).toBe("French, English, Spanish"); + }); + + test("should name a language that is not in the registry by its short code", () => { + // GIVEN a short code of a language that is not in the registry + const givenShortCode = "xx"; + + // WHEN resolving the names + const actualNames = getLanguageNames(["en", givenShortCode]); + + // THEN expect the unknown language to be named by its short code + expect(actualNames).toBe(`English, ${givenShortCode}`); + }); + + test("should return an empty string when there are no languages", () => { + // GIVEN no languages + // WHEN resolving their names + const actualNames = getLanguageNames([]); + + // THEN expect an empty string + expect(actualNames).toBe(""); + }); +}); diff --git a/frontend/src/modeldirectory/components/AvailableLanguages/AvailableLanguages.tsx b/frontend/src/modeldirectory/components/AvailableLanguages/AvailableLanguages.tsx new file mode 100644 index 000000000..0c9806a35 --- /dev/null +++ b/frontend/src/modeldirectory/components/AvailableLanguages/AvailableLanguages.tsx @@ -0,0 +1,89 @@ +import * as React from "react"; +import { Box, Typography, useTheme } from "@mui/material"; +import LanguageIcon from "@mui/icons-material/Language"; +import LanguageAPISpecs from "api-specifications/language"; + +export interface AvailableLanguagesProps { + availableLanguages: string[]; + // show "Language:" or "Languages:" before the names, which are then emphasized + showLabel?: boolean; + "data-testid"?: string; +} + +const uniqueId = "ca1f70b2-a487-4d07-b49d-22ce6e282759"; +export const DATA_TEST_ID = { + AVAILABLE_LANGUAGES: `available-languages-${uniqueId}`, + AVAILABLE_LANGUAGES_ICON: `available-languages-icon-${uniqueId}`, + AVAILABLE_LANGUAGES_LABEL: `available-languages-label-${uniqueId}`, + AVAILABLE_LANGUAGES_NAMES: `available-languages-names-${uniqueId}`, +}; + +export const TEXT = { + getLanguagesTitle: (count: number) => (count === 1 ? "Language" : "Languages"), +}; + +/** + * Resolves the human readable names of languages from the language registry, e.g. "English, French" for ["en", "fr"]. + * @param availableLanguages the short codes of the languages, e.g. ["en", "fr"] + * @returns the registry names of the languages in the given order, a language that is not in the registry is named by + * its short code + */ +export function getLanguageNames(availableLanguages: string[]): string { + return availableLanguages + .map((shortCode) => LanguageAPISpecs.Helpers.getLanguageByShortCode(shortCode)?.name ?? shortCode) + .join(", "); +} + +/** + * Shows the names of the languages a model carries data in, next to a language icon, e.g. "🌐 English, French". + * The names are written out in full, so that they are not confused with the model locale, which is shown by its + * short code. + */ +const AvailableLanguages = (props: Readonly) => { + const theme = useTheme(); + if (props.availableLanguages.length === 0) { + return null; + } + const title = TEXT.getLanguagesTitle(props.availableLanguages.length); + + return ( + + + + {props.showLabel && ( + + {`${title}:`} + + )} + + {getLanguageNames(props.availableLanguages)} + + + + ); +}; + +export default AvailableLanguages; diff --git a/frontend/src/modeldirectory/components/AvailableLanguages/__snapshots__/AvailableLanguages.test.tsx.snap b/frontend/src/modeldirectory/components/AvailableLanguages/__snapshots__/AvailableLanguages.test.tsx.snap new file mode 100644 index 000000000..e05a03d71 --- /dev/null +++ b/frontend/src/modeldirectory/components/AvailableLanguages/__snapshots__/AvailableLanguages.test.tsx.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AvailableLanguages should render the label before the names of the available languages when the label is shown 1`] = ` +
+ + + + Languages: + + + English, French + + +
+`; + +exports[`AvailableLanguages should render the names of the available languages next to a language icon 1`] = ` +
+ + + + Languages + + + + + English, Amharic, French + + +
+`; diff --git a/frontend/src/modeldirectory/components/ModelProperties/components/ModelPropertiesContent/components/ModelPropertiesDescription/ModelPropertiesDescription.stories.tsx b/frontend/src/modeldirectory/components/ModelProperties/components/ModelPropertiesContent/components/ModelPropertiesDescription/ModelPropertiesDescription.stories.tsx index c2edb3570..db22e6e7c 100644 --- a/frontend/src/modeldirectory/components/ModelProperties/components/ModelPropertiesContent/components/ModelPropertiesDescription/ModelPropertiesDescription.stories.tsx +++ b/frontend/src/modeldirectory/components/ModelProperties/components/ModelPropertiesContent/components/ModelPropertiesDescription/ModelPropertiesDescription.stories.tsx @@ -16,3 +16,9 @@ export default meta; type Story = StoryObj; export const Shown: Story = {}; + +export const ShownWithMultipleLanguages: Story = { + args: { + model: { ...getOneFakeModel(1), availableLanguages: ["en", "am", "fr"] }, + }, +}; diff --git a/frontend/src/modeldirectory/components/ModelProperties/components/ModelPropertiesContent/components/ModelPropertiesDescription/ModelPropertiesDescription.test.tsx b/frontend/src/modeldirectory/components/ModelProperties/components/ModelPropertiesContent/components/ModelPropertiesDescription/ModelPropertiesDescription.test.tsx index 29895c28d..a3905aedd 100644 --- a/frontend/src/modeldirectory/components/ModelProperties/components/ModelPropertiesContent/components/ModelPropertiesDescription/ModelPropertiesDescription.test.tsx +++ b/frontend/src/modeldirectory/components/ModelProperties/components/ModelPropertiesContent/components/ModelPropertiesDescription/ModelPropertiesDescription.test.tsx @@ -33,6 +33,11 @@ jest.mock("src/theme/PropertyFieldLayout/MarkdownPropertyField/MarkdownPropertyF }); describe("ModelPropertiesDescription", () => { + beforeEach(() => { + (console.error as jest.Mock).mockClear(); + (console.warn as jest.Mock).mockClear(); + }); + test("should render correctly with the provided model props", () => { // GIVEN a model const givenModel = fakeModel; @@ -72,6 +77,19 @@ describe("ModelPropertiesDescription", () => { }, {} ); + // AND the available languages property to be shown + const availableLanguagesComponent = screen.getByTestId(DATA_TEST_ID.MODEL_PROPERTIES_AVAILABLE_LANGUAGES); + expect(availableLanguagesComponent).toBeInTheDocument(); + // AND the TextPropertyField component to be called with the name of the language, under a singular label + expect(TextPropertyField).toHaveBeenCalledWith( + { + label: FIELD_LABEL_TEXT.LABEL_LANGUAGE, + text: "English", + "data-testid": DATA_TEST_ID.MODEL_PROPERTIES_AVAILABLE_LANGUAGES, + fieldId: FIELD_ID.AVAILABLE_LANGUAGES, + }, + {} + ); // AND the description property to be shown const descriptionItem = screen.getByTestId(DATA_TEST_ID.MODEL_PROPERTIES_DESCRIPTION); expect(descriptionItem).toBeInTheDocument(); @@ -89,6 +107,29 @@ describe("ModelPropertiesDescription", () => { expect(modelPropertiesDescriptionContainer).toMatchSnapshot(); }); + test("should show the names of every available language of a multilingual model", () => { + // GIVEN a model available in English, Amharic and French + const givenModel = { ...fakeModel, availableLanguages: ["en", "am", "fr"] }; + + // WHEN the ModelPropertiesDescription is rendered with the given model + render(); + + // THEN expect no errors or warning to have occurred + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + // AND the TextPropertyField component to be called with the names of the languages, in the model's order, + // under a plural label + expect(TextPropertyField).toHaveBeenCalledWith( + { + label: FIELD_LABEL_TEXT.LABEL_LANGUAGES, + text: "English, Amharic, French", + "data-testid": DATA_TEST_ID.MODEL_PROPERTIES_AVAILABLE_LANGUAGES, + fieldId: FIELD_ID.AVAILABLE_LANGUAGES, + }, + {} + ); + }); + describe( // eslint-disable-next-line jest/valid-describe-callback,jest/valid-title authorizationTests.defaultName, @@ -100,6 +141,7 @@ describe("ModelPropertiesDescription", () => { DATA_TEST_ID.MODEL_PROPERTIES_DESCRIPTION_CONTAINER, DATA_TEST_ID.MODEL_PROPERTIES_NAME, DATA_TEST_ID.MODEL_PROPERTIES_LOCALE, + DATA_TEST_ID.MODEL_PROPERTIES_AVAILABLE_LANGUAGES, DATA_TEST_ID.MODEL_PROPERTIES_DESCRIPTION, ], }) diff --git a/frontend/src/modeldirectory/components/ModelProperties/components/ModelPropertiesContent/components/ModelPropertiesDescription/ModelPropertiesDescription.tsx b/frontend/src/modeldirectory/components/ModelProperties/components/ModelPropertiesContent/components/ModelPropertiesDescription/ModelPropertiesDescription.tsx index 58fe438dd..d5c34b1e0 100644 --- a/frontend/src/modeldirectory/components/ModelProperties/components/ModelPropertiesContent/components/ModelPropertiesDescription/ModelPropertiesDescription.tsx +++ b/frontend/src/modeldirectory/components/ModelProperties/components/ModelPropertiesContent/components/ModelPropertiesDescription/ModelPropertiesDescription.tsx @@ -4,6 +4,7 @@ import Box from "@mui/material/Box"; import { useTheme } from "@mui/material"; import TextPropertyField from "src/theme/PropertyFieldLayout/TextPropertyField/TextPropertyField"; import MarkdownPropertyField from "src/theme/PropertyFieldLayout/MarkdownPropertyField/MarkdownPropertyField"; +import { getLanguageNames } from "src/modeldirectory/components/AvailableLanguages/AvailableLanguages"; export interface ModelPropertiesDescriptionProps { model: ModelInfoTypes.ModelInfo; @@ -15,18 +16,22 @@ export const DATA_TEST_ID = { MODEL_PROPERTIES_DESCRIPTION_CONTAINER: `model-properties-description-container-${uniqueId}`, MODEL_PROPERTIES_NAME: `model-properties-name-${uniqueId}`, MODEL_PROPERTIES_LOCALE: `model-properties-locale-${uniqueId}`, + MODEL_PROPERTIES_AVAILABLE_LANGUAGES: `model-properties-available-languages-${uniqueId}`, MODEL_PROPERTIES_DESCRIPTION: `model-properties-description-${uniqueId}`, }; export const FIELD_ID = { NAME: `name-${uniqueId}`, LOCALE: `locale-${uniqueId}`, + AVAILABLE_LANGUAGES: `available-languages-${uniqueId}`, DESCRIPTION: `description-${uniqueId}`, }; export const FIELD_LABEL_TEXT = { LABEL_NAME: "Name", LABEL_LOCALE: "Locale", + LABEL_LANGUAGE: "Language", + LABEL_LANGUAGES: "Languages", LABEL_DESCRIPTION: "Description", }; @@ -60,6 +65,16 @@ const ModelPropertiesDescription: React.FC = ( data-testid={DATA_TEST_ID.MODEL_PROPERTIES_LOCALE} fieldId={FIELD_ID.LOCALE} /> + Text Property Field Mock +
+ Text Property Field Mock +
{ // AND the subtitle to be shown expect(screen.getByTestId(DATA_TEST_ID.MODEL_CARD_SUBTITLE)).toHaveTextContent(getCardSubtitle(givenGroup)); + // AND the languages of the latest model to be shown + expect(screen.getByTestId(DATA_TEST_ID.MODEL_CARD_AVAILABLE_LANGUAGES)).toHaveTextContent("Language:English"); + // AND the description of the latest model to be shown expect(screen.getByTestId(DATA_TEST_ID.MODEL_CARD_DESCRIPTION)).toHaveTextContent( givenGroup.latestModel.description @@ -127,6 +130,34 @@ describe("ModelCard", () => { expect(actualCard).toMatchSnapshot(); }); + test("should render the languages of the latest model when the versions carry different languages", () => { + // GIVEN a group whose latest model is available in English and French + const locale = { UUID: getMockUUID(5002), name: "Kenya", shortCode: "KE" }; + const givenLatestModel = getOneDeterministicFakeModel(1, { + locale, + availableLanguages: ["en", "fr"], + createdAt: new Date("2023-03-01T00:00:00.000Z"), + }); + // AND an older model available only in English + const givenOlderModel = getOneDeterministicFakeModel(2, { + locale, + availableLanguages: ["en"], + createdAt: new Date("2023-02-01T00:00:00.000Z"), + }); + const givenGroup = groupModelsByLocale([givenOlderModel, givenLatestModel])[0]; + + // WHEN the component is rendered + renderModelCard(givenGroup); + + // THEN expect no errors or warning to have occurred + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + // AND the languages of the latest model to be shown with their label + expect(screen.getByTestId(DATA_TEST_ID.MODEL_CARD_AVAILABLE_LANGUAGES)).toHaveTextContent( + "Languages:English, French" + ); + }); + test("should render one version row per model with the correct props", () => { // GIVEN a group with three versions const givenGroup = getTestGroup(); diff --git a/frontend/src/modeldirectory/components/ModelsCardList/components/ModelCard/ModelCard.tsx b/frontend/src/modeldirectory/components/ModelsCardList/components/ModelCard/ModelCard.tsx index 84cc44a31..25942f136 100644 --- a/frontend/src/modeldirectory/components/ModelsCardList/components/ModelCard/ModelCard.tsx +++ b/frontend/src/modeldirectory/components/ModelsCardList/components/ModelCard/ModelCard.tsx @@ -5,6 +5,7 @@ import { TaxonomyGroup } from "src/modeldirectory/components/ModelsCardList/grou import ImportProcessStateIcon from "src/modeldirectory/components/ImportProcessStateIcon/ImportProcessStateIcon"; import MarkdownPropertyField from "src/theme/PropertyFieldLayout/MarkdownPropertyField/MarkdownPropertyField"; import VersionRow from "../VersionRow/VersionRow"; +import AvailableLanguages from "src/modeldirectory/components/AvailableLanguages/AvailableLanguages"; export interface ModelCardProps { group: TaxonomyGroup; @@ -22,6 +23,7 @@ export const DATA_TEST_ID = { MODEL_CARD_STATUS_ICON_CONTAINER: `model-card-status-icon-container-${uniqueId}`, MODEL_CARD_TITLE: `model-card-title-${uniqueId}`, MODEL_CARD_SUBTITLE: `model-card-subtitle-${uniqueId}`, + MODEL_CARD_AVAILABLE_LANGUAGES: `model-card-available-languages-${uniqueId}`, MODEL_CARD_DESCRIPTION: `model-card-description-${uniqueId}`, MODEL_CARD_VERSIONS_COUNT: `model-card-versions-count-${uniqueId}`, MODEL_CARD_DETAILS: `model-card-details-${uniqueId}`, @@ -81,8 +83,8 @@ export function getVersionsCountText(count: number): string { /** * An expandable card for a model (grouped by locale). - * The collapsed header shows the status, title, subtitle (model name), description and number of versions, - * the expanded card shows one row per version. + * The collapsed header shows the status, title, subtitle (model name), languages of the latest model, + * description and number of versions, the expanded card shows one row per version. */ const ModelCard = (props: Readonly) => { const [isExpanded, setIsExpanded] = React.useState(false); @@ -165,6 +167,13 @@ const ModelCard = (props: Readonly) => { > {getCardSubtitle(props.group)} + theme.tabiyaSpacing.xs}> + + theme.tabiyaSpacing.xs} // links in the description should not toggle the accordion when clicked diff --git a/frontend/src/modeldirectory/components/ModelsCardList/components/ModelCard/__snapshots__/ModelCard.test.tsx.snap b/frontend/src/modeldirectory/components/ModelsCardList/components/ModelCard/__snapshots__/ModelCard.test.tsx.snap index 980f7565a..2e2035af8 100644 --- a/frontend/src/modeldirectory/components/ModelsCardList/components/ModelCard/__snapshots__/ModelCard.test.tsx.snap +++ b/frontend/src/modeldirectory/components/ModelsCardList/components/ModelCard/__snapshots__/ModelCard.test.tsx.snap @@ -43,6 +43,40 @@ exports[`ModelCard should render the collapsed card with the model summary 1`] = > Localized for the South Africa labour market

+
+
+ + + + Language: + + + English + + +
+
{ + const model = getDownloadableModel(); + model.availableLanguages = ["en", "am", "fr"]; + return model; + })(), + isLatest: true, + isModelManager: false, + }, +}; diff --git a/frontend/src/modeldirectory/components/ModelsCardList/components/VersionRow/VersionRow.test.tsx b/frontend/src/modeldirectory/components/ModelsCardList/components/VersionRow/VersionRow.test.tsx index fe146d896..22a2d847f 100644 --- a/frontend/src/modeldirectory/components/ModelsCardList/components/VersionRow/VersionRow.test.tsx +++ b/frontend/src/modeldirectory/components/ModelsCardList/components/VersionRow/VersionRow.test.tsx @@ -1,7 +1,7 @@ // mute the console import "src/_test_utilities/consoleMock"; -import { render, screen } from "src/_test_utilities/test-utils"; +import { render, screen, within } from "src/_test_utilities/test-utils"; import userEvent from "@testing-library/user-event"; import VersionRow, { DATA_TEST_ID, @@ -22,6 +22,7 @@ import ExportProcessStateAPISpecs from "api-specifications/exportProcessState"; import { ModelInfoTypes } from "src/modelInfo/modelInfoTypes"; import { DATA_TEST_ID as APPROVE_MODAL_DATA_TEST_ID } from "src/theme/ApproveModal/ApproveModal"; import { routerPaths } from "src/app/routerPaths"; +import { DATA_TEST_ID as AVAILABLE_LANGUAGES_DATA_TEST_ID } from "src/modeldirectory/components/AvailableLanguages/AvailableLanguages"; // mock the ImportProcessStateIcon component jest.mock("src/modeldirectory/components/ImportProcessStateIcon/ImportProcessStateIcon", () => { @@ -160,6 +161,43 @@ describe("VersionRow", () => { expect(screen.queryByTestId(DATA_TEST_ID.RELEASE_CANDIDATE_CHIP)).not.toBeInTheDocument(); }); + describe("available languages", () => { + test("should render the names of the available languages below the version for a multilingual model", () => { + // GIVEN a model available in English, Amharic and French + const givenModel = getOneDeterministicFakeModel(1, { availableLanguages: ["en", "am", "fr"] }); + + // WHEN the component is rendered + setupVersionRow({ model: givenModel, isLatest: true }); + + // THEN expect no errors or warning to have occurred + expect(console.error).not.toHaveBeenCalled(); + expect(console.warn).not.toHaveBeenCalled(); + // AND the names of the available languages to be shown + expect(screen.getByTestId(DATA_TEST_ID.AVAILABLE_LANGUAGES)).toHaveTextContent("English, Amharic, French"); + // AND the locale chip to still show only the locale short code + expect(screen.getByTestId(DATA_TEST_ID.LOCALE_CHIP)).toHaveTextContent( + new RegExp(`^${givenModel.locale.shortCode}$`) + ); + // AND the row to match the snapshot + expect(screen.getByTestId(DATA_TEST_ID.VERSION_ROW)).toMatchSnapshot(); + }); + + test("should render the name of the language of a single language model", () => { + // GIVEN a model available in a single language + const givenModel = getOneDeterministicFakeModel(1, { availableLanguages: ["en"] }); + + // WHEN the component is rendered + setupVersionRow({ model: givenModel }); + + // THEN expect the name of the language to be shown + expect( + within(screen.getByTestId(DATA_TEST_ID.AVAILABLE_LANGUAGES)).getByTestId( + AVAILABLE_LANGUAGES_DATA_TEST_ID.AVAILABLE_LANGUAGES_NAMES + ) + ).toHaveTextContent(/^English$/); + }); + }); + test("should notify on explore when the explore button is clicked", async () => { // GIVEN a rendered version row const givenModel = setupVersionRow({}); diff --git a/frontend/src/modeldirectory/components/ModelsCardList/components/VersionRow/VersionRow.tsx b/frontend/src/modeldirectory/components/ModelsCardList/components/VersionRow/VersionRow.tsx index 2e190762f..02df70df2 100644 --- a/frontend/src/modeldirectory/components/ModelsCardList/components/VersionRow/VersionRow.tsx +++ b/frontend/src/modeldirectory/components/ModelsCardList/components/VersionRow/VersionRow.tsx @@ -16,6 +16,7 @@ import ExportProcessStateAPISpecs from "api-specifications/exportProcessState"; import ImportProcessStateAPISpecs from "api-specifications/importProcessState"; import { routerPaths } from "src/app/routerPaths"; import { useNavigate } from "react-router-dom"; +import AvailableLanguages from "src/modeldirectory/components/AvailableLanguages/AvailableLanguages"; export interface VersionRowProps { model: ModelInfoTypes.ModelInfo; @@ -33,6 +34,7 @@ export const DATA_TEST_ID = { VERSION_TEXT: `version-text-${uniqueId}`, LATEST_CHIP: `latest-chip-${uniqueId}`, LOCALE_CHIP: `locale-chip-${uniqueId}`, + AVAILABLE_LANGUAGES: `available-languages-${uniqueId}`, RELEASE_CANDIDATE_CHIP: `release-candidate-chip-${uniqueId}`, IMPORT_STATE_ICON_CONTAINER: `import-state-icon-container-${uniqueId}`, EXPORT_STATE_ICON_CONTAINER: `export-state-icon-container-${uniqueId}`, @@ -160,68 +162,74 @@ const VersionRow = (props: Readonly) => { paddingRight={theme.fixedSpacing(theme.tabiyaSpacing.lg)} data-testid={DATA_TEST_ID.VERSION_ROW} > - - + - {versionLabel} - - {props.isLatest && ( - - )} - {!props.model.released && ( + + {versionLabel} + + {props.isLatest && ( + + )} + {!props.model.released && ( + + )} - )} - + + + )} + {props.isModelManager && !latestSuccessfulExport && latestExport && ( + + + + )} + + - {props.isModelManager && ( - - - - )} - {props.isModelManager && !latestSuccessfulExport && latestExport && ( - - - - )}
-

- v1.0.0 -

+

+ v1.0.0 +

+
+ + latest + +
+
+ + ZA + +
+
+ + + + Languages + + + + + English, Amharic, French + + +
+
+
+ + + + + + + CSV + + + +
+
+`; + +exports[`VersionRow should render the version row with all the common elements 1`] = ` +
+
+
+

+ v1.0.0 +

+
+ + latest + +
+
+ + ZA + +
- - ZA + + + Language + + + + + English +