diff --git a/docs/decisions/2026-10-12-numeric-fields-preserve-edits-and-api-values.md b/docs/decisions/2026-10-12-numeric-fields-preserve-edits-and-api-values.md new file mode 100644 index 000000000000..0a13b3e3536b --- /dev/null +++ b/docs/decisions/2026-10-12-numeric-fields-preserve-edits-and-api-values.md @@ -0,0 +1,29 @@ +# Numeric fields preserve decimal edits and nullable API values + +- **Status:** Accepted +- **Revisions:** v1 2026-10-12 (initial) +- **Deciders:** Harshit Shah +- **Guard:** core `number-input.test.tsx`, `ProfilerSettingsModal.test.tsx`, `ContractSLAFormTab.utils.test.ts` +- **Related:** open-metadata/openmetadata-collate#7302, open-metadata/openmetadata-collate#7303 + +## Context +Migrating numeric fields must retain decimal editing, clearing, bounds, and numeric payloads. +A grid-based stepper changes 3.5 to 5 with a step of 1; existing fields increment it to 4.5. +Browser step/min validation can also reject stored profiler samples of zero when saving an +otherwise untouched configuration. Clearing a field must remain distinguishable from zero. + +## Decision +Core `NumberInput` maintains an editable string and reports finite numbers or `null`. Bounds +apply when editing is committed; loading a controlled value does not silently rewrite it. +Arrow keys add the requested step relative to the edited value rather than snapping to a grid. +Native spinner buttons are hidden because they use grid stepping. Decimal arithmetic avoids +binary rounding drift; explicit precision is limited to 0–20 fractional digits. + +Forms bind numeric callbacks directly to React Hook Form. Serializers distinguish `null` from +zero and omit cleared optional durations. Wall-clock SLA availability remains an HH:mm string; +selecting a timezone does not convert that clock time. + +## Consequences +Core owns numeric behavior and styling. Callers keep domain validation and serialization, +including compatibility with existing stored values. These contracts are covered by interaction +and payload tests rather than assertions about Ant Design markup. diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select-migration.test.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select-migration.test.tsx new file mode 100644 index 000000000000..e19c7dda4d91 --- /dev/null +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select-migration.test.tsx @@ -0,0 +1,96 @@ +/* + * 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 { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { TreeSelect } from './tree-select'; +import { TreeSelectNode } from './tree-select.types'; + +const children: TreeSelectNode[] = [ + { id: 'a', label: 'A', value: 'a' }, + { id: 'b', label: 'B', value: 'b' }, +]; +const parent: TreeSelectNode = { + id: 'all', + label: 'All', + value: 'all', + children, +}; +const fetchData = async () => ({ nodes: [parent] }); + +describe('TreeSelect migrated selection contracts', () => { + it('moves keyboard focus into asynchronously loaded options and selects with Enter', async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render( + ({ nodes: children })} + id="teams" + onChange={onChange} + /> + ); + const input = screen.getByRole('combobox', { name: 'Choose teams' }); + expect(input).toHaveAttribute('id', 'teams'); + await user.click(input); + await user.keyboard('{ArrowDown}'); + const firstRow = await screen.findByRole('row', { name: 'A' }); + await waitFor(() => expect(firstRow).toHaveFocus()); + expect(input).toHaveAttribute( + 'aria-controls', + screen.getByRole('treegrid').id + ); + await user.keyboard('{Enter}'); + await waitFor(() => expect(onChange).toHaveBeenCalledWith([children[0]])); + }); + + it('limits visible chips without discarding hidden selections', () => { + render( + + ); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.queryByText('B')).not.toBeInTheDocument(); + expect(screen.getByText('+1')).toBeInTheDocument(); + }); + it('collapses a selected branch in the trigger and forwards focus events', async () => { + const onFocus = vi.fn(); + const onBlur = vi.fn(); + const user = userEvent.setup(); + render( + + ); + expect(screen.getByText('All')).toBeInTheDocument(); + expect(screen.queryByText('A')).not.toBeInTheDocument(); + await user.click(screen.getByRole('combobox', { name: 'Metrics' })); + expect(onFocus).toHaveBeenCalled(); + await user.tab(); + expect(onBlur).toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select.tsx index b6877443604e..252e09436d3e 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/tree-select/tree-select.tsx @@ -23,6 +23,7 @@ import { type RefObject, useCallback, useEffect, + useId, useMemo, useRef, useState, @@ -215,6 +216,8 @@ const collectSelectableNodes = ( export const TreeSelect = ({ label, + id, + 'aria-label': ariaLabel, placeholder, hint, tooltip, @@ -247,6 +250,11 @@ export const TreeSelect = ({ fullWidthTrigger = false, onFetchError, renderSelectedItem, + isLoading = false, + maxVisibleItems, + selectedDisplayStrategy = 'all', + onFocus, + onBlur, bordered = false, showSelectAll = false, commitMode = 'immediate', @@ -264,6 +272,11 @@ export const TreeSelect = ({ filterNode, }: TreeSelectProps): ReactElement => { const { t } = useCoreTranslation(); + const treeId = useId(); + const inputId = id ?? `${treeId}-input`; + const [pendingTreeFocus, setPendingTreeFocus] = useState< + 'first' | 'last' | null + >(null); const [internalOpen, setInternalOpen] = useState(false); const isOpen = controlledIsOpen ?? internalOpen; const [expandedKeys, setExpandedKeys] = useState>( @@ -321,7 +334,7 @@ export const TreeSelect = ({ // The button badge excludes root ids, so it needs the tree while closed. const { treeData, - loading, + loading: dataLoading, loadingNodes, loadChildren, loadMoreChildren, @@ -337,6 +350,8 @@ export const TreeSelect = ({ enabled: hasOpened || isButtonVariant, }); + const loading = dataLoading || isLoading; + const visibleNodeIds = useMemo( () => searchable ? getVisibleNodeIds(treeData, searchTerm, filterNode) : null, @@ -752,6 +767,51 @@ export const TreeSelect = ({ [selectedData, toggleNodeSelection] ); + const displayedNodes = useMemo(() => { + if (selectedDisplayStrategy === 'all') { + return selectedData; + } + const parents = new Map(); + const walk = (nodes: TreeSelectNode[], parentId?: string) => { + nodes.forEach((node) => { + if (parentId) { + parents.set(node.id, parentId); + } + if (node.children) { + walk(node.children, node.id); + } + }); + }; + walk(treeData); + // Selected branches can arrive before their async options have loaded. + walk(selectedData); + selectedData.forEach((node) => { + if (node.parentId && !parents.has(node.id)) { + parents.set(node.id, node.parentId); + } + }); + const selectedIds = new Set(selectedData.map((node) => node.id)); + + return selectedData.filter((node) => { + let parentId = parents.get(node.id); + while (parentId) { + if (selectedIds.has(parentId)) { + return false; + } + parentId = parents.get(parentId); + } + + return true; + }); + }, [selectedData, selectedDisplayStrategy, treeData]); + const visibleNodes = displayedNodes.slice( + 0, + maxVisibleItems === undefined + ? displayedNodes.length + : Math.max(0, maxVisibleItems) + ); + const hiddenNodes = displayedNodes.slice(visibleNodes.length); + const displayValue = isOpen ? inputValue : !multiple && !renderSelectedItem @@ -914,6 +974,27 @@ export const TreeSelect = ({ setShowSelectedOnly(false); }, [onChange, multiple, selectedData, clearSearch, setOpen]); + // Wait for async rows before handing focus to React Aria's keyboard navigation. + useEffect(() => { + if (!isOpen) { + setPendingTreeFocus(null); + + return; + } + if (!pendingTreeFocus || loading) { + return; + } + const rows = popoverRef.current?.querySelectorAll( + '[role="row"]:not([aria-disabled="true"])' + ); + const row = + pendingTreeFocus === 'first' ? rows?.[0] : rows?.[rows.length - 1]; + if (row) { + row.focus(); + setPendingTreeFocus(null); + } + }, [isOpen, pendingTreeFocus, loading, filteredTreeData]); + const treeDropdownContent = (
({
) : ( { const id = String(key); @@ -1050,7 +1132,10 @@ export const TreeSelect = ({ if (renderTrigger) { return ( -
+
@@ -1072,7 +1157,10 @@ export const TreeSelect = ({ const triggerText = label ?? placeholder ?? ''; return ( -
+
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.tsx index 7f6b2913d86e..d6ea8be3a6a0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.tsx @@ -32,7 +32,7 @@ import { Trash01, Upload01, } from '@openmetadata/ui-core-components/icons'; -import type { RadioChangeEvent } from 'antd'; + import { AxiosError } from 'axios'; import { isEmpty } from 'lodash'; import React, { lazy, useCallback, useEffect, useMemo, useState } from 'react'; @@ -288,8 +288,8 @@ const ContractDetail: React.FC<{ ] ); - const handleModeChange = useCallback((e: RadioChangeEvent) => { - setMode(e.target.value); + const handleModeChange = useCallback((mode: DataContractMode) => { + setMode(mode); }, []); const statusBadge = useMemo(() => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSLAFormTab/ContractSLAFormTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSLAFormTab/ContractSLAFormTab.test.tsx index 149648123e74..b081c72ed339 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSLAFormTab/ContractSLAFormTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSLAFormTab/ContractSLAFormTab.test.tsx @@ -143,7 +143,7 @@ describe('ContractSLAFormTab', () => { it('should have proper validation rules for numeric fields', () => { render(); - // Verify InputNumber components are rendered with proper constraints + // Verify NumberInput components are rendered with proper constraints const refreshFrequencyInput = screen.getByTestId( 'refresh-frequency-interval-input' ); @@ -154,39 +154,31 @@ describe('ContractSLAFormTab', () => { expect(maxLatencyInput).toBeInTheDocument(); expect(retentionPeriodInput).toBeInTheDocument(); - // Check that these are proper InputNumber components - expect( - refreshFrequencyInput.closest('.ant-input-number') - ).toBeInTheDocument(); - expect(maxLatencyInput.closest('.ant-input-number')).toBeInTheDocument(); - expect( - retentionPeriodInput.closest('.ant-input-number') - ).toBeInTheDocument(); + // Check that these are proper NumberInput components + expect(refreshFrequencyInput).toHaveAttribute('type', 'number'); + expect(maxLatencyInput).toHaveAttribute('type', 'number'); + expect(retentionPeriodInput).toHaveAttribute('type', 'number'); }); it('should have validation rules configured for minimum values', () => { render(); - // Verify InputNumber components are rendered which have built-in numeric validation + // Verify NumberInput components are rendered which have built-in numeric validation const refreshFrequencyInput = screen.getByTestId( 'refresh-frequency-interval-input' ); const maxLatencyInput = screen.getByTestId('max-latency-value-input'); const retentionPeriodInput = screen.getByTestId('retention-period-input'); - // InputNumber components should be present + // NumberInput components should be present expect(refreshFrequencyInput).toBeInTheDocument(); expect(maxLatencyInput).toBeInTheDocument(); expect(retentionPeriodInput).toBeInTheDocument(); - // They should be InputNumber components (not regular Input) - expect( - refreshFrequencyInput.closest('.ant-input-number') - ).toBeInTheDocument(); - expect(maxLatencyInput.closest('.ant-input-number')).toBeInTheDocument(); - expect( - retentionPeriodInput.closest('.ant-input-number') - ).toBeInTheDocument(); + // They should be NumberInput components (not regular Input) + expect(refreshFrequencyInput).toHaveAttribute('type', 'number'); + expect(maxLatencyInput).toHaveAttribute('type', 'number'); + expect(retentionPeriodInput).toHaveAttribute('type', 'number'); }); it('should have form validation rules for required fields', () => { @@ -205,15 +197,15 @@ describe('ContractSLAFormTab', () => { expect(screen.getByTestId('retention-period-input')).toBeInTheDocument(); }); - it('should configure InputNumber with appropriate constraints', () => { + it('should configure NumberInput with appropriate constraints', () => { render(); const refreshFrequencyInput = screen.getByTestId( 'refresh-frequency-interval-input' ); - // InputNumber should have numeric input type behavior - expect(refreshFrequencyInput).toHaveAttribute('role', 'spinbutton'); + // NumberInput should have numeric input type behavior + expect(refreshFrequencyInput).toHaveAttribute('type', 'number'); }); }); @@ -291,7 +283,7 @@ describe('ContractSLAFormTab', () => { render(); // Component implements conditional logic that checks for >= 0 values - // This is verified by the presence of InputNumber components which handle this validation + // This is verified by the presence of NumberInput components which handle this validation expect( screen.getByTestId('refresh-frequency-interval-input') ).toBeInTheDocument(); @@ -366,10 +358,10 @@ describe('ContractSLAFormTab', () => { }); describe('Edge Cases and Boundary Conditions', () => { - it('should render InputNumber components that handle numeric validation', () => { + it('should render NumberInput components that handle numeric validation', () => { render(); - // InputNumber components provide built-in validation for numeric inputs + // NumberInput components provide built-in validation for numeric inputs const intervalInput = screen.getByTestId( 'refresh-frequency-interval-input' ); @@ -380,10 +372,10 @@ describe('ContractSLAFormTab', () => { expect(maxLatencyInput).toBeInTheDocument(); expect(retentionInput).toBeInTheDocument(); - // These should be InputNumber components - expect(intervalInput.closest('.ant-input-number')).toBeInTheDocument(); - expect(maxLatencyInput.closest('.ant-input-number')).toBeInTheDocument(); - expect(retentionInput.closest('.ant-input-number')).toBeInTheDocument(); + // These should be NumberInput components + expect(intervalInput).toHaveAttribute('type', 'number'); + expect(maxLatencyInput).toHaveAttribute('type', 'number'); + expect(retentionInput).toHaveAttribute('type', 'number'); }); it('should handle form reset after initial values are set', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSLAFormTab/ContractSLAFormTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSLAFormTab/ContractSLAFormTab.tsx index 6a76a045524e..31357a527e51 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSLAFormTab/ContractSLAFormTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractSLAFormTab/ContractSLAFormTab.tsx @@ -11,26 +11,30 @@ * limitations under the License. */ import { + Box, Card, + FormField, + FormItemLabel, Grid, + HintText, + HookForm, + NumberInput, + TimePicker, Tooltip, Typography, } from '@openmetadata/ui-core-components'; -import { Button, Form, InputNumber, Select, TimePicker } from 'antd'; -import { FormProps } from 'antd/lib/form/Form'; +import { Button, Select } from 'antd'; import { isEmpty } from 'lodash'; -import moment from 'moment'; import { useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { ReactComponent as LeftOutlined } from '../../../assets/svg/left-arrow.svg'; import { MAX_LATENCY_UNITS, REFRESH_FREQUENCY_UNITS, RETENTION_UNITS, - SLA_AVAILABILITY_TIME_FORMAT, } from '../../../constants/DataContract.constants'; import { - ContractSLA, DataContract, Timezone, } from '../../../generated/entity/data/dataContract'; @@ -42,6 +46,11 @@ import { getPopupContainer } from '../../../utils/formPureUtils'; import { getColumnOptionsFromTableColumn } from '../../../utils/TablePureUtils'; import { useGenericContext } from '../../Customization/GenericProvider/GenericContext'; import './contract-sla-form-tab.less'; +import { + SlaFormValues, + toContractSla, + toSlaFormValues, +} from './ContractSLAFormTab.utils'; export const ContractSLAFormTab: React.FC<{ onChange: (data: Partial) => void; @@ -54,7 +63,7 @@ export const ContractSLAFormTab: React.FC<{ }; }> = ({ onChange, onPrev, buttonProps, initialValues }) => { const { t } = useTranslation(); - const [form] = Form.useForm(); + const form = useForm({ mode: 'onChange' }); const { data: tableData } = useGenericContext(); const columnOptions = useMemo(() => { @@ -85,71 +94,14 @@ export const ContractSLAFormTab: React.FC<{ }; }, []); - const handleFormChange: FormProps['onValuesChange'] = (_, values) => { - const slaData: ContractSLA = {}; - - if (values.availabilityTime) { - slaData.availabilityTime = values.availabilityTime.format( - SLA_AVAILABILITY_TIME_FORMAT - ); - } - - if (values.timezone) { - slaData.timezone = values.timezone; - } - - if (values.columnName) { - slaData.columnName = values.columnName; - } - - if (values.max_latency_unit && values.max_latency_value >= 0) { - slaData.maxLatency = { - unit: values.max_latency_unit, - value: values.max_latency_value, - }; - } - - if ( - values.refresh_frequency_interval >= 0 && - values.refresh_frequency_unit - ) { - slaData.refreshFrequency = { - interval: values.refresh_frequency_interval, - unit: values.refresh_frequency_unit, - }; - } - - if (values.retention_period >= 0 && values.retention_unit) { - slaData.retention = { - period: values.retention_period, - unit: values.retention_unit, - }; - } - - onChange({ sla: slaData }); - }; + const handleFormChange = () => + onChange({ sla: toContractSla(form.getValues()) }); useEffect(() => { if (initialValues) { - form.setFieldsValue({ - max_latency_unit: initialValues.sla?.maxLatency?.unit, - max_latency_value: initialValues.sla?.maxLatency?.value, - refresh_frequency_interval: - initialValues.sla?.refreshFrequency?.interval, - refresh_frequency_unit: initialValues.sla?.refreshFrequency?.unit, - retention_period: initialValues.sla?.retention?.period, - retention_unit: initialValues.sla?.retention?.unit, - timezone: initialValues.sla?.timezone, - columnName: initialValues.sla?.columnName, - availabilityTime: initialValues.sla?.availabilityTime - ? moment( - initialValues.sla?.availabilityTime, - SLA_AVAILABILITY_TIME_FORMAT - ) - : undefined, - }); + form.reset(toSlaFormValues(initialValues.sla)); } - }, [initialValues]); + }, [initialValues, form.reset]); return ( <> @@ -163,11 +115,10 @@ export const ContractSLAFormTab: React.FC<{
-
+ onSubmit={(event) => event.preventDefault()}> @@ -183,42 +134,77 @@ export const ContractSLAFormTab: React.FC<{ className="layout-row layout-grid" style={{ ...getLayoutGutter(12) }}> - - - + }}> + {({ field, fieldState }) => ( + + + { + field.onChange(value); + handleFormChange(); + }} + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} + - - { + field.onChange(value); + handleFormChange(); + }} + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} +
@@ -235,42 +221,75 @@ export const ContractSLAFormTab: React.FC<{ className="layout-row layout-grid" style={{ ...getLayoutGutter(24) }}> - - - + }}> + {({ field, fieldState }) => ( + + + { + field.onChange(value); + handleFormChange(); + }} + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} + - - { + field.onChange(value); + handleFormChange(); + }} + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} +
@@ -289,47 +308,83 @@ export const ContractSLAFormTab: React.FC<{ className="layout-row layout-grid" style={{ ...getLayoutGutter(24) }}> - - - + + {({ field, fieldState }) => ( + + + { + field.onChange(value); + handleFormChange(); + }} + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} + - - - + + {({ field, fieldState }) => ( + + + + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} +
@@ -346,42 +401,75 @@ export const ContractSLAFormTab: React.FC<{ className="layout-row layout-grid" style={{ ...getLayoutGutter(24) }}> - - - + }}> + {({ field, fieldState }) => ( + + + { + field.onChange(value); + handleFormChange(); + }} + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} + - - { + field.onChange(value); + handleFormChange(); + }} + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} +
@@ -396,24 +484,44 @@ export const ContractSLAFormTab: React.FC<{ {t('message.contract-sla-column-name-description')} - - { + field.onChange(value); + handleFormChange(); + }} + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} + - +
- ))} -
- )} - -); - -const ParameterForm: React.FC = ({ definition, table }) => { - const { t } = useTranslation(); - - const prepareForm = ( - data: TestCaseParameterDefinition, - DynamicField?: ReactElement - ) => { - const label = getEntityName(data); - const ruleValidation: RuleRender = ({ getFieldValue }) => ({ - validator(_, formValue) { - if (data?.validationRule) { - const fieldValue = data.validationRule.parameterField - ? +getFieldValue(['params', data.validationRule.parameterField]) - : undefined; - const value = +formValue; - if (fieldValue && value) { - switch (data.validationRule.rule) { - case Rule.GreaterThanOrEquals: - return validateGreaterThanOrEquals(fieldValue, value); - case Rule.LessThanOrEquals: - return validateLessThanOrEquals(fieldValue, value); - case Rule.Equals: - return validateEquals(fieldValue, value); - case Rule.NotEquals: - return validateNotEquals(fieldValue, value); - } - } - } - - return Promise.resolve(); - }, - }); - - let internalFormItemProps: FormItemProps = {}; - let Field = ( - - ); - if (data.optionValues?.length) { - Field = ( - - ); - } else if (data.dataType === TestDataType.String) { - Field = buildStringField({ data, table, definition, label, t }); - } else if (NUMERIC_DATA_TYPES.includes(data.dataType)) { - Field = ( - - ); - } else if (data.dataType === TestDataType.Boolean) { - Field = ; - internalFormItemProps = { - ...internalFormItemProps, - valuePropName: 'isSelected', - }; - } else if ( - data.dataType === TestDataType.Array || - data.dataType === TestDataType.Set - ) { - return buildArraySetField({ data, label, DynamicField, t }); - } - - const commonFormItemProps = { - 'data-testid': 'parameter', - key: data.name, - - name: data.name, - rules: [ - { - required: data.required, - message: `${t('message.field-text-is-required', { - fieldText: label, - })}`, - }, - ruleValidation, - ], - tooltip: data.description, - ...internalFormItemProps, - }; - - return data.dataType === TestDataType.Boolean ? ( -
- - {Field} - - {label} -
- ) : ( - - {DynamicField ?? Field} - - ); - }; - - const paramsForm: FormListProps['children'] = () => { - switch (definition.fullyQualifiedName) { - case TABLE_DIFF: - return ( - - ); - - default: - return definition.parameterDefinition?.map((data) => prepareForm(data)); - } - }; - - return {paramsForm}; -}; - -export default ParameterForm; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomRangeWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomRangeWidget.tsx index df538e450097..9ed6741cce52 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomRangeWidget.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomRangeWidget.tsx @@ -10,9 +10,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Grid } from '@openmetadata/ui-core-components'; +import { Grid, NumberInput, Slider } from '@openmetadata/ui-core-components'; import { WidgetProps } from '@rjsf/utils'; -import { InputNumber, Slider } from 'antd'; + import { getLayoutGutter } from '../../../../utils/common/layout.utils'; export const CustomRangeWidget = (props: WidgetProps) => { @@ -25,26 +25,27 @@ export const CustomRangeWidget = (props: WidgetProps) => { style={{ ...getLayoutGutter(20) }}> `${value}%`} + maxValue={100} + minValue={min} value={props.value} onChange={props.onChange} /> - `${value}%`} + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomeRangeWidget.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomeRangeWidget.test.tsx index 86deef594bd2..206a087844b9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomeRangeWidget.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/CustomeRangeWidget.test.tsx @@ -49,7 +49,7 @@ describe('Test Custom Range Widget', () => { const { getByTestId } = render(); const inputNumber = getByTestId('slider-input'); - fireEvent.change(inputNumber, { target: { value: '30%' } }); + fireEvent.change(inputNumber, { target: { value: '30' } }); expect(widgetProps.onChange).toHaveBeenCalledWith(30); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/ProfilerSettings.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/ProfilerSettings.test.tsx index e299eed9b30e..05d83d876fc9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/ProfilerSettings.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/ProfilerSettings/ProfilerSettings.test.tsx @@ -82,7 +82,7 @@ describe('ProfilerSettings profile sample minimum', () => { 'aria-valuemin', '1' ); - expect(screen.getByRole('slider')).toHaveAttribute('aria-valuemin', '1'); + expect(screen.getByRole('slider')).toHaveAttribute('min', '1'); }); // A 0 sample makes ingestion profile the full table, and configs saved diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.test.tsx index 1aeca49c3521..6e3b2b9f6f15 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.test.tsx @@ -97,6 +97,7 @@ jest.mock('../../../SchemaEditor/SchemaEditor', () => { .mockImplementation(({ onChange }: { onChange: (v: string) => void }) => ( @@ -288,8 +289,7 @@ describe('ProfilerSettingsModal partitioning round-trip', () => { }); it('should preserve partitioning when only the SQL query is edited', async () => { - // The SQL editor sits outside any `
`, so editing it never reaches the - // form's `onValuesChange` -- the path that masked the bug for fields that do. + // Editing SQL must preserve the partitioning loaded from the server. const payload = await renderAndSave(mockTableProfilerConfig, () => { fireEvent.click(screen.getByTestId('schema-editor')); }); @@ -405,7 +405,7 @@ describe('ProfilerSettingsModal profile-sample round-trip', () => { 'aria-valuemin', '1' ); - expect(screen.getByRole('slider')).toHaveAttribute('aria-valuemin', '1'); + expect(screen.getByRole('slider')).toHaveAttribute('min', '1'); }); it('should clamp a typed 0 percentage up to 1 on blur', async () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.tsx index 49c3f31b4d7e..7c7fe225e3b6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/Profiler/TableProfiler/ProfilerSettingsModal/ProfilerSettingsModal.tsx @@ -16,14 +16,25 @@ import Icon from '@ant-design/icons/lib/components/Icon'; import { Box, Button as CoreButton, + FormField, + FormItemLabel, Grid, + HintText, + HookForm, + NumberInput, SlideoutMenu, Toggle, + TreeSelect, Typography, } from '@openmetadata/ui-core-components'; -import { Button, Input, InputNumber, Select, TreeSelect } from 'antd'; -import Form from 'antd/lib/form'; -import { FormProps, List } from 'antd/lib/form/Form'; +import { Button, Input, Select } from 'antd'; +import { useFieldArray, useForm, useWatch } from 'react-hook-form'; +import { + getTreeSelectValue, + getTreeSelectValues, + toTreeSelectNodes, +} from '../../../../../utils/TreeSelect.utils'; + import { getLayoutGutter } from '../../../../../utils/common/layout.utils'; import { AxiosError } from 'axios'; @@ -45,7 +56,6 @@ import { INTERVAL_TYPE_OPTIONS, INTERVAL_UNIT_OPTIONS, MIN_PROFILE_SAMPLE, - PROFILER_MODAL_LABEL_STYLE, PROFILE_SAMPLE_OPTIONS, SUPPORTED_COLUMN_DATA_TYPE_FOR_INTERVAL, TIME_BASED_PARTITION, @@ -85,6 +95,25 @@ const SchemaEditor = withSuspenseFallback( const getPopupContainer = (triggerNode: HTMLElement) => triggerNode.parentElement ?? document.body; +type ProfilerFormValues = Pick< + ProfilerForm, + | 'enablePartitioning' + | 'partitionColumnName' + | 'partitionIntervalType' + | 'partitionIntegerRangeStart' + | 'partitionIntegerRangeEnd' + | 'partitionInterval' + | 'partitionIntervalUnit' + | 'profileSampleType' + | 'profileSamplePercentage' + | 'profileSampleRows' + | 'includeColumns' +> & { + sampleDataCount?: number; + // RHF needs object rows; saving unwraps them to the API's string list. + partitionValues: { value: string }[]; +}; + const ProfilerSettingsModal: React.FC = ({ tableId, columns, @@ -92,7 +121,24 @@ const ProfilerSettingsModal: React.FC = ({ onVisibilityChange, }) => { const { t } = useTranslation(); - const [form] = Form.useForm(); + const form = useForm({ + mode: 'onChange', + defaultValues: { + profileSampleType: ProfileSampleType.Percentage, + profileSamplePercentage: 100, + sampleDataCount: 50, + includeColumns: DEFAULT_INCLUDE_PROFILE, + partitionValues: [{ value: '' }], + }, + }); + const includeFields = useFieldArray({ + control: form.control, + name: 'includeColumns', + }); + const partitionFields = useFieldArray({ + control: form.control, + name: 'partitionValues', + }); const [isLoading, setIsLoading] = useState(false); const [isDataLoading, setIsDataLoading] = useState(true); @@ -151,10 +197,18 @@ const ProfilerSettingsModal: React.FC = ({ }, ]; - return metricsOptions; + return toTreeSelectNodes(metricsOptions); }, [columns]); - const partitionIntervalType = Form.useWatch(['partitionIntervalType'], form); + const fetchMetrics = useCallback( + async () => ({ nodes: metricsOptions }), + [metricsOptions] + ); + + const partitionIntervalType = useWatch({ + control: form.control, + name: 'partitionIntervalType', + }); const partitionColumnOptions = useMemo(() => { const partitionColumnOptions = columns.reduce((result, column) => { @@ -193,7 +247,8 @@ const ProfilerSettingsModal: React.FC = ({ const profileSampleType = staticConfig?.profileSampleType; const applyProfileSampleFields = () => { - form.setFieldsValue({ + form.reset({ + ...form.getValues(), profileSampleType, profileSamplePercentage: !isNil(profileSample) && @@ -222,7 +277,7 @@ const ProfilerSettingsModal: React.FC = ({ return col; }); - form.setFieldsValue({ includeColumns: includeColValue }); + form.reset({ ...form.getValues(), includeColumns: includeColValue }); handleStateChange({ includeCol: includeColValue, }); @@ -237,8 +292,12 @@ const ProfilerSettingsModal: React.FC = ({ enablePartition: partitioning.enablePartitioning || false, }); - form.setFieldsValue({ + form.reset({ + ...form.getValues(), ...partitioning, + partitionValues: partitioning.partitionValues?.map((value) => ({ + value, + })) ?? [{ value: '' }], }); }; @@ -249,7 +308,8 @@ const ProfilerSettingsModal: React.FC = ({ selectedProfileSampleType: profileSampleType, sampleDataCount, }); - form.setFieldsValue({ + form.reset({ + ...form.getValues(), sampleDataCount: sampleDataCount ?? initialState.sampleDataCount, }); @@ -282,8 +342,10 @@ const ProfilerSettingsModal: React.FC = ({ } }; - const getIncludesColumns = () => { - const includeCols = state.includeCol.filter( + const getIncludesColumns = ( + columns: ProfilerFormValues['includeColumns'] + ) => { + const includeCols = columns.filter( ({ columnName }) => !isUndefined(columnName) ); @@ -302,15 +364,15 @@ const ProfilerSettingsModal: React.FC = ({ }); }; - const handleSave: FormProps['onFinish'] = useCallback( - async (data: ProfilerForm) => { + const handleSave = useCallback( + async (data: ProfilerFormValues) => { const buildPartitioning = (): TableProfilerConfig['partitioning'] => { if (!state.enablePartition) { return undefined; } // Read straight from the form: the loaded config is pushed into the - // form with `setFieldsValue`, which never fires `onValuesChange`, so + // form with a reset, which never fires input callbacks, so // any state copy is stale until the user edits a partition field. const partitionData = pick( data, @@ -327,16 +389,17 @@ const ProfilerSettingsModal: React.FC = ({ ...partitionData, partitionValues: data.partitionIntervalType === PartitionIntervalTypes.ColumnValue - ? partitionData.partitionValues?.filter( - (value) => !isEmpty(value) - ) + ? partitionData.partitionValues + ?.map((item) => item.value) + .filter((value) => !isEmpty(value)) : undefined, enablePartitioning: state.enablePartition, }; }; const buildProfileConfig = (): TableProfilerConfig => { - const { excludeCol, sqlQuery, includeCol } = state; + const { excludeCol, sqlQuery } = state; + const includeCol = data.includeColumns; const { profileSamplePercentage, profileSampleRows, @@ -366,7 +429,7 @@ const ProfilerSettingsModal: React.FC = ({ } : undefined, includeColumns: !isEqual(includeCol, DEFAULT_INCLUDE_PROFILE) - ? getIncludesColumns() + ? getIncludesColumns(includeCol) : undefined, partitioning: buildPartitioning(), sampleDataCount, @@ -400,7 +463,7 @@ const ProfilerSettingsModal: React.FC = ({ setIsLoading(false); } }, - [state, getIncludesColumns] + [state, getIncludesColumns, onVisibilityChange, t, tableId] ); const handleCancel = useCallback(() => { @@ -431,31 +494,17 @@ const ProfilerSettingsModal: React.FC = ({ }); }, []); - const handleIncludeColumnsProfiler = useCallback( - (changedValues: Partial, data: ProfilerForm) => { - const { partitionIntervalType, enablePartitioning } = changedValues; - if (partitionIntervalType || !isNil(enablePartitioning)) { - form.setFieldsValue({ - partitionColumnName: undefined, - partitionIntegerRangeStart: undefined, - partitionIntegerRangeEnd: undefined, - partitionIntervalUnit: undefined, - partitionInterval: undefined, - partitionValues: [''], - }); - } - if (!isNil(enablePartitioning)) { - form.setFieldsValue({ - partitionIntervalType: undefined, - }); - } - - handleStateChange({ - includeCol: data.includeColumns, - }); - }, - [] - ); + const resetPartitionFields = (resetType: boolean) => { + form.setValue('partitionColumnName', undefined); + form.setValue('partitionIntegerRangeStart', undefined); + form.setValue('partitionIntegerRangeEnd', undefined); + form.setValue('partitionIntervalUnit', undefined); + form.setValue('partitionInterval', undefined); + partitionFields.replace([{ value: '' }]); + if (resetType) { + form.setValue('partitionIntervalType', undefined); + } + }; const handleChange = (field: keyof ProfilerSettingModalState) => @@ -502,7 +551,11 @@ const ProfilerSettingsModal: React.FC = ({ ) : ( -
+ @@ -510,84 +563,124 @@ const ProfilerSettingsModal: React.FC = ({ className="layout-column" data-testid="profile-sample-container" span={24}> - - className="profiler-settings-form new-form-style" - data-testid="configure-ingestion-container" - form={form} - initialValues={{ - profileSampleType: state?.selectedProfileSampleType, - profileSamplePercentage: state?.profileSample || 100, - sampleDataCount: state?.sampleDataCount, - }} - layout="vertical"> - - { + field.onChange(value); + handleProfileSampleType(value); + }} + /> + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + {state?.selectedProfileSampleType === ProfileSampleType.Percentage && ( - - - + + {({ field, fieldState }) => ( + + + { + field.onChange(value); + handleProfileSample(value); + }} + /> + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + )} - {state?.selectedProfileSampleType === ProfileSampleType.Rows && ( - - - + + {({ field, fieldState }) => ( + + + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + )} - - - - + + {({ field, fieldState }) => ( + + + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + +
= ({ - - autoComplete="off" - className="new-form-style" - form={form} - id="profiler-setting-form" - initialValues={{ - includeColumns: state?.includeCol, - partitionData: [''], - ...state?.data?.partitioning, - }} - layout="vertical" - name="includeColumnsProfiler" - onFinish={handleSave} - onValuesChange={handleIncludeColumnsProfiler}> - - {(fields, { add, remove }) => ( - <> -
-

- {`${t('label.include')}:`} -

-
-
1, - })} - data-testid="include-column-container"> - {fields.map(({ key, name, ...restField }) => ( - - - + <> +
+

+ {`${t('label.include')}:`} +

+
+
1, + })} + data-testid="include-column-container"> + {includeFields.fields.map(({ id }, name) => ( + + + + {({ field, fieldState }) => ( + + direction="col" + gap={2}> - + }}> + {({ field, fieldState }) => ( + + + {t('label.interval-type')} + + } + required={state.enablePartition} + /> + - + }}> + {({ field, fieldState }) => ( + + + {t('label.column-entity', { + entity: t('label.name'), + })} + + } + required={state.enablePartition} + /> + - + }}> + {({ field, fieldState }) => ( + + + {t('label.interval-unit')} + + } + required={state.enablePartition} + /> + { + field.onChange(value); + }} /> - -
-
+ ); return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.test.tsx index 026142ffd81b..7a05faadc38a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.test.tsx @@ -11,22 +11,25 @@ * limitations under the License. */ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { PipelineViewMode } from '../../../generated/configuration/lineageSettings'; import LineageConfigModal from './LineageConfigModal'; const onCancel = jest.fn(); const onSave = jest.fn(); +let user: ReturnType; const config = { upstreamDepth: 2, downstreamDepth: 3, - nodesPerLayer: 4, + nodesPerLayer: 6, pipelineViewMode: PipelineViewMode.Node, }; describe('LineageConfigModal', () => { beforeEach(() => { jest.clearAllMocks(); + user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); }); it('renders the modal with pre-populated values', async () => { @@ -81,13 +84,14 @@ describe('LineageConfigModal', () => { 'field-nodes-per-layer' ); - // Update values - fireEvent.change(fieldUpstream, { target: { value: '5' } }); - fireEvent.change(fieldDownstream, { target: { value: '6' } }); - fireEvent.change(fieldNodesPerLayer, { target: { value: '7' } }); + await user.clear(fieldUpstream); + await user.type(fieldUpstream, '5'); + await user.clear(fieldDownstream); + await user.type(fieldDownstream, '6'); + await user.clear(fieldNodesPerLayer); + await user.type(fieldNodesPerLayer, '7'); - // Submit form - fireEvent.click(screen.getByRole('button', { name: 'OK' })); + await user.click(screen.getByRole('button', { name: 'OK' })); await waitFor(() => { expect(onSave).toHaveBeenCalledWith({ @@ -110,13 +114,14 @@ describe('LineageConfigModal', () => { const fieldUpstream = await screen.findByTestId('field-upstream'); - // Try to set negative value - fireEvent.change(fieldUpstream, { target: { value: '-1' } }); + await user.clear(fieldUpstream); + await user.type(fieldUpstream, '-1'); // Submit form fireEvent.click(screen.getByRole('button', { name: 'OK' })); await waitFor(() => { + expect(fieldUpstream).toHaveAttribute('aria-invalid', 'true'); expect(onSave).not.toHaveBeenCalled(); }); }); @@ -134,12 +139,14 @@ describe('LineageConfigModal', () => { const fieldDownstream = await screen.findByTestId('field-downstream'); // Try to set negative value - fireEvent.change(fieldDownstream, { target: { value: '-1' } }); + await user.clear(fieldDownstream); + await user.type(fieldDownstream, '-1'); // Submit form fireEvent.click(screen.getByRole('button', { name: 'OK' })); await waitFor(() => { + expect(fieldDownstream).toHaveAttribute('aria-invalid', 'true'); expect(onSave).not.toHaveBeenCalled(); }); }); @@ -158,13 +165,14 @@ describe('LineageConfigModal', () => { 'field-nodes-per-layer' ); - // Try to set value less than minimum (5) - fireEvent.change(fieldNodesPerLayer, { target: { value: '4' } }); + await user.clear(fieldNodesPerLayer); + await user.type(fieldNodesPerLayer, '4'); // Submit form fireEvent.click(screen.getByRole('button', { name: 'OK' })); await waitFor(() => { + expect(fieldNodesPerLayer).toHaveAttribute('aria-invalid', 'true'); expect(onSave).not.toHaveBeenCalled(); }); }); @@ -185,10 +193,12 @@ describe('LineageConfigModal', () => { 'field-nodes-per-layer' ); - // Clear all fields - fireEvent.change(fieldUpstream, { target: { value: '' } }); - fireEvent.change(fieldDownstream, { target: { value: '' } }); - fireEvent.change(fieldNodesPerLayer, { target: { value: '' } }); + await user.clear(fieldUpstream); + await user.tab(); + await user.clear(fieldDownstream); + await user.tab(); + await user.clear(fieldNodesPerLayer); + await user.tab(); // Submit form fireEvent.click(screen.getByRole('button', { name: 'OK' })); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.tsx index 1737afd33383..3760349bb219 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/LineageConfigModal.tsx @@ -10,11 +10,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Form, InputNumber, Modal } from 'antd'; -import React from 'react'; +import { + Box, + FormField, + HookForm, + NumberInput, +} from '@openmetadata/ui-core-components'; +import { Modal } from 'antd'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { VALIDATION_MESSAGES } from '../../../constants/constants'; -import { LineageConfigModalProps } from './EntityLineage.interface'; +import { + LineageConfig, + LineageConfigModalProps, +} from './EntityLineage.interface'; const LineageConfigModal: React.FC = ({ visible, @@ -23,7 +31,38 @@ const LineageConfigModal: React.FC = ({ onSave, }) => { const { t } = useTranslation(); - const [form] = Form.useForm(); + const form = useForm({ + defaultValues: { + upstreamDepth: config.upstreamDepth, + downstreamDepth: config.downstreamDepth, + nodesPerLayer: config.nodesPerLayer, + }, + mode: 'onChange', + }); + const handleSubmit = form.handleSubmit((values) => onSave(values)); + const fields = [ + { + name: 'upstreamDepth', + label: t('label.upstream-depth'), + minimum: 0, + tooltip: t('message.upstream-depth-tooltip'), + testId: 'field-upstream', + }, + { + name: 'downstreamDepth', + label: t('label.downstream-depth'), + minimum: 0, + tooltip: t('message.downstream-depth-tooltip'), + testId: 'field-downstream', + }, + { + name: 'nodesPerLayer', + label: t('label.nodes-per-layer'), + minimum: 5, + tooltip: t('message.nodes-per-layer-tooltip'), + testId: 'field-nodes-per-layer', + }, + ] as const; return ( = ({ open={visible} title={t('label.lineage-config')} onCancel={onCancel} - onOk={form.submit}> -
- - - - - - - - - - - -
+ onOk={handleSubmit}> + + + {fields.map(({ name, label, minimum, tooltip, testId }) => ( + + {({ field, fieldState }) => ( + + )} + + ))} + +
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/APIEndpointSummary/APIEndpointSummary.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/APIEndpointSummary/APIEndpointSummary.tsx index 62333499501e..ebce5e3028a5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/APIEndpointSummary/APIEndpointSummary.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/EntitySummaryPanel/APIEndpointSummary/APIEndpointSummary.tsx @@ -11,8 +11,13 @@ * limitations under the License. */ -import { Grid, Typography } from '@openmetadata/ui-core-components'; -import { Radio, RadioChangeEvent } from 'antd'; +import { + ButtonGroup, + ButtonGroupItem, + Grid, + Typography, +} from '@openmetadata/ui-core-components'; + import { isEmpty } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -77,10 +82,6 @@ const APIEndpointSummary = ({ } }, [entityDetails]); - const handleViewChange = (e: RadioChangeEvent) => { - setViewType(e.target.value); - }; - useEffect(() => { if (entityDetails.service?.type === 'apiService') { fetchApiEndpointDetails(); @@ -92,14 +93,20 @@ const APIEndpointSummary = ({ className="layout-row layout-grid p-md border-radius-card" style={getLayoutGutter(0, 8)}> - - + + setViewType([...keys][0] as SchemaViewType) + }> + {t('label.request')} - - + + {t('label.response')} - - + + {isEmpty(activeSchema?.schemaFields) ? ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/EntitySeachSettings/EntitySearchSettings.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/EntitySeachSettings/EntitySearchSettings.tsx index 6597d9554724..592df16d751b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/EntitySeachSettings/EntitySearchSettings.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/EntitySeachSettings/EntitySearchSettings.tsx @@ -19,10 +19,11 @@ import { Badge, Box, Grid, + NumberInput, Toggle, Typography, } from '@openmetadata/ui-core-components'; -import { InputNumber, Select } from 'antd'; +import { Select } from 'antd'; import { AxiosError } from 'axios'; import { isEmpty, omit, startCase } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; @@ -729,8 +730,9 @@ const EntitySearchSettings = () => { {t('label.weight')} - handleRankingStageWeightChange( @@ -858,8 +860,9 @@ const EntitySearchSettings = () => { {t('label.max')} - diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldConfiguration/FieldConfiguration.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldConfiguration/FieldConfiguration.tsx index bdfeae0ed0f2..288c89ac12a4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldConfiguration/FieldConfiguration.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldConfiguration/FieldConfiguration.tsx @@ -19,12 +19,13 @@ import { Badge, Box, Divider, + Slider, Toggle, Tooltip, TooltipTrigger, Typography, } from '@openmetadata/ui-core-components'; -import { Button, Select, Slider } from 'antd'; +import { Button, Select } from 'antd'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as Delete } from '../../../assets/svg/delete-colored.svg'; @@ -209,12 +210,14 @@ const FieldConfiguration: React.FC = ({
+ handleWeightChange(Array.isArray(value) ? value[0] : value) + } />
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldValueBoostModal/FieldValueBoostModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldValueBoostModal/FieldValueBoostModal.tsx index 59171100f08d..3db0321508b4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldValueBoostModal/FieldValueBoostModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/FieldValueBoostModal/FieldValueBoostModal.tsx @@ -10,8 +10,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Typography } from '@openmetadata/ui-core-components'; -import { Form, Input, Modal, Select, Slider } from 'antd'; +import { Slider, Typography } from '@openmetadata/ui-core-components'; +import { Form, Input, Modal, Select } from 'antd'; import { useForm } from 'antd/lib/form/Form'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -156,12 +156,14 @@ const FieldValueBoostModal: React.FC = ({
+ handleFactorChange(Array.isArray(value) ? value[0] : value) + } />
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/TermBoost/TermBoost.tsx b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/TermBoost/TermBoost.tsx index 0022f1bc386b..f5077cb9dfd4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/TermBoost/TermBoost.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/SearchSettings/TermBoost/TermBoost.tsx @@ -11,8 +11,13 @@ * limitations under the License. */ import Icon, { DownOutlined } from '@ant-design/icons'; -import { Box, Divider, Typography } from '@openmetadata/ui-core-components'; -import { Button, Slider } from 'antd'; +import { + Box, + Divider, + Slider, + Typography, +} from '@openmetadata/ui-core-components'; +import { Button } from 'antd'; import { AxiosError } from 'axios'; import classNames from 'classnames'; import { ComponentProps, useEffect, useState } from 'react'; @@ -163,12 +168,14 @@ const TermBoostComponent: React.FC = ({
+ handleBoostChange(Array.isArray(value) ? value[0] : value) + } />
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.test.tsx index b6a2649eb9a6..9d705c5d436a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.test.tsx @@ -10,94 +10,80 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; +import { TeamType } from '../../../../generated/entity/teams/teamHierarchy'; import { getTeamsHierarchy } from '../../../../rest/teamsAPI'; import TeamsSelectable from './TeamsSelectable'; -const mockSelChange = jest.fn(); - -const mockProps = { - onSelectionChange: mockSelChange, -}; - -jest.mock('antd', () => ({ - TreeSelect: jest.fn().mockImplementation(({ onChange }) => ( -
onChange([])}> - TreeSelect.component -
- )), -})); - jest.mock('../../../../rest/teamsAPI', () => ({ - getTeamsHierarchy: jest.fn().mockImplementation(() => - Promise.resolve({ - data: [], - }) - ), + getTeamsHierarchy: jest.fn(), })); - jest.mock('../../../../utils/EntityNameUtils', () => ({ - getEntityName: jest.fn().mockImplementation(() => 'entityName'), + getEntityName: () => 'entityName', })); -describe('TeamsSelectable component test', () => { - it('TeamsSelectable component should render properly', async () => { - const { findByText } = render(, { - wrapper: MemoryRouter, - }); +describe('TeamsSelectable', () => { + beforeEach(() => { + (getTeamsHierarchy as jest.Mock).mockResolvedValue({ data: [] }); + }); + + it('renders a searchable team picker', async () => { + render(, { wrapper: MemoryRouter }); - const treeSelect = await findByText('TreeSelect.component'); + expect(screen.getByRole('combobox')).toBeInTheDocument(); - expect(treeSelect).toBeInTheDocument(); + await waitFor(() => expect(getTeamsHierarchy).toHaveBeenCalled()); }); - it('TeamsSelectable component should fire selection change', async () => { - const { findByText } = render(, { + it('returns team references when selecting and clearing a group', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + const onSelectionChange = jest.fn(); + (getTeamsHierarchy as jest.Mock).mockResolvedValue({ + data: [ + { + id: 'team-id', + name: 'Team', + teamType: TeamType.Group, + isJoinable: true, + }, + ], + }); + render(, { wrapper: MemoryRouter, }); + await user.click(screen.getByRole('combobox')); + await user.click(await screen.findByText('entityName')); - const treeSelect = await findByText('TreeSelect.component'); + expect(onSelectionChange).toHaveBeenLastCalledWith([ + { id: 'team-id', displayName: 'entityName', type: 'team' }, + ]); - fireEvent.click( - treeSelect, - new MouseEvent('click', { - bubbles: true, - cancelable: true, - }) + await user.click( + screen.getByRole('button', { name: /remove.*entityName/i }) ); - expect(treeSelect).toBeInTheDocument(); - expect(mockSelChange).toHaveBeenCalled(); + expect(onSelectionChange).toHaveBeenLastCalledWith([]); }); - it('should show no teams alert when API returns no data', async () => { - await act(async () => { - render(, { - wrapper: MemoryRouter, - }); - }); + it('shows an empty-team message after an empty response', async () => { + render(, { wrapper: MemoryRouter }); - const noTeamsAlert = screen.getByRole('alert'); - - expect(noTeamsAlert).toBeInTheDocument(); + expect( + await screen.findByText('message.no-entity-data-available') + ).toBeInTheDocument(); }); - it('should not show no teams alert when API returns teams data', async () => { - (getTeamsHierarchy as jest.Mock).mockImplementation(() => - Promise.resolve({ - data: [{ name: 'testTeam' }], - }) - ); - await act(async () => { - render(, { - wrapper: MemoryRouter, - }); + it('keeps the empty-team message hidden when data exists', async () => { + (getTeamsHierarchy as jest.Mock).mockResolvedValue({ + data: [{ id: 'team-id', name: 'Team', teamType: TeamType.Group }], }); + render(, { wrapper: MemoryRouter }); + await waitFor(() => expect(getTeamsHierarchy).toHaveBeenCalled()); - const noTeamsAlert = screen.queryByRole('alert'); - - expect(noTeamsAlert).toBeNull(); + expect( + screen.queryByText('message.no-entity-data-available') + ).not.toBeInTheDocument(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.tsx index 31ecf3e0a135..27a67a9f29a8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.tsx @@ -11,12 +11,16 @@ * limitations under the License. */ -import { Alert } from '@openmetadata/ui-core-components'; -import { TreeSelect } from 'antd'; +import { + Alert, + TreeSelect, + TreeSelectNode, +} from '@openmetadata/ui-core-components'; + import { AxiosError } from 'axios'; import { isEmpty } from 'lodash'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { TeamHierarchy } from '../../../../generated/entity/teams/teamHierarchy'; import { EntityReference } from '../../../../generated/entity/type'; @@ -41,7 +45,13 @@ const TeamsSelectable = ({ const [teams, setTeams] = useState>([]); const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); - const onChange = (newValue: { label: string; value: string }[]) => { + const onChange = (value: TreeSelectNode | TreeSelectNode[] | null) => { + const newValue: TreeSelectNode[] = []; + if (Array.isArray(value)) { + newValue.push(...value); + } else if (value) { + newValue.push(value); + } onSelectionChange && onSelectionChange( newValue.map( @@ -78,15 +88,19 @@ const TeamsSelectable = ({ loadOptions(); }, []); - const showLeafIcon = false; - const teamsTree = useMemo( () => buildTeamsSelectableTree(teams, filterJoinable), [teams, filterJoinable] ); + const fetchData = useCallback( + async () => ({ nodes: teamsTree }), + [teamsTree] + ); + const selectedTeamsInternal = useMemo(() => { return selectedTeams?.map((selectedTeam) => ({ + id: selectedTeam.id, label: getEntityName(selectedTeam), value: selectedTeam.id, })); @@ -95,22 +109,15 @@ const TeamsSelectable = ({ return ( <> triggerNode.parentElement} - loading={isLoading} - maxTagCount={maxValueCount} + fetchData={fetchData} + isLoading={isLoading} + maxVisibleItems={maxValueCount} placeholder={placeholder} - showCheckedStrategy={TreeSelect.SHOW_CHILD} - style={{ width: '100%' }} - treeData={teamsTree} - treeLine={{ showLeafIcon }} - treeNodeFilterProp="title" + showCheckbox={false} + showIcon={false} value={selectedTeamsInternal} onChange={onChange} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.utils.test.ts b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.utils.test.ts index 49d1ee27b29e..f20d6e1545f1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.utils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.utils.test.ts @@ -25,13 +25,13 @@ const groupTeam = (id: string, isJoinable = true): TeamHierarchy => ({ }); describe('buildTeamsSelectableTree', () => { - it('should mark Group teams as selectable', () => { + it('should mark Group teams as allowSelection', () => { const tree = buildTeamsSelectableTree([groupTeam('group-1')]); expect(tree).toHaveLength(1); expect(tree[0]).toMatchObject({ value: 'group-1', - selectable: true, + allowSelection: true, disabled: false, }); }); @@ -60,7 +60,7 @@ describe('buildTeamsSelectableTree', () => { expect(tree).toHaveLength(0); }); - it('should keep non-Group branches leading to Group teams as non-selectable', () => { + it('should keep non-Group branches leading to Group teams as non-allowSelection', () => { const tree = buildTeamsSelectableTree([ { id: 'division-1', @@ -83,15 +83,15 @@ describe('buildTeamsSelectableTree', () => { ]); expect(tree).toHaveLength(1); - expect(tree[0].selectable).toBe(false); + expect(tree[0].allowSelection).toBe(false); expect(tree[0].children).toHaveLength(1); expect(tree[0].children?.[0]).toMatchObject({ value: 'department-1', - selectable: false, + allowSelection: false, }); expect(tree[0].children?.[0].children?.[0]).toMatchObject({ value: 'group-1', - selectable: true, + allowSelection: true, }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.utils.ts b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.utils.ts index d66a25bead70..67d7407b20a8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.utils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectable.utils.ts @@ -10,6 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { TreeSelectNode } from '@openmetadata/ui-core-components'; import { isEmpty } from 'lodash'; import { ChildElement, @@ -18,13 +19,7 @@ import { } from '../../../../generated/entity/teams/teamHierarchy'; import { getEntityName } from '../../../../utils/EntityNameUtils'; -export interface TeamsSelectableTreeNode { - title: string; - value: string; - selectable: boolean; - disabled: boolean; - children?: TeamsSelectableTreeNode[]; -} +export type TeamsSelectableTreeNode = TreeSelectNode; const getTreeNodeData = ( team: TeamHierarchy | ChildElement, @@ -43,10 +38,12 @@ const getTreeNodeData = ( } return { - title: getEntityName(team), + id: team.id, + label: getEntityName(team), value: team.id, - selectable: isGroupTeam, - disabled: filterJoinable ? !team.isJoinable : false, + allowSelection: isGroupTeam, + // Branches must remain expandable even when they cannot have direct members. + disabled: isGroupTeam && filterJoinable ? !team.isJoinable : false, children: isEmpty(children) ? undefined : children, }; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectableNew.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectableNew.tsx index a7e165416f11..0223ed05ace0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectableNew.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Team/TeamsSelectable/TeamsSelectableNew.tsx @@ -11,35 +11,26 @@ * limitations under the License. */ -import { Alert } from '@openmetadata/ui-core-components'; -import { TreeSelect } from 'antd'; +import { + Alert, + TreeSelect, + TreeSelectNode, +} from '@openmetadata/ui-core-components'; + import { AxiosError } from 'axios'; import { isEmpty } from 'lodash'; -import { BaseSelectRef } from 'rc-select'; -import { forwardRef, useEffect, useMemo, useState } from 'react'; +import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { TeamHierarchy } from '../../../../generated/entity/teams/teamHierarchy'; import { getTeamsHierarchy } from '../../../../rest/teamsAPI'; import { getEntityName } from '../../../../utils/EntityNameUtils'; import i18n from '../../../../utils/i18next/LocalUtil'; import { showErrorToast } from '../../../../utils/ToastUtils'; -import { TagRenderer } from '../../../common/TagRenderer/TagRenderer'; import { TeamsSelectableProps } from './TeamsSelectable.interface'; import { buildTeamsSelectableTree } from './TeamsSelectable.utils'; -const renderMaxTagPlaceholder = ( - count: number, - t: ReturnType['t'] -) => ( - - {t('label.plus-count-more', { - count, - })} - -); - -const TeamsSelectableNew = forwardRef( +const TeamsSelectableNew = forwardRef( ( { showTeamsAlert, @@ -60,7 +51,13 @@ const TeamsSelectableNew = forwardRef( const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); - const onChange = (newValue: { label: string; value: string }[]) => { + const onChange = (value: TreeSelectNode | TreeSelectNode[] | null) => { + const newValue: TreeSelectNode[] = []; + if (Array.isArray(value)) { + newValue.push(...value); + } else if (value) { + newValue.push(value); + } onSelectionChange && onSelectionChange( newValue.map((val) => ({ @@ -88,51 +85,41 @@ const TeamsSelectableNew = forwardRef( loadOptions(); }, []); - const showLeafIcon = false; - const teamsTree = useMemo( () => buildTeamsSelectableTree(teams, filterJoinable), [teams, filterJoinable] ); + const fetchData = useCallback( + async () => ({ nodes: teamsTree }), + [teamsTree] + ); + const selectedTeamsInternal = useMemo(() => { return selectedTeams?.map((selectedTeam) => ({ + id: selectedTeam.id, label: getEntityName(selectedTeam), value: selectedTeam.id, })); }, [selectedTeams]); return ( - <> +
trigger.parentElement} - loading={isLoading} - maxTagCount={maxValueCount} - maxTagPlaceholder={(omittedValues) => - renderMaxTagPlaceholder(omittedValues.length, t) - } - open={open} + fetchData={fetchData} + isLoading={isLoading} + isOpen={open} + maxVisibleItems={maxValueCount} placeholder={placeholder} - placement="bottomLeft" - popupClassName="teams-custom-dropdown-class" - ref={ref} - showCheckedStrategy={TreeSelect.SHOW_CHILD} - style={{ width: '100%' }} - tagRender={TagRenderer} - treeData={teamsTree} - treeLine={{ showLeafIcon }} - treeNodeFilterProp="title" + popoverClassName="teams-custom-dropdown-class" + showCheckbox={false} + showIcon={false} value={selectedTeamsInternal} onChange={onChange} - onDropdownVisibleChange={handleDropdownChange} + onOpenChange={handleDropdownChange} /> {noTeam && ( ( variant="brand" /> )} - +
); } ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.component.tsx index ef6ef6649d49..80580245ca41 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.component.tsx @@ -11,8 +11,15 @@ * limitations under the License. */ -import { Box, Card, Grid, Typography } from '@openmetadata/ui-core-components'; -import { AutoComplete, Button, Form, Input, Select } from 'antd'; +import { + Box, + Card, + ComboBox, + Grid, + Select as CoreSelect, + Typography, +} from '@openmetadata/ui-core-components'; +import { Button, Form, Input, Select } from 'antd'; import { AxiosError } from 'axios'; import { debounce } from 'lodash'; import React, { useCallback, useMemo, useState } from 'react'; @@ -380,19 +387,37 @@ const AdminPermissionDebugger: React.FC = () => { - : null - } - options={userOptions} - placeholder={t('label.search-entity', { - entity: t('label.user'), - })} - style={{ maxWidth: 400 }} - onSearch={searchUsers} - onSelect={handleUserSelect} - /> +
+ {/* Keep the popup open while the debounced search replaces its empty collection. */} + : undefined + } + items={userOptions.map((option) => ({ + id: option.value, + label: option.label, + }))} + placeholder={t('label.search-entity', { + entity: t('label.user'), + })} + shortcut={false} + onInputChange={searchUsers} + onSelectionChange={(key) => { + if (key != null) { + handleUserSelect(String(key)); + } + }}> + {(item) => ( + + {item.label} + + )} + +
{selectedUsername && ( <> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.test.tsx index 1dcbbf23925d..f9e497b6a90b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/AdminPermissionDebugger/AdminPermissionDebugger.test.tsx @@ -11,10 +11,13 @@ * limitations under the License. */ -import { render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { HelmetProvider } from 'react-helmet-async'; import { MemoryRouter } from 'react-router-dom'; +import { getPermissionDebugInfo } from '../../../../rest/permissionAPI'; +import { searchQuery } from '../../../../rest/searchAPI'; import AdminPermissionDebugger from './AdminPermissionDebugger.component'; jest.mock('../../../../rest/searchAPI'); @@ -46,6 +49,42 @@ describe('AdminPermissionDebugger', () => { jest.clearAllMocks(); }); + it('keeps asynchronously returned users selectable after an empty initial search', async () => { + let resolveSearch!: (value: unknown) => void; + (searchQuery as jest.Mock).mockImplementation( + () => + new Promise((resolve) => { + resolveSearch = resolve; + }) + ); + (getPermissionDebugInfo as jest.Mock).mockResolvedValue({ + data: undefined, + }); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + renderWithRouter(); + await user.type(screen.getByRole('combobox'), 'visual'); + act(() => jest.advanceTimersByTime(300)); + await waitFor(() => expect(searchQuery).toHaveBeenCalled()); + await act(async () => + resolveSearch({ + hits: { + hits: [ + { _source: { name: 'visual_user', displayName: 'Visual User' } }, + ], + }, + }) + ); + const option = await screen.findByRole('option', { + name: 'Visual User (visual_user)', + }); + await user.click(option); + await waitFor(() => + expect(screen.getByText('visual_user')).toBeInTheDocument() + ); + + expect(getPermissionDebugInfo).toHaveBeenCalledWith('visual_user'); + }); + it('should render the component', () => { renderWithRouter(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx index b73ed7c03236..f3a06edce0c4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Users/CreateUser/CreateUser.component.tsx @@ -13,10 +13,18 @@ import { PlusOutlined } from '@ant-design/icons'; import Icon from '@ant-design/icons/lib/components/Icon'; -import { Box, Toggle } from '@openmetadata/ui-core-components'; -import { Button, Form, FormProps, Input, Radio, Select } from 'antd'; +import { + Box, + FormField, + FormItemLabel, + HintText, + HookForm, + RadioButton, + RadioGroup, + Toggle, +} from '@openmetadata/ui-core-components'; +import { Button, Input, Select } from 'antd'; import { AxiosError } from 'axios'; -import { TFunction } from 'i18next'; import { compact, debounce, @@ -27,13 +35,11 @@ import { uniqBy, } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useForm, UseFormReturn, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; import { ReactComponent as IconSync } from '../../../../assets/svg/ic-sync.svg'; -import { - AGGREGATE_PAGE_SIZE_LARGE, - VALIDATION_MESSAGES, -} from '../../../../constants/constants'; +import { AGGREGATE_PAGE_SIZE_LARGE } from '../../../../constants/constants'; import { EMAIL_REG_EX, passwordRegex, @@ -44,101 +50,104 @@ import { AuthType, CreatePasswordType, } from '../../../../generated/api/teams/createUser'; +import { JWTTokenExpiry } from '../../../../generated/entity/teams/user'; import { EntityReference } from '../../../../generated/entity/type'; import { AuthProvider } from '../../../../generated/settings/settings'; import { useApplicationStore } from '../../../../hooks/useApplicationStore'; import { useDomainStore } from '../../../../hooks/useDomainStore'; -import { - FieldProp, - FieldTypes, - FormItemLayout, -} from '../../../../interface/FormUtils.interface'; import { generateRandomPwd } from '../../../../rest/auth-API'; import { getAllPersonas } from '../../../../rest/PersonaAPI'; import { searchRoles } from '../../../../rest/rolesAPIV1'; import { getJWTTokenExpiryOptions } from '../../../../utils/BotsUtils'; import { getEntityName } from '../../../../utils/EntityNameUtils'; import { getEntityReferenceListFromEntities } from '../../../../utils/EntityReferenceUtils'; -import { getField } from '../../../../utils/formUtils'; import { showErrorToast } from '../../../../utils/ToastUtils'; import { AsyncSelect } from '../../../common/AsyncSelect/AsyncSelect'; import { AsyncSelectListProps } from '../../../common/AsyncSelect/AsyncSelectList.interface'; import CopyToClipboardButton from '../../../common/CopyToClipboardButton/CopyToClipboardButton'; import { DomainLabel } from '../../../common/DomainLabel/DomainLabel.component'; +import DomainSelect from '../../../common/DomainSelect/DomainSelect'; +import { DomainSelectTrigger } from '../../../common/DomainSelect/DomainSelectTrigger'; import InlineAlert from '../../../common/InlineAlert/InlineAlert'; import Loader from '../../../common/Loader/Loader'; +import RichTextEditor from '../../../common/RichTextEditor/RichTextEditor'; import TeamsSelectable from '../../Team/TeamsSelectable/TeamsSelectable'; import { CreateUserFormData, CreateUserProps } from './CreateUser.interface'; -// Pure builder for the domains FieldProp - no hook/prop dependency beyond its -// arguments, so it lives at module scope. -const buildDomainsField = ( - activeDomainEntityRef: EntityReference | undefined, - t: TFunction -): FieldProp => ({ - name: 'domains', - id: 'root/domains', - required: false, - label: t('label.domain-plural') as string, - type: FieldTypes.DOMAIN_SELECT, - props: { - selectedDomain: activeDomainEntityRef ? [activeDomainEntityRef] : undefined, - multiple: true, - children: ( - {' '} - {t('label.or-drag-and-drop-an-xml-file-here')} - - - - {t('message.upload-saml-metadata-xml-description')} - - - + hint={t('message.upload-saml-metadata-xml-description')} + input-data-testid="file-upload-input" + orDragAndDropLabel={t('label.or-drag-and-drop')} + onDropFiles={handleMetadataFileUpload} + onDropUnacceptedFiles={handleMetadataFileUpload} + /> )} {metadataUploadStatus !== null && ( BeforeUploadValueType | Promise; - onCSVUploaded: (event: ProgressEvent, file?: RcFile) => void; + onCSVUploaded: (event: ProgressEvent, file?: File) => void; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/UploadFile/UploadFile.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/UploadFile/UploadFile.test.tsx index f6338f441cbe..29940fd9ba21 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/UploadFile/UploadFile.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/UploadFile/UploadFile.test.tsx @@ -39,14 +39,12 @@ describe('UploadFile Component', () => { expect(screen.getByTestId('upload-file-widget')).toBeInTheDocument(); - const uploadIcon = screen.getByRole('button').querySelector('svg'); - - expect(uploadIcon).toHaveClass('tw:text-fg-quaternary'); - expect(uploadIcon).toHaveAttribute('stroke', 'currentColor'); - expect(uploadIcon).toHaveAttribute('width', '86'); - expect(uploadIcon).toHaveAttribute('height', '86'); + expect(screen.getByTestId('upload-file-input')).toHaveAttribute( + 'accept', + '.csv' + ); expect( - await screen.findByText(/message.drag-and-drop-or-browse-csv-files-here/) + screen.getByRole('button', { name: 'label.click-to-browse' }) ).toBeInTheDocument(); }); @@ -55,23 +53,23 @@ describe('UploadFile Component', () => { const uploadWidget = screen.getByTestId('upload-file-widget'); - expect(uploadWidget).toHaveAttribute('disabled'); + expect(uploadWidget).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByTestId('upload-file-input')).toBeDisabled(); }); it('should not render with disabled state', () => { render(); - const uploadWidget = screen.getByTestId('upload-file-widget'); - - expect(uploadWidget).not.toHaveAttribute('disabled'); + expect(screen.getByTestId('upload-file-input')).not.toBeDisabled(); }); it('should render with custom file type', () => { render(); - const uploadWidget = screen.getByTestId('upload-file-widget'); - - expect(uploadWidget).toHaveAttribute('accept', '.json,.xml'); + expect(screen.getByTestId('upload-file-input')).toHaveAttribute( + 'accept', + '.json,.xml' + ); }); it('should call onCSVUploaded when file is uploaded successfully', async () => { @@ -207,12 +205,12 @@ describe('UploadFile Component', () => { render(); expect( - await screen.findByText(/message.drag-and-drop-or-browse-csv-files-here/) + await screen.findByText(/label.click-to-browse/) ).toBeInTheDocument(); }); it('should render compact upload variant', async () => { - const { container } = render( + render( { /> ); + expect(screen.getByTestId('upload-file-widget')).toBeInTheDocument(); expect( - container.querySelector('.file-dragger-wrapper-compact') - ).toBeInTheDocument(); - expect( - await screen.findByText(/message.drop-csv-or-browse/) + await screen.findByText(/label.click-to-browse/) ).toBeInTheDocument(); expect(screen.getByText('Accepts .csv up to 10 MB')).toBeInTheDocument(); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/UploadFile/UploadFile.tsx b/openmetadata-ui/src/main/resources/ui/src/components/UploadFile/UploadFile.tsx index e8ecc107c881..394364fcaeee 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/UploadFile/UploadFile.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/UploadFile/UploadFile.tsx @@ -10,118 +10,81 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Box, Typography } from '@openmetadata/ui-core-components'; -import { UploadCloud01 } from '@openmetadata/ui-core-components/icons'; -import { UploadProps } from 'antd'; -import type { RcFile } from 'antd/lib/upload'; -import Dragger from 'antd/lib/upload/Dragger'; +import { FileUploadDropZone } from '@openmetadata/ui-core-components'; import { AxiosError } from 'axios'; -import type { UploadRequestOption } from 'rc-upload/lib/interface'; -import { FC, useCallback, useState } from 'react'; +import { FC, useCallback, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Transi18next } from '../../utils/i18next/LocalUtil'; import { showErrorToast } from '../../utils/ToastUtils'; import Loader from '../common/Loader/Loader'; -import './upload-file.less'; import { UploadFileProps } from './UploadFile.interface'; const UploadFile: FC = ({ acceptedFileDescription, disabled, fileType, - variant = 'default', beforeUpload, onCSVUploaded, }) => { const [uploading, setUploading] = useState(false); + const processing = useRef(false); const { t } = useTranslation(); - - const handleUpload: UploadProps['customRequest'] = useCallback( - (options: UploadRequestOption) => { + const handleUpload = useCallback( + async (files: FileList) => { + const file = files[0]; + if (!file || processing.current) { + return; + } + processing.current = true; setUploading(true); - const reader = new FileReader(); - reader.onload = (e) => { + const finish = () => { + processing.current = false; setUploading(false); - onCSVUploaded(e, options.file as RcFile); - }; - reader.onerror = () => { - setUploading(false); - showErrorToast(new Error(t('server.unexpected-error')) as AxiosError); }; try { - reader.readAsText(options.file as Blob); + const result = await beforeUpload?.(file, Array.from(files)); + if (result === false || typeof result === 'string') { + finish(); + + return; + } + const reader = new FileReader(); + reader.onload = (event) => { + finish(); + onCSVUploaded(event, file); + }; + reader.onerror = () => { + finish(); + showErrorToast(new Error(t('server.unexpected-error')) as AxiosError); + }; + // beforeUpload may transform the contents while callers still need the + // original file's name for the import preview and downloaded report. + reader.readAsText(result instanceof Blob ? result : file); } catch (error) { - setUploading(false); + finish(); showErrorToast(error as AxiosError); } }, - [onCSVUploaded, t] + [beforeUpload, onCSVUploaded, t] ); - if (uploading) { return ; } - const isCompact = variant === 'compact'; - return ( - - {isCompact ? ( -
- - - - - } - values={{ - text: t('label.click-to-browse'), - }} - /> - - - {acceptedFileDescription ?? - t('message.accepts-file-up-to-size', { - fileType, - size: '10 MB', - })} - -
- ) : ( - - - - } - values={{ - text: t('label.browse'), - }} - /> - - - )} -
+ hint={ + acceptedFileDescription ?? + t('message.accepts-file-up-to-size', { fileType, size: '10 MB' }) + } + input-data-testid="upload-file-input" + isDisabled={disabled} + orDragAndDropLabel={t('label.or-drag-and-drop')} + onDropFiles={handleUpload} + /> ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/UploadFile/upload-file.less b/openmetadata-ui/src/main/resources/ui/src/components/UploadFile/upload-file.less deleted file mode 100644 index b7dc6717f79c..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/UploadFile/upload-file.less +++ /dev/null @@ -1,99 +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 (reference) '../../styles/variables.less'; - -.file-dragger-wrapper { - padding: @padding-lg; - background: var(--tw-color-bg-primary); - .file-dragger-text { - font-weight: var(--om-font-weight-medium); - font-size: var(--om-font-size-md); - - .browse-text { - color: @primary-color; - } - } - .ant-upload-disabled { - .file-dragger-text, - .file-dragger-compact-title, - .file-dragger-compact-description { - color: @grey-300; - - .browse-text { - color: @grey-300; - } - } - } -} - -.file-dragger-wrapper-compact { - background: transparent; - padding: 0; - - &.ant-upload-wrapper .ant-upload-drag { - background: @grey-26; - border: 1.5px dashed @grey-15; - border-radius: var(--om-radius-xl); - transition: background var(--om-duration-180) ease, - border-color var(--om-duration-180) ease; - - &:hover { - background: @primary-50; - border-color: @primary-6; - } - } - - &.ant-upload-wrapper .ant-upload-drag .ant-upload { - padding: var(--om-space-38) var(--om-space-24); - } -} - -.file-dragger-compact-content { - align-items: center; - display: flex; - flex-direction: column; - gap: var(--om-space-8); - justify-content: center; - min-height: 136px; - text-align: center; -} - -.file-dragger-compact-icon { - align-items: center; - background: @primary-50; - border-radius: var(--om-radius-999); - color: @primary-6; - display: inline-flex; - height: 40px; - justify-content: center; - margin-bottom: var(--om-space-6); - width: 40px; -} - -.file-dragger-compact-title { - color: @grey-900; - font-size: var(--om-font-size-15); - font-weight: @font-semibold; - line-height: 22px; - - .browse-text { - color: @primary-6; - } -} - -.file-dragger-compact-description { - color: @grey-600; - font-size: var(--om-font-size-13); - font-weight: @font-regular; - line-height: 18px; -} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/EntityImport/EntityImport.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/EntityImport/EntityImport.test.tsx index cd6105566eea..205b2db0032b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/EntityImport/EntityImport.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/EntityImport/EntityImport.test.tsx @@ -128,7 +128,7 @@ describe('EntityImport component', () => { expect(uploadDragger).toBeInTheDocument(); await act(async () => { - fireEvent.change(uploadDragger, { target: { files: [file] } }); + fireEvent.change(screen.getByTestId('upload-file-input'), { target: { files: [file] } }); }); expect( @@ -195,7 +195,7 @@ describe('EntityImport component', () => { expect(uploadDragger).toBeInTheDocument(); await act(async () => { - fireEvent.change(uploadDragger, { target: { files: [file] } }); + fireEvent.change(screen.getByTestId('upload-file-input'), { target: { files: [file] } }); }); expect( @@ -260,7 +260,7 @@ describe('EntityImport component', () => { expect(uploadDragger).toBeInTheDocument(); await act(async () => { - fireEvent.change(uploadDragger, { target: { files: [file] } }); + fireEvent.change(screen.getByTestId('upload-file-input'), { target: { files: [file] } }); }); expect( @@ -312,7 +312,7 @@ describe('EntityImport component', () => { expect(uploadDragger).toBeInTheDocument(); await act(async () => { - fireEvent.change(uploadDragger, { target: { files: [file] } }); + fireEvent.change(screen.getByTestId('upload-file-input'), { target: { files: [file] } }); }); expect( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/TreeSelectWidget.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/TreeSelectWidget.test.tsx index b24530034e55..6b30aab04ecf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/TreeSelectWidget.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/TreeSelectWidget.test.tsx @@ -89,7 +89,9 @@ describe('Test TreeSelectWidget Component', () => { ); await act(async () => { - userEvent.click(treeSelectInput); + await userEvent + .setup({ advanceTimers: jest.advanceTimersByTime }) + .click(treeSelectInput); }); await waitFor(() => screen.getByText('Table')); @@ -115,13 +117,15 @@ describe('Test TreeSelectWidget Component', () => { ); await act(async () => { - userEvent.click(treeSelectInput); + await userEvent + .setup({ advanceTimers: jest.advanceTimersByTime }) + .click(treeSelectInput); }); - await waitFor(() => screen.getByText('All')); + await waitFor(() => screen.getByText('label.all')); await act(async () => { - fireEvent.click(screen.getByText('All')); + fireEvent.click(screen.getByText('label.all')); }); expect(mockOnChange).toHaveBeenCalledWith(['all']); @@ -147,12 +151,14 @@ describe('Test TreeSelectWidget Component', () => { ); await act(async () => { - userEvent.click(treeSelectInput); + await userEvent + .setup({ advanceTimers: jest.advanceTimersByTime }) + .click(treeSelectInput); }); await waitFor(() => screen.getByText('Table')); - expect(screen.getAllByText('All')).toHaveLength(1); + expect(screen.getAllByText('label.all')).toHaveLength(1); }); it('Should expand "all" into individual enum values when expandAllValue is true', async () => { @@ -180,13 +186,15 @@ describe('Test TreeSelectWidget Component', () => { ); await act(async () => { - userEvent.click(treeSelectInput); + await userEvent + .setup({ advanceTimers: jest.advanceTimersByTime }) + .click(treeSelectInput); }); - await waitFor(() => screen.getByText('All')); + await waitFor(() => screen.getByText('label.all')); await act(async () => { - fireEvent.click(screen.getByText('All')); + fireEvent.click(screen.getByText('label.all')); }); expect(mockOnChange).toHaveBeenCalledWith([ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/TreeSelectWidget.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/TreeSelectWidget.tsx index 65b955c075eb..ea3fc5fd79b0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/TreeSelectWidget.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/Form/JSONSchema/JsonSchemaWidgets/TreeSelectWidget.tsx @@ -10,13 +10,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import Icon from '@ant-design/icons/lib/components/Icon'; +import { TreeSelect } from '@openmetadata/ui-core-components'; import { WidgetProps } from '@rjsf/utils'; -import { TreeSelect } from 'antd'; import { startCase } from 'lodash'; -import { FC, ReactNode, useCallback, useMemo } from 'react'; -import { ReactComponent as ArrowIcon } from '../../../../../assets/svg/ic-arrow-down.svg'; -import { TEXT_BODY_COLOR } from '../../../../../constants/constants'; +import { FC, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + getTreeSelectValue, + getTreeSelectValues, + toTreeSelectNodes, +} from '../../../../../utils/TreeSelect.utils'; const ALL_VALUE = 'all'; @@ -26,78 +29,55 @@ const TreeSelectWidget: FC = ({ onChange, ...rest }) => { + const { t } = useTranslation(); const treeData = useMemo( - () => [ - { - title: 'All', - value: ALL_VALUE, - key: ALL_VALUE, - children: rest.options.enumOptions - ?.filter((node) => node.value !== ALL_VALUE) - .map((node) => ({ - title: startCase(node.label), - value: node.value, - key: node.value, - })), - }, - ], - [rest.options.enumOptions] - ); - - // When `expandAllValue` is set on the schema, the synthetic "all" parent - // node is a UI-only convenience: the backend enum does not include "all", - // so we expand it back into the full list of enum values before submit. - // Without this, selecting "All" would persist a value that violates the - // schema (e.g. MetadataExporterApp eventTypes). - const shouldExpandAll = Boolean(rest.schema.expandAllValue); - - const handleChange = useCallback( - (value: unknown) => { - if ( - shouldExpandAll && - Array.isArray(value) && - value.includes(ALL_VALUE) - ) { - const expanded = - rest.options.enumOptions?.map((option) => option.value) ?? []; - onChange(expanded); - - return; - } - - onChange(value); - }, - [onChange, shouldExpandAll, rest.options.enumOptions] + () => + toTreeSelectNodes([ + { + label: t('label.all'), + value: ALL_VALUE, + children: rest.options.enumOptions + ?.filter((node) => node.value !== ALL_VALUE) + .map((node) => ({ + label: startCase(node.label), + value: node.value, + })), + }, + ]), + [rest.options.enumOptions, t] ); + const fetchData = useCallback(async () => ({ nodes: treeData }), [treeData]); return ( - } - treeData={treeData} + defaultExpandedKeys={['all']} + disabled={rest.disabled || rest.readonly} + fetchData={fetchData} + id={rest.id} + placeholder={rest.placeholder} + required={rest.required} + selectedDisplayStrategy="parent" + value={getTreeSelectValue(treeData, rest.value)} onBlur={() => onBlur(rest.id, rest.value)} - onChange={handleChange} + onChange={(selection) => { + const values = getTreeSelectValues(selection); + // Some schemas expose All only as a UI convenience; never persist it + // when the backend enum requires the individual values instead. + onChange( + rest.schema.expandAllValue && values.includes(ALL_VALUE) + ? rest.options.enumOptions + ?.filter((option) => option.value !== ALL_VALUE) + .map((option) => option.value) ?? [] + : values + ); + }} onFocus={() => onFocus(rest.id, rest.value)} - {...rest}> - {rest.children as ReactNode} - + /> ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/SliderWithInput/SliderWithInput.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/SliderWithInput/SliderWithInput.tsx index ffe4f3be7ff7..5f1434d4c5ab 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/SliderWithInput/SliderWithInput.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/SliderWithInput/SliderWithInput.tsx @@ -11,11 +11,16 @@ * limitations under the License. */ -import { Box, ButtonUtility, Tooltip } from '@openmetadata/ui-core-components'; +import { + Box, + ButtonUtility, + NumberInput, + Slider, + Tooltip, +} from '@openmetadata/ui-core-components'; import { XClose } from '@openmetadata/ui-core-components/icons'; -import { InputNumber, Slider } from 'antd'; + import { useTranslation } from 'react-i18next'; -import { percentageFormatter } from '../../../utils/ChartUtils'; import { getLayoutGutter } from '../../../utils/common/layout.utils'; import { SliderWithInputProps } from './SliderWithInput.interface'; const SliderWithInput = ({ @@ -34,27 +39,30 @@ const SliderWithInput = ({ wrap="wrap"> `${value}%`} + maxValue={100} + minValue={min} value={value} - onChange={onChange} + onChange={(value) => + onChange(Array.isArray(value) ? value[0] : value) + } />
- + onChange(Array.isArray(value) ? value[0] : value) + } /> { const navigate = useNavigate(); const { t } = useTranslation(); - const [form] = Form.useForm(); + const form = useForm({ mode: 'onChange' }); const [activeField, setActiveField] = useState(''); const [loading, setLoading] = useState(false); const [updating, setUpdating] = useState(false); @@ -56,7 +59,7 @@ const EditLoginConfiguration = () => { const data = await getLoginConfig(); - form.setFieldsValue({ ...(data ?? {}) }); + form.reset(data ?? {}); } catch (error) { showErrorToast(error as AxiosError); } finally { @@ -87,46 +90,15 @@ const EditLoginConfiguration = () => { [] ); - const formFields: FieldProp[] = [ + const formFields = [ { - name: 'maxLoginFailAttempts', + name: 'maxLoginFailAttempts' as const, label: t('label.max-login-fail-attempt-plural'), - type: FieldTypes.NUMBER, - required: false, - id: 'root/maxLoginFailAttempts', - props: { - 'data-testid': 'maxLoginFailAttempts', - size: 'default', - style: { width: '100%' }, - autoFocus: true, - }, - rules: [{ min: 0, type: 'number' }], - }, - { - name: 'accessBlockTime', - label: t('label.access-block-time'), - type: FieldTypes.NUMBER, - required: false, - id: 'root/accessBlockTime', - props: { - 'data-testid': 'accessBlockTime', - size: 'default', - style: { width: '100%' }, - }, - rules: [{ min: 0, type: 'number' }], }, + { name: 'accessBlockTime' as const, label: t('label.access-block-time') }, { - name: 'jwtTokenExpiryTime', + name: 'jwtTokenExpiryTime' as const, label: t('label.jwt-token-expiry-time'), - type: FieldTypes.NUMBER, - required: false, - id: 'root/jwtTokenExpiryTime', - props: { - 'data-testid': 'jwtTokenExpiryTime', - size: 'default', - style: { width: '100%' }, - }, - rules: [{ min: 0, type: 'number' }], }, ]; @@ -153,26 +125,43 @@ const EditLoginConfiguration = () => { } }; - useEffect(() => { - fetchCustomLogoConfig(); - }, []); - const firstPanelChildren = ( <> -
{ - e.preventDefault(); - e.stopPropagation(); - setActiveField(e.target.id); - }}> - {generateFormFields(formFields)} + onSubmit={form.handleSubmit(handleSubmit)}> + {formFields.map(({ name, label }) => ( + + {({ field, fieldState }) => ( + field.onChange(value ?? undefined)} + onFocus={() => setActiveField('root/' + name)} + /> + )} + + ))} -
+ ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/EntityImport/BulkEntityImportPage/BulkEntityImportPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/EntityImport/BulkEntityImportPage/BulkEntityImportPage.tsx index 0e3742744a18..8a45bf11df48 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/EntityImport/BulkEntityImportPage/BulkEntityImportPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/EntityImport/BulkEntityImportPage/BulkEntityImportPage.tsx @@ -34,7 +34,7 @@ import { StopCircle, XClose, } from '@openmetadata/ui-core-components/icons'; -import type { RcFile } from 'antd/lib/upload'; + import { AxiosError } from 'axios'; import { capitalize, isEmpty, startCase } from 'lodash'; import { unparse } from 'papaparse'; @@ -827,7 +827,7 @@ const BulkEntityImportPage = () => { ); const handleLoadData = useCallback( - (e: ProgressEvent, file?: RcFile) => { + (e: ProgressEvent, file?: File) => { const result = String(e.target?.result ?? ''); setSelectedCsvFile({ diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/AddKPIPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/AddKPIPage.tsx index f65e50e102ff..33b0b25c3e1c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/AddKPIPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/KPIPage/AddKPIPage.tsx @@ -11,27 +11,30 @@ * limitations under the License. */ -import { Box, Grid, Typography } from '@openmetadata/ui-core-components'; import { - Button, - Form, - FormProps, - Input, - InputNumber, - Select, + Box, + FormField, + FormItemLabel, + Grid, + HintText, + HookForm, + NumberInput, Slider, -} from 'antd'; -import { useForm, useWatch } from 'antd/lib/form/Form'; + Typography, +} from '@openmetadata/ui-core-components'; +import { Button, Input, Select } from 'antd'; import { AxiosError } from 'axios'; import { isUndefined, kebabCase } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import DatePicker from '../../components/common/DatePicker/DatePicker'; import ResizablePanels from '../../components/common/ResizablePanels/ResizablePanels'; +import RichTextEditor from '../../components/common/RichTextEditor/RichTextEditor'; import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; import { ADD_KPI_BREADCRUMB } from '../../constants/Breadcrumb.constants'; -import { ROUTES, VALIDATION_MESSAGES } from '../../constants/constants'; +import { ROUTES } from '../../constants/constants'; import { KPI_DATE_PICKER_FORMAT } from '../../constants/DataInsight.constants'; import { TabSpecificField } from '../../enums/entity.enum'; import { @@ -40,11 +43,9 @@ import { } from '../../generated/api/dataInsight/kpi/createKpiRequest'; import { Kpi } from '../../generated/dataInsight/kpi/kpi'; import { withPageLayout } from '../../hoc/withPageLayout'; -import { FieldProp, FieldTypes } from '../../interface/FormUtils.interface'; import { getListKPIs, postKPI } from '../../rest/KpiAPI'; import { getLayoutGutter } from '../../utils/common/layout.utils'; import { getDisabledDates } from '../../utils/DataInsightPureUtils'; -import { getField } from '../../utils/formUtils'; import { translateWithNestedKeys } from '../../utils/i18next/LocalUtil'; import { filterChartOptions, @@ -52,16 +53,23 @@ import { KPIMetricTypeOptions, } from '../../utils/KPI/KPIUtils'; import { showErrorToast } from '../../utils/ToastUtils'; -import './kpi-page.less'; import { KPIFormValues } from './KPIPage.interface'; +import { normalizeKpiDates } from './KPIPage.utils'; const AddKPIPage = () => { const navigate = useNavigate(); const { t } = useTranslation(); - const [form] = useForm(); + const form = useForm({ + mode: 'onChange', + defaultValues: { + metricType: KpiTargetType.Percentage, + targetValue: 0, + description: '', + }, + }); const [isCreatingKPI, setIsCreatingKPI] = useState(false); const [kpiList, setKpiList] = useState>([]); - const metricType = useWatch('metricType', form); + const metricType = useWatch({ control: form.control, name: 'metricType' }); const chartOptions = useMemo(() => { return filterChartOptions(kpiList); @@ -81,33 +89,29 @@ const AddKPIPage = () => { const handleCancel = () => navigate(-1); - const handleFormValuesChange: FormProps['onValuesChange'] = ( - changedValues, - allValues + const handleFormValuesChange = ( + changes: Partial, + values: KPIFormValues ) => { - if (changedValues.startDate) { - const startDate = changedValues.startDate.startOf('day'); - form.setFieldsValue({ startDate }); - if (startDate > allValues.endDate) { - form.setFieldsValue({ - endDate: undefined, - }); - } + const dates = normalizeKpiDates(changes, values); + if ('startDate' in dates) { + form.setValue('startDate', dates.startDate ?? null, { + shouldDirty: true, + shouldValidate: true, + }); } - - if (changedValues.endDate) { - let endDate = changedValues.endDate.endOf('day'); - form.setFieldsValue({ endDate }); - if (endDate < allValues.startDate) { - endDate = changedValues.endDate.startOf('day'); - form.setFieldsValue({ - startDate: endDate, - }); - } + if ('endDate' in dates) { + form.setValue('endDate', dates.endDate ?? null, { + shouldDirty: true, + shouldValidate: true, + }); } }; - const handleSubmit: FormProps['onFinish'] = async (values) => { + const handleSubmit = async (values: KPIFormValues) => { + if (values.targetValue == null || !values.startDate || !values.endDate) { + return; + } const startDate = values.startDate.valueOf(); const endDate = values.endDate.valueOf(); @@ -118,7 +122,7 @@ const AddKPIPage = () => { const formData: CreateKpiRequest = { dataInsightChart: dataInsightChart, - description: values.description, + description: values.description ?? '', name: kebabCase(`${values.displayName ?? ''} ${metricType}`), displayName: values.displayName, startDate, @@ -138,33 +142,6 @@ const AddKPIPage = () => { } }; - const descriptionField: FieldProp = useMemo( - () => ({ - name: 'description', - required: true, - label: t('label.description'), - id: 'root/description', - type: FieldTypes.DESCRIPTION, - rules: [ - { - required: true, - message: t('label.field-required', { - field: t('label.description-kpi'), - }), - }, - ], - props: { - 'data-testid': 'description', - initialValue: '', - style: { - margin: 0, - }, - placeHolder: t('message.write-your-description'), - }, - }), - [] - ); - useEffect(() => { fetchKpiList(); }, []); @@ -199,179 +176,246 @@ const AddKPIPage = () => { entity: t('label.kpi-uppercase'), })} -
- + - - - - - + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + + + {({ field, fieldState }) => ( + + + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + + + {({ field, fieldState }) => ( + + + - - - - - - - - + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + + + {({ field, fieldState }) => ( + + + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + + + {({ field, fieldState }) => ( + + + - - {t('label.entity-lineage')} - - - {t('label.column-level-lineage')} - - - {t('label.data-observability')} - - - + + {({ field, fieldState }) => ( + + + + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} + - - - -
+ + {({ field, fieldState }) => ( + + + + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} + + + { }); const [isSaveLoading, setIsSaveLoading] = useState(false); + const form = useForm({ + mode: 'onChange', + defaultValues: { + ruleName: '', + resources: [], + operations: [], + ruleEffect: Effect.Allow, + condition: '', + }, + }); + const handleCancel = () => { navigate(policiesPath); }; @@ -91,26 +110,6 @@ const AddPolicyPage = () => { } }; - const descriptionField: FieldProp = useMemo( - () => ({ - name: 'description', - required: false, - label: `${t('label.description')}:`, - id: 'root/description', - type: FieldTypes.DESCRIPTION, - props: { - 'data-testid': 'description', - initialValue: '', - style: { - margin: 0, - }, - placeHolder: t('message.write-your-description'), - onTextChange: (value: string) => setDescription(value), - }, - }), - [] - ); - const translatedAddPolicyBreadcrumb = useMemo( () => ADD_POLICY_PAGE_BREADCRUMB.map((option) => ({ @@ -139,29 +138,45 @@ const AddPolicyPage = () => { entity: t('label.policy'), })} -
- + - setName(e.target.value)} + rules={NAME_FIELD_VALIDATION}> + {({ field, fieldState }) => ( + + + { + field.onChange(e); + setName(e.target.value); + }} + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} + + + + - - - {getField(descriptionField)} - + { } /> - - + { {t('label.create')} - +
), diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/AddRulePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/AddRulePage.tsx index a57fe5826ad1..2a5e591ce245 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/AddRulePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/AddRulePage.tsx @@ -11,12 +11,18 @@ * limitations under the License. */ -import { Box, Card, Typography } from '@openmetadata/ui-core-components'; -import { Button, Form } from 'antd'; +import { + Box, + Card, + HookForm, + Typography, +} from '@openmetadata/ui-core-components'; +import { Button } from 'antd'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { trim } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import Loader from '../../../components/common/Loader/Loader'; @@ -36,7 +42,7 @@ import { getSettingPath, } from '../../../utils/RouterUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; -import RuleForm from '../RuleForm/RuleForm'; +import RuleForm, { PolicyRuleFormValues } from '../RuleForm/RuleForm'; const policiesPath = getPath(GlobalSettingOptions.POLICIES); @@ -55,6 +61,17 @@ const AddRulePage = () => { effect: Effect.Allow, }); + const form = useForm({ + mode: 'onChange', + defaultValues: { + ruleName: '', + resources: [], + operations: [], + ruleEffect: Effect.Allow, + condition: '', + }, + }); + const breadcrumb = useMemo( () => [ { @@ -155,15 +172,16 @@ const AddRulePage = () => { data-testid="add-rule-title"> {t('label.add-new-entity', { entity: t('label.rule') })} -
- + onSubmit={form.handleSubmit(handleSubmit)}> + { {t('label.create')} - + diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/EditRulePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/EditRulePage.tsx index 954c0a8c00a8..0349812aca4e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/EditRulePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/PoliciesDetailPage/EditRulePage.tsx @@ -11,12 +11,18 @@ * limitations under the License. */ -import { Box, Card, Typography } from '@openmetadata/ui-core-components'; -import { Button, Form } from 'antd'; +import { + Box, + Card, + HookForm, + Typography, +} from '@openmetadata/ui-core-components'; +import { Button } from 'antd'; import { AxiosError } from 'axios'; import { compare } from 'fast-json-patch'; import { trim } from 'lodash'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import Loader from '../../../components/common/Loader/Loader'; @@ -36,7 +42,7 @@ import { getSettingPath, } from '../../../utils/RouterUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; -import RuleForm from '../RuleForm/RuleForm'; +import RuleForm, { PolicyRuleFormValues } from '../RuleForm/RuleForm'; const policiesPath = getPath(GlobalSettingOptions.POLICIES); @@ -59,6 +65,17 @@ const EditRulePage = () => { const selectedRuleRef = useRef(InitialData); + const form = useForm({ + mode: 'onChange', + defaultValues: { + ruleName: '', + resources: [], + operations: [], + ruleEffect: Effect.Allow, + condition: '', + }, + }); + const breadcrumb = useMemo( () => [ { @@ -94,6 +111,13 @@ const EditRulePage = () => { const selectedRule = data.rules.find((rule) => rule.name === ruleName); selectedRuleRef.current = selectedRule; setRuleData(selectedRule ?? InitialData); + form.reset({ + ruleName: selectedRule?.name ?? '', + resources: selectedRule?.resources ?? [], + operations: selectedRule?.operations ?? [], + ruleEffect: selectedRule?.effect ?? Effect.Allow, + condition: selectedRule?.condition ?? '', + }); } else { setPolicy({} as Policy); } @@ -175,20 +199,14 @@ const EditRulePage = () => { {t('label.edit-entity', { entity: t('label.rule') })}{' '} {`"${ruleName}"`} -
+ onSubmit={form.handleSubmit(handleSubmit)}> @@ -209,7 +227,7 @@ const EditRulePage = () => { {t('label.save')} - + diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.test.tsx index a209f8834fb0..55f21c6ed764 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.test.tsx @@ -11,10 +11,11 @@ * limitations under the License. */ +import { HookForm } from '@openmetadata/ui-core-components'; import { fireEvent, render, screen } from '@testing-library/react'; -import { Form } from 'antd'; +import { useForm } from 'react-hook-form'; import { Rule } from '../../../generated/api/policies/createPolicy'; -import RuleForm, { RuleFormProps } from './RuleForm'; +import RuleForm, { PolicyRuleFormValues, RuleFormProps } from './RuleForm'; jest.mock('../../../rest/rolesAPIV1', () => ({ getPolicyFunctions: jest.fn().mockImplementation(() => Promise.resolve()), @@ -50,11 +51,21 @@ const mockProps = { setRuleData, }; -const MockFormComponent = ({ ...mockProps }: RuleFormProps) => { +const MockFormComponent = ({ ...mockProps }: Omit) => { + const form = useForm({ + defaultValues: { + ruleName: mockProps.ruleData.name, + resources: mockProps.ruleData.resources, + operations: mockProps.ruleData.operations, + ruleEffect: mockProps.ruleData.effect, + condition: mockProps.ruleData.condition, + }, + }); + return ( -
- - + + + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.tsx index 6acab74de1c2..c917ca16ed2b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PoliciesPage/RuleForm/RuleForm.tsx @@ -11,14 +11,24 @@ * limitations under the License. */ -import { AutoComplete, Form, Input, Select, TreeSelect } from 'antd'; -import { BaseOptionType } from 'antd/lib/select'; +import { + Box, + ComboBox, + FormField, + FormItemLabel, + HintText, + Select as CoreSelect, + TreeSelect, +} from '@openmetadata/ui-core-components'; +import { Input, Select } from 'antd'; import { AxiosError } from 'axios'; import { capitalize, startCase, uniq, uniqBy } from 'lodash'; import { FC, useEffect, useMemo, useState } from 'react'; +import { UseFormReturn } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { EntityAttachmentProvider } from '../../../components/common/EntityDescription/EntityAttachmentProvider/EntityAttachmentProvider'; -import { NAME_FIELD_RULES } from '../../../constants/Form.constants'; +import RichTextEditor from '../../../components/common/RichTextEditor/RichTextEditor'; +import { NAME_FIELD_VALIDATION } from '../../../constants/Form.constants'; import { EntityType } from '../../../enums/entity.enum'; import { Effect, @@ -27,26 +37,40 @@ import { } from '../../../generated/api/policies/createPolicy'; import { ResourceDescriptor } from '../../../generated/entity/policies/accessControl/resourceDescriptor'; import { Function } from '../../../generated/type/function'; -import { FieldProp, FieldTypes } from '../../../interface/FormUtils.interface'; import { getPolicyFunctions, getPolicyResources, validateRuleCondition, } from '../../../rest/rolesAPIV1'; -import { getField } from '../../../utils/formUtils'; import { ALL_TYPE_RESOURCE_LIST } from '../../../utils/PermissionsUtils'; import { getErrorText } from '../../../utils/StringUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; +import { + getTreeSelectValue, + getTreeSelectValues, + toTreeSelectNodes, +} from '../../../utils/TreeSelect.utils'; const { Option } = Select; +export interface PolicyRuleFormValues { + ruleName: string; + resources: string[]; + operations: Operation[]; + ruleEffect: Effect; + condition?: string; + name?: string; +} + export interface RuleFormProps { + form: UseFormReturn; ruleData: Rule; setRuleData: (value: React.SetStateAction) => void; description?: string; } const RuleForm: FC = ({ + form, ruleData, setRuleData, description, @@ -58,9 +82,9 @@ const RuleForm: FC = ({ const [policyFunctions, setPolicyFunctions] = useState([]); - const [conditionOptions, setConditionOptions] = useState( - [] - ); + const [conditionOptions, setConditionOptions] = useState< + { value: string; label: string }[] + >([]); const [validationError, setValidationError] = useState(''); const [isValidatingCondition, setIsValidating] = useState(false); @@ -80,13 +104,13 @@ const RuleForm: FC = ({ key: 'all', children: resources.map((resource) => ({ title: startCase(resource.name), - value: resource.name, + value: resource.name ?? '', key: resource.name, })), }, ]; - return option; + return toTreeSelectNodes(option); }, [policyResources]); /** @@ -124,24 +148,38 @@ const RuleForm: FC = ({ }, ]; - return option; + return toTreeSelectNodes(option); }, [ruleData.resources, policyResources]); + const fetchResources = useMemo( + () => async () => ({ nodes: resourcesOptions }), + [resourcesOptions] + ); + const fetchOperations = useMemo( + () => async () => ({ nodes: operationOptions }), + [operationOptions] + ); + const getConditionOptions = (conditionFunctions: Function[]) => { - return conditionFunctions.reduce((prev: BaseOptionType[], curr) => { - const currentValues = (curr.examples || []).map((example: string) => ({ - label: example, - value: example, - })); + return conditionFunctions.reduce( + (prev: { value: string; label: string }[], curr) => { + const currentValues = (curr.examples || []).map((example: string) => ({ + label: example, + value: example, + })); - return uniqBy([...prev, ...currentValues], 'value'); - }, []); + return uniqBy([...prev, ...currentValues], 'value'); + }, + [] + ); }; const handleConditionSearch = (value: string) => { if (value) { - setConditionOptions((prev) => - prev.filter((condition) => condition.value.includes(value)) + setConditionOptions( + getConditionOptions(policyFunctions).filter((condition) => + condition.value.includes(value) + ) ); } else { setConditionOptions(getConditionOptions(policyFunctions)); @@ -196,27 +234,6 @@ const RuleForm: FC = ({ } }; - const descriptionField: FieldProp = useMemo( - () => ({ - name: 'ruleDescription', - required: false, - label: t('label.description'), - id: 'root/description', - type: FieldTypes.DESCRIPTION, - props: { - 'data-testid': 'description', - initialValue: description, - style: { - margin: 0, - }, - placeHolder: t('message.write-your-description'), - onTextChange: (value: string) => - setRuleData((prev: Rule) => ({ ...prev, description: value })), - }, - }), - [description, setRuleData] - ); - useEffect(() => { fetchPolicyResources(); fetchPolicyFunctions(); @@ -228,139 +245,215 @@ const RuleForm: FC = ({ return ( <> - - - setRuleData((prev: Rule) => ({ ...prev, name: e.target.value })) - } - /> - + rules={NAME_FIELD_VALIDATION}> + {({ field, fieldState }) => ( + + + { + field.onChange(e); + setRuleData((prev) => ({ ...prev, name: e.target.value })); + }} + /> + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + - {getField(descriptionField)} + + + + setRuleData((prev) => ({ ...prev, description: value })) + } + /> + - - { - setRuleData((prev: Rule) => ({ - ...prev, - resources: values, - })); - }} - /> - - + {({ field, fieldState }) => ( + + { + const values = getTreeSelectValues(selection); + field.onChange(values); + setRuleData((prev) => ({ + ...prev, + resources: values as string[], + })); + }} + /> + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + + - { - setRuleData((prev: Rule) => ({ - ...prev, - operations: values, - })); - }} - /> - - + {({ field, fieldState }) => ( + + { + const values = getTreeSelectValues(selection); + field.onChange(values); + setRuleData((prev) => ({ + ...prev, + operations: values as Operation[], + })); + }} + /> + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + + - - - - <> - { - setRuleData((prev: Rule) => ({ ...prev, condition: value })); - !value && setValidationError(''); - handleConditionValidation(value); - }} - onSearch={handleConditionSearch} - /> - {validationError && ( -
- {`❌ ${t('label.invalid-condition')} : ${validationError}`} -
- )} - {isValidatingCondition && ( -
- {t('label.validating-condition')} -
- )} - {isValidCondition && !isValidatingCondition && !validationError && ( -
- {`✅ ${t('label.valid-condition')}`} -
- )} - -
+ rules={{ + required: t('label.field-required', { + field: `${t('label.effect')}:`, + }), + }}> + {({ field, fieldState }) => ( + + + + {fieldState.error && ( + {fieldState.error.message} + )} + + )} + + + {({ field, fieldState }) => ( + + + <> + ({ + id: option.value, + label: option.label, + }))} + placeholder={t('label.condition')} + shortcut={false} + showSearchIcon={false} + onBlur={field.onBlur} + onInputChange={(value) => { + field.onChange(value); + setRuleData((prev) => ({ ...prev, condition: value })); + if (!value) { + setValidationError(''); + setIsValidCondition(false); + } + handleConditionSearch(value); + handleConditionValidation(value); + }}> + {(item) => ( + {item.label} + )} + + {validationError && ( +
+ {`❌ ${t('label.invalid-condition')} : ${validationError}`} +
+ )} + {isValidatingCondition && ( +
+ {t('label.validating-condition')} +
+ )} + {isValidCondition && + !isValidatingCondition && + !validationError && ( +
+ {`✅ ${t('label.valid-condition')}`} +
+ )} + + {fieldState.error && ( + {fieldState.error.message} + )} +
+ )} +
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.tsx index 07a8ddcced26..a9018a80c451 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerConfigurationPage/ProfilerConfigurationPage.tsx @@ -17,14 +17,19 @@ import { AccordionItem, AccordionPanel, Box, + FormField, Grid, + HintText, + HookForm, Toggle, + TreeSelect, Typography, } from '@openmetadata/ui-core-components'; -import { Button, Form, Select, TreeSelect } from 'antd'; +import { Button, Select } from 'antd'; import { AxiosError } from 'axios'; import { isEmpty, isEqual, values } from 'lodash'; import { Fragment, useEffect, useMemo, useState } from 'react'; +import { useFieldArray, useForm, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import Loader from '../../components/common/Loader/Loader'; @@ -52,11 +57,31 @@ import { import { getLayoutGutter } from '../../utils/common/layout.utils'; import { getSettingPageEntityBreadCrumb } from '../../utils/GlobalSettingsUtils'; import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; +import { + getTreeSelectValue, + getTreeSelectValues, + toTreeSelectNodes, +} from '../../utils/TreeSelect.utils'; import './profiler-configuration-page.style.less'; import profilerConfigurationClassBase from './ProfilerConfigurationClassBase'; const ProfilerConfigurationPage = () => { - const [form] = Form.useForm(); + const form = useForm({ + defaultValues: DEFAULT_PROFILER_CONFIG_VALUE, + mode: 'onChange', + }); + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'metricConfiguration', + }); + const metricsOptions = useMemo( + () => toTreeSelectNodes(PROFILER_METRICS_TYPE_OPTIONS), + [] + ); + const fetchMetrics = useMemo( + () => async () => ({ nodes: metricsOptions }), + [metricsOptions] + ); const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(true); const { t } = useTranslation(); @@ -71,9 +96,10 @@ const ProfilerConfigurationPage = () => { ); // Watchers - const selectedMetricConfiguration = Form.useWatch< - MetricConfigurationDefinition[] - >('metricConfiguration', form); + const selectedMetricConfiguration = useWatch({ + control: form.control, + name: 'metricConfiguration', + }); const dataTypeOptions = useMemo(() => { return values(DataType).map((value) => ({ @@ -141,7 +167,7 @@ const ProfilerConfigurationPage = () => { | ProfilerConfiguration | undefined; - form.setFieldsValue({ + form.reset({ metricConfiguration: isEmpty(configValue?.metricConfiguration) ? DEFAULT_PROFILER_CONFIG_VALUE.metricConfiguration : configValue?.metricConfiguration, @@ -169,13 +195,12 @@ const ProfilerConfigurationPage = () => {
- + + onSubmit={form.handleSubmit(handleSubmit)}> @@ -206,122 +231,149 @@ const ProfilerConfigurationPage = () => { /> - - {(fields, { add, remove }) => { - return ( - - - {t('label.data-type')} - * - - - {t('label.metric-type')} - - - {t('label.disable')} - - {fields.map(({ key, name }) => ( - - - - + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} + + + + + {({ field, fieldState }) => ( + <> + + - - - - - - - - - - - ); - }} - + +
+ +
+
+
@@ -372,21 +424,34 @@ const ProfilerConfigurationPage = () => { - - { - if (isSelected) { - form.setFieldValue( - ['sampleDataConfig', 'readSampleData'], - true - ); - } - }} - /> - + + {({ field, fieldState }) => ( + <> + + { + field.onChange(isSelected); + if (isSelected) { + form.setValue( + 'sampleDataConfig.readSampleData', + true, + { shouldDirty: true } + ); + } + }} + /> + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} +
@@ -414,11 +479,25 @@ const ProfilerConfigurationPage = () => { - - - + + {({ field, fieldState }) => ( + <> + + + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} +
@@ -445,7 +524,7 @@ const ProfilerConfigurationPage = () => { {sparkAgentConfigComponent} - + ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SearchSettingsPage/SearchSettingsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SearchSettingsPage/SearchSettingsPage.tsx index 1e6da5a345c1..2d8fa05c1e0b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/SearchSettingsPage/SearchSettingsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/SearchSettingsPage/SearchSettingsPage.tsx @@ -18,10 +18,11 @@ import { AccordionPanel, Box, Grid, + Slider, Toggle, Typography, } from '@openmetadata/ui-core-components'; -import { Button, Slider } from 'antd'; +import { Button } from 'antd'; import { AxiosError } from 'axios'; import { isEmpty } from 'lodash'; import { useEffect, useMemo, useState } from 'react'; @@ -696,17 +697,16 @@ const SearchSettingsPage = () => { className="layout-column tw:block" style={{ flex: 'auto' }}> `${t('label.semantic')}: ${val}`, - }} value={ searchConfig?.globalSettings?.semanticWeight ?? 0.4 } - onChange={(val: number) => { + onChange={(value) => { + const val = Array.isArray(value) ? value[0] : value; if (!searchConfig) { return; } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TreeSelect.utils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TreeSelect.utils.test.ts new file mode 100644 index 000000000000..9f2692a233bd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TreeSelect.utils.test.ts @@ -0,0 +1,39 @@ +/* + * 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 { + getTreeSelectValue, + getTreeSelectValues, + toTreeSelectNodes, +} from './TreeSelect.utils'; +const nodes = toTreeSelectNodes([ + { + value: 'all', + title: 'All', + children: [ + { value: 'a', title: 'A' }, + { value: 'b', title: 'B' }, + ], + }, +]); + +it('expands persisted parent selections and condenses checked descendants for the API', () => { + const selection = getTreeSelectValue(nodes, ['all']); + + expect(selection.map((node) => node.id)).toEqual(['all', 'a', 'b']); + expect(getTreeSelectValues(selection)).toEqual(['all']); +}); + +it('keeps independent child selections and clearing intact', () => { + expect(getTreeSelectValues(getTreeSelectValue(nodes, ['b']))).toEqual(['b']); + expect(getTreeSelectValues(null)).toEqual([]); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TreeSelect.utils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TreeSelect.utils.ts new file mode 100644 index 000000000000..cfd6f89971ae --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TreeSelect.utils.ts @@ -0,0 +1,75 @@ +/* + * 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 { TreeSelectNode } from '@openmetadata/ui-core-components'; + +export interface TreeOption { + value: string | number; + label?: string; + title?: string; + children?: TreeOption[]; + disabled?: boolean; +} + +export const toTreeSelectNodes = ( + options: TreeOption[], + parentId?: string +): TreeSelectNode[] => + options.map((option) => ({ + id: String(option.value), + value: String(option.value), + // Core uses string values; preserve numeric enums for the schema payload. + data: option.value, + label: option.label ?? option.title ?? String(option.value), + parentId, + disabled: option.disabled, + children: option.children + ? toTreeSelectNodes(option.children, String(option.value)) + : undefined, + })); + +export const getTreeSelectValue = ( + nodes: TreeSelectNode[], + values: (string | number)[] = [] +): TreeSelectNode[] => { + const selected = new Set(values); + const result: TreeSelectNode[] = []; + const visit = (items: TreeSelectNode[], parentSelected = false) => + items.forEach((node) => { + const isSelected = + parentSelected || + selected.has(node.data as string | number) || + selected.has((node.data as string | number) ?? node.value ?? node.id); + if (isSelected) { + result.push(node); + } + if (node.children) { + visit(node.children, isSelected); + } + }); + // Core tracks each checked descendant; expand persisted parent values so the + // synthetic All row and its children agree when the picker first opens. + visit(nodes); + + return result; +}; + +export const getTreeSelectValues = ( + selection: TreeSelectNode | TreeSelectNode[] | null +): (string | number)[] => { + const nodes = selection ? [selection].flat() : []; + const selectedIds = new Set(nodes.map((node) => node.id)); + + return nodes + .filter((node) => !node.parentId || !selectedIds.has(node.parentId)) + .map((node) => node.value ?? node.id); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/formUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/formUtils.tsx index 34b33f0c365b..5fb8b42e3ff7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/formUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/formUtils.tsx @@ -20,14 +20,7 @@ import { ToggleProps, Typography, } from '@openmetadata/ui-core-components'; -import { - Checkbox, - Form, - FormItemProps, - Input, - InputNumber, - Select, -} from 'antd'; +import { Form, FormItemProps, Input, Select } from 'antd'; import { RuleObject } from 'antd/lib/form'; import { AxiosError } from 'axios'; import classNames from 'classnames'; @@ -100,11 +93,7 @@ const FIELD_ELEMENT_RENDERERS: Partial< /> ), }), - [FieldTypes.NUMBER]: ({ props, id, placeholder }) => ({ - element: ( - - ), - }), + [FieldTypes.FILTER_PATTERN]: ({ props }) => ({ element: , }), @@ -119,10 +108,7 @@ const FIELD_ELEMENT_RENDERERS: Partial< formPropsPatch: { valuePropName: 'isSelected' }, }; }, - [FieldTypes.CHECK_BOX]: ({ props, id }) => ({ - element: , - formPropsPatch: { valuePropName: 'checked' }, - }), + [FieldTypes.SELECT]: ({ props, id }) => ({ element: