From 942bb16e01ccb792c6cf2cd88012bd2d2f73f8f7 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:55:11 +0530 Subject: [PATCH 01/67] refactor(query-builder): reroute antd package imports to ui package Co-Authored-By: Claude Sonnet 4.6 --- .../ContractSemanticFormTab/ContractSemanticFormTab.tsx | 2 +- .../components/Explore/AdvanceSearchModal.component.tsx | 2 +- .../AdvanceSearchProvider.component.tsx | 4 ++-- .../AdvanceSearchProvider.interface.ts | 2 +- .../AdvancedAssetsFilterField.component.tsx | 2 +- .../QueryBuilderWidget/QueryBuilderWidget.tsx | 4 ++-- .../QueryBuilderWidgetV1/QueryBuilderWidgetV1.test.tsx | 6 +++--- .../common/QueryBuilderWidgetV1/QueryBuilderWidgetV1.tsx | 4 ++-- .../main/resources/ui/src/utils/AdvancedSearchClassBase.ts | 7 +++---- .../main/resources/ui/src/utils/AdvancedSearchPureUtils.ts | 4 ++-- .../main/resources/ui/src/utils/AdvancedSearchUtils.tsx | 2 +- .../main/resources/ui/src/utils/CuratedAssetsPureUtils.ts | 2 +- .../resources/ui/src/utils/JSONLogicSearchClassBase.ts | 6 +++--- .../main/resources/ui/src/utils/QueryBuilderPureUtils.ts | 2 +- .../src/main/resources/ui/src/utils/QueryBuilderUtils.tsx | 2 +- 15 files changed, 25 insertions(+), 26 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSemanticFormTab/ContractSemanticFormTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSemanticFormTab/ContractSemanticFormTab.tsx index 001f119e831e..1a45eb14f335 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSemanticFormTab/ContractSemanticFormTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSemanticFormTab/ContractSemanticFormTab.tsx @@ -12,7 +12,7 @@ */ import Icon from '@ant-design/icons'; -import { Actions, JsonTree } from '@react-awesome-query-builder/antd'; +import { Actions, JsonTree } from '@react-awesome-query-builder/ui'; import { Button, Col, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchModal.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchModal.component.tsx index e29185044e39..07f2d36158c4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchModal.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchModal.component.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Builder, Query } from '@react-awesome-query-builder/antd'; +import { Builder, Query } from '@react-awesome-query-builder/ui'; import { Button, Modal, Space, Typography } from 'antd'; import { FunctionComponent } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component.tsx index 412a32487f62..5e66524a8eb6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.component.tsx @@ -17,8 +17,8 @@ import { ImmutableTree, OldJsonTree, Utils as QbUtils, -} from '@react-awesome-query-builder/antd'; -import '@react-awesome-query-builder/antd/css/styles.css'; +} from '@react-awesome-query-builder/ui'; +import '@react-awesome-query-builder/ui/css/styles.css'; import { isEmpty, isEqual, isNil, isString } from 'lodash'; import Qs from 'qs'; import { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.interface.ts index a9719ee334d9..1f6d2962150b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.interface.ts @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { Config, ImmutableTree } from '@react-awesome-query-builder/antd'; +import type { Config, ImmutableTree } from '@react-awesome-query-builder/ui'; import { ReactNode } from 'react'; import { SearchIndex } from '../../../enums/search.enum'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/CuratedAssetsWidget/AdvancedAssetsFilterField/AdvancedAssetsFilterField.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/CuratedAssetsWidget/AdvancedAssetsFilterField/AdvancedAssetsFilterField.component.tsx index 0ec67c0d8138..b3a48ae5a64d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/CuratedAssetsWidget/AdvancedAssetsFilterField/AdvancedAssetsFilterField.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/CuratedAssetsWidget/AdvancedAssetsFilterField/AdvancedAssetsFilterField.component.tsx @@ -18,7 +18,7 @@ import { JsonTree, Query, Utils as QbUtils, -} from '@react-awesome-query-builder/antd'; +} from '@react-awesome-query-builder/ui'; import { Col, Form, Input, Row, Skeleton } from 'antd'; import { debounce, isEmpty, isUndefined } from 'lodash'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/QueryBuilderWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/QueryBuilderWidget.tsx index f616cc5c565a..2d212bd67a6d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/QueryBuilderWidget.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/QueryBuilderWidget.tsx @@ -18,8 +18,8 @@ import { ImmutableTree, Query, Utils as QbUtils, -} from '@react-awesome-query-builder/antd'; -import '@react-awesome-query-builder/antd/css/styles.css'; +} from '@react-awesome-query-builder/ui'; +import '@react-awesome-query-builder/ui/css/styles.css'; import { WidgetProps } from '@rjsf/utils'; import { Alert, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/QueryBuilderWidgetV1/QueryBuilderWidgetV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/QueryBuilderWidgetV1/QueryBuilderWidgetV1.test.tsx index 9af0e8328b3f..4083b990cb1c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/QueryBuilderWidgetV1/QueryBuilderWidgetV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/QueryBuilderWidgetV1/QueryBuilderWidgetV1.test.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { JsonTree, Utils } from '@react-awesome-query-builder/antd'; +import { JsonTree, Utils } from '@react-awesome-query-builder/ui'; import '@testing-library/jest-dom'; import { act, @@ -93,8 +93,8 @@ const mocks = { }, }; -jest.mock('@react-awesome-query-builder/antd', () => { - const actual = jest.requireActual('@react-awesome-query-builder/antd'); +jest.mock('@react-awesome-query-builder/ui', () => { + const actual = jest.requireActual('@react-awesome-query-builder/ui'); return { ...actual, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/QueryBuilderWidgetV1/QueryBuilderWidgetV1.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/QueryBuilderWidgetV1/QueryBuilderWidgetV1.tsx index 26ef761b5f75..0b42eee3bf14 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/QueryBuilderWidgetV1/QueryBuilderWidgetV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/QueryBuilderWidgetV1/QueryBuilderWidgetV1.tsx @@ -22,8 +22,8 @@ import { JsonTree, Query, Utils as QbUtils, -} from '@react-awesome-query-builder/antd'; -import '@react-awesome-query-builder/antd/css/styles.css'; +} from '@react-awesome-query-builder/ui'; +import '@react-awesome-query-builder/ui/css/styles.css'; import { Alert, Button, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts index 487accff3718..248b2a9d11a9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts @@ -12,15 +12,14 @@ */ import { - AntdConfig, + BasicConfig, type AsyncFetchListValuesResult, - type BasicConfig, type Field, type Fields, type ListItem, type ListValues, type SelectFieldSettings, -} from '@react-awesome-query-builder/antd'; +} from '@react-awesome-query-builder/ui'; import { debounce, isEmpty, sortBy, toLower } from 'lodash'; import { SearchOutputType, @@ -58,7 +57,7 @@ const ENUM_ASYNC_FETCH_PAGE_SIZE = 100; type OMField = Field & { __omPropertyType: CustomPropertySummary['type'] }; class AdvancedSearchClassBase { - baseConfig = AntdConfig; + baseConfig = BasicConfig; configTypes: BasicConfig['types'] = { ...this.baseConfig.types, multiselect: { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.ts index 64619f7de741..b9857752d3e1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchPureUtils.ts @@ -10,8 +10,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { OldJsonTree } from '@react-awesome-query-builder/antd'; -import { Utils as QbUtils } from '@react-awesome-query-builder/antd'; +import type { OldJsonTree } from '@react-awesome-query-builder/ui'; +import { Utils as QbUtils } from '@react-awesome-query-builder/ui'; import { isArray, isEmpty, toLower } from 'lodash'; import type { Bucket } from 'Models'; import type { ExploreQuickFilterField } from '../components/Explore/ExplorePage.interface'; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx index 50d28c1c619e..b1c81ad053ee 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx @@ -19,7 +19,7 @@ import { ListValues, RenderSettings, ValueSource, -} from '@react-awesome-query-builder/antd'; +} from '@react-awesome-query-builder/ui'; import { Button, Checkbox, MenuProps, Radio, Space, Typography } from 'antd'; import { isArray, isEmpty } from 'lodash'; import React from 'react'; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CuratedAssetsPureUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/CuratedAssetsPureUtils.ts index 86bd0e299f0c..50b2b40cd2a5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CuratedAssetsPureUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CuratedAssetsPureUtils.ts @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Config, Utils as QbUtils } from '@react-awesome-query-builder/antd'; +import { Config, Utils as QbUtils } from '@react-awesome-query-builder/ui'; import { isEmpty } from 'lodash'; import { Bucket } from 'Models'; import Qs from 'qs'; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts index 4e2f107b3415..2a37cb1cabb0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts @@ -11,7 +11,7 @@ * limitations under the License. */ import { - AntdConfig, + BasicConfig, AsyncFetchListValuesResult, Config, FieldOrGroup, @@ -19,7 +19,7 @@ import { ListItem, Operators, SelectFieldSettings, -} from '@react-awesome-query-builder/antd'; +} from '@react-awesome-query-builder/ui'; import { get, sortBy, toLower } from 'lodash'; import { LIST_VALUE_OPERATORS, @@ -49,7 +49,7 @@ import { getFieldsByKeys } from './QueryBuilderPureUtils'; import { renderJSONLogicQueryBuilderButtons } from './QueryBuilderUtils'; class JSONLogicSearchClassBase { - baseConfig = AntdConfig as Config; + baseConfig = BasicConfig as Config; configTypes: Config['types'] = { ...this.baseConfig.types, multiselect: { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderPureUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderPureUtils.ts index 7977048ab466..442c7470798d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderPureUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderPureUtils.ts @@ -15,7 +15,7 @@ import type { Fields, OldJsonItem, OldJsonTree, -} from '@react-awesome-query-builder/antd'; +} from '@react-awesome-query-builder/ui'; import { isBoolean, isEmpty, isUndefined } from 'lodash'; import { EntityReferenceFields } from '../enums/AdvancedSearch.enum'; import { EntityType } from '../enums/entity.enum'; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx index c2b1bee191b0..8325b41fb836 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ import { CloseOutlined, PlusOutlined } from '@ant-design/icons'; -import type { RenderSettings } from '@react-awesome-query-builder/antd'; +import type { RenderSettings } from '@react-awesome-query-builder/ui'; import { Button } from 'antd'; import { t } from './i18next/LocalUtil'; From 4eef5dac3a7e3455506275b781be1f74c09f6a77 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:04:24 +0530 Subject: [PATCH 02/67] feat(query-builder): add OMTextWidget and OMNumberWidget using core Input Replace @react-awesome-query-builder/antd widgets with core-components. Two new widgets using Input component: - OMTextWidget: string input values - OMNumberWidget: numeric input with type="number" All tests passing, TypeScript strict compilation verified. Co-Authored-By: Claude Sonnet 4.6 --- .../queryBuilderWidgets/OMNumberWidget.tsx | 36 +++++++++++ .../queryBuilderWidgets/OMTextWidget.test.tsx | 62 +++++++++++++++++++ .../queryBuilderWidgets/OMTextWidget.tsx | 32 ++++++++++ 3 files changed, 130 insertions(+) create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMNumberWidget.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMTextWidget.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMTextWidget.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMNumberWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMNumberWidget.tsx new file mode 100644 index 000000000000..3d59f268889c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMNumberWidget.tsx @@ -0,0 +1,36 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 { Input } from '@openmetadata/ui-core-components'; +import type { NumberWidgetProps } from '@react-awesome-query-builder/ui'; +import type { FC } from 'react'; + +const OMNumberWidget: FC = ({ + value, + setValue, + placeholder, + readonly, +}) => ( + + setValue(v === '' ? null : (Number(v) as number & null)) + } + /> +); + +export default OMNumberWidget; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMTextWidget.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMTextWidget.test.tsx new file mode 100644 index 000000000000..80ad3b955e46 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMTextWidget.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 { fireEvent, render, screen } from '@testing-library/react'; +import OMNumberWidget from './OMNumberWidget'; +import OMTextWidget from './OMTextWidget'; + +const baseProps = { + placeholder: 'Enter value', + value: '', + setValue: jest.fn(), + readonly: false, + // minimal required props from AbstractWidgetProps + field: {} as any, + fieldDefinition: {} as any, + fieldSrc: 'value', + operator: 'equal', + config: {} as any, + widgetId: 'test', +} as any; + +describe('OMTextWidget', () => { + it('renders an input with the given value', () => { + render(); + + expect(screen.getByDisplayValue('hello')).toBeInTheDocument(); + }); + + it('calls setValue when input changes', () => { + const setValue = jest.fn(); + render(); + const input = screen.getByPlaceholderText( + 'Enter value' + ) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'abc' } }); + + expect(setValue).toHaveBeenCalledWith('abc'); + }); + + it('is disabled when readonly is true', () => { + render(); + + expect(screen.getByRole('textbox')).toBeDisabled(); + }); +}); + +describe('OMNumberWidget', () => { + it('renders a number input', () => { + render(); + + expect(screen.getByDisplayValue('42')).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMTextWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMTextWidget.tsx new file mode 100644 index 000000000000..7e0520ad7ebd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMTextWidget.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 { Input } from '@openmetadata/ui-core-components'; +import type { TextWidgetProps } from '@react-awesome-query-builder/ui'; +import type { FC } from 'react'; + +const OMTextWidget: FC = ({ + value, + setValue, + placeholder, + readonly, +}) => ( + setValue(v || null)} + /> +); + +export default OMTextWidget; From a634a184132e44f9a7b7fe98e70e849d372e3f01 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:09:49 +0530 Subject: [PATCH 03/67] feat(query-builder): add OMSelectWidget using core Select with async support Implements Task 3 of the query builder migration from Ant Design to openmetadata-ui-core-components. Provides async-capable single-select widget wrapping core Select component. Includes handling for both static list values and async fetch callbacks, with proper TypeScript typing. - Converts listValues (array or object format) to SelectItemType[] - Supports async data loading via asyncFetch callback - Properly disables when readonly - Fully tested with 2 core test cases (render + disabled state) Co-Authored-By: Claude Sonnet 4.6 --- .../OMSelectWidget.test.tsx | 49 ++++++++++ .../queryBuilderWidgets/OMSelectWidget.tsx | 91 +++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.test.tsx new file mode 100644 index 000000000000..496a0c2cb9c1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 type { SelectWidgetProps } from '@react-awesome-query-builder/ui'; +import { render, screen } from '@testing-library/react'; + +import OMSelectWidget from './OMSelectWidget'; + +const baseProps = { + placeholder: 'Select option', + value: null, + setValue: jest.fn(), + readonly: false, + listValues: [ + { value: 'opt1', title: 'Option 1' }, + { value: 'opt2', title: 'Option 2' }, + ], + useAsyncSearch: false, + showSearch: false, + field: {} as any, + fieldDefinition: {} as any, + fieldSrc: 'value' as const, + operator: 'select_equals', + config: {} as any, + widgetId: 'test', +} as unknown as SelectWidgetProps; + +describe('OMSelectWidget', () => { + it('renders without crashing', () => { + render(); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('is disabled when readonly', () => { + render(); + + expect(screen.getByRole('button')).toBeDisabled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.tsx new file mode 100644 index 000000000000..f5b69a8b83a1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.tsx @@ -0,0 +1,91 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 { Select, SelectItemType } from '@openmetadata/ui-core-components'; +import type { + ListItem, + SelectWidgetProps, +} from '@react-awesome-query-builder/ui'; +import type { FC } from 'react'; +import { useCallback, useEffect, useState } from 'react'; + +const toSelectItems = ( + listValues: SelectWidgetProps['listValues'] +): SelectItemType[] => { + if (!listValues) { + return []; + } + if (Array.isArray(listValues)) { + return (listValues as ListItem[]).map((item) => ({ + id: String(item.value), + label: String(item.title ?? item.value), + })); + } + + return Object.entries(listValues).map(([k, v]) => ({ + id: k, + label: v as string, + })); +}; + +const OMSelectWidget: FC = ({ + value, + setValue, + placeholder, + readonly, + listValues, + asyncFetch, + useAsyncSearch, +}) => { + const staticItems = toSelectItems(listValues); + const [items, setItems] = useState(staticItems); + + const loadAsync = useCallback( + async (search: string) => { + if (!asyncFetch) { + return; + } + const result = await asyncFetch(search); + setItems( + (result.values as ListItem[]).map((item) => ({ + id: String(item.value), + label: String(item.title ?? item.value), + })) + ); + }, + [asyncFetch] + ); + + useEffect(() => { + if (useAsyncSearch && asyncFetch) { + loadAsync(''); + } + }, [useAsyncSearch, loadAsync]); + + return ( + + ); +}; + +export default OMSelectWidget; From f013d54027fca53f459891b3031db25672b6a41f Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:17:26 +0530 Subject: [PATCH 04/67] feat(query-builder): add OMMultiSelectWidget using core MultiSelect with async support Co-Authored-By: Claude Sonnet 4.6 --- .../OMMultiSelectWidget.test.tsx | 48 +++++++ .../OMMultiSelectWidget.tsx | 133 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMMultiSelectWidget.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMMultiSelectWidget.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMMultiSelectWidget.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMMultiSelectWidget.test.tsx new file mode 100644 index 000000000000..720a144f750d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMMultiSelectWidget.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 type { MultiSelectWidgetProps } from '@react-awesome-query-builder/ui'; +import { render, screen } from '@testing-library/react'; +import OMMultiSelectWidget from './OMMultiSelectWidget'; + +const baseProps = { + placeholder: 'Select options', + value: [], + setValue: jest.fn(), + readonly: false, + listValues: [ + { value: 'a', title: 'Alpha' }, + { value: 'b', title: 'Beta' }, + ], + useAsyncSearch: false, + showSearch: false, + field: {} as any, + fieldDefinition: {} as any, + fieldSrc: 'value' as const, + operator: 'multiselect_equals', + config: {} as any, + widgetId: 'test', +} as unknown as MultiSelectWidgetProps; + +describe('OMMultiSelectWidget', () => { + it('renders without crashing', () => { + render(); + + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('is disabled when readonly', () => { + render(); + + expect(screen.getByRole('combobox')).toBeDisabled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMMultiSelectWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMMultiSelectWidget.tsx new file mode 100644 index 000000000000..3d97fca3da13 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMMultiSelectWidget.tsx @@ -0,0 +1,133 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 { MultiSelect, SelectItemType } from '@openmetadata/ui-core-components'; +import type { + ListItem, + MultiSelectWidgetProps, +} from '@react-awesome-query-builder/ui'; +import { useCallback, useEffect, useState } from 'react'; +import type { Key } from 'react-aria-components'; +import { useListData } from 'react-stately'; + +const toSelectItems = ( + listValues: MultiSelectWidgetProps['listValues'] +): SelectItemType[] => { + if (!listValues) { + return []; + } + if (Array.isArray(listValues)) { + return (listValues as ListItem[]).map((item) => ({ + id: String(item.value), + label: String(item.title ?? item.value), + })); + } + + return Object.entries(listValues).map(([k, v]) => ({ + id: k, + label: v as string, + })); +}; + +const OMMultiSelectWidget = ({ + value, + setValue, + placeholder, + readonly, + listValues, + asyncFetch, + useAsyncSearch, +}: MultiSelectWidgetProps) => { + const valueArray = Array.isArray(value) ? value.map(String) : []; + const staticItems = toSelectItems(listValues); + const [allItems, setAllItems] = useState(staticItems); + + const selectedItems = useListData({ + initialItems: staticItems.filter((i) => valueArray.includes(i.id)), + }); + + useEffect(() => { + const currentIds = new Set(selectedItems.items.map((i) => i.id)); + const targetIds = new Set(valueArray); + + for (const id of targetIds) { + if (!currentIds.has(id)) { + const item = allItems.find((i) => i.id === id); + if (item) { + selectedItems.append(item); + } + } + } + for (const item of selectedItems.items) { + if (!targetIds.has(item.id)) { + selectedItems.remove(item.id); + } + } + }, [valueArray.join(',')]); + + const loadAsync = useCallback( + async (search: string) => { + if (!asyncFetch) { + return; + } + const result = await asyncFetch(search); + setAllItems( + (result.values as ListItem[]).map((item) => ({ + id: String(item.value), + label: String(item.title ?? item.value), + })) + ); + }, + [asyncFetch] + ); + + useEffect(() => { + if (useAsyncSearch && asyncFetch) { + loadAsync(''); + } + }, [useAsyncSearch, loadAsync]); + + const handleItemInserted = useCallback( + (key: Key) => { + setValue([...valueArray, String(key)]); + }, + [valueArray, setValue] + ); + + const handleItemCleared = useCallback( + (key: Key) => { + const next = valueArray.filter((v) => v !== String(key)); + setValue(next.length > 0 ? next : null); + }, + [valueArray, setValue] + ); + + return ( + loadAsync(search)} + onItemCleared={handleItemCleared} + onItemInserted={handleItemInserted}> + {(item) => ( + + {item.label} + + )} + + ); +}; + +export default OMMultiSelectWidget; From 7b9b943b86d04f4cbbd2d960ae6c2652246f03f1 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:24:18 +0530 Subject: [PATCH 05/67] feat(query-builder): add OMBooleanWidget and OMDateWidget Co-Authored-By: Claude Sonnet 4.6 --- .../OMBooleanWidget.test.tsx | 45 ++++++++++++++++ .../queryBuilderWidgets/OMBooleanWidget.tsx | 29 +++++++++++ .../queryBuilderWidgets/OMDateWidget.tsx | 51 +++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMBooleanWidget.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMBooleanWidget.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMDateWidget.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMBooleanWidget.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMBooleanWidget.test.tsx new file mode 100644 index 000000000000..61ba70ae925a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMBooleanWidget.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 type { + BooleanWidgetProps, + FieldSource, +} from '@react-awesome-query-builder/ui'; +import { render, screen } from '@testing-library/react'; +import OMBooleanWidget from './OMBooleanWidget'; + +const baseProps: Partial = { + value: false, + setValue: jest.fn(), + readonly: false, + field: {} as any, + fieldDefinition: {} as any, + fieldSrc: 'value' as FieldSource, + operator: 'equal', + config: {} as any, + placeholder: '', + widgetId: 'test', +}; + +describe('OMBooleanWidget', () => { + it('renders a toggle', () => { + render(); + + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('reflects the value prop', () => { + render(); + + expect(screen.getByRole('switch')).toBeChecked(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMBooleanWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMBooleanWidget.tsx new file mode 100644 index 000000000000..82b62ffc1455 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMBooleanWidget.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 { Toggle } from '@openmetadata/ui-core-components'; +import type { BooleanWidgetProps } from '@react-awesome-query-builder/ui'; +import type { FC } from 'react'; + +const OMBooleanWidget: FC = ({ + value, + setValue, + readonly, +}) => ( + setValue(checked)} + /> +); + +export default OMBooleanWidget; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMDateWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMDateWidget.tsx new file mode 100644 index 000000000000..c5fec60e2de4 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMDateWidget.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 type { DateTimeWidgetProps } from '@react-awesome-query-builder/ui'; +import type { FC } from 'react'; + +// Determine the input type based on the widget's time-related settings. +const getInputType = (operator: string): 'date' | 'datetime-local' | 'time' => { + if (operator.includes('time') && !operator.includes('date')) { + return 'time'; + } + if (operator.includes('datetime') || operator.includes('date_time')) { + return 'datetime-local'; + } + + return 'date'; +}; + +const classNameValue = + 'tw:rounded-lg tw:bg-primary tw:px-3 tw:py-2 tw:text-sm tw:text-primary ' + + 'tw:shadow-xs tw:ring-1 tw:ring-primary tw:ring-inset tw:outline-hidden ' + + 'tw:transition tw:duration-100 focus:tw:ring-2 focus:tw:ring-brand ' + + 'disabled:tw:cursor-not-allowed disabled:tw:bg-disabled_subtle disabled:tw:text-disabled'; + +const OMDateWidget: FC = ({ + value, + setValue, + placeholder, + readonly, + operator, +}) => ( + setValue(e.target.value || null)} + /> +); + +export default OMDateWidget; From 2a53e9d406530460649588b3798713945ba73ad4 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:36:50 +0530 Subject: [PATCH 06/67] feat(query-builder): add OMFieldSelect and OMConjs using core Select and ButtonGroup Co-Authored-By: Claude Sonnet 4.6 --- .../src/utils/queryBuilderWidgets/OMConjs.tsx | 49 +++++++++++ .../OMFieldSelect.test.tsx | 87 +++++++++++++++++++ .../queryBuilderWidgets/OMFieldSelect.tsx | 51 +++++++++++ 3 files changed, 187 insertions(+) create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMConjs.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMConjs.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMConjs.tsx new file mode 100644 index 000000000000..53f596af80b2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMConjs.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 { ButtonGroup, ButtonGroupItem } from '@openmetadata/ui-core-components'; +import type { ConjsProps } from '@react-awesome-query-builder/ui'; +import type { FC } from 'react'; +import type { Key } from 'react-aria-components'; + +const OMConjs: FC = ({ + selectedConjunction, + setConjunction, + conjunctionOptions, + readonly, +}) => { + const options = Object.entries(conjunctionOptions ?? {}); + + return ( + () + } + selectionMode="single" + size="sm" + onSelectionChange={(keys: Set) => { + const key = keys.values().next().value; + if (key !== undefined) { + setConjunction(String(key)); + } + }}> + {options.map(([key, opt]) => ( + + {opt.label} + + ))} + + ); +}; + +export default OMConjs; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.test.tsx new file mode 100644 index 000000000000..1835cfe3bdc4 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 { render, screen } from '@testing-library/react'; +import OMConjs from './OMConjs'; +import OMFieldSelect from './OMFieldSelect'; + +const fieldProps = { + items: [ + { + key: 'name', + path: 'name', + label: 'Name', + fullLabel: 'Name', + groupkey: undefined, + grouplabel: undefined, + }, + { + key: 'owner', + path: 'owner', + label: 'Owner', + fullLabel: 'Owner', + groupkey: undefined, + grouplabel: undefined, + }, + ], + selectedKey: 'name', + setField: jest.fn(), + readonly: false, + placeholder: 'Select field', + errorText: '', + config: {} as any, +}; + +const conjsProps = { + id: 'group-1', + selectedConjunction: 'AND', + setConjunction: jest.fn(), + conjunctionOptions: { + AND: { + id: 'AND', + label: 'AND', + checked: true, + key: 'AND', + path: '', + conjunction: 'AND', + }, + OR: { + id: 'OR', + label: 'OR', + checked: false, + key: 'OR', + path: '', + conjunction: 'OR', + }, + }, + not: false, + setNot: jest.fn(), + showNot: false, + readonly: false, +}; + +describe('OMFieldSelect', () => { + it('renders a button (select trigger)', () => { + render(); + + expect(screen.getByRole('button')).toBeInTheDocument(); + }); +}); + +describe('OMConjs', () => { + it('renders AND and OR buttons', () => { + render(); + + expect(screen.getByText('AND')).toBeInTheDocument(); + expect(screen.getByText('OR')).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx new file mode 100644 index 000000000000..5d75401e9ced --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 { Select, SelectItemType } from '@openmetadata/ui-core-components'; +import type { FieldProps } from '@react-awesome-query-builder/ui'; +import type { FC } from 'react'; + +const OMFieldSelect: FC = ({ + items, + selectedKey, + setField, + readonly, + placeholder, +}) => { + const selectItems: SelectItemType[] = items.map((item) => ({ + id: item.key, + label: item.label, + supportingText: item.grouplabel, + })); + + return ( + + ); +}; + +export default OMFieldSelect; From 4e94b3bbb2e9624b53f501b8931d3da49231c4a5 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:42:57 +0530 Subject: [PATCH 07/67] feat(query-builder): add QueryBuilderOMConfig assembling OMConfig from core-component widgets Co-Authored-By: Claude Sonnet 4.6 --- .../ui/src/utils/QueryBuilderOMConfig.tsx | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderOMConfig.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderOMConfig.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderOMConfig.tsx new file mode 100644 index 000000000000..d046dec6ac38 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderOMConfig.tsx @@ -0,0 +1,71 @@ +/* + * Copyright 2024 Collate. + * Licensed 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 type { BasicConfig } from '@react-awesome-query-builder/ui'; +import { BasicConfig as QbBasicConfig } from '@react-awesome-query-builder/ui'; +import OMBooleanWidget from './queryBuilderWidgets/OMBooleanWidget'; +import OMConjs from './queryBuilderWidgets/OMConjs'; +import OMDateWidget from './queryBuilderWidgets/OMDateWidget'; +import OMFieldSelect from './queryBuilderWidgets/OMFieldSelect'; +import OMMultiSelectWidget from './queryBuilderWidgets/OMMultiSelectWidget'; +import OMNumberWidget from './queryBuilderWidgets/OMNumberWidget'; +import OMSelectWidget from './queryBuilderWidgets/OMSelectWidget'; +import OMTextWidget from './queryBuilderWidgets/OMTextWidget'; + +export const OMConfig: BasicConfig = { + ...QbBasicConfig, + settings: { + ...QbBasicConfig.settings, + renderField: (props) => , + renderOperator: (props) => , + renderConjs: (props) => , + }, + widgets: { + ...QbBasicConfig.widgets, + text: { + ...QbBasicConfig.widgets.text, + factory: (props) => , + }, + textarea: { + ...QbBasicConfig.widgets.textarea, + factory: (props) => , + }, + number: { + ...QbBasicConfig.widgets.number, + factory: (props) => , + }, + select: { + ...QbBasicConfig.widgets.select, + factory: (props) => , + }, + multiselect: { + ...QbBasicConfig.widgets.multiselect, + factory: (props) => , + }, + boolean: { + ...QbBasicConfig.widgets.boolean, + factory: (props) => , + }, + date: { + ...QbBasicConfig.widgets.date, + factory: (props) => , + }, + time: { + ...QbBasicConfig.widgets.time, + factory: (props) => , + }, + datetime: { + ...QbBasicConfig.widgets.datetime, + factory: (props) => , + }, + }, +}; From 371d8cd17a7213f4fdffdb3ff20086edb04b7ef6 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:48:20 +0530 Subject: [PATCH 08/67] feat(query-builder): wire OMConfig into AdvancedSearchClassBase, migrate button renderers to core-components - AdvancedSearchClassBase: replace BasicConfig value with OMConfig from QueryBuilderOMConfig; BasicConfig is now type-only - AdvancedSearchUtils: renderAdvanceSearchButtons uses Button (core), X and Trash01 icons from @untitledui/icons; removes @ant-design/icons and antd Button imports - QueryBuilderUtils: renderQueryBuilderFilterButtons and renderJSONLogicQueryBuilderButtons use Button (core) and X/Plus from @untitledui/icons; removes antd and @ant-design/icons imports Co-Authored-By: Claude Sonnet 4.6 --- .../ui/src/utils/AdvancedSearchClassBase.ts | 5 +- .../ui/src/utils/AdvancedSearchUtils.tsx | 51 ++++++++-------- .../ui/src/utils/QueryBuilderUtils.tsx | 59 ++++++++++--------- 3 files changed, 59 insertions(+), 56 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts index 248b2a9d11a9..94f24256be7b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchClassBase.ts @@ -12,14 +12,15 @@ */ import { - BasicConfig, type AsyncFetchListValuesResult, + type BasicConfig, type Field, type Fields, type ListItem, type ListValues, type SelectFieldSettings, } from '@react-awesome-query-builder/ui'; +import { OMConfig } from './QueryBuilderOMConfig'; import { debounce, isEmpty, sortBy, toLower } from 'lodash'; import { SearchOutputType, @@ -57,7 +58,7 @@ const ENUM_ASYNC_FETCH_PAGE_SIZE = 100; type OMField = Field & { __omPropertyType: CustomPropertySummary['type'] }; class AdvancedSearchClassBase { - baseConfig = BasicConfig; + baseConfig = OMConfig; configTypes: BasicConfig['types'] = { ...this.baseConfig.types, multiselect: { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx index b1c81ad053ee..f44fc1c55d1d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.tsx @@ -11,8 +11,6 @@ * limitations under the License. */ -import Icon, { CloseCircleOutlined, PlusOutlined } from '@ant-design/icons'; -import { CustomIconComponentProps } from '@ant-design/icons/lib/components/Icon'; import { Field, FieldOrGroup, @@ -20,10 +18,10 @@ import { RenderSettings, ValueSource, } from '@react-awesome-query-builder/ui'; -import { Button, Checkbox, MenuProps, Radio, Space, Typography } from 'antd'; +import { Checkbox, MenuProps, Radio, Space, Typography } from 'antd'; +import { Button } from '@openmetadata/ui-core-components'; +import { Plus, Trash01, X } from '@untitledui/icons'; import { isArray, isEmpty } from 'lodash'; -import React from 'react'; -import { ReactComponent as IconDeleteColored } from '../assets/svg/ic-delete-colored.svg'; import ProfilePicture from '../components/common/ProfilePicture/ProfilePicture'; import { SearchOutputType } from '../components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.interface'; import { ExploreQuickFilterField } from '../components/Explore/ExplorePage.interface'; @@ -50,48 +48,47 @@ export const renderAdvanceSearchButtons: RenderSettings['renderButton'] = ( if (type === 'delRule') { return ( - - } + ); - } else if (type === 'addRule') { + } + + if (type === 'addRule') { return ( ); - } else if (type === 'addGroup') { + } + + if (type === 'addGroup') { return ( ); - } else if (type === 'delGroup') { + } + + if (type === 'delGroup') { return ( - void} /> ); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx index 8325b41fb836..83370d7c885f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.tsx @@ -10,9 +10,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { CloseOutlined, PlusOutlined } from '@ant-design/icons'; import type { RenderSettings } from '@react-awesome-query-builder/ui'; -import { Button } from 'antd'; +import { Button } from '@openmetadata/ui-core-components'; +import { Plus, X } from '@untitledui/icons'; import { t } from './i18next/LocalUtil'; export const renderQueryBuilderFilterButtons: RenderSettings['renderButton'] = ( @@ -22,32 +22,34 @@ export const renderQueryBuilderFilterButtons: RenderSettings['renderButton'] = ( if (type === 'delRule') { return ( - ); } @@ -61,30 +63,33 @@ export const renderJSONLogicQueryBuilderButtons: RenderSettings['renderButton'] if (type === 'delRule') { return ( - - - )} - - + {showFilteredResourceCount && ( + + + + {t('message.click-here-to-view-assets-on-explore')} + + + + )} + ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/QueryBuilderWidgetV1/query-builder-widget-v1.less b/openmetadata-ui/src/main/resources/ui/src/components/common/QueryBuilderWidgetV1/query-builder-widget-v1.less index 707adab33e4a..9c7c8e603c14 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/QueryBuilderWidgetV1/query-builder-widget-v1.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/QueryBuilderWidgetV1/query-builder-widget-v1.less @@ -11,18 +11,8 @@ * limitations under the License. */ -@import (reference) '../../../styles/variables.less'; - .query-builder-card { - background-color: @grey-6; - .ant-alert-info { - background-color: @blue-8; - border-color: @blue-7; - - .ant-alert-icon { - color: @blue-7; - } - } + background-color: var(--color-bg-secondary); } .query-builder-form-field @@ -37,7 +27,7 @@ position: absolute !important; margin-top: 0; right: 0; - top: -56px; // updating this as size of button is increased + top: -56px; display: block; } } @@ -50,12 +40,6 @@ } .query-builder-form-field { - .ant-select-disabled.ant-select:not(.ant-select-customize-input) - .ant-select-selector { - color: @black; - background-color: @background-color; - } - .hide--line.one--child { margin-top: 0; padding-top: 16px; @@ -75,10 +59,6 @@ .group--field { width: 180px; - .ant-select { - width: 100% !important; - } - label { font-weight: normal; margin-bottom: 6px; @@ -91,7 +71,7 @@ .rule.group-or-rule { .rule--header { - .ant-btn-group { + .rule--btn-group { margin: 0 !important; align-self: flex-start; } @@ -106,10 +86,6 @@ .group--field { margin: 0px; flex: 0 1 25%; - - .ant-select { - min-width: 100% !important; // override the inline min-width style of the select provided by antd - } } } @@ -129,10 +105,6 @@ display: none; } - .rule-container .ant-btn-group { - visibility: visible; - } - .action.action--ADD-RULE { position: static !important; margin-top: 8px; @@ -152,19 +124,11 @@ .widget--widget { margin: 0px; flex: 1; - - .ant-col { - padding: 0px !important; // remove padding from ant-col inline styling by antd - } } .rule--operator, .rule--value .rule--widget { width: 100%; - - .ant-select { - min-width: 100% !important; // override the inline min-width style of the select provided by antd - } } } } @@ -199,10 +163,6 @@ } } } - - .rule-container .ant-btn-group { - visibility: visible; - } } } @@ -218,47 +178,42 @@ } .json-logic-field-select { - .ant-select-item-group { + .item-group { padding-left: 8px; position: relative; - color: @text-color; + color: var(--color-text-primary); font-size: 14px; - background-color: @grey-6; + background-color: var(--color-bg-secondary); } - /* Add vertical line for children */ - .ant-select-item-option-grouped { + .item-option-grouped { position: relative; padding-left: 32px; - /* Indentation for child items */ } - /* Add vertical line before each child */ - .ant-select-item-option-grouped::before { + .item-option-grouped::before { content: ''; position: absolute; left: 16px; top: 0; bottom: 0; width: 1px; - background-color: @border-color; + background-color: var(--color-border-primary); } - /* Adjust line height for last child */ - .ant-select-item-option-grouped:last-child::before { + .item-option-grouped:last-child::before { height: 16px; bottom: auto; } - /* Add horizontal connector for each child */ - .ant-select-item-option-grouped::after { + .item-option-grouped::after { content: ''; position: absolute; left: 16px; top: 16px; width: 10px; height: 1px; - background-color: @border-color; + background-color: var(--color-border-primary); } } From 4e55e9be8602dd4c90133c3920e7d7085cf836d0 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:12:59 +0530 Subject: [PATCH 11/67] refactor(query-builder): update test files from antd to ui package Updated 7 test files to import from @react-awesome-query-builder/ui instead of @react-awesome-query-builder/antd, and replaced AntdConfig with BasicConfig. Applied UI checkstyle (organize-imports, lint:fix, prettier) on all modified test files. Co-Authored-By: Claude Sonnet 4.6 --- .../ContractSemanticFormTab.test.tsx | 2 +- .../AdvancedAssetsFilterField.test.tsx | 2 +- .../QueryBuilderWidget/QueryBuilderWidget.test.tsx | 4 ++-- .../src/components/common/TagsSection/TagsSection.test.tsx | 6 +++--- .../resources/ui/src/utils/AdvancedSearchUtils.test.tsx | 6 +++--- .../main/resources/ui/src/utils/CuratedAssetsUtils.test.tsx | 2 +- .../main/resources/ui/src/utils/JSONLogicSearchClassBase.ts | 2 +- .../main/resources/ui/src/utils/QueryBuilderUtils.test.ts | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSemanticFormTab/ContractSemanticFormTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSemanticFormTab/ContractSemanticFormTab.test.tsx index 9ef906467e99..3184eb45d526 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSemanticFormTab/ContractSemanticFormTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSemanticFormTab/ContractSemanticFormTab.test.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Actions, JsonTree } from '@react-awesome-query-builder/antd'; +import { Actions, JsonTree } from '@react-awesome-query-builder/ui'; import '@testing-library/jest-dom'; import { act, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/CuratedAssetsWidget/AdvancedAssetsFilterField/AdvancedAssetsFilterField.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/CuratedAssetsWidget/AdvancedAssetsFilterField/AdvancedAssetsFilterField.test.tsx index d514c72879bc..9efb17d030f1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/CuratedAssetsWidget/AdvancedAssetsFilterField/AdvancedAssetsFilterField.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/CuratedAssetsWidget/AdvancedAssetsFilterField/AdvancedAssetsFilterField.test.tsx @@ -37,7 +37,7 @@ jest.mock( }) ); -jest.mock('@react-awesome-query-builder/antd', () => ({ +jest.mock('@react-awesome-query-builder/ui', () => ({ Builder: jest .fn() .mockImplementation(() => ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/QueryBuilderWidget.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/QueryBuilderWidget.test.tsx index b15cb8a2f644..3da50bac5c44 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/QueryBuilderWidget.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/QueryBuilderWidget/QueryBuilderWidget.test.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AntdConfig } from '@react-awesome-query-builder/antd'; +import { BasicConfig } from '@react-awesome-query-builder/ui'; import { Registry } from '@rjsf/utils'; import { render, screen } from '@testing-library/react'; import React from 'react'; @@ -19,7 +19,7 @@ import QueryBuilderWidget from './QueryBuilderWidget'; const mockOnFocus = jest.fn(); const mockOnBlur = jest.fn(); const mockOnChange = jest.fn(); -const baseConfig = AntdConfig; +const baseConfig = BasicConfig; jest.mock( '../../../../../Explore/AdvanceSearchProvider/AdvanceSearchProvider.component', diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TagsSection/TagsSection.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TagsSection/TagsSection.test.tsx index 3f214cde9318..c104c2134f9c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TagsSection/TagsSection.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TagsSection/TagsSection.test.tsx @@ -21,9 +21,9 @@ import { } from '../../../generated/type/tagLabel'; import TagsSection from './TagsSection'; -// Mock @react-awesome-query-builder/antd -jest.mock('@react-awesome-query-builder/antd', () => ({ - ...jest.requireActual('@react-awesome-query-builder/antd'), +// Mock @react-awesome-query-builder/ui +jest.mock('@react-awesome-query-builder/ui', () => ({ + ...jest.requireActual('@react-awesome-query-builder/ui'), Config: {}, Utils: { loadFromJsonLogic: jest.fn(), diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.test.tsx index 258c07b8109d..3854f62d723b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/AdvancedSearchUtils.test.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { FieldOrGroup } from '@react-awesome-query-builder/antd'; +import { FieldOrGroup } from '@react-awesome-query-builder/ui'; import { SearchOutputType } from '../components/Explore/AdvanceSearchProvider/AdvanceSearchProvider.interface'; import { AssetsOfEntity } from '../components/Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; import { SearchDropdownOption } from '../components/SearchDropdown/SearchDropdown.interface'; @@ -78,8 +78,8 @@ jest.mock('./AdvancedSearchClassBase', () => ({ const mockUuid = jest.fn(); let uuidCounter = 0; -jest.mock('@react-awesome-query-builder/antd', () => ({ - ...jest.requireActual('@react-awesome-query-builder/antd'), +jest.mock('@react-awesome-query-builder/ui', () => ({ + ...jest.requireActual('@react-awesome-query-builder/ui'), Utils: { uuid: () => mockUuid(), }, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CuratedAssetsUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CuratedAssetsUtils.test.tsx index 88026bb55cec..7d9501047a49 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CuratedAssetsUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CuratedAssetsUtils.test.tsx @@ -40,7 +40,7 @@ jest.mock('./RouterUtils', () => ({ getExplorePath: jest.fn().mockReturnValue('/explore'), })); -jest.mock('@react-awesome-query-builder/antd', () => ({ +jest.mock('@react-awesome-query-builder/ui', () => ({ Utils: { checkTree: jest.fn().mockReturnValue({}), loadTree: jest.fn().mockReturnValue({}), diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts index 2a37cb1cabb0..693a724108c8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts @@ -11,8 +11,8 @@ * limitations under the License. */ import { - BasicConfig, AsyncFetchListValuesResult, + BasicConfig, Config, FieldOrGroup, Fields, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.test.ts index 9835cabf7c23..85ddb612d18c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/QueryBuilderUtils.test.ts @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Fields } from '@react-awesome-query-builder/antd'; +import { Fields } from '@react-awesome-query-builder/ui'; import { EntityType } from '../enums/entity.enum'; import { QueryFieldInterface, From 9ca0533ceaa37ba6130c6ff63b1284f9607aa103 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:27:46 +0530 Subject: [PATCH 12/67] fix(query-builder): correct OMDateWidget fieldType detection, remove invalid type cast, wire JSONLogicSearchClassBase to OMConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OMDateWidget: replace getInputType(operator) with fieldType prop — operator values like "equal"/"less" never contain "time"/"datetime"; fieldType is the correct discriminant - OMDateWidget: fix tw:bg-disabled_subtle → tw:bg-disabled-subtle (underscore → dash matches CSS token) - OMNumberWidget: remove impossible `as number & null` intersection cast; Number(v) is already number - JSONLogicSearchClassBase: import OMConfig and set baseConfig = OMConfig so JSON-logic query builder uses OM-styled widgets consistently with AdvancedSearchClassBase Co-Authored-By: Claude Sonnet 4.6 --- .../ui/src/utils/JSONLogicSearchClassBase.ts | 4 ++-- .../queryBuilderWidgets/OMDateWidget.tsx | 24 +++++++------------ .../queryBuilderWidgets/OMNumberWidget.tsx | 4 +--- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts index 693a724108c8..14f2286f19a7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/JSONLogicSearchClassBase.ts @@ -12,7 +12,6 @@ */ import { AsyncFetchListValuesResult, - BasicConfig, Config, FieldOrGroup, Fields, @@ -45,11 +44,12 @@ import { searchQuery } from '../rest/searchAPI'; import { getTags } from '../rest/tagAPI'; import advancedSearchClassBase from './AdvancedSearchClassBase'; import { t } from './i18next/LocalUtil'; +import { OMConfig } from './QueryBuilderOMConfig'; import { getFieldsByKeys } from './QueryBuilderPureUtils'; import { renderJSONLogicQueryBuilderButtons } from './QueryBuilderUtils'; class JSONLogicSearchClassBase { - baseConfig = BasicConfig as Config; + baseConfig = OMConfig as Config; configTypes: Config['types'] = { ...this.baseConfig.types, multiselect: { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMDateWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMDateWidget.tsx index c5fec60e2de4..36ed8835b937 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMDateWidget.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMDateWidget.tsx @@ -13,36 +13,30 @@ import type { DateTimeWidgetProps } from '@react-awesome-query-builder/ui'; import type { FC } from 'react'; -// Determine the input type based on the widget's time-related settings. -const getInputType = (operator: string): 'date' | 'datetime-local' | 'time' => { - if (operator.includes('time') && !operator.includes('date')) { - return 'time'; - } - if (operator.includes('datetime') || operator.includes('date_time')) { - return 'datetime-local'; - } - - return 'date'; -}; - const classNameValue = 'tw:rounded-lg tw:bg-primary tw:px-3 tw:py-2 tw:text-sm tw:text-primary ' + 'tw:shadow-xs tw:ring-1 tw:ring-primary tw:ring-inset tw:outline-hidden ' + 'tw:transition tw:duration-100 focus:tw:ring-2 focus:tw:ring-brand ' + - 'disabled:tw:cursor-not-allowed disabled:tw:bg-disabled_subtle disabled:tw:text-disabled'; + 'disabled:tw:cursor-not-allowed disabled:tw:bg-disabled-subtle disabled:tw:text-disabled'; const OMDateWidget: FC = ({ value, setValue, placeholder, readonly, - operator, + fieldType, }) => ( setValue(e.target.value || null)} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMNumberWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMNumberWidget.tsx index 3d59f268889c..1d52dcec2bf1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMNumberWidget.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMNumberWidget.tsx @@ -27,9 +27,7 @@ const OMNumberWidget: FC = ({ size="sm" type="number" value={value !== null && value !== undefined ? String(value) : ''} - onChange={(v: string) => - setValue(v === '' ? null : (Number(v) as number & null)) - } + onChange={(v: string) => setValue(v === '' ? null : Number(v))} /> ); From 7c92bd154189d4115c094fd74d2cba02064e98aa Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:30:05 +0530 Subject: [PATCH 13/67] fix(query-builder): remove hardcoded size from OMConjs ButtonGroup, document native input in OMDateWidget --- .../resources/ui/src/utils/queryBuilderWidgets/OMConjs.tsx | 1 - .../ui/src/utils/queryBuilderWidgets/OMDateWidget.tsx | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMConjs.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMConjs.tsx index 53f596af80b2..79b4ccdb59c4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMConjs.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMConjs.tsx @@ -30,7 +30,6 @@ const OMConjs: FC = ({ selectedConjunction ? new Set([selectedConjunction]) : new Set() } selectionMode="single" - size="sm" onSelectionChange={(keys: Set) => { const key = keys.values().next().value; if (key !== undefined) { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMDateWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMDateWidget.tsx index 36ed8835b937..a40fe04de9fa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMDateWidget.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMDateWidget.tsx @@ -13,6 +13,9 @@ import type { DateTimeWidgetProps } from '@react-awesome-query-builder/ui'; import type { FC } from 'react'; +// DateInput from @openmetadata/ui-core-components is not publicly exported and requires +// @internationalized/date CalendarDate objects, which are incompatible with the query +// builder's string-based date values. Native is used instead. const classNameValue = 'tw:rounded-lg tw:bg-primary tw:px-3 tw:py-2 tw:text-sm tw:text-primary ' + 'tw:shadow-xs tw:ring-1 tw:ring-primary tw:ring-inset tw:outline-hidden ' + From e3f91abb546a2902285fe48824bdcce4ca19c48c Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:33:15 +0530 Subject: [PATCH 14/67] fix(query-builder): update Playwright selectors from antd to react-aria after widget migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OMFieldSelect: switch plain Select → Select.ComboBox for searchable field/operator selection - OMSelectWidget: add Select.ComboBox branch for async mode (exposes input[role="combobox"]) - advancedSearch.ts: rewrite selectOption/fillRule/fillStaticListRule/runRuleGroupTestsWithNonExistingValue to use react-aria selectors ([role="listbox"], [role="option"], input[role="combobox"]) - AdvancedSearch.spec.ts, AdvancedSearchSuggestions.spec.ts, CuratedAssets.spec.ts, DataContractsSemanticRules.spec.ts, customPropertyAdvancedSearchUtils.ts: replace all .ant-select/.ant-select-dropdown/.ant-select-item-option-content selectors with ARIA equivalents; skip antd-specific Load More test that relied on rc-virtual-list Co-Authored-By: Claude Sonnet 4.6 --- .../e2e/Features/AdvancedSearch.spec.ts | 91 +++----- .../AdvancedSearchSuggestions.spec.ts | 19 +- .../e2e/Features/CuratedAssets.spec.ts | 70 ++---- .../Pages/DataContractsSemanticRules.spec.ts | 209 +++++++++--------- .../ui/playwright/utils/advancedSearch.ts | 130 ++++------- .../customPropertyAdvancedSearchUtils.ts | 11 +- .../queryBuilderWidgets/OMFieldSelect.tsx | 9 +- .../queryBuilderWidgets/OMSelectWidget.tsx | 27 +++ 8 files changed, 239 insertions(+), 327 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts index b4a482845766..1684b803b8ad 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearch.spec.ts @@ -453,23 +453,24 @@ test.describe( const ruleLocator = page.locator('.rule').nth(0); await selectOption( page, - ruleLocator.locator('.rule--field .ant-select'), + ruleLocator.locator('.rule--field'), 'Status', true ); - await selectOption( - page, - ruleLocator.locator('.rule--operator .ant-select'), - '==' - ); + await selectOption(page, ruleLocator.locator('.rule--operator'), '=='); }); await test.step('Open Status value dropdown and verify all hard-coded options appear', async () => { const ruleLocator = page.locator('.rule').nth(0); - await ruleLocator.locator('.widget--widget > .ant-select').click(); + const triggerBtn = ruleLocator.locator( + '.widget--widget button[aria-haspopup="listbox"]' + ); + + await expect(triggerBtn).toBeVisible(); + await triggerBtn.click(); const dropdown = page - .locator('.ant-select-dropdown') + .locator('[role="listbox"]') .filter({ hasText: EntityStatus.Approved }) .last(); @@ -478,7 +479,7 @@ test.describe( for (const status of ENTITY_STATUSES) { await expect( dropdown - .locator('.ant-select-item-option') + .getByRole('option') .filter({ hasText: new RegExp(`^${status}$`, 'i') }) .first() ).toBeVisible(); @@ -1638,83 +1639,45 @@ test.describe( await selectOption( page, - ruleLocator.locator('.rule--field .ant-select'), + ruleLocator.locator('.rule--field'), 'Custom Properties', true ); await selectOption( page, - ruleLocator.locator('.rule--field .ant-select'), + ruleLocator.locator('.rule--field'), 'Table', true ); await selectOption( page, - ruleLocator.locator('.rule--field .ant-select'), + ruleLocator.locator('.rule--field'), enumCPName, true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), 'Equals' ); - const valueSelector = ruleLocator.locator( - '.ant-select-selection-overflow' + const comboboxInput = ruleLocator.locator( + '.rule--widget input[role="combobox"]' ); - await expect(valueSelector).toBeVisible({ timeout: 15000 }); - await valueSelector.click(); + await expect(comboboxInput).toBeVisible({ timeout: 15000 }); + await comboboxInput.click(); - const dropdown = page.locator('.ant-select-dropdown:visible').last(); + const dropdown = page.locator('[role="listbox"]:visible').last(); await expect(dropdown).toBeVisible(); - return { ruleLocator, valueSelector, dropdown }; + return { ruleLocator, comboboxInput, dropdown }; }; - test('should append page-2 items and make them visible when Load more button is clicked', async ({ - page, - }) => { - test.slow(); - - const { dropdown } = await openEnumValueDropdown(page); - - // Page 1 items present; page-2 item not yet visible - await expect( - dropdown.locator(`[title="${FIRST_PAGE_VALUE}"]`) - ).toBeVisible({ timeout: 10000 }); - await expect( - dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`) - ).not.toBeVisible(); - - // "Load more..." button visible at the bottom of the list - const loadMoreBtn = dropdown - .locator('a') - .filter({ hasText: /load more/i }); - - await expect(loadMoreBtn).toBeVisible(); - - // Click Load more → page-2 items append - await loadMoreBtn.click(); - - // Hover over the virtual list so mouse wheel events target it - const virtualListHolder = dropdown.locator('.rc-virtual-list-holder'); - - await expect(virtualListHolder).toBeVisible(); - await virtualListHolder.hover(); - - // Wheel-scroll in small increments until the page-2 item comes into view - const secondPageItem = dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`); - let found = await secondPageItem.isVisible(); - - for (let i = 0; i < 20 && !found; i++) { - await page.mouse.wheel(0, 200); - found = await secondPageItem.isVisible(); - } - - await expect(secondPageItem).toBeVisible({ timeout: 5000 }); + test.skip('should append page-2 items and make them visible when Load more button is clicked', () => { + // Load more and rc-virtual-list are Ant Design Select features not present + // in the new react-aria MultiSelect component. }); test('should find page-2 items via search without clicking Load more', async ({ @@ -1726,22 +1689,22 @@ test.describe( // Page 1 items load; page-2 item is not yet visible await expect( - dropdown.locator(`[title="${FIRST_PAGE_VALUE}"]`) + dropdown.getByRole('option', { name: FIRST_PAGE_VALUE }) ).toBeVisible({ timeout: 10000 }); await expect( - dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`) + dropdown.getByRole('option', { name: SECOND_PAGE_VALUE }) ).not.toBeVisible(); // Type to search — asyncFetch filters the full values array, not just the loaded page const searchInput = ruleLocator.locator( - '.rule--widget .ant-select-selection-search-input' + '.rule--widget input[role="combobox"]' ); await searchInput.fill(SECOND_PAGE_VALUE); // Item appears immediately without clicking Load more await expect( - dropdown.locator(`[title="${SECOND_PAGE_VALUE}"]`) + dropdown.getByRole('option', { name: SECOND_PAGE_VALUE }) ).toBeVisible({ timeout: 10000 }); }); } diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearchSuggestions.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearchSuggestions.spec.ts index 2eef301f54f9..1f8880c5ddb6 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearchSuggestions.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/AdvancedSearchSuggestions.spec.ts @@ -58,27 +58,19 @@ test.describe('Advanced Search Suggestions', () => { await selectOption( page, - ruleLocator.locator('.rule--field .ant-select'), + ruleLocator.locator('.rule--field'), field.label, true ); - await selectOption( - page, - ruleLocator.locator('.rule--operator .ant-select'), - '==' - ); + await selectOption(page, ruleLocator.locator('.rule--operator'), '=='); const dropdownInput = ruleLocator.locator( - '.widget--widget > .ant-select > .ant-select-selector input' + '.widget--widget input[role="combobox"]' ); - const aggregateRes1 = page.waitForResponse('/api/v1/search/aggregate?*'); - await dropdownInput.click(); - await aggregateRes1; - const searchText = toLower( getFieldsSuggestionSearchText(field.label, testData.fieldSearchData) ); @@ -95,7 +87,10 @@ test.describe('Advanced Search Suggestions', () => { await test .expect( - page.locator(`.ant-select-dropdown:visible [title="${searchText}"]`) + page + .locator('[role="listbox"]:visible [role="option"]') + .filter({ hasText: searchText }) + .first() ) .toBeVisible(); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CuratedAssets.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CuratedAssets.spec.ts index eb460b91acbe..56d2de7ba6d7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CuratedAssets.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/CuratedAssets.spec.ts @@ -144,14 +144,14 @@ test.describe('Curated Assets Widget', () => { await selectOption( page, - ruleLocator.locator('.rule--field .ant-select'), + ruleLocator.locator('.rule--field'), 'Display Name', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), 'Contains' ); @@ -260,16 +260,12 @@ test.describe('Curated Assets Widget', () => { const ruleLocator = page.locator('.rule').nth(0); await selectOption( page, - ruleLocator.locator('.rule--field .ant-select'), + ruleLocator.locator('.rule--field'), 'Deleted', true ); - await selectOption( - page, - ruleLocator.locator('.rule--operator .ant-select'), - 'Is' - ); + await selectOption(page, ruleLocator.locator('.rule--operator'), 'Is'); await ruleLocator .locator('.rule--value .rule--widget--BOOLEAN .ant-switch') @@ -336,15 +332,11 @@ test.describe('Curated Assets Widget', () => { const ruleLocator1 = page.locator('.rule').nth(0); await selectOption( page, - ruleLocator1.locator('.rule--field .ant-select'), + ruleLocator1.locator('.rule--field'), 'Owners', true ); - await selectOption( - page, - ruleLocator1.locator('.rule--operator .ant-select'), - 'Is Set' - ); + await selectOption(page, ruleLocator1.locator('.rule--operator'), 'Is Set'); await page.getByRole('button', { name: 'Add Condition' }).click(); @@ -354,15 +346,11 @@ test.describe('Curated Assets Widget', () => { const ruleLocator2 = page.locator('.rule').nth(1); await selectOption( page, - ruleLocator2.locator('.rule--field .ant-select'), + ruleLocator2.locator('.rule--field'), 'Deleted', true ); - await selectOption( - page, - ruleLocator2.locator('.rule--operator .ant-select'), - 'Is' - ); + await selectOption(page, ruleLocator2.locator('.rule--operator'), 'Is'); await ruleLocator2 .locator('.rule--value .rule--widget--BOOLEAN .ant-switch') .click(); @@ -439,15 +427,11 @@ test.describe('Curated Assets Widget', () => { const ruleLocator1 = page.locator('.rule').nth(0); await selectOption( page, - ruleLocator1.locator('.rule--field .ant-select'), + ruleLocator1.locator('.rule--field'), 'Deleted', true ); - await selectOption( - page, - ruleLocator1.locator('.rule--operator .ant-select'), - 'Is' - ); + await selectOption(page, ruleLocator1.locator('.rule--operator'), 'Is'); await ruleLocator1 .locator('.rule--value .rule--widget--BOOLEAN .ant-switch') .click(); @@ -458,13 +442,13 @@ test.describe('Curated Assets Widget', () => { const ruleLocator2 = page.locator('.rule').nth(1); await selectOption( page, - ruleLocator2.locator('.rule--field .ant-select'), + ruleLocator2.locator('.rule--field'), 'Display Name', true ); await selectOption( page, - ruleLocator2.locator('.rule--operator .ant-select'), + ruleLocator2.locator('.rule--operator'), 'Contains' ); @@ -555,18 +539,14 @@ test.describe('Curated Assets Widget', () => { const ruleLocator1 = page.locator('.rule').nth(0); await selectOption( page, - ruleLocator1.locator('.rule--field .ant-select'), + ruleLocator1.locator('.rule--field'), 'Owners', true ); + await selectOption(page, ruleLocator1.locator('.rule--operator'), 'Any in'); await selectOption( page, - ruleLocator1.locator('.rule--operator .ant-select'), - 'Any in' - ); - await selectOption( - page, - ruleLocator1.locator('.rule--value .ant-select'), + ruleLocator1.locator('.rule--value'), 'admin', true ); @@ -579,18 +559,14 @@ test.describe('Curated Assets Widget', () => { const ruleLocator2 = page.locator('.rule').nth(1); await selectOption( page, - ruleLocator2.locator('.rule--field .ant-select'), + ruleLocator2.locator('.rule--field'), 'Description Status', true ); + await selectOption(page, ruleLocator2.locator('.rule--operator'), 'Is'); await selectOption( page, - ruleLocator2.locator('.rule--operator .ant-select'), - 'Is' - ); - await selectOption( - page, - ruleLocator2.locator('.rule--value .ant-select'), + ruleLocator2.locator('.rule--value'), 'Incomplete' ); await ruleLocator2.locator('.rule--value input').fill('production'); @@ -601,18 +577,14 @@ test.describe('Curated Assets Widget', () => { const ruleLocator3 = page.locator('.rule').nth(2); await selectOption( page, - ruleLocator3.locator('.rule--field .ant-select'), + ruleLocator3.locator('.rule--field'), 'Tier', true ); + await selectOption(page, ruleLocator3.locator('.rule--operator'), 'Is Not'); await selectOption( page, - ruleLocator3.locator('.rule--operator .ant-select'), - 'Is Not' - ); - await selectOption( - page, - ruleLocator3.locator('.rule--value .ant-select'), + ruleLocator3.locator('.rule--value'), 'tier.tier5', true ); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContractsSemanticRules.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContractsSemanticRules.spec.ts index 0a2d262308d2..8a7d7d788ddd 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContractsSemanticRules.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContractsSemanticRules.spec.ts @@ -101,18 +101,18 @@ test.describe('Data Contracts Semantics Rule Owner', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Owners', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), team.responseData.displayName, true ); @@ -198,18 +198,18 @@ test.describe('Data Contracts Semantics Rule Owner', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Owners', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_not ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), user.getUserDisplayName(), true ); @@ -302,18 +302,18 @@ test.describe('Data Contracts Semantics Rule Owner', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Owners', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.any_in ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), user.getUserDisplayName(), true ); @@ -406,18 +406,18 @@ test.describe('Data Contracts Semantics Rule Owner', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Owners', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.not_in ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), user.getUserDisplayName(), true ); @@ -502,13 +502,13 @@ test.describe('Data Contracts Semantics Rule Owner', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Owners', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_set ); @@ -585,13 +585,13 @@ test.describe('Data Contracts Semantics Rule Owner', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Owners', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_not_set ); @@ -669,13 +669,13 @@ test.describe('Data Contracts Semantics Rule Description', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Description', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.contains ); @@ -759,13 +759,13 @@ test.describe('Data Contracts Semantics Rule Description', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Description', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.not_contains ); const inputElement = ruleLocator.locator( @@ -847,13 +847,13 @@ test.describe('Data Contracts Semantics Rule Description', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Description', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_set ); @@ -936,13 +936,13 @@ test.describe('Data Contracts Semantics Rule Description', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Description', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_not_set ); @@ -1037,18 +1037,18 @@ test.describe('Data Contracts Semantics Rule Domain', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Domain', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), domain1.responseData.name, true ); @@ -1122,18 +1122,18 @@ test.describe('Data Contracts Semantics Rule Domain', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Domain', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_not ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), domain2.responseData.name, true ); @@ -1208,18 +1208,18 @@ test.describe('Data Contracts Semantics Rule Domain', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Domain', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.any_in ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), domain1.responseData.name, true ); @@ -1292,18 +1292,18 @@ test.describe('Data Contracts Semantics Rule Domain', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Domain', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.not_in ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), domain1.responseData.name, true ); @@ -1376,13 +1376,13 @@ test.describe('Data Contracts Semantics Rule Domain', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Domain', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_set ); @@ -1452,13 +1452,13 @@ test.describe('Data Contracts Semantics Rule Domain', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Domain', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_not_set ); @@ -1534,13 +1534,13 @@ test.describe('Data Contracts Semantics Rule Version', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Version', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is ); @@ -1651,13 +1651,13 @@ test.describe('Data Contracts Semantics Rule Version', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Version', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_not ); @@ -1771,13 +1771,13 @@ test.describe('Data Contracts Semantics Rule Version', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Version', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.less ); @@ -1848,13 +1848,13 @@ test.describe('Data Contracts Semantics Rule Version', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Version', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.greater ); @@ -1928,13 +1928,13 @@ test.describe('Data Contracts Semantics Rule Version', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Version', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.less_equal ); @@ -2008,13 +2008,13 @@ test.describe('Data Contracts Semantics Rule Version', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Version', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.greater_equal ); @@ -2108,19 +2108,19 @@ test.describe('Data Contracts Semantics Rule DataProduct', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Data Product', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), createdDataProducts[0].responseData.name, true ); @@ -2206,19 +2206,19 @@ test.describe('Data Contracts Semantics Rule DataProduct', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Data Product', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_not ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), createdDataProducts[0].responseData.name, true ); @@ -2304,18 +2304,18 @@ test.describe('Data Contracts Semantics Rule DataProduct', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Data Product', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.any_in ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), createdDataProducts[0].responseData.name, true ); @@ -2401,18 +2401,18 @@ test.describe('Data Contracts Semantics Rule DataProduct', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Data Product', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.not_in ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), createdDataProducts[0].responseData.name, true ); @@ -2542,13 +2542,13 @@ test.describe('Data Contracts Semantics Rule DataProduct', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Data Product', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_set ); @@ -2628,13 +2628,13 @@ test.describe('Data Contracts Semantics Rule DataProduct', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Data Product', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_not_set ); @@ -2719,19 +2719,19 @@ test.describe('Data Contracts Semantics Rule DisplayName', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Display Name', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), table.entityResponseData.displayName || '', true ); @@ -2809,19 +2809,19 @@ test.describe('Data Contracts Semantics Rule DisplayName', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Display Name', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_not ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), table.entityResponseData.displayName || '', true ); @@ -2899,18 +2899,18 @@ test.describe('Data Contracts Semantics Rule DisplayName', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Display Name', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.any_in ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), table.entityResponseData.displayName || '', true ); @@ -2988,18 +2988,18 @@ test.describe('Data Contracts Semantics Rule DisplayName', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Display Name', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.not_in ); await selectOption( page, - ruleLocator.locator('.rule--value .ant-select'), + ruleLocator.locator('.rule--value'), table.entityResponseData.displayName || '', true ); @@ -3077,13 +3077,13 @@ test.describe('Data Contracts Semantics Rule DisplayName', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Display Name', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_set ); @@ -3161,13 +3161,13 @@ test.describe('Data Contracts Semantics Rule DisplayName', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Display Name', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.is_not_set ); @@ -3247,13 +3247,13 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Updated on', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.between ); @@ -3334,13 +3334,13 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Updated on', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.not_between ); @@ -3433,13 +3433,13 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Updated on', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.less ); @@ -3529,13 +3529,13 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Updated on', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.greater ); @@ -3628,13 +3628,13 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Updated on', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.less_equal ); @@ -3730,13 +3730,13 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Updated on', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), DATA_CONTRACT_SEMANTIC_OPERATIONS.greater_equal ); @@ -3843,13 +3843,13 @@ test.describe('Data Contract - Semantics Fields Validation', () => { const ruleLocator = page.locator('.group').nth(0); await selectOption( page, - ruleLocator.locator('.group--field .ant-select'), + ruleLocator.locator('.group--field'), 'Owners', true ); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), 'Is Set' ); }); @@ -3869,17 +3869,8 @@ test.describe('Data Contract - Semantics Fields Validation', () => { }); await test.step('select Is Set operator and error is hidden', async () => { - await selectOption( - page, - page.locator('.rule--field .ant-select'), - 'Owners', - true - ); - await selectOption( - page, - page.locator('.rule--operator .ant-select'), - 'Is Set' - ); + await selectOption(page, page.locator('.rule--field'), 'Owners', true); + await selectOption(page, page.locator('.rule--operator'), 'Is Set'); await expect(page.getByText(/rule is required/i)).not.toBeVisible(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts index 24cb7a7b6d95..160d0cc6c617 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts @@ -174,51 +174,37 @@ export const selectOption = async ( optionTitle: string, isSearchable = false ) => { - if (isSearchable) { - // Wait for dropdown to be visible before clicking - const selector = dropdownLocator.locator('.ant-select-selector'); - await expect(selector).toBeVisible(); - await selector.click(); + const comboboxInput = dropdownLocator.locator('input[role="combobox"]'); - await dropdownLocator - .locator('.ant-select-arrow-loading svg[data-icon="loading"]') - .waitFor({ state: 'detached' }); + if ((await comboboxInput.count()) > 0) { + await expect(comboboxInput).toBeVisible(); - // Clear any existing input and type the new value - const combobox = dropdownLocator.getByRole('combobox'); - await combobox.clear(); - - await dropdownLocator - .locator('.ant-select-arrow-loading svg[data-icon="loading"]') - .waitFor({ state: 'detached' }); - - await combobox.fill(optionTitle); - - await dropdownLocator - .locator('.ant-select-arrow-loading svg[data-icon="loading"]') - .waitFor({ state: 'detached' }); + if (isSearchable) { + await comboboxInput.clear(); + await comboboxInput.fill(optionTitle); + } else { + await comboboxInput.click(); + } } else { - await dropdownLocator.click(); + const triggerButton = dropdownLocator.locator( + 'button[aria-haspopup="listbox"]' + ); + + await expect(triggerButton).toBeVisible(); + await triggerButton.click(); } - await expect(dropdownLocator).toHaveClass(/(^|\s)ant-select-focused(\s|$)/); + await page.locator('[role="listbox"]').first().waitFor({ state: 'visible' }); - await page.locator('.ant-select-dropdown:visible').first().waitFor({ - state: 'visible', - }); + // eslint-disable-next-line playwright/no-wait-for-timeout -- dropdown animation settling + await page.waitForTimeout(100); - // CRITICAL: Use :visible selector chain pattern (Rule 4 from deflake guide) - // Use .first() to handle multiple matches (acceptable when scoped to visible dropdown) const optionLocator = page - .locator('.ant-select-dropdown:visible') - .getByTitle(optionTitle, { exact: true }) + .locator('[role="listbox"]:visible') + .getByRole('option', { name: optionTitle, exact: true }) .first(); - await expect(optionLocator).toBeVisible(); - // Wait for dropdown animations to settle before clicking - // This prevents "element detached from DOM" errors during re-renders - // eslint-disable-next-line playwright/no-wait-for-timeout -- dropdown animation settling - await page.waitForTimeout(100); + await expect(optionLocator).toBeVisible(); await optionLocator.click({ timeout: 10000 }); }; @@ -260,19 +246,10 @@ export const fillRule = async ( const ruleLocator = page.locator('.rule').nth(index - 1); // Perform click on rule field - await selectOption( - page, - ruleLocator.locator('.rule--field .ant-select'), - field.id, - true - ); + await selectOption(page, ruleLocator.locator('.rule--field'), field.id, true); // Perform click on operator - await selectOption( - page, - ruleLocator.locator('.rule--operator .ant-select'), - condition - ); + await selectOption(page, ruleLocator.locator('.rule--operator'), condition); if (searchCriteria) { const inputElement = ruleLocator.locator( @@ -284,15 +261,11 @@ export const fillRule = async ( await inputElement.fill(searchData); } else { const dropdownInput = ruleLocator.locator( - '.widget--widget > .ant-select > .ant-select-selector input' + '.widget--widget input[role="combobox"]' ); - const aggregateRes1 = page.waitForResponse('/api/v1/search/aggregate?*'); - await dropdownInput.click(); - await aggregateRes1; - const aggregateRes2 = page.waitForResponse( `/api/v1/search/aggregate?*${getEncodedFqn( escapeESReservedCharacters(searchData) @@ -303,27 +276,26 @@ export const fillRule = async ( await aggregateRes2; - const dropdown = page.locator('.ant-select-dropdown:visible'); - const exactTitleMatch = dropdown - .locator('[title]') - .filter({ - hasText: new RegExp(`^${escapeRegex(searchData)}$`, 'i'), + const dropdown = page.locator('[role="listbox"]:visible'); + const exactMatch = dropdown + .getByRole('option', { + name: new RegExp(`^${escapeRegex(searchData)}$`, 'i'), }) .first(); - const partialTextMatch = dropdown - .locator('.ant-select-item-option-content') + const partialMatch = dropdown + .getByRole('option') .filter({ hasText: new RegExp(escapeRegex(searchData), 'i'), }) .first(); - if (await exactTitleMatch.count()) { - await exactTitleMatch.click(); - } else if (await partialTextMatch.count()) { - await partialTextMatch.click(); + if (await exactMatch.count()) { + await exactMatch.click(); + } else if (await partialMatch.count()) { + await partialMatch.click(); } else { // Some suggestion backends normalize or delay option text; Enter keeps - // the typed criteria and avoids waiting forever on an exact title match. + // the typed criteria and avoids waiting forever on an exact match. await dropdownInput.press('Enter'); } } @@ -664,28 +636,26 @@ export const runRuleGroupTestsWithNonExistingValue = async (page: Page) => { // Perform click on rule field await selectOption( page, - ruleLocator.locator('.rule--field .ant-select'), + ruleLocator.locator('.rule--field'), 'Database', true ); - await selectOption( - page, - ruleLocator.locator('.rule--operator .ant-select'), - '==' - ); + await selectOption(page, ruleLocator.locator('.rule--operator'), '=='); const inputElement = ruleLocator.locator( - '.rule--widget--SELECT .ant-select-selection-search-input' + '.rule--widget--SELECT input[role="combobox"]' ); + await inputElement.fill('non-existing-value'); - const dropdownText = page.locator('.ant-select-item-empty'); - await expect(dropdownText).toContainText('Loading...'); + const listbox = page.locator('[role="listbox"]:visible'); + + await expect(listbox).toBeVisible(); // eslint-disable-next-line playwright/no-wait-for-timeout -- search debounce delay await page.waitForTimeout(1000); - await expect(dropdownText).not.toContainText('Loading...'); + await expect(listbox.getByRole('option')).toHaveCount(0); }; // For fields backed by hard-coded listValues (no aggregate API call), options are @@ -709,20 +679,12 @@ export const fillStaticListRule = async ( await selectOption( page, - ruleLocator.locator('.rule--field .ant-select'), + ruleLocator.locator('.rule--field'), fieldLabel, true ); - await selectOption( - page, - ruleLocator.locator('.rule--operator .ant-select'), - condition - ); - await selectOption( - page, - ruleLocator.locator('.widget--widget > .ant-select'), - value - ); + await selectOption(page, ruleLocator.locator('.rule--operator'), condition); + await selectOption(page, ruleLocator.locator('.widget--widget'), value); }; export const getFieldsSuggestionSearchText = ( diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customPropertyAdvancedSearchUtils.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customPropertyAdvancedSearchUtils.ts index 8ef4ee98bce7..c43b89192f78 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customPropertyAdvancedSearchUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customPropertyAdvancedSearchUtils.ts @@ -430,7 +430,8 @@ const handlePropertyValueInput = async ( // Handle entity reference selection if (isEntityRefProperty) { await page - .locator(`.ant-select-dropdown:visible [title*="${value as string}"]`) + .locator('[role="listbox"]:visible [role="option"]') + .filter({ hasText: value as string }) .first() .click(); } @@ -449,21 +450,21 @@ export const applyCustomPropertyFilter = async ( await selectOption( page, - ruleLocator.locator('.rule--field .ant-select'), + ruleLocator.locator('.rule--field'), 'Custom Properties', true ); await selectOption( page, - ruleLocator.locator('.rule--field .ant-select'), + ruleLocator.locator('.rule--field'), entityType, true ); await selectOption( page, - ruleLocator.locator('.rule--field .ant-select'), + ruleLocator.locator('.rule--field'), propertyName, true ); @@ -471,7 +472,7 @@ export const applyCustomPropertyFilter = async ( const operatorLabel = getOperatorLabel(operator); await selectOption( page, - ruleLocator.locator('.rule--operator .ant-select'), + ruleLocator.locator('.rule--operator'), operatorLabel ); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx index 5d75401e9ced..1e3dbf69183a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx @@ -24,15 +24,16 @@ const OMFieldSelect: FC = ({ const selectItems: SelectItemType[] = items.map((item) => ({ id: item.key, label: item.label, - supportingText: item.grouplabel, })); return ( - + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.tsx index f5b69a8b83a1..5261e59c1ce2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.tsx @@ -71,6 +71,33 @@ const OMSelectWidget: FC = ({ } }, [useAsyncSearch, loadAsync]); + if (useAsyncSearch && asyncFetch) { + return ( + { + loadAsync(v); + }} + onSelectionChange={(key) => + setValue(key !== null ? String(key) : null) + }> + {(item) => ( + + {item.label} + + )} + + ); + } + return ( instead of the antd DatePicker. Update all affected Playwright tests: - advancedSearch.ts: rewrite selectRange() to fill two native date inputs directly (no dropdown interaction needed) - DataContractsSemanticRules.spec.ts: replace all .ant-picker, .ant-picker-range, .ant-picker-dropdown, .ant-picker-input-active patterns; change date format from 'dd.MM.yyyy' (antd) to 'yyyy-MM-dd' (ISO, required for native input) - PersonaFlow.spec.ts: remove .ant-select suffix from .rule--field and .rule--operator selectors Co-Authored-By: Claude Sonnet 4.6 --- .../playwright/e2e/Flow/PersonaFlow.spec.ts | 4 +- .../Pages/DataContractsSemanticRules.spec.ts | 114 ++++++------------ .../ui/playwright/utils/advancedSearch.ts | 12 +- 3 files changed, 38 insertions(+), 92 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts index 91c96ea35061..81a2882e67c8 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PersonaFlow.spec.ts @@ -858,13 +858,13 @@ test.describe('Curated Assets – Description filter', () => { await selectOption( adminPage, - rule0.locator('.rule--field .ant-select'), + rule0.locator('.rule--field'), 'Description', true ); await selectOption( adminPage, - rule0.locator('.rule--operator .ant-select'), + rule0.locator('.rule--operator'), 'Contains' ); await rule0 diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContractsSemanticRules.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContractsSemanticRules.spec.ts index 8a7d7d788ddd..1e8ce7f190f8 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContractsSemanticRules.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContractsSemanticRules.spec.ts @@ -3257,10 +3257,10 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { DATA_CONTRACT_SEMANTIC_OPERATIONS.between ); - const startDate = customFormatDateTime(getCurrentMillis(), 'dd.MM.yyyy'); + const startDate = customFormatDateTime(getCurrentMillis(), 'yyyy-MM-dd'); const endDate = customFormatDateTime( getEpochMillisForFutureDays(5), - 'dd.MM.yyyy' + 'yyyy-MM-dd' ); await selectRange(page, ruleLocator, startDate, endDate); @@ -3289,11 +3289,14 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const newStart = customFormatDateTime( getEpochMillisForFutureDays(1), - 'dd.MM.yyyy' + 'yyyy-MM-dd' ); - page.getByRole('textbox', { name: 'Enter date from' }).fill(newStart); - await page.press('.ant-picker-input-active input', 'Enter'); - await page.press('.ant-picker-input-active input', 'Enter'); + await page + .locator('.group') + .nth(0) + .locator('.rule--value input[type="date"]') + .nth(0) + .fill(newStart); // save and trigger contract validation await saveAndTriggerDataContractValidation(page, true); @@ -3344,10 +3347,10 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { DATA_CONTRACT_SEMANTIC_OPERATIONS.not_between ); - const startDate = customFormatDateTime(getCurrentMillis(), 'dd.MM.yyyy'); + const startDate = customFormatDateTime(getCurrentMillis(), 'yyyy-MM-dd'); const endDate = customFormatDateTime( getEpochMillisForFutureDays(5), - 'dd.MM.yyyy' + 'yyyy-MM-dd' ); await selectRange(page, ruleLocator, startDate, endDate); @@ -3377,23 +3380,14 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const newStart = customFormatDateTime( getEpochMillisForFutureDays(1), - 'dd.MM.yyyy' + 'yyyy-MM-dd' ); await page .locator('.group') .nth(0) - .locator('.rule--value .ant-picker-range') - .click(); - - await page.locator('.ant-picker-dropdown-range').waitFor({ - state: 'visible', - }); - - await page - .getByRole('textbox', { name: 'Enter date from' }) + .locator('.rule--value input[type="date"]') + .nth(0) .fill(newStart); - await page.press('.ant-picker-input-active input', 'Enter'); - await page.press('.ant-picker-input-active input', 'Enter'); // save and trigger contract validation await saveAndTriggerDataContractValidation(page, true); @@ -3443,14 +3437,9 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { DATA_CONTRACT_SEMANTIC_OPERATIONS.less ); - const date = customFormatDateTime(getCurrentMillis(), 'dd.MM.yyyy'); + const date = customFormatDateTime(getCurrentMillis(), 'yyyy-MM-dd'); - await ruleLocator.locator('.rule--value .ant-picker').click(); - await page.locator('.ant-picker-dropdown').waitFor({ - state: 'visible', - }); - await page.locator('.ant-picker-input input').fill(date); - await page.press('.ant-picker-input input', 'Enter'); + await ruleLocator.locator('.rule--value input[type="date"]').fill(date); // save and trigger contract validation await saveAndTriggerDataContractValidation(page, true); @@ -3477,19 +3466,14 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const newDate = customFormatDateTime( getEpochMillisForFutureDays(1), - 'dd.MM.yyyy' + 'yyyy-MM-dd' ); await page .locator('.group') .nth(0) - .locator('.rule--value .ant-picker') - .click(); - await page.locator('.ant-picker-dropdown').waitFor({ - state: 'visible', - }); - await page.locator('.ant-picker-input input').fill(newDate); - await page.press('.ant-picker-input input', 'Enter'); + .locator('.rule--value input[type="date"]') + .fill(newDate); // save and trigger contract validation await saveAndTriggerDataContractValidation(page, true); @@ -3541,15 +3525,10 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const date = customFormatDateTime( getEpochMillisForFutureDays(1), - 'dd.MM.yyyy' + 'yyyy-MM-dd' ); - await ruleLocator.locator('.rule--value .ant-picker').click(); - await page.locator('.ant-picker-dropdown').waitFor({ - state: 'visible', - }); - await page.locator('.ant-picker-input input').fill(date); - await page.press('.ant-picker-input input', 'Enter'); + await ruleLocator.locator('.rule--value input[type="date"]').fill(date); // save and trigger contract validation await saveAndTriggerDataContractValidation(page, true); @@ -3576,19 +3555,14 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const newDate = customFormatDateTime( getEpochMillisForFutureDays(-1), - 'dd.MM.yyyy' + 'yyyy-MM-dd' ); await page .locator('.group') .nth(0) - .locator('.rule--value .ant-picker') - .click(); - await page.locator('.ant-picker-dropdown').waitFor({ - state: 'visible', - }); - await page.locator('.ant-picker-input input').fill(newDate); - await page.press('.ant-picker-input input', 'Enter'); + .locator('.rule--value input[type="date"]') + .fill(newDate); // save and trigger contract validation await saveAndTriggerDataContractValidation(page, true); @@ -3640,15 +3614,10 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const date = customFormatDateTime( getEpochMillisForFutureDays(1), - 'dd.MM.yyyy' + 'yyyy-MM-dd' ); - await ruleLocator.locator('.rule--value .ant-picker').click(); - await page.locator('.ant-picker-dropdown').waitFor({ - state: 'visible', - }); - await page.locator('.ant-picker-input input').fill(date); - await page.press('.ant-picker-input input', 'Enter'); + await ruleLocator.locator('.rule--value input[type="date"]').fill(date); // save and trigger contract validation await saveAndTriggerDataContractValidation(page, true); @@ -3674,19 +3643,14 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const newDate = customFormatDateTime( getEpochMillisForFutureDays(-1), - 'dd.MM.yyyy' + 'yyyy-MM-dd' ); await page .locator('.group') .nth(0) - .locator('.rule--value .ant-picker') - .click(); - await page.locator('.ant-picker-dropdown').waitFor({ - state: 'visible', - }); - await page.locator('.ant-picker-input input').fill(newDate); - await page.press('.ant-picker-input input', 'Enter'); + .locator('.rule--value input[type="date"]') + .fill(newDate); // save and trigger contract validation await saveAndTriggerDataContractValidation(page, true); @@ -3742,15 +3706,10 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const date = customFormatDateTime( getEpochMillisForFutureDays(-1), - 'dd.MM.yyyy' + 'yyyy-MM-dd' ); - await ruleLocator.locator('.rule--value .ant-picker').click(); - await page.locator('.ant-picker-dropdown').waitFor({ - state: 'visible', - }); - await page.locator('.ant-picker-input input').fill(date); - await page.press('.ant-picker-input input', 'Enter'); + await ruleLocator.locator('.rule--value input[type="date"]').fill(date); // save and trigger contract validation await saveAndTriggerDataContractValidation(page, true); @@ -3776,19 +3735,14 @@ test.describe('Data Contracts Semantics Rule Updated on', () => { const newDate = customFormatDateTime( getEpochMillisForFutureDays(1), - 'dd.MM.yyyy' + 'yyyy-MM-dd' ); await page .locator('.group') .nth(0) - .locator('.rule--value .ant-picker') - .click(); - await page.locator('.ant-picker-dropdown').waitFor({ - state: 'visible', - }); - await page.locator('.ant-picker-input input').fill(newDate); - await page.press('.ant-picker-input input', 'Enter'); + .locator('.rule--value input[type="date"]') + .fill(newDate); // save and trigger contract validation await saveAndTriggerDataContractValidation(page, true); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts index d1abded3c277..07a6f50fce2c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts @@ -212,16 +212,8 @@ export const selectRange = async ( startDate: string, endDate: string ) => { - await ruleLocator.locator('.rule--value .ant-picker-range').click(); - - await page.locator('.ant-picker-dropdown-range').waitFor({ - state: 'visible', - }); - - await page.locator('.ant-picker-input-active input').fill(startDate); - await page.press('.ant-picker-input-active input', 'Enter'); - await page.locator('.ant-picker-input-active input').fill(endDate); - await page.press('.ant-picker-input-active input', 'Enter'); + await ruleLocator.locator('.rule--value input[type="date"]').nth(0).fill(startDate); + await ruleLocator.locator('.rule--value input[type="date"]').nth(1).fill(endDate); }; export const fillRule = async ( From 5f2ef62a18cbac15c008e9663f622fbd322545dd Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:40:33 +0530 Subject: [PATCH 18/67] fix(query-builder): replace Select.ComboBox with Autocomplete in OMFieldSelect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select.ComboBox passes items directly to AriaListBox, bypassing react-aria's built-in filtering — typing in the input does not filter the options list. Autocomplete filters visibleItems client-side via a contains() check and exposes onSearchChange for future BE search support. Key changes: - OMFieldSelect: switch from Select.ComboBox to Autocomplete - selectedKey → selectedItemsValue (memoized array so Autocomplete's useEffect resets internalSelected when the selected field changes) - selectedLabel shown as placeholder so the user can still see what's selected - renderTag={() => null} suppresses badge chips (single-value use case) - icon={null} hides the search icon to keep the trigger compact - OMFieldSelect.test: update assertion from getByRole('button') to getByRole('combobox') to match Autocomplete's AriaInput Co-Authored-By: Claude Sonnet 4.6 --- .../ui/playwright/utils/advancedSearch.ts | 10 +++- .../OMFieldSelect.test.tsx | 4 +- .../queryBuilderWidgets/OMFieldSelect.tsx | 48 +++++++++++-------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts index 07a6f50fce2c..d0db3e0d2011 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts @@ -212,8 +212,14 @@ export const selectRange = async ( startDate: string, endDate: string ) => { - await ruleLocator.locator('.rule--value input[type="date"]').nth(0).fill(startDate); - await ruleLocator.locator('.rule--value input[type="date"]').nth(1).fill(endDate); + await ruleLocator + .locator('.rule--value input[type="date"]') + .nth(0) + .fill(startDate); + await ruleLocator + .locator('.rule--value input[type="date"]') + .nth(1) + .fill(endDate); }; export const fillRule = async ( diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.test.tsx index 1835cfe3bdc4..7bb1444b1101 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.test.tsx @@ -70,10 +70,10 @@ const conjsProps = { }; describe('OMFieldSelect', () => { - it('renders a button (select trigger)', () => { + it('renders a combobox input', () => { render(); - expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx index 1e3dbf69183a..5017f046a50a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx @@ -10,9 +10,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Select, SelectItemType } from '@openmetadata/ui-core-components'; +import { Autocomplete, SelectItemType } from '@openmetadata/ui-core-components'; import type { FieldProps } from '@react-awesome-query-builder/ui'; -import type { FC } from 'react'; +import type { FC, ReactNode } from 'react'; +import { useMemo } from 'react'; const OMFieldSelect: FC = ({ items, @@ -21,31 +22,36 @@ const OMFieldSelect: FC = ({ readonly, placeholder, }) => { - const selectItems: SelectItemType[] = items.map((item) => ({ - id: item.key, - label: item.label, - })); + const selectItems: SelectItemType[] = useMemo( + () => items.map((item) => ({ id: item.key, label: item.label })), + [items] + ); + + const selectedLabel = selectedKey + ? selectItems.find((i) => i.id === selectedKey)?.label ?? selectedKey + : undefined; + + const selectedItemsValue = useMemo( + () => + selectedKey + ? [{ id: selectedKey, label: selectedLabel ?? selectedKey }] + : [], + [selectedKey, selectedLabel] + ); return ( - { - if (key) { - setField(String(key)); - } - }}> + placeholder={selectedLabel ?? placeholder ?? 'Select field'} + renderTag={(): ReactNode => null} + selectedItems={selectedItemsValue} + onItemInserted={(key) => setField(String(key))}> {(item) => ( - - {item.label} - + )} - + ); }; From 412901c738d0964f627360738b8ec941d1689f98 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:16:01 +0530 Subject: [PATCH 19/67] fix(query-builder): revert OMFieldSelect from Autocomplete back to Select.ComboBox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Autocomplete is designed for multi-select and excludes currently-selected items from visibleItems. For RAQB's field/operator selectors (single-select, static list), this caused the currently-selected item to never appear in the dropdown, making every Playwright selectOption call time out after 3 minutes. Select.ComboBox uses menuTrigger="focus" and explicit handlePointerDown that calls state.open(), so clicking or filling the input reliably opens the menu with all options visible — including the one already selected. Co-Authored-By: Claude Sonnet 4.6 --- .../queryBuilderWidgets/OMFieldSelect.tsx | 48 ++++++++----------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx index 5017f046a50a..1e3dbf69183a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMFieldSelect.tsx @@ -10,10 +10,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Autocomplete, SelectItemType } from '@openmetadata/ui-core-components'; +import { Select, SelectItemType } from '@openmetadata/ui-core-components'; import type { FieldProps } from '@react-awesome-query-builder/ui'; -import type { FC, ReactNode } from 'react'; -import { useMemo } from 'react'; +import type { FC } from 'react'; const OMFieldSelect: FC = ({ items, @@ -22,36 +21,31 @@ const OMFieldSelect: FC = ({ readonly, placeholder, }) => { - const selectItems: SelectItemType[] = useMemo( - () => items.map((item) => ({ id: item.key, label: item.label })), - [items] - ); - - const selectedLabel = selectedKey - ? selectItems.find((i) => i.id === selectedKey)?.label ?? selectedKey - : undefined; - - const selectedItemsValue = useMemo( - () => - selectedKey - ? [{ id: selectedKey, label: selectedLabel ?? selectedKey }] - : [], - [selectedKey, selectedLabel] - ); + const selectItems: SelectItemType[] = items.map((item) => ({ + id: item.key, + label: item.label, + })); return ( - null} - selectedItems={selectedItemsValue} - onItemInserted={(key) => setField(String(key))}> + placeholder={placeholder ?? 'Select field'} + selectedKey={selectedKey ?? undefined} + shortcut={false} + showSearchIcon={false} + size="sm" + onSelectionChange={(key) => { + if (key) { + setField(String(key)); + } + }}> {(item) => ( - + + {item.label} + )} - + ); }; From ff7e4861ad93ffac219aa7acc977ce49ac25a80e Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:48:09 +0530 Subject: [PATCH 20/67] =?UTF-8?q?fix(query-builder):=20fix=20strict=20mode?= =?UTF-8?q?=20violation=20in=20selectOption=20=E2=80=94=20add=20.first()?= =?UTF-8?q?=20to=20.or()=20locator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Select.ComboBox renders both input[role="combobox"] and button[aria-haspopup="listbox"], so the .or() locator was resolving to 2 elements causing Playwright strict mode to reject toBeVisible(). Adding .first() ensures a single-element locator without changing the branch logic below (comboboxInput.isVisible() still correctly targets only the input). Co-Authored-By: Claude Sonnet 4.6 --- .../src/main/resources/ui/playwright/utils/advancedSearch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts index d0db3e0d2011..5f0d6c99de56 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts @@ -179,7 +179,7 @@ export const selectOption = async ( 'button[aria-haspopup="listbox"]' ); - await expect(comboboxInput.or(triggerButton)).toBeVisible(); + await expect(comboboxInput.or(triggerButton).first()).toBeVisible(); if (await comboboxInput.isVisible()) { if (isSearchable) { From 315279d321c126da01e1ec900c7cc184192e4c3d Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:07:29 +0530 Subject: [PATCH 21/67] fix(query-builder): open Select.ComboBox via trigger button to show all options Clicking comboboxInput opens the ComboBox with the current selection's label as an active filter, hiding unrelated options (e.g. selecting '>' fails when current operator is '='). Per the ARIA spec, clicking the disclosure/trigger button opens the ComboBox in show-all mode. This also unifies the open path for both Select and Select.ComboBox in the non-searchable case. Co-Authored-By: Claude Sonnet 4.6 --- .../resources/ui/playwright/utils/advancedSearch.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts index 5f0d6c99de56..61e04368548d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts @@ -181,14 +181,13 @@ export const selectOption = async ( await expect(comboboxInput.or(triggerButton).first()).toBeVisible(); - if (await comboboxInput.isVisible()) { - if (isSearchable) { - await comboboxInput.clear(); - await comboboxInput.fill(optionTitle); - } else { - await comboboxInput.click(); - } + if (isSearchable) { + await comboboxInput.clear(); + await comboboxInput.fill(optionTitle); } else { + // Clicking the trigger button (not the input) opens Select.ComboBox in + // "show all" mode (per ARIA spec for disclosure buttons), preventing the + // input's current value from filtering out unrelated options. await triggerButton.click(); } From 4e2b5ab9efd37dbb81621ca79332f44fd68c6a75 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:10:04 +0530 Subject: [PATCH 22/67] fix(query-builder): use combobox input click (not toggle button) to open Select.ComboBox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the chevron AriaButton toggles the ComboBox open/close via React Aria's state.toggle(), causing the dropdown to close immediately after opening. Instead, click the hidden input element which triggers AriaGroup.handlePointerDown → state.open(null, 'input') — a one-way open, not a toggle. Then fill('') clears the current-value filter so all options are visible. For plain Select (no combobox input), keep using the trigger button click. Co-Authored-By: Claude Sonnet 4.6 --- .../resources/ui/playwright/utils/advancedSearch.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts index 61e04368548d..3743cd264df6 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts @@ -184,10 +184,14 @@ export const selectOption = async ( if (isSearchable) { await comboboxInput.clear(); await comboboxInput.fill(optionTitle); + } else if ((await comboboxInput.count()) > 0) { + // Select.ComboBox: clicking the input triggers AriaGroup.handlePointerDown + // which calls state.open(null, 'input') — not a toggle, so it only opens. + // Fill with '' afterwards to clear any current-value filter so all options show. + await comboboxInput.click(); + await comboboxInput.fill(''); } else { - // Clicking the trigger button (not the input) opens Select.ComboBox in - // "show all" mode (per ARIA spec for disclosure buttons), preventing the - // input's current value from filtering out unrelated options. + // Plain Select (no combobox input): click the trigger button to open. await triggerButton.click(); } From af2bf07a6a5726d37e9bfecd38204a6a4d9f8731 Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:02:49 +0530 Subject: [PATCH 23/67] fix(query-builder): exclude Suggestions listbox when waiting for dropdown page.locator('[role="listbox"]').first() was resolving to the global-search Suggestions listbox (aria-label="Suggestions") which is always in the DOM but permanently hidden. The ComboBox dropdown listbox appears later in DOM order, so .first() always picked the wrong one, causing waitFor({ state: 'visible' }) to time out after 332+ retries. Add :not([aria-label="Suggestions"]) to both the waitFor locator and the option search locator to skip the Suggestions listbox entirely. Co-Authored-By: Claude Sonnet 4.6 --- .../main/resources/ui/playwright/utils/advancedSearch.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts index 3743cd264df6..4b3033a5210b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts @@ -195,13 +195,18 @@ export const selectOption = async ( await triggerButton.click(); } - await page.locator('[role="listbox"]').first().waitFor({ state: 'visible' }); + // Exclude the global-search Suggestions listbox (aria-label="Suggestions") which is + // always present in the DOM but hidden — .first() would otherwise resolve to it. + await page + .locator('[role="listbox"]:not([aria-label="Suggestions"])') + .first() + .waitFor({ state: 'visible' }); // eslint-disable-next-line playwright/no-wait-for-timeout -- dropdown animation settling await page.waitForTimeout(100); const optionLocator = page - .locator('[role="listbox"]:visible') + .locator('[role="listbox"]:not([aria-label="Suggestions"]):visible') .getByRole('option', { name: optionTitle, exact: true }) .first(); From ed906d5fa5c8848df72294ff4802741d54ce902f Mon Sep 17 00:00:00 2001 From: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:06:04 +0530 Subject: [PATCH 24/67] fix(query-builder): wait for visible listbox and guard async widget races Verified against Storybook with a Playwright probe: 1. React Aria labels EVERY ComboBox popup listbox "Suggestions" (@react-aria/combobox listboxLabel), so the previous :not([aria-label="Suggestions"]) filter excluded the query builder's own dropdown and every selectOption call hung until test timeout. 2. The popup listbox mounts only while open, but the nav-bar search's Suggestions listbox stays in the DOM hidden. A plain [role="listbox"].first() pins that hidden element and waitFor never resolves. Filter with :visible so the locator re-resolves to whichever listbox is actually open. 3. OMSelectWidget/OMMultiSelectWidget loadAsync had no request ordering: the mount-time loadAsync('') could resolve after loadAsync(searchTerm) and clobber items with unfiltered results, so the typed option never appeared. Add a requestId guard so only the latest response wins, and drop the redundant clear() before fill() in the searchable path. Co-Authored-By: Claude Sonnet 4.6 --- .../ui/playwright/utils/advancedSearch.ts | 12 ++++++----- .../OMMultiSelectWidget.tsx | 21 ++++++++++++------- .../queryBuilderWidgets/OMSelectWidget.tsx | 20 +++++++++++------- 3 files changed, 34 insertions(+), 19 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts index 4b3033a5210b..32eb21b51dc3 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/advancedSearch.ts @@ -182,7 +182,7 @@ export const selectOption = async ( await expect(comboboxInput.or(triggerButton).first()).toBeVisible(); if (isSearchable) { - await comboboxInput.clear(); + // Single fill (no clear first) — one input event, one async fetch. await comboboxInput.fill(optionTitle); } else if ((await comboboxInput.count()) > 0) { // Select.ComboBox: clicking the input triggers AriaGroup.handlePointerDown @@ -195,10 +195,12 @@ export const selectOption = async ( await triggerButton.click(); } - // Exclude the global-search Suggestions listbox (aria-label="Suggestions") which is - // always present in the DOM but hidden — .first() would otherwise resolve to it. + // The ComboBox popup listbox mounts only while open, but other hidden + // listboxes stay in the DOM (e.g. the nav-bar search's "Suggestions"). + // React Aria labels every ComboBox popup "Suggestions", so filter by + // visibility — never by aria-label or DOM order. await page - .locator('[role="listbox"]:not([aria-label="Suggestions"])') + .locator('[role="listbox"]:visible') .first() .waitFor({ state: 'visible' }); @@ -206,7 +208,7 @@ export const selectOption = async ( await page.waitForTimeout(100); const optionLocator = page - .locator('[role="listbox"]:not([aria-label="Suggestions"]):visible') + .locator('[role="listbox"]:visible') .getByRole('option', { name: optionTitle, exact: true }) .first(); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMMultiSelectWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMMultiSelectWidget.tsx index 3d97fca3da13..3d53f8313f75 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMMultiSelectWidget.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMMultiSelectWidget.tsx @@ -15,7 +15,7 @@ import type { ListItem, MultiSelectWidgetProps, } from '@react-awesome-query-builder/ui'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { Key } from 'react-aria-components'; import { useListData } from 'react-stately'; @@ -74,18 +74,25 @@ const OMMultiSelectWidget = ({ } }, [valueArray.join(',')]); + const requestIdRef = useRef(0); + const loadAsync = useCallback( async (search: string) => { if (!asyncFetch) { return; } + // Guard against out-of-order responses: only the latest request may + // set items, otherwise a slow earlier fetch overwrites newer results. + const requestId = ++requestIdRef.current; const result = await asyncFetch(search); - setAllItems( - (result.values as ListItem[]).map((item) => ({ - id: String(item.value), - label: String(item.title ?? item.value), - })) - ); + if (requestId === requestIdRef.current) { + setAllItems( + (result.values as ListItem[]).map((item) => ({ + id: String(item.value), + label: String(item.title ?? item.value), + })) + ); + } }, [asyncFetch] ); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.tsx index 5261e59c1ce2..4461b5a8fb73 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/queryBuilderWidgets/OMSelectWidget.tsx @@ -16,7 +16,7 @@ import type { SelectWidgetProps, } from '@react-awesome-query-builder/ui'; import type { FC } from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; const toSelectItems = ( listValues: SelectWidgetProps['listValues'] @@ -48,19 +48,25 @@ const OMSelectWidget: FC = ({ }) => { const staticItems = toSelectItems(listValues); const [items, setItems] = useState(staticItems); + const requestIdRef = useRef(0); const loadAsync = useCallback( async (search: string) => { if (!asyncFetch) { return; } + // Guard against out-of-order responses: only the latest request may + // set items, otherwise a slow earlier fetch overwrites newer results. + const requestId = ++requestIdRef.current; const result = await asyncFetch(search); - setItems( - (result.values as ListItem[]).map((item) => ({ - id: String(item.value), - label: String(item.title ?? item.value), - })) - ); + if (requestId === requestIdRef.current) { + setItems( + (result.values as ListItem[]).map((item) => ({ + id: String(item.value), + label: String(item.title ?? item.value), + })) + ); + } }, [asyncFetch] ); From 78a5b44e7ae87c07dc13fffc51bcb22c53b3c031 Mon Sep 17 00:00:00 2001 From: Rohit Jain <60229265+Rohit0301@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:48:49 +0530 Subject: [PATCH 25/67] Migrated asset selection drawer and modal (#29956) * Migrated asset selection drawer and modal * lint fix * addressed gitar comment * fixed playwright test * addressed PR comment * lint fix --- .../resources/ui/playwright/utils/glossary.ts | 43 +- .../AssetSelectionContentBody.test.tsx | 437 +++++++++ .../AssetSelectionContentBody.tsx | 265 ++++++ .../AssetSelectionDrawer.test.tsx | 320 +++++++ .../AssetSelectionDrawer.tsx | 82 +- .../AssetSelectionFooter.test.tsx | 184 ++++ .../AssetSelectionFooter.tsx | 93 ++ .../AssetSelectionModal.test.tsx | 296 ++++++ .../AssetSelectionModal.tsx | 76 +- .../useAssetSelectionDrawer.tsx | 92 -- .../useAssetSelectionState.test.ts | 876 ++++++++++++++++++ ...nContent.tsx => useAssetSelectionState.ts} | 345 +------ .../EntityHeaderTitle.component.tsx | 9 +- .../TableDataCardV2/TableDataCardV2.tsx | 17 + .../ui/src/constants/Assets.constants.ts | 12 +- 15 files changed, 2691 insertions(+), 456 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionContentBody.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionContentBody.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionDrawer.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionFooter.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionFooter.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.test.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionDrawer.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionState.test.ts rename openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/{useAssetSelectionContent.tsx => useAssetSelectionState.ts} (59%) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts index f11817817151..61effeff909f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/glossary.ts @@ -787,10 +787,10 @@ export const addAssetToGlossaryTerm = async ( await page.click('[data-testid="glossary-term-add-button-menu"]'); await page.getByRole('menuitem', { name: 'Assets' }).click(); - await expect(page.locator('[role="dialog"].ant-modal')).toBeVisible(); - await expect( - page.locator('[data-testid="asset-selection-modal"] .ant-modal-title') - ).toContainText('Add Assets'); + const assetSelectionModal = page.getByTestId('asset-selection-modal'); + + await expect(assetSelectionModal).toBeVisible(); + await expect(assetSelectionModal).toContainText('Add Assets'); await expect(page.locator('.asset-filters-wrapper')).toBeVisible(); @@ -821,7 +821,7 @@ export const addAssetToGlossaryTerm = async ( await page.click( `[data-testid="table-data-card_${entityFqn}"] input[type="checkbox"]` ); - + await waitForAllLoadersToDisappear(page); await expect( page.locator( `[data-testid="table-data-card_${entityFqn}"] [data-testid="entity-header-name"]` @@ -854,15 +854,11 @@ const testFilterWithSpecificOption = async ( await filterResponse; - await expect( - page.locator('.asset-filters-wrapper .text-primary.cursor-pointer') - ).toBeVisible(); + await expect(filterWrapper.getByTestId('clear-filters')).toBeVisible(); const clearFilterResponse = page.waitForResponse('/api/v1/search/query?*'); - await page - .locator('.asset-filters-wrapper .text-primary.cursor-pointer') - .click(); + await filterWrapper.getByTestId('clear-filters').click(); await clearFilterResponse; }; @@ -875,9 +871,10 @@ const testFilterWithFirstOption = async ( const filter = filterWrapper.getByTestId(`search-dropdown-${filterName}`); await filter.click(); - await page.getByTestId('drop-down-menu').waitFor(); + const dropdownMenu = page.getByTestId('drop-down-menu'); + await dropdownMenu.waitFor(); - const options = page.locator('[data-testid="drop-down-menu"]'); + const options = dropdownMenu.locator('[data-testid$="-checkbox"]'); await waitForAllLoadersToDisappear(page); const firstOption = options.first(); const noDataPlaceholder = page.getByText(/No data available/i); @@ -895,17 +892,13 @@ const testFilterWithFirstOption = async ( await filterResponse; - await expect( - page.locator('.asset-filters-wrapper .text-primary.cursor-pointer') - ).toBeVisible(); + await expect(filterWrapper.getByTestId('clear-filters')).toBeVisible(); const clearFilterResponse = page.waitForResponse( '/api/v1/search/query?*' ); - await page - .locator('.asset-filters-wrapper .text-primary.cursor-pointer') - .click(); + await filterWrapper.getByTestId('clear-filters').click(); await clearFilterResponse; } @@ -925,10 +918,10 @@ export const verifyAssetModalFilters = async ( await page.click('[data-testid="glossary-term-add-button-menu"]'); await page.getByRole('menuitem', { name: 'Assets' }).click(); - await expect(page.locator('[role="dialog"].ant-modal')).toBeVisible(); - await expect( - page.locator('[data-testid="asset-selection-modal"] .ant-modal-title') - ).toContainText('Add Assets'); + const assetSelectionModal = page.getByTestId('asset-selection-modal'); + + await expect(assetSelectionModal).toBeVisible(); + await expect(assetSelectionModal).toContainText('Add Assets'); await expect(page.locator('.asset-filters-wrapper')).toBeVisible(); @@ -955,7 +948,7 @@ export const verifyAssetModalFilters = async ( page, filterWrapper, 'entityType', - 'table', + 'table-checkbox', 'table' ); @@ -963,7 +956,7 @@ export const verifyAssetModalFilters = async ( page, filterWrapper, 'serviceType', - 'mysql', + 'mysql-checkbox', 'mysql' ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionContentBody.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionContentBody.test.tsx new file mode 100644 index 000000000000..432a77c6c2f3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionContentBody.test.tsx @@ -0,0 +1,437 @@ +/* + * Copyright 2026 Collate. + * Licensed 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 { fireEvent, render, screen } from '@testing-library/react'; +import AssetSelectionContentBody, { + AssetSelectionContentBodyProps, +} from './AssetSelectionContentBody'; + +jest.mock('@openmetadata/ui-core-components', () => ({ + Box: ({ + children, + onScroll, + 'data-testid': testId, + }: { + children: React.ReactNode; + onScroll?: () => void; + 'data-testid'?: string; + }) => ( +
+ {children} +
+ ), + Divider: () =>
, + Typography: ({ + children, + onClick, + 'data-testid': testId, + }: { + children: React.ReactNode; + onClick?: () => void; + 'data-testid'?: string; + }) => ( + + {children} + + ), +})); + +jest.mock('../../common/Banner/Banner', () => { + return jest.fn(({ type, message }: { type: string; message: string }) => ( +
+ {message} +
+ )); +}); + +jest.mock('../../common/ErrorWithPlaceholder/ErrorPlaceHolder', () => { + return jest.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )); +}); + +jest.mock('../../common/Loader/Loader', () => { + return jest.fn(() =>
); +}); + +jest.mock('../../common/SearchBarComponent/SearchBar.component', () => { + return jest.fn( + ({ + searchValue, + onSearch, + }: { + searchValue: string; + onSearch: (value: string) => void; + }) => ( + onSearch(e.target.value)} + /> + ) + ); +}); + +jest.mock('../../common/TableDataCardV2/TableDataCardV2', () => { + return jest.fn( + ({ + id, + checked, + source, + handleSummaryPanelDisplay, + }: { + id: string; + checked?: boolean; + source: { id?: string }; + handleSummaryPanelDisplay?: (source: unknown) => void; + }) => ( +
+ +
+ ) + ); +}); + +jest.mock('../../Explore/ExploreQuickFilters', () => { + return jest.fn(() =>
); +}); + +jest.mock('../DomainAssetDryRunModal/DomainAssetDryRunModal.component', () => { + return jest.fn( + ({ + visible, + onConfirm, + onCancel, + }: { + visible: boolean; + onConfirm: () => void; + onCancel: () => void; + }) => + visible ? ( +
+ + +
+ ) : null + ); +}); + +const mockSetSearch = jest.fn(); +const mockHandleCardClick = jest.fn(); +const mockConfirmDomainAssetMove = jest.fn(); +const mockCancelDomainAssetMove = jest.fn(); +const mockOnScroll = jest.fn(); +const mockOnSelectAll = jest.fn(); +const mockGetErrorStatusAndMessage = jest + .fn() + .mockReturnValue({ isError: false, errorMessage: null }); +const mockHandleQuickFiltersValueSelect = jest.fn(); +const mockClearFilters = jest.fn(); + +const buildItem = (id: string) => ({ + _id: id, + _index: 'index', + _source: { id, name: id }, +}); + +const defaultProps: AssetSelectionContentBodyProps = { + search: '', + setSearch: mockSetSearch, + items: [], + failedStatus: undefined, + dryRunWarnings: undefined, + exportJob: undefined, + selectedItems: new Map(), + isLoading: false, + isSaveLoading: false, + assetJobResponse: undefined, + aggregations: undefined, + quickFilterQuery: undefined, + filters: [], + totalCount: 0, + handleCardClick: mockHandleCardClick, + confirmDomainAssetMove: mockConfirmDomainAssetMove, + cancelDomainAssetMove: mockCancelDomainAssetMove, + onScroll: mockOnScroll, + onSelectAll: mockOnSelectAll, + getErrorStatusAndMessage: mockGetErrorStatusAndMessage, + handleQuickFiltersValueSelect: mockHandleQuickFiltersValueSelect, + clearFilters: mockClearFilters, +} as unknown as AssetSelectionContentBodyProps; + +describe('AssetSelectionContentBody', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetErrorStatusAndMessage.mockReturnValue({ + isError: false, + errorMessage: null, + }); + }); + + it('should show error banner when exportJob has an error', () => { + render( + + ); + + expect(screen.getByTestId('banner')).toHaveAttribute('data-type', 'error'); + }); + + it('should show success/loading banner when assetJobResponse is present without export error', () => { + render( + + ); + + expect(screen.getByTestId('banner')).toHaveAttribute( + 'data-type', + 'success' + ); + }); + + it('should not show banner when no exportJob or assetJobResponse', () => { + render(); + + expect(screen.queryByTestId('banner')).not.toBeInTheDocument(); + }); + + it('should render info banner when infoBannerText is provided', () => { + render( + + ); + + expect(screen.getByText('some info')).toBeInTheDocument(); + }); + + it('should call setSearch when search bar value changes', () => { + render(); + + fireEvent.change(screen.getByTestId('search-bar'), { + target: { value: 'new search' }, + }); + + expect(mockSetSearch).toHaveBeenCalledWith('new search'); + }); + + it('should not render clear-filters when quickFilterQuery is undefined', () => { + render(); + + expect(screen.queryByTestId('clear-filters')).not.toBeInTheDocument(); + }); + + it('should render clear-filters and call clearFilters when quickFilterQuery is set', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('clear-filters')); + + expect(mockClearFilters).toHaveBeenCalledTimes(1); + }); + + it('should show validation error alert when failedStatus has failed requests', () => { + render( + + ); + + expect( + screen.getByText('label.validation-error-plural') + ).toBeInTheDocument(); + }); + + it('should not show validation error alert when failedStatus has no failed requests', () => { + render( + + ); + + expect( + screen.queryByText('label.validation-error-plural') + ).not.toBeInTheDocument(); + }); + + it('should render a card per item and select-all checkbox when items exist', () => { + const items = [buildItem('1'), buildItem('2')]; + + render( + + ); + + expect(screen.getByTestId('tabledatacard-1')).toBeInTheDocument(); + expect(screen.getByTestId('tabledatacard-2')).toBeInTheDocument(); + expect(screen.getByText('label.select-field')).toBeInTheDocument(); + }); + + it('should mark a card as checked when its id is in selectedItems', () => { + const items = [buildItem('1')]; + const selectedItems = new Map([['1', { id: '1' }]]) as never; + + render( + + ); + + expect(screen.getByTestId('tabledatacard-1')).toHaveAttribute( + 'data-checked', + 'true' + ); + }); + + it('should call handleCardClick when a card is selected', () => { + const items = [buildItem('1')]; + + render( + + ); + + fireEvent.click(screen.getByTestId('tabledatacard-1-select')); + + expect(mockHandleCardClick).toHaveBeenCalled(); + }); + + it('should call onSelectAll with true when select-all checkbox is checked', () => { + const items = [buildItem('1')]; + + render( + + ); + + fireEvent.click(screen.getByRole('checkbox')); + + expect(mockOnSelectAll).toHaveBeenCalledWith(true); + }); + + it('should show a per-item error message when getErrorStatusAndMessage reports an error', () => { + mockGetErrorStatusAndMessage.mockReturnValue({ + isError: true, + errorMessage: 'card failed', + }); + const items = [buildItem('1')]; + + render( + + ); + + expect(screen.getByText('card failed')).toBeInTheDocument(); + }); + + it('should show bottom loader when loading additional pages', () => { + const items = [buildItem('1')]; + + render( + + ); + + expect(screen.getByTestId('loader')).toBeInTheDocument(); + }); + + it('should show empty placeholder when not loading and there are no items', () => { + render( + + ); + + expect(screen.getByTestId('error-placeholder')).toBeInTheDocument(); + expect(screen.getByText('no data found')).toBeInTheDocument(); + }); + + it('should show loader when loading and there are no items yet', () => { + render(); + + expect(screen.getByTestId('loader')).toBeInTheDocument(); + expect(screen.queryByTestId('error-placeholder')).not.toBeInTheDocument(); + }); + + it('should render dry run modal when dryRunWarnings is defined', () => { + render( + + ); + + expect(screen.getByTestId('dry-run-modal')).toBeInTheDocument(); + }); + + it('should not render dry run modal when dryRunWarnings is undefined', () => { + render(); + + expect(screen.queryByTestId('dry-run-modal')).not.toBeInTheDocument(); + }); + + it('should wire confirm/cancel of dry run modal to the provided handlers', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('dry-run-confirm')); + fireEvent.click(screen.getByTestId('dry-run-cancel')); + + expect(mockConfirmDomainAssetMove).toHaveBeenCalledTimes(1); + expect(mockCancelDomainAssetMove).toHaveBeenCalledTimes(1); + }); + + it('should call onScroll when the scrollable container is scrolled', () => { + const items = [buildItem('1')]; + + const { container } = render( + + ); + + const scrollableContainer = screen + .getByTestId('tabledatacard-1') + .closest('.asset-list-wrapper')?.parentElement; + + fireEvent.scroll(scrollableContainer ?? container, { + target: { scrollTop: 100 }, + }); + + expect(mockOnScroll).toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionContentBody.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionContentBody.tsx new file mode 100644 index 000000000000..6a31e1131bf8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionContentBody.tsx @@ -0,0 +1,265 @@ +/* + * Copyright 2025 Collate. + * Licensed 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 { ExclamationCircleOutlined } from '@ant-design/icons'; +import { + Box, + Divider as CoreDivider, + Typography, +} from '@openmetadata/ui-core-components'; +import { Alert, Checkbox, List } from 'antd'; +import classNames from 'classnames'; +import { isUndefined } from 'lodash'; +import VirtualList from 'rc-virtual-list'; +import { useTranslation } from 'react-i18next'; +import { SearchIndex } from '../../../enums/search.enum'; +import Banner from '../../common/Banner/Banner'; +import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; +import Loader from '../../common/Loader/Loader'; +import Searchbar from '../../common/SearchBarComponent/SearchBar.component'; +import TableDataCardV2 from '../../common/TableDataCardV2/TableDataCardV2'; +import ExploreQuickFilters from '../../Explore/ExploreQuickFilters'; +import DomainAssetDryRunModal from '../DomainAssetDryRunModal/DomainAssetDryRunModal.component'; +import './asset-selection-model.style.less'; +import { UseAssetSelectionStateReturn } from './useAssetSelectionState'; + +export interface AssetSelectionContentBodyProps + extends Pick< + UseAssetSelectionStateReturn, + | 'search' + | 'setSearch' + | 'items' + | 'failedStatus' + | 'dryRunWarnings' + | 'exportJob' + | 'selectedItems' + | 'isLoading' + | 'isSaveLoading' + | 'assetJobResponse' + | 'aggregations' + | 'quickFilterQuery' + | 'filters' + | 'totalCount' + | 'handleCardClick' + | 'confirmDomainAssetMove' + | 'cancelDomainAssetMove' + | 'onScroll' + | 'onSelectAll' + | 'getErrorStatusAndMessage' + | 'handleQuickFiltersValueSelect' + | 'clearFilters' + > { + emptyPlaceHolderText?: string; + infoBannerText?: string; +} + +const AssetSelectionContentBody = ({ + emptyPlaceHolderText, + infoBannerText, + search, + setSearch, + items, + failedStatus, + dryRunWarnings, + exportJob, + selectedItems, + isLoading, + isSaveLoading, + assetJobResponse, + aggregations, + quickFilterQuery, + filters, + totalCount, + handleCardClick, + confirmDomainAssetMove, + cancelDomainAssetMove, + onScroll, + onSelectAll, + getErrorStatusAndMessage, + handleQuickFiltersValueSelect, + clearFilters, +}: AssetSelectionContentBodyProps) => { + const { t } = useTranslation(); + + return ( + + + {(assetJobResponse || exportJob?.error) && ( + + )} + + {infoBannerText && ( + + )} + +
+
+ +
+
+ +
+ + {quickFilterQuery && ( + + {t('label.clear-entity', { + entity: '', + })} + + )} +
+ + {failedStatus?.failedRequest && + failedStatus.failedRequest.length > 0 && ( + + {t('message.validation-error-assets')} + + } + message={ +
+ + + {t('label.validation-error-plural')} + +
+ } + type="error" + /> + )} +
+ + + {items.length > 0 && ( +
+ onSelectAll(e.target.checked)}> + {t('label.select-field', { + field: t('label.all'), + })} + + + item._source.id ?? item._id}> + {({ _source: item }) => { + const { isError, errorMessage } = getErrorStatusAndMessage( + item.id ?? '' + ); + + return ( +
+ + {isError && ( + <> +
+ +
+
+ + + {errorMessage} + +
+ + )} +
+ ); + }} +
+
+ {isLoading && items.length < totalCount && ( +
+ +
+ )} +
+ )} + + {!isLoading && items.length === 0 && ( + + {emptyPlaceHolderText && ( + {emptyPlaceHolderText} + )} + + )} + + {isLoading && items.length === 0 && } +
+ + +
+ ); +}; + +export default AssetSelectionContentBody; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionDrawer.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionDrawer.test.tsx new file mode 100644 index 000000000000..1079f2a96831 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionDrawer.test.tsx @@ -0,0 +1,320 @@ +/* + * Copyright 2026 Collate. + * Licensed 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 { fireEvent, render, screen } from '@testing-library/react'; +import { AssetsOfEntity } from '../../Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; +import { AssetSelectionDrawer } from './AssetSelectionDrawer'; +import { useAssetSelectionState } from './useAssetSelectionState'; + +jest.mock('@openmetadata/ui-core-components', () => ({ + SlideoutMenu: Object.assign( + ({ + isOpen, + onOpenChange, + children, + 'data-testid': testId, + }: { + isOpen: boolean; + onOpenChange?: (isOpen: boolean) => void; + children: React.ReactNode; + 'data-testid'?: string; + }) => + isOpen ? ( +
+ {children} + +
+ ) : null, + { + Header: ({ + children, + onClose, + 'data-testid': testId, + }: { + children: React.ReactNode; + onClose?: () => void; + 'data-testid'?: string; + }) => ( +
+ {children} +
+ ), + Content: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Footer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + } + ), + Typography: ({ + children, + 'data-testid': testId, + }: { + children: React.ReactNode; + 'data-testid'?: string; + }) => {children}, +})); + +jest.mock('./useAssetSelectionState'); + +jest.mock('./AssetSelectionContentBody', () => { + return jest.fn(() =>
); +}); + +jest.mock('./AssetSelectionFooter', () => { + return jest.fn( + ({ onSave, onCancel }: { onSave?: () => void; onCancel?: () => void }) => ( +
+ + +
+ ) + ); +}); + +const mockOnSaveAction = jest.fn(); +const mockOnCancel = jest.fn(); +const mockOnSave = jest.fn(); + +const baseState = { + search: '', + setSearch: jest.fn(), + items: [], + failedStatus: undefined, + dryRunWarnings: undefined, + exportJob: undefined, + selectedItems: new Map(), + isLoading: false, + isSaveLoading: false, + assetJobResponse: undefined, + aggregations: undefined, + quickFilterQuery: undefined, + filters: [], + totalCount: 0, + handleCardClick: jest.fn(), + onSaveAction: mockOnSaveAction, + confirmDomainAssetMove: jest.fn(), + cancelDomainAssetMove: jest.fn(), + onScroll: jest.fn(), + onSelectAll: jest.fn(), + getErrorStatusAndMessage: jest.fn(), + handleQuickFiltersValueSelect: jest.fn(), + clearFilters: jest.fn(), +}; + +describe('AssetSelectionDrawer', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useAssetSelectionState as jest.Mock).mockReturnValue(baseState); + }); + + it('should not render content body when open is false', () => { + render( + + ); + + expect(screen.queryByTestId('content-body')).not.toBeInTheDocument(); + }); + + it('should render content body when open is true', () => { + render( + + ); + + expect(screen.getByTestId('content-body')).toBeInTheDocument(); + }); + + it('should render the footer once the drawer is open', () => { + render( + + ); + + expect(screen.getByTestId('footer')).toBeInTheDocument(); + }); + + it('should call useAssetSelectionState with drawer variant and provided props', () => { + render( + + ); + + expect(useAssetSelectionState).toHaveBeenCalledWith( + expect.objectContaining({ + entityFqn: 'glossary.term', + type: AssetsOfEntity.DOMAIN, + open: true, + variant: 'drawer', + onSave: mockOnSave, + onCancel: mockOnCancel, + }) + ); + }); + + it('should default type to GLOSSARY when not provided', () => { + render( + + ); + + expect(useAssetSelectionState).toHaveBeenCalledWith( + expect.objectContaining({ type: AssetsOfEntity.GLOSSARY }) + ); + }); + + it('should render the default title when no title prop is given', () => { + render( + + ); + + expect(screen.getByTestId('drawer-heading')).toHaveTextContent( + 'label.add-entity' + ); + }); + + it('should render a custom title when title prop is given', () => { + render( + + ); + + expect(screen.getByTestId('drawer-heading')).toHaveTextContent( + 'Add Input Ports' + ); + }); + + it('should call onCancel when the header close icon is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('drawer-close-icon')); + + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + + it('should call onCancel when the slideout requests close via onOpenChange', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('slideout-backdrop')); + + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + + it('should pass derived counts to the footer', () => { + (useAssetSelectionState as jest.Mock).mockReturnValue({ + ...baseState, + failedStatus: { failedRequest: [{ request: { id: '1' } }] }, + assetJobResponse: { message: 'running' }, + selectedItems: new Map([['1', {}]]), + }); + + render( + + ); + + expect(screen.getByTestId('footer')).toBeInTheDocument(); + }); + + it("should call the hook's onSaveAction when footer save is clicked", () => { + render( + + ); + + fireEvent.click(screen.getByTestId('footer-save')); + + expect(mockOnSaveAction).toHaveBeenCalledTimes(1); + }); + + it('should call onCancel when footer cancel is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('footer-cancel')); + + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + + it('should pass infoBannerText through to the content body', () => { + render( + + ); + + expect(screen.getByTestId('content-body')).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionDrawer.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionDrawer.tsx index 061c8d331521..3ada6c841ef8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionDrawer.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionDrawer.tsx @@ -10,9 +10,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { SlideoutMenu, Typography } from '@openmetadata/ui-core-components'; +import { isUndefined } from 'lodash'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { QueryFilterInterface } from '../../../pages/ExplorePage/ExplorePage.interface'; import { AssetsOfEntity } from '../../Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; -import { useAssetSelectionDrawer } from './useAssetSelectionDrawer'; +import AssetSelectionContentBody from './AssetSelectionContentBody'; +import AssetSelectionFooter from './AssetSelectionFooter'; +import { useAssetSelectionState } from './useAssetSelectionState'; interface AssetSelectionDrawerProps { entityFqn: string; @@ -37,15 +43,79 @@ export const AssetSelectionDrawer = ({ onCancel, title, }: AssetSelectionDrawerProps) => { - return useAssetSelectionDrawer({ + const { t } = useTranslation(); + + const state = useAssetSelectionState({ entityFqn, - open, type, queryFilter, - emptyPlaceHolderText, - infoBannerText, + open, + variant: 'drawer', onSave, onCancel, - title, }); + + const handleOpenChange = useCallback( + (isOpen: boolean) => { + if (!isOpen) { + onCancel(); + } + }, + [onCancel] + ); + + return ( + + + + {title ?? t('label.add-entity', { entity: t('label.asset-plural') })} + + + + {open && ( + + )} + + + + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionFooter.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionFooter.test.tsx new file mode 100644 index 000000000000..7252d645fa5f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionFooter.test.tsx @@ -0,0 +1,184 @@ +/* + * Copyright 2026 Collate. + * Licensed 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 { fireEvent, render, screen } from '@testing-library/react'; +import AssetSelectionFooter, { + AssetSelectionFooterProps, +} from './AssetSelectionFooter'; + +jest.mock('@openmetadata/ui-core-components', () => ({ + Box: ({ + children, + 'data-testid': testId, + }: { + children: React.ReactNode; + 'data-testid'?: string; + }) =>
{children}
, + Button: ({ + children, + onClick, + isDisabled, + isLoading, + 'data-testid': testId, + }: { + children: React.ReactNode; + onClick?: () => void; + isDisabled?: boolean; + isLoading?: boolean; + 'data-testid'?: string; + }) => ( + + ), + Divider: ({ 'data-testid': testId }: { 'data-testid'?: string }) => ( +
+ ), + Typography: ({ + children, + 'data-testid': testId, + }: { + children: React.ReactNode; + 'data-testid'?: string; + }) => {children}, +})); + +const mockOnCancel = jest.fn(); +const mockOnSave = jest.fn(); + +const defaultProps: AssetSelectionFooterProps = { + selectedCount: 0, + errorCount: 0, + isLoading: false, + isSaveLoading: false, + hasAssetJobResponse: false, + onCancel: mockOnCancel, + onSave: mockOnSave, +}; + +describe('AssetSelectionFooter', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not render selected count when selectedCount is 0', () => { + render(); + + expect( + screen.queryByText(/label.selected-lowercase/) + ).not.toBeInTheDocument(); + }); + + it('should render selected count when selectedCount is at least 1', () => { + render(); + + expect(screen.getByText(/label.selected-lowercase/)).toBeInTheDocument(); + }); + + it('should not render error count or divider when errorCount is 0', () => { + render(); + + expect(screen.queryByText(/label.error/)).not.toBeInTheDocument(); + }); + + it('should render error count and divider when errorCount is greater than 0', () => { + render(); + + expect(screen.getByText(/label.error/)).toBeInTheDocument(); + }); + + it('should disable save button when selectedCount is 0', () => { + render(); + + expect(screen.getByTestId('save-btn')).toBeDisabled(); + }); + + it('should disable save button when isLoading is true', () => { + render( + + ); + + expect(screen.getByTestId('save-btn')).toBeDisabled(); + }); + + it('should disable save button when isSaveLoading is true', () => { + render( + + ); + + expect(screen.getByTestId('save-btn')).toBeDisabled(); + }); + + it('should disable save button when hasAssetJobResponse is true', () => { + render( + + ); + + expect(screen.getByTestId('save-btn')).toBeDisabled(); + }); + + it('should enable save button when selectedCount is at least 1 and nothing is loading', () => { + render(); + + expect(screen.getByTestId('save-btn')).not.toBeDisabled(); + }); + + it('should show loading state on save button when isSaveLoading is true', () => { + render( + + ); + + expect(screen.getByTestId('save-btn')).toHaveAttribute( + 'data-loading', + 'true' + ); + }); + + it('should show loading state on save button when hasAssetJobResponse is true', () => { + render( + + ); + + expect(screen.getByTestId('save-btn')).toHaveAttribute( + 'data-loading', + 'true' + ); + }); + + it('should call onCancel when cancel button is clicked', () => { + render(); + + fireEvent.click(screen.getByTestId('cancel-btn')); + + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + + it('should call onSave when save button is clicked and enabled', () => { + render(); + + fireEvent.click(screen.getByTestId('save-btn')); + + expect(mockOnSave).toHaveBeenCalledTimes(1); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionFooter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionFooter.tsx new file mode 100644 index 000000000000..3b0b078bd787 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionFooter.tsx @@ -0,0 +1,93 @@ +/* + * Copyright 2025 Collate. + * Licensed 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 { + Box, + Button, + Divider, + Typography, +} from '@openmetadata/ui-core-components'; +import { AlertCircle, CheckCircle } from '@untitledui/icons'; +import { useTranslation } from 'react-i18next'; + +export interface AssetSelectionFooterProps { + selectedCount: number; + errorCount: number; + isLoading: boolean; + isSaveLoading: boolean; + hasAssetJobResponse: boolean; + onCancel?: () => void; + onSave: () => void; +} + +const AssetSelectionFooter = ({ + selectedCount, + errorCount, + isLoading, + isSaveLoading, + hasAssetJobResponse, + onCancel, + onSave, +}: AssetSelectionFooterProps) => { + const { t } = useTranslation(); + + return ( + + + {selectedCount >= 1 && ( + + + + + + {selectedCount} {t('label.selected-lowercase')} + + + )} + {errorCount > 0 && ( + <> + + + + + + + {errorCount} {t('label.error')} + + + + )} + + + + + + + + ); +}; + +export default AssetSelectionFooter; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.test.tsx new file mode 100644 index 000000000000..b79b3130eab8 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.test.tsx @@ -0,0 +1,296 @@ +/* + * Copyright 2026 Collate. + * Licensed 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 { fireEvent, render, screen } from '@testing-library/react'; +import { AssetsOfEntity } from '../../Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; +import { AssetSelectionModal } from './AssetSelectionModal'; +import { useAssetSelectionState } from './useAssetSelectionState'; + +jest.mock('@openmetadata/ui-core-components', () => { + const MockDialog = Object.assign( + ({ + children, + title, + onClose, + 'data-testid': testId, + }: { + children: React.ReactNode; + title?: string; + onClose?: () => void; + 'data-testid'?: string; + }) => ( +
+ {title} + + {children} +
+ ), + { + Content: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Footer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + } + ); + + return { + Dialog: MockDialog, + Modal: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ModalOverlay: ({ + children, + isOpen, + onOpenChange, + }: { + children: React.ReactNode; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + }) => + isOpen ? ( +
+ {children} + +
+ ) : null, + }; +}); + +jest.mock('./useAssetSelectionState'); + +jest.mock('./AssetSelectionContentBody', () => { + return jest.fn(() =>
); +}); + +jest.mock('./AssetSelectionFooter', () => { + return jest.fn( + ({ onSave, onCancel }: { onSave?: () => void; onCancel?: () => void }) => ( +
+ + +
+ ) + ); +}); + +const mockOnSaveAction = jest.fn(); +const mockOnCancel = jest.fn(); +const mockOnSave = jest.fn(); + +const baseState = { + search: '', + setSearch: jest.fn(), + items: [], + failedStatus: undefined, + dryRunWarnings: undefined, + exportJob: undefined, + selectedItems: new Map(), + isLoading: false, + isSaveLoading: false, + assetJobResponse: undefined, + aggregations: undefined, + quickFilterQuery: undefined, + filters: [], + totalCount: 0, + handleCardClick: jest.fn(), + onSaveAction: mockOnSaveAction, + confirmDomainAssetMove: jest.fn(), + cancelDomainAssetMove: jest.fn(), + onScroll: jest.fn(), + onSelectAll: jest.fn(), + getErrorStatusAndMessage: jest.fn(), + handleQuickFiltersValueSelect: jest.fn(), + clearFilters: jest.fn(), +}; + +describe('AssetSelectionModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useAssetSelectionState as jest.Mock).mockReturnValue(baseState); + }); + + it('should not render the modal overlay when open is false', () => { + render( + + ); + + expect(screen.queryByTestId('modal-overlay')).not.toBeInTheDocument(); + }); + + it('should render the modal overlay and dialog when open is true', () => { + render( + + ); + + expect(screen.getByTestId('modal-overlay')).toBeInTheDocument(); + expect(screen.getByTestId('asset-selection-modal')).toBeInTheDocument(); + }); + + it('should render the content body unconditionally inside the dialog once open', () => { + render( + + ); + + expect(screen.getByTestId('content-body')).toBeInTheDocument(); + }); + + it('should call useAssetSelectionState with modal variant and provided props', () => { + render( + + ); + + expect(useAssetSelectionState).toHaveBeenCalledWith( + expect.objectContaining({ + entityFqn: 'glossary.term', + type: AssetsOfEntity.TAG, + open: true, + variant: 'modal', + onSave: mockOnSave, + onCancel: mockOnCancel, + }) + ); + }); + + it('should default type to GLOSSARY when not provided', () => { + render( + + ); + + expect(useAssetSelectionState).toHaveBeenCalledWith( + expect.objectContaining({ type: AssetsOfEntity.GLOSSARY }) + ); + }); + + it('should always render the default add-entity title', () => { + render( + + ); + + expect(screen.getByTestId('dialog-title')).toHaveTextContent( + 'label.add-entity' + ); + }); + + it('should call onCancel when the dialog close button is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('dialog-close')); + + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + + it('should call onCancel when the overlay requests close via onOpenChange', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('modal-backdrop')); + + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + + it("should call the hook's onSaveAction when footer save is clicked", () => { + render( + + ); + + fireEvent.click(screen.getByTestId('footer-save')); + + expect(mockOnSaveAction).toHaveBeenCalledTimes(1); + }); + + it('should call onCancel when footer cancel is clicked', () => { + render( + + ); + + fireEvent.click(screen.getByTestId('footer-cancel')); + + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + + it('should render the footer with derived state from the hook', () => { + (useAssetSelectionState as jest.Mock).mockReturnValue({ + ...baseState, + failedStatus: { failedRequest: [{ request: { id: '1' } }] }, + assetJobResponse: { message: 'running' }, + selectedItems: new Map([['1', {}]]), + }); + + render( + + ); + + expect(screen.getByTestId('footer')).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.tsx index 754e1283e12a..b16c3e6e96d0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/AssetSelectionModal.tsx @@ -10,12 +10,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Modal } from 'antd'; +import { Dialog, Modal, ModalOverlay } from '@openmetadata/ui-core-components'; +import { isUndefined } from 'lodash'; import { useTranslation } from 'react-i18next'; import { AssetsOfEntity } from '../../Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; import './asset-selection-model.style.less'; +import AssetSelectionContentBody from './AssetSelectionContentBody'; +import AssetSelectionFooter from './AssetSelectionFooter'; import { AssetSelectionModalProps } from './AssetSelectionModal.interface'; -import { useAssetSelectionContent } from './useAssetSelectionContent'; +import { useAssetSelectionState } from './useAssetSelectionState'; export const AssetSelectionModal = ({ entityFqn, @@ -28,7 +31,7 @@ export const AssetSelectionModal = ({ }: AssetSelectionModalProps) => { const { t } = useTranslation(); - const { content, footer } = useAssetSelectionContent({ + const state = useAssetSelectionState({ entityFqn, onCancel, onSave, @@ -36,23 +39,60 @@ export const AssetSelectionModal = ({ type, variant: 'modal', queryFilter, - emptyPlaceHolderText, }); return ( - - {content} - + !isOpen && onCancel?.()}> + + + + + + + + + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionDrawer.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionDrawer.tsx deleted file mode 100644 index b84eb745d76f..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionDrawer.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2025 Collate. - * Licensed 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 { useCallback, useEffect } from 'react'; -import { useTranslation } from 'react-i18next'; -import { QueryFilterInterface } from '../../../pages/ExplorePage/ExplorePage.interface'; -import { useCompositeDrawer } from '../../common/atoms/drawer'; -import { AssetsOfEntity } from '../../Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; -import { useAssetSelectionContent } from './useAssetSelectionContent'; - -interface UseAssetSelectionDrawerProps { - entityFqn: string; - open: boolean; - type?: AssetsOfEntity; - queryFilter?: QueryFilterInterface; - emptyPlaceHolderText?: string; - infoBannerText?: string; - title?: string; - onSave?: () => void; - onCancel: () => void; -} - -export const useAssetSelectionDrawer = ({ - entityFqn, - open, - type = AssetsOfEntity.GLOSSARY, - queryFilter, - emptyPlaceHolderText, - infoBannerText, - title, - onSave, - onCancel, -}: UseAssetSelectionDrawerProps) => { - const { t } = useTranslation(); - - const handleSave = useCallback(() => { - onSave?.(); - onCancel(); - }, [onSave, onCancel]); - - // Always call the hook, but content only fetches when open is true - const { content, footer } = useAssetSelectionContent({ - entityFqn, - type, - queryFilter, - emptyPlaceHolderText, - infoBannerText, - open, - variant: 'drawer', - onSave: handleSave, - onCancel, - }); - - // Create the drawer - always mounted but content conditionally rendered - const assetDrawer = useCompositeDrawer({ - width: 670, - closeOnEscape: false, - testId: 'asset-selection-modal', - onClose: onCancel, - header: { - title: - title ?? t('label.add-entity', { entity: t('label.asset-plural') }), - onClose: onCancel, - }, - body: { - children: open ? content : null, - }, - footer: { - customContent: open ? footer : null, - }, - }); - - // Sync parent's open state with drawer's internal state for animation - useEffect(() => { - if (open) { - assetDrawer.openDrawer(); - } else { - assetDrawer.closeDrawer(); - } - }, [open, assetDrawer]); - - return assetDrawer.compositeDrawer; -}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionState.test.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionState.test.ts new file mode 100644 index 000000000000..57a33eef6126 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionState.test.ts @@ -0,0 +1,876 @@ +/* + * Copyright 2026 Collate. + * Licensed 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 { act, renderHook, waitFor } from '@testing-library/react'; +import { useWebSocketConnector } from '../../../context/WebSocketProvider/WebSocketProvider'; +import { SearchIndex } from '../../../enums/search.enum'; +import { Status } from '../../../generated/type/bulkOperationResult'; +import { + addAssetsToDataProduct, + addInputPortsToDataProduct, + addOutputPortsToDataProduct, + getDataProductByName, +} from '../../../rest/dataProductAPI'; +import { addAssetsToDomain, getDomainByName } from '../../../rest/domainAPI'; +import { + addAssetsToGlossaryTerm, + getGlossaryTermByFQN, +} from '../../../rest/glossaryAPI'; +import { searchQuery } from '../../../rest/searchAPI'; +import { addAssetsToTags, getTagByFqn } from '../../../rest/tagAPI'; +import { getAssetsPageQuickFilters } from '../../../utils/AdvancedSearchPureUtils'; +import { getDomainDryRunImpacts } from '../../../utils/Domain/DomainDryRunUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import { AssetsOfEntity } from '../../Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; +import { useAssetSelectionState } from './useAssetSelectionState'; + +jest.mock('../../../rest/searchAPI', () => ({ + searchQuery: jest.fn(), +})); + +jest.mock('../../../rest/domainAPI', () => ({ + getDomainByName: jest.fn(), + addAssetsToDomain: jest.fn(), +})); + +jest.mock('../../../rest/dataProductAPI', () => ({ + getDataProductByName: jest.fn(), + addAssetsToDataProduct: jest.fn(), + addInputPortsToDataProduct: jest.fn(), + addOutputPortsToDataProduct: jest.fn(), +})); + +jest.mock('../../../rest/glossaryAPI', () => ({ + getGlossaryTermByFQN: jest.fn(), + addAssetsToGlossaryTerm: jest.fn(), +})); + +jest.mock('../../../rest/tagAPI', () => ({ + getTagByFqn: jest.fn(), + addAssetsToTags: jest.fn(), +})); + +jest.mock('../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); + +jest.mock('../../../utils/AdvancedSearchPureUtils', () => ({ + getAssetsPageQuickFilters: jest.fn().mockReturnValue([]), +})); + +jest.mock('../../../utils/Domain/DomainDryRunUtils', () => ({ + getDomainDryRunImpacts: jest.fn().mockReturnValue([]), +})); + +const mockSocket = { + on: jest.fn(), + off: jest.fn(), +}; + +jest.mock('../../../context/WebSocketProvider/WebSocketProvider', () => ({ + useWebSocketConnector: jest.fn(), +})); + +const buildHit = (id: string) => ({ + _id: id, + _index: 'index', + _source: { id, name: id, entityType: 'table' }, +}); + +const buildSearchResponse = (hits: unknown[], total = hits.length) => ({ + hits: { hits, total: { value: total } }, + aggregations: {}, +}); + +describe('useAssetSelectionState', () => { + const mockOnSave = jest.fn(); + const mockOnCancel = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useWebSocketConnector as jest.Mock).mockReturnValue({ + socket: mockSocket, + }); + (searchQuery as jest.Mock).mockResolvedValue(buildSearchResponse([])); + (getGlossaryTermByFQN as jest.Mock).mockResolvedValue({ + id: 'glossary-term-id', + fullyQualifiedName: 'glossary.term', + }); + (getDomainByName as jest.Mock).mockResolvedValue({ + id: 'domain-id', + fullyQualifiedName: 'domain.name', + }); + (getDataProductByName as jest.Mock).mockResolvedValue({ + id: 'data-product-id', + fullyQualifiedName: 'data.product', + }); + (getTagByFqn as jest.Mock).mockResolvedValue({ + id: 'tag-id', + fullyQualifiedName: 'tag.name', + }); + }); + + const renderAssetSelectionState = ( + overrides: Partial[0]> = {} + ) => + renderHook(() => + useAssetSelectionState({ + entityFqn: 'glossary.term', + open: true, + type: AssetsOfEntity.GLOSSARY, + variant: 'modal', + onSave: mockOnSave, + onCancel: mockOnCancel, + ...overrides, + }) + ); + + it('should fetch entities on mount when open is true', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1'), buildHit('2')], 2) + ); + + const { result } = renderAssetSelectionState(); + + await waitFor(() => { + expect(result.current.items).toHaveLength(2); + }); + + expect(searchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + pageNumber: 1, + pageSize: 25, + query: '', + includeDeleted: false, + }) + ); + expect(result.current.totalCount).toBe(2); + }); + + it('should not fetch entities when open is false', async () => { + renderAssetSelectionState({ open: false }); + + await waitFor(() => { + expect(searchQuery).not.toHaveBeenCalled(); + }); + }); + + it('should use DATA_ASSET search index for glossary type', async () => { + renderAssetSelectionState({ type: AssetsOfEntity.GLOSSARY }); + + await waitFor(() => { + expect(searchQuery).toHaveBeenCalledWith( + expect.objectContaining({ searchIndex: SearchIndex.DATA_ASSET }) + ); + }); + }); + + it('should use ALL search index for domain type', async () => { + renderAssetSelectionState({ type: AssetsOfEntity.DOMAIN }); + + await waitFor(() => { + expect(searchQuery).toHaveBeenCalledWith( + expect.objectContaining({ searchIndex: SearchIndex.ALL }) + ); + }); + }); + + it('should fetch the current glossary term for GLOSSARY type', async () => { + renderAssetSelectionState({ type: AssetsOfEntity.GLOSSARY }); + + await waitFor(() => { + expect(getGlossaryTermByFQN).toHaveBeenCalledWith( + 'glossary.term', + expect.anything() + ); + }); + }); + + it('should fetch the current domain for DOMAIN type', async () => { + renderAssetSelectionState({ type: AssetsOfEntity.DOMAIN }); + + await waitFor(() => { + expect(getDomainByName).toHaveBeenCalledWith('glossary.term'); + }); + }); + + it('should fetch the current data product for DATA_PRODUCT type', async () => { + renderAssetSelectionState({ type: AssetsOfEntity.DATA_PRODUCT }); + + await waitFor(() => { + expect(getDataProductByName).toHaveBeenCalledWith( + 'glossary.term', + expect.anything() + ); + }); + }); + + it('should fetch the current tag for TAG type', async () => { + renderAssetSelectionState({ type: AssetsOfEntity.TAG }); + + await waitFor(() => { + expect(getTagByFqn).toHaveBeenCalledWith('glossary.term'); + }); + }); + + it('should toggle item selection on handleCardClick', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + + const { result } = renderAssetSelectionState(); + + await waitFor(() => { + expect(result.current.items).toHaveLength(1); + }); + + act(() => { + result.current.handleCardClick({ id: '1', entityType: 'table' } as never); + }); + + expect(result.current.selectedItems?.has('1')).toBe(true); + + act(() => { + result.current.handleCardClick({ id: '1', entityType: 'table' } as never); + }); + + expect(result.current.selectedItems?.has('1')).toBe(false); + }); + + it('should select all loaded items when onSelectAll(true) is called', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1'), buildHit('2')], 2) + ); + + const { result } = renderAssetSelectionState(); + + await waitFor(() => { + expect(result.current.items).toHaveLength(2); + }); + + act(() => { + result.current.onSelectAll(true); + }); + + expect(result.current.selectedItems?.size).toBe(2); + }); + + it('should clear selection when onSelectAll(false) is called', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + + const { result } = renderAssetSelectionState(); + + await waitFor(() => { + expect(result.current.items).toHaveLength(1); + }); + + act(() => { + result.current.onSelectAll(true); + }); + + expect(result.current.selectedItems?.size).toBe(1); + + act(() => { + result.current.onSelectAll(false); + }); + + expect(result.current.selectedItems?.size).toBe(0); + }); + + it('should fetch the next page on scroll near the bottom when more items remain', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 5) + ); + + const { result } = renderAssetSelectionState(); + + await waitFor(() => { + expect(result.current.items).toHaveLength(1); + }); + + (searchQuery as jest.Mock).mockClear(); + + act(() => { + result.current.onScroll({ + currentTarget: { + scrollHeight: 500, + scrollTop: 470, + clientHeight: 100, + }, + } as never); + }); + + await waitFor(() => { + expect(searchQuery).toHaveBeenCalledWith( + expect.objectContaining({ pageNumber: 2 }) + ); + }); + }); + + it('should not fetch more when all items are already loaded', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + + const { result } = renderAssetSelectionState(); + + await waitFor(() => { + expect(result.current.items).toHaveLength(1); + }); + + (searchQuery as jest.Mock).mockClear(); + + act(() => { + result.current.onScroll({ + currentTarget: { + scrollHeight: 500, + scrollTop: 470, + clientHeight: 100, + }, + } as never); + }); + + expect(searchQuery).not.toHaveBeenCalled(); + }); + + it('should update filters and derive quickFilterQuery on handleQuickFiltersValueSelect', async () => { + (getAssetsPageQuickFilters as jest.Mock).mockReturnValue([ + { key: 'entityType', value: [] }, + ]); + + const { result } = renderAssetSelectionState(); + + await waitFor(() => { + expect(result.current.filters).toHaveLength(1); + }); + + act(() => { + result.current.handleQuickFiltersValueSelect({ + key: 'entityType', + value: [{ key: 'table', label: 'Table' }], + } as never); + }); + + expect( + result.current.filters.find((f) => f.key === 'entityType')?.value + ).toHaveLength(1); + }); + + it('should clear all filter values and quickFilterQuery on clearFilters', async () => { + (getAssetsPageQuickFilters as jest.Mock).mockReturnValue([ + { key: 'entityType', value: [] }, + ]); + + const { result } = renderAssetSelectionState(); + + await waitFor(() => { + expect(result.current.filters).toHaveLength(1); + }); + + act(() => { + result.current.handleQuickFiltersValueSelect({ + key: 'entityType', + value: [{ key: 'table', label: 'Table' }], + } as never); + }); + + act(() => { + result.current.clearFilters(); + }); + + expect(result.current.quickFilterQuery).toBeUndefined(); + expect(result.current.filters.every((f) => f?.value?.length === 0)).toBe( + true + ); + }); + + describe('save flow', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const selectOneItem = async ( + result: ReturnType['result'] + ) => { + await waitFor(() => { + expect(result.current.items).toHaveLength(1); + }); + + act(() => { + result.current.handleCardClick({ + id: '1', + entityType: 'table', + } as never); + }); + }; + + it('should save glossary assets and call onSave/onCancel after success', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + (addAssetsToGlossaryTerm as jest.Mock).mockResolvedValue({ + status: Status.Success, + }); + + const { result } = renderAssetSelectionState({ + type: AssetsOfEntity.GLOSSARY, + }); + + await selectOneItem(result); + + await act(async () => { + result.current.onSaveAction(); + }); + + expect(addAssetsToGlossaryTerm).toHaveBeenCalled(); + + await act(async () => { + jest.advanceTimersByTime(500); + }); + + await waitFor(() => { + expect(mockOnSave).toHaveBeenCalled(); + expect(mockOnCancel).toHaveBeenCalled(); + }); + }); + + it('should save data product assets via addAssetsToDataProduct', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + (addAssetsToDataProduct as jest.Mock).mockResolvedValue({ + status: Status.Success, + }); + + const { result } = renderAssetSelectionState({ + type: AssetsOfEntity.DATA_PRODUCT, + }); + + await selectOneItem(result); + + await act(async () => { + result.current.onSaveAction(); + }); + + expect(addAssetsToDataProduct).toHaveBeenCalledWith( + 'data.product', + expect.any(Array) + ); + }); + + it('should save input ports via addInputPortsToDataProduct', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + (addInputPortsToDataProduct as jest.Mock).mockResolvedValue({ + status: Status.Success, + }); + + const { result } = renderAssetSelectionState({ + type: AssetsOfEntity.DATA_PRODUCT_INPUT_PORT, + }); + + await selectOneItem(result); + + await act(async () => { + result.current.onSaveAction(); + }); + + expect(addInputPortsToDataProduct).toHaveBeenCalledWith( + 'data.product', + expect.any(Array) + ); + }); + + it('should save output ports via addOutputPortsToDataProduct', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + (addOutputPortsToDataProduct as jest.Mock).mockResolvedValue({ + status: Status.Success, + }); + + const { result } = renderAssetSelectionState({ + type: AssetsOfEntity.DATA_PRODUCT_OUTPUT_PORT, + }); + + await selectOneItem(result); + + await act(async () => { + result.current.onSaveAction(); + }); + + expect(addOutputPortsToDataProduct).toHaveBeenCalledWith( + 'data.product', + expect.any(Array) + ); + }); + + it('should save tag assets via addAssetsToTags', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + (addAssetsToTags as jest.Mock).mockResolvedValue({ + status: Status.Success, + }); + + const { result } = renderAssetSelectionState({ + type: AssetsOfEntity.TAG, + }); + + await selectOneItem(result); + + await act(async () => { + result.current.onSaveAction(); + }); + + expect(addAssetsToTags).toHaveBeenCalledWith('tag-id', expect.any(Array)); + }); + + it('should set failedStatus when the save response is not successful', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + const failedResult = { + status: Status.Failure, + failedRequest: [{ request: { id: '1' }, message: 'could not save' }], + }; + (addAssetsToGlossaryTerm as jest.Mock).mockResolvedValue(failedResult); + + const { result } = renderAssetSelectionState({ + type: AssetsOfEntity.GLOSSARY, + }); + + await selectOneItem(result); + + await act(async () => { + result.current.onSaveAction(); + }); + + await waitFor(() => { + expect(result.current.failedStatus).toEqual(failedResult); + }); + + expect(result.current.getErrorStatusAndMessage('1')).toEqual({ + isError: true, + errorMessage: 'could not save', + }); + expect(mockOnSave).not.toHaveBeenCalled(); + }); + + it('should set assetJobResponse when the save response is a bulk job', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + (addAssetsToGlossaryTerm as jest.Mock).mockResolvedValue({ + jobId: 'job-1', + message: 'started', + }); + + const { result } = renderAssetSelectionState({ + type: AssetsOfEntity.GLOSSARY, + }); + + await selectOneItem(result); + + await act(async () => { + result.current.onSaveAction(); + }); + + await waitFor(() => { + expect(result.current.assetJobResponse).toEqual({ + jobId: 'job-1', + message: 'started', + }); + }); + }); + + it('should show a drawer-specific toast when save throws in drawer variant', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + (addAssetsToGlossaryTerm as jest.Mock).mockRejectedValue( + new Error('network error') + ); + + const { result } = renderAssetSelectionState({ + type: AssetsOfEntity.GLOSSARY, + variant: 'drawer', + }); + + await selectOneItem(result); + + await act(async () => { + result.current.onSaveAction(); + }); + + await waitFor(() => { + expect(showErrorToast).toHaveBeenCalledWith( + expect.anything(), + 'server.add-entity-error' + ); + }); + }); + + it('should show a raw error toast when save throws in modal variant', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + const error = new Error('network error'); + (addAssetsToGlossaryTerm as jest.Mock).mockRejectedValue(error); + + const { result } = renderAssetSelectionState({ + type: AssetsOfEntity.GLOSSARY, + variant: 'modal', + }); + + await selectOneItem(result); + + await act(async () => { + result.current.onSaveAction(); + }); + + await waitFor(() => { + expect(showErrorToast).toHaveBeenCalledWith(error); + }); + }); + + it('should perform a domain dry run first and stop when there are impacts', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + (addAssetsToDomain as jest.Mock).mockResolvedValue({ + status: Status.Success, + }); + (getDomainDryRunImpacts as jest.Mock).mockReturnValue([ + { request: { id: '1' } }, + ]); + + const { result } = renderAssetSelectionState({ + type: AssetsOfEntity.DOMAIN, + }); + + await selectOneItem(result); + + await act(async () => { + result.current.onSaveAction(); + }); + + expect(addAssetsToDomain).toHaveBeenCalledWith( + 'domain.name', + expect.any(Array), + { dryRun: true } + ); + expect(addAssetsToDomain).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(result.current.dryRunWarnings).toEqual([ + { request: { id: '1' } }, + ]); + }); + }); + + it('should save directly when the domain dry run has no impacts', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + (addAssetsToDomain as jest.Mock).mockResolvedValue({ + status: Status.Success, + }); + (getDomainDryRunImpacts as jest.Mock).mockReturnValue([]); + + const { result } = renderAssetSelectionState({ + type: AssetsOfEntity.DOMAIN, + }); + + await selectOneItem(result); + + await act(async () => { + result.current.onSaveAction(); + }); + + expect(addAssetsToDomain).toHaveBeenCalledTimes(2); + expect(addAssetsToDomain).toHaveBeenLastCalledWith( + 'domain.name', + expect.any(Array) + ); + }); + + it('should confirm the domain move using pending entities and clear warnings', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + (addAssetsToDomain as jest.Mock).mockResolvedValue({ + status: Status.Success, + }); + (getDomainDryRunImpacts as jest.Mock).mockReturnValue([ + { request: { id: '1' } }, + ]); + + const { result } = renderAssetSelectionState({ + type: AssetsOfEntity.DOMAIN, + }); + + await selectOneItem(result); + + await act(async () => { + result.current.onSaveAction(); + }); + + await waitFor(() => { + expect(result.current.dryRunWarnings).toBeDefined(); + }); + + (addAssetsToDomain as jest.Mock).mockClear(); + + let confirmPromise: Promise | undefined; + act(() => { + confirmPromise = result.current.confirmDomainAssetMove(); + }); + + await waitFor(() => { + expect(addAssetsToDomain).toHaveBeenCalledWith( + 'domain.name', + expect.any(Array) + ); + }); + + await act(async () => { + jest.advanceTimersByTime(500); + await confirmPromise; + }); + + expect(result.current.dryRunWarnings).toBeUndefined(); + }); + + it('should cancel the domain move without calling the API', async () => { + (searchQuery as jest.Mock).mockResolvedValue( + buildSearchResponse([buildHit('1')], 1) + ); + (addAssetsToDomain as jest.Mock).mockResolvedValue({ + status: Status.Success, + }); + (getDomainDryRunImpacts as jest.Mock).mockReturnValue([ + { request: { id: '1' } }, + ]); + + const { result } = renderAssetSelectionState({ + type: AssetsOfEntity.DOMAIN, + }); + + await selectOneItem(result); + + await act(async () => { + result.current.onSaveAction(); + }); + + await waitFor(() => { + expect(result.current.dryRunWarnings).toBeDefined(); + }); + + (addAssetsToDomain as jest.Mock).mockClear(); + + act(() => { + result.current.cancelDomainAssetMove(); + }); + + expect(addAssetsToDomain).not.toHaveBeenCalled(); + expect(result.current.dryRunWarnings).toBeUndefined(); + }); + }); + + describe('websocket bulk assets channel', () => { + it('should register and unregister the BULK_ASSETS_CHANNEL listener', () => { + const { unmount } = renderAssetSelectionState(); + + expect(mockSocket.on).toHaveBeenCalledWith( + 'bulkAssetsChannel', + expect.any(Function) + ); + + unmount(); + + expect(mockSocket.off).toHaveBeenCalledWith('bulkAssetsChannel'); + }); + + it('should call onSave/onCancel when a COMPLETED success activity arrives', async () => { + const { result } = renderAssetSelectionState(); + + const handler = mockSocket.on.mock.calls[0][1]; + + act(() => { + handler( + JSON.stringify({ + status: 'COMPLETED', + result: { status: 'success' }, + }) + ); + }); + + await waitFor(() => { + expect(mockOnSave).toHaveBeenCalled(); + expect(mockOnCancel).toHaveBeenCalled(); + }); + + expect(result.current.assetJobResponse).toBeUndefined(); + }); + + it('should set failedStatus when a COMPLETED failure activity arrives', async () => { + const { result } = renderAssetSelectionState(); + + const handler = mockSocket.on.mock.calls[0][1]; + const failureResult = { status: 'failure', failedRequest: [] }; + + act(() => { + handler( + JSON.stringify({ + status: 'COMPLETED', + result: failureResult, + }) + ); + }); + + await waitFor(() => { + expect(result.current.failedStatus).toEqual(failureResult); + }); + }); + + it('should set exportJob and clear assetJobResponse when a FAILED activity arrives', async () => { + const { result } = renderAssetSelectionState(); + + const handler = mockSocket.on.mock.calls[0][1]; + + act(() => { + handler( + JSON.stringify({ + status: 'FAILED', + error: 'job failed', + }) + ); + }); + + await waitFor(() => { + expect(result.current.exportJob).toEqual( + expect.objectContaining({ status: 'FAILED' }) + ); + }); + + expect(result.current.assetJobResponse).toBeUndefined(); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionContent.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionState.ts similarity index 59% rename from openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionContent.tsx rename to openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionState.ts index 4a7ca85a24a4..0cda68578fe2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionContent.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/AssetsSelectionModal/useAssetSelectionState.ts @@ -10,25 +10,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - CheckOutlined, - CloseOutlined, - ExclamationCircleOutlined, -} from '@ant-design/icons'; -import { - Box, - Button, - Divider as MuiDivider, - Typography as MuiTypography, - useTheme, -} from '@mui/material'; -import { AlertCircle, CheckCircle } from '@untitledui/icons'; -import { Alert, Checkbox, Divider, List, Space, Typography } from 'antd'; import { AxiosError } from 'axios'; -import classNames from 'classnames'; import { isUndefined } from 'lodash'; import { EntityDetailUnion } from 'Models'; -import VirtualList from 'rc-virtual-list'; import { UIEventHandler, useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -51,7 +35,6 @@ import { Response as BulkResponse, Status, } from '../../../generated/type/bulkOperationResult'; -import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { Aggregations } from '../../../interface/search.interface'; import { QueryFilterInterface } from '../../../pages/ExplorePage/ExplorePage.interface'; import { @@ -76,23 +59,15 @@ import { getQuickFilterQuery, } from '../../../utils/ExplorePureUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; -import Banner from '../../common/Banner/Banner'; -import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; -import Loader from '../../common/Loader/Loader'; -import Searchbar from '../../common/SearchBarComponent/SearchBar.component'; -import TableDataCardV2 from '../../common/TableDataCardV2/TableDataCardV2'; import { CSVExportJob, CSVExportResponse, } from '../../Entity/EntityExportModalProvider/EntityExportModalProvider.interface'; import { ExploreQuickFilterField } from '../../Explore/ExplorePage.interface'; -import ExploreQuickFilters from '../../Explore/ExploreQuickFilters'; import { AssetsOfEntity } from '../../Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; import { SearchedDataProps } from '../../SearchedData/SearchedData.interface'; -import DomainAssetDryRunModal from '../DomainAssetDryRunModal/DomainAssetDryRunModal.component'; -import './asset-selection-model.style.less'; -export interface AssetSelectionContentProps { +export interface UseAssetSelectionStateProps { entityFqn: string; open?: boolean; type?: AssetsOfEntity; @@ -100,11 +75,11 @@ export interface AssetSelectionContentProps { onSave?: () => void; onCancel?: () => void; queryFilter?: QueryFilterInterface; - emptyPlaceHolderText?: string; - infoBannerText?: string; } -export const useAssetSelectionContent = ({ +const SCROLL_BOTTOM_THRESHOLD_PX = 50; + +export const useAssetSelectionState = ({ entityFqn, onCancel, onSave, @@ -112,11 +87,7 @@ export const useAssetSelectionContent = ({ type = AssetsOfEntity.GLOSSARY, variant = 'modal', queryFilter, - emptyPlaceHolderText, - infoBannerText, -}: AssetSelectionContentProps) => { - const { theme } = useApplicationStore(); - const muiTheme = useTheme(); +}: UseAssetSelectionStateProps) => { const { t } = useTranslation(); const [search, setSearch] = useState(''); const [items, setItems] = useState([]); @@ -441,12 +412,13 @@ export const useAssetSelectionContent = ({ const onScroll: UIEventHandler = useCallback( (e) => { - const scrollHeight = - e.currentTarget.scrollHeight - e.currentTarget.scrollTop; + const remainingScrollHeight = + e.currentTarget.scrollHeight - + e.currentTarget.scrollTop - + e.currentTarget.clientHeight; if ( - scrollHeight > 499 && - scrollHeight < 501 && + remainingScrollHeight < SCROLL_BOTTOM_THRESHOLD_PX && items.length < totalCount ) { const combinedQueryFilter = getCombinedQueryFilterObject( @@ -455,7 +427,6 @@ export const useAssetSelectionContent = ({ ); if (isLoading) { - // No need to fetchEntities if already loading return; } @@ -579,271 +550,33 @@ export const useAssetSelectionContent = ({ }; }, [socket]); - const modalFooter = ( -
-
- {selectedItems && selectedItems.size >= 1 && ( - - - {selectedItems.size} {t('label.selected-lowercase')} - - )} - {failedStatus?.failedRequest && - failedStatus.failedRequest.length > 0 && ( - <> - - - - {failedStatus.failedRequest.length} {t('label.error')} - - - )} -
- -
- - -
-
- ); - - const drawerFooter = ( - - - {selectedItems && selectedItems.size >= 1 && ( - - - - {selectedItems.size} {t('label.selected-lowercase')} - - - )} - {failedStatus?.failedRequest && - failedStatus.failedRequest.length > 0 && ( - <> - - - - - {failedStatus.failedRequest.length} {t('label.error')} - - - - )} - - - - - - - - ); - - const footer = variant === 'drawer' ? drawerFooter : modalFooter; - - const content = ( - - {(assetJobResponse || exportJob?.error) && ( - - )} - - {infoBannerText && ( - - )} - -
-
- -
-
- -
- - {quickFilterQuery && ( - - {t('label.clear-entity', { - entity: '', - })} - - )} -
- - {failedStatus?.failedRequest && failedStatus.failedRequest.length > 0 && ( - - {t('message.validation-error-assets')} - - } - message={ -
- - - {t('label.validation-error-plural')} - -
- } - type="error" - /> - )} - - {items.length > 0 && ( -
- onSelectAll(e.target.checked)}> - {t('label.select-field', { - field: t('label.all'), - })} - - - - {({ _source: item }) => { - const { isError, errorMessage } = getErrorStatusAndMessage( - item.id ?? '' - ); - - return ( -
- - {isError && ( - <> -
- -
-
- - - {errorMessage} - -
- - )} -
- ); - }} -
-
- {isLoading && items.length < totalCount && ( -
- -
- )} -
- )} - - {!isLoading && items.length === 0 && ( - - {emptyPlaceHolderText && ( - {emptyPlaceHolderText} - )} - - )} - - {isLoading && } - - -
- ); - - return { content, footer }; + return { + search, + setSearch, + items, + failedStatus, + dryRunWarnings, + exportJob, + selectedItems, + isLoading, + isSaveLoading, + assetJobResponse, + aggregations, + quickFilterQuery, + filters, + totalCount, + handleCardClick, + onSaveAction, + confirmDomainAssetMove, + cancelDomainAssetMove, + onScroll, + onSelectAll, + getErrorStatusAndMessage, + handleQuickFiltersValueSelect, + clearFilters, + }; }; + +export type UseAssetSelectionStateReturn = ReturnType< + typeof useAssetSelectionState +>; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityHeaderTitle/EntityHeaderTitle.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityHeaderTitle/EntityHeaderTitle.component.tsx index 3e6344a1e7d5..a3aed1fff652 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityHeaderTitle/EntityHeaderTitle.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityHeaderTitle/EntityHeaderTitle.component.tsx @@ -14,7 +14,7 @@ import Icon, { ExclamationCircleFilled } from '@ant-design/icons'; import { Badge, Button, Col, Row, Tooltip, Typography } from 'antd'; import classNames from 'classnames'; import { isEmpty } from 'lodash'; -import { useMemo, useState } from 'react'; +import { MouseEvent, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { ReactComponent as ShareIcon } from '../../../assets/svg/copy-right.svg'; @@ -61,7 +61,9 @@ const EntityHeaderTitle = ({ entityUrl ?? globalThis.location.href ); - const handleShareButtonClick = async () => { + const handleShareButtonClick = async (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); await onCopyToClipBoard(); setCopyTooltip(t('message.link-copy-to-clipboard')); setTimeout(() => setCopyTooltip(''), 2000); @@ -220,7 +222,8 @@ const EntityHeaderTitle = ({ className="no-underline d-inline-block w-max-full entity-header-title-link" data-testid="entity-link" target={openEntityInNewPage ? '_blank' : '_self'} - to={link}> + to={link} + onClick={(e) => e.stopPropagation()}> {content} ) : ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TableDataCardV2/TableDataCardV2.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TableDataCardV2/TableDataCardV2.tsx index 22d16506185f..9f9b291c6ee2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TableDataCardV2/TableDataCardV2.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TableDataCardV2/TableDataCardV2.tsx @@ -21,6 +21,7 @@ import { EntityType } from '../../../enums/entity.enum'; import { EntityReference } from '../../../generated/entity/type'; import { useApplicationStore } from '../../../hooks/useApplicationStore'; import { getEntityBreadcrumbs } from '../../../utils/EntityBreadcrumbPureUtils'; +import { getEntityLinkFromType } from '../../../utils/EntityLinkUtils'; import { getEntityName } from '../../../utils/EntityNameUtils'; import { getUsagePercentile } from '../../../utils/TablePureUtils'; import { getServiceIcon } from '../../../utils/TableUtils'; @@ -133,6 +134,21 @@ const TableDataCardV2: React.FC = forwardRef< [source] ); + const entityUrl = useMemo(() => { + if (!source.fullyQualifiedName) { + return undefined; + } + const entityPath = getEntityLinkFromType( + source.fullyQualifiedName, + source.entityType as EntityType, + source + ); + + return entityPath + ? `${globalThis.location.origin}${entityPath}` + : undefined; + }, [source]); + return (
= forwardRef< displayNameClassName={displayNameClassName} entityData={source} entityType={source.entityType as EntityType} + entityUrl={entityUrl} icon={serviceIcon} nameClassName={nameClassName} openEntityInNewPage={openEntityInNewPage} diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/Assets.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/Assets.constants.ts index 1a140a64a546..73b47fd86cc8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/Assets.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/Assets.constants.ts @@ -15,14 +15,14 @@ import { CodeSquare01, File02, File05, - File06, FileCheck02, - Folder, Settings02, SlashCircle01, - Sun, } from '@untitledui/icons'; import React from 'react'; +import { ReactComponent as FileIcon } from '../assets/svg/common/file.svg'; +import { ReactComponent as FolderIcon } from '../assets/svg/common/folder.svg'; +import { ReactComponent as MemoriesIcon } from '../assets/svg/common/memories.svg'; import { ReactComponent as KnowledgeCenter } from '../assets/svg/context-center.svg'; import { ReactComponent as AIAutomation } from '../assets/svg/entity/ai-automation.svg'; import { ReactComponent as APICollection } from '../assets/svg/entity/api-collection.svg'; @@ -366,7 +366,7 @@ export const ENTITY_ICON_MAPPER: Record< iconClass: 'tw:text-utility-blue-700', bgClass: 'tw:bg-utility-blue-50', borderClass: 'tw:border-utility-blue-200', - icon: File06, + icon: FileIcon, }, [EntityType.KNOWLEDGE_CENTER]: { iconClass: 'tw:text-utility-blue-700', @@ -528,7 +528,7 @@ export const ENTITY_ICON_MAPPER: Record< iconClass: 'tw:text-teal-600 tw:dark:text-teal-400', bgClass: 'tw:bg-teal-50 tw:dark:bg-teal-950', borderClass: 'tw:border-teal-300 tw:dark:border-teal-700', - icon: Sun, + icon: MemoriesIcon, }, aiAutomation: { iconClass: 'tw:text-moss-600 tw:dark:text-moss-400', @@ -552,7 +552,7 @@ export const ENTITY_ICON_MAPPER: Record< iconClass: 'tw:text-utility-error-600', bgClass: 'tw:bg-error-primary', borderClass: 'tw:border-utility-error-200', - icon: Folder, + icon: FolderIcon, }, contextPlugin: { iconClass: 'tw:text-utility-fuchsia-600', From b04a39f9164b776bfe179625ac80aae918c3e357 Mon Sep 17 00:00:00 2001 From: Rohit Jain <60229265+Rohit0301@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:49:04 +0530 Subject: [PATCH 26/67] refactor(context-center): wrap header components with HeaderShell (#29964) * refactor(context-center): wrap header components with HeaderShell for consistent AI/embedded mode styling Replaces raw Card + getHeaderCardClassName() pattern in ContextCenterHeader, ArticleDetailHeader, and ArticleVersionHeader with the HeaderShell component. Uses variant="gradient" when embedded to align background treatment across all context center page headers. Co-Authored-By: Claude Sonnet 4.6 * fixed ui issues * updated widget action button icons * lint fix * minor fix * lint fix * fixed unit test --------- Co-authored-by: Claude Sonnet 4.6 --- .../svg/action-icons/list-conversation.svg | 7 + .../ui/src/assets/svg/action-icons/plus.svg | 3 + .../svg/action-icons/request-update.svg | 4 + .../ArticleDetailHeader.component.tsx | 661 +++++++++--------- .../ArticleDetailHeader.test.tsx | 8 +- .../ArticleVersionHeader.component.tsx | 34 +- .../ContextCenterHeader.component.tsx | 69 +- .../ContextCenterHeader.test.tsx | 23 + .../WidgetActionButton/WidgetActionButton.tsx | 21 +- .../ui/src/locale/languages/ar-sa.json | 1 + .../ui/src/locale/languages/de-de.json | 1 + .../ui/src/locale/languages/en-us.json | 1 + .../ui/src/locale/languages/es-es.json | 1 + .../ui/src/locale/languages/fr-fr.json | 1 + .../ui/src/locale/languages/gl-es.json | 1 + .../ui/src/locale/languages/he-he.json | 1 + .../ui/src/locale/languages/ja-jp.json | 1 + .../ui/src/locale/languages/ko-kr.json | 1 + .../ui/src/locale/languages/mr-in.json | 1 + .../ui/src/locale/languages/nl-nl.json | 1 + .../ui/src/locale/languages/pr-pr.json | 1 + .../ui/src/locale/languages/pt-br.json | 1 + .../ui/src/locale/languages/pt-pt.json | 1 + .../ui/src/locale/languages/ru-ru.json | 1 + .../ui/src/locale/languages/sv-se.json | 1 + .../ui/src/locale/languages/th-th.json | 1 + .../ui/src/locale/languages/tr-tr.json | 1 + .../ui/src/locale/languages/zh-cn.json | 1 + .../ui/src/locale/languages/zh-tw.json | 1 + 29 files changed, 431 insertions(+), 419 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/action-icons/list-conversation.svg create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/action-icons/plus.svg create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/action-icons/request-update.svg diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/action-icons/list-conversation.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/action-icons/list-conversation.svg new file mode 100644 index 000000000000..10168ee2a058 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/action-icons/list-conversation.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/action-icons/plus.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/action-icons/plus.svg new file mode 100644 index 000000000000..583d2622b37a --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/action-icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/action-icons/request-update.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/action-icons/request-update.svg new file mode 100644 index 000000000000..abd48769ad66 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/action-icons/request-update.svg @@ -0,0 +1,4 @@ + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.component.tsx index b3328171ea51..512614de1d18 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.component.tsx @@ -27,7 +27,6 @@ import { } from '@openmetadata/ui-core-components'; import { UploadCloud01 } from '@untitledui/icons'; import { AxiosError } from 'axios'; -import classNames from 'classnames'; import { cloneDeep, isUndefined, toString, uniqBy } from 'lodash'; import { FC, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -74,6 +73,7 @@ import { updateKnowledgeCenterRecentViewed } from '../../../utils/KnowledgePageU import { showErrorToast } from '../../../utils/ToastUtils'; import DomainSelectableList from '../../common/DomainSelectableList/DomainSelectableList.component'; import HeaderBreadcrumb from '../../common/HeaderBreadcrumb/HeaderBreadcrumb.component'; +import HeaderShell from '../../common/HeaderShell/HeaderShell.component'; import { OwnerLabel } from '../../common/OwnerLabel/OwnerLabel.component'; import { UserTeamSelectableList } from '../../common/UserTeamSelectableList/UserTeamSelectableList.component'; import CopyLinkButton from '../../CopyLinkButton/CopyLinkButton.component'; @@ -293,7 +293,6 @@ const ArticleDetailHeader: FC = ({ }, [contentChangeState]); const breadcrumbInsideCard = contextCenterClassBase.isBreadcrumbInsideCard(); - const headerCardClassName = contextCenterClassBase.getHeaderCardClassName(); const breadcrumbEl = ( @@ -320,358 +319,336 @@ const ArticleDetailHeader: FC = ({ ); } - return ( -
- {!breadcrumbInsideCard && breadcrumbEl} - - - {breadcrumbInsideCard &&
{breadcrumbEl}
} - {/* Row 1: title + meta + actions */} -
-
-
- {/* Article name with icon */} -
- - {getKnowledgePageName(knowledgePage, t)} - - {entityStatusBadge} -
- - {/* Domain · Owner row */} -
- {/* Domain */} -
- - - - - - - {firstDomain - ? firstDomain.displayName ?? firstDomain.name - : t('label.no-entity', { entity: t('label.domain') })} - - {extraDomains.length > 0 && ( - - +{extraDomains.length} - - )} - {permissions.EditAll && ( - - } - tooltip={t('label.edit-entity', { - entity: t('label.domain'), - })} - /> - - )} -
- - {/* Dot separator */} - - - {/* Owners */} -
- - - - - - - {owners.length > 0 ? ( -
- -
- ) : ( - - {t('label.no-entity', { entity: t('label.owner') })} - - )} - {(permissions.EditAll || permissions.EditOwners) && ( - - } - tooltip={t('label.edit-entity', { - entity: t('label.owner-plural'), - })} - /> - - )} -
- - {/* Editors */} - {editors.length > 0 && ( - <> - -
- - - - - -
- -
-
- - )} -
-
-
- - {/* Action buttons */} -
- - {contentChangeIcon} - - - - - - - - - {/* Up vote */} - - - ) : ( - - ) - } - tooltip={t('label.up-vote')} - onClick={() => handleVoteChange(QueryVoteType.votedUp)} + const metaEl = ( + + + + + - {/* Down vote */} - + + + + {firstDomain + ? firstDomain.displayName ?? firstDomain.name + : t('label.no-entity', { entity: t('label.domain') })} + + {extraDomains.length > 0 && ( + + +{extraDomains.length} + + )} + {permissions.EditAll && ( + - ) : ( - - ) - } - tooltip={t('label.down-vote')} - onClick={() => handleVoteChange(QueryVoteType.votedDown)} + data-testid="edit-domain-btn" + icon={} + tooltip={t('label.edit-entity', { + entity: t('label.domain'), + })} /> - - } - tooltip={t('label.conversation')} - onClick={handleOpenConversation} + + )} + + + + + + + + - - - ) : ( - - ) - } - tooltip={isFollowing ? t('label.un-follow') : t('label.follow')} - onClick={handleFollowClick} + + + + {owners.length > 0 ? ( +
+ - + ) : ( + + {t('label.no-entity', { entity: t('label.owner') })} + + )} + {(permissions.EditAll || permissions.EditOwners) && ( + + - - - - {permissions?.Delete && ( - - } - size="sm" - tooltip={t('label.manage-entity', { - entity: t('label.article'), - })} - /> - - { - if (key === 'delete') { - setIsDeleteModalOpen(true); - } - }}> - -
-
-
-
-
-
- )} - - } + tooltip={t('label.edit-entity', { + entity: t('label.owner-plural'), })} - open={isDeleteModalOpen} - onCancel={() => setIsDeleteModalOpen(false)} - onDelete={handleDeleteConfirm} /> -
-
- - {/* Row 2: tab strip + right-panel toggle */} -
- onTabChange?.(String(key))}> - - {tabs?.map((tab) => ( - - - - ))} - - - - {activeTab !== EntityTabs.ACTIVITY_FEED && ( - - - - } - onClick={onToggleRightPanel} + + )} + + + {editors.length > 0 && ( + <> + + + + + - )} -
- +
+ +
+ + + )} + + ); + + const actionsEl = ( +
+ + {contentChangeIcon} + + + + + + + + + + ) : ( + + ) + } + tooltip={t('label.up-vote')} + onClick={() => handleVoteChange(QueryVoteType.votedUp)} + /> + + + ) : ( + + ) + } + tooltip={t('label.down-vote')} + onClick={() => handleVoteChange(QueryVoteType.votedDown)} + /> + + } + tooltip={t('label.conversation')} + onClick={handleOpenConversation} + /> + + + ) : ( + + ) + } + tooltip={isFollowing ? t('label.un-follow') : t('label.follow')} + onClick={handleFollowClick} + /> + + + + + {permissions?.Delete && ( + + } + size="sm" + tooltip={t('label.manage-entity', { + entity: t('label.article'), + })} + /> + + { + if (key === 'delete') { + setIsDeleteModalOpen(true); + } + }}> + + + + + + + + )} + + setIsDeleteModalOpen(false)} + onDelete={handleDeleteConfirm} + /> +
+ ); + + const footerEl = ( + + onTabChange?.(String(key))}> + + {tabs?.map((tab) => ( + + + + ))} + + + + {activeTab !== EntityTabs.ACTIVITY_FEED && ( + + )} + + ); + + return ( +
+ {!breadcrumbInsideCard && breadcrumbEl} + + {getKnowledgePageName(knowledgePage, t)} + + } + variant={isEmbedded ? 'gradient' : 'flat'} + />
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.test.tsx index 102aca616b81..74bad21c6e4c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleDetailHeader/ArticleDetailHeader.test.tsx @@ -140,10 +140,16 @@ jest.mock('@openmetadata/ui-core-components', () => ({ ({ children, onClick, + 'data-testid': testId, }: { children: React.ReactNode; onClick?: () => void; - }) => + 'data-testid'?: string; + }) => ( + + ) ), ButtonUtility: jest.fn( ({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleVersionHeader/ArticleVersionHeader.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleVersionHeader/ArticleVersionHeader.component.tsx index b676104ae0b4..052d2a8fb81c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleVersionHeader/ArticleVersionHeader.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ArticleVersionHeader/ArticleVersionHeader.component.tsx @@ -11,15 +11,14 @@ * limitations under the License. */ -import { Card, Skeleton, Typography } from '@openmetadata/ui-core-components'; -import classNames from 'classnames'; +import { Card, Skeleton } from '@openmetadata/ui-core-components'; import { FC } from 'react'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as FileIcon } from '../../../assets/svg/common/file.svg'; import { KnowledgePage } from '../../../interface/knowledge-center.interface'; import contextCenterClassBase from '../../../utils/ContextCenterClassBase'; import { getKnowledgePageName } from '../../../utils/KnowledgePagePureUtils'; import HeaderBreadcrumb from '../../common/HeaderBreadcrumb/HeaderBreadcrumb.component'; +import HeaderShell from '../../common/HeaderShell/HeaderShell.component'; interface ArticleVersionHeaderProps { knowledgePage?: KnowledgePage; @@ -57,35 +56,20 @@ const ArticleVersionHeader: FC = ({ } const breadcrumbInsideCard = contextCenterClassBase.isBreadcrumbInsideCard(); - const headerCardClassName = contextCenterClassBase.getHeaderCardClassName(); const breadcrumbEl = ( ); return ( -
+
{!breadcrumbInsideCard && breadcrumbEl} - - - {breadcrumbInsideCard &&
{breadcrumbEl}
} -
-
- -
- - - {getKnowledgePageName(knowledgePage, t)} - -
-
+
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.component.tsx index 550a46f79be6..38327c0adfc4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.component.tsx @@ -11,19 +11,14 @@ * limitations under the License. */ -import { - Button, - Card, - Input, - Typography, -} from '@openmetadata/ui-core-components'; +import { Button, Input } from '@openmetadata/ui-core-components'; import { Plus, SearchMd } from '@untitledui/icons'; -import classNames from 'classnames'; import { FC } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as UploadIcon } from '../../../assets/svg/action-icons/upload.svg'; import contextCenterClassBase from '../../../utils/ContextCenterClassBase'; import HeaderBreadcrumb from '../../common/HeaderBreadcrumb/HeaderBreadcrumb.component'; +import HeaderShell from '../../common/HeaderShell/HeaderShell.component'; import { ContextCenterHeaderProps } from './ContextCenterHeader.interface'; const ContextCenterHeader: FC = ({ @@ -41,7 +36,6 @@ const ContextCenterHeader: FC = ({ }) => { const { t } = useTranslation(); const breadcrumbInsideCard = contextCenterClassBase.isBreadcrumbInsideCard(); - const headerCardClassName = contextCenterClassBase.getHeaderCardClassName(); const isEmbedded = contextCenterClassBase.isEmbeddedMode(); const resolvedBreadcrumbs = [ @@ -76,41 +70,34 @@ const ContextCenterHeader: FC = ({ ); + const actionsEl = ( +
+ {onSearch && ( + + )} + {hasPermission ? actionsSlot ?? defaultActions : null} +
+ ); + return ( -
+
{!breadcrumbInsideCard && breadcrumbEl} - - - {breadcrumbInsideCard &&
{breadcrumbEl}
} -
-
-
- {title} -
- {subtitle && ( - {subtitle} - )} -
-
- {onSearch && ( - - )} - {hasPermission ? actionsSlot ?? defaultActions : null} -
-
-
+
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.test.tsx index 0bca9f882166..6dc2c3ae831c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ContextCenter/ContextCenterHeader/ContextCenterHeader.test.tsx @@ -20,6 +20,9 @@ jest.mock('react-router-dom', () => ({ })); jest.mock('@openmetadata/ui-core-components', () => ({ + Box: jest.fn(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), Breadcrumbs: jest.fn(() =>