diff --git a/openmetadata-spec/src/main/resources/json/schema/system/ui/page.json b/openmetadata-spec/src/main/resources/json/schema/system/ui/page.json index e833e76d910e..e063e81c1f98 100644 --- a/openmetadata-spec/src/main/resources/json/schema/system/ui/page.json +++ b/openmetadata-spec/src/main/resources/json/schema/system/ui/page.json @@ -37,7 +37,8 @@ "Directory", "File", "Spreadsheet", - "Worksheet" + "Worksheet", + "Service" ] } }, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/customizeDetail.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/customizeDetail.ts index 64d6669897ff..4c38e2a6311d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/customizeDetail.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/customizeDetail.ts @@ -80,6 +80,8 @@ export enum EntityTabs { SUBDOMAINS = 'subdomains', CONTRACT = 'contract', ER_DIAGRAM = 'erDiagram', + FILES = 'files', + SPREADSHEETS = 'spreadsheets', } export const TABLE_DEFAULT_TABS = [ @@ -246,3 +248,13 @@ export const GLOSSARY_TERM_DEFAULT_TABS = [ EntityTabs.ACTIVITY_FEED, EntityTabs.CUSTOM_PROPERTIES, ]; + +export const SERVICE_DEFAULT_TABS = [ + EntityTabs.INSIGHTS, + EntityTabs.DETAILS, + EntityTabs.DATA_Model, + EntityTabs.FILES, + EntityTabs.SPREADSHEETS, + EntityTabs.AGENTS, + EntityTabs.CONNECTION, +]; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Pagination.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Pagination.spec.ts index abd2acf954ad..81098f18c9f5 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Pagination.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Pagination.spec.ts @@ -241,12 +241,34 @@ test.describe('Pagination Tests', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { test('should test pagination on Service Databases page', async ({ page, }) => { - await page.goto(`/service/databaseServices/${databaseFqn}/databases`); - await testPaginationNavigation( - page, - '/api/v1/databases', - '[data-testid="service-children-table"]' - ); + const searchApiMatcher = (response: { url: () => string }) => + response.url().includes('/api/v1/search/query'); + + const page1ResponsePromise = page.waitForResponse(searchApiMatcher); + await page.goto(`/service/databaseServices/${databaseFqn}/details`); + await page.locator('[data-testid="service-children-table"]').waitFor({ + state: 'visible', + }); + const page1Response = await page1ResponsePromise; + expect(page1Response.status()).toBe(200); + await waitForAllLoadersToDisappear(page); + + await expect(page.getByTestId('previous')).toBeDisabled(); + const nextButton = page.getByTestId('next'); + await expect(nextButton).toBeEnabled(); + + const [page2Response] = await Promise.all([ + page.waitForResponse(searchApiMatcher), + nextButton.click(), + ]); + expect(page2Response.status()).toBe(200); + await waitForAllLoadersToDisappear(page); + + await expect(page.getByTestId('previous')).toBeEnabled(); + const paginationText = page.locator('[data-testid="page-indicator"]'); + await expect(paginationText).toBeVisible(); + const page2Content = await paginationText.textContent(); + expect(page2Content).toMatch(/2\s*of\s*\d+/); const responsePromise = page.waitForResponse((response) => response @@ -262,10 +284,8 @@ test.describe('Pagination Tests', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { state: 'detached', }); - const databaseResponsePromise = page.waitForResponse((response) => - response.url().includes('/api/v1/databases') - ); - await page.getByTestId('databases').click(); + const databaseResponsePromise = page.waitForResponse(searchApiMatcher); + await page.getByTestId('details').click(); const response2 = await databaseResponsePromise; expect(response2.status()).toBe(200); await waitForAllLoadersToDisappear(page); @@ -273,10 +293,13 @@ test.describe('Pagination Tests', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { state: 'visible', }); - const paginationText = page.locator('[data-testid="page-indicator"]'); - await expect(paginationText).toBeVisible(); + const paginationTextAfterSwitch = page.locator( + '[data-testid="page-indicator"]' + ); + await expect(paginationTextAfterSwitch).toBeVisible(); - const paginationTextContent = await paginationText.textContent(); + const paginationTextContent = + await paginationTextAfterSwitch.textContent(); expect(paginationTextContent).toMatch(/1\s*of\s*\d+/); }); test('should test Service Database Tables complete flow with search', async ({ @@ -286,8 +309,8 @@ test.describe('Pagination Tests', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { await testCompletePaginationWithSearch({ page, - baseUrl: `/service/databaseServices/${databaseFqn}/databases`, - normalApiPattern: '/api/v1/databases', + baseUrl: `/service/databaseServices/${databaseFqn}/details`, + normalApiPattern: '/api/v1/search/query', searchApiPattern: '/api/v1/search/query', searchTestTerm: 'pw', searchParamName: 'schema', @@ -673,9 +696,9 @@ test.describe('Pagination Tests', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { '[data-testid="data-models-table"]' ); const responsePromise = page.waitForResponse((response) => - response.url().includes('/api/v1/dashboard/datamodels') + response.url().includes('/api/v1/search/query') ); - await page.getByTestId('dashboards').click(); + await page.getByTestId('details').click(); const response = await responsePromise; expect(response.status()).toBe(200); await waitForAllLoadersToDisappear(page); @@ -736,14 +759,36 @@ test.describe('Pagination Tests', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { }); test('should test Directories normal pagination', async ({ page }) => { + const searchApiMatcher = (response: { url: () => string }) => + response.url().includes('/api/v1/search/query'); + + const page1ResponsePromise = page.waitForResponse(searchApiMatcher); await page.goto( - `/service/driveServices/${serviceFqn}/directories?pageSize=15` - ); - await testPaginationNavigation( - page, - '/api/v1/drives/directories', - '[data-testid="service-children-table"]' + `/service/driveServices/${serviceFqn}/details?pageSize=15` ); + await page.locator('[data-testid="service-children-table"]').waitFor({ + state: 'visible', + }); + const page1Response = await page1ResponsePromise; + expect(page1Response.status()).toBe(200); + await waitForAllLoadersToDisappear(page); + + await expect(page.getByTestId('previous')).toBeDisabled(); + const nextButton = page.getByTestId('next'); + await expect(nextButton).toBeEnabled(); + + const [page2Response] = await Promise.all([ + page.waitForResponse(searchApiMatcher), + nextButton.click(), + ]); + expect(page2Response.status()).toBe(200); + await waitForAllLoadersToDisappear(page); + + await expect(page.getByTestId('previous')).toBeEnabled(); + const paginationText = page.locator('[data-testid="page-indicator"]'); + await expect(paginationText).toBeVisible(); + const page2Content = await paginationText.textContent(); + expect(page2Content).toMatch(/2\s*of\s*\d+/); }); test('should test Directories complete flow with search', async ({ @@ -753,8 +798,8 @@ test.describe('Pagination Tests', PLAYWRIGHT_BASIC_TEST_TAG_OBJ, () => { await testCompletePaginationWithSearch({ page, - baseUrl: `/service/driveServices/${serviceFqn}/directories?showDeletedTables=false`, - normalApiPattern: '/api/v1/drives/directories', + baseUrl: `/service/driveServices/${serviceFqn}/details?showDeletedTables=false`, + normalApiPattern: '/api/v1/search/query', searchApiPattern: '/api/v1/search/query', searchTestTerm: 'pw', searchParamName: 'schema', diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ServiceCustomization.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ServiceCustomization.spec.ts new file mode 100644 index 000000000000..2e604516547e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ServiceCustomization.spec.ts @@ -0,0 +1,307 @@ +/* + * 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 { expect, test as base, type Page } from '@playwright/test'; +import { PLAYWRIGHT_BASIC_TEST_TAG_OBJ } from '../../constant/config'; +import { SERVICE_DEFAULT_TABS } from '../../constant/customizeDetail'; +import { GlobalSettingOptions } from '../../constant/settings'; +import { DatabaseServiceClass } from '../../support/entity/service/DatabaseServiceClass'; +import { PersonaClass } from '../../support/persona/PersonaClass'; +import { AdminClass } from '../../support/user/AdminClass'; +import { UserClass } from '../../support/user/UserClass'; +import { performAdminLogin } from '../../utils/admin'; +import { + getApiContext, + redirectToHomePage, + toastNotification, +} from '../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../utils/entity'; +import { navigateToPersonaWithPagination } from '../../utils/persona'; +import { settingClick } from '../../utils/sidebar'; + +const persona = new PersonaClass(); +const adminUser = new AdminClass(); +const user = new UserClass(); +const databaseService = new DatabaseServiceClass(); + +const test = base.extend<{ + adminPage: Page; + userPage: Page; +}>({ + adminPage: async ({ browser }, use) => { + const adminPage = await browser.newPage(); + await adminUser.login(adminPage); + await use(adminPage); + await adminPage.close(); + }, + userPage: async ({ browser }, use) => { + const page = await browser.newPage(); + await user.login(page); + await use(page); + await page.close(); + }, +}); + +test.beforeAll('Setup Service Customization tests', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + await adminUser.create(apiContext); + await adminUser.setAdminRole(apiContext); + await user.create(apiContext); + await user.setAdminRole(apiContext); + + await persona.create(apiContext); + await databaseService.create(apiContext); + + await user.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: '/personas/0', + value: { + id: persona.responseData.id, + name: persona.responseData.name, + displayName: persona.responseData.displayName, + fullyQualifiedName: persona.responseData.fullyQualifiedName, + type: 'persona', + }, + }, + { + op: 'add', + path: '/defaultPersona', + value: { + id: persona.responseData.id, + name: persona.responseData.name, + displayName: persona.responseData.displayName, + fullyQualifiedName: persona.responseData.fullyQualifiedName, + type: 'persona', + }, + }, + ], + }); + + await afterAction(); +}); + +test.afterAll('Cleanup Service Customization tests', async ({ browser }) => { + test.slow(); + + const { apiContext, afterAction } = await performAdminLogin(browser); + await databaseService.delete(apiContext); + await adminUser.delete(apiContext); + await user.delete(apiContext); + await persona.delete(apiContext); + await afterAction(); +}); + +test.describe( + 'Service persona customization', + PLAYWRIGHT_BASIC_TEST_TAG_OBJ, + () => { + test('should show Service customize option', async ({ adminPage }) => { + await redirectToHomePage(adminPage); + + const personaListResponse = + adminPage.waitForResponse(`/api/v1/personas?*`); + await settingClick(adminPage, GlobalSettingOptions.PERSONA); + await personaListResponse; + + await navigateToPersonaWithPagination(adminPage, persona.data.name, true); + await adminPage.getByRole('tab', { name: 'Customize UI' }).click(); + + await expect( + adminPage.getByText('Service', { exact: true }) + ).toBeVisible(); + }); + + test('Service customization should work', async ({ + adminPage, + userPage, + }) => { + test.slow(); + + await test.step('should show all tabs as default when no customization is done', async () => { + await redirectToHomePage(adminPage); + + const personaListResponse = + adminPage.waitForResponse(`/api/v1/personas?*`); + await settingClick(adminPage, GlobalSettingOptions.PERSONA); + await personaListResponse; + + await navigateToPersonaWithPagination( + adminPage, + persona.data.name, + true + ); + await adminPage.getByRole('tab', { name: 'Customize UI' }).click(); + await adminPage.getByText('Service', { exact: true }).click(); + + await waitForAllLoadersToDisappear(adminPage); + + const tabs = adminPage + .getByTestId('customize-tab-card') + .getByRole('button') + .filter({ hasNotText: 'Add Tab' }); + + await expect(tabs).toHaveCount(SERVICE_DEFAULT_TABS.length); + + for (const tabName of SERVICE_DEFAULT_TABS) { + await expect( + adminPage + .getByTestId('customize-tab-card') + .getByTestId(`tab-${tabName}`) + ).toBeVisible(); + } + }); + + await test.step('apply customization', async () => { + await expect( + adminPage.locator('#KnowledgePanel\\.Description') + ).toBeVisible(); + + await adminPage + .locator('#KnowledgePanel\\.Description') + .getByTestId('remove-widget-button') + .click(); + + await adminPage.getByTestId('tab-connection').click(); + await adminPage.getByText('Hide', { exact: true }).click(); + + await adminPage.getByRole('button', { name: 'Add tab' }).click(); + + await expect(adminPage.getByRole('dialog')).toBeVisible(); + + const dialogTextbox = adminPage.getByTestId('add-tab-input'); + await dialogTextbox.fill('Custom Tab'); + + const addButton = adminPage + .getByRole('dialog') + .getByRole('button', { name: 'Add' }); + + await adminPage.locator('.ant-modal').waitFor({ state: 'visible' }); + await expect(addButton).toBeEnabled(); + await addButton.click(); + + await expect(adminPage.getByTestId('tab-Custom Tab')).toBeVisible(); + await expect( + adminPage.getByText('Customize Custom Tab Widgets') + ).toBeVisible(); + + await adminPage.getByRole('dialog').waitFor({ state: 'hidden' }); + await adminPage + .locator('.ant-modal-wrap') + .waitFor({ state: 'detached' }); + + const addWidgetButton = adminPage + .getByTestId('ExtraWidget.EmptyWidgetPlaceholder') + .getByTestId('add-widget-button'); + await addWidgetButton.waitFor({ state: 'visible' }); + await expect(addWidgetButton).toBeEnabled(); + await addWidgetButton.click(); + await adminPage + .getByTestId('widget-info-tabs') + .waitFor({ state: 'visible' }); + + await adminPage + .getByTestId('add-widget-modal') + .getByTestId('Description-widget') + .click(); + await adminPage + .getByTestId('add-widget-modal') + .getByTestId('add-widget-button') + .click(); + + await adminPage + .getByTestId('widget-info-tabs') + .waitFor({ state: 'hidden' }); + + await adminPage.getByTestId('save-button').click(); + + await toastNotification( + adminPage, + /^Page layout (created|updated) successfully\.$/ + ); + }); + + await test.step('Validate customization on service detail page', async () => { + await redirectToHomePage(userPage); + + await databaseService.visitEntityPage(userPage); + await waitForAllLoadersToDisappear(userPage); + + await expect( + userPage.getByRole('tab', { name: 'Custom Tab' }) + ).toBeVisible(); + + const customTab = userPage + .locator('main [role="tablist"]') + .last() + .getByRole('tab', { name: 'Custom Tab' }); + + await customTab.focus(); + await userPage.keyboard.press('Enter'); + + await expect + .poll(async () => + userPage.getByTestId(/KnowledgePanel.Description-/).count() + ) + .toBeGreaterThan(0); + + const visibleDescriptionWidget = userPage.locator( + '[data-testid^="KnowledgePanel.Description-"]:visible' + ); + await expect(visibleDescriptionWidget.first()).toBeVisible(); + }); + + await test.step('Validate customization applies to different service types', async () => { + const { apiContext } = await getApiContext(adminPage); + + const response = await apiContext.post( + '/api/v1/services/messagingServices', + { + data: { + name: `pw-messaging-svc-${Date.now()}`, + serviceType: 'Kafka', + connection: { + config: { + type: 'Kafka', + bootstrapServers: 'localhost:9092', + }, + }, + }, + } + ); + const serviceData = await response.json(); + + try { + await redirectToHomePage(userPage); + await settingClick(userPage, GlobalSettingOptions.MESSAGING); + await userPage + .getByTestId(`service-name-${serviceData.name}`) + .click(); + await waitForAllLoadersToDisappear(userPage); + + await expect( + userPage.getByRole('tab', { name: 'Custom Tab' }) + ).toBeVisible(); + } finally { + await apiContext.delete( + `/api/v1/services/messagingServices/name/${encodeURIComponent( + serviceData.fullyQualifiedName + )}?recursive=true&hardDelete=true` + ); + } + }); + }); + } +); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddDetailsPageWidgetModal/AddDetailsPageWidgetModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddDetailsPageWidgetModal/AddDetailsPageWidgetModal.tsx index 2d6623f8a30f..da6f85a5ff49 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddDetailsPageWidgetModal/AddDetailsPageWidgetModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/CustomizableComponents/AddDetailsPageWidgetModal/AddDetailsPageWidgetModal.tsx @@ -23,6 +23,7 @@ import { ERROR_PLACEHOLDER_TYPE } from '../../../../enums/common.enum'; import { WidgetWidths } from '../../../../enums/CustomizablePage.enum'; import { Document } from '../../../../generated/entity/docStore/document'; import { getWidgetWidthLabelFromKey } from '../../../../utils/CustomizableLandingPageUtils'; +import customizeMyDataPageClassBase from '../../../../utils/CustomizeMyDataPageClassBase'; import ErrorPlaceHolder from '../../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; import { WidgetSizeInfo } from '../AddWidgetModal/AddWidgetModal.interface'; import AddWidgetTabContent from '../AddWidgetModal/AddWidgetTabContent'; @@ -63,6 +64,9 @@ function AddDetailsPageWidgetModal({ const tabItems: TabsProps['items'] = useMemo( () => sortBy(widgetsList, 'name')?.map((widget) => { + const scaleFactor = + maxGridSizeSupport / + customizeMyDataPageClassBase.landingPageMaxGridSize; const widgetSizeOptions: Array = widget.data.gridSizes.map((size: GridSizes) => ({ label: ( @@ -70,7 +74,10 @@ function AddDetailsPageWidgetModal({ {getWidgetWidthLabelFromKey(toString(size))} ), - value: WidgetWidths[size], + value: Math.min( + maxGridSizeSupport, + Math.max(1, Math.round(WidgetWidths[size] * scaleFactor)) + ), })); return { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Service/ServiceEntityTable/ServiceEntityTable.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Service/ServiceEntityTable/ServiceEntityTable.test.tsx new file mode 100644 index 000000000000..3c8369513636 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Service/ServiceEntityTable/ServiceEntityTable.test.tsx @@ -0,0 +1,198 @@ +/* + * 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 { searchQuery } from '../../../rest/searchAPI'; +import ServiceEntityTable from './ServiceEntityTable'; + +jest.mock('react-router-dom', () => ({ + useParams: jest.fn().mockReturnValue({ + serviceCategory: 'databaseServices', + }), + useNavigate: jest.fn().mockReturnValue(jest.fn()), +})); + +jest.mock('../../Customization/GenericProvider/GenericProvider', () => ({ + useGenericContext: jest.fn().mockReturnValue({ + data: { + id: 'service-1', + name: 'test-service', + fullyQualifiedName: 'test-service', + deleted: false, + }, + permissions: { EditAll: true }, + }), +})); + +jest.mock('@openmetadata/ui-core-components', () => ({ + Toggle: jest + .fn() + .mockImplementation(({ 'data-testid': testId }) => ( +
+ )), +})); + +jest.mock('../../../hooks/useFqn', () => ({ + useFqn: jest.fn().mockReturnValue({ fqn: 'test-service' }), +})); + +jest.mock('../../../hooks/useCustomLocation/useCustomLocation', () => + jest.fn().mockReturnValue({ + pathname: '/databaseServices/test-service/details', + search: '', + }) +); + +jest.mock('../../../context/PermissionProvider/PermissionProvider', () => ({ + usePermissionProvider: jest.fn().mockReturnValue({ + permissions: { + databaseService: { ViewAll: true, EditAll: true, EditDisplayName: true }, + }, + }), +})); + +jest.mock('../../../hooks/paging/usePaging', () => ({ + usePaging: jest.fn().mockReturnValue({ + paging: { total: 0 }, + pageSize: 10, + currentPage: 1, + showPagination: false, + handlePageChange: jest.fn(), + handlePagingChange: jest.fn(), + handlePageSizeChange: jest.fn(), + }), +})); + +jest.mock('../../../hooks/useTableFilters', () => ({ + useTableFilters: jest.fn().mockReturnValue({ + filters: { showDeletedTables: false }, + setFilters: jest.fn(), + }), +})); + +jest.mock('../../../rest/searchAPI', () => ({ + searchQuery: jest.fn().mockResolvedValue({ + hits: { + total: { value: 2 }, + hits: [ + { + _source: { + id: 'db-1', + name: 'test_db', + displayName: 'Test DB', + fullyQualifiedName: 'test-service.test_db', + }, + }, + { + _source: { + id: 'db-2', + name: 'prod_db', + displayName: 'Prod DB', + fullyQualifiedName: 'test-service.prod_db', + }, + }, + ], + }, + }), +})); + +jest.mock('../../../utils/ServiceMainTabContentUtils', () => ({ + callServicePatchAPI: jest.fn(), + getServiceMainTabColumns: jest.fn().mockReturnValue([ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + }, + ]), +})); + +jest.mock('../../../utils/DatabaseSchemaDetailsUtils', () => ({ + buildSchemaQueryFilter: jest + .fn() + .mockReturnValue('{"query":{"bool":{"must":[]}}}'), +})); + +jest.mock('../../../utils/ServiceUtils', () => ({ + getCountLabel: jest.fn().mockReturnValue('Databases'), + getSearchIndexForService: jest.fn().mockReturnValue('database_search_index'), +})); + +jest.mock('../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); + +jest.mock('../../../utils/EntityBulkEdit/EntityBulkEditUtils', () => ({ + getBulkEditButton: jest.fn().mockReturnValue(null), +})); + +jest.mock('../../../utils/EntityUtils', () => ({ + getEntityBulkEditPath: jest + .fn() + .mockReturnValue('/bulk-edit/databaseService/test-service'), +})); + +jest.mock('../../../utils/i18next/LocalUtil', () => ({ + t: jest.fn().mockImplementation((key: string) => key), + detectBrowserLanguage: jest.fn().mockReturnValue('en-US'), +})); + +describe('ServiceEntityTable', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render and fetch entities on mount', async () => { + render(); + + await waitFor(() => { + expect(searchQuery).toHaveBeenCalledWith( + expect.objectContaining({ + pageNumber: 1, + pageSize: 10, + query: '', + trackTotalHits: true, + includeDeleted: false, + }) + ); + }); + }); + + it('should render the table element', async () => { + render(); + + await waitFor(() => { + expect(searchQuery).toHaveBeenCalled(); + }); + + expect(screen.getByTestId('service-children-table')).toBeInTheDocument(); + }); + + it('should not fetch entities when isCustomizationPage is true', async () => { + render(); + + await waitFor(() => { + expect(searchQuery).not.toHaveBeenCalled(); + }); + }); + + it('should show deleted toggle', async () => { + render(); + + await waitFor(() => { + expect(searchQuery).toHaveBeenCalled(); + }); + + expect(screen.getByTestId('show-deleted')).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Service/ServiceEntityTable/ServiceEntityTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Service/ServiceEntityTable/ServiceEntityTable.tsx new file mode 100644 index 000000000000..ace139449546 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Service/ServiceEntityTable/ServiceEntityTable.tsx @@ -0,0 +1,329 @@ +/* + * 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 { Toggle } from '@openmetadata/ui-core-components'; +import { ColumnsType } from 'antd/lib/table'; +import { AxiosError } from 'axios'; +import { compare } from 'fast-json-patch'; +import { isEmpty } from 'lodash'; +import { ServiceTypes } from 'Models'; +import QueryString from 'qs'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + INITIAL_PAGING_VALUE, + INITIAL_TABLE_FILTERS, +} from '../../../constants/constants'; +import { TABLE_SCROLL_VALUE } from '../../../constants/Table.constants'; +import { + COMMON_STATIC_TABLE_VISIBLE_COLUMNS, + DEFAULT_SERVICE_TAB_VISIBLE_COLUMNS, +} from '../../../constants/TableKeys.constants'; +import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; +import { EntityType } from '../../../enums/entity.enum'; +import { usePaging } from '../../../hooks/paging/usePaging'; +import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation'; +import { useFqn } from '../../../hooks/useFqn'; +import { useTableFilters } from '../../../hooks/useTableFilters'; +import { ServicesType } from '../../../interface/service.interface'; +import { ServicePageData } from '../../../pages/ServiceDetailsPage/ServiceDetailsPage.interface'; +import { searchQuery } from '../../../rest/searchAPI'; +import { buildSchemaQueryFilter } from '../../../utils/DatabaseSchemaDetailsUtils'; +import { getBulkEditButton } from '../../../utils/EntityBulkEdit/EntityBulkEditUtils'; +import { getEntityBulkEditPath } from '../../../utils/EntityUtils'; +import { t } from '../../../utils/i18next/LocalUtil'; +import { + callServicePatchAPI, + getServiceMainTabColumns, +} from '../../../utils/ServiceMainTabContentUtils'; +import { + getCountLabel, + getSearchIndexForService, +} from '../../../utils/ServiceUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; +import { PagingHandlerParams } from '../../common/NextPrevious/NextPrevious.interface'; +import Table from '../../common/Table/Table'; +import { useGenericContext } from '../../Customization/GenericProvider/GenericProvider'; +import { EntityName } from '../../Modals/EntityNameModal/EntityNameModal.interface'; + +interface ServiceEntityTableProps { + isCustomizationPage?: boolean; +} + +const ServiceEntityTable = ({ + isCustomizationPage = false, +}: ServiceEntityTableProps) => { + const routeParams = useParams<{ serviceCategory?: string }>(); + const serviceCategory = (routeParams.serviceCategory ?? '') as ServiceTypes; + const { fqn: decodedServiceFQN } = useFqn(); + const { permissions } = usePermissionProvider(); + const location = useCustomLocation(); + const navigate = useNavigate(); + const { data: serviceData, permissions: servicePermission } = + useGenericContext(); + + const [entities, setEntities] = useState([]); + const entitiesRef = useRef(entities); + entitiesRef.current = entities; + const [isLoading, setIsLoading] = useState(true); + const { filters, setFilters } = useTableFilters(INITIAL_TABLE_FILTERS); + const { showDeletedTables: showDeleted } = filters; + const pagingInfo = usePaging(); + const { + paging, + pageSize, + currentPage, + handlePageChange, + handlePagingChange, + } = pagingInfo; + + const searchValue = useMemo(() => { + const param = location.search; + const searchData = QueryString.parse( + param.startsWith('?') ? param.substring(1) : param + ); + + return searchData.schema as string | undefined; + }, [location.search]); + + const fetchEntities = useCallback( + async (pageNumber: number = INITIAL_PAGING_VALUE) => { + if (!serviceCategory || !decodedServiceFQN) { + setIsLoading(false); + + return; + } + + const searchIndex = getSearchIndexForService(serviceCategory); + + try { + setIsLoading(true); + const res = await searchQuery({ + pageNumber, + pageSize, + searchIndex, + query: '', + queryFilter: buildSchemaQueryFilter( + 'service.fullyQualifiedName.keyword', + decodedServiceFQN, + searchValue + ), + includeDeleted: showDeleted, + trackTotalHits: true, + }); + const items = res.hits.hits.map( + (hit) => hit._source as ServicePageData + ); + const total = res.hits.total.value; + + setEntities(items); + handlePagingChange({ total }); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsLoading(false); + } + }, + [ + serviceCategory, + pageSize, + decodedServiceFQN, + searchValue, + showDeleted, + handlePagingChange, + ] + ); + + const handleDisplayNameUpdate = useCallback( + async (entityData: EntityName, id?: string) => { + try { + const pageDataDetails = entitiesRef.current.find( + (data) => data.id === id + ); + if (!pageDataDetails) { + return; + } + const updatedData = { + ...pageDataDetails, + displayName: entityData.displayName ?? undefined, + }; + const jsonPatch = compare(pageDataDetails, updatedData); + const response = await callServicePatchAPI( + serviceCategory, + pageDataDetails.id, + jsonPatch + ); + setEntities((prevData) => + prevData.map((data) => (data.id === id && response ? response : data)) + ); + } catch (error) { + showErrorToast(error as AxiosError); + } + }, + [serviceCategory] + ); + + const editDisplayNamePermission = useMemo(() => { + if (isCustomizationPage) { + return false; + } + + const servicePermissions: Record< + string, + { EditAll?: boolean; EditDisplayName?: boolean } | undefined + > = { + databaseServices: permissions.databaseService, + messagingServices: permissions.messagingService, + dashboardServices: permissions.dashboardService, + pipelineServices: permissions.pipelineService, + mlmodelServices: permissions.mlmodelService, + storageServices: permissions.storageService, + searchServices: permissions.searchService, + apiServices: permissions.apiService, + driveServices: permissions.driveService, + metadataServices: permissions.metadataService, + }; + + const currentPermission = servicePermissions[serviceCategory]; + + return ( + currentPermission?.EditAll || currentPermission?.EditDisplayName || false + ); + }, [permissions, serviceCategory, isCustomizationPage]); + + const tableColumns: ColumnsType = useMemo( + () => + getServiceMainTabColumns( + serviceCategory, + editDisplayNamePermission, + handleDisplayNameUpdate, + searchValue + ), + [ + serviceCategory, + handleDisplayNameUpdate, + editDisplayNamePermission, + searchValue, + ] + ); + + const handleShowDeleted = useCallback( + (value: boolean) => { + setFilters({ showDeletedTables: value }); + handlePageChange(INITIAL_PAGING_VALUE, { + cursorType: null, + cursorValue: undefined, + }); + }, + [handlePageChange, setFilters] + ); + + const onServiceSearch = useCallback( + (value: string) => { + setFilters({ schema: isEmpty(value) ? undefined : value }); + handlePageChange(INITIAL_PAGING_VALUE, { + cursorType: null, + cursorValue: undefined, + }); + }, + [handlePageChange, setFilters] + ); + + const tablePaginationHandler = useCallback( + ({ currentPage }: PagingHandlerParams) => { + handlePageChange(currentPage); + }, + [handlePageChange] + ); + + const isDatabaseService = serviceCategory === 'databaseServices'; + + const handleEditTable = useCallback(() => { + navigate({ + pathname: getEntityBulkEditPath( + EntityType.DATABASE_SERVICE, + decodedServiceFQN + ), + }); + }, [navigate, decodedServiceFQN]); + + const searchProps = useMemo( + () => ({ + placeholder: t('label.search-for-type', { + type: getCountLabel(serviceCategory), + }), + typingInterval: 500, + searchValue, + onSearch: onServiceSearch, + }), + [onServiceSearch, serviceCategory, searchValue] + ); + + useEffect(() => { + if (isCustomizationPage) { + setEntities([]); + setIsLoading(false); + + return; + } + fetchEntities(currentPage); + }, [currentPage, isCustomizationPage, fetchEntities]); + + return ( + + + {isDatabaseService && + !isCustomizationPage && + getBulkEditButton( + (servicePermission?.EditAll ?? false) && !serviceData?.deleted, + handleEditTable + )} + + } + loading={isLoading} + locale={{ + emptyText: , + }} + pagination={false} + rowKey="id" + scroll={TABLE_SCROLL_VALUE} + searchProps={searchProps} + size="small" + staticVisibleColumns={COMMON_STATIC_TABLE_VISIBLE_COLUMNS} + /> + ); +}; + +export default ServiceEntityTable; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/Customize.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/Customize.constants.ts index 51f762591816..56820f1e7e42 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/Customize.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/Customize.constants.ts @@ -90,7 +90,18 @@ export type CustomizeEntityType = | EntityType.DIRECTORY | EntityType.FILE | EntityType.SPREADSHEET - | EntityType.WORKSHEET; + | EntityType.WORKSHEET + | EntityType.DATABASE_SERVICE + | EntityType.MESSAGING_SERVICE + | EntityType.DASHBOARD_SERVICE + | EntityType.PIPELINE_SERVICE + | EntityType.MLMODEL_SERVICE + | EntityType.METADATA_SERVICE + | EntityType.STORAGE_SERVICE + | EntityType.SEARCH_SERVICE + | EntityType.API_SERVICE + | EntityType.DRIVE_SERVICE + | EntityType.SECURITY_SERVICE; export const ENTITY_PAGE_TYPE_MAP: Record = { [EntityType.TABLE]: PageType.Table, @@ -117,4 +128,15 @@ export const ENTITY_PAGE_TYPE_MAP: Record = { [EntityType.FILE]: PageType.File, [EntityType.SPREADSHEET]: PageType.Spreadsheet, [EntityType.WORKSHEET]: PageType.Worksheet, + [EntityType.DATABASE_SERVICE]: PageType.Service, + [EntityType.MESSAGING_SERVICE]: PageType.Service, + [EntityType.DASHBOARD_SERVICE]: PageType.Service, + [EntityType.PIPELINE_SERVICE]: PageType.Service, + [EntityType.MLMODEL_SERVICE]: PageType.Service, + [EntityType.METADATA_SERVICE]: PageType.Service, + [EntityType.STORAGE_SERVICE]: PageType.Service, + [EntityType.SEARCH_SERVICE]: PageType.Service, + [EntityType.API_SERVICE]: PageType.Service, + [EntityType.DRIVE_SERVICE]: PageType.Service, + [EntityType.SECURITY_SERVICE]: PageType.Service, }; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/ServiceDetailPage.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/ServiceDetailPage.constants.ts new file mode 100644 index 000000000000..52acab8211bd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/constants/ServiceDetailPage.constants.ts @@ -0,0 +1,43 @@ +/* + * 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 { + DatabaseService, + DatabaseServiceType, +} from '../generated/entity/services/databaseService'; + +export const SERVICE_DUMMY_DATA: DatabaseService = { + id: 'c4f1d0d5-ace6-4e57-b734-8e7f0a5e2c9f', + name: 'sample_data', + fullyQualifiedName: 'sample_data', + displayName: 'Sample Data', + description: + 'This **mock** service contains sample databases with schemas and tables for demonstration purposes.', + serviceType: DatabaseServiceType.BigQuery, + tags: [], + version: 1.0, + updatedAt: 1736405710107, + updatedBy: 'admin', + owners: [ + { + id: '50bb97a5-cf0c-4273-930e-b3e802b52ee1', + type: 'user', + name: 'admin', + fullyQualifiedName: 'admin', + displayName: 'Admin', + deleted: false, + }, + ], + deleted: false, + domains: [], + followers: [], +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/enums/CustomizeDetailPage.enum.ts b/openmetadata-ui/src/main/resources/ui/src/enums/CustomizeDetailPage.enum.ts index fd3c965cf758..2968a0768957 100644 --- a/openmetadata-ui/src/main/resources/ui/src/enums/CustomizeDetailPage.enum.ts +++ b/openmetadata-ui/src/main/resources/ui/src/enums/CustomizeDetailPage.enum.ts @@ -57,6 +57,7 @@ export enum DetailPageWidgetKeys { MARKETPLACE_DATA_PRODUCTS = 'KnowledgePanel.MarketplaceDataProducts', MARKETPLACE_DOMAINS = 'KnowledgePanel.MarketplaceDomains', MARKETPLACE_ANNOUNCEMENTS = 'KnowledgePanel.MarketplaceAnnouncements', + SERVICE_ENTITY_TABLE = 'KnowledgePanel.ServiceEntityTable', } export enum GlossaryTermDetailPageWidgetKeys { diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/system/ui/page.ts b/openmetadata-ui/src/main/resources/ui/src/generated/system/ui/page.ts index f1cc0253d72d..6a13bad7fc14 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/system/ui/page.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/system/ui/page.ts @@ -132,6 +132,7 @@ export enum PageType { MlModel = "MlModel", Pipeline = "Pipeline", SearchIndex = "SearchIndex", + Service = "Service", Spreadsheet = "Spreadsheet", StoredProcedure = "StoredProcedure", Table = "Table", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/system/ui/uiCustomization.ts b/openmetadata-ui/src/main/resources/ui/src/generated/system/ui/uiCustomization.ts index 536b4f1dd1aa..6effcc173deb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/system/ui/uiCustomization.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/system/ui/uiCustomization.ts @@ -258,6 +258,7 @@ export enum PageType { MlModel = "MlModel", Pipeline = "Pipeline", SearchIndex = "SearchIndex", + Service = "Service", Spreadsheet = "Spreadsheet", StoredProcedure = "StoredProcedure", Table = "Table", diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizablePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizablePage.tsx index 6654e309474b..1aba99b98678 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizablePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/CustomizablePage/CustomizablePage.tsx @@ -387,6 +387,7 @@ export const CustomizablePage = () => { case PageType.File: case PageType.Spreadsheet: case PageType.Worksheet: + case PageType.Service: return ( ({ jest.mock('../../rest/searchAPI', () => ({ searchQuery: jest.fn().mockImplementation(() => Promise.resolve({ + hits: { + total: { value: 0 }, + hits: [], + }, paging: { total: 0, }, @@ -250,6 +251,9 @@ jest.mock('../../hooks/useApplicationStore', () => ({ jest.mock('react-router-dom', () => ({ useNavigate: jest.fn().mockImplementation(() => jest.fn()), + useParams: jest.fn().mockReturnValue({ + serviceCategory: 'databaseServices', + }), useLocation: () => ({ pathname: '/mock-path', search: '', @@ -397,14 +401,35 @@ jest.mock('../../components/ServiceInsights/ServiceInsightsTab', () => )) ); -jest.mock('./ServiceMainTabContent', () => - jest - .fn() - .mockImplementation(() => ( -
ServiceMainTabContent
- )) +jest.mock('../../hooks/useCustomPages', () => ({ + useCustomPages: jest.fn().mockReturnValue({ + customizedPage: null, + isLoading: false, + }), +})); + +jest.mock( + '../../components/Customization/GenericProvider/GenericProvider', + () => ({ + GenericProvider: jest + .fn() + .mockImplementation(({ children }) => ( +
{children}
+ )), + useGenericContext: jest.fn().mockReturnValue({ + type: 'databaseService', + }), + }) ); +jest.mock('../../components/Customization/GenericTab/GenericTab', () => ({ + GenericTab: jest + .fn() + .mockImplementation(({ type }) => ( +
Generic Tab - {type}
+ )), +})); + jest.mock( '../../components/Dashboard/DataModel/DataModels/DataModelsTable', () => @@ -485,6 +510,7 @@ jest.mock('../../utils/ServiceUtils', () => ({ getResourceEntityFromServiceCategory: jest .fn() .mockReturnValue('databaseService'), + getSearchIndexForService: jest.fn().mockReturnValue('database_search_index'), getServiceDisplayNameQueryFilter: jest.fn().mockReturnValue(''), getServiceRouteFromServiceType: jest.fn().mockReturnValue('database'), shouldTestConnection: jest.fn().mockReturnValue(true), @@ -561,6 +587,10 @@ jest.mock('../../utils/StringsUtils', () => ({ getEncodedFqn: jest.fn().mockImplementation((text) => text), })); +jest.mock('../../utils/DatabaseSchemaDetailsUtils', () => ({ + buildSchemaQueryFilter: jest.fn().mockReturnValue(''), +})); + const mockSetFilters = jest.fn(); jest.mock('../../hooks/useTableFilters', () => ({ useTableFilters: jest.fn().mockImplementation(() => ({ @@ -849,93 +879,6 @@ describe('ServiceDetailsPage', () => { }); }); - describe('Data Fetching', () => { - it('should fetch databases for database service', async () => { - (getDatabases as jest.Mock).mockResolvedValue({ - data: [{ id: 'db1', name: 'Database 1' }], - paging: { total: 1 }, - }); - - await renderComponent(); - - await waitFor(() => { - expect(getDatabases).toHaveBeenCalled(); - }); - }); - - it('should include usageSummary in database fields when ViewUsage is allowed', async () => { - (getPrioritizedViewPermission as jest.Mock).mockReturnValue(true); - (getDatabases as jest.Mock).mockResolvedValue({ - data: [{ id: 'db1', name: 'Database 1' }], - paging: { total: 1 }, - }); - - await renderComponent(); - - await waitFor(() => { - expect(getDatabases).toHaveBeenCalled(); - }); - - const fields = (getDatabases as jest.Mock).mock.calls[0][1]; - - expect(fields).toContain('usageSummary'); - }); - - it('should exclude usageSummary from database fields when ViewUsage is denied', async () => { - (getPrioritizedViewPermission as jest.Mock).mockReturnValue(false); - (getDatabases as jest.Mock).mockResolvedValue({ - data: [{ id: 'db1', name: 'Database 1' }], - paging: { total: 1 }, - }); - - await renderComponent(); - - await waitFor(() => { - expect(getDatabases).toHaveBeenCalled(); - }); - - const fields = (getDatabases as jest.Mock).mock.calls[0][1]; - - expect(fields).not.toContain('usageSummary'); - }); - - it('should fetch topics for messaging service', async () => { - (getTopics as jest.Mock).mockResolvedValue({ - data: [{ id: 'topic1', name: 'Topic 1' }], - paging: { total: 1 }, - }); - - (useRequiredParams as jest.Mock).mockReturnValue({ - serviceCategory: ServiceCategory.MESSAGING_SERVICES, - tab: EntityTabs.INSIGHTS, - }); - - await renderComponent(); - - await waitFor(() => { - expect(getTopics).toHaveBeenCalled(); - }); - }); - - it('should fetch dashboards for dashboard service', async () => { - (getDashboards as jest.Mock).mockResolvedValue({ - data: [{ id: 'dashboard1', name: 'Dashboard 1' }], - paging: { total: 1 }, - }); - - (useRequiredParams as jest.Mock).mockReturnValue({ - serviceCategory: ServiceCategory.DASHBOARD_SERVICES, - tab: EntityTabs.INSIGHTS, - }); - - await renderComponent(); - - await waitFor(() => { - expect(getDashboards).toHaveBeenCalled(); - }); - }); - }); - describe('Data Model Tab Count', () => { beforeEach(() => { // Set up dashboard service context diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ServiceDetailsPage/ServiceDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ServiceDetailsPage/ServiceDetailsPage.tsx index 57cf5a7c6e99..1c90318b9513 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ServiceDetailsPage/ServiceDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ServiceDetailsPage/ServiceDetailsPage.tsx @@ -37,6 +37,8 @@ import Loader from '../../components/common/Loader/Loader'; import { PagingHandlerParams } from '../../components/common/NextPrevious/NextPrevious.interface'; import TabsLabel from '../../components/common/TabsLabel/TabsLabel.component'; import TestConnection from '../../components/common/TestConnection/TestConnection'; +import { GenericProvider } from '../../components/Customization/GenericProvider/GenericProvider'; +import { GenericTab } from '../../components/Customization/GenericTab/GenericTab'; import DataModelTable from '../../components/Dashboard/DataModel/DataModels/DataModelsTable'; import { DataAssetsHeader } from '../../components/DataAssets/DataAssetsHeader/DataAssetsHeader.component'; import { DataAssetWithDomains } from '../../components/DataAssets/DataAssetsHeader/DataAssetsHeader.interface'; @@ -55,6 +57,7 @@ import { pagingObject, ROUTES, } from '../../constants/constants'; +import { CustomizeEntityType } from '../../constants/Customize.constants'; import { GlobalSettingsMenuCategory } from '../../constants/GlobalSettings.constants'; import { SERVICE_INSIGHTS_WORKFLOW_DEFINITION_NAME } from '../../constants/ServiceInsightsTab.constants'; import { @@ -78,39 +81,30 @@ import { SearchIndex } from '../../enums/search.enum'; import { ServiceAgentSubTabs, ServiceCategory } from '../../enums/service.enum'; import { AgentType, App } from '../../generated/entity/applications/app'; import { Tag } from '../../generated/entity/classification/tag'; -import { Directory } from '../../generated/entity/data/directory'; import { File } from '../../generated/entity/data/file'; import { Spreadsheet } from '../../generated/entity/data/spreadsheet'; import { DataProduct } from '../../generated/entity/domains/dataProduct'; -import { Operation as PermissionOperation } from '../../generated/entity/policies/accessControl/resourcePermission'; import { DashboardConnection } from '../../generated/entity/services/dashboardService'; import { IngestionPipeline } from '../../generated/entity/services/ingestionPipelines/ingestionPipeline'; import { WorkflowStatus } from '../../generated/governance/workflows/workflowInstance'; +import { PageType } from '../../generated/system/ui/page'; import { Include } from '../../generated/type/include'; import { Paging } from '../../generated/type/paging'; import { useAuth } from '../../hooks/authHooks'; import { usePaging } from '../../hooks/paging/usePaging'; import { useApplicationStore } from '../../hooks/useApplicationStore'; +import { useCustomPages } from '../../hooks/useCustomPages'; import { useFqn } from '../../hooks/useFqn'; import { useTableFilters } from '../../hooks/useTableFilters'; import { ConfigData, ServicesType } from '../../interface/service.interface'; -import { getApiCollections } from '../../rest/apiCollectionsAPI'; import { getApplicationList } from '../../rest/applicationAPI'; -import { - getDashboards, - getDataModels, - ListDataModelParams, -} from '../../rest/dashboardAPI'; -import { getDatabases } from '../../rest/databaseAPI'; +import { getDataModels, ListDataModelParams } from '../../rest/dashboardAPI'; import { getDriveAssets } from '../../rest/driveAPI'; import { getIngestionPipelines, getPipelineServiceHostIp, } from '../../rest/ingestionPipelineAPI'; -import { getMlModels } from '../../rest/mlModelAPI'; -import { getPipelines } from '../../rest/pipelineAPI'; import { searchQuery } from '../../rest/searchAPI'; -import { getSearchIndexes } from '../../rest/SearchIndexAPI'; import { addServiceFollower, getServiceByFQN, @@ -118,13 +112,13 @@ import { removeServiceFollower, restoreService, } from '../../rest/serviceAPI'; -import { getContainers } from '../../rest/storageAPI'; -import { getTopics } from '../../rest/topicsAPI'; import { getWorkflowInstancesForApplication, getWorkflowInstanceStateById, } from '../../rest/workflowAPI'; import { getEntityMissingError } from '../../utils/CommonUtils'; +import { getDetailsTabWithNewLabel } from '../../utils/CustomizePage/CustomizePageUtils'; +import { buildSchemaQueryFilter } from '../../utils/DatabaseSchemaDetailsUtils'; import { commonTableFields } from '../../utils/DatasetDetailsUtils'; import { getCurrentMillis, @@ -141,10 +135,7 @@ import { PluginEntityDetailsContext, TabContribution, } from '../../utils/ExtensionPointTypes'; -import { - DEFAULT_ENTITY_PERMISSION, - getPrioritizedViewPermission, -} from '../../utils/PermissionsUtils'; +import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; import { getEditConnectionPath, getServiceDetailsPath, @@ -156,6 +147,7 @@ import { getCountLabel, getEntityTypeFromServiceCategory, getResourceEntityFromServiceCategory, + getSearchIndexForService, getServiceDisplayNameQueryFilter, getServiceRouteFromServiceType, shouldTestConnection, @@ -168,8 +160,6 @@ import { updateTierTag } from '../../utils/TagsUtils'; import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; import { useRequiredParams } from '../../utils/useRequiredParams'; import './service-details-page.less'; -import { ServicePageData } from './ServiceDetailsPage.interface'; -import ServiceMainTabContent from './ServiceMainTabContent'; const ServiceDetailsPage: FunctionComponent = () => { const { t } = useTranslation(); @@ -185,6 +175,7 @@ const ServiceDetailsPage: FunctionComponent = () => { tab: string; }>(); const { fqn: decodedServiceFQN } = useFqn(); + const { customizedPage } = useCustomPages(PageType.Service); const { isMetadataService, isSecurityService } = useMemo( () => ({ isMetadataService: serviceCategory === ServiceCategory.METADATA_SERVICES, @@ -244,13 +235,7 @@ const ServiceDetailsPage: FunctionComponent = () => { handlePagingChange: handleIngestionPagingChange, } = ingestionPagingInfo; - const { - paging, - pageSize, - currentPage, - handlePageChange, - handlePagingChange, - } = pagingInfo; + const { paging: _paging, handlePageChange, handlePagingChange } = pagingInfo; const { paging: filesPaging, @@ -269,13 +254,11 @@ const ServiceDetailsPage: FunctionComponent = () => { const [serviceDetails, setServiceDetails] = useState( {} as ServicesType ); - const [data, setData] = useState>([]); const [files, setFiles] = useState>([]); const [spreadsheets, setSpreadsheets] = useState>([]); const [isLoading, setIsLoading] = useState(!isOpenMetadataService); const [isIngestionPipelineLoading, setIsIngestionPipelineLoading] = useState(false); - const [isServiceLoading, setIsServiceLoading] = useState(true); const [isFilesLoading, setIsFilesLoading] = useState(true); const [isSpreadsheetsLoading, setIsSpreadsheetsLoading] = useState(true); const [dataModelPaging, setDataModelPaging] = useState(pagingObject); @@ -300,7 +283,7 @@ const ServiceDetailsPage: FunctionComponent = () => { const { showDeletedTables: showDeleted } = tableFilters; const isInitialLoadRef = useRef(true); - const isInitialPaginationLoadRef = useRef(true); + const [serviceEntityCount, setServiceEntityCount] = useState(0); const { isFollowing, followers = [] } = useMemo( () => ({ @@ -354,6 +337,11 @@ const ServiceDetailsPage: FunctionComponent = () => { const activeTab = useMemo(() => { if (tab) { + const countLabel = getCountLabel(serviceCategory).toLowerCase(); + if (tab === countLabel) { + return EntityTabs.DETAILS; + } + return tab; } if (isMetadataService) { @@ -367,6 +355,20 @@ const ServiceDetailsPage: FunctionComponent = () => { return EntityTabs.INSIGHTS; }, [tab, serviceCategory, isMetadataService, isSecurityService]); + useEffect(() => { + const countLabel = getCountLabel(serviceCategory).toLowerCase(); + if (tab === countLabel && tab !== EntityTabs.DETAILS) { + navigate( + getServiceDetailsPath( + decodedServiceFQN, + serviceCategory, + EntityTabs.DETAILS + ), + { replace: true } + ); + } + }, [tab, serviceCategory, decodedServiceFQN, navigate]); + const handleSearchChange = useCallback( (searchValue: string) => { handleIngestionPageChange(INITIAL_PAGING_VALUE); @@ -410,11 +412,10 @@ const ServiceDetailsPage: FunctionComponent = () => { return shouldTestConnection(serviceCategory); }, [serviceCategory]); - const { - version: currentVersion, - deleted, - id: serviceId, - } = useMemo(() => serviceDetails, [serviceDetails]); + const { version: currentVersion, id: serviceId } = useMemo( + () => serviceDetails, + [serviceDetails] + ); const fetchServicePermission = useCallback(async () => { setIsLoading(true); @@ -444,7 +445,7 @@ const ServiceDetailsPage: FunctionComponent = () => { if (isAgentTab) { subTab = ServiceAgentSubTabs.METADATA; } - if (key === getCountLabel(serviceCategory).toLowerCase()) { + if (key === EntityTabs.DETAILS) { handlePageChange(INITIAL_PAGING_VALUE, { cursorType: null, cursorValue: undefined, @@ -669,66 +670,10 @@ const ServiceDetailsPage: FunctionComponent = () => { [showDeleted] ); - const fetchDatabases = useCallback( - async (paging?: PagingWithoutTotal) => { - const databaseUsagePermission = getPrioritizedViewPermission( - permissions.database, - PermissionOperation.ViewUsage - ); - const { data, paging: resPaging } = await getDatabases( - decodedServiceFQN, - databaseUsagePermission - ? `${TabSpecificField.USAGE_SUMMARY},${commonTableFields}` - : commonTableFields, - paging, - include - ); - - setData(data); - handlePagingChange(resPaging); - }, - [decodedServiceFQN, include, permissions.database] - ); - - const fetchTopics = useCallback( - async (paging?: PagingWithoutTotal) => { - const { data, paging: resPaging } = await getTopics( - decodedServiceFQN, - commonTableFields, - paging, - include - ); - setData(data); - handlePagingChange(resPaging); - }, - [decodedServiceFQN, include] - ); - - const fetchDashboards = useCallback( - async (paging?: PagingWithoutTotal) => { - const dashboardUsagePermission = getPrioritizedViewPermission( - permissions.dashboard, - PermissionOperation.ViewUsage - ); - const { data, paging: resPaging } = await getDashboards( - decodedServiceFQN, - dashboardUsagePermission - ? `${commonTableFields},${TabSpecificField.USAGE_SUMMARY}` - : commonTableFields, - paging, - include - ); - setData(data); - handlePagingChange(resPaging); - }, - [decodedServiceFQN, include, permissions.dashboard] - ); - // Fetch Data Model count to show it in tab label const fetchDashboardsDataModel = useCallback( async (params?: ListDataModelParams) => { try { - setIsServiceLoading(true); const { paging: resPaging } = await getDataModels({ service: decodedServiceFQN, fields: `${commonTableFields}, ${TabSpecificField.FOLLOWERS}`, @@ -744,100 +689,6 @@ const ServiceDetailsPage: FunctionComponent = () => { [decodedServiceFQN, include] ); - const fetchPipeLines = useCallback( - async (paging?: PagingWithoutTotal) => { - const pipelineUsagePermission = getPrioritizedViewPermission( - permissions.pipeline, - PermissionOperation.ViewUsage - ); - const { data, paging: resPaging } = await getPipelines( - decodedServiceFQN, - pipelineUsagePermission - ? `${commonTableFields},${TabSpecificField.STATE},${TabSpecificField.USAGE_SUMMARY}` - : `${commonTableFields},${TabSpecificField.STATE}`, - paging, - include - ); - setData(data); - handlePagingChange(resPaging); - }, - [decodedServiceFQN, include, permissions.pipeline] - ); - - const fetchMlModal = useCallback( - async (paging?: PagingWithoutTotal) => { - const { data, paging: resPaging } = await getMlModels( - decodedServiceFQN, - commonTableFields, - paging, - include - ); - setData(data); - handlePagingChange(resPaging); - }, - [decodedServiceFQN, include] - ); - - const fetchContainers = useCallback( - async (paging?: PagingWithoutTotal) => { - const response = await getContainers({ - service: decodedServiceFQN, - fields: commonTableFields, - paging, - root: true, - include, - }); - - setData(response.data); - handlePagingChange(response.paging); - }, - [decodedServiceFQN, include] - ); - - const fetchSearchIndexes = useCallback( - async (paging?: PagingWithoutTotal) => { - const response = await getSearchIndexes({ - service: decodedServiceFQN, - fields: commonTableFields, - paging, - root: true, - include, - }); - - setData(response.data); - handlePagingChange(response.paging); - }, - [decodedServiceFQN, include] - ); - const fetchCollections = useCallback( - async (paging?: PagingWithoutTotal) => { - const response = await getApiCollections({ - service: decodedServiceFQN, - fields: commonTableFields, - paging, - include, - }); - - setData(response.data); - handlePagingChange(response.paging); - }, - [decodedServiceFQN, include] - ); - const fetchDirectories = useCallback( - async (paging?: PagingWithoutTotal) => { - const response = await getDriveAssets(EntityType.DIRECTORY, { - service: decodedServiceFQN, - fields: commonTableFields, - root: true, - paging, - include, - }); - - setData(response.data); - handlePagingChange(response.paging); - }, - [decodedServiceFQN, include] - ); const fetchFiles = useCallback( async (paging?: PagingWithoutTotal) => { try { @@ -886,83 +737,6 @@ const ServiceDetailsPage: FunctionComponent = () => { [decodedServiceFQN, include] ); - const getOtherDetails = useCallback( - async (paging?: PagingWithoutTotal) => { - try { - setIsServiceLoading(true); - const pagingParams = { ...paging, limit: pageSize }; - switch (serviceCategory) { - case ServiceCategory.DATABASE_SERVICES: { - await fetchDatabases(pagingParams); - - break; - } - case ServiceCategory.MESSAGING_SERVICES: { - await fetchTopics(pagingParams); - - break; - } - case ServiceCategory.DASHBOARD_SERVICES: { - await fetchDashboards(pagingParams); - - break; - } - case ServiceCategory.PIPELINE_SERVICES: { - await fetchPipeLines(pagingParams); - - break; - } - case ServiceCategory.ML_MODEL_SERVICES: { - await fetchMlModal(pagingParams); - - break; - } - case ServiceCategory.STORAGE_SERVICES: { - await fetchContainers(pagingParams); - - break; - } - case ServiceCategory.SEARCH_SERVICES: { - await fetchSearchIndexes(pagingParams); - - break; - } - case ServiceCategory.API_SERVICES: { - await fetchCollections(pagingParams); - - break; - } - case ServiceCategory.DRIVE_SERVICES: { - await fetchDirectories(pagingParams); - - break; - } - default: - break; - } - } catch { - setData([]); - handlePagingChange(pagingObject); - } finally { - setIsServiceLoading(false); - } - }, - [ - tab, - serviceCategory, - fetchDatabases, - fetchTopics, - fetchDashboards, - fetchPipeLines, - fetchMlModal, - fetchContainers, - fetchSearchIndexes, - fetchCollections, - fetchDirectories, - pageSize, - ] - ); - const fetchServiceDetails = useCallback(async () => { try { setIsLoading(true); @@ -1396,39 +1170,37 @@ const ServiceDetailsPage: FunctionComponent = () => { }, [isWorkflowStatusLoading, workflowStatesData?.mainInstanceState.status]); useEffect(() => { - if ( - !searchValue && - isInitialPaginationLoadRef.current && - activeTab !== getCountLabel(serviceCategory).toLowerCase() - ) { - getOtherDetails({ limit: pageSize }); - isInitialPaginationLoadRef.current = false; - } - }, [searchValue, activeTab, serviceCategory, pageSize, getOtherDetails]); - - useEffect(() => { - if ( - searchValue || - activeTab !== getCountLabel(serviceCategory).toLowerCase() - ) { + if (isMetadataService || isSecurityService) { return; } - const { cursorType, cursorValue } = pagingInfo?.pagingCursor ?? {}; - getOtherDetails({ - limit: pageSize, - ...(cursorType && { [cursorType]: cursorValue }), - }); - if (isInitialPaginationLoadRef.current) { - isInitialPaginationLoadRef.current = false; - } + const fetchEntityCount = async () => { + try { + const res = await searchQuery({ + pageNumber: 1, + pageSize: 0, + searchIndex: getSearchIndexForService(serviceCategory), + query: '', + queryFilter: buildSchemaQueryFilter( + 'service.fullyQualifiedName.keyword', + decodedServiceFQN, + searchValue ? toString(searchValue) : undefined + ), + includeDeleted: showDeleted, + trackTotalHits: true, + }); + setServiceEntityCount(res.hits.total.value); + } catch { + // count fetch is best-effort + } + }; + fetchEntityCount(); }, [ + serviceCategory, + decodedServiceFQN, + isMetadataService, + isSecurityService, showDeleted, - deleted, - pageSize, searchValue, - pagingInfo?.pagingCursor, - activeTab, - serviceCategory, ]); useEffect(() => { @@ -1753,27 +1525,9 @@ const ServiceDetailsPage: FunctionComponent = () => { }, { name: getCountLabel(serviceCategory), - key: getCountLabel(serviceCategory).toLowerCase(), - count: paging.total, - children: ( - - ), + key: EntityTabs.DETAILS, + count: serviceEntityCount, + children: , } ); } @@ -1872,7 +1626,7 @@ const ServiceDetailsPage: FunctionComponent = () => { // Merge core tabs and plugin tabs const allTabs = [...tabs, ...pluginTabs]; - return allTabs + const mappedTabs = allTabs .filter((tab) => !tab.isHidden) .map((tab) => ({ label: ( @@ -1886,20 +1640,21 @@ const ServiceDetailsPage: FunctionComponent = () => { key: tab.key, children: tab.children, })); + + return getDetailsTabWithNewLabel( + mappedTabs, + customizedPage?.tabs, + EntityTabs.DETAILS + ); }, [ currentUser, - currentPage, serviceDetails, isAdminUser, serviceCategory, - paging, servicePermission, handleDescriptionUpdate, showDeleted, handleShowDeleted, - data, - isServiceLoading, - getOtherDetails, saveUpdatedServiceData, dataModelPaging, ingestionPaging, @@ -1928,6 +1683,7 @@ const ServiceDetailsPage: FunctionComponent = () => { extensionRegistry, decodedServiceFQN, isOpenMetadataService, + customizedPage, ]); const servicePermissionWithTrigger = useMemo( () => ({ @@ -1994,15 +1750,22 @@ const ServiceDetailsPage: FunctionComponent = () => { /> -
- - + + customizedPage={customizedPage} + data={serviceDetails} + permissions={servicePermission} + type={entityType as CustomizeEntityType} + onUpdate={saveUpdatedServiceData}> + + + + )} diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CustomizePage/CustomizePageUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/CustomizePage/CustomizePageUtils.ts index 62c50174787a..2fa51f4820bc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CustomizePage/CustomizePageUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CustomizePage/CustomizePageUtils.ts @@ -42,6 +42,7 @@ import metricDetailsClassBase from '../MetricEntityUtils/MetricDetailsClassBase' import mlModelClassBase from '../MlModel/MlModelClassBase'; import pipelineClassBase from '../PipelineClassBase'; import searchIndexClassBase from '../SearchIndexDetailsClassBase'; +import serviceDetailsClassBase from '../ServiceDetailsPage/ServiceDetailsClassBase'; import spreadsheetClassBase from '../SpreadsheetClassBase'; import storedProcedureClassBase from '../StoredProcedureClassBase'; import tableClassBase from '../TableClassBase'; @@ -178,6 +179,8 @@ export const getDefaultTabs = (pageType?: string): Tab[] => { return spreadsheetClassBase.getSpreadsheetDetailPageTabsIds(); case PageType.Worksheet: return worksheetClassBase.getWorksheetDetailPageTabsIds(); + case PageType.Service: + return serviceDetailsClassBase.getServiceDetailPageTabsIds(); default: return [ { @@ -244,6 +247,8 @@ export const getDefaultWidgetForTab = (pageType: PageType, tab: EntityTabs) => { return spreadsheetClassBase.getDefaultLayout(tab); case PageType.Worksheet: return worksheetClassBase.getDefaultLayout(tab); + case PageType.Service: + return serviceDetailsClassBase.getDefaultLayout(tab); default: return []; } @@ -327,6 +332,8 @@ export const getCustomizableWidgetByPage = ( return spreadsheetClassBase.getCommonWidgetList(); case PageType.Worksheet: return worksheetClassBase.getCommonWidgetList(); + case PageType.Service: + return serviceDetailsClassBase.getCommonWidgetList(); case PageType.LandingPage: default: return []; @@ -381,6 +388,8 @@ export const getDummyDataByPage = (pageType: PageType) => { return spreadsheetClassBase.getDummyData(); case PageType.Worksheet: return worksheetClassBase.getDummyData(); + case PageType.Service: + return serviceDetailsClassBase.getDummyData() as EntityUnion; case PageType.LandingPage: default: return {} as EntityUnion; @@ -442,6 +451,8 @@ export const getWidgetsFromKey = ( return spreadsheetClassBase.getWidgetsFromKey(widgetConfig); case PageType.Worksheet: return worksheetClassBase.getWidgetsFromKey(widgetConfig); + case PageType.Service: + return serviceDetailsClassBase.getWidgetsFromKey(widgetConfig); default: return null; } @@ -501,6 +512,8 @@ export const getWidgetHeight = (pageType: PageType, widgetName: string) => { return spreadsheetClassBase.getWidgetHeight(widgetName); case PageType.Worksheet: return worksheetClassBase.getWidgetHeight(widgetName); + case PageType.Service: + return serviceDetailsClassBase.getWidgetHeight(widgetName); default: return 0; } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GenericWidget/GenericWidgetUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/GenericWidget/GenericWidgetUtils.tsx index 14fcbb015717..c223aa4437e4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GenericWidget/GenericWidgetUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GenericWidget/GenericWidgetUtils.tsx @@ -31,6 +31,7 @@ import { EntityUnion } from '../../components/Explore/ExplorePage.interface'; import GlossaryTermTab from '../../components/Glossary/GlossaryTermTab/GlossaryTermTab.component'; import MlModelFeaturesList from '../../components/MlModel/MlModelDetail/MlModelFeaturesList'; import { PipelineTaskTab } from '../../components/Pipeline/PipelineTaskTab/PipelineTaskTab'; +import ServiceEntityTable from '../../components/Service/ServiceEntityTable/ServiceEntityTable'; import TagsViewer from '../../components/Tag/TagsViewer/TagsViewer'; import { DisplayType } from '../../components/Tag/TagsViewer/TagsViewer.interface'; import TopicSchemaFields from '../../components/Topic/TopicSchema/TopicSchema'; @@ -201,4 +202,7 @@ export const WIDGET_COMPONENTS = { widgetKey={DetailPageWidgetKeys.MARKETPLACE_DOMAINS} /> ), + [DetailPageWidgetKeys.SERVICE_ENTITY_TABLE]: () => ( + + ), } as const; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.test.ts index a271f56a7a92..b4df2ee1ee43 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.test.ts @@ -42,6 +42,11 @@ describe('PersonaUtils', () => { label: 'label.governance', icon: 'svg-mock', }), + expect.objectContaining({ + key: PageType.Service, + label: 'label.service', + icon: 'svg-mock', + }), expect.objectContaining({ key: 'data-assets', label: 'label.data-asset-plural', @@ -139,6 +144,7 @@ describe('PersonaUtils', () => { expect(keys).not.toContain(PageType.LandingPage); expect(keys).not.toContain(PageType.Tag); expect(keys).not.toContain(PageType.Classification); + expect(keys).not.toContain(PageType.Service); }); it('should include all other entities in data-assets category', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.ts index e39715c94575..0400d05f61f6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/Persona/PersonaUtils.ts @@ -33,6 +33,7 @@ import { ReactComponent as MlModelIcon } from '../../assets/svg/ml-models-colore import { ReactComponent as NavigationIcon } from '../../assets/svg/navigation.svg'; import { ReactComponent as PipelineIcon } from '../../assets/svg/pipelines-colored-new.svg'; import { ReactComponent as SearchIndexIcon } from '../../assets/svg/search-index-colored-new.svg'; +import { ReactComponent as ServiceIcon } from '../../assets/svg/setting-services-omd.svg'; import { ReactComponent as SpreadsheetIcon } from '../../assets/svg/spreadsheet-colored-new.svg'; import { ReactComponent as StorageIcon } from '../../assets/svg/storage-colored-new.svg'; import { ReactComponent as StoredProcedureIcon } from '../../assets/svg/stored-procedures-colored-new.svg'; @@ -80,6 +81,7 @@ const ENTITY_ICONS: Record = { [PageType.Tag]: TagIcon, [PageType.DataProduct]: DataProductIcon, [PageType.DataMarketplace]: DataProductIcon, + [PageType.Service]: ServiceIcon, }; export const getCustomizePageCategories = (): SettingMenuItem[] => { @@ -112,6 +114,14 @@ export const getCustomizePageCategories = (): SettingMenuItem[] => { description: 'Customize the Govern pages with widget of your preference', icon: ENTITY_ICONS['govern'], }, + { + key: PageType.Service, + label: i18n.t('label.service'), + description: i18n.t('message.entity-customize-description', { + entity: i18n.t('label.service'), + }), + icon: ENTITY_ICONS[PageType.Service], + }, { key: 'data-assets', label: i18n.t('label.data-asset-plural'), @@ -165,6 +175,7 @@ export const getCustomizePageOptions = ( PageType.Tag, PageType.Classification, PageType.DataMarketplace, + PageType.Service, ].includes(item) ) { acc.push(generateSettingItems(item)); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceDetailsPage/ServiceDetailsClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceDetailsPage/ServiceDetailsClassBase.ts new file mode 100644 index 000000000000..05b6c4c6bd04 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceDetailsPage/ServiceDetailsClassBase.ts @@ -0,0 +1,231 @@ +/* + * 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 { + CUSTOM_PROPERTIES_WIDGET, + DATA_PRODUCTS_WIDGET, + DESCRIPTION_WIDGET, + GLOSSARY_TERMS_WIDGET, + GridSizes, + TAGS_WIDGET, +} from '../../constants/CustomizeWidgets.constants'; +import { SERVICE_DUMMY_DATA } from '../../constants/ServiceDetailPage.constants'; +import { DetailPageWidgetKeys } from '../../enums/CustomizeDetailPage.enum'; +import { EntityTabs } from '../../enums/entity.enum'; +import { Tab } from '../../generated/system/ui/uiCustomization'; +import { ServicesType } from '../../interface/service.interface'; +import { WidgetConfig } from '../../pages/CustomizablePage/CustomizablePage.interface'; +import { getTabLabelFromId } from '../CustomizePage/CustomizePageUtils'; +import i18n from '../i18next/LocalUtil'; +import { getServiceWidgetsFromKey } from './ServiceDetailsPage.util'; + +type ServiceWidgetKeys = + | DetailPageWidgetKeys.DESCRIPTION + | DetailPageWidgetKeys.SERVICE_ENTITY_TABLE + | DetailPageWidgetKeys.DATA_PRODUCTS + | DetailPageWidgetKeys.TAGS + | DetailPageWidgetKeys.GLOSSARY_TERMS + | DetailPageWidgetKeys.CUSTOM_PROPERTIES; + +class ServiceDetailsClassBase { + defaultWidgetHeight: Record; + + constructor() { + this.defaultWidgetHeight = { + [DetailPageWidgetKeys.DESCRIPTION]: 2, + [DetailPageWidgetKeys.SERVICE_ENTITY_TABLE]: 4, + [DetailPageWidgetKeys.DATA_PRODUCTS]: 2, + [DetailPageWidgetKeys.TAGS]: 2, + [DetailPageWidgetKeys.GLOSSARY_TERMS]: 2, + [DetailPageWidgetKeys.CUSTOM_PROPERTIES]: 4, + }; + } + + public getServiceDetailPageTabsIds(): Tab[] { + return [ + { + id: EntityTabs.INSIGHTS, + name: EntityTabs.INSIGHTS, + displayName: getTabLabelFromId(EntityTabs.INSIGHTS), + layout: [], + editable: false, + }, + { + id: EntityTabs.DETAILS, + name: EntityTabs.DETAILS, + displayName: getTabLabelFromId(EntityTabs.DETAILS), + layout: this.getDefaultLayout(EntityTabs.DETAILS), + editable: true, + }, + { + id: EntityTabs.DATA_Model, + name: EntityTabs.DATA_Model, + displayName: getTabLabelFromId(EntityTabs.DATA_Model), + layout: [], + editable: false, + }, + { + id: EntityTabs.FILES, + name: EntityTabs.FILES, + displayName: getTabLabelFromId(EntityTabs.FILES), + layout: [], + editable: false, + }, + { + id: EntityTabs.SPREADSHEETS, + name: EntityTabs.SPREADSHEETS, + displayName: getTabLabelFromId(EntityTabs.SPREADSHEETS), + layout: [], + editable: false, + }, + { + id: EntityTabs.AGENTS, + name: EntityTabs.AGENTS, + displayName: getTabLabelFromId(EntityTabs.AGENTS), + layout: [], + editable: false, + }, + { + id: EntityTabs.CONNECTION, + name: EntityTabs.CONNECTION, + displayName: getTabLabelFromId(EntityTabs.CONNECTION), + layout: [], + editable: false, + }, + ]; + } + + public getDefaultLayout(tab?: EntityTabs): WidgetConfig[] { + if (tab && tab !== EntityTabs.DETAILS) { + return []; + } + + return [ + { + h: + this.defaultWidgetHeight[DetailPageWidgetKeys.DESCRIPTION] + + this.defaultWidgetHeight[DetailPageWidgetKeys.SERVICE_ENTITY_TABLE] + + 0.5, + i: DetailPageWidgetKeys.LEFT_PANEL, + w: 6, + x: 0, + y: 0, + children: [ + { + h: this.defaultWidgetHeight[DetailPageWidgetKeys.DESCRIPTION], + i: DetailPageWidgetKeys.DESCRIPTION, + w: 1, + x: 0, + y: 0, + static: false, + }, + { + h: this.defaultWidgetHeight[ + DetailPageWidgetKeys.SERVICE_ENTITY_TABLE + ], + i: DetailPageWidgetKeys.SERVICE_ENTITY_TABLE, + w: 1, + x: 0, + y: 1, + static: false, + }, + ], + static: true, + }, + { + h: this.defaultWidgetHeight[DetailPageWidgetKeys.DATA_PRODUCTS], + i: DetailPageWidgetKeys.DATA_PRODUCTS, + w: 2, + x: 6, + y: 1, + static: false, + }, + { + h: this.defaultWidgetHeight[DetailPageWidgetKeys.TAGS], + i: DetailPageWidgetKeys.TAGS, + w: 2, + x: 6, + y: 2, + static: false, + }, + { + h: this.defaultWidgetHeight[DetailPageWidgetKeys.GLOSSARY_TERMS], + i: DetailPageWidgetKeys.GLOSSARY_TERMS, + w: 2, + x: 6, + y: 3, + static: false, + }, + { + h: this.defaultWidgetHeight[DetailPageWidgetKeys.CUSTOM_PROPERTIES], + i: DetailPageWidgetKeys.CUSTOM_PROPERTIES, + w: 2, + x: 6, + y: 6, + static: false, + }, + ]; + } + + public getDummyData(): ServicesType { + return SERVICE_DUMMY_DATA; + } + + public getCommonWidgetList() { + return [ + DESCRIPTION_WIDGET, + { + fullyQualifiedName: DetailPageWidgetKeys.SERVICE_ENTITY_TABLE, + name: i18n.t('label.entity-detail-plural', { + entity: i18n.t('label.service'), + }), + data: { + gridSizes: ['large'] as GridSizes[], + }, + }, + DATA_PRODUCTS_WIDGET, + TAGS_WIDGET, + GLOSSARY_TERMS_WIDGET, + CUSTOM_PROPERTIES_WIDGET, + ]; + } + + public getWidgetsFromKey(widgetConfig: WidgetConfig) { + return getServiceWidgetsFromKey(widgetConfig); + } + + public getWidgetHeight(widgetName: string) { + switch (widgetName) { + case DetailPageWidgetKeys.DESCRIPTION: + return this.defaultWidgetHeight[DetailPageWidgetKeys.DESCRIPTION]; + case DetailPageWidgetKeys.SERVICE_ENTITY_TABLE: + return this.defaultWidgetHeight[ + DetailPageWidgetKeys.SERVICE_ENTITY_TABLE + ]; + case DetailPageWidgetKeys.DATA_PRODUCTS: + return this.defaultWidgetHeight[DetailPageWidgetKeys.DATA_PRODUCTS]; + case DetailPageWidgetKeys.TAGS: + return this.defaultWidgetHeight[DetailPageWidgetKeys.TAGS]; + case DetailPageWidgetKeys.GLOSSARY_TERMS: + return this.defaultWidgetHeight[DetailPageWidgetKeys.GLOSSARY_TERMS]; + case DetailPageWidgetKeys.CUSTOM_PROPERTIES: + return this.defaultWidgetHeight[DetailPageWidgetKeys.CUSTOM_PROPERTIES]; + default: + return 1; + } + } +} + +const serviceDetailsClassBase = new ServiceDetailsClassBase(); + +export default serviceDetailsClassBase; +export { ServiceDetailsClassBase }; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceDetailsPage/ServiceDetailsPage.util.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceDetailsPage/ServiceDetailsPage.util.tsx new file mode 100644 index 000000000000..bc3f74e21a90 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceDetailsPage/ServiceDetailsPage.util.tsx @@ -0,0 +1,42 @@ +/* + * 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 { useGenericContext } from '../../components/Customization/GenericProvider/GenericProvider'; +import { CommonWidgets } from '../../components/DataAssets/CommonWidgets/CommonWidgets'; +import ServiceEntityTable from '../../components/Service/ServiceEntityTable/ServiceEntityTable'; +import { DetailPageWidgetKeys } from '../../enums/CustomizeDetailPage.enum'; +import { EntityType } from '../../enums/entity.enum'; +import { ServicesType } from '../../interface/service.interface'; +import { WidgetConfig } from '../../pages/CustomizablePage/CustomizablePage.interface'; + +const ServiceCommonWidgets = ({ + widgetConfig, +}: { + widgetConfig: WidgetConfig; +}) => { + const { type } = useGenericContext(); + + return ( + + ); +}; + +export const getServiceWidgetsFromKey = (widgetConfig: WidgetConfig) => { + if (widgetConfig.i.startsWith(DetailPageWidgetKeys.SERVICE_ENTITY_TABLE)) { + return ; + } + + return ; +};