This is an automated email from the ASF dual-hosted git repository. pawarprasad123 pushed a commit to branch ATLAS-5371 in repository https://gitbox.apache.org/repos/asf/atlas.git
commit 31010f9971d8146deb794255fe63782e63481a6b Author: Prasad Pawar <[email protected]> AuthorDate: Mon Aug 10 22:17:32 2026 +0530 ATLAS-5371: Atlas UI: Render ENUM-typed entity attributes as dropdowns in Create/Edit Entity form --- .../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();
