diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContracts.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContracts.spec.ts index 0347c083f812..ca177932a21d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContracts.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/DataContracts.spec.ts @@ -2249,6 +2249,88 @@ description: }); } }); + + test('Contract created from the form defaults to Draft', async ({ page }) => { + const { apiContext } = await getApiContext(page); + const table = new TableClass(); + await table.create(apiContext); + + try { + await redirectToHomePage(page); + await table.visitEntityPage(page); + await navigateToContractTab(page); + await clickAddContractButton(page); + + await expect(page.getByTestId('contract-entity-status')).toContainText( + 'Draft' + ); + + await page.getByTestId('contract-name').fill('draft_default_contract'); + + const createResponse = page.waitForResponse( + (response) => + response.url().endsWith('/api/v1/dataContracts') && + response.request().method() === 'POST' + ); + await page.getByTestId('save-contract-btn').click(); + const created = await createResponse; + + expect(created.ok()).toBe(true); + expect((await created.json()).entityStatus).toBe('Draft'); + + const statusCard = page.getByTestId('contract-status-card'); + const statusBadge = statusCard.getByText('Draft', { exact: true }); + + await expect(statusBadge).toBeVisible(); + // A Draft must not be painted with the green success palette. + await expect(statusBadge).toHaveClass(/utility-warning/); + await expect(statusBadge).not.toHaveClass(/utility-success/); + } finally { + await table.delete(apiContext); + } + }); + + test('Contract is created with the status picked in the form', async ({ + page, + }) => { + const { apiContext } = await getApiContext(page); + const table = new TableClass(); + await table.create(apiContext); + + try { + await redirectToHomePage(page); + await table.visitEntityPage(page); + await navigateToContractTab(page); + await clickAddContractButton(page); + + await page.getByTestId('contract-name').fill('approved_choice_contract'); + + await selectOption( + page, + page.getByTestId('contract-entity-status'), + 'Approved' + ); + + const createResponse = page.waitForResponse( + (response) => + response.url().endsWith('/api/v1/dataContracts') && + response.request().method() === 'POST' + ); + await page.getByTestId('save-contract-btn').click(); + const created = await createResponse; + + expect(created.ok()).toBe(true); + expect((await created.json()).entityStatus).toBe('Approved'); + + const statusCard = page.getByTestId('contract-status-card'); + const statusBadge = statusCard.getByText('Approved', { exact: true }); + + await expect(statusBadge).toBeVisible(); + await expect(statusBadge).toHaveClass(/utility-success/); + } finally { + await table.delete(apiContext); + } + }); }); entitiesWithDataContracts.forEach((EntityClass) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/AddDataContract/AddDataContract.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/AddDataContract/AddDataContract.test.tsx index e297b9fe9a79..9d99697e9d34 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/AddDataContract/AddDataContract.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/AddDataContract/AddDataContract.test.tsx @@ -96,6 +96,11 @@ jest.mock('../ContractDetailFormTab/ContractDetailFormTab', () => ({ + )), @@ -442,7 +447,7 @@ describe('AddDataContract', () => { type: EntityType.TABLE, }, semantics: undefined, // validSemantics - undefined when no semantics provided - entityStatus: EntityStatus.Approved, + entityStatus: EntityStatus.Draft, }) ); expect(showSuccessToast).toHaveBeenCalledWith( @@ -474,7 +479,7 @@ describe('AddDataContract', () => { type: EntityType.TABLE, }, semantics: undefined, // validSemantics - undefined when no semantics provided - entityStatus: EntityStatus.Approved, + entityStatus: EntityStatus.Draft, }) ); expect(showSuccessToast).toHaveBeenCalledWith( @@ -483,6 +488,43 @@ describe('AddDataContract', () => { expect(mockOnSave).toHaveBeenCalled(); }); + it('should send the status picked in the form instead of forcing Approved', async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByText('Change')); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('entity-status-change-btn')); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('save-contract-btn')); + }); + + expect((createContract as jest.Mock).mock.calls[0][0]).toEqual( + expect.objectContaining({ entityStatus: EntityStatus.InReview }) + ); + }); + + it('should default a newly created contract to Draft, never Approved', async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByText('Change')); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('save-contract-btn')); + }); + + const payload = (createContract as jest.Mock).mock.calls[0][0]; + + expect(payload.entityStatus).toBe(EntityStatus.Draft); + expect(payload.entityStatus).not.toBe(EntityStatus.Approved); + }); + it('should call updateContract for existing contract with JSON patch', async () => { render( { expect(mockOnSave).toHaveBeenCalled(); }); + it('should not patch entityStatus when editing without touching the status', async () => { + render( + + ); + + // Change an unrelated field; mockContract is already Approved, so a + // spurious /entityStatus op would silently rewrite an existing status. + await act(async () => { + fireEvent.click(screen.getByText('Change')); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('save-contract-btn')); + }); + + expect((updateContract as jest.Mock).mock.calls[0][1]).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ path: '/entityStatus' }), + ]) + ); + }); + + it('should patch entityStatus when the author changes the status', async () => { + render( + + ); + + await act(async () => { + fireEvent.click(screen.getByTestId('entity-status-change-btn')); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId('save-contract-btn')); + }); + + expect((updateContract as jest.Mock).mock.calls[0][1]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: '/entityStatus', + value: EntityStatus.InReview, + }), + ]) + ); + }); + it('should handle save errors gracefully', async () => { const mockError = new Error('Save failed'); (createContract as jest.Mock).mockRejectedValue(mockError); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/AddDataContract/AddDataContract.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/AddDataContract/AddDataContract.tsx index a47add6b8ea2..bb53b41393c3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/AddDataContract/AddDataContract.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/AddDataContract/AddDataContract.tsx @@ -26,13 +26,13 @@ import { ReactComponent as TableIcon } from '../../../assets/svg/table-outline.s import { ReactComponent as SLAIcon } from '../../../assets/svg/timeout.svg'; import { DataContractMode, + DEFAULT_DATA_CONTRACT_STATUS, EDataContractTab, } from '../../../constants/DataContract.constants'; import { CSMode } from '../../../enums/codemirror.enum'; import { EntityType } from '../../../enums/entity.enum'; import { DataContract, - EntityStatus, TermsOfUse, } from '../../../generated/entity/data/dataContract'; import { Table } from '../../../generated/entity/data/table'; @@ -264,7 +264,7 @@ const AddDataContract: React.FC<{ semantics: validSemantics, security: validSecurity, termsOfUse: termsOfUseContent, - entityStatus: EntityStatus.Approved, + entityStatus: formValues.entityStatus ?? DEFAULT_DATA_CONTRACT_STATUS, }); } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailFormTab/ContractDetailFormTab.entityStatus.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailFormTab/ContractDetailFormTab.entityStatus.test.tsx new file mode 100644 index 000000000000..94dbb9ce400c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailFormTab/ContractDetailFormTab.entityStatus.test.tsx @@ -0,0 +1,102 @@ +/* + * 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. + */ + +/* + * ContractDetailFormTab.test.tsx stubs `generateFormFields`, so it can only + * inspect the field descriptors. These cases render the real Ant Design control + * to assert what the author actually sees and can pick. + */ +import '@testing-library/jest-dom'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { DATA_CONTRACT_AUTHORING_STATUS_OPTIONS } from '../../../constants/DataContract.constants'; +import { EntityStatus } from '../../../generated/entity/data/dataContract'; +import { ContractDetailFormTab } from './ContractDetailFormTab'; + +jest.mock('../../../hooks/useEntityRules', () => ({ + useEntityRules: jest.fn().mockImplementation(() => ({ + entityRules: { + canAddMultipleUserOwners: true, + canAddMultipleTeamOwner: true, + }, + })), +})); + +const commonProps = { + buttonProps: { isNextVisible: true }, + onChange: jest.fn(), + onNext: jest.fn(), +}; + +const getStatusSelect = () => screen.getByTestId('contract-entity-status'); + +const openStatusDropdown = async () => { + await act(async () => { + fireEvent.mouseDown( + getStatusSelect().querySelector('.ant-select-selector') as Element + ); + }); +}; + +describe('ContractDetailFormTab entity status control', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should show Draft as the pre-selected status when creating a contract', () => { + render(); + + expect(getStatusSelect()).toHaveTextContent('label.draft'); + expect(getStatusSelect()).not.toHaveTextContent('label.approved'); + }); + + it('should show the contract status when editing an existing contract', () => { + render( + + ); + + expect(getStatusSelect()).toHaveTextContent('label.approved'); + }); + + it('should offer only Draft, In Review and Approved as authoring statuses', async () => { + render(); + + await openStatusDropdown(); + + const options = document.querySelectorAll('.ant-select-item-option'); + + // Compare against the authoring list itself rather than literal i18n keys, + // so renaming a key cannot fail this for a non-behavioural reason. Which + // statuses that list may contain is asserted in ContractDetailFormTab.test.tsx. + expect(Array.from(options).map((option) => option.textContent)).toEqual( + DATA_CONTRACT_AUTHORING_STATUS_OPTIONS.map(({ labelKey }) => labelKey) + ); + }); + + it('should report the picked status to the parent form', async () => { + render(); + + await openStatusDropdown(); + + await act(async () => { + fireEvent.click(screen.getByText('label.in-review')); + }); + + expect(commonProps.onChange).toHaveBeenCalledWith( + { entityStatus: EntityStatus.InReview }, + expect.anything() + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailFormTab/ContractDetailFormTab.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailFormTab/ContractDetailFormTab.test.tsx index b16389e66a2b..f9e7b9248be6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailFormTab/ContractDetailFormTab.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailFormTab/ContractDetailFormTab.test.tsx @@ -12,8 +12,12 @@ */ import '@testing-library/jest-dom'; import { act, fireEvent, render, screen } from '@testing-library/react'; -import { DataContract } from '../../../generated/entity/data/dataContract'; +import { + DataContract, + EntityStatus, +} from '../../../generated/entity/data/dataContract'; import { EntityReference } from '../../../generated/entity/type'; +import { FieldProp, FieldTypes } from '../../../interface/FormUtils.interface'; import { ContractDetailFormTab } from './ContractDetailFormTab'; jest.mock('../../../utils/formUtils', () => ({ @@ -38,6 +42,10 @@ jest.mock('react-i18next', () => ({ 'label.contract-detail-plural': 'Contract Details', 'message.contract-detail-plural-description': 'Enter contract details', 'label.next': 'Next', + 'label.status': 'Status', + 'label.draft': 'Draft', + 'label.in-review': 'In Review', + 'label.approved': 'Approved', }; return translations[key] || key; @@ -118,6 +126,31 @@ describe('ContractDetailFormTab', () => { expect(screen.getByText('Owners')).toBeInTheDocument(); }); + it('should render a status field so the author can pick the initial status', () => { + render(); + + expect(screen.getByText('Status')).toBeInTheDocument(); + }); + + it('should only offer authoring statuses, never Rejected/Archived/Unprocessed', () => { + const generateFormFields = jest.requireMock( + '../../../utils/formUtils' + ).generateFormFields; + + render(); + + const statusField = generateFormFields.mock.calls[0][0].find( + (field: FieldProp) => field.name === 'entityStatus' + ); + + expect(statusField.type).toBe(FieldTypes.SELECT); + expect(statusField.props.options).toEqual([ + { label: 'Draft', value: EntityStatus.Draft }, + { label: 'In Review', value: EntityStatus.InReview }, + { label: 'Approved', value: EntityStatus.Approved }, + ]); + }); + it('should set form values when initial values are provided', () => { const { container } = render( ({ label: t(labelKey), value }) + ); + const fields: FieldProp[] = [ { label: t('label.contract-title'), @@ -77,6 +85,20 @@ export const ContractDetailFormTab: React.FC<{ trigger: 'onUpdate', }, }, + { + label: t('label.status'), + id: 'entityStatus', + name: 'entityStatus', + type: FieldTypes.SELECT, + required: false, + props: { + 'data-testid': 'contract-entity-status', + options: entityStatusOptions, + placeholder: t('label.please-select-entity', { + entity: t('label.status'), + }), + }, + }, { label: t('label.description'), id: 'description', @@ -91,6 +113,12 @@ export const ContractDetailFormTab: React.FC<{ ]; useEffect(() => { + // Create mode has no initialValues, so the select would render blank and the + // payload would fall back silently. Seed it with the same Draft default. + form.setFieldsValue({ + entityStatus: initialValues?.entityStatus ?? DEFAULT_DATA_CONTRACT_STATUS, + }); + if (initialValues) { form.setFieldsValue({ name: getEntityName(initialValues), diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.test.tsx index 4631d0025b32..9065e914a0ac 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataContract/ContractDetailTab/ContractDetail.test.tsx @@ -25,6 +25,7 @@ import { DataContractMode } from '../../../constants/DataContract.constants'; import { ContractExecutionStatus, DataContract, + EntityStatus, } from '../../../generated/entity/data/dataContract'; import { Column } from '../../../generated/entity/data/table'; import { DataContractResult } from '../../../generated/entity/datacontract/dataContractResult'; @@ -46,9 +47,11 @@ jest.mock('@openmetadata/ui-core-components', () => ({ Badge: jest.fn(({ children }: { children?: React.ReactNode }) => ( {children} )), - BadgeWithIcon: jest.fn(({ children }: { children?: React.ReactNode }) => ( - {children} - )), + BadgeWithIcon: jest.fn( + ({ children, color }: { children?: React.ReactNode; color?: string }) => ( + {children} + ) + ), Box: jest.fn( ({ children, @@ -615,6 +618,45 @@ describe('ContractDetail', () => { }); }); + describe('Entity Status Badge', () => { + const renderWithStatus = (entityStatus?: EntityStatus) => { + render( + , + { wrapper: MemoryRouter } + ); + + return screen + .getByTestId('contract-status-card') + .querySelector('[data-color]'); + }; + + it.each([ + [EntityStatus.Draft, 'warning'], + [EntityStatus.InReview, 'purple'], + [EntityStatus.Approved, 'success'], + [EntityStatus.Rejected, 'error'], + [EntityStatus.Deprecated, 'gray'], + [EntityStatus.Archived, 'gray'], + ])('should render %s with the %s colour', (entityStatus, expectedColor) => { + const badge = renderWithStatus(entityStatus); + + expect(badge).toHaveTextContent(entityStatus); + expect(badge).toHaveAttribute('data-color', expectedColor); + }); + + it('should not paint a success colour when the status is missing', () => { + const badge = renderWithStatus(undefined); + + expect(badge).toHaveAttribute('data-color', 'gray'); + }); + }); + describe('Contract Actions', () => { it('should call onEdit when edit button is clicked', () => { render( 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 a5b389883172..d2c8c3ad4029 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 @@ -67,6 +67,7 @@ import { } from '../../../utils/DataContract/DataContractUtils'; import { formatDateTime } from '../../../utils/date-time/DateTimeUtils'; import { getEntityName } from '../../../utils/EntityNameUtils'; +import { getEntityStatusBadgeColor } from '../../../utils/EntityStatusUtils'; import { pruneEmptyChildren } from '../../../utils/TablePureUtils'; import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils'; import AlertBar from '../../AlertBar/AlertBar'; @@ -500,13 +501,13 @@ const ContractDetail: React.FC<{ orientation="vertical" /> - + {`${t('label.status')} : `} @@ -520,7 +521,7 @@ const ContractDetail: React.FC<{ /> - + {`${t('label.owner-plural')} : `} diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/DataContract.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/DataContract.constants.ts index 2d24433f873d..b659292a5540 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/DataContract.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/DataContract.constants.ts @@ -14,9 +14,33 @@ import type { BarProps } from 'recharts'; import { EntityReferenceFields } from '../enums/AdvancedSearch.enum'; import { EntityType } from '../enums/entity.enum'; +import { EntityStatus } from '../generated/entity/data/dataContract'; export const CONTRACT_DATE_TIME_FORMAT = 'MM/dd/yyyy, h:mma'; +/** + * A new contract starts as a Draft so it goes through review instead of being + * published on creation. The schema documents the same default, but that default + * is inert (jsonschema2pojo resolves the `$ref`ed `type/status.json` default of + * `Unprocessed` instead), so the client has to send the status explicitly. + */ +export const DEFAULT_DATA_CONTRACT_STATUS = EntityStatus.Draft; + +/** + * Statuses a human can author a contract into. `Rejected` is an outcome a reviewer + * produces by closing the approval task, `Archived`/`Deprecated` are end-of-life + * states, and `Unprocessed` is an internal sentinel — none of them is a state + * somebody writing a contract should be able to pick. + */ +export const DATA_CONTRACT_AUTHORING_STATUS_OPTIONS: { + labelKey: string; + value: EntityStatus; +}[] = [ + { labelKey: 'label.draft', value: EntityStatus.Draft }, + { labelKey: 'label.in-review', value: EntityStatus.InReview }, + { labelKey: 'label.approved', value: EntityStatus.Approved }, +]; + export enum DataContractMode { YAML, UI, diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityStatusUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/EntityStatusUtils.ts index c5ec4de99da9..17c89ae4d228 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityStatusUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityStatusUtils.ts @@ -11,6 +11,7 @@ * limitations under the License. */ +import { BadgeColors } from '@openmetadata/ui-core-components'; import { isNil } from 'lodash'; import { StatusType } from '../components/common/StatusBadge/StatusBadge.interface'; import { EntityStatus } from '../generated/entity/data/glossaryTerm'; @@ -29,6 +30,36 @@ export const getEntityStatusClass = (status: EntityStatus): StatusType => { return EntityStatusClass[status] ?? StatusType.Pending; }; +/** + * BadgeColors equivalent of EntityStatusClass, for the UntitledUI Badge family. + * Kept in the same palette as the legacy StatusBadge (`status-badge.less`) so a + * status looks the same in both stacks: approved green, draft/unprocessed + * yellow, in-review purple, rejected red, deprecated/archived grey. + */ +export const EntityStatusBadgeColor: Record = { + [EntityStatus.Approved]: 'success', + [EntityStatus.Draft]: 'warning', + [EntityStatus.Rejected]: 'error', + [EntityStatus.Deprecated]: 'gray', + [EntityStatus.InReview]: 'purple', + [EntityStatus.Unprocessed]: 'warning', + [EntityStatus.Archived]: 'gray', +}; + +/** + * Takes the enum's string values rather than the enum itself: quicktype re-emits + * EntityStatus per entity with no shared module, and TS string enums are nominal, + * so callers holding e.g. the dataContract copy could not otherwise pass it. + * + * Falls back to a neutral grey — an unknown or absent status must not be painted + * as a confident success. + */ +export const getEntityStatusBadgeColor = ( + status?: `${EntityStatus}` +): BadgeColors => { + return (status ? EntityStatusBadgeColor[status] : undefined) ?? 'gray'; +}; + export const isDeleted = (deleted: unknown): boolean => { return (deleted as string) === 'false' || deleted === false || isNil(deleted) ? false