Skip to content

Commit 9f9b5db

Browse files
authored
feat(portal): translation of flavorsUI and errors (#350)
* feat(portal): added flavor translations * feat(portal): added flavor translations * fix(portal): fixed tests * fix(portal): change import * fix(core): all t trans * chore(portal): extract and compile lingui
1 parent 31ae1dd commit 9f9b5db

13 files changed

Lines changed: 386 additions & 63 deletions

File tree

apps/aurora-portal/src/client/routes/_auth/accounts/$accountId/projects/$projectId/compute/-components/Flavors/-components/FilterToolbar.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useState } from "react"
22
import { Stack, Select, SelectOption, InputGroup, SearchInput } from "@cloudoperators/juno-ui-components"
3+
import { useLingui } from "@lingui/react/macro"
34

45
interface FilterToolbarProps {
56
searchTerm: string
@@ -18,6 +19,7 @@ const FilterToolbar: React.FC<FilterToolbarProps> = ({
1819
sortDirection,
1920
handleSortDirectionChange,
2021
}) => {
22+
const { t } = useLingui()
2123
const [debounceTimer, setDebounceTimer] = useState<number | undefined>(undefined)
2224

2325
const handleSearchChange = (value: React.ChangeEvent<HTMLInputElement>) => {
@@ -45,7 +47,7 @@ const FilterToolbar: React.FC<FilterToolbarProps> = ({
4547
<Stack direction="vertical" gap="3" className="w-full">
4648
<Stack gap="6" className="flex flex-row items-center flex-wrap w-full">
4749
<SearchInput
48-
placeholder="Enter search term or regex"
50+
placeholder={t`Enter search term or regex`}
4951
value={searchTerm || ""}
5052
className="w-full md:w-80 flex-shrink-0"
5153
onInput={handleSearchChange}
@@ -54,18 +56,18 @@ const FilterToolbar: React.FC<FilterToolbarProps> = ({
5456
/>
5557
<Stack className="flex flex-row items-center">
5658
<InputGroup className="flex-shrink-0 w-full md:w-80">
57-
<Select onChange={handleSortByChange} value={sortBy} data-testid="sort-select">
58-
<SelectOption value="name">Name</SelectOption>
59-
<SelectOption value="vcpus">VCPUs</SelectOption>
60-
<SelectOption value="ram">RAM</SelectOption>
61-
<SelectOption value="disk">Root Disk</SelectOption>
62-
<SelectOption value="OS-FLV-EXT-DATA:ephemeral">Ephemeral Disk</SelectOption>
63-
<SelectOption value="swap">Swap</SelectOption>
64-
<SelectOption value="rxtx_factor">RX/TX Factor</SelectOption>
59+
<Select onChange={handleSortByChange} value={sortBy} data-testid="sort-select" label={t`sort by`}>
60+
<SelectOption value="name">{t`Name`}</SelectOption>
61+
<SelectOption value="vcpus">{t`VCPUs`}</SelectOption>
62+
<SelectOption value="ram">{t`RAM`}</SelectOption>
63+
<SelectOption value="disk">{t`Root Disk`}</SelectOption>
64+
<SelectOption value="OS-FLV-EXT-DATA:ephemeral">{t`Ephemeral Disk`}</SelectOption>
65+
<SelectOption value="swap">{t`Swap`}</SelectOption>
66+
<SelectOption value="rxtx_factor">{t`RX/TX Factor`}</SelectOption>
6567
</Select>
6668
<Select onChange={handleSortDirectionChange} value={sortDirection} data-testid="direction-select">
67-
<SelectOption value="asc">Ascending</SelectOption>
68-
<SelectOption value="desc">Descending</SelectOption>
69+
<SelectOption value="asc">{t`Ascending`}</SelectOption>
70+
<SelectOption value="desc">{t`Descending`}</SelectOption>
6971
</Select>
7072
</InputGroup>
7173
</Stack>
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
1-
import React from "react"
2-
import { render, screen } from "@testing-library/react"
3-
import { describe, it, expect, vi } from "vitest"
1+
import { render, screen, act } from "@testing-library/react"
2+
import { describe, it, expect, beforeAll } from "vitest"
43
import { FlavorListContainer } from "./FlavorListContainer"
54
import { Flavor } from "@/server/Compute/types/flavor"
5+
import { I18nProvider } from "@lingui/react"
6+
import { ReactNode } from "react"
7+
import { i18n } from "@lingui/core"
8+
9+
const TestingProvider = ({ children }: { children: ReactNode }) => <I18nProvider i18n={i18n}>{children}</I18nProvider>
610

711
describe("FlavorListContainer", () => {
12+
beforeAll(async () => {
13+
await act(async () => {
14+
i18n.activate("en")
15+
})
16+
})
17+
818
const mockFlavors: Flavor[] = [
919
{
1020
id: "1",
@@ -28,37 +38,59 @@ describe("FlavorListContainer", () => {
2838
},
2939
]
3040

31-
beforeEach(() => {
32-
vi.clearAllMocks()
33-
vi.useFakeTimers()
34-
})
41+
it("renders loading message when isLoading is true", async () => {
42+
await act(async () => {
43+
render(<FlavorListContainer isLoading={true} />, { wrapper: TestingProvider })
44+
})
3545

36-
afterEach(() => {
37-
vi.useRealTimers()
46+
// Use findByText for async rendering
47+
expect(await screen.findByText("Loading...")).toBeInTheDocument()
48+
expect(screen.queryByText("No flavors found")).not.toBeInTheDocument()
49+
expect(screen.queryByText("Name")).not.toBeInTheDocument()
3850
})
3951

40-
it("renders loading message when isLoading is true", () => {
41-
render(<FlavorListContainer isLoading={true} />)
42-
expect(screen.getByTestId("loading")).toBeInTheDocument()
43-
expect(screen.queryByTestId("no-flavors")).not.toBeInTheDocument()
44-
expect(screen.queryByTestId("flavors-table")).not.toBeInTheDocument()
45-
})
52+
it("renders no flavors message when flavors is empty", async () => {
53+
await act(async () => {
54+
render(<FlavorListContainer flavors={[]} isLoading={false} />, { wrapper: TestingProvider })
55+
})
4656

47-
it("renders no flavors message when flavors is empty", () => {
48-
render(<FlavorListContainer flavors={[]} isLoading={false} />)
49-
expect(screen.queryByTestId("loading")).not.toBeInTheDocument()
50-
expect(screen.getByTestId("no-flavors")).toBeInTheDocument()
51-
expect(screen.queryByTestId("flavors-table")).not.toBeInTheDocument()
57+
expect(screen.queryByText("Loading...")).not.toBeInTheDocument()
58+
expect(await screen.findByText("No flavors found")).toBeInTheDocument()
59+
expect(await screen.findByText(/There are no flavors available for this project/)).toBeInTheDocument()
60+
expect(screen.queryByText("vCPU")).not.toBeInTheDocument()
5261
})
5362

54-
it("renders the flavors table when flavors are provided", () => {
55-
render(<FlavorListContainer flavors={mockFlavors} isLoading={false} />)
56-
expect(screen.queryByTestId("loading")).not.toBeInTheDocument()
57-
expect(screen.queryByTestId("no-flavors")).not.toBeInTheDocument()
58-
expect(screen.getByTestId("flavors-table")).toBeInTheDocument()
63+
it("renders no flavors message when flavors is undefined", async () => {
64+
await act(async () => {
65+
render(<FlavorListContainer flavors={undefined} isLoading={false} />, {
66+
wrapper: TestingProvider,
67+
})
68+
})
69+
70+
expect(screen.queryByText("Loading...")).not.toBeInTheDocument()
71+
expect(await screen.findByText("No flavors found")).toBeInTheDocument()
72+
expect(await screen.findByText(/There are no flavors available for this project/)).toBeInTheDocument()
73+
expect(screen.queryByText("vCPU")).not.toBeInTheDocument()
74+
})
5975

60-
mockFlavors.forEach((flavor) => {
61-
expect(screen.getByTestId(`flavor-row-${flavor.id}`)).toBeInTheDocument()
76+
it("renders the flavors table when flavors are provided", async () => {
77+
await act(async () => {
78+
render(<FlavorListContainer flavors={mockFlavors} isLoading={false} />, {
79+
wrapper: TestingProvider,
80+
})
6281
})
82+
83+
expect(screen.queryByText("Loading...")).not.toBeInTheDocument()
84+
expect(screen.queryByText("No flavors found")).not.toBeInTheDocument()
85+
86+
expect(screen.getByText("Name")).toBeInTheDocument()
87+
expect(screen.getByText("vCPU")).toBeInTheDocument()
88+
expect(screen.getByText("RAM (MiB)")).toBeInTheDocument()
89+
expect(screen.getByText("Root Disk (GiB)")).toBeInTheDocument()
90+
expect(screen.getByText("Ephemeral Disk (GiB)")).toBeInTheDocument()
91+
expect(screen.getByText("Swap (MiB)")).toBeInTheDocument()
92+
expect(screen.getByText("RX/TX Factor")).toBeInTheDocument()
93+
expect(screen.getByText("Flavor1")).toBeInTheDocument()
94+
expect(screen.getByText("Flavor2")).toBeInTheDocument()
6395
})
6496
})

apps/aurora-portal/src/client/routes/_auth/accounts/$accountId/projects/$projectId/compute/-components/Flavors/-components/FlavorListContainer.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
DataGridCell,
77
ContentHeading,
88
} from "@cloudoperators/juno-ui-components"
9+
import { Trans } from "@lingui/react/macro"
910

1011
interface FlavorListContainerProps {
1112
flavors?: Flavor[]
@@ -14,18 +15,26 @@ interface FlavorListContainerProps {
1415

1516
export const FlavorListContainer = ({ flavors, isLoading }: FlavorListContainerProps) => {
1617
if (isLoading) {
17-
return <div data-testid="loading">Loading flavors...</div>
18+
return (
19+
<div data-testid="loading">
20+
<Trans>Loading...</Trans>
21+
</div>
22+
)
1823
}
1924

2025
if (!flavors || flavors.length === 0) {
2126
return (
2227
<DataGrid columns={7} className="flavors" data-testid="no-flavors">
2328
<DataGridRow>
2429
<DataGridCell colSpan={7}>
25-
<ContentHeading>No flavors found</ContentHeading>
30+
<ContentHeading>
31+
<Trans>No flavors found</Trans>
32+
</ContentHeading>
2633
<p>
27-
There are no flavors available for this project with the current filters applied. Try adjusting your
28-
filter criteria or create a new flavor.
34+
<Trans>
35+
There are no flavors available for this project with the current filters applied. Try adjusting your
36+
filter criteria or create a new flavor.
37+
</Trans>
2938
</p>
3039
</DataGridCell>
3140
</DataGridRow>

apps/aurora-portal/src/client/routes/_auth/accounts/$accountId/projects/$projectId/compute/-components/Flavors/List.tsx

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { useState, useEffect } from "react"
2+
import { useLingui } from "@lingui/react/macro"
23
import { TrpcClient } from "@/client/trpcClient"
34
import { Flavor } from "@/server/Compute/types/flavor"
4-
import { Message } from "@cloudoperators/juno-ui-components"
5+
import { Message, Button } from "@cloudoperators/juno-ui-components"
6+
import { useErrorTranslation } from "@/client/utils/useErrorTranslation"
57
import FilterToolbar from "./-components/FilterToolbar"
68
import { FlavorListContainer } from "./-components/FlavorListContainer"
79

@@ -10,13 +12,47 @@ interface FlavorsProps {
1012
project: string
1113
}
1214

15+
interface ErrorState {
16+
message: string
17+
code: string
18+
isRetryable: boolean
19+
}
20+
1321
export const Flavors = ({ client, project }: FlavorsProps) => {
22+
const { t } = useLingui()
23+
const { translateError, isRetryableError } = useErrorTranslation()
24+
1425
const [sortBy, setSortBy] = useState("name")
1526
const [sortDirection, setSortDirection] = useState("asc")
1627
const [searchTerm, setSearchTerm] = useState("")
1728
const [flavors, setFlavors] = useState<Flavor[] | undefined>(undefined)
18-
const [error, setError] = useState<Error | undefined>(undefined)
29+
const [error, setError] = useState<ErrorState | undefined>(undefined)
1930
const [isLoading, setIsLoading] = useState(true)
31+
const [refetchTrigger, setRefetchTrigger] = useState(0)
32+
33+
const handleError = (err: unknown) => {
34+
console.error(err)
35+
36+
let errorCode = "UNKNOWN_ERROR"
37+
38+
if (err && typeof err === "object" && "message" in err) {
39+
const errorMessage = err.message
40+
if (typeof errorMessage === "string" && errorMessage.startsWith("FLAVORS_")) {
41+
errorCode = errorMessage
42+
}
43+
}
44+
45+
setError({
46+
message: translateError(errorCode),
47+
code: errorCode,
48+
isRetryable: isRetryableError(errorCode),
49+
})
50+
}
51+
52+
const retryFetch = () => {
53+
setError(undefined)
54+
setRefetchTrigger((prev) => prev + 1)
55+
}
2056

2157
useEffect(() => {
2258
const fetchFlavors = async () => {
@@ -31,15 +67,14 @@ export const Flavors = ({ client, project }: FlavorsProps) => {
3167
})
3268
setFlavors(data)
3369
} catch (err) {
34-
console.error(err)
35-
setError(err as Error)
70+
handleError(err)
3671
} finally {
3772
setIsLoading(false)
3873
}
3974
}
4075

4176
fetchFlavors()
42-
}, [client, project, sortBy, sortDirection, searchTerm])
77+
}, [client, project, sortBy, sortDirection, searchTerm, refetchTrigger])
4378

4479
const handleSortByChange = (value: string | number | string[] | undefined) => {
4580
if (value && typeof value === "string") {
@@ -54,7 +89,16 @@ export const Flavors = ({ client, project }: FlavorsProps) => {
5489
}
5590

5691
if (error) {
57-
return <Message text={error.message} variant="error" />
92+
return (
93+
<div className="error-container">
94+
<Message text={error.message} variant="error" />
95+
{error.isRetryable && (
96+
<Button onClick={retryFetch} variant="primary" className="mt-4">
97+
{t`Retry`}
98+
</Button>
99+
)}
100+
</div>
101+
)
58102
}
59103

60104
return (
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useLingui } from "@lingui/react/macro"
2+
3+
export const useErrorTranslation = () => {
4+
const { t } = useLingui()
5+
6+
const translateError = (errorCode: string): string => {
7+
switch (errorCode) {
8+
case "FLAVORS_UNAUTHORIZED":
9+
return t`Your session has expired. Please log in again.`
10+
case "FLAVORS_FORBIDDEN":
11+
return t`You don't have permission to access flavors for this project.`
12+
case "FLAVORS_NOT_FOUND":
13+
return t`Flavor service is not available for this project.`
14+
case "FLAVORS_SERVER_ERROR":
15+
return t`Server is experiencing issues. Please try again later.`
16+
case "FLAVORS_FETCH_FAILED":
17+
return t`Failed to fetch flavors from server.`
18+
case "FLAVORS_PARSE_ERROR":
19+
return t`Server returned unexpected data format.`
20+
default:
21+
return t`An unexpected error occurred. Please try again.`
22+
}
23+
}
24+
25+
const isRetryableError = (errorCode: string): boolean => {
26+
return ["FLAVORS_SERVER_ERROR", "FLAVORS_FETCH_FAILED"].includes(errorCode)
27+
}
28+
29+
return { translateError, isRetryableError }
30+
}

0 commit comments

Comments
 (0)