This is an automated email from the ASF dual-hosted git repository.
pawarprasad123 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/atlas.git
The following commit(s) were added to refs/heads/master by this push:
new 40de707c8 ATLAS-5371: Atlas UI: Render ENUM-typed entity attributes as
dropdowns in Create/Edit Entity form (#723)
40de707c8 is described below
commit 40de707c84a4a599764f02bbc4ffd1bb04dd15a6
Author: Prasad Pawar <[email protected]>
AuthorDate: Wed Aug 12 08:59:03 2026 +0530
ATLAS-5371: Atlas UI: Render ENUM-typed entity attributes as dropdowns in
Create/Edit Entity form (#723)
---
.../src/components/Forms/FormEnumMultiSelect.tsx | 107 ++++++++
.../Forms/__tests__/FormEnumMultiSelect.test.tsx | 103 ++++++++
.../utils/__tests__/entityTypeConfigUtils.test.ts | 100 ++++++++
.../src/utils/__tests__/enumTypeUtils.test.ts | 158 ++++++++++++
dashboard/src/utils/entityTypeConfigUtils.ts | 62 +++++
dashboard/src/utils/enumTypeUtils.ts | 137 ++++++++++
.../EntityDetailTabs/AttributeProperties.tsx | 28 +-
.../__tests__/AttributeProperties.test.tsx | 64 +++++
dashboard/src/views/Entity/EntityForm.tsx | 93 +++----
dashboard/src/views/Entity/entityFormFields.tsx | 108 ++++++++
dashboard/src/views/__tests__/EntityForm.test.tsx | 105 +++++++-
.../src/views/__tests__/EntityFormEnum.test.tsx | 285 +++++++++++++++++++++
.../js/views/entity/CreateEntityLayoutView.js | 98 ++++++-
.../js/views/entity/EntityDetailTableLayoutView.js | 3 +-
.../js/views/search/SearchResultLayoutView.js | 1 +
15 files changed, 1371 insertions(+), 81 deletions(-)
diff --git a/dashboard/src/components/Forms/FormEnumMultiSelect.tsx
b/dashboard/src/components/Forms/FormEnumMultiSelect.tsx
new file mode 100644
index 000000000..7beed0a0c
--- /dev/null
+++ b/dashboard/src/components/Forms/FormEnumMultiSelect.tsx
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { LightTooltip } from "@components/muiComponents";
+import { Autocomplete, InputLabel, TextField, Typography } from
"@mui/material";
+import {
+ areEnumOptionsEqual,
+ EnumOption,
+ getEnumOptionLabel,
+ normalizeMultiEnumValue
+} from "@utils/enumTypeUtils";
+import { Capitalize, isEmpty } from "@utils/Utils";
+import { Controller } from "react-hook-form";
+
+const FormEnumMultiSelect = ({
+ data,
+ control,
+ optionsList,
+ fieldName
+}: {
+ data: { name: string; isOptional: boolean; typeName: string;
cardinality?: string };
+ control: any;
+ optionsList: EnumOption[];
+ fieldName?: string;
+}) => {
+ const { name, isOptional, typeName, cardinality } = data;
+
+ return (
+ <Controller
+ name={!isEmpty(fieldName) ? `${fieldName}.${name}` :
name}
+ control={control}
+ key={`enum-multi-${name}`}
+ rules={{
+ required: isOptional ? false : true
+ }}
+ defaultValue={[]}
+ render={({ field: { onChange, value }, fieldState: {
error } }) => {
+ const selectedValues =
normalizeMultiEnumValue(value, optionsList);
+
+ return (
+ <>
+ <div className="form-fields">
+ <InputLabel
+
className="form-textfield-label"
+
required={isOptional ? false : true}
+ >
+
{Capitalize(name)}
+ </InputLabel>
+ <LightTooltip
title={`Data Type: (${typeName})`}>
+ <Typography
+
color="#666666"
+
overflow="hidden"
+
maxWidth="160px"
+
fontSize={14}
+ noWrap
+
>{`(${typeName})${cardinality ? ` ${cardinality}` : ""}`}</Typography>
+ </LightTooltip>
+ </div>
+ <Autocomplete
+ size="small"
+ multiple
+ disableCloseOnSelect
+
className="form-autocomplete-field"
+ onChange={(_event,
selectedOptions) => {
+
onChange(selectedOptions);
+ }}
+ sx={{ width: "100%" }}
+ value={selectedValues}
+ filterSelectedOptions
+
getOptionLabel={getEnumOptionLabel}
+
isOptionEqualToValue={areEnumOptionsEqual}
+ options={optionsList}
+ renderInput={(params)
=> (
+ <TextField
+
{...params}
+
error={!!error}
+
className="form-textfield"
+
size="small"
+
InputProps={{
+
...params.InputProps
+ }}
+
placeholder="Select enum values"
+ />
+ )}
+ />
+ </>
+ );
+ }}
+ />
+ );
+};
+
+export default FormEnumMultiSelect;
diff --git
a/dashboard/src/components/Forms/__tests__/FormEnumMultiSelect.test.tsx
b/dashboard/src/components/Forms/__tests__/FormEnumMultiSelect.test.tsx
new file mode 100644
index 000000000..a4cf50579
--- /dev/null
+++ b/dashboard/src/components/Forms/__tests__/FormEnumMultiSelect.test.tsx
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from "react";
+import { render, screen } from "@utils/test-utils";
+import userEvent from "@testing-library/user-event";
+import { useForm } from "react-hook-form";
+import FormEnumMultiSelect from "../FormEnumMultiSelect";
+
+const optionsList = [
+ { label: "LRS", value: "LRS" },
+ { label: "ZRS", value: "ZRS" },
+ { label: "GRS", value: "GRS" }
+];
+
+const TestForm = ({
+ defaultValue = []
+}: {
+ defaultValue?: unknown;
+}) => {
+ const { control } = useForm({
+ defaultValues: {
+ replicationTypes: defaultValue
+ }
+ });
+
+ return (
+ <FormEnumMultiSelect
+ data={{
+ name: "replicationTypes",
+ isOptional: false,
+ typeName: "array<adls_gen2_replication>",
+ cardinality: "SET"
+ }}
+ control={control}
+ optionsList={optionsList}
+ />
+ );
+};
+
+describe("FormEnumMultiSelect", () => {
+ it("renders a multi-select dropdown for array enum attributes", async
() => {
+ const user = userEvent.setup();
+ render(<TestForm defaultValue={["LRS"]} />);
+
+
expect(screen.getByText("ReplicationTypes")).toBeInTheDocument();
+ expect(screen.getByText("LRS")).toBeInTheDocument();
+
+ const valueInput = screen.getByRole("combobox");
+ await user.click(valueInput);
+ expect(await screen.findByRole("option", { name: "ZRS"
})).toBeInTheDocument();
+ expect(screen.getByRole("option", { name: "GRS"
})).toBeInTheDocument();
+ });
+
+ it("renders empty selection when no default value is provided", () => {
+ render(<TestForm />);
+
+ expect(screen.getByPlaceholderText("Select enum
values")).toBeInTheDocument();
+ });
+
+ it("allows optional multi enum to remain empty (negative validation
path)", () => {
+ const OptionalForm = () => {
+ const { control } = useForm({
+ defaultValues: {
+ storageTypes: []
+ }
+ });
+
+ return (
+ <FormEnumMultiSelect
+ data={{
+ name: "storageTypes",
+ isOptional: true,
+ typeName:
"array<ozone_storage_type>",
+ cardinality: "SET"
+ }}
+ control={control}
+ optionsList={[
+ { label: "SSD", value: "SSD" },
+ { label: "DISK", value: "DISK" }
+ ]}
+ />
+ );
+ };
+
+ render(<OptionalForm />);
+ expect(screen.getByPlaceholderText("Select enum
values")).toBeInTheDocument();
+ });
+});
diff --git a/dashboard/src/utils/__tests__/entityTypeConfigUtils.test.ts
b/dashboard/src/utils/__tests__/entityTypeConfigUtils.test.ts
new file mode 100644
index 000000000..b0e6ad05c
--- /dev/null
+++ b/dashboard/src/utils/__tests__/entityTypeConfigUtils.test.ts
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ filterEditableEntityTypes,
+ isEntityTypeEditable,
+ parseEditableEntityTypes
+} from "@utils/entityTypeConfigUtils";
+
+describe("entityTypeConfigUtils", () => {
+ describe("parseEditableEntityTypes", () => {
+ it("returns wildcard for * config (positive)", () => {
+ expect(parseEditableEntityTypes("*")).toBe("*");
+ });
+
+ it("parses comma-separated entity types with spaces
(positive)", () => {
+ expect(parseEditableEntityTypes("hdfs_path,
enumchecking")).toEqual([
+ "hdfs_path",
+ "enumchecking"
+ ]);
+ });
+
+ it("returns empty array for empty, null, or undefined config
(negative)", () => {
+ expect(parseEditableEntityTypes("")).toEqual([]);
+ expect(parseEditableEntityTypes(null)).toEqual([]);
+ expect(parseEditableEntityTypes(undefined)).toEqual([]);
+ });
+
+ it("returns empty array for whitespace-only config (negative)",
() => {
+ expect(parseEditableEntityTypes(" ")).toEqual([]);
+ });
+ });
+
+ describe("isEntityTypeEditable", () => {
+ it("allows any type when config is wildcard (positive)", () => {
+ expect(isEntityTypeEditable("enumchecking",
"*")).toBe(true);
+ expect(isEntityTypeEditable("hdfs_path",
"*")).toBe(true);
+ });
+
+ it("allows type when listed in comma-separated config
(positive)", () => {
+ expect(isEntityTypeEditable("enumchecking",
"hdfs_path,enumchecking")).toBe(
+ true
+ );
+ });
+
+ it("denies type when not listed in config (negative)", () => {
+ expect(isEntityTypeEditable("enumchecking",
"hdfs_path")).toBe(false);
+ expect(isEntityTypeEditable("DataSet",
"hdfs_path,enumchecking")).toBe(
+ false
+ );
+ });
+
+ it("denies all types when config is empty (negative)", () => {
+ expect(isEntityTypeEditable("enumchecking",
"")).toBe(false);
+ expect(isEntityTypeEditable("enumchecking",
null)).toBe(false);
+ });
+ });
+
+ describe("filterEditableEntityTypes", () => {
+ const allTypes = ["hdfs_path", "enumchecking", "DataSet",
"__internal"];
+
+ it("returns all types for wildcard config (positive)", () => {
+ expect(filterEditableEntityTypes(allTypes,
"*")).toEqual(allTypes);
+ });
+
+ it("returns only configured types for comma-separated config
(positive)", () => {
+ expect(
+ filterEditableEntityTypes(allTypes,
"hdfs_path,enumchecking")
+ ).toEqual(["hdfs_path", "enumchecking"]);
+ });
+
+ it("returns empty list when config is empty (negative)", () => {
+ expect(filterEditableEntityTypes(allTypes,
"")).toEqual([]);
+ });
+
+ it("returns empty list when no types match config (negative)",
() => {
+ expect(filterEditableEntityTypes(allTypes,
"unknown_type")).toEqual([]);
+ });
+
+ it("returns empty list when input type list is empty
(negative)", () => {
+ expect(filterEditableEntityTypes([],
"hdfs_path,enumchecking")).toEqual(
+ []
+ );
+ });
+ });
+});
diff --git a/dashboard/src/utils/__tests__/enumTypeUtils.test.ts
b/dashboard/src/utils/__tests__/enumTypeUtils.test.ts
new file mode 100644
index 000000000..3485ba615
--- /dev/null
+++ b/dashboard/src/utils/__tests__/enumTypeUtils.test.ts
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ areEnumOptionsEqual,
+ buildEnumOptionsForTypeName,
+ getInnerTypeName,
+ isArrayTypeName,
+ isEnumTypeName,
+ normalizeMultiEnumValue,
+ serializeMultiEnumValue
+} from "@utils/enumTypeUtils";
+
+const mockEnumDefs = [
+ {
+ name: "adls_gen2_replication",
+ elementDefs: [
+ { value: "LRS" },
+ { value: "ZRS" },
+ { value: "GRS" },
+ { value: "GZRS" },
+ { value: "RA-GRS" }
+ ]
+ },
+ {
+ name: "ozone_storage_type",
+ elementDefs: [
+ { value: "RAM_DISK" },
+ { value: "SSD" },
+ { value: "DISK" },
+ { value: "ARCHIVE" }
+ ]
+ }
+];
+
+describe("enumTypeUtils", () => {
+ it("detects array enum type names", () => {
+
expect(isArrayTypeName("array<adls_gen2_replication>")).toBe(true);
+ expect(isArrayTypeName("adls_gen2_replication")).toBe(false);
+ });
+
+ it("extracts inner enum type from array type name", () => {
+ expect(getInnerTypeName("array<adls_gen2_replication>")).toBe(
+ "adls_gen2_replication"
+ );
+ expect(getInnerTypeName("adls_gen2_replication")).toBe(
+ "adls_gen2_replication"
+ );
+ });
+
+ it("builds enum options for single and multi-value enum types", () => {
+ expect(
+ buildEnumOptionsForTypeName("adls_gen2_replication",
mockEnumDefs)
+ ).toEqual([
+ { label: "LRS", value: "LRS" },
+ { label: "ZRS", value: "ZRS" },
+ { label: "GRS", value: "GRS" },
+ { label: "GZRS", value: "GZRS" },
+ { label: "RA-GRS", value: "RA-GRS" }
+ ]);
+
+ expect(
+
buildEnumOptionsForTypeName("array<adls_gen2_replication>", mockEnumDefs)
+ ).toEqual([
+ { label: "LRS", value: "LRS" },
+ { label: "ZRS", value: "ZRS" },
+ { label: "GRS", value: "GRS" },
+ { label: "GZRS", value: "GZRS" },
+ { label: "RA-GRS", value: "RA-GRS" }
+ ]);
+ });
+
+ it("identifies enum and non-enum type names", () => {
+ expect(
+ isEnumTypeName("array<adls_gen2_replication>",
mockEnumDefs)
+ ).toBe(true);
+ expect(isEnumTypeName("array<string>",
mockEnumDefs)).toBe(false);
+ expect(isEnumTypeName("string", mockEnumDefs)).toBe(false);
+ expect(isEnumTypeName("adls_gen2_replication",
undefined)).toBe(false);
+ });
+
+ it("normalizes stored string values into enum option objects", () => {
+ const options = buildEnumOptionsForTypeName(
+ "array<adls_gen2_replication>",
+ mockEnumDefs
+ );
+
+ expect(normalizeMultiEnumValue(["LRS", "ZRS"],
options)).toEqual([
+ { label: "LRS", value: "LRS" },
+ { label: "ZRS", value: "ZRS" }
+ ]);
+ });
+
+ it("returns empty array when multi enum value is missing or invalid",
() => {
+ const options = buildEnumOptionsForTypeName(
+ "array<adls_gen2_replication>",
+ mockEnumDefs
+ );
+
+ expect(normalizeMultiEnumValue(undefined, options)).toEqual([]);
+ expect(normalizeMultiEnumValue("LRS", options)).toEqual([]);
+ });
+
+ it("serializes multi enum values for submit", () => {
+ expect(serializeMultiEnumValue(["LRS", "ZRS"])).toEqual(["LRS",
"ZRS"]);
+ expect(
+ serializeMultiEnumValue([
+ { label: "LRS", value: "LRS" },
+ { label: "ZRS", value: "ZRS" }
+ ])
+ ).toEqual(["LRS", "ZRS"]);
+ expect(serializeMultiEnumValue(undefined)).toEqual([]);
+ });
+
+ it("compares enum options for autocomplete selection", () => {
+ const option = { label: "LRS", value: "LRS" };
+
+ expect(areEnumOptionsEqual(option, "LRS")).toBe(true);
+ expect(areEnumOptionsEqual(option, { label: "ZRS", value: "ZRS"
})).toBe(
+ false
+ );
+ });
+
+ it("builds ozone_storage_type enum options (positive)", () => {
+ expect(
+ buildEnumOptionsForTypeName("ozone_storage_type",
mockEnumDefs)
+ ).toEqual([
+ { label: "RAM_DISK", value: "RAM_DISK" },
+ { label: "SSD", value: "SSD" },
+ { label: "DISK", value: "DISK" },
+ { label: "ARCHIVE", value: "ARCHIVE" }
+ ]);
+ });
+
+ it("returns false for enum type when enumDefs is empty (negative)", ()
=> {
+ expect(isEnumTypeName("ozone_storage_type", [])).toBe(false);
+ });
+
+ it("returns empty options when enum type is not found (negative)", ()
=> {
+ expect(
+ buildEnumOptionsForTypeName("unknown_enum_type",
mockEnumDefs)
+ ).toEqual([]);
+ });
+});
diff --git a/dashboard/src/utils/entityTypeConfigUtils.ts
b/dashboard/src/utils/entityTypeConfigUtils.ts
new file mode 100644
index 000000000..093844e66
--- /dev/null
+++ b/dashboard/src/utils/entityTypeConfigUtils.ts
@@ -0,0 +1,62 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export type EditableEntityTypesConfig = string[] | "*";
+
+export const parseEditableEntityTypes = (
+ editableTypesConfig: string | undefined | null
+): EditableEntityTypesConfig => {
+ if (!editableTypesConfig || editableTypesConfig.trim() === "") {
+ return [];
+ }
+
+ const trimmedConfig = editableTypesConfig.trim();
+ if (trimmedConfig === "*") {
+ return "*";
+ }
+
+ return trimmedConfig
+ .split(",")
+ .map((typeName) => typeName.trim())
+ .filter(Boolean);
+};
+
+export const isEntityTypeEditable = (
+ typeName: string,
+ editableTypesConfig: string | undefined | null
+): boolean => {
+ const parsedConfig = parseEditableEntityTypes(editableTypesConfig);
+
+ if (parsedConfig === "*") {
+ return true;
+ }
+
+ return parsedConfig.includes(typeName);
+};
+
+export const filterEditableEntityTypes = (
+ allTypeNames: string[],
+ editableTypesConfig: string | undefined | null
+): string[] => {
+ const parsedConfig = parseEditableEntityTypes(editableTypesConfig);
+
+ if (parsedConfig === "*") {
+ return allTypeNames;
+ }
+
+ return allTypeNames.filter((typeName) =>
parsedConfig.includes(typeName));
+};
diff --git a/dashboard/src/utils/enumTypeUtils.ts
b/dashboard/src/utils/enumTypeUtils.ts
new file mode 100644
index 000000000..867707f14
--- /dev/null
+++ b/dashboard/src/utils/enumTypeUtils.ts
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface EnumOption {
+ label: string;
+ value: string;
+}
+
+export interface EnumDef {
+ name: string;
+ elementDefs?: Array<{ value: string }>;
+}
+
+export const getInnerTypeName = (typeName: string): string => {
+ if (!typeName || typeName.indexOf("array<") !== 0) {
+ return typeName;
+ }
+
+ const match = typeName.match(/array<(.*)>/);
+ return match?.[1] || typeName;
+};
+
+export const isArrayTypeName = (typeName: string): boolean =>
+ Boolean(typeName && typeName.indexOf("array<") === 0);
+
+export const isEnumTypeName = (
+ typeName: string,
+ enumDefs: EnumDef[] | undefined
+): boolean => {
+ if (!typeName || !enumDefs?.length) {
+ return false;
+ }
+
+ const innerTypeName = getInnerTypeName(typeName);
+ return enumDefs.some((enumDef) => enumDef.name === innerTypeName);
+};
+
+export const buildEnumOptionsForTypeName = (
+ typeName: string,
+ enumDefs: EnumDef[] | undefined
+): EnumOption[] => {
+ if (typeName === "array<boolean>") {
+ return [
+ { label: "true", value: "true" },
+ { label: "false", value: "false" }
+ ];
+ }
+
+ if (!enumDefs?.length) {
+ return [];
+ }
+
+ const innerTypeName = getInnerTypeName(typeName);
+ const foundEnumType = enumDefs.find((enumDef) => enumDef.name ===
innerTypeName);
+ const elementDefs = foundEnumType?.elementDefs || [];
+
+ return elementDefs.map((elementDef) => ({
+ label: elementDef.value,
+ value: elementDef.value
+ }));
+};
+
+export const normalizeMultiEnumValue = (
+ value: unknown,
+ options: EnumOption[]
+): EnumOption[] => {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ return value.map((entry) => {
+ if (entry && typeof entry === "object" && "value" in entry) {
+ const optionValue = String((entry as EnumOption).value);
+ return (
+ options.find((option) => option.value ===
optionValue) || {
+ label: optionValue,
+ value: optionValue
+ }
+ );
+ }
+
+ const optionValue = String(entry);
+ return (
+ options.find((option) => option.value === optionValue)
|| {
+ label: optionValue,
+ value: optionValue
+ }
+ );
+ });
+};
+
+export const serializeMultiEnumValue = (value: unknown): string[] => {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ return value.map((entry) => {
+ if (entry && typeof entry === "object" && "value" in entry) {
+ return String((entry as EnumOption).value);
+ }
+
+ return String(entry);
+ });
+};
+
+export const getEnumOptionLabel = (option: EnumOption | string): string => {
+ if (typeof option === "string") {
+ return option;
+ }
+
+ return option.label;
+};
+
+export const areEnumOptionsEqual = (
+ option: EnumOption,
+ value: EnumOption | string
+): boolean => {
+ if (typeof value === "string") {
+ return option.value === value;
+ }
+
+ return option.value === value.value;
+};
diff --git
a/dashboard/src/views/DetailPage/EntityDetailTabs/AttributeProperties.tsx
b/dashboard/src/views/DetailPage/EntityDetailTabs/AttributeProperties.tsx
index bdf10246d..8efb029e7 100644
--- a/dashboard/src/views/DetailPage/EntityDetailTabs/AttributeProperties.tsx
+++ b/dashboard/src/views/DetailPage/EntityDetailTabs/AttributeProperties.tsx
@@ -24,6 +24,7 @@ import {
LightTooltip
} from "@components/muiComponents";
import { isArray, isEmpty, isNull } from "@utils/Utils";
+import { isEntityTypeEditable } from "@utils/entityTypeConfigUtils";
import SkeletonLoader from "@components/SkeletonLoader";
import { getValues } from "@components/commonComponents";
import { useState } from "react";
@@ -55,28 +56,13 @@ const AttributeProperties = ({
const { entityDefs = [] } = entityData || {};
const { data = {} } = sessionObj || {};
- const key = "atlas.entity.update.allowed";
+ const updateAllowedKey = "atlas.entity.update.allowed";
+ const editableTypesKey = "atlas.ui.editable.entity.types";
- let entityUpdate: boolean = false;
- let entityTypeConfList: string[] = [];
-
- // Only process if the key exists and is not empty
- if (!isEmpty(data?.[key])) {
- let entityTypeList = data["atlas.ui.editable.entity.types"]
- .trim()
- .split(",");
- if (entityTypeList.length) {
- if (entityTypeList[0] === "*") {
- entityTypeConfList = [];
- entityUpdate = true; // Wildcard means all types are allowed
- } else if (entityTypeList.length > 0) {
- entityTypeConfList = entityTypeList;
- // Check if current entity type is in the allowed list
- if (entityTypeConfList.includes(typeName)) {
- entityUpdate = true;
- }
- }
- }
+ let entityUpdate = false;
+
+ if (!isEmpty(data?.[updateAllowedKey])) {
+ entityUpdate = isEntityTypeEditable(typeName, data?.[editableTypesKey]);
}
const [entityModal, setEntityModal] = useState<boolean>(false);
diff --git
a/dashboard/src/views/DetailPage/EntityDetailTabs/__tests__/AttributeProperties.test.tsx
b/dashboard/src/views/DetailPage/EntityDetailTabs/__tests__/AttributeProperties.test.tsx
index 158b9b66f..ba2b8fef8 100644
---
a/dashboard/src/views/DetailPage/EntityDetailTabs/__tests__/AttributeProperties.test.tsx
+++
b/dashboard/src/views/DetailPage/EntityDetailTabs/__tests__/AttributeProperties.test.tsx
@@ -617,6 +617,70 @@ describe('AttributeProperties', () => {
expect(screen.queryByTestId('custom-button')).not.toBeInTheDocument();
});
+ it('should show Edit button for enumchecking when type is
allowed (positive)', () => {
+ mockUseAppSelector.mockImplementation((selector: any)
=> {
+ const mockState = {
+ session: {
+ sessionObj: {
+ data: {
+
'atlas.entity.update.allowed': true,
+
'atlas.ui.editable.entity.types': 'hdfs_path,enumchecking'
+ }
+ }
+ }
+ };
+ return selector(mockState);
+ });
+
+ render(
+ <TestWrapper>
+ <AttributeProperties
+ entity={{
+ ...defaultMockEntity,
+ typeName: 'enumchecking'
+ }}
+
referredEntities={defaultMockReferredEntities}
+ loading={false}
+ propertiesName="Technical"
+ />
+ </TestWrapper>
+ );
+
+
expect(screen.getByTestId('custom-button')).toBeInTheDocument();
+ });
+
+ it('should hide Edit button for enumchecking when type is not
allowed (negative)', () => {
+ mockUseAppSelector.mockImplementation((selector: any)
=> {
+ const mockState = {
+ session: {
+ sessionObj: {
+ data: {
+
'atlas.entity.update.allowed': true,
+
'atlas.ui.editable.entity.types': 'hdfs_path'
+ }
+ }
+ }
+ };
+ return selector(mockState);
+ });
+
+ render(
+ <TestWrapper>
+ <AttributeProperties
+ entity={{
+ ...defaultMockEntity,
+ typeName: 'enumchecking'
+ }}
+
referredEntities={defaultMockReferredEntities}
+ loading={false}
+ propertiesName="Technical"
+ />
+ </TestWrapper>
+ );
+
+
expect(screen.queryByTestId('custom-button')).not.toBeInTheDocument();
+ });
+
it('should not show Edit button when
atlas.entity.update.allowed is empty', () => {
mockUseAppSelector.mockImplementation((selector: any)
=> {
const mockState = {
diff --git a/dashboard/src/views/Entity/EntityForm.tsx
b/dashboard/src/views/Entity/EntityForm.tsx
index d933b0ec1..c43d93825 100644
--- a/dashboard/src/views/Entity/EntityForm.tsx
+++ b/dashboard/src/views/Entity/EntityForm.tsx
@@ -21,16 +21,17 @@ import {
getEntity,
getTypedef
} from "@api/apiMethods/entityFormApiMethod";
-import FormAutocomplete from "@components/Forms/FormAutocomplete";
-import FormCreatableSelect from "@components/Forms/FormCreatableSelect";
-import FormDatepicker from "@components/Forms/FormDatepicker";
-import FormInputText from "@components/Forms/FormInputText";
-import FormSelectBoolean from "@components/Forms/FormSelectBoolean";
-import FormTextArea from "@components/Forms/FormTextArea";
import CustomModal from "@components/Modal";
import SkeletonLoader from "@components/SkeletonLoader";
import { useAppDispatch, useAppSelector } from "@hooks/reducerHook";
import { Action, DynamicObject, State } from "@models/entityFormType";
+import {
+ buildEnumOptionsForTypeName,
+ isArrayTypeName,
+ isEnumTypeName,
+ normalizeMultiEnumValue,
+ serializeMultiEnumValue
+} from "@utils/enumTypeUtils";
import {
Autocomplete,
FormControl,
@@ -51,11 +52,13 @@ import {
isString,
serverError
} from "@utils/Utils";
+import { filterEditableEntityTypes } from "@utils/entityTypeConfigUtils";
import moment from "moment";
import { useEffect, useReducer, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { toast } from "react-toastify";
+import { renderEntityFormControl } from "./entityFormFields";
export const initialState: State = {
entityTypeObj: null,
@@ -97,10 +100,12 @@ const EntityForm = ({
(state: any) => state.session
);
const { data = {} } = sessionObj || {};
- const entityTypes = data?.[key] || {};
+ const editableEntityTypesConfig = data?.[key];
const { typeHeaderData }: any = useAppSelector(
(state: any) => state.typeHeader
);
+ const { enumObj = {} }: any = useAppSelector((state: any) => state.enum);
+ const { enumDefs = [] } = enumObj?.data || {};
const toastId: any = useRef(null);
const [state, dispatch] = useReducer(reducer, initialState);
const [typeValue, setTypeValue] = useState<any>("");
@@ -217,6 +222,15 @@ const EntityForm = ({
)
) {
val = extractValue(value, type);
+ } else if (isEnumTypeName(type, enumDefs)) {
+ if (isArrayTypeName(type)) {
+ val = normalizeMultiEnumValue(
+ value,
+ buildEnumOptionsForTypeName(type, enumDefs)
+ );
+ } else {
+ val = value || null;
+ }
} else if (type === "date" || type === "time") {
val = moment(value);
} else if (
@@ -314,9 +328,12 @@ const EntityForm = ({
const { entityDefs = {} } = entityData || {};
const optionsList = !isEmpty(entityDefs)
- ? entityDefs.map((entity: { name: string }) => {
- return entity.name;
- })
+ ? filterEditableEntityTypes(
+ entityDefs
+ .map((entity: { name: string }) => entity.name)
+ .filter((name: string) => name.indexOf("__") !== 0),
+ editableEntityTypesConfig
+ )
: [];
const { entityTypeObj }: State = state;
@@ -389,6 +406,12 @@ const EntityForm = ({
)
) {
val = extractValue(value, type);
+ } else if (isEnumTypeName(type, enumDefs)) {
+ if (isArrayTypeName(type)) {
+ val = !isEmpty(value) ? serializeMultiEnumValue(value) : null;
+ } else {
+ val = isString(value) && value.length ? value : null;
+ }
} else if (type === "date" || type === "time") {
val = value?.valueOf();
} else if (
@@ -472,36 +495,13 @@ const EntityForm = ({
};
const renderFormControl: any = (obj: any) => {
- let attributeObj = entityDefs.find(
- (typedef: any) => typedef.name == obj.typeName
- );
- let typeHeaderObj = typeHeaderData.find(
- (typeheader: { name: string }) => typeheader.name == obj.typeName
- );
- switch (obj.typeName) {
- case (obj.typeName.indexOf("map") > -1 ||
- typeHeaderObj?.category === "STRUCT") &&
- obj.typeName:
- return <FormTextArea data={obj} control={control} />;
- case (attributeObj && obj.typeName) ||
- (obj.typeName.indexOf("array") > -1 &&
- obj.cardinality == "SET" &&
- obj.typeName):
- return <FormAutocomplete data={obj} control={control} />;
- case (attributeObj && obj.typeName) ||
- (obj.typeName.indexOf("array") > -1 &&
- obj.cardinality == "SINGLE" &&
- obj.typeName):
- return <FormCreatableSelect data={obj} control={control} />;
- case "boolean":
- return <FormSelectBoolean data={obj} control={control} />;
- case "date":
- case "time":
- return <FormDatepicker data={obj} control={control} />;
-
- default:
- return <FormInputText data={obj} control={control} />;
- }
+ return renderEntityFormControl({
+ obj,
+ control,
+ entityDefs,
+ typeHeaderData,
+ enumDefs
+ });
};
const { name } = extractKeyValueFromEntity(entity) || { name: '', found:
false, key: '' };
@@ -550,18 +550,7 @@ const EntityForm = ({
}}
disableClearable={true}
id="search-by-type"
- options={optionsList
- .map((obj: any) => {
- if (entityTypes != "*") {
- if (obj == entityTypes) {
- return obj;
- }
- } else {
- return obj;
- }
- })
- .filter(Boolean)
- .sort()}
+ options={optionsList.sort()}
className="entity-form-type-select"
renderInput={(params) => {
return (
diff --git a/dashboard/src/views/Entity/entityFormFields.tsx
b/dashboard/src/views/Entity/entityFormFields.tsx
new file mode 100644
index 000000000..9ad99ff52
--- /dev/null
+++ b/dashboard/src/views/Entity/entityFormFields.tsx
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import FormAutocomplete from "@components/Forms/FormAutocomplete";
+import FormCreatableSelect from "@components/Forms/FormCreatableSelect";
+import FormDatepicker from "@components/Forms/FormDatepicker";
+import FormEnumMultiSelect from "@components/Forms/FormEnumMultiSelect";
+import FormInputText from "@components/Forms/FormInputText";
+import FormSelectBoolean from "@components/Forms/FormSelectBoolean";
+import FormSingleSelect from "@components/Forms/FormSingleSelect";
+import FormTextArea from "@components/Forms/FormTextArea";
+import {
+ buildEnumOptionsForTypeName,
+ EnumDef,
+ isArrayTypeName,
+ isEnumTypeName
+} from "@utils/enumTypeUtils";
+
+export const renderEntityFormControl = ({
+ obj,
+ control,
+ entityDefs,
+ typeHeaderData,
+ enumDefs
+}: {
+ obj: {
+ name: string;
+ typeName: string;
+ isOptional: boolean;
+ cardinality?: string;
+ };
+ control: any;
+ entityDefs: Array<{ name: string }>;
+ typeHeaderData: Array<{ name: string; category?: string }>;
+ enumDefs: EnumDef[];
+}) => {
+ const attributeObj = entityDefs.find(
+ (typedef) => typedef.name === obj.typeName
+ );
+ const typeHeaderObj = typeHeaderData.find(
+ (typeheader) => typeheader.name === obj.typeName
+ );
+ const enumOptions = buildEnumOptionsForTypeName(obj.typeName, enumDefs);
+ const isEnumType = isEnumTypeName(obj.typeName, enumDefs);
+ const isMultiEnumType = isArrayTypeName(obj.typeName) && isEnumType;
+ const isEntityReference = Boolean(attributeObj);
+ const isArrayType = obj.typeName.indexOf("array") > -1;
+
+ if (
+ obj.typeName.indexOf("map") > -1 ||
+ typeHeaderObj?.category === "STRUCT"
+ ) {
+ return <FormTextArea data={obj} control={control} />;
+ }
+
+ if (isMultiEnumType) {
+ return (
+ <FormEnumMultiSelect
+ data={obj}
+ control={control}
+ optionsList={enumOptions}
+ />
+ );
+ }
+
+ if (isEnumType) {
+ return (
+ <FormSingleSelect
+ data={obj}
+ control={control}
+ optionsList={enumOptions}
+ typeName={obj.typeName}
+ />
+ );
+ }
+
+ if (isEntityReference || (isArrayType && obj.cardinality === "SET")) {
+ return <FormAutocomplete data={obj} control={control} />;
+ }
+
+ if (isEntityReference || (isArrayType && obj.cardinality === "SINGLE"))
{
+ return <FormCreatableSelect data={obj} control={control} />;
+ }
+
+ if (obj.typeName === "boolean") {
+ return <FormSelectBoolean data={obj} control={control} />;
+ }
+
+ if (obj.typeName === "date" || obj.typeName === "time") {
+ return <FormDatepicker data={obj} control={control} />;
+ }
+
+ return <FormInputText data={obj} control={control} />;
+};
diff --git a/dashboard/src/views/__tests__/EntityForm.test.tsx
b/dashboard/src/views/__tests__/EntityForm.test.tsx
index 63d336faf..d69f46862 100644
--- a/dashboard/src/views/__tests__/EntityForm.test.tsx
+++ b/dashboard/src/views/__tests__/EntityForm.test.tsx
@@ -136,6 +136,16 @@ jest.mock('@components/Forms/FormCreatableSelect', () => ({
default: ({ data }: any) => <input
data-testid={`creatable-${data.name}`} />
}));
+jest.mock('@components/Forms/FormSingleSelect', () => ({
+ __esModule: true,
+ default: ({ data }: any) => <select
data-testid={`enum-single-${data.name}`} />
+}));
+
+jest.mock('@components/Forms/FormEnumMultiSelect', () => ({
+ __esModule: true,
+ default: ({ data }: any) => <select multiple
data-testid={`enum-multi-${data.name}`} />
+}));
+
jest.mock('@components/Modal', () => ({
__esModule: true,
default: ({ open, onClose, children, title, button1Handler,
button2Handler, button2Label }: any) =>
@@ -154,12 +164,18 @@ jest.mock('@components/SkeletonLoader', () => ({
default: () => <div data-testid="skeleton">Loading...</div>
}));
-const createStore = (entityData: any = {}, sessionData: any = {},
typeHeaderData: any = []) => {
+const createStore = (
+ entityData: any = {},
+ sessionData: any = {},
+ typeHeaderData: any = [],
+ enumData: any = { enumDefs: [] }
+) => {
return configureStore({
reducer: {
entity: () => ({ entityData }),
session: () => ({ sessionObj: { data: sessionData } }),
- typeHeader: () => ({ typeHeaderData })
+ typeHeader: () => ({ typeHeaderData }),
+ enum: () => ({ enumObj: { data: enumData } })
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
@@ -204,6 +220,30 @@ describe('EntityForm - 100% Coverage', () => {
{ name: 'output', typeName: 'DataSet',
isOptional: true, cardinality: 'SINGLE' },
{ name: 'outputs', typeName:
'array<DataSet>', isOptional: true, cardinality: 'SINGLE' }
]
+ },
+ {
+ name: 'enumchecking',
+ category: 'ENTITY',
+ attributeDefs: [
+ { name: 'name', typeName: 'string',
isOptional: false, cardinality: 'SINGLE' },
+ { name: 'storageType', typeName:
'ozone_storage_type', isOptional: true, cardinality: 'SINGLE' },
+ { name: 'requiredStorageType',
typeName: 'ozone_storage_type', isOptional: false, cardinality: 'SINGLE' },
+ { name: 'storageTypes', typeName:
'array<ozone_storage_type>', isOptional: true, cardinality: 'SET' }
+ ]
+ },
+ {
+ name: 'hdfs_path',
+ category: 'ENTITY',
+ attributeDefs: [
+ { name: 'path', typeName: 'string',
isOptional: false, cardinality: 'SINGLE' }
+ ]
+ },
+ {
+ name: '__internal_type',
+ category: 'ENTITY',
+ attributeDefs: [
+ { name: 'internalAttr', typeName:
'string', isOptional: false, cardinality: 'SINGLE' }
+ ]
}
]
};
@@ -1750,6 +1790,58 @@ describe('EntityForm - 100% Coverage', () => {
});
describe('Entity Type Selection', () => {
+ const openEntityTypeDropdown = async () => {
+ const autocomplete =
screen.getByLabelText('Search-entity-type');
+ fireEvent.mouseDown(autocomplete);
+ return autocomplete;
+ };
+
+ test('shows enumchecking when listed in session config
(positive)', async () => {
+ const restrictedSessionData = {
+ 'atlas.ui.editable.entity.types':
'hdfs_path,enumchecking'
+ };
+ const store = createStore(mockEntityData,
restrictedSessionData, mockTypeHeaderData);
+
+ render(
+ <Provider store={store}>
+ <MemoryRouter>
+ <EntityForm open={true}
onClose={jest.fn()} />
+ </MemoryRouter>
+ </Provider>
+ );
+
+ await openEntityTypeDropdown();
+
+ await waitFor(() => {
+ expect(screen.getByRole('option', { name:
'enumchecking' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name:
'hdfs_path' })).toBeInTheDocument();
+ });
+ expect(screen.queryByRole('option', { name: 'DataSet'
})).not.toBeInTheDocument();
+ expect(screen.queryByRole('option', { name:
'__internal_type' })).not.toBeInTheDocument();
+ });
+
+ test('hides enumchecking when not listed in session config
(negative)', async () => {
+ const restrictedSessionData = {
+ 'atlas.ui.editable.entity.types': 'DataSet'
+ };
+ const store = createStore(mockEntityData,
restrictedSessionData, mockTypeHeaderData);
+
+ render(
+ <Provider store={store}>
+ <MemoryRouter>
+ <EntityForm open={true}
onClose={jest.fn()} />
+ </MemoryRouter>
+ </Provider>
+ );
+
+ await openEntityTypeDropdown();
+
+ await waitFor(() => {
+ expect(screen.getByRole('option', { name:
'DataSet' })).toBeInTheDocument();
+ });
+ expect(screen.queryByRole('option', { name:
'enumchecking' })).not.toBeInTheDocument();
+ });
+
test('filters entity types when session config is not
wildcard', () => {
const restrictedSessionData = {
'atlas.ui.editable.entity.types': 'DataSet'
@@ -1767,7 +1859,7 @@ describe('EntityForm - 100% Coverage', () => {
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
- test('shows all entity types when session config is wildcard',
() => {
+ test('shows all entity types when session config is wildcard',
async () => {
const store = createStore(mockEntityData,
mockSessionData, mockTypeHeaderData);
render(
@@ -1778,7 +1870,12 @@ describe('EntityForm - 100% Coverage', () => {
</Provider>
);
- expect(screen.getByTestId('modal')).toBeInTheDocument();
+ await openEntityTypeDropdown();
+
+ await waitFor(() => {
+ expect(screen.getByRole('option', { name:
'enumchecking' })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name:
'DataSet' })).toBeInTheDocument();
+ });
});
test('renders entity type selection autocomplete', async () => {
diff --git a/dashboard/src/views/__tests__/EntityFormEnum.test.tsx
b/dashboard/src/views/__tests__/EntityFormEnum.test.tsx
new file mode 100644
index 000000000..a08662116
--- /dev/null
+++ b/dashboard/src/views/__tests__/EntityFormEnum.test.tsx
@@ -0,0 +1,285 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import { useForm } from "react-hook-form";
+import { renderEntityFormControl } from "../Entity/entityFormFields";
+
+jest.mock("@components/Forms/FormInputText", () => ({
+ __esModule: true,
+ default: ({ data }: { data: { name: string } }) => (
+ <input data-testid={`input-${data.name}`} />
+ )
+}));
+
+jest.mock("@components/Forms/FormSingleSelect", () => ({
+ __esModule: true,
+ default: ({ data }: { data: { name: string } }) => (
+ <select data-testid={`enum-single-${data.name}`} />
+ )
+}));
+
+jest.mock("@components/Forms/FormEnumMultiSelect", () => ({
+ __esModule: true,
+ default: ({ data }: { data: { name: string } }) => (
+ <select multiple data-testid={`enum-multi-${data.name}`} />
+ )
+}));
+
+jest.mock("@components/Forms/FormAutocomplete", () => ({
+ __esModule: true,
+ default: ({ data }: { data: { name: string } }) => (
+ <input data-testid={`autocomplete-${data.name}`} />
+ )
+}));
+
+jest.mock("@components/Forms/FormCreatableSelect", () => ({
+ __esModule: true,
+ default: ({ data }: { data: { name: string } }) => (
+ <input data-testid={`creatable-${data.name}`} />
+ )
+}));
+
+jest.mock("@components/Forms/FormSelectBoolean", () => ({
+ __esModule: true,
+ default: ({ data }: { data: { name: string } }) => (
+ <select data-testid={`boolean-${data.name}`} />
+ )
+}));
+
+jest.mock("@components/Forms/FormDatepicker", () => ({
+ __esModule: true,
+ default: ({ data }: { data: { name: string } }) => (
+ <input data-testid={`date-${data.name}`} />
+ )
+}));
+
+jest.mock("@components/Forms/FormTextArea", () => ({
+ __esModule: true,
+ default: ({ data }: { data: { name: string } }) => (
+ <textarea data-testid={`textarea-${data.name}`} />
+ )
+}));
+
+const mockEnumDefs = [
+ {
+ name: "adls_gen2_replication",
+ elementDefs: [{ value: "LRS" }, { value: "ZRS" }, { value:
"GRS" }]
+ },
+ {
+ name: "ozone_storage_type",
+ elementDefs: [
+ { value: "RAM_DISK" },
+ { value: "SSD" },
+ { value: "DISK" },
+ { value: "ARCHIVE" }
+ ]
+ }
+];
+
+const TestHarness = ({
+ typeName,
+ name,
+ cardinality = "SINGLE"
+}: {
+ typeName: string;
+ name: string;
+ cardinality?: string;
+}) => {
+ const { control } = useForm();
+
+ return renderEntityFormControl({
+ obj: {
+ name,
+ typeName,
+ isOptional: true,
+ cardinality
+ },
+ control,
+ entityDefs: [{ name: "DataSet" }],
+ typeHeaderData: [{ name: "CustomStruct", category: "STRUCT" }],
+ enumDefs: mockEnumDefs
+ });
+};
+
+describe("entityFormFields enum rendering", () => {
+ it("renders single enum attributes as dropdown select", () => {
+ render(
+ <TestHarness
+ name="replication"
+ typeName="adls_gen2_replication"
+ />
+ );
+
+
expect(screen.getByTestId("enum-single-replication")).toBeInTheDocument();
+ });
+
+ it("renders array enum attributes as multi-select dropdown", () => {
+ render(
+ <TestHarness
+ name="replicationTypes"
+ typeName="array<adls_gen2_replication>"
+ cardinality="SET"
+ />
+ );
+
+
expect(screen.getByTestId("enum-multi-replicationTypes")).toBeInTheDocument();
+ });
+
+ it("falls back to text input for unknown enum typedef names", () => {
+ render(
+ <TestHarness name="unknownEnum"
typeName="unknown_enum_type" />
+ );
+
+
expect(screen.getByTestId("input-unknownEnum")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("enum-single-unknownEnum")
+ ).not.toBeInTheDocument();
+ });
+
+ it("keeps primitive string attributes on text input", () => {
+ render(<TestHarness name="description" typeName="string" />);
+
+
expect(screen.getByTestId("input-description")).toBeInTheDocument();
+
expect(screen.queryByTestId("enum-single-description")).not.toBeInTheDocument();
+ });
+
+ it("keeps entity reference arrays on autocomplete instead of enum
dropdown", () => {
+ render(
+ <TestHarness
+ name="inputs"
+ typeName="array<DataSet>"
+ cardinality="SET"
+ />
+ );
+
+
expect(screen.getByTestId("autocomplete-inputs")).toBeInTheDocument();
+
expect(screen.queryByTestId("enum-multi-inputs")).not.toBeInTheDocument();
+ });
+
+ it("renders ozone_storage_type single enum as dropdown (positive)", ()
=> {
+ render(
+ <TestHarness name="storageType"
typeName="ozone_storage_type" />
+ );
+
+
expect(screen.getByTestId("enum-single-storageType")).toBeInTheDocument();
+
expect(screen.queryByTestId("input-storageType")).not.toBeInTheDocument();
+ });
+
+ it("renders array<ozone_storage_type> as multi-select (positive)", ()
=> {
+ render(
+ <TestHarness
+ name="storageTypes"
+ typeName="array<ozone_storage_type>"
+ cardinality="SET"
+ />
+ );
+
+
expect(screen.getByTestId("enum-multi-storageTypes")).toBeInTheDocument();
+ });
+
+ it("renders map attributes as textarea instead of enum dropdown
(negative)", () => {
+ render(
+ <TestHarness name="metadata"
typeName="map<string,string>" />
+ );
+
+
expect(screen.getByTestId("textarea-metadata")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("enum-single-metadata")
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders boolean attributes as boolean select instead of enum
dropdown (negative)", () => {
+ render(<TestHarness name="isActive" typeName="boolean" />);
+
+
expect(screen.getByTestId("boolean-isActive")).toBeInTheDocument();
+
expect(screen.queryByTestId("enum-single-isActive")).not.toBeInTheDocument();
+ });
+
+ it("falls back to text input when enumDefs list is empty (negative)",
() => {
+ const EmptyEnumHarness = () => {
+ const { control } = useForm();
+
+ return renderEntityFormControl({
+ obj: {
+ name: "storageType",
+ typeName: "ozone_storage_type",
+ isOptional: true
+ },
+ control,
+ entityDefs: [{ name: "DataSet" }],
+ typeHeaderData: [],
+ enumDefs: []
+ });
+ };
+
+ render(<EmptyEnumHarness />);
+
+
expect(screen.getByTestId("input-storageType")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("enum-single-storageType")
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders date attributes with datepicker (hdfs_path-style
primitive)", () => {
+ render(<TestHarness name="modifiedTime" typeName="date" />);
+
+
expect(screen.getByTestId("date-modifiedTime")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("enum-single-modifiedTime")
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders struct attributes as textarea instead of enum dropdown", ()
=> {
+ const StructHarness = () => {
+ const { control } = useForm();
+
+ return renderEntityFormControl({
+ obj: {
+ name: "customStruct",
+ typeName: "CustomStruct",
+ isOptional: true
+ },
+ control,
+ entityDefs: [],
+ typeHeaderData: [{ name: "CustomStruct",
category: "STRUCT" }],
+ enumDefs: mockEnumDefs
+ });
+ };
+
+ render(<StructHarness />);
+
+
expect(screen.getByTestId("textarea-customStruct")).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("enum-single-customStruct")
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders entity reference arrays with creatable select for SINGLE
cardinality", () => {
+ render(
+ <TestHarness
+ name="path"
+ typeName="array<hdfs_path>"
+ cardinality="SINGLE"
+ />
+ );
+
+
expect(screen.getByTestId("creatable-path")).toBeInTheDocument();
+
expect(screen.queryByTestId("enum-multi-path")).not.toBeInTheDocument();
+ });
+});
diff --git a/dashboardv2/public/js/views/entity/CreateEntityLayoutView.js
b/dashboardv2/public/js/views/entity/CreateEntityLayoutView.js
index be413bfa6..dbbc9f78f 100644
--- a/dashboardv2/public/js/views/entity/CreateEntityLayoutView.js
+++ b/dashboardv2/public/js/views/entity/CreateEntityLayoutView.js
@@ -73,7 +73,7 @@ define(['require',
* @constructs
*/
initialize: function(options) {
- _.extend(this, _.pick(options, 'guid', 'callback',
'showLoader', 'entityDefCollection', 'typeHeaders', 'searchVent'));
+ _.extend(this, _.pick(options, 'guid', 'callback',
'showLoader', 'entityDefCollection', 'typeHeaders', 'searchVent',
'enumDefCollection'));
var that = this,
entityTitle, okLabel;
this.selectStoreCollection = new Backbone.Collection();
@@ -473,6 +473,25 @@ define(['require',
this.value = setValue;
}
});
+ this.$('select[data-enum="true"]').each(function() {
+ var $this = $(this),
+ dataKey = $this.data('key');
+ if ($this.prop('multiple')) {
+ $this.select2({
+ allowClear: !$this.hasClass('true'),
+ multiple: true,
+ placeholder: '-- Select --'
+ });
+ }
+ if (that.guid && that.entityData) {
+ var setValue =
that.entityData.get("entity").attributes[dataKey];
+ if ($this.prop('multiple') && _.isArray(setValue)) {
+ $this.val(setValue).trigger('change');
+ } else if (!$this.prop('multiple') && setValue) {
+ $this.val(setValue).trigger('change');
+ }
+ }
+ });
this.addJsonSearchData();
},
initializeValidation: function() {
@@ -526,6 +545,60 @@ define(['require',
typeOfDefination = (relationshipType ? "Relationships" :
"Attributes");
return '<div class="attribute-dash-box ' + (alloptional ?
"alloptional" : "") + ' "><span class="attribute-type-label">' +
(typeOfDefination) + '</span>' + htmlField + '</div>';
},
+ getInnerEnumTypeName: function(typeName) {
+ if (!typeName || typeName.indexOf("array<") !== 0) {
+ return typeName;
+ }
+ var match = typeName.match(/array<(.*)>/);
+ return match && match[1] ? match[1] : typeName;
+ },
+ getEnumTypeDef: function(typeName) {
+ if (!this.enumDefCollection || !typeName) {
+ return null;
+ }
+ var innerEnumTypeName = this.getInnerEnumTypeName(typeName);
+ return this.enumDefCollection.fullCollection.findWhere({ name:
innerEnumTypeName });
+ },
+ getEnumSelect: function(object) {
+ var value = object.value,
+ name = _.escape(value.name),
+ entityValue = object.entityValue,
+ isAttribute = object.isAttribute,
+ isRelation = object.isRelation,
+ enumTypeDef = object.enumTypeDef,
+ isMultiValued = object.isMultiValued,
+ typeName = value.typeName,
+ optionsHtml = "",
+ selectedValues = [];
+
+ if (isMultiValued && _.isArray(entityValue)) {
+ selectedValues = entityValue;
+ }
+
+ if (!isMultiValued && value.isOptional) {
+ optionsHtml += '<option value="">-- Select ' +
_.escape(enumTypeDef.get("name")) + ' --</option>';
+ }
+
+ _.each(enumTypeDef.get("elementDefs"), function(elementDef) {
+ var selected = "";
+ if (isMultiValued) {
+ if (selectedValues.indexOf(elementDef.value) > -1) {
+ selected = " selected";
+ }
+ } else if (entityValue && elementDef.value ===
entityValue) {
+ selected = " selected";
+ }
+ optionsHtml += '<option value="' +
_.escape(elementDef.value) + '"' + selected + '>' + _.escape(elementDef.value)
+ '</option>';
+ });
+
+ return '<select class="form-control row-margin-bottom
entityInputBox ' + (value.isOptional === true ? "false" : "true") +
+ '" data-type="' + typeName +
+ '" data-attribute="' + isAttribute +
+ '" data-relation="' + isRelation +
+ '" data-key="' + name +
+ '" data-enum="true"' +
+ ' data-id="entityInput"' + (isMultiValued ? '
multiple="multiple"' : '') + '>' + optionsHtml + '</select>';
+ },
getSelect: function(object) {
var value = object.value,
name = _.escape(value.name),
@@ -606,7 +679,9 @@ define(['require',
entityValue = "";
if (this.guid) {
var dataValue =
this.entityData.get("entity").attributes[value.name];
- if (_.isObject(dataValue)) {
+ if (_.isArray(dataValue)) {
+ entityValue = dataValue;
+ } else if (_.isObject(dataValue)) {
entityValue = JSON.stringify(dataValue);
} else {
if (dataValue) {
@@ -621,6 +696,17 @@ define(['require',
}
}
}
+ var enumTypeDef = this.getEnumTypeDef(typeName);
+ if (enumTypeDef) {
+ return this.getEnumSelect({
+ "value": value,
+ "entityValue": entityValue,
+ "isAttribute": isAttribute,
+ "isRelation": isRelation,
+ "enumTypeDef": enumTypeDef,
+ "isMultiValued": typeName.indexOf("array<") === 0
+ });
+ }
if ((typeName &&
this.entityDefCollection.fullCollection.find({ name: typeName })) || typeName
=== "boolean" || typeName.indexOf("array") > -1) {
return this.getSelect({
"value": value,
@@ -721,7 +807,13 @@ define(['require',
val = null;
// Extract Data
if (dataTypeEnitity && datakeyEntity) {
- if (that.entityDefCollection.fullCollection.find({
name: dataTypeEnitity })) {
+ if ($(this).data('enum')) {
+ if ($(this).prop('multiple')) {
+ val = (value && value.length) ? value :
null;
+ } else {
+ val = (value && value.length) ? value :
null;
+ }
+ } else if
(that.entityDefCollection.fullCollection.find({ name: dataTypeEnitity })) {
val = extractValue(value, typeName);
} else if (dataTypeEnitity === 'date' ||
dataTypeEnitity === 'time') {
val = Date.parse(value);
diff --git a/dashboardv2/public/js/views/entity/EntityDetailTableLayoutView.js
b/dashboardv2/public/js/views/entity/EntityDetailTableLayoutView.js
index eb4647c2c..d162fb97f 100644
--- a/dashboardv2/public/js/views/entity/EntityDetailTableLayoutView.js
+++ b/dashboardv2/public/js/views/entity/EntityDetailTableLayoutView.js
@@ -67,7 +67,7 @@ define(['require',
* @constructs
*/
initialize: function(options) {
- _.extend(this, _.pick(options, 'entity', 'typeHeaders',
'attributeDefs', 'attributes', 'editEntity', 'guid', 'entityDefCollection',
'searchVent', 'fetchCollection', 'isRelationshipDetailPage'));
+ _.extend(this, _.pick(options, 'entity', 'typeHeaders',
'attributeDefs', 'attributes', 'editEntity', 'guid', 'entityDefCollection',
'enumDefCollection', 'searchVent', 'fetchCollection',
'isRelationshipDetailPage'));
this.entityModel = new VEntity({});
this.showAllProperties = false;
},
@@ -104,6 +104,7 @@ define(['require',
searchVent: that.searchVent,
entityDefCollection: that.entityDefCollection,
typeHeaders: that.typeHeaders,
+ enumDefCollection: that.enumDefCollection,
callback: function() {
that.fetchCollection();
}
diff --git a/dashboardv2/public/js/views/search/SearchResultLayoutView.js
b/dashboardv2/public/js/views/search/SearchResultLayoutView.js
index 90c337671..160736971 100644
--- a/dashboardv2/public/js/views/search/SearchResultLayoutView.js
+++ b/dashboardv2/public/js/views/search/SearchResultLayoutView.js
@@ -1372,6 +1372,7 @@ define(['require',
var view = new CreateEntityLayoutView({
entityDefCollection: that.entityDefCollection,
typeHeaders: that.typeHeaders,
+ enumDefCollection: that.enumDefCollection,
searchVent: that.searchVent,
callback: function() {
that.fetchCollection();