Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: edit display name #18720

Merged
merged 11 commits into from
Nov 28, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@

export interface DatabaseSchemaTableProps {
isDatabaseDeleted?: boolean;
isVersionPage?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,45 +11,70 @@
* limitations under the License.
*/
import { Col, Row, Switch, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { AxiosError } from 'axios';
import { compare } from 'fast-json-patch';
import { t } from 'i18next';
import { isEmpty } from 'lodash';
import QueryString from 'qs';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory } from 'react-router-dom';
import {
getEntityDetailsPath,
INITIAL_PAGING_VALUE,
NO_DATA_PLACEHOLDER,
PAGE_SIZE,
} from '../../../../constants/constants';
import { TabSpecificField } from '../../../../enums/entity.enum';
import { usePermissionProvider } from '../../../../context/PermissionProvider/PermissionProvider';
import { EntityType, TabSpecificField } from '../../../../enums/entity.enum';
import { SearchIndex } from '../../../../enums/search.enum';
import { DatabaseSchema } from '../../../../generated/entity/data/databaseSchema';
import { EntityReference } from '../../../../generated/entity/type';
import { UsageDetails } from '../../../../generated/type/entityUsage';
import { Include } from '../../../../generated/type/include';
import { Paging } from '../../../../generated/type/paging';
import { usePaging } from '../../../../hooks/paging/usePaging';
import useCustomLocation from '../../../../hooks/useCustomLocation/useCustomLocation';
import { useFqn } from '../../../../hooks/useFqn';
import { getDatabaseSchemas } from '../../../../rest/databaseAPI';
import {
getDatabaseSchemas,
patchDatabaseSchemaDetails,
} from '../../../../rest/databaseAPI';
import { searchQuery } from '../../../../rest/searchAPI';
import { schemaTableColumns } from '../../../../utils/Database/Database.util';
import { getEntityName } from '../../../../utils/EntityUtils';
import { getUsagePercentile } from '../../../../utils/TableUtils';
import { showErrorToast } from '../../../../utils/ToastUtils';
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 RichTextEditorPreviewer from '../../../common/RichTextEditor/RichTextEditorPreviewer';
import Searchbar from '../../../common/SearchBarComponent/SearchBar.component';
import Table from '../../../common/Table/Table';
import { EntityName } from '../../../Modals/EntityNameModal/EntityNameModal.interface';
import { DatabaseSchemaTableProps } from './DatabaseSchemaTable.interface';

export const DatabaseSchemaTable = ({
isDatabaseDeleted,
isVersionPage = false,
}: Readonly<DatabaseSchemaTableProps>) => {
const { fqn: decodedDatabaseFQN } = useFqn();
const history = useHistory();
const location = useCustomLocation();
const { permissions } = usePermissionProvider();

const [schemas, setSchemas] = useState<DatabaseSchema[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showDeletedSchemas, setShowDeletedSchemas] = useState<boolean>(false);

const allowEditDisplayNamePermission = useMemo(() => {
return (
!isVersionPage &&
(permissions.databaseSchema.EditAll ||
permissions.databaseSchema.EditDisplayName)
);
}, [permissions, isVersionPage]);
Ashish8689 marked this conversation as resolved.
Show resolved Hide resolved

const searchValue = useMemo(() => {
const param = location.search;
const searchData = QueryString.parse(
Expand Down Expand Up @@ -160,6 +185,95 @@ export const DatabaseSchemaTable = ({
}
};

const handleDisplayNameUpdate = useCallback(
async (data: EntityName, id?: string) => {
try {
const schemaDetails = schemas.find((schema) => schema.id === id);
if (!schemaDetails) {
return;
}
const updatedData = { ...schemaDetails, displayName: data.displayName };
const jsonPatch = compare(schemaDetails, updatedData);
await patchDatabaseSchemaDetails(schemaDetails.id ?? '', jsonPatch);
setSchemas((prevData) =>
prevData.map((schema) =>
schema.id === id
? { ...schema, displayName: data.displayName }
: schema
)
);
} catch (error) {
showErrorToast(error as AxiosError);
}
},
[schemas]
);

const schemaTableColumns: ColumnsType<DatabaseSchema> = useMemo(
() => [
{
title: t('label.schema-name'),
dataIndex: 'name',
key: 'name',
width: 250,
render: (_, record: DatabaseSchema) => (
<DisplayName
allowRename={allowEditDisplayNamePermission}
displayName={record.displayName}
id={record.id ?? ''}
key={record.id}
link={
record.fullyQualifiedName
? getEntityDetailsPath(
EntityType.DATABASE_SCHEMA,
record.fullyQualifiedName
)
: ''
}
name={record.name}
onEditDisplayName={handleDisplayNameUpdate}
/>
),
},
{
title: t('label.description'),
dataIndex: 'description',
key: 'description',
render: (text: string) =>
text?.trim() ? (
<RichTextEditorPreviewer markdown={text} />
) : (
<span className="text-grey-muted">
{t('label.no-entity', { entity: t('label.description') })}
</span>
),
},
{
title: t('label.owner-plural'),
dataIndex: 'owners',
key: 'owners',
width: 120,
render: (owners: EntityReference[]) =>
!isEmpty(owners) && owners.length > 0 ? (
owners.map((owner: EntityReference) => getEntityName(owner))
) : (
<Typography.Text data-testid="no-owner-text">
{NO_DATA_PLACEHOLDER}
</Typography.Text>
),
},
{
title: t('label.usage'),
dataIndex: 'usageSummary',
key: 'usageSummary',
width: 120,
render: (text: UsageDetails) =>
getUsagePercentile(text?.weeklyStats?.percentileRank ?? 0),
},
],
[handleDisplayNameUpdate, allowEditDisplayNamePermission]
);

useEffect(() => {
fetchDatabaseSchema();
}, [decodedDatabaseFQN, pageSize, showDeletedSchemas, isDatabaseDeleted]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import { Rule } from 'antd/lib/form';
import { Constraint } from '../../../generated/entity/data/table';

export type EntityName = { name: string; displayName?: string };
export type EntityName = { name: string; displayName?: string; id?: string };

export type EntityNameWithAdditionFields = EntityName & {
constraint: Constraint;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { act, fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import DisplayName, { DisplayNameProps } from './DisplayName';

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Link: jest
.fn()
.mockImplementation(({ children, ...props }) => (
<span {...props}>{children}</span>
)),
}));

jest.mock('../../../constants/constants', () => ({
DE_ACTIVE_COLOR: '#BFBFBF',
ICON_DIMENSION: { width: 16, height: 16 },
}));

jest.mock('../../Modals/EntityNameModal/EntityNameModal.component', () =>
jest.fn().mockImplementation(() => <p>Mocked Modal</p>)
);

const mockOnEditDisplayName = jest.fn();

const mockProps: DisplayNameProps = {
id: '1',
name: 'Sample Entity',
displayName: 'Sample Display Name',
link: '/entity/1',
allowRename: true,
onEditDisplayName: mockOnEditDisplayName,
};

describe('Test DisplayName Component', () => {
it('Should render the component with the display name', async () => {
await act(async () => {
render(
<MemoryRouter>
<DisplayName {...mockProps} />
</MemoryRouter>
);

const displayNameField = await screen.getByTestId('column-display-name');

expect(displayNameField).toBeInTheDocument();
expect(displayNameField).toHaveTextContent('Sample Display Name');

const editButton = screen.queryByTestId('edit-displayName-button');

expect(editButton).toBeInTheDocument();
});
});

it('Should render the component with name when display name is empty', async () => {
await act(async () => {
render(
<MemoryRouter>
<DisplayName {...mockProps} displayName={undefined} />
</MemoryRouter>
);

const nameField = screen.getByTestId('column-name');

expect(nameField).toBeInTheDocument();
expect(nameField).toHaveTextContent('Sample Entity');
});
});

it('Should open the edit modal on edit button click', async () => {
await act(async () => {
render(
<MemoryRouter>
<DisplayName {...mockProps} />
</MemoryRouter>
);
const editButton = screen.getByTestId('edit-displayName-button');
fireEvent.click(editButton);

const nameField = await screen.findByTestId('column-name');

expect(nameField).toBeInTheDocument();

const displayNameField = await screen.findByTestId('column-display-name');

expect(displayNameField).toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* Copyright 2024 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button, Tooltip, Typography } from 'antd';
import { AxiosError } from 'axios';
import { isEmpty } from 'lodash';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { ReactComponent as IconEdit } from '../../../assets/svg/edit-new.svg';
import { DE_ACTIVE_COLOR, ICON_DIMENSION } from '../../../constants/constants';
import { showErrorToast } from '../../../utils/ToastUtils';
import EntityNameModal from '../../Modals/EntityNameModal/EntityNameModal.component';
import { EntityName } from '../../Modals/EntityNameModal/EntityNameModal.interface';

export interface DisplayNameProps {
id: string;
name?: string;
displayName?: string;
link: string;
onEditDisplayName?: (data: EntityName, id?: string) => Promise<void>;
allowRename: boolean;
}
pranita09 marked this conversation as resolved.
Show resolved Hide resolved

const DisplayName: React.FC<DisplayNameProps> = ({
id,
name,
displayName,
onEditDisplayName,
link,
allowRename,
}) => {
const { t } = useTranslation();

const [isDisplayNameEditing, setIsDisplayNameEditing] = useState(false);

const handleDisplayNameUpdate = async (data: EntityName) => {
setIsDisplayNameEditing(true);
try {
await onEditDisplayName?.(data, id);
} catch (error) {
showErrorToast(error as AxiosError);
} finally {
setIsDisplayNameEditing(false);
}
};

return (
<div className="flex-column hover-icon-group w-max-full">
<Typography.Text
className="m-b-0 d-block text-grey-muted break-word"
data-testid="column-name">
{isEmpty(displayName) ? (
<Link className="break-word" data-testid={name} to={link}>
{name}
</Link>
) : (
<>
{name}
<Typography.Text
className="m-b-0 d-block break-word"
data-testid="column-display-name">
<Link className="break-word" data-testid={displayName} to={link}>
{displayName}
</Link>
</Typography.Text>
</>
)}
</Typography.Text>

{allowRename ? (
<Tooltip placement="right" title={t('label.edit')}>
<Button
ghost
className="hover-cell-icon"
data-testid="edit-displayName-button"
icon={<IconEdit color={DE_ACTIVE_COLOR} {...ICON_DIMENSION} />}
type="text"
onClick={() => setIsDisplayNameEditing(true)}
/>
</Tooltip>
) : null}
{isDisplayNameEditing && (
<EntityNameModal
allowRename={allowRename}
entity={{
name: name ?? '',
displayName,
}}
title={t('label.edit-entity', {
entity: t('label.display-name'),
})}
visible={isDisplayNameEditing}
onCancel={() => setIsDisplayNameEditing(false)}
onSave={handleDisplayNameUpdate}
/>
)}
</div>
);
};

export default DisplayName;
Loading
Loading