Skip to content

Commit

Permalink
Enable adding new nested entity that are already part of a nested ent…
Browse files Browse the repository at this point in the history
…ity when the form is in edit-mode (Issue #5375, PR #5546)

# Description

Enabling the possibility to add new nested entities that are within embedded entities.

closes *#5375*

https://github.com/inmanta/web-console/assets/44098050/d8f2015e-be7e-4737-9a5c-c661f8f07e76
  • Loading branch information
LukasStordeur authored and inmantaci committed Feb 20, 2024
1 parent 89409f6 commit 3538a1c
Show file tree
Hide file tree
Showing 9 changed files with 332 additions and 32 deletions.
6 changes: 6 additions & 0 deletions changelogs/unreleased/5375-nested-embedded-entity-edit.yml
@@ -0,0 +1,6 @@
description: Enable adding new nested entity that are already part of a nested entity when the form is in edit-mode
issue-nr: 5375
change-type: patch
destination-branches: [master, iso7]
sections:
bugfix: "{{description}}"
2 changes: 2 additions & 0 deletions src/Slices/EditInstance/UI/EditInstancePage.test.tsx
Expand Up @@ -522,6 +522,7 @@ test("Given the EditInstance View When adding new nested embedded entity Then th
const addedOptionalEmbedded = screen.getByLabelText(
"DictListFieldInputItem-editableOptionalEmbedded_base.1",
);

//check if direct attributes are correctly displayed
expect(within(addedOptionalEmbedded).queryByText("string")).toBeEnabled();
expect(
Expand Down Expand Up @@ -646,6 +647,7 @@ test("Given the EditInstance View When adding new nested embedded entity Then th
within(nested_editableOptionalEmbedded_base).getByText("Add"),
);
});

expect(
within(nested_editableOptionalEmbedded_base).queryByText("Delete"),
).toBeEnabled();
Expand Down
22 changes: 22 additions & 0 deletions src/Test/Data/Field.ts
Expand Up @@ -19,6 +19,17 @@ export const text: TextField = {
type: "string?",
};

export const textDisabled: TextField = {
kind: "Text",
name: "text_field_disabled",
description: "description",
isOptional: true,
isDisabled: true,
defaultValue: "",
inputType: TextInputTypes.text,
type: "string?",
};

export const bool: BooleanField = {
kind: "Boolean",
name: "boolean_field",
Expand Down Expand Up @@ -114,6 +125,17 @@ export const dictList = (fields?: Field[]): DictListField => ({
fields: fields || [],
});

export const nestedDictList = (fields?: Field[]): DictListField => ({
kind: "DictList",
name: "nested_dict_list_field",
description: "description",
isOptional: true,
isDisabled: false,
min: 1,
max: 4,
fields: [dictList(fields)] || [],
});

export const nestedEditable: Field[] = [
{
kind: "Text",
Expand Down
4 changes: 2 additions & 2 deletions src/Test/Data/Service/EmbeddedEntity.ts
Expand Up @@ -437,7 +437,7 @@ export const nestedEditable: EmbeddedEntity[] = [
attributes: [
{
name: "attr4",
modifier: "rw+",
modifier: "rw",
type: "int[]",
default_value: null,
default_value_set: false,
Expand All @@ -448,7 +448,7 @@ export const nestedEditable: EmbeddedEntity[] = [
embedded_entities: [],
name: "embedded_single",
description: "description",
modifier: "rw+",
modifier: "rw",
lower_limit: 0,
upper_limit: 1,
},
Expand Down
2 changes: 1 addition & 1 deletion src/UI/Components/Diagram/helpers.test.ts
Expand Up @@ -195,7 +195,7 @@ describe("createConnectionRules", () => {
name: "embedded_single",
lowerLimit: null,
upperLimit: 1,
modifier: "rw+",
modifier: "rw",
kind: "Embedded",
},
],
Expand Down
132 changes: 124 additions & 8 deletions src/UI/Components/ServiceInstanceForm/Components/FieldInput.tsx
Expand Up @@ -29,19 +29,47 @@ interface Props {
originalState: InstanceAttributeModel;
getUpdate: GetUpdate;
path: string | null;
isNew?: boolean;
}

/**
* Type representing a function to update the state within the form.
*
* @param {string} path - The path within the form state to update.
* @param {unknown} value - The new value to set at the specified path.
* @param {boolean} [multi] - Optional flag indicating if the update is for multiple values. Default is false.
* @returns {void}
*/
type GetUpdate = (path: string, value: unknown, multi?: boolean) => void;

/**
* Combines the current path with the next path segment to create a new path.
*
* @param {string | null} path - The current path (can be null).
* @param {string} next - The next path segment to append.
* @returns {string} The new combined path.
*/
const makePath = (path: string | null, next: string): string =>
path === null ? next : `${path}.${next}`;

/**
* FieldInput component for managing form input related to a specific field.
*
* @param {Props} props - Props for the FieldInput component.
* @prop {Field} field - The field associated with the input.
* @prop {FormState} formState - The current form state.
* @prop {OriginalState} originalState - The original state of the field.
* @prop {Function} getUpdate - Function to get updates for the field.
* @prop {string} path - The path of the field within the form.
* @prop {boolean} isNew - Flag indicating whether the field is newly added. Default is false.
*/
export const FieldInput: React.FC<Props> = ({
field,
formState,
originalState,
getUpdate,
path,
isNew = false,
}) => {
//callback was used to avoid re-render in useEffect used in SelectFormInput
const getEnumUpdate = useCallback(
Expand All @@ -50,6 +78,7 @@ export const FieldInput: React.FC<Props> = ({
},
[getUpdate, path, field.name],
);

switch (field.kind) {
case "Boolean":
return field.isOptional ? (
Expand All @@ -65,7 +94,8 @@ export const FieldInput: React.FC<Props> = ({
key={field.name}
shouldBeDisabled={
field.isDisabled &&
get(originalState, makePath(path, field.name)) !== undefined
get(originalState, makePath(path, field.name)) !== undefined &&
!isNew
}
/>
) : (
Expand All @@ -80,7 +110,8 @@ export const FieldInput: React.FC<Props> = ({
key={field.name}
shouldBeDisabled={
field.isDisabled &&
get(originalState, makePath(path, field.name)) !== undefined
get(originalState, makePath(path, field.name)) !== undefined &&
!isNew
}
/>
);
Expand All @@ -97,7 +128,8 @@ export const FieldInput: React.FC<Props> = ({
shouldBeDisabled={
field.isDisabled &&
get(originalState, makePath(path, field.name).split(".")) !==
undefined
undefined &&
!isNew
}
type={field.inputType}
handleInputChange={(value, _event) =>
Expand All @@ -118,7 +150,8 @@ export const FieldInput: React.FC<Props> = ({
isOptional={field.isOptional}
shouldBeDisabled={
field.isDisabled &&
get(originalState, makePath(path, field.name)) !== undefined
get(originalState, makePath(path, field.name)) !== undefined &&
!isNew
}
type={field.inputType}
handleInputChange={(value, _event) => {
Expand All @@ -140,7 +173,8 @@ export const FieldInput: React.FC<Props> = ({
isOptional={field.isOptional}
shouldBeDisabled={
field.isDisabled &&
get(originalState, makePath(path, field.name)) !== undefined
get(originalState, makePath(path, field.name)) !== undefined &&
!isNew
}
type={field.inputType}
handleInputChange={(value, _event) =>
Expand Down Expand Up @@ -183,7 +217,8 @@ export const FieldInput: React.FC<Props> = ({
key={field.name}
shouldBeDisabled={
field.isDisabled &&
get(originalState, makePath(path, field.name)) !== undefined
get(originalState, makePath(path, field.name)) !== undefined &&
!isNew
}
/>
);
Expand Down Expand Up @@ -230,6 +265,12 @@ export const FieldInput: React.FC<Props> = ({
}
};

/**
* Get a placeholder for the given data type.
*
* @param {string} typeName - The data type name.
* @returns {string | undefined} The placeholder string for the given data type, or undefined if not found.
*/
const getPlaceholderForType = (typeName: string): string | undefined => {
if (typeName === "int[]") {
return words("inventory.form.placeholder.intList");
Expand All @@ -244,6 +285,12 @@ const getPlaceholderForType = (typeName: string): string | undefined => {
return undefined;
};

/**
* Get a type hint for the given data type.
*
* @param {string} typeName - The data type name.
* @returns {string | undefined} The type hint string for the given data type, or undefined if not found.
*/
const getTypeHintForType = (typeName: string): string | undefined => {
if (typeName.endsWith("[]")) {
return words("inventory.form.typeHint.list")(
Expand All @@ -263,6 +310,17 @@ interface NestedProps {
path: string | null;
}

/**
* NestedFieldInput component with inner state for managing nested field input.
*
* @param {NestedProps} props - Props for the NestedFieldInput component.
* @prop {Field} field - The nested field.
* @prop {FormState} formState - The form state.
* @prop {OriginalState} originalState - The original state of the nested field.
* @prop {Function} getUpdate - Function to update and get updates for the nested field.
* @prop {string} path - The path of the nested field.
* @returns {JSX.Element} The rendered NestedFieldInput component.
*/
const NestedFieldInput: React.FC<NestedProps> = ({
field,
formState,
Expand All @@ -283,6 +341,7 @@ const NestedFieldInput: React.FC<NestedProps> = ({
setShowList(false);
return getUpdate(makePath(path, field.name), null);
};

return (
<StyledFormFieldGroupExpandable
aria-label={`NestedFieldInput-${makePath(path, field.name)}`}
Expand Down Expand Up @@ -345,6 +404,17 @@ interface DictListProps {
path: string | null;
}

/**
* DictListFieldInput component with inner state to manage dictionary list field input.
*
* @param {DictListProps} props - Props for the DictListFieldInput component.
* @prop {Field} field - The dictionary list field.
* @prop {FormState} formState - The form state.
* @prop {OriginalState} originalState - The original state of the dictionary list field.
* @prop {Function} getUpdate - Function to update and get updates for the dictionary list field.
* @prop {string} path - The path of the dictionary list field.
* @returns {JSX.Element} The rendered DictListFieldInput component.
*/
const DictListFieldInput: React.FC<DictListProps> = ({
field,
formState,
Expand All @@ -353,22 +423,65 @@ const DictListFieldInput: React.FC<DictListProps> = ({
path,
}) => {
const list = get(formState, makePath(path, field.name)) as Array<unknown>;
const [addedItemsPaths, setAddedItemPaths] = useState<string[]>([]);

/**
* Add a new formField group of the same type to the list.
* Stores the paths of the newly added elements.
*
* @returns void
*/
const onAdd = () => {
if (field.max && list.length >= field.max) {
return;
}

get(originalState, makePath(path, field.name));
setAddedItemPaths([
...addedItemsPaths,
`${makePath(path, field.name)}.${list.length}`,
]);

getUpdate(makePath(path, field.name), [
...list,
createFormState(field.fields),
]);
};

const getOnDelete = (index: number) => () =>
/**
* Delete method that also handles the update of the stored paths
*
* @param {index} number
* @returns void
*/
const getOnDelete = (index: number) => () => {
const newPaths: string[] = [];

/**
* We need to update the stored paths after the deleted item,
* because paths are dynamically defined and not fixed.
* If the user deletes an item preceding the new items,
* we want to make sure the path refers to the same entity.
*/
addedItemsPaths.forEach((addedPath, indexPath) => {
const lastDigit: number = Number(addedPath.slice(-1));

if (indexPath < index) {
newPaths.push(addedPath); // add addedPath to newPath
} else if (lastDigit > index) {
const truncatedPath = addedPath.slice(0, -1); // truncate the last digit
const modifiedPath = `${truncatedPath}${lastDigit - 1}`; // deduce 1 from the index
newPaths.push(modifiedPath);
}
});

setAddedItemPaths([...newPaths]);

getUpdate(makePath(path, field.name), [
...list.slice(0, index),
...list.slice(index + 1, list.length),
]);
};

return (
<StyledFormFieldGroupExpandable
Expand Down Expand Up @@ -400,7 +513,7 @@ const DictListFieldInput: React.FC<DictListProps> = ({
/>
}
>
{list.map((item, index) => (
{list.map((_item, index) => (
<StyledFormFieldGroupExpandable
aria-label={`DictListFieldInputItem-${makePath(
path,
Expand Down Expand Up @@ -441,6 +554,9 @@ const DictListFieldInput: React.FC<DictListProps> = ({
originalState={originalState}
getUpdate={getUpdate}
path={makePath(path, `${field.name}.${index}`)}
isNew={addedItemsPaths.includes(
`${makePath(path, field.name)}.${index}`,
)}
/>
))}
</StyledFormFieldGroupExpandable>
Expand Down

0 comments on commit 3538a1c

Please sign in to comment.