diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts index f20b89edb3b3..52cb0b82daf7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Tags.spec.ts @@ -262,11 +262,14 @@ test('Classification Page', async ({ page }) => { await validateForm(page); - await page.fill('[data-testid="name"]', NEW_CLASSIFICATION.name); - await page.fill( - '[data-testid="displayName"]', - NEW_CLASSIFICATION.displayName - ); + await page + .getByTestId('name') + .getByRole('textbox') + .fill(NEW_CLASSIFICATION.name); + await page + .getByTestId('displayName') + .getByRole('textbox') + .fill(NEW_CLASSIFICATION.displayName); await page.locator(descriptionBox).fill(NEW_CLASSIFICATION.description); await page.click('[data-testid="mutually-exclusive-button"]'); @@ -296,14 +299,14 @@ test('Classification Page', async ({ page }) => { await expect(page.getByTestId('tags-form')).toBeVisible(); await validateForm(page); - - await page.fill('[data-testid="name"]', NEW_TAG.name); - await page.fill('[data-testid="displayName"]', NEW_TAG.displayName); + await page.getByTestId('name').getByRole('textbox').fill(NEW_TAG.name); + await page + .getByTestId('displayName') + .getByRole('textbox') + .fill(NEW_TAG.displayName); await page.locator(descriptionBox).fill(NEW_TAG.description); await page.getByTestId('icon-picker-btn').click(); - await page - .getByRole('button', { name: `Select icon ${NEW_TAG.icon}` }) - .click(); + await page.getByRole('button', { name: NEW_TAG.icon }).click(); await page .getByRole('button', { name: `Select color ${NEW_TAG.color}` }) .click(); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts index ba1b72897b54..a9eab37198f6 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/tag.ts @@ -247,22 +247,20 @@ export async function validateForm(page: Page) { await submitForm(page); // error messages - await expect(page.locator('#tags_name_help')).toBeVisible(); - await expect(page.locator('#tags_name_help')).toContainText( - 'Name is required' - ); + await expect(page.getByText('Name is required')).toBeVisible(); - await expect(page.locator('#tags_description_help')).toBeVisible(); - await expect(page.locator('#tags_description_help')).toContainText( - 'Description is required' - ); + await expect(page.getByText('Description is required')).toBeVisible(); // validation should work for invalid names // min length validation await page.locator('[data-testid="name"]').scrollIntoViewIfNeeded(); - await page.locator('[data-testid="name"]').clear(); - await page.locator('[data-testid="name"]').fill(TAG_INVALID_NAMES.MIN_LENGTH); + await page.getByTestId('name').getByRole('textbox').clear(); + await page + .getByTestId('name') + .getByRole('textbox') + .fill(TAG_INVALID_NAMES.MIN_LENGTH); + await page.waitForLoadState('domcontentloaded'); await expect( @@ -270,8 +268,12 @@ export async function validateForm(page: Page) { ).toBeVisible(); // max length validation - await page.locator('[data-testid="name"]').clear(); - await page.locator('[data-testid="name"]').fill(TAG_INVALID_NAMES.MAX_LENGTH); + await page.getByTestId('name').getByRole('textbox').clear(); + await page + .getByTestId('name') + .getByRole('textbox') + .fill(TAG_INVALID_NAMES.MAX_LENGTH); + await page.waitForLoadState('domcontentloaded'); await expect( @@ -279,9 +281,10 @@ export async function validateForm(page: Page) { ).toBeVisible(); // with special char validation - await page.locator('[data-testid="name"]').clear(); + await page.getByTestId('name').getByRole('textbox').clear(); await page - .locator('[data-testid="name"]') + .getByTestId('name') + .getByRole('textbox') .fill(TAG_INVALID_NAMES.WITH_SPECIAL_CHARS); await page.waitForLoadState('domcontentloaded'); @@ -500,18 +503,21 @@ export const LIMITED_USER_RULES: PolicyRulesType[] = [ ]; export const fillTagForm = async (adminPage: Page, domain: Domain) => { - await adminPage.fill('[data-testid="name"]', NEW_TAG.name); - await adminPage.fill('[data-testid="displayName"]', NEW_TAG.displayName); + await adminPage.getByTestId('name').getByRole('textbox').fill(NEW_TAG.name); + await adminPage + .getByTestId('displayName') + .getByRole('textbox') + .fill(NEW_TAG.displayName); await adminPage.locator(descriptionBox).fill(NEW_TAG.description); await adminPage.getByTestId('icon-picker-btn').click(); - await adminPage - .getByRole('button', { name: `Select icon ${NEW_TAG.icon}` }) - .click(); + await adminPage.getByRole('button', { name: NEW_TAG.icon }).click(); await adminPage .getByRole('button', { name: `Select color ${NEW_TAG.color}` }) .click(); - const domainInput = adminPage.getByTestId('domain-select'); + const domainInput = adminPage + .getByTestId('domain-select') + .getByRole('combobox'); await domainInput.scrollIntoViewIfNeeded(); await domainInput.waitFor({ state: 'visible' }); await domainInput.click(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/MUIUserTeamSelect/MUIUserTeamSelect.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/MUIUserTeamSelect/MUIUserTeamSelect.tsx deleted file mode 100644 index fa87938ec826..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/MUIUserTeamSelect/MUIUserTeamSelect.tsx +++ /dev/null @@ -1,435 +0,0 @@ -/* - * 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 { - Autocomplete, - AutocompleteRenderGetTagProps, - Box, - Chip, - TextField, - useTheme, -} from '@mui/material'; -import { XClose } from '@untitledui/icons'; -import { debounce } from 'lodash'; -import { - ComponentPropsWithoutRef, - FC, - HTMLAttributes, - SyntheticEvent, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; -import { useTranslation } from 'react-i18next'; -import { ReactComponent as IconTeams } from '../../../assets/svg/teams-grey.svg'; -import { PAGE_SIZE_MEDIUM } from '../../../constants/constants'; -import { EntityType } from '../../../enums/entity.enum'; -import { SearchIndex } from '../../../enums/search.enum'; -import { EntityReference } from '../../../generated/entity/type'; -import { searchData } from '../../../rest/miscAPI'; -import { - formatTeamsResponse, - formatUsersResponse, -} from '../../../utils/APIUtils'; -import { getEntityName } from '../../../utils/EntityNameUtils'; -import { getEntityReferenceFromEntity } from '../../../utils/EntityReferenceUtils'; -import { ProfilePicture } from '../atoms/ProfilePicture'; - -export interface MUIUserTeamSelectProps { - // Entity type restrictions - userOnly?: boolean; - teamOnly?: boolean; - - // Multiple selection control - multipleUser?: boolean; - multipleTeam?: boolean; - - // Common props - placeholder?: string; - value?: EntityReference[]; - onChange?: (selected: EntityReference[]) => void; - autoFocus?: boolean; - label?: React.ReactNode; - required?: boolean; -} - -interface OptionType { - label: string; - value: string; - entity: EntityReference; - isTeam: boolean; -} - -const MUIUserTeamSelect: FC = ({ - userOnly = false, - teamOnly = false, - multipleUser = false, - multipleTeam = false, - placeholder, - value = [], - onChange, - autoFocus, - label, - required, -}) => { - const { t } = useTranslation(); - const theme = useTheme(); - const [userOptions, setUserOptions] = useState([]); - const [teamOptions, setTeamOptions] = useState([]); - const [loading, setLoading] = useState(false); - const [inputValue, setInputValue] = useState(''); - const [open, setOpen] = useState(false); - - const selectedOptions = useMemo(() => { - return value.map((entity) => ({ - label: getEntityName(entity), - value: entity.id || '', - entity, - isTeam: entity.type === EntityType.TEAM, - })); - }, [value]); - - const fetchUsers = async (searchText: string) => { - if (teamOnly) { - setUserOptions([]); - - return; - } - - const res = await searchData( - searchText, - 1, - PAGE_SIZE_MEDIUM, - 'isBot:false', - '', - '', - SearchIndex.USER, - false, - false, - true - ); - - const users = formatUsersResponse(res.data.hits.hits); - - setUserOptions( - users.map((user) => ({ - label: getEntityName(user), - value: user.id, - entity: user as unknown as EntityReference, - isTeam: false, - })) - ); - }; - - const fetchTeams = async (searchText: string) => { - if (userOnly) { - setTeamOptions([]); - - return; - } - - const res = await searchData( - searchText, - 1, - PAGE_SIZE_MEDIUM, - '', - '', - '', - SearchIndex.TEAM, - false, - false, - true - ); - - const teams = formatTeamsResponse(res.data.hits.hits); - - setTeamOptions( - teams.map((team) => ({ - label: getEntityName(team), - value: team.id, - entity: team as unknown as EntityReference, - isTeam: true, - })) - ); - }; - - const handleSearch = useCallback( - debounce(async (searchText: string) => { - setLoading(true); - - try { - await Promise.all([fetchUsers(searchText), fetchTeams(searchText)]); - } catch (error) { - setUserOptions([]); - setTeamOptions([]); - } finally { - setLoading(false); - } - }, 300), - [userOnly, teamOnly] - ); - - useEffect(() => { - if (inputValue) { - handleSearch(inputValue); - } else { - setLoading(true); - setUserOptions([]); - setTeamOptions([]); - handleSearch(''); - } - }, [inputValue]); - - // Fetch initial options when dropdown opens - useEffect(() => { - if ( - open && - userOptions.length === 0 && - teamOptions.length === 0 && - !inputValue - ) { - handleSearch(''); - } - }, [open]); - - const handleChange = ( - _event: SyntheticEvent, - newValue: string | OptionType | (string | OptionType)[] | null - ) => { - if (!onChange) { - return; - } - - // Filter out string values from freeSolo - if (typeof newValue === 'string') { - return; - } - - if (Array.isArray(newValue)) { - // Multiple selection mode - handle team/user exclusivity - // Filter out any string values - const optionValues = newValue.filter( - (v): v is OptionType => typeof v !== 'string' - ); - let finalSelection = [...optionValues]; - - // Check if a new team was just added (comparing with previous selection) - const newTeams = optionValues.filter((opt) => opt.isTeam); - const oldTeams = selectedOptions.filter((opt) => opt.isTeam); - const teamWasAdded = newTeams.length > oldTeams.length; - - // Check if a new user was just added - const newUsers = optionValues.filter((opt) => !opt.isTeam); - const oldUsers = selectedOptions.filter((opt) => !opt.isTeam); - const userWasAdded = newUsers.length > oldUsers.length; - - if (teamWasAdded) { - // When a team is selected, remove all users and keep only the latest team - finalSelection = finalSelection.filter((opt) => opt.isTeam); - if (!multipleTeam && finalSelection.length > 1) { - // Keep only the most recent team - finalSelection = [newTeams[newTeams.length - 1]]; - } - } else if (userWasAdded) { - // When a user is selected, remove all teams - finalSelection = finalSelection.filter((opt) => !opt.isTeam); - if (!multipleUser && finalSelection.length > 1) { - // Keep only the most recent user - finalSelection = [newUsers[newUsers.length - 1]]; - } - } - - // Clean entities to valid EntityReference format - const entities = finalSelection.map((opt) => - getEntityReferenceFromEntity( - opt.entity, - opt.isTeam ? EntityType.TEAM : EntityType.USER - ) - ); - onChange(entities); - } else if (newValue) { - // Single selection mode - clean entity - const cleanEntity = getEntityReferenceFromEntity( - newValue.entity, - newValue.isTeam ? EntityType.TEAM : EntityType.USER - ); - onChange([cleanEntity]); - } else { - onChange([]); - } - }; - - const isMultiple = useMemo(() => { - // If both user and team can be selected - if (!userOnly && !teamOnly) { - return multipleUser || multipleTeam; - } - // If only users - if (userOnly) { - return multipleUser; - } - // If only teams - if (teamOnly) { - return multipleTeam; - } - - return false; - }, [userOnly, teamOnly, multipleUser, multipleTeam]); - - const isOptionEqualToValue = (option: OptionType, val: OptionType) => { - return option.value === val.value; - }; - - const filterOptions = (opts: OptionType[]) => { - // No filtering - all options are always available for selection - // The handleChange function will handle the replacement logic - return opts; - }; - - const renderOption = ( - props: HTMLAttributes, - option: OptionType - ) => { - const { entity, isTeam } = option; - - return ( - - {isTeam ? ( - - ) : ( - - )} - {getEntityName(entity)} - - ); - }; - - const renderTags = ( - value: OptionType[], - getTagProps: AutocompleteRenderGetTagProps - ) => { - return value.map((option, index) => { - const { entity, isTeam } = option; - const tagProps = getTagProps({ index }); - - return ( - - ) : ( - - ) - } - color="secondary" - deleteIcon={} - key={entity.id} - label={getEntityName(entity)} - size="small" - sx={{ - borderRadius: '8px', - backgroundColor: 'transparent', - borderColor: theme.palette.grey[300], - }} - variant="outlined" - /> - ); - }); - }; - - const getPlaceholderText = () => { - if (placeholder) { - return placeholder; - } - if (userOnly) { - return t('label.select-field', { field: t('label.user-plural') }); - } - if (teamOnly) { - return t('label.select-field', { field: t('label.team-plural') }); - } - - return t('label.select-users-or-team'); - }; - - const allOptions = useMemo( - () => [...userOptions, ...teamOptions], - [userOptions, teamOptions] - ); - - return ( - > & { key: string } - } - autoFocus={autoFocus} - filterOptions={filterOptions} - getOptionLabel={(option) => - typeof option === 'string' ? option : option.label - } - inputValue={inputValue} - isOptionEqualToValue={isOptionEqualToValue} - loading={loading} - multiple={isMultiple} - open={open && (allOptions.length > 0 || loading)} - options={allOptions} - renderInput={(params) => ( - - )} - renderOption={renderOption} - renderTags={renderTags} - slotProps={{ - popper: { 'data-react-aria-top-layer': true } as object, - }} - sx={{ width: '100%' }} - value={isMultiple ? selectedOptions : selectedOptions[0] || null} - onChange={handleChange} - onClose={() => setOpen(false)} - onInputChange={(_, newValue) => setInputValue(newValue)} - onOpen={() => setOpen(true)} - /> - ); -}; - -export default MUIUserTeamSelect; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/OwnerLabel/OwnerAvatarStack.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/OwnerLabel/OwnerAvatarStack.component.tsx index 29dec23e4547..6d9d2c3ef398 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/OwnerLabel/OwnerAvatarStack.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/OwnerLabel/OwnerAvatarStack.component.tsx @@ -122,10 +122,12 @@ export const OwnerAvatarStack: React.FC = ({ const linkContent = ( - {isTeam ? renderTeamBadge(owner) : renderUserBadge(owner)} +
+ {isTeam ? renderTeamBadge(owner) : renderUserBadge(owner)} +
{ return jest.fn(() =>
Tags Form
); @@ -93,12 +94,11 @@ jest.mock('@openmetadata/ui-core-components', () => ({ })); const mockForm = { - submit: jest.fn(), - resetFields: jest.fn(), - getFieldsValue: jest.fn(), - setFieldsValue: jest.fn(), - validateFields: jest.fn(), -} as unknown as FormInstance; + control: {}, + handleSubmit: jest.fn(), + reset: jest.fn(), + formState: { isSubmitSuccessful: false }, +} as unknown as UseFormReturn; describe('ClassificationFormDrawer', () => { const mockOnClose = jest.fn(); @@ -106,7 +106,7 @@ describe('ClassificationFormDrawer', () => { const defaultProps = { open: true, - formRef: mockForm, + form: mockForm, classifications: [], isTier: false, isLoading: false, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/ClassificationFormDrawer.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/ClassificationFormDrawer.tsx index 6e4d0ecbedc2..f88ed04d11ef 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/ClassificationFormDrawer.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/ClassificationFormDrawer.tsx @@ -16,14 +16,14 @@ import { SlideoutMenu, Typography, } from '@openmetadata/ui-core-components'; -import { FC, useCallback } from 'react'; +import { FC, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import TagsForm from './TagsForm'; import { ClassificationFormDrawerProps } from './TagsPage.interface'; const ClassificationFormDrawer: FC = ({ open, - formRef, + form, classifications, isTier, isLoading, @@ -31,6 +31,7 @@ const ClassificationFormDrawer: FC = ({ onSubmit, }) => { const { t } = useTranslation(); + const submitRef = useRef<() => void>(() => void 0); const handleOpenChange = useCallback( (isOpen: boolean) => { @@ -61,9 +62,10 @@ const ClassificationFormDrawer: FC = ({ isClassification showMutuallyExclusive data={classifications} - formRef={formRef} + form={form} isEditing={false} isTier={isTier} + submitRef={submitRef} onSubmit={onSubmit} /> @@ -81,7 +83,7 @@ const ClassificationFormDrawer: FC = ({ data-testid="save-button" isDisabled={isLoading} isLoading={isLoading} - onClick={() => formRef.submit()}> + onClick={() => submitRef.current()}> {t('label.save')} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagFormDrawer.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagFormDrawer.test.tsx index 3a1698e1dc27..92bf914711a7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagFormDrawer.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagFormDrawer.test.tsx @@ -12,8 +12,9 @@ */ import { fireEvent, render, screen } from '@testing-library/react'; -import { FormInstance } from 'antd'; +import { UseFormReturn } from 'react-hook-form'; import TagFormDrawer from './TagFormDrawer'; +import { TagFormValues } from './TagsPage.interface'; jest.mock('./TagsForm', () => { return jest.fn(() =>
Tags Form
); @@ -93,12 +94,11 @@ jest.mock('@openmetadata/ui-core-components', () => ({ })); const mockForm = { - submit: jest.fn(), - resetFields: jest.fn(), - getFieldsValue: jest.fn(), - setFieldsValue: jest.fn(), - validateFields: jest.fn(), -} as unknown as FormInstance; + control: {}, + handleSubmit: jest.fn(), + reset: jest.fn(), + formState: { isSubmitSuccessful: false }, +} as unknown as UseFormReturn; describe('TagFormDrawer', () => { const mockOnClose = jest.fn(); @@ -106,7 +106,7 @@ describe('TagFormDrawer', () => { const defaultProps = { open: true, - formRef: mockForm, + form: mockForm, isTier: false, isLoading: false, permissions: { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagFormDrawer.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagFormDrawer.tsx index bd06989465d9..a5b7dc63f382 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagFormDrawer.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagFormDrawer.tsx @@ -17,7 +17,7 @@ import { Typography, } from '@openmetadata/ui-core-components'; import { isUndefined } from 'lodash'; -import { FC, useCallback } from 'react'; +import { FC, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { ProviderType } from '../../generated/api/classification/createTag'; import TagsForm from './TagsForm'; @@ -26,7 +26,7 @@ import { TagFormDrawerProps } from './TagsPage.interface'; const TagFormDrawer: FC = ({ open, editTag, - formRef, + form, isTier, isLoading, permissions, @@ -35,6 +35,7 @@ const TagFormDrawer: FC = ({ onSubmit, }) => { const { t } = useTranslation(); + const submitRef = useRef<() => void>(() => void 0); const handleOpenChange = useCallback( (isOpen: boolean) => { @@ -62,13 +63,14 @@ const TagFormDrawer: FC = ({ @@ -86,7 +88,7 @@ const TagFormDrawer: FC = ({ data-testid="save-button" isDisabled={isLoading} isLoading={isLoading} - onClick={() => formRef.submit()}> + onClick={() => submitRef.current()}> {t('label.save')} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsForm.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsForm.test.tsx index 4e7b301829b0..63cf18cfd66d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsForm.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TagsPage/TagsForm.test.tsx @@ -12,13 +12,15 @@ */ import { render, screen } from '@testing-library/react'; -import { Form } from 'antd'; -import { DEFAULT_FORM_VALUE } from '../../constants/Tags.constant'; -import { Classification } from '../../generated/entity/classification/classification'; -import { Tag } from '../../generated/entity/classification/tag'; +import { useForm } from 'react-hook-form'; import TagsForm from './TagsForm'; +import { TagFormValues } from './TagsPage.interface'; jest.mock('@openmetadata/ui-core-components', () => { + const { FieldTypes, HelperTextType } = jest.requireActual( + '@openmetadata/ui-core-components' + ); + const GridItem = ({ children }: { children: React.ReactNode }) => (
{children}
); @@ -27,10 +29,35 @@ jest.mock('@openmetadata/ui-core-components', () => { ); GridComponent.Item = GridItem; + const Toggle = ({ + isSelected, + onChange, + isDisabled, + className, + ...rest + }: { + isSelected?: boolean; + onChange?: (val: boolean) => void; + isDisabled?: boolean; + className?: string; + } & Record) => ( + , - Toggle: ({ - id, - isSelected, - onChange, - isDisabled, - className, - }: { - id?: string; - isSelected?: boolean; - onChange?: (val: boolean) => void; - isDisabled?: boolean; - className?: string; - }) => ( -