Skip to content

Commit 2907aa3

Browse files
authored
feat(portal): adding create and delete Modal to FlavorUI (#357)
* feat(portal): added flavor creation modal * feat(portal): feat(portal): implementing create flavor * feat(portal): test portal and helper * feat(portal): test portal and helper, added success message, added del * feat(portal): seperate validation * fix(portal): add import * fix(portal): fix import for build * feat(portal): exclude empty values and higher readbility * feat(portal): dont show empty swap
1 parent 8877e4f commit 2907aa3

23 files changed

Lines changed: 2847 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { render, screen, act, fireEvent } from "@testing-library/react"
2+
import { describe, it, expect, beforeAll } from "vitest"
3+
import { CreateFlavorModal } from "./CreateFlavorModal"
4+
import { TrpcClient } from "@/client/trpcClient"
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>
10+
11+
describe("CreateFlavorModal", () => {
12+
beforeAll(async () => {
13+
await act(async () => {
14+
i18n.activate("en")
15+
})
16+
})
17+
18+
const mockClient = {
19+
compute: {
20+
createFlavor: {
21+
mutate: vi.fn().mockResolvedValue({}),
22+
},
23+
},
24+
} as unknown as TrpcClient
25+
26+
it("renders the modal ", async () => {
27+
await act(async () => {
28+
render(
29+
<CreateFlavorModal
30+
client={mockClient}
31+
isOpen={true}
32+
onClose={vi.fn()}
33+
project="test-project"
34+
onSuccess={vi.fn()}
35+
/>,
36+
{
37+
wrapper: TestingProvider,
38+
}
39+
)
40+
})
41+
42+
expect(screen.getByLabelText("Flavor ID")).toBeInTheDocument()
43+
expect(screen.getByLabelText("Flavor Name")).toBeInTheDocument()
44+
expect(screen.getByLabelText("Description")).toBeInTheDocument()
45+
expect(screen.getByLabelText("VCPUs")).toBeInTheDocument()
46+
expect(screen.getByLabelText("RAM (MiB)")).toBeInTheDocument()
47+
expect(screen.getByLabelText("Disk (GiB)")).toBeInTheDocument()
48+
expect(screen.getByLabelText("Ephemeral Disk (GiB)")).toBeInTheDocument()
49+
expect(screen.getByLabelText("Swap (MiB)")).toBeInTheDocument()
50+
expect(screen.getByLabelText("RX/TX Factor")).toBeInTheDocument()
51+
})
52+
53+
it("submits the form with changed values and calls createFlavor", async () => {
54+
await act(async () => {
55+
render(
56+
<CreateFlavorModal
57+
client={mockClient}
58+
isOpen={true}
59+
onClose={vi.fn()}
60+
project="test-project"
61+
onSuccess={vi.fn()}
62+
/>,
63+
{
64+
wrapper: TestingProvider,
65+
}
66+
)
67+
})
68+
69+
fireEvent.change(screen.getByLabelText("Flavor ID"), { target: { value: "TestFlavor" } })
70+
fireEvent.change(screen.getByLabelText("Flavor Name"), { target: { value: "TestFlavor" } })
71+
fireEvent.change(screen.getByLabelText("Description"), { target: { value: "A test flavor" } })
72+
fireEvent.change(screen.getByLabelText("VCPUs"), { target: { value: "4" } })
73+
fireEvent.change(screen.getByLabelText("RAM (MiB)"), { target: { value: "2048" } })
74+
fireEvent.change(screen.getByLabelText("Disk (GiB)"), { target: { value: "20" } })
75+
76+
const submitButton = screen.getByText(/Create New Flavor/i)
77+
78+
await act(async () => {
79+
fireEvent.click(submitButton)
80+
})
81+
82+
expect(mockClient.compute.createFlavor.mutate).toHaveBeenCalledWith({
83+
projectId: "test-project",
84+
flavor: {
85+
id: "TestFlavor",
86+
name: "TestFlavor",
87+
description: "A test flavor",
88+
vcpus: 4,
89+
ram: 2048,
90+
disk: 20,
91+
},
92+
})
93+
})
94+
95+
it("displays error messages on invalid input", async () => {
96+
await act(async () => {
97+
render(
98+
<CreateFlavorModal
99+
client={mockClient}
100+
isOpen={true}
101+
onClose={vi.fn()}
102+
project="test-project"
103+
onSuccess={vi.fn()}
104+
/>,
105+
{
106+
wrapper: TestingProvider,
107+
}
108+
)
109+
})
110+
111+
fireEvent.change(screen.getByLabelText("Flavor Name"), { target: { value: "T" } })
112+
fireEvent.blur(screen.getByLabelText("Flavor Name"))
113+
114+
expect(screen.getByText("Name must be 2-50 characters long.")).toBeInTheDocument()
115+
})
116+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import React, { useState } from "react"
2+
import { useLingui } from "@lingui/react/macro"
3+
import { TrpcClient } from "@/client/trpcClient"
4+
import {
5+
Modal,
6+
Form,
7+
FormRow,
8+
FormSection,
9+
TextInput,
10+
Message,
11+
Spinner,
12+
Stack,
13+
} from "@cloudoperators/juno-ui-components"
14+
import { Flavor } from "@/server/Compute/types/flavor"
15+
import { validateField, FlavorFormField, FieldErrors } from "./flavorValidation"
16+
import { cleanFlavorData } from "./flavorValidation"
17+
18+
interface CreateFlavorModalProps {
19+
client: TrpcClient
20+
isOpen: boolean
21+
onClose: () => void
22+
project: string
23+
onSuccess: (name: string) => void
24+
}
25+
26+
export const CreateFlavorModal: React.FC<CreateFlavorModalProps> = ({
27+
client,
28+
isOpen,
29+
onClose,
30+
project,
31+
onSuccess,
32+
}) => {
33+
const { t } = useLingui()
34+
const [newFlavor, setNewFlavor] = useState<Partial<Flavor>>({})
35+
const [errors, setErrors] = useState<FieldErrors>({})
36+
const [isLoading, setIsLoading] = useState(false)
37+
const [generalError, setGeneralError] = useState<string | null>(null)
38+
39+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
40+
const { name, value } = e.target
41+
setNewFlavor((prev) => ({
42+
...prev,
43+
[name]: value,
44+
}))
45+
if (generalError) setGeneralError(null)
46+
}
47+
48+
const handleNumericInputChange = (name: FlavorFormField, value: number | undefined) => {
49+
setNewFlavor((prev) => ({
50+
...prev,
51+
[name]: value,
52+
}))
53+
if (generalError) setGeneralError(null)
54+
}
55+
56+
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
57+
const { name, value } = e.target
58+
const error = validateField(name as FlavorFormField, value, t)
59+
setErrors((prev) => ({
60+
...prev,
61+
[name]: error,
62+
}))
63+
}
64+
65+
const handleSubmit = async (e: React.FormEvent) => {
66+
e.preventDefault()
67+
setGeneralError(null)
68+
69+
const newErrors: FieldErrors = {}
70+
71+
const requiredFields: FlavorFormField[] = ["name", "vcpus", "ram", "disk"]
72+
requiredFields.forEach((key) => {
73+
const error = validateField(key, newFlavor[key], t)
74+
if (error) {
75+
newErrors[key] = error
76+
}
77+
})
78+
79+
const optionalFields: FlavorFormField[] = ["id", "swap", "OS-FLV-EXT-DATA:ephemeral", "rxtx_factor", "description"]
80+
optionalFields.forEach((key) => {
81+
const value = newFlavor[key]
82+
if (value !== undefined && value !== "" && value !== null) {
83+
const error = validateField(key, value, t)
84+
if (error) {
85+
newErrors[key] = error
86+
}
87+
}
88+
})
89+
90+
if (Object.keys(newErrors).length > 0) {
91+
setErrors(newErrors)
92+
setGeneralError(t`Please fix the validation errors below.`)
93+
return
94+
}
95+
96+
try {
97+
setIsLoading(true)
98+
99+
const flavorData = cleanFlavorData(newFlavor)
100+
101+
await client.compute.createFlavor.mutate({
102+
projectId: project,
103+
flavor: flavorData,
104+
})
105+
106+
onSuccess(flavorData.name)
107+
handleClose()
108+
} catch (error) {
109+
console.error(error)
110+
setGeneralError(t`Failed to create flavor. Please try again.`)
111+
} finally {
112+
setIsLoading(false)
113+
}
114+
}
115+
116+
const handleClose = () => {
117+
setNewFlavor({})
118+
setErrors({})
119+
setGeneralError(null)
120+
onClose()
121+
}
122+
123+
const dismissError = () => {
124+
setGeneralError(null)
125+
}
126+
127+
return (
128+
<Modal
129+
onCancel={handleClose}
130+
size="large"
131+
title={t`Create Flavor`}
132+
open={isOpen}
133+
onConfirm={handleSubmit}
134+
cancelButtonLabel={t`Cancel`}
135+
confirmButtonLabel={t`Create New Flavor`}
136+
>
137+
{isLoading && (
138+
<Stack distribution="center" alignment="center">
139+
<Spinner variant="primary" />
140+
</Stack>
141+
)}
142+
{!isLoading && (
143+
<Form>
144+
{generalError && (
145+
<FormRow>
146+
<Message onDismiss={dismissError} text={generalError} variant="error" />
147+
</FormRow>
148+
)}
149+
150+
<FormSection>
151+
<FormRow>
152+
<TextInput
153+
id="id"
154+
name="id"
155+
label={t`Flavor ID`}
156+
value={newFlavor.id || ""}
157+
onChange={handleInputChange}
158+
onBlur={handleBlur}
159+
errortext={errors.id}
160+
/>
161+
</FormRow>
162+
<FormRow>
163+
<TextInput
164+
id="name"
165+
name="name"
166+
label={t`Flavor Name`}
167+
value={newFlavor.name || ""}
168+
onChange={handleInputChange}
169+
onBlur={handleBlur}
170+
errortext={errors.name}
171+
required
172+
/>
173+
</FormRow>
174+
<FormRow>
175+
<TextInput
176+
id="description"
177+
name="description"
178+
label={t`Description`}
179+
value={newFlavor.description || ""}
180+
onChange={handleInputChange}
181+
onBlur={handleBlur}
182+
errortext={errors.description}
183+
/>
184+
</FormRow>
185+
<FormRow>
186+
<TextInput
187+
id="vcpus"
188+
name="vcpus"
189+
label={t`VCPUs`}
190+
value={String(newFlavor.vcpus || "")}
191+
onChange={(e) => handleNumericInputChange("vcpus", Number(e.target.value))}
192+
onBlur={handleBlur}
193+
errortext={errors.vcpus}
194+
type="number"
195+
required
196+
/>
197+
</FormRow>
198+
<FormRow>
199+
<TextInput
200+
id="ram"
201+
name="ram"
202+
label={t`RAM (MiB)`}
203+
value={String(newFlavor.ram || "")}
204+
onChange={(e) => handleNumericInputChange("ram", Number(e.target.value))}
205+
onBlur={handleBlur}
206+
errortext={errors.ram}
207+
type="number"
208+
required
209+
/>
210+
</FormRow>
211+
<FormRow>
212+
<TextInput
213+
id="disk"
214+
name="disk"
215+
label={t`Disk (GiB)`}
216+
value={String(newFlavor.disk || "")}
217+
onChange={(e) => handleNumericInputChange("disk", Number(e.target.value))}
218+
onBlur={handleBlur}
219+
errortext={errors.disk}
220+
type="number"
221+
required
222+
/>
223+
</FormRow>
224+
<FormRow>
225+
<TextInput
226+
id="OS-FLV-EXT-DATA:ephemeral"
227+
name="OS-FLV-EXT-DATA:ephemeral"
228+
label={t`Ephemeral Disk (GiB)`}
229+
value={String(newFlavor["OS-FLV-EXT-DATA:ephemeral"] || "")}
230+
onChange={(e) => handleNumericInputChange("OS-FLV-EXT-DATA:ephemeral", Number(e.target.value))}
231+
onBlur={handleBlur}
232+
errortext={errors["OS-FLV-EXT-DATA:ephemeral"]}
233+
type="number"
234+
/>
235+
</FormRow>
236+
<FormRow>
237+
<TextInput
238+
id="swap"
239+
name="swap"
240+
label={t`Swap (MiB)`}
241+
value={String(newFlavor.swap || "")}
242+
onChange={(e) => handleNumericInputChange("swap", e.target.value ? Number(e.target.value) : undefined)}
243+
onBlur={handleBlur}
244+
errortext={errors.swap}
245+
type="number"
246+
/>
247+
</FormRow>
248+
<FormRow>
249+
<TextInput
250+
id="rxtx_factor"
251+
name="rxtx_factor"
252+
label={t`RX/TX Factor`}
253+
defaultValue={1}
254+
value={String(newFlavor.rxtx_factor || "")}
255+
onChange={(e) => handleNumericInputChange("rxtx_factor", Number(e.target.value))}
256+
onBlur={handleBlur}
257+
errortext={errors.rxtx_factor}
258+
type="number"
259+
/>
260+
</FormRow>
261+
</FormSection>
262+
</Form>
263+
)}
264+
</Modal>
265+
)
266+
}

0 commit comments

Comments
 (0)