#19882: supported bulk edit for entity (#20119)

* supported bulk edit for entity

* added support for other database level entity and update the edit button and remove the add row button

* added playwright test for databaseService,database,schema and Table

* added the banner so the info around the import should be present

* improve the route to protected, and move common button to utils

* revert the admin only route change as user with editAll permission can perform the action

* added check around the route, allow only for editAll access for the entity

* change the route to the entityRoute file to organize in single place
This commit is contained in:
Ashish Gupta 2025-03-13 15:50:41 +05:30 committed by GitHub
parent 99a2372fe4
commit a313e0ec69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1369 additions and 199 deletions

View File

@ -0,0 +1,575 @@
/*
* 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 } from '@playwright/test';
import { SERVICE_TYPE } from '../../constant/service';
import { GlobalSettingOptions } from '../../constant/settings';
import { EntityDataClass } from '../../support/entity/EntityDataClass';
import { TableClass } from '../../support/entity/TableClass';
import {
createNewPage,
descriptionBoxReadOnly,
getApiContext,
redirectToHomePage,
toastNotification,
} from '../../utils/common';
import {
createColumnRowDetails,
createCustomPropertiesForEntity,
createDatabaseRowDetails,
createDatabaseSchemaRowDetails,
createTableRowDetails,
fillDescriptionDetails,
fillGlossaryTermDetails,
fillRowDetails,
fillTagDetails,
pressKeyXTimes,
validateImportStatus,
} from '../../utils/importUtils';
import { visitServiceDetailsPage } from '../../utils/service';
// use the admin user to login
test.use({
storageState: 'playwright/.auth/admin.json',
});
const glossaryDetails = {
name: EntityDataClass.glossaryTerm1.data.name,
parent: EntityDataClass.glossary1.data.name,
};
const databaseSchemaDetails1 = {
...createDatabaseSchemaRowDetails(),
glossary: glossaryDetails,
};
const tableDetails1 = {
...createTableRowDetails(),
glossary: glossaryDetails,
};
const columnDetails1 = {
...createColumnRowDetails(),
glossary: glossaryDetails,
};
test.describe('Bulk Edit Entity', () => {
test.beforeAll('setup pre-test', async ({ browser }, testInfo) => {
const { apiContext, afterAction } = await createNewPage(browser);
testInfo.setTimeout(90000);
await EntityDataClass.preRequisitesForTests(apiContext);
await afterAction();
});
test.afterAll('Cleanup', async ({ browser }, testInfo) => {
const { apiContext, afterAction } = await createNewPage(browser);
testInfo.setTimeout(90000);
await EntityDataClass.postRequisitesForTests(apiContext);
await afterAction();
});
test.beforeEach(async ({ page }) => {
await redirectToHomePage(page);
});
test('Database service', async ({ page }) => {
test.slow(true);
const table = new TableClass();
let customPropertyRecord: Record<string, string> = {};
const { apiContext, afterAction } = await getApiContext(page);
await table.create(apiContext);
await test.step('create custom properties for extension edit', async () => {
customPropertyRecord = await createCustomPropertiesForEntity(
page,
GlobalSettingOptions.DATABASES
);
});
await test.step('Perform bulk edit action', async () => {
const databaseDetails = {
...createDatabaseRowDetails(),
domains: EntityDataClass.domain1.responseData,
glossary: glossaryDetails,
};
await visitServiceDetailsPage(
page,
{
name: table.service.name,
type: SERVICE_TYPE.Database,
},
false
);
await page.click('[data-testid="bulk-edit-table"]');
// Adding manual wait for the file to load
await page.waitForTimeout(500);
await page.waitForSelector('[data-testid="loader"]', {
state: 'detached',
});
// Adding some assertion to make sure that CSV loaded correctly
await expect(
page.locator('.InovuaReactDataGrid__header-layout')
).toBeVisible();
await expect(page.getByRole('button', { name: 'Next' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Previous' })
).not.toBeVisible();
// Click on first cell and edit
await page.click(
'.InovuaReactDataGrid__row--first > .InovuaReactDataGrid__row-cell-wrap > .InovuaReactDataGrid__cell--first'
);
await fillRowDetails(
{
...databaseDetails,
name: table.database.name,
owners: [
EntityDataClass.user1.responseData?.['displayName'],
EntityDataClass.user2.responseData?.['displayName'],
],
},
page,
customPropertyRecord
);
await page.getByRole('button', { name: 'Next' }).click();
await validateImportStatus(page, {
passed: '2',
processed: '2',
failed: '0',
});
await page.getByRole('button', { name: 'Update' }).click();
await page
.locator('.inovua-react-toolkit-load-mask__background-layer')
.waitFor({ state: 'detached' });
await toastNotification(page, /details updated successfully/);
// Verify Details updated
await expect(page.getByTestId('column-name')).toHaveText(
`${table.database.name}${databaseDetails.displayName}`
);
await expect(
page.locator(`.ant-table-cell ${descriptionBoxReadOnly}`)
).toContainText('Playwright Database description.');
// Verify Owners
await expect(
page.locator(`.ant-table-cell [data-testid="owner-label"]`)
).toContainText(EntityDataClass.user1.responseData?.['displayName']);
await expect(
page.locator(`.ant-table-cell [data-testid="owner-label"]`)
).toContainText(EntityDataClass.user2.responseData?.['displayName']);
// Verify Tags
await expect(
page.getByRole('link', {
name: 'Sensitive',
})
).toBeVisible();
await expect(
page.getByRole('link', {
name: 'Tier1',
})
).toBeVisible();
await expect(
page.getByRole('link', {
name: EntityDataClass.glossaryTerm1.data.displayName,
})
).toBeVisible();
});
await table.delete(apiContext);
await afterAction();
});
test('Database', async ({ page }) => {
test.slow(true);
let customPropertyRecord: Record<string, string> = {};
const table = new TableClass();
const { apiContext, afterAction } = await getApiContext(page);
await table.create(apiContext);
await test.step('create custom properties for extension edit', async () => {
customPropertyRecord = await createCustomPropertiesForEntity(
page,
GlobalSettingOptions.DATABASE_SCHEMA
);
});
await test.step('Perform bulk edit action', async () => {
// visit entity Page
await visitServiceDetailsPage(
page,
{
name: table.service.name,
type: SERVICE_TYPE.Database,
},
false
);
const databaseResponse = page.waitForResponse(
`/api/v1/databases/name/*${table.database.name}?**`
);
await page.getByTestId(table.database.name).click();
await databaseResponse;
await page.click('[data-testid="bulk-edit-table"]');
// Adding manual wait for the file to load
await page.waitForTimeout(500);
await page.waitForSelector('[data-testid="loader"]', {
state: 'detached',
});
// Adding some assertion to make sure that CSV loaded correctly
await expect(
page.locator('.InovuaReactDataGrid__header-layout')
).toBeVisible();
await expect(page.getByRole('button', { name: 'Next' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Previous' })
).not.toBeVisible();
// click on last row first cell
await page.click(
'.InovuaReactDataGrid__row--first > .InovuaReactDataGrid__row-cell-wrap > .InovuaReactDataGrid__cell--first'
);
// Click on first cell and edit
await fillRowDetails(
{
...databaseSchemaDetails1,
name: table.schema.name,
owners: [
EntityDataClass.user1.responseData?.['displayName'],
EntityDataClass.user2.responseData?.['displayName'],
],
domains: EntityDataClass.domain1.responseData,
},
page,
customPropertyRecord
);
await page.getByRole('button', { name: 'Next' }).click();
const loader = page.locator(
'.inovua-react-toolkit-load-mask__background-layer'
);
await loader.waitFor({ state: 'hidden' });
await validateImportStatus(page, {
passed: '2',
processed: '2',
failed: '0',
});
await page.waitForSelector('.InovuaReactDataGrid__header-layout', {
state: 'visible',
});
await page.getByRole('button', { name: 'Update' }).click();
await page
.locator('.inovua-react-toolkit-load-mask__background-layer')
.waitFor({ state: 'detached' });
await toastNotification(page, /details updated successfully/);
// Verify Details updated
await expect(page.getByTestId('column-name')).toHaveText(
`${table.schema.name}${databaseSchemaDetails1.displayName}`
);
await expect(
page.locator(`.ant-table-cell ${descriptionBoxReadOnly}`)
).toContainText('Playwright Database Schema description.');
// Verify Owners
await expect(
page.locator(`.ant-table-cell [data-testid="owner-label"]`)
).toContainText(EntityDataClass.user1.responseData?.['displayName']);
await expect(
page.locator(`.ant-table-cell [data-testid="owner-label"]`)
).toContainText(EntityDataClass.user2.responseData?.['displayName']);
await page.getByTestId('column-display-name').click();
await page.waitForLoadState('networkidle');
await page.waitForSelector('loader', { state: 'hidden' });
// Verify Tags
await expect(
page.getByRole('link', {
name: 'Sensitive',
})
).toBeVisible();
await expect(
page.getByRole('link', {
name: 'Tier1',
})
).toBeVisible();
await expect(
page.getByRole('link', {
name: EntityDataClass.glossaryTerm1.data.displayName,
})
).toBeVisible();
});
await table.delete(apiContext);
await afterAction();
});
test('Database Schema', async ({ page }) => {
test.slow(true);
let customPropertyRecord: Record<string, string> = {};
const table = new TableClass();
const { apiContext, afterAction } = await getApiContext(page);
await table.create(apiContext);
await test.step('create custom properties for extension edit', async () => {
customPropertyRecord = await createCustomPropertiesForEntity(
page,
GlobalSettingOptions.TABLES
);
});
await test.step('Perform bulk edit action', async () => {
// visit entity page
await visitServiceDetailsPage(
page,
{
name: table.service.name,
type: SERVICE_TYPE.Database,
},
false
);
const databaseResponse = page.waitForResponse(
`/api/v1/databases/name/*${table.database.name}?**`
);
await page.getByTestId(table.database.name).click();
await databaseResponse;
const databaseSchemaResponse = page.waitForResponse(
`/api/v1/databaseSchemas/name/*${table.schema.name}?*`
);
await page.getByTestId(table.schema.name).click();
await databaseSchemaResponse;
await page.click('[data-testid="bulk-edit-table"]');
// Adding manual wait for the file to load
await page.waitForTimeout(500);
// Adding some assertion to make sure that CSV loaded correctly
await expect(
page.locator('.InovuaReactDataGrid__header-layout')
).toBeVisible();
await expect(page.getByRole('button', { name: 'Next' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Previous' })
).not.toBeVisible();
// Click on first cell and edit
await page.click(
'.InovuaReactDataGrid__row--first > .InovuaReactDataGrid__row-cell-wrap > .InovuaReactDataGrid__cell--first'
);
await fillRowDetails(
{
...tableDetails1,
name: table.entity.name,
owners: [
EntityDataClass.user1.responseData?.['displayName'],
EntityDataClass.user2.responseData?.['displayName'],
],
domains: EntityDataClass.domain1.responseData,
},
page,
customPropertyRecord
);
await page.getByRole('button', { name: 'Next' }).click();
await validateImportStatus(page, {
passed: '2',
processed: '2',
failed: '0',
});
await page.getByRole('button', { name: 'Update' }).click();
await toastNotification(page, /details updated successfully/);
// Verify Details updated
await expect(page.getByTestId('column-name')).toHaveText(
`${table.entity.name}${tableDetails1.displayName}`
);
await expect(
page.locator(`.ant-table-cell ${descriptionBoxReadOnly}`)
).toContainText('Playwright Table description');
// Go to Table Page
await page
.getByTestId('column-display-name')
.getByTestId(table.entity.name)
.click();
await page.waitForLoadState('networkidle');
await page.waitForSelector('loader', { state: 'hidden' });
// Verify Domain
await expect(page.getByTestId('domain-link')).toContainText(
EntityDataClass.domain1.responseData.displayName
);
// Verify Owners
await expect(page.getByTestId('owner-label')).toContainText(
EntityDataClass.user1.responseData?.['displayName']
);
await expect(page.getByTestId('owner-label')).toContainText(
EntityDataClass.user2.responseData?.['displayName']
);
// Verify Tags
await expect(
page.getByRole('link', {
name: 'Sensitive',
})
).toBeVisible();
await expect(
page.getByRole('link', {
name: 'Tier1',
})
).toBeVisible();
await expect(
page.getByRole('link', {
name: EntityDataClass.glossaryTerm1.data.displayName,
})
).toBeVisible();
});
await table.delete(apiContext);
await afterAction();
});
test('Table', async ({ page }) => {
test.slow();
const tableEntity = new TableClass();
const { apiContext, afterAction } = await getApiContext(page);
await tableEntity.create(apiContext);
await test.step('Perform bulk edit action', async () => {
await tableEntity.visitEntityPage(page);
await page.click('[data-testid="bulk-edit-table"]');
// Adding manual wait for the file to load
await page.waitForTimeout(500);
// Adding some assertion to make sure that CSV loaded correctly
await expect(
page.locator('.InovuaReactDataGrid__header-layout')
).toBeVisible();
await expect(page.getByRole('button', { name: 'Next' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Previous' })
).not.toBeVisible();
// click on row first cell
await page.click(
'.InovuaReactDataGrid__row--first > .InovuaReactDataGrid__row-cell-wrap > .InovuaReactDataGrid__cell--first'
);
await page.click('.InovuaReactDataGrid__cell--cell-active');
await pressKeyXTimes(page, 2, 'ArrowRight');
await fillDescriptionDetails(page, columnDetails1.description);
await pressKeyXTimes(page, 5, 'ArrowRight');
await fillTagDetails(page, columnDetails1.tag);
await page
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowRight', { delay: 100 });
await fillGlossaryTermDetails(page, columnDetails1.glossary);
// Reverse traves to first cell to fill the details
await page.click('.InovuaReactDataGrid__cell--cell-active');
await page
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowDown', { delay: 100 });
await page.click('[type="button"] >> text="Next"', { force: true });
await validateImportStatus(page, {
passed: '7',
processed: '7',
failed: '0',
});
await page.click('[type="button"] >> text="Update"', { force: true });
await page
.locator('.inovua-react-toolkit-load-mask__background-layer')
.waitFor({ state: 'detached' });
await toastNotification(page, /details updated successfully/);
// Verify Details updated
await expect(
page.getByRole('cell', { name: 'Playwright Table column' })
).toBeVisible();
// Verify Tags
await expect(
page.getByRole('link', {
name: 'Sensitive',
})
).toBeVisible();
await expect(
page.getByRole('link', {
name: EntityDataClass.glossaryTerm1.data.displayName,
})
).toBeVisible();
});
await tableEntity.delete(apiContext);
await afterAction();
});
});

View File

@ -23,7 +23,6 @@ import { Operation } from '../../generated/entity/policies/policy';
import AddCustomMetricPage from '../../pages/AddCustomMetricPage/AddCustomMetricPage';
import { CustomizablePage } from '../../pages/CustomizablePage/CustomizablePage';
import DataQualityPage from '../../pages/DataQuality/DataQualityPage';
import BulkEntityImportPage from '../../pages/EntityImport/BulkEntityImportPage/BulkEntityImportPage';
import ForbiddenPage from '../../pages/ForbiddenPage/ForbiddenPage';
import TagPage from '../../pages/TagPage/TagPage';
import { checkPermission, userPermissions } from '../../utils/PermissionsUtils';
@ -282,13 +281,6 @@ const AuthenticatedAppRouter: FunctionComponent = () => {
<Switch>
<Route exact component={ForbiddenPage} path={ROUTES.FORBIDDEN} />
{/* Handle Entity Import pages */}
<Route
exact
component={BulkEntityImportPage}
path={ROUTES.ENTITY_IMPORT}
/>
<Route exact component={MyDataPage} path={ROUTES.MY_DATA} />
<Route exact component={TourPageComponent} path={ROUTES.TOUR} />
<Route exact component={ExplorePageV1} path={ROUTES.EXPLORE} />

View File

@ -0,0 +1,71 @@
/*
* 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 React, { useCallback, useEffect, useState } from 'react';
import { Redirect, Route, Switch, useHistory, useParams } from 'react-router';
import { SUPPORTED_BULK_IMPORT_EDIT_ENTITY } from '../../constants/BulkImport.constant';
import { ROUTES } from '../../constants/constants';
import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider';
import { ResourceEntity } from '../../context/PermissionProvider/PermissionProvider.interface';
import { useFqn } from '../../hooks/useFqn';
import BulkEntityImportPage from '../../pages/EntityImport/BulkEntityImportPage/BulkEntityImportPage';
import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils';
const EntityImportRouter = () => {
const history = useHistory();
const { fqn } = useFqn();
const { entityType } = useParams<{ entityType: ResourceEntity }>();
const { getEntityPermissionByFqn } = usePermissionProvider();
const [isLoading, setIsLoading] = useState(true);
const [entityPermission, setEntityPermission] = useState(
DEFAULT_ENTITY_PERMISSION
);
const fetchResourcePermission = useCallback(async () => {
try {
setIsLoading(true);
const entityPermission = await getEntityPermissionByFqn(entityType, fqn);
setEntityPermission(entityPermission);
} catch (error) {
// will not show logs
} finally {
setIsLoading(false);
}
}, [entityType, fqn]);
useEffect(() => {
if (fqn && SUPPORTED_BULK_IMPORT_EDIT_ENTITY.includes(entityType)) {
fetchResourcePermission();
} else {
history.push(ROUTES.NOT_FOUND);
}
}, [fqn, entityType]);
if (isLoading) {
return null;
}
return (
<Switch>
{entityPermission.EditAll && (
<Route
exact
component={BulkEntityImportPage}
path={[ROUTES.ENTITY_IMPORT, ROUTES.BULK_EDIT_ENTITY_WITH_FQN]}
/>
)}
<Redirect to={ROUTES.NOT_FOUND} />
</Switch>
);
};
export default EntityImportRouter;

View File

@ -16,6 +16,7 @@ import { ROUTES } from '../../constants/constants';
import { EntityType } from '../../enums/entity.enum';
import EntityVersionPage from '../../pages/EntityVersionPage/EntityVersionPage.component';
import entityUtilClassBase from '../../utils/EntityUtilClassBase';
import EntityImportRouter from './EntityImportRouter';
const EntityRouter = () => {
const { entityType } = useParams<{ entityType: EntityType }>();
@ -27,6 +28,12 @@ const EntityRouter = () => {
return (
<Switch>
{/* Handle Entity Import and Edit pages */}
<Route
component={EntityImportRouter}
path={[ROUTES.ENTITY_IMPORT, ROUTES.BULK_EDIT_ENTITY_WITH_FQN]}
/>
<Route
exact
component={EntityVersionPage}

View File

@ -0,0 +1,183 @@
/*
* 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 ReactDataGrid from '@inovua/reactdatagrid-community';
import { Button, Col, Row } from 'antd';
import { isEmpty } from 'lodash';
import React, { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { readString } from 'react-papaparse';
import { useParams } from 'react-router-dom';
import { ENTITY_BULK_EDIT_STEPS } from '../../constants/BulkEdit.constants';
import { EntityType } from '../../enums/entity.enum';
import { useFqn } from '../../hooks/useFqn';
import {
getBulkEditCSVExportEntityApi,
getBulkEntityEditBreadcrumbList,
} from '../../utils/EntityBulkEdit/EntityBulkEditUtils';
import Banner from '../common/Banner/Banner';
import { ImportStatus } from '../common/EntityImport/ImportStatus/ImportStatus.component';
import Loader from '../common/Loader/Loader';
import TitleBreadcrumb from '../common/TitleBreadcrumb/TitleBreadcrumb.component';
import { TitleBreadcrumbProps } from '../common/TitleBreadcrumb/TitleBreadcrumb.interface';
import { useEntityExportModalProvider } from '../Entity/EntityExportModalProvider/EntityExportModalProvider.component';
import Stepper from '../Settings/Services/Ingestion/IngestionStepper/IngestionStepper.component';
import { BulkEditEntityProps } from './BulkEditEntity.interface';
const BulkEditEntity = ({
onKeyDown,
onEditStop,
onEditStart,
onEditComplete,
dataSource,
columns,
setGridRef,
activeStep,
handleBack,
handleValidate,
isValidating,
validationData,
validateCSVData,
activeAsyncImportJob,
onCSVReadComplete,
}: BulkEditEntityProps) => {
const { t } = useTranslation();
const { fqn } = useFqn();
const { entityType } = useParams<{ entityType: EntityType }>();
const { triggerExportForBulkEdit, csvExportData, clearCSVExportData } =
useEntityExportModalProvider();
const breadcrumbList: TitleBreadcrumbProps['titleLinks'] = useMemo(
() => getBulkEntityEditBreadcrumbList(entityType, fqn),
[entityType, fqn]
);
useEffect(() => {
triggerExportForBulkEdit({
name: fqn,
onExport: getBulkEditCSVExportEntityApi(entityType),
});
}, []);
useEffect(() => {
if (csvExportData) {
readString(csvExportData, {
worker: true,
skipEmptyLines: true,
complete: onCSVReadComplete,
});
}
}, [csvExportData]);
useEffect(() => {
// clear the csvExportData data from the state
return () => {
clearCSVExportData();
};
}, []);
return (
<>
<Col span={24}>
<TitleBreadcrumb titleLinks={breadcrumbList} />
</Col>
<Col span={24}>
<Stepper
activeStep={activeStep}
className="w-max-600 mx-auto"
steps={ENTITY_BULK_EDIT_STEPS}
/>
</Col>
<Col span={24}>
{activeAsyncImportJob?.jobId && (
<Banner
className="border-radius"
isLoading={!activeAsyncImportJob.error}
message={
activeAsyncImportJob.error ?? activeAsyncImportJob.message ?? ''
}
type={activeAsyncImportJob.error ? 'error' : 'success'}
/>
)}
</Col>
{isEmpty(csvExportData) ? (
<Loader />
) : (
<>
<Col span={24}>
{activeStep === 1 && (
<ReactDataGrid
editable
columns={columns}
dataSource={dataSource}
defaultActiveCell={[0, 0]}
handle={setGridRef}
idProperty="id"
loading={isValidating}
minRowHeight={30}
showZebraRows={false}
style={{ height: 'calc(100vh - 245px)' }}
onEditComplete={onEditComplete}
onEditStart={onEditStart}
onEditStop={onEditStop}
onKeyDown={onKeyDown}
/>
)}
{activeStep === 2 && validationData && (
<Row gutter={[16, 16]}>
<Col span={24}>
<ImportStatus csvImportResult={validationData} />
</Col>
<Col span={24}>
{validateCSVData && (
<ReactDataGrid
idProperty="id"
loading={isValidating}
style={{ height: 'calc(100vh - 300px)' }}
{...validateCSVData}
/>
)}
</Col>
</Row>
)}
</Col>
{activeStep > 0 && (
<Col span={24}>
<div className="float-right import-footer">
{activeStep > 1 && (
<Button disabled={isValidating} onClick={handleBack}>
{t('label.previous')}
</Button>
)}
{activeStep < 3 && (
<Button
className="m-l-sm"
disabled={isValidating}
type="primary"
onClick={handleValidate}>
{activeStep === 2 ? t('label.update') : t('label.next')}
</Button>
)}
</div>
</Col>
)}
</>
)}
</>
);
};
export default BulkEditEntity;

View File

@ -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 {
TypeColumn,
TypeComputedProps,
} from '@inovua/reactdatagrid-community/types';
import { VALIDATION_STEP } from '../../constants/BulkImport.constant';
import { CSVImportResult } from '../../generated/type/csvImportResult';
import { CSVImportJobType } from '../BulkImport/BulkEntityImport.interface';
export interface BulkEditEntityProps {
dataSource: Record<string, string>[];
columns: TypeColumn[];
activeStep: VALIDATION_STEP;
activeAsyncImportJob?: CSVImportJobType;
isValidating: boolean;
validationData?: CSVImportResult;
validateCSVData?: {
columns: TypeColumn[];
dataSource: Record<string, string>[];
};
handleBack: () => void;
handleValidate: () => Promise<void>;
setGridRef: React.Dispatch<
React.SetStateAction<React.MutableRefObject<TypeComputedProps | null>>
>;
onKeyDown: (event: KeyboardEvent) => void;
onEditStop: () => void;
onEditStart: () => void;
onCSVReadComplete: (results: { data: string[][] }) => void;
onEditComplete: ({ value, columnId, rowId }: any) => void;
}

View File

@ -47,8 +47,9 @@ import {
patchDatabaseSchemaDetails,
} from '../../../../rest/databaseAPI';
import { searchQuery } from '../../../../rest/searchAPI';
import { getBulkEditButton } from '../../../../utils/EntityBulkEdit/EntityBulkEditUtils';
import {
getEntityName,
getEntityBulkEditPath,
highlightSearchText,
} from '../../../../utils/EntityUtils';
import { stringToHTML } from '../../../../utils/StringsUtils';
@ -58,6 +59,7 @@ import DisplayName from '../../../common/DisplayName/DisplayName';
import ErrorPlaceHolder from '../../../common/ErrorWithPlaceholder/ErrorPlaceHolder';
import NextPrevious from '../../../common/NextPrevious/NextPrevious';
import { PagingHandlerParams } from '../../../common/NextPrevious/NextPrevious.interface';
import { OwnerLabel } from '../../../common/OwnerLabel/OwnerLabel.component';
import RichTextEditorPreviewerV1 from '../../../common/RichTextEditor/RichTextEditorPreviewerV1';
import Searchbar from '../../../common/SearchBarComponent/SearchBar.component';
import Table from '../../../common/Table/Table';
@ -271,7 +273,7 @@ export const DatabaseSchemaTable = ({
width: 120,
render: (owners: EntityReference[]) =>
!isEmpty(owners) && owners.length > 0 ? (
owners.map((owner: EntityReference) => getEntityName(owner))
<OwnerLabel owners={owners} />
) : (
<Typography.Text data-testid="no-owner-text">
{NO_DATA_PLACEHOLDER}
@ -290,6 +292,12 @@ export const DatabaseSchemaTable = ({
[handleDisplayNameUpdate, allowEditDisplayNamePermission]
);
const handleEditTable = () => {
history.push({
pathname: getEntityBulkEditPath(EntityType.DATABASE, decodedDatabaseFQN),
});
};
useEffect(() => {
fetchDatabaseSchema();
}, [decodedDatabaseFQN, pageSize, showDeletedSchemas, isDatabaseDeleted]);
@ -324,6 +332,10 @@ export const DatabaseSchemaTable = ({
data-testid="database-databaseSchemas"
dataSource={schemas}
defaultVisibleColumns={DEFAULT_DATABASE_SCHEMA_VISIBLE_COLUMNS}
extraTableFilters={getBulkEditButton(
permissions.databaseSchema.EditAll,
handleEditTable
)}
loading={isLoading}
locale={{
emptyText: <ErrorPlaceHolder className="m-y-md" />,

View File

@ -27,6 +27,7 @@ import {
import { EntityTags, TagFilterOptions } from 'Models';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { ReactComponent as IconEdit } from '../../../assets/svg/edit-new.svg';
import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants';
import {
@ -54,8 +55,10 @@ import { TagLabel } from '../../../generated/type/tagLabel';
import { useFqn } from '../../../hooks/useFqn';
import { getTestCaseExecutionSummary } from '../../../rest/testAPI';
import { getPartialNameFromTableFQN } from '../../../utils/CommonUtils';
import { getBulkEditButton } from '../../../utils/EntityBulkEdit/EntityBulkEditUtils';
import {
getColumnSorter,
getEntityBulkEditPath,
getEntityName,
getFrequentlyJoinedColumns,
highlightSearchArrayElement,
@ -98,6 +101,7 @@ import {
const SchemaTable = () => {
const { t } = useTranslation();
const history = useHistory();
const [testCaseSummary, setTestCaseSummary] = useState<TestSummary>();
const [searchedColumns, setSearchedColumns] = useState<Column[]>([]);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
@ -573,6 +577,12 @@ const SchemaTable = () => {
</Form.Item>
);
const handleEditTable = () => {
history.push({
pathname: getEntityBulkEditPath(EntityType.TABLE, decodedEntityFqn),
});
};
useEffect(() => {
setExpandedRowKeys(nestedTableFqnKeys);
}, [searchText]);
@ -596,6 +606,15 @@ const SchemaTable = () => {
return (
<Row gutter={[16, 16]}>
<Col span={8}>
<Searchbar
removeMargin
placeholder={t('message.find-in-table')}
searchValue={searchText}
typingInterval={500}
onSearch={handleSearchAction}
/>
</Col>
<Col id="schemaDetails" span={24}>
<Table
bordered
@ -605,16 +624,10 @@ const SchemaTable = () => {
dataSource={data}
defaultVisibleColumns={DEFAULT_SCHEMA_TABLE_VISIBLE_COLUMNS}
expandable={expandableConfig}
extraTableFilters={
<Searchbar
removeMargin
placeholder={t('message.find-in-table')}
searchValue={searchText}
typingInterval={500}
onSearch={handleSearchAction}
/>
}
extraTableFiltersClassName="justify-between"
extraTableFilters={getBulkEditButton(
tablePermissions.EditAll,
handleEditTable
)}
locale={{
emptyText: <FilterTablePlaceHolder />,
}}

View File

@ -14,9 +14,18 @@ import { Form, Input, Modal } from 'antd';
import { AxiosError } from 'axios';
import classNames from 'classnames';
import { isString } from 'lodash';
import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import React, {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { getCurrentISODate } from '../../../utils/date-time/DateTimeUtils';
import { isBulkEditRoute } from '../../../utils/EntityBulkEdit/EntityBulkEditUtils';
import { showErrorToast } from '../../../utils/ToastUtils';
import Banner from '../../common/Banner/Banner';
import {
@ -38,6 +47,8 @@ export const EntityExportModalProvider = ({
}) => {
const [form] = Form.useForm();
const { t } = useTranslation();
const location = useLocation();
const [exportData, setExportData] = useState<ExportData | null>(null);
const [downloading, setDownloading] = useState<boolean>(false);
@ -45,6 +56,13 @@ export const EntityExportModalProvider = ({
const [csvExportJob, setCSVExportJob] = useState<Partial<CSVExportJob>>();
const [csvExportData, setCSVExportData] = useState<string>();
const isBulkEdit = useMemo(
() => isBulkEditRoute(location.pathname),
[location]
);
const handleCancel = () => {
setExportData(null);
};
@ -106,65 +124,82 @@ export const EntityExportModalProvider = ({
}
};
const handleCSVExportSuccess = (data: string, fileName?: string) => {
handleDownload(
data,
fileName ?? `${exportData?.name}_${getCurrentISODate()}`
);
setDownloading(false);
handleCancel();
setCSVExportJob(undefined);
csvExportJobRef.current = undefined;
};
const handleCSVExportJobUpdate = (
response: Partial<CSVExportWebsocketResponse>
) => {
// If multiple tab is open, then we need to check if the tab has active job or not before initiating the download
if (!csvExportJobRef.current) {
return;
}
const updatedCSVExportJob: Partial<CSVExportJob> = {
...response,
...csvExportJobRef.current,
};
setCSVExportJob(updatedCSVExportJob);
csvExportJobRef.current = updatedCSVExportJob;
if (response.status === 'COMPLETED' && response.data) {
handleCSVExportSuccess(
response.data ?? '',
csvExportJobRef.current?.fileName
);
} else {
const handleCSVExportSuccess = useCallback(
(data: string, fileName?: string) => {
if (isBulkEdit) {
setCSVExportData(data);
} else {
handleDownload(
data,
fileName ?? `${exportData?.name}_${getCurrentISODate()}`
);
}
setDownloading(false);
}
};
handleCancel();
setCSVExportJob(undefined);
csvExportJobRef.current = undefined;
},
[isBulkEdit]
);
const handleCSVExportJobUpdate = useCallback(
(response: Partial<CSVExportWebsocketResponse>) => {
// If multiple tab is open, then we need to check if the tab has active job or not before initiating the download
if (!csvExportJobRef.current) {
return;
}
const updatedCSVExportJob: Partial<CSVExportJob> = {
...response,
...csvExportJobRef.current,
};
setCSVExportJob(updatedCSVExportJob);
csvExportJobRef.current = updatedCSVExportJob;
if (response.status === 'COMPLETED' && response.data) {
handleCSVExportSuccess(
response.data ?? '',
csvExportJobRef.current?.fileName
);
} else {
setDownloading(false);
}
},
[isBulkEdit, handleCSVExportSuccess]
);
useEffect(() => {
if (exportData) {
form.setFieldValue(
'fileName',
`${exportData.name}_${getCurrentISODate()}`
);
if (isBulkEdit) {
handleExport({ fileName: 'bulk-edit' });
} else {
form.setFieldValue(
'fileName',
`${exportData.name}_${getCurrentISODate()}`
);
}
}
}, [exportData]);
}, [isBulkEdit, exportData]);
const providerValue = useMemo(
() => ({
csvExportData,
clearCSVExportData: () => setCSVExportData(undefined),
showModal,
triggerExportForBulkEdit: (exportData: ExportData) => {
setExportData(exportData);
},
onUpdateCSVExportJob: handleCSVExportJobUpdate,
}),
[]
[isBulkEdit, csvExportData, handleCSVExportJobUpdate]
);
return (
<EntityExportModalContext.Provider value={providerValue}>
<>
{children}
{exportData && (
{exportData && !isBulkEdit && (
<Modal
centered
open

View File

@ -33,6 +33,9 @@ export type ExportData = {
onExport: (name: string) => Promise<CSVExportResponse | string>;
};
export interface EntityExportModalContextProps {
csvExportData?: string;
clearCSVExportData: () => void;
showModal: (data: ExportData) => void;
triggerExportForBulkEdit: (data: ExportData) => void;
onUpdateCSVExportJob: (data: Partial<CSVExportWebsocketResponse>) => void;
}

View File

@ -12,6 +12,7 @@
*/
import { act, fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { useLocation } from 'react-router-dom';
import {
EntityExportModalProvider,
useEntityExportModalProvider,
@ -28,6 +29,12 @@ const mockShowModal: ExportData = {
onExport: jest.fn().mockImplementation(() => Promise.resolve(mockExportJob)),
};
jest.mock('react-router-dom', () => ({
useLocation: jest.fn().mockImplementation(() => ({
pathname: '/mock-path',
})),
}));
const ConsumerComponent = () => {
const { showModal } = useEntityExportModalProvider();
@ -140,4 +147,21 @@ describe('EntityExportModalProvider component', () => {
expect(await screen.findByText(mockExportJob.message)).toBeInTheDocument();
});
it('Export modal should not be visible if route is bulk edit', async () => {
(useLocation as jest.Mock).mockReturnValue({
pathname: '/bulk/edit',
});
render(
<EntityExportModalProvider>
<ConsumerComponent />
</EntityExportModalProvider>
);
const manageBtn = await screen.findByText('Manage');
fireEvent.click(manageBtn);
expect(screen.queryByTestId('export-entity-modal')).not.toBeInTheDocument();
});
});

View File

@ -414,7 +414,7 @@ const NavBar = ({
socket && socket.off(SOCKET_EVENTS.CSV_EXPORT_CHANNEL);
socket && socket.off(SOCKET_EVENTS.BACKGROUND_JOB_CHANNEL);
};
}, [socket]);
}, [socket, onUpdateCSVExportJob]);
useEffect(() => {
fetchOMVersion();

View File

@ -0,0 +1,25 @@
/*
* 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 i18n from '../utils/i18next/LocalUtil';
import { VALIDATION_STEP } from './BulkImport.constant';
export const ENTITY_BULK_EDIT_STEPS = [
{
name: i18n.t('label.preview-and-edit'),
step: VALIDATION_STEP.EDIT_VALIDATE,
},
{
name: i18n.t('label.update'),
step: VALIDATION_STEP.UPDATE,
},
];

View File

@ -11,8 +11,16 @@
* limitations under the License.
*/
import { startCase } from 'lodash';
import { ResourceEntity } from '../context/PermissionProvider/PermissionProvider.interface';
import i18n from '../utils/i18next/LocalUtil';
export const SUPPORTED_BULK_IMPORT_EDIT_ENTITY = [
ResourceEntity.TABLE,
ResourceEntity.DATABASE_SERVICE,
ResourceEntity.DATABASE,
ResourceEntity.DATABASE_SCHEMA,
];
export enum VALIDATION_STEP {
UPLOAD = 0,
EDIT_VALIDATE = 1,

View File

@ -287,6 +287,10 @@ export const ROUTES = {
// Entity Import
ENTITY_IMPORT: `/bulk/import/${PLACEHOLDER_ROUTE_ENTITY_TYPE}/${PLACEHOLDER_ROUTE_FQN}`,
// Entity Bulk Edit
BULK_EDIT_ENTITY: `/bulk/edit`,
BULK_EDIT_ENTITY_WITH_FQN: `/bulk/edit/${PLACEHOLDER_ROUTE_ENTITY_TYPE}/${PLACEHOLDER_ROUTE_FQN}`,
};
export const SOCKET_EVENTS = {

View File

@ -145,6 +145,7 @@
"browse": "Durchsuchen",
"browse-app-plural": "Browse Apps",
"browse-csv-file": "CSV-Datei durchsuchen",
"bulk-edit": "Massenbearbeitung",
"by-entity": "Nach {{entity}}",
"by-lowercase": "von",
"ca-certs": "CA-Zertifikate",

View File

@ -145,6 +145,7 @@
"browse": "Browse",
"browse-app-plural": "Browse Apps",
"browse-csv-file": "Browse CSV file",
"bulk-edit": "Bulk Edit",
"by-entity": "By {{entity}}",
"by-lowercase": "by",
"ca-certs": "CA Certs",

View File

@ -145,6 +145,7 @@
"browse": "Explorar",
"browse-app-plural": "Explorar Apps",
"browse-csv-file": "Examinar archivo CSV",
"bulk-edit": "Edición masiva",
"by-entity": "Por {{entity}}",
"by-lowercase": "por",
"ca-certs": "Certificados CA",

View File

@ -145,6 +145,7 @@
"browse": "Naviguer",
"browse-app-plural": "Browse Apps",
"browse-csv-file": "Naviguer le fichier CSV",
"bulk-edit": "Modification en masse",
"by-entity": "Par {{entity}}",
"by-lowercase": "par",
"ca-certs": "Certificats CA",

View File

@ -145,6 +145,7 @@
"browse": "Explorar",
"browse-app-plural": "Explorar aplicacións",
"browse-csv-file": "Explorar ficheiro CSV",
"bulk-edit": "Edición masiva",
"by-entity": "Por {{entity}}",
"by-lowercase": "por",
"ca-certs": "Certificados CA",

View File

@ -145,6 +145,7 @@
"browse": "עיון",
"browse-app-plural": "עיין באפליקציות",
"browse-csv-file": "עיין בקובץ CSV",
"bulk-edit": "עריכה מרובה",
"by-entity": "לפי {{entity}}",
"by-lowercase": "לפי",
"ca-certs": "תעודות CA",

View File

@ -145,6 +145,7 @@
"browse": "Browse",
"browse-app-plural": "Browse Apps",
"browse-csv-file": "CSVファイルを見る",
"bulk-edit": "一括編集",
"by-entity": "By {{entity}}",
"by-lowercase": "by",
"ca-certs": "CA Certs",

View File

@ -145,6 +145,7 @@
"browse": "찾아보기",
"browse-app-plural": "앱 찾아보기",
"browse-csv-file": "CSV 파일 찾아보기",
"bulk-edit": "일괄 편집",
"by-entity": "{{entity}} 기준",
"by-lowercase": "의",
"ca-certs": "CA 인증서",

View File

@ -145,6 +145,7 @@
"browse": "ब्राउझ करा",
"browse-app-plural": "ॲप्स ब्राउझ करा",
"browse-csv-file": "CSV फाइल ब्राउझ करा",
"bulk-edit": "सामूहिक संपादन",
"by-entity": "{{entity}} द्वारे",
"by-lowercase": "द्वारे",
"ca-certs": "CA प्रमाणपत्रे",

View File

@ -145,6 +145,7 @@
"browse": "Bladeren",
"browse-app-plural": "Blader door apps",
"browse-csv-file": "Blader door CSV-bestand",
"bulk-edit": "Bulk bewerken",
"by-entity": "Door {{entity}}",
"by-lowercase": "door",
"ca-certs": "CA-certificaten",

View File

@ -145,6 +145,7 @@
"browse": "مرور",
"browse-app-plural": "مرور برنامه‌ها",
"browse-csv-file": "مرور فایل CSV",
"bulk-edit": "ویرایش دسته‌ای",
"by-entity": "توسط {{entity}}",
"by-lowercase": "توسط",
"ca-certs": "گواهی‌های CA",

View File

@ -145,6 +145,7 @@
"browse": "Navegar",
"browse-app-plural": "Navegar em Apps",
"browse-csv-file": "Navegar no arquivo CSV",
"bulk-edit": "Edição em massa",
"by-entity": "Por {{entity}}",
"by-lowercase": "por",
"ca-certs": "Certificados CA",

View File

@ -145,6 +145,7 @@
"browse": "Navegar",
"browse-app-plural": "Navegar em Apps",
"browse-csv-file": "Navegar no arquivo CSV",
"bulk-edit": "Edição em massa",
"by-entity": "Por {{entity}}",
"by-lowercase": "por",
"ca-certs": "Certificados CA",

View File

@ -145,6 +145,7 @@
"browse": "Просмотр",
"browse-app-plural": "Browse Apps",
"browse-csv-file": "Просмотр CSV-файла",
"bulk-edit": "Массовое редактирование",
"by-entity": "{{entity}}",
"by-lowercase": "к",
"ca-certs": "Сертификаты CA",

View File

@ -145,6 +145,7 @@
"browse": "เรียกดู",
"browse-app-plural": "เรียกดูแอปพลิเคชัน",
"browse-csv-file": "เรียกดูไฟล์ CSV",
"bulk-edit": "แก้ไขเป็นกลุ่ม",
"by-entity": "โดย {{entity}}",
"by-lowercase": "โดย",
"ca-certs": "ใบรับรอง CA",

View File

@ -145,6 +145,7 @@
"browse": "浏览",
"browse-app-plural": "浏览应用",
"browse-csv-file": "打开 CSV 文件",
"bulk-edit": "批量编辑",
"by-entity": "按{{entity}}",
"by-lowercase": "by",
"ca-certs": "CA 证书",

View File

@ -18,6 +18,7 @@ import { compare } from 'fast-json-patch';
import { isUndefined } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import DisplayName from '../../components/common/DisplayName/DisplayName';
import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder';
import NextPrevious from '../../components/common/NextPrevious/NextPrevious';
@ -45,7 +46,9 @@ import {
patchTableDetails,
TableListParams,
} from '../../rest/tableAPI';
import { getBulkEditButton } from '../../utils/EntityBulkEdit/EntityBulkEditUtils';
import entityUtilClassBase from '../../utils/EntityUtilClassBase';
import { getEntityBulkEditPath } from '../../utils/EntityUtils';
import { showErrorToast } from '../../utils/ToastUtils';
interface SchemaTablesTabProps {
@ -56,6 +59,7 @@ function SchemaTablesTab({
isVersionView = false,
}: Readonly<SchemaTablesTabProps>) {
const { t } = useTranslation();
const history = useHistory();
const [tableData, setTableData] = useState<Array<Table>>([]);
const [tableDataLoading, setTableDataLoading] = useState<boolean>(true);
const { permissions } = usePermissionProvider();
@ -201,6 +205,15 @@ function SchemaTablesTab({
[handleDisplayNameUpdate, allowEditDisplayNamePermission]
);
const handleEditTable = () => {
history.push({
pathname: getEntityBulkEditPath(
EntityType.DATABASE_SCHEMA,
decodedDatabaseSchemaFQN
),
});
};
useEffect(() => {
if (viewDatabaseSchemaPermission && decodedDatabaseSchemaFQN) {
if (pagingCursor?.cursorData?.cursorType) {
@ -228,29 +241,30 @@ function SchemaTablesTab({
return (
<Row gutter={[16, 16]}>
{!isVersionView && (
<Col span={24}>
<Row justify="end">
<Col>
<Switch
checked={tableFilters.showDeletedTables}
data-testid="show-deleted"
onClick={handleShowDeletedTables}
/>
<Typography.Text className="m-l-xs">
{t('label.deleted')}
</Typography.Text>{' '}
</Col>
</Row>
</Col>
)}
<Col span={24}>
<TableAntd
bordered
columns={tableColumn}
data-testid="databaseSchema-tables"
dataSource={tableData}
extraTableFilters={
!isVersionView && (
<>
<span>
<Switch
checked={tableFilters.showDeletedTables}
data-testid="show-deleted"
onClick={handleShowDeletedTables}
/>
<Typography.Text className="m-l-xs">
{t('label.deleted')}
</Typography.Text>
</span>
{getBulkEditButton(permissions.table.EditAll, handleEditTable)}
</>
)
}
loading={tableDataLoading}
locale={{
emptyText: (

View File

@ -28,7 +28,8 @@ import React, {
} from 'react';
import { useTranslation } from 'react-i18next';
import { usePapaParse } from 'react-papaparse';
import { useHistory, useParams } from 'react-router-dom';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import BulkEditEntity from '../../../components/BulkEditEntity/BulkEditEntity.component';
import {
CSVImportAsyncWebsocketResponse,
CSVImportJobType,
@ -57,6 +58,7 @@ import {
getCSVStringFromColumnsAndDataSource,
getEntityColumnsAndDataSourceFromCSV,
} from '../../../utils/CSV/CSV.utils';
import { isBulkEditRoute } from '../../../utils/EntityBulkEdit/EntityBulkEditUtils';
import {
getBulkEntityImportBreadcrumbList,
getImportedEntityType,
@ -79,6 +81,7 @@ const BulkEntityImportPage = () => {
);
const activeStepRef = useRef<VALIDATION_STEP>(VALIDATION_STEP.UPLOAD);
const location = useLocation();
const { t } = useTranslation();
const { entityType } = useParams<{ entityType: EntityType }>();
const { fqn } = useFqn();
@ -94,6 +97,11 @@ const BulkEntityImportPage = () => {
MutableRefObject<TypeComputedProps | null>
>({ current: null });
const isBulkEdit = useMemo(
() => isBulkEditRoute(location.pathname),
[location]
);
const importedEntityType = useMemo(
() => getImportedEntityType(entityType),
[entityType]
@ -455,118 +463,147 @@ const BulkEntityImportPage = () => {
entity: entityType,
})}>
<Row className="p-x-lg" gutter={[16, 16]}>
<Col span={24}>
<TitleBreadcrumb titleLinks={breadcrumbList} />
</Col>
<Col span={24}>
<Stepper activeStep={activeStep} steps={ENTITY_IMPORT_STEPS} />
</Col>
<Col span={24}>
{activeAsyncImportJob?.jobId && (
<Banner
className="border-radius"
isLoading={!activeAsyncImportJob.error}
message={
activeAsyncImportJob.error ?? activeAsyncImportJob.message ?? ''
}
type={activeAsyncImportJob.error ? 'error' : 'success'}
/>
)}
</Col>
<Col span={24}>
{activeStep === 0 && (
<>
{validationData?.abortReason ? (
<Card className="m-t-lg">
<Space
align="center"
className="w-full justify-center p-lg text-center"
direction="vertical"
size={16}>
<Typography.Text
className="text-center"
data-testid="abort-reason">
<strong className="d-block">{t('label.aborted')}</strong>{' '}
{validationData.abortReason}
</Typography.Text>
<Space size={16}>
<Button
ghost
data-testid="cancel-button"
type="primary"
onClick={handleRetryCsvUpload}>
{t('label.back')}
</Button>
</Space>
</Space>
</Card>
) : (
<UploadFile fileType=".csv" onCSVUploaded={handleLoadData} />
{isBulkEdit ? (
<BulkEditEntity
activeAsyncImportJob={activeAsyncImportJob}
activeStep={activeStep}
columns={columns}
dataSource={dataSource}
handleBack={handleBack}
handleValidate={handleValidate}
isValidating={isValidating}
setGridRef={setGridRef}
validateCSVData={validateCSVData}
validationData={validationData}
onCSVReadComplete={onCSVReadComplete}
onEditComplete={onEditComplete}
onEditStart={onEditStart}
onEditStop={onEditStop}
onKeyDown={onKeyDown}
/>
) : (
<>
<Col span={24}>
<TitleBreadcrumb titleLinks={breadcrumbList} />
</Col>
<Col span={24}>
<Stepper activeStep={activeStep} steps={ENTITY_IMPORT_STEPS} />
</Col>
<Col span={24}>
{activeAsyncImportJob?.jobId && (
<Banner
className="border-radius"
isLoading={!activeAsyncImportJob.error}
message={
activeAsyncImportJob.error ??
activeAsyncImportJob.message ??
''
}
type={activeAsyncImportJob.error ? 'error' : 'success'}
/>
)}
</>
)}
{activeStep === 1 && (
<ReactDataGrid
editable
columns={columns}
dataSource={dataSource}
defaultActiveCell={[0, 0]}
handle={setGridRef}
idProperty="id"
loading={isValidating}
minRowHeight={30}
showZebraRows={false}
style={{ height: 'calc(100vh - 245px)' }}
onEditComplete={onEditComplete}
onEditStart={onEditStart}
onEditStop={onEditStop}
onKeyDown={onKeyDown}
/>
)}
{activeStep === 2 && validationData && (
<Row gutter={[16, 16]}>
<Col span={24}>
<ImportStatus csvImportResult={validationData} />
</Col>
</Col>
<Col span={24}>
{activeStep === 0 && (
<>
{validationData?.abortReason ? (
<Card className="m-t-lg">
<Space
align="center"
className="w-full justify-center p-lg text-center"
direction="vertical"
size={16}>
<Typography.Text
className="text-center"
data-testid="abort-reason">
<strong className="d-block">
{t('label.aborted')}
</strong>{' '}
{validationData.abortReason}
</Typography.Text>
<Space size={16}>
<Button
ghost
data-testid="cancel-button"
type="primary"
onClick={handleRetryCsvUpload}>
{t('label.back')}
</Button>
</Space>
</Space>
</Card>
) : (
<UploadFile
fileType=".csv"
onCSVUploaded={handleLoadData}
/>
)}
</>
)}
{activeStep === 1 && (
<ReactDataGrid
editable
columns={columns}
dataSource={dataSource}
defaultActiveCell={[0, 0]}
handle={setGridRef}
idProperty="id"
loading={isValidating}
minRowHeight={30}
showZebraRows={false}
style={{ height: 'calc(100vh - 245px)' }}
onEditComplete={onEditComplete}
onEditStart={onEditStart}
onEditStop={onEditStop}
onKeyDown={onKeyDown}
/>
)}
{activeStep === 2 && validationData && (
<Row gutter={[16, 16]}>
<Col span={24}>
<ImportStatus csvImportResult={validationData} />
</Col>
<Col span={24}>
{validateCSVData && (
<ReactDataGrid
idProperty="id"
loading={isValidating}
style={{ height: 'calc(100vh - 300px)' }}
{...validateCSVData}
/>
)}
</Col>
</Row>
)}
</Col>
{activeStep > 0 && (
<Col span={24}>
{validateCSVData && (
<ReactDataGrid
idProperty="id"
loading={isValidating}
style={{ height: 'calc(100vh - 300px)' }}
{...validateCSVData}
/>
{activeStep === 1 && (
<Button data-testid="add-row-btn" onClick={handleAddRow}>
{`+ ${t('label.add-row')}`}
</Button>
)}
<div className="float-right import-footer">
{activeStep > 0 && (
<Button disabled={isValidating} onClick={handleBack}>
{t('label.previous')}
</Button>
)}
{activeStep < 3 && (
<Button
className="m-l-sm"
disabled={isValidating}
type="primary"
onClick={handleValidate}>
{activeStep === 2 ? t('label.update') : t('label.next')}
</Button>
)}
</div>
</Col>
</Row>
)}
</Col>
{activeStep > 0 && (
<Col span={24}>
{activeStep === 1 && (
<Button data-testid="add-row-btn" onClick={handleAddRow}>
{`+ ${t('label.add-row')}`}
</Button>
)}
<div className="float-right import-footer">
{activeStep > 0 && (
<Button disabled={isValidating} onClick={handleBack}>
{t('label.previous')}
</Button>
)}
{activeStep < 3 && (
<Button
className="m-l-sm"
disabled={isValidating}
type="primary"
onClick={handleValidate}>
{activeStep === 2 ? t('label.update') : t('label.next')}
</Button>
)}
</div>
</Col>
</>
)}
</Row>
</PageLayoutV1>

View File

@ -19,7 +19,7 @@ import { isUndefined } from 'lodash';
import { EntityTags, ServiceTypes } from 'Models';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { useHistory, useParams } from 'react-router-dom';
import DescriptionV1 from '../../components/common/EntityDescription/DescriptionV1';
import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder';
import Loader from '../../components/common/Loader/Loader';
@ -41,6 +41,8 @@ import { EntityType } from '../../enums/entity.enum';
import { Paging } from '../../generated/type/paging';
import { UsePagingInterface } from '../../hooks/paging/usePaging';
import { ServicesType } from '../../interface/service.interface';
import { getBulkEditButton } from '../../utils/EntityBulkEdit/EntityBulkEditUtils';
import { getEntityBulkEditPath } from '../../utils/EntityUtils';
import {
callServicePatchAPI,
getServiceMainTabColumns,
@ -89,6 +91,7 @@ function ServiceMainTabContent({
serviceCategory: ServiceTypes;
}>();
const { permissions } = usePermissionProvider();
const history = useHistory();
const [pageData, setPageData] = useState<ServicePageData[]>([]);
const tier = getTierTags(serviceDetails?.tags ?? []);
@ -200,6 +203,15 @@ function ServiceMainTabContent({
[serviceCategory]
);
const handleEditTable = () => {
history.push({
pathname: getEntityBulkEditPath(
EntityType.DATABASE_SERVICE,
serviceDetails.fullyQualifiedName ?? ''
),
});
};
const {
editTagsPermission,
editGlossaryTermsPermission,
@ -260,16 +272,24 @@ function ServiceMainTabContent({
DEFAULT_SERVICE_TAB_VISIBLE_COLUMNS
}
extraTableFilters={
<span>
<Switch
checked={showDeleted}
data-testid="show-deleted"
onClick={onShowDeletedChange}
/>
<Typography.Text className="m-l-xs">
{t('label.deleted')}
</Typography.Text>
</span>
<>
<span>
<Switch
checked={showDeleted}
data-testid="show-deleted"
onClick={onShowDeletedChange}
/>
<Typography.Text className="m-l-xs">
{t('label.deleted')}
</Typography.Text>
</span>
{entityType === EntityType.DATABASE_SERVICE &&
getBulkEditButton(
servicePermission.EditAll,
handleEditTable
)}
</>
}
locale={{
emptyText: <ErrorPlaceHolder className="m-y-md" />,

View File

@ -0,0 +1,82 @@
/*
* 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 Icon from '@ant-design/icons';
import { Button } from 'antd';
import React from 'react';
import { ReactComponent as IconEdit } from '../../assets/svg/edit-new.svg';
import { TitleBreadcrumbProps } from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.interface';
import { ROUTES } from '../../constants/constants';
import { EntityType } from '../../enums/entity.enum';
import {
exportDatabaseDetailsInCSV,
exportDatabaseSchemaDetailsInCSV,
} from '../../rest/databaseAPI';
import { exportDatabaseServiceDetailsInCSV } from '../../rest/serviceAPI';
import { exportTableDetailsInCSV } from '../../rest/tableAPI';
import entityUtilClassBase from '../EntityUtilClassBase';
import Fqn from '../Fqn';
import i18n from '../i18next/LocalUtil';
export const isBulkEditRoute = (pathname: string) => {
return pathname.includes(ROUTES.BULK_EDIT_ENTITY);
};
export const getBulkEntityEditBreadcrumbList = (
entityType: EntityType,
fqn: string
): TitleBreadcrumbProps['titleLinks'] => [
{
name: Fqn.split(fqn).pop(),
url: entityUtilClassBase.getEntityLink(entityType, fqn),
},
{
name: i18n.t('label.bulk-edit'),
url: '',
activeTitle: true,
},
];
export const getBulkEditCSVExportEntityApi = (entityType: EntityType) => {
switch (entityType) {
case EntityType.DATABASE_SERVICE:
return exportDatabaseServiceDetailsInCSV;
case EntityType.DATABASE:
return exportDatabaseDetailsInCSV;
case EntityType.DATABASE_SCHEMA:
return exportDatabaseSchemaDetailsInCSV;
case EntityType.TABLE:
return exportTableDetailsInCSV;
default:
return exportTableDetailsInCSV;
}
};
export const getBulkEditButton = (
hasPermission: boolean,
onClickHandler: () => void
) => {
return hasPermission ? (
<Button
className="text-primary p-0"
data-testid="bulk-edit-table"
icon={<Icon component={IconEdit} />}
type="text"
onClick={onClickHandler}>
{i18n.t('label.edit')}
</Button>
) : null;
};

View File

@ -2493,6 +2493,13 @@ export const getEntityImportPath = (entityType: EntityType, fqn: string) => {
).replace(PLACEHOLDER_ROUTE_FQN, fqn);
};
export const getEntityBulkEditPath = (entityType: EntityType, fqn: string) => {
return ROUTES.BULK_EDIT_ENTITY_WITH_FQN.replace(
PLACEHOLDER_ROUTE_ENTITY_TYPE,
entityType
).replace(PLACEHOLDER_ROUTE_FQN, fqn);
};
/**
* Updates the node type based on whether it's a source or target node
* @param node - The node to update