Skip to content

Commit 0f90eec

Browse files
authored
In ip prefix pool form, default prefix type field is now a select instead of a text input (#5922)
1 parent ffbb200 commit 0f90eec

File tree

8 files changed

+246
-8
lines changed

8 files changed

+246
-8
lines changed
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { useCurrentBranch } from "@/entities/branches/ui/branches-provider";
2+
import { IP_PREFIX_GENERIC } from "@/entities/ipam/constants";
3+
import { createObject } from "@/entities/nodes/api/createObject";
4+
import { updateObjectWithId } from "@/entities/nodes/api/updateObjectWithId";
5+
import { IP_PREFIX_POOL } from "@/entities/resource-manager/constants";
6+
import { getSchema } from "@/entities/schema/domain/get-schema";
7+
import { useSchema } from "@/entities/schema/ui/hooks/useSchema";
8+
import graphqlClient from "@/shared/api/graphql/graphqlClientApollo";
9+
import DynamicForm from "@/shared/components/form/dynamic-form";
10+
import { NodeFormProps } from "@/shared/components/form/node-form";
11+
import { DynamicSelectFieldProps, FormFieldValue } from "@/shared/components/form/type";
12+
import { getFormFieldsFromSchema } from "@/shared/components/form/utils/getFormFieldsFromSchema";
13+
import { getCreateMutationFromFormData } from "@/shared/components/form/utils/mutations/getCreateMutationFromFormData";
14+
import { ALERT_TYPES, Alert } from "@/shared/components/ui/alert";
15+
import { stringifyWithoutQuotes } from "@/shared/utils/string";
16+
import { gql } from "@apollo/client";
17+
import { useMemo } from "react";
18+
import { toast } from "react-toastify";
19+
20+
export interface IpPrefixPoolFormProps extends NodeFormProps {}
21+
22+
export function IpPrefixPoolForm({
23+
currentObject,
24+
isUpdate,
25+
onSuccess,
26+
onSubmit,
27+
...props
28+
}: IpPrefixPoolFormProps) {
29+
const { schema: genericPrefixSchema, isGeneric } = useSchema(IP_PREFIX_GENERIC);
30+
const { currentBranch } = useCurrentBranch();
31+
32+
const fields = useMemo(() => {
33+
const schemaFields = getFormFieldsFromSchema({
34+
...props,
35+
initialObject: currentObject,
36+
isUpdate,
37+
});
38+
39+
if (!genericPrefixSchema || !isGeneric) return schemaFields;
40+
41+
// Replace default_prefix_type (text) field with a select
42+
return schemaFields.map((field) => {
43+
if (field.name === "default_prefix_type") {
44+
const items =
45+
genericPrefixSchema.used_by?.map((kind) => {
46+
const { schema } = getSchema(kind);
47+
48+
if (!schema) {
49+
return {
50+
key: kind,
51+
label: kind,
52+
};
53+
}
54+
55+
return {
56+
key: kind,
57+
label: (
58+
<div className="flex items-center justify-between w-full">
59+
<span>{schema.label}</span>
60+
<span className="text-xs text-gray-500">{schema.namespace}</span>
61+
</div>
62+
),
63+
};
64+
}) ?? [];
65+
66+
const defaultValue =
67+
isUpdate && currentObject
68+
? field.defaultValue
69+
: items.length === 1
70+
? { source: { type: "user" }, value: items[0]?.key }
71+
: field.defaultValue;
72+
73+
return {
74+
...field,
75+
type: "select",
76+
items,
77+
defaultValue,
78+
} as DynamicSelectFieldProps;
79+
}
80+
return field;
81+
});
82+
}, [props, genericPrefixSchema, isGeneric, currentObject, isUpdate]);
83+
84+
async function handleSubmit(data: Record<string, FormFieldValue>) {
85+
try {
86+
const newObject = getCreateMutationFromFormData(fields, data);
87+
88+
if (!Object.keys(newObject).length) {
89+
return;
90+
}
91+
92+
const mutationString =
93+
isUpdate && currentObject
94+
? updateObjectWithId({
95+
kind: IP_PREFIX_POOL,
96+
data: stringifyWithoutQuotes({
97+
id: currentObject.id,
98+
...newObject,
99+
}),
100+
})
101+
: createObject({
102+
kind: IP_PREFIX_POOL,
103+
data: stringifyWithoutQuotes(newObject),
104+
});
105+
106+
const mutation = gql`
107+
${mutationString}
108+
`;
109+
110+
const result = await graphqlClient.mutate({
111+
mutation,
112+
context: {
113+
branch: currentBranch.name,
114+
},
115+
});
116+
117+
const operationType = isUpdate ? "Update" : "Create";
118+
const successMessage = isUpdate ? "IP prefix pool updated" : "IP prefix pool created";
119+
toast(<Alert type={ALERT_TYPES.SUCCESS} message={successMessage} />, {
120+
toastId: `alert-success-ip-prefix-pool-${operationType}`,
121+
});
122+
123+
if (onSuccess) {
124+
const resultData = result?.data?.[`${IP_PREFIX_POOL}${operationType}`];
125+
await onSuccess(resultData);
126+
}
127+
} catch (error: unknown) {
128+
console.error(
129+
`An error occurred while ${isUpdate ? "updating" : "creating"} the IP prefix pool:`,
130+
error
131+
);
132+
}
133+
}
134+
135+
return (
136+
<DynamicForm
137+
fields={fields}
138+
onSubmit={(formData: Record<string, FormFieldValue>) =>
139+
onSubmit ? onSubmit({ formData, fields }) : handleSubmit(formData)
140+
}
141+
className="p-4 overflow-auto"
142+
/>
143+
);
144+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { classNames } from "@/shared/utils/common";
2+
import { Label as AriaLabel, LabelProps as AriaLabelProps } from "react-aria-components";
3+
4+
export function Label({ className, ...props }: AriaLabelProps) {
5+
return (
6+
<AriaLabel
7+
className={classNames(
8+
"text-sm font-medium text-gray-900 cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
9+
className
10+
)}
11+
{...props}
12+
/>
13+
);
14+
}

frontend/app/src/shared/components/aria/select.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { ListBoxItem } from "@/shared/components/aria/list-box";
1212
import { focusVisibleStyle } from "@/shared/components/style-rac";
1313
import { inputStyle } from "@/shared/components/ui/style";
1414
import { classNames } from "@/shared/utils/common";
15-
import { Icon } from "@iconify-icon/react";
15+
import { ChevronDownIcon } from "lucide-react";
1616
import { Popover, PopoverProps } from "./popover";
1717

1818
export const Select = AriaSelect;
@@ -26,14 +26,13 @@ export const SelectTrigger = ({ className, children, ...props }: AriaButtonProps
2626
)}
2727
{...props}
2828
>
29-
<AriaSelectValue className={classNames("truncate data-[placeholder]:text-gray-400")} />
30-
<Icon icon="mdi:chevron-down" className="ml-auto" />
29+
<AriaSelectValue className="truncate data-[placeholder]:text-gray-400 grow" />
30+
<ChevronDownIcon className="ml-auto size-4" />
3131
</AriaButton>
3232
);
3333

3434
export const SelectPopover = ({ className, ...props }: PopoverProps) => (
3535
<Popover
36-
isNonModal
3736
className={composeRenderProps(className, (className) =>
3837
classNames("min-w-[--trigger-width]", className)
3938
)}

frontend/app/src/shared/components/form/dynamic-form.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import PasswordInputField from "@/shared/components/form/fields/password-input.f
1515
import RelationshipHierarchicalField from "@/shared/components/form/fields/relationship-hierarchical.field";
1616
import RelationshipManyField from "@/shared/components/form/fields/relationship-many.field";
1717
import RelationshipField from "@/shared/components/form/fields/relationship.field";
18+
import { SelectField } from "@/shared/components/form/fields/select.field";
1819
import TextareaField from "@/shared/components/form/fields/textarea.field";
1920
import { DynamicFieldProps, FormFieldValue } from "@/shared/components/form/type";
2021
import { Form, FormProps, FormRef, FormSubmit } from "@/shared/components/ui/form";
@@ -112,6 +113,10 @@ export const DynamicInput = (props: DynamicFieldProps) => {
112113
const { type, ...otherProps } = props;
113114
return <EnumField {...otherProps} />;
114115
}
116+
case "select": {
117+
const { type, ...otherProps } = props;
118+
return <SelectField {...otherProps} />;
119+
}
115120
case "relationship": {
116121
const { schema: peerSchema } = getSchema(props.relationship.peer);
117122

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { Label } from "@/shared/components/aria/label";
2+
import { Select, SelectItem, SelectList, SelectTrigger } from "@/shared/components/aria/select";
3+
import {
4+
DynamicSelectFieldProps,
5+
FormAttributeValue,
6+
FormFieldProps,
7+
} from "@/shared/components/form/type";
8+
import { updateFormFieldValue } from "@/shared/components/form/utils/updateFormFieldValue";
9+
import { FormField, FormInput, FormMessage } from "@/shared/components/ui/form";
10+
11+
export interface SelectFieldProps
12+
extends FormFieldProps,
13+
Omit<DynamicSelectFieldProps, "defaultValue" | "name"> {}
14+
15+
export function SelectField({
16+
defaultValue = { source: null, value: null },
17+
description,
18+
label,
19+
name,
20+
rules,
21+
unique,
22+
items,
23+
...props
24+
}: SelectFieldProps) {
25+
return (
26+
<FormField
27+
key={name}
28+
name={name}
29+
rules={rules}
30+
defaultValue={defaultValue}
31+
render={({ field }) => {
32+
const fieldData: FormAttributeValue = field.value;
33+
const currentSelectedKey = (fieldData?.value as string | undefined) ?? null;
34+
35+
return (
36+
<div className="space-y-2">
37+
<FormInput>
38+
<Select
39+
selectedKey={currentSelectedKey}
40+
onSelectionChange={(key) =>
41+
field.onChange(
42+
updateFormFieldValue(currentSelectedKey === key ? null : key, defaultValue)
43+
)
44+
}
45+
placeholder=""
46+
{...props}
47+
>
48+
<Label>{label}</Label>
49+
<SelectTrigger />
50+
51+
<SelectList selectionMode="single" items={items}>
52+
{(item) => <SelectItem textValue={item.label}>{item.label}</SelectItem>}
53+
</SelectList>
54+
</Select>
55+
</FormInput>
56+
57+
<FormMessage />
58+
</div>
59+
);
60+
}}
61+
/>
62+
);
63+
}

frontend/app/src/shared/components/form/object-form.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import {
1111

1212
import { AttributeType, RelationshipType } from "@/entities/nodes/getObjectItemDisplayValue";
1313
import { NodeObject } from "@/entities/nodes/types";
14-
import { IP_ADDRESS_POOL } from "@/entities/resource-manager/constants";
14+
import { IP_ADDRESS_POOL, IP_PREFIX_POOL } from "@/entities/resource-manager/constants";
1515
import { IpAddressPoolForm } from "@/entities/resource-manager/ui/ip-address-pool-form";
16+
import { IpPrefixPoolForm } from "@/entities/resource-manager/ui/ip-prefix-pool-form";
1617
import { NumberPoolForm } from "@/entities/resource-manager/ui/number-pool-form";
1718
import { AccountForm } from "@/entities/role-manager/ui/account-form";
1819
import { AccountGroupForm } from "@/entities/role-manager/ui/account-group-form";
@@ -113,6 +114,10 @@ const ObjectForm = ({ kind, currentProfiles, ...props }: ObjectFormProps) => {
113114
return <IpAddressPoolForm {...props} />;
114115
}
115116

117+
if (kind === IP_PREFIX_POOL) {
118+
return <IpPrefixPoolForm schema={schema} {...props} />;
119+
}
120+
116121
if (isGeneric) {
117122
return <GenericObjectForm genericSchema={schema} {...props} />;
118123
}

frontend/app/src/shared/components/form/type.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,17 @@ export type DynamicEnumFieldProps = FormFieldProps & {
154154
schema?: ModelSchema;
155155
};
156156

157+
export type DynamicSelectFieldProps = FormFieldProps & {
158+
type: "select";
159+
items: Array<{ key: string; label: string }>;
160+
};
161+
157162
export type DynamicAttributeFieldProps =
158163
| DynamicInputFieldProps
159164
| DynamicNumberFieldProps
160165
| DynamicDropdownFieldProps
161-
| DynamicEnumFieldProps;
166+
| DynamicEnumFieldProps
167+
| DynamicSelectFieldProps;
162168

163169
export type DynamicRelationshipFieldProps = Omit<FormFieldProps, "defaultValue"> & {
164170
type: "relationship";

frontend/app/tests/e2e/resource-manager/resource-pool.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ test.describe("/resource-manager - Resource Manager", () => {
1919
await page.getByRole("option", { name: "10.0.0.0/8" }).click();
2020
await page.getByRole("option", { name: "10.0.0.0/16" }).click();
2121
await page.getByRole("option", { name: "10.1.0.0/16" }).click();
22+
await expect(page.getByLabel("Default Prefix Type")).toContainText("IP PrefixIpam");
2223
await page.getByLabel("Resources *").click();
2324

2425
await page.getByLabel("IPAM Namespace *").click();
2526
await page.getByRole("option", { name: "default" }).click();
2627
await page.getByRole("button", { name: "Save" }).click();
2728

28-
await expect(page.getByText("PrefixPool created")).toBeVisible();
29+
await expect(page.getByText("IP prefix pool created")).toBeVisible();
2930
await expect(page.getByRole("link", { name: "test prefix pool" })).toBeVisible();
3031
});
3132

@@ -39,10 +40,11 @@ test.describe("/resource-manager - Resource Manager", () => {
3940
expect(page.url()).toContain("/resource-manager/");
4041

4142
await page.getByTestId("edit-button").click();
43+
await expect(page.getByLabel("Default Prefix Type")).toContainText("IP PrefixIpam");
4244
await page.getByLabel("Description").fill("a test pool for e2e");
4345
await page.getByRole("button", { name: "Save" }).click();
4446

45-
await expect(page.getByText("PrefixPool updated")).toBeVisible();
47+
await expect(page.getByText("IPPrefixPool updated")).toBeVisible();
4648
await expect(page.getByText("Descriptiona test pool for e2e")).toBeVisible();
4749
});
4850

0 commit comments

Comments
 (0)