fix(ui): show full content for ellipsis column names (#8443)

* fix(ui): show full content for ellipsis column names

* fix unit tests and mock i18next

* address comments

* fix cypress failure

* address review comments
improve unit tests console errors
This commit is contained in:
Chirag Madlani 2022-11-01 16:26:40 +05:30 committed by GitHub
parent 5819c45937
commit ca91bafa39
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 549 additions and 344 deletions

View File

@ -95,7 +95,7 @@ describe('Tags page should work', () => {
}); });
}); });
it('Add new tag flow shoud work properly', () => { it('Add new tag flow should work properly', () => {
cy.get('[data-testid="side-panel-category"]') cy.get('[data-testid="side-panel-category"]')
.contains(NEW_TAG_CATEGORY.name) .contains(NEW_TAG_CATEGORY.name)
.should('be.visible') .should('be.visible')

View File

@ -75,6 +75,7 @@ const DashboardDetailsProps = {
chartUrl: 'http://localhost', chartUrl: 'http://localhost',
chartType: 'Area', chartType: 'Area',
displayName: 'Test chart', displayName: 'Test chart',
id: '1',
}, },
] as ChartType[], ] as ChartType[],
serviceType: '', serviceType: '',

View File

@ -38,7 +38,7 @@ export const dashboardVersionProp = {
deleted: false, deleted: false,
}, },
{ {
id: '0698ab5d-a122-4b86-a6e5-d10bf3550bd7', id: '0698ab5d-a122-4b86-a6e5-d10bf3550bd6',
type: 'chart', type: 'chart',
name: 'without_description', name: 'without_description',
description: '', description: '',

View File

@ -40,7 +40,7 @@ import TabsPane from '../common/TabsPane/TabsPane';
import PageContainer from '../containers/PageContainer'; import PageContainer from '../containers/PageContainer';
import EntityVersionTimeLine from '../EntityVersionTimeLine/EntityVersionTimeLine'; import EntityVersionTimeLine from '../EntityVersionTimeLine/EntityVersionTimeLine';
import Loader from '../Loader/Loader'; import Loader from '../Loader/Loader';
import SchemaTab from '../SchemaTab/SchemaTab.component'; import VersionTable from '../VersionTable/VersionTable.component';
import { DatasetVersionProp } from './DatasetVersion.interface'; import { DatasetVersionProp } from './DatasetVersion.interface';
const DatasetVersion: React.FC<DatasetVersionProp> = ({ const DatasetVersion: React.FC<DatasetVersionProp> = ({
@ -406,8 +406,7 @@ const DatasetVersion: React.FC<DatasetVersionProp> = ({
</div> </div>
<div className="tw-col-span-full"> <div className="tw-col-span-full">
<SchemaTab <VersionTable
isReadOnly
columnName={getPartialNameFromTableFQN( columnName={getPartialNameFromTableFQN(
datasetFQN, datasetFQN,
[FqnPart.Column], [FqnPart.Column],
@ -418,7 +417,6 @@ const DatasetVersion: React.FC<DatasetVersionProp> = ({
// TODO: Below we should have separate type for Dataset instead casting it to `Table` // TODO: Below we should have separate type for Dataset instead casting it to `Table`
(currentVersionData as Table).joins as ColumnJoins[] (currentVersionData as Table).joins as ColumnJoins[]
} }
tableConstraints={[]}
/> />
</div> </div>
</div> </div>

View File

@ -13,7 +13,8 @@
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons'; import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Popover, Table } from 'antd'; import { Popover, Table, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import classNames from 'classnames'; import classNames from 'classnames';
import { cloneDeep, isEmpty, isUndefined, lowerCase } from 'lodash'; import { cloneDeep, isEmpty, isUndefined, lowerCase } from 'lodash';
import { EntityFieldThreads, EntityTags, TagOption } from 'Models'; import { EntityFieldThreads, EntityTags, TagOption } from 'Models';
@ -25,20 +26,19 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link, useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { FQN_SEPARATOR_CHAR } from '../../constants/char.constants'; import { FQN_SEPARATOR_CHAR } from '../../constants/char.constants';
import { getTableDetailsPath } from '../../constants/constants';
import { EntityField } from '../../constants/feed.constants'; import { EntityField } from '../../constants/feed.constants';
import { SettledStatus } from '../../enums/axios.enum'; import { SettledStatus } from '../../enums/axios.enum';
import { EntityType, FqnPart } from '../../enums/entity.enum'; import { EntityType, FqnPart } from '../../enums/entity.enum';
import { Column, JoinedWith } from '../../generated/entity/data/table'; import { Column } from '../../generated/entity/data/table';
import { ThreadType } from '../../generated/entity/feed/thread'; import { ThreadType } from '../../generated/entity/feed/thread';
import { LabelType, State, TagLabel } from '../../generated/type/tagLabel'; import { LabelType, State, TagLabel } from '../../generated/type/tagLabel';
import { getPartialNameFromTableFQN } from '../../utils/CommonUtils';
import { import {
getPartialNameFromTableFQN, ENTITY_LINK_SEPARATOR,
getTableFQNFromColumnFQN, getFrequentlyJoinedColumns,
} from '../../utils/CommonUtils'; } from '../../utils/EntityUtils';
import { ENTITY_LINK_SEPARATOR } from '../../utils/EntityUtils';
import { getFieldThreadElement } from '../../utils/FeedElementUtils'; import { getFieldThreadElement } from '../../utils/FeedElementUtils';
import { import {
fetchGlossaryTerms, fetchGlossaryTerms,
@ -62,8 +62,7 @@ import RichTextEditorPreviewer from '../common/rich-text-editor/RichTextEditorPr
import { ModalWithMarkdownEditor } from '../Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor'; import { ModalWithMarkdownEditor } from '../Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor';
import TagsContainer from '../tags-container/tags-container'; import TagsContainer from '../tags-container/tags-container';
import TagsViewer from '../tags-viewer/tags-viewer'; import TagsViewer from '../tags-viewer/tags-viewer';
import { TABLE_HEADERS_V1 } from './EntityTable.constants'; import { EntityTableProps, TableCellRendered } from './EntityTable.interface';
import { EntityTableProps } from './EntityTable.interface';
import './EntityTable.style.less'; import './EntityTable.style.less';
const EntityTable = ({ const EntityTable = ({
@ -252,21 +251,6 @@ const EntityTable = ({
setEditColumnTag(undefined); setEditColumnTag(undefined);
}; };
const getFrequentlyJoinedWithColumns = (
columnName: string
): Array<JoinedWith> => {
return (
joins.find((join) => join.columnName === columnName)?.joinedWith || []
);
};
const checkIfJoinsAvailable = (columnName: string): boolean => {
return (
joins &&
Boolean(joins.length) &&
Boolean(joins.find((join) => join.columnName === columnName))
);
};
const searchInColumns = (table: Column[], searchText: string): Column[] => { const searchInColumns = (table: Column[], searchText: string): Column[] => {
const searchedValue: Column[] = table.reduce((searchedCols, column) => { const searchedValue: Column[] = table.reduce((searchedCols, column) => {
const isContainData = const isContainData =
@ -438,46 +422,30 @@ const EntityTable = ({
); );
}; };
const getDataTypeDisplayCell = (record: Column | Column) => { const renderDataTypeDisplay: TableCellRendered<Column, 'dataTypeDisplay'> = (
dataTypeDisplay
) => {
return ( return (
<> <>
{record.dataTypeDisplay ? ( {dataTypeDisplay ? (
<> isReadOnly || (dataTypeDisplay.length < 25 && !isReadOnly) ? (
{isReadOnly ? ( lowerCase(dataTypeDisplay)
<div className="tw-flex tw-flex-wrap tw-w-60 tw-overflow-x-auto"> ) : (
<RichTextEditorPreviewer <PopOver
markdown={record.dataTypeDisplay.toLowerCase()} html={
/> <div className="break-word">
</div> <span>{lowerCase(dataTypeDisplay)}</span>
) : ( </div>
<> }
{record.dataTypeDisplay.length > 25 ? ( key="pop-over"
<span> position="bottom"
<PopOver theme="light"
html={ trigger="click">
<div className="tw-break-words"> <Typography.Text ellipsis className="cursor-pointer">
<span>{record.dataTypeDisplay.toLowerCase()}</span> {dataTypeDisplay}
</div> </Typography.Text>
} </PopOver>
key="pop-over" )
position="bottom"
theme="light"
trigger="click">
<div className="tw-cursor-pointer tw-underline tw-inline-block">
<RichTextEditorPreviewer
markdown={`${record.dataTypeDisplay
.slice(0, 20)
.toLowerCase()}...`}
/>
</div>
</PopOver>
</span>
) : (
record.dataTypeDisplay.toLowerCase()
)}
</>
)}
</>
) : ( ) : (
'--' '--'
)} )}
@ -485,24 +453,28 @@ const EntityTable = ({
); );
}; };
const getDescriptionCell = (index: number, record: Column | Column) => { const renderDescription: TableCellRendered<Column, 'description'> = (
description,
record,
index
) => {
return ( return (
<div className="hover-icon-group"> <div className="hover-icon-group">
<div className="tw-inline-block"> <div className="d-inline-block">
<div <div
className="tw-flex" className="d-flex"
data-testid="description" data-testid="description"
id={`column-description-${index}`}> id={`column-description-${index}`}>
<div> <div>
{record?.description ? ( {description ? (
<RichTextEditorPreviewer markdown={record?.description} /> <RichTextEditorPreviewer markdown={description} />
) : ( ) : (
<span className="tw-no-description"> <span className="tw-no-description">
{t('label.no-description')} {t('label.no-description')}
</span> </span>
)} )}
</div> </div>
<div className="tw-flex tw--mt-1.5"> <div className="d-flex tw--mt-1.5">
{!isReadOnly ? ( {!isReadOnly ? (
<Fragment> <Fragment>
{hasDescriptionEditAccess && ( {hasDescriptionEditAccess && (
@ -550,189 +522,97 @@ const EntityTable = ({
</div> </div>
</div> </div>
</div> </div>
{checkIfJoinsAvailable(record?.name) && ( {getFrequentlyJoinedColumns(
<div className="tw-mt-3" data-testid="frequently-joined-columns"> record?.name,
<span className="tw-text-grey-muted tw-mr-1"> joins,
{t('label.frequently-joined-columns')}: t('label.frequently-joined-columns')
</span>
<span>
{getFrequentlyJoinedWithColumns(record?.name)
.slice(0, 3)
.map((columnJoin, index) => (
<Fragment key={index}>
{index > 0 && <span className="tw-mr-1">,</span>}
<Link
className="link-text"
to={getTableDetailsPath(
getTableFQNFromColumnFQN(columnJoin.fullyQualifiedName),
getPartialNameFromTableFQN(
columnJoin.fullyQualifiedName,
[FqnPart.Column]
)
)}>
{getPartialNameFromTableFQN(
columnJoin.fullyQualifiedName,
[FqnPart.Database, FqnPart.Table, FqnPart.Column],
FQN_SEPARATOR_CHAR
)}
</Link>
</Fragment>
))}
{getFrequentlyJoinedWithColumns(record?.name).length > 3 && (
<PopOver
html={
<div className="tw-text-left">
{getFrequentlyJoinedWithColumns(record?.name)
?.slice(3)
.map((columnJoin, index) => (
<Fragment key={index}>
<a
className="link-text tw-block tw-py-1"
href={getTableDetailsPath(
getTableFQNFromColumnFQN(
columnJoin?.fullyQualifiedName
),
getPartialNameFromTableFQN(
columnJoin?.fullyQualifiedName,
[FqnPart.Column]
)
)}>
{getPartialNameFromTableFQN(
columnJoin?.fullyQualifiedName,
[
FqnPart.Database,
FqnPart.Table,
FqnPart.Column,
]
)}
</a>
</Fragment>
))}
</div>
}
position="bottom"
theme="light"
trigger="click">
<span className="show-more tw-ml-1 tw-underline">...</span>
</PopOver>
)}
</span>
</div>
)} )}
</div> </div>
); );
}; };
const getTagsCell = (index: number, record: Column | Column) => { const renderTags: TableCellRendered<Column, 'tags'> = useCallback(
return ( (tags, record: Column, index: number) => {
<div className="hover-icon-group"> return (
{isReadOnly ? ( <div className="hover-icon-group">
<div className="tw-flex tw-flex-wrap"> {isReadOnly ? (
<TagsViewer sizeCap={-1} tags={record?.tags || []} /> <div className="tw-flex tw-flex-wrap">
</div> <TagsViewer sizeCap={-1} tags={tags || []} />
) : (
<div
className={classNames(
`tw-flex tw-justify-content`,
editColumnTag?.index === index || !isEmpty(record.tags)
? 'tw-flex-col tw-items-start'
: 'tw-items-center'
)}
data-testid="tags-wrapper"
onClick={() => {
if (!editColumnTag) {
handleEditColumnTag(record, index);
// Fetch tags and terms only once
if (allTags.length === 0 || tagFetchFailed) {
fetchTagsAndGlossaryTerms();
}
}
}}>
<TagsContainer
className="w-max-256"
editable={editColumnTag?.index === index}
isLoading={isTagLoading && editColumnTag?.index === index}
selectedTags={record?.tags || []}
showAddTagButton={hasTagEditAccess}
size="small"
tagList={allTags}
type="label"
onCancel={() => {
handleTagSelection();
}}
onSelectionChange={(tags) => {
handleTagSelection(tags, record?.name);
}}
/>
<div className="tw-mt-1 tw-flex">
{getRequestTagsElement(record)}
{getFieldThreadElement(
getColumnName(record),
'tags',
entityFieldThreads as EntityFieldThreads[],
onThreadLinkSelect,
EntityType.TABLE,
entityFqn,
`columns${ENTITY_LINK_SEPARATOR}${getColumnName(
record
)}${ENTITY_LINK_SEPARATOR}tags`,
Boolean(record?.name?.length)
)}
{getFieldThreadElement(
getColumnName(record),
EntityField.TAGS,
entityFieldTasks as EntityFieldThreads[],
onThreadLinkSelect,
EntityType.TABLE,
entityFqn,
`${EntityField.COLUMNS}${ENTITY_LINK_SEPARATOR}${getColumnName(
record
)}${ENTITY_LINK_SEPARATOR}${EntityField.TAGS}`,
Boolean(record?.name),
ThreadType.Task
)}
</div> </div>
</div> ) : (
)} <div
</div> className={classNames(
); `tw-flex tw-justify-content`,
}; editColumnTag?.index === index || !isEmpty(tags)
? 'tw-flex-col tw-items-start'
const renderCell = useCallback( : 'tw-items-center'
(key: string, record: Column | Column, index: number) => {
switch (key) {
case TABLE_HEADERS_V1.dataTypeDisplay:
return getDataTypeDisplayCell(record);
case TABLE_HEADERS_V1.description:
return getDescriptionCell(index, record);
case TABLE_HEADERS_V1.tags:
return getTagsCell(index, record);
default:
return (
<Fragment>
{isReadOnly ? (
<div className="tw-inline-block">
<RichTextEditorPreviewer markdown={record.name} />
</div>
) : (
<span>
{prepareConstraintIcon(record.name, record.constraint)}
<span>{record.name}</span>
</span>
)} )}
</Fragment> data-testid="tags-wrapper"
); onClick={() => {
} if (!editColumnTag) {
handleEditColumnTag(record, index);
// Fetch tags and terms only once
if (allTags.length === 0 || tagFetchFailed) {
fetchTagsAndGlossaryTerms();
}
}
}}>
<TagsContainer
className="w-max-256"
editable={editColumnTag?.index === index}
isLoading={isTagLoading && editColumnTag?.index === index}
selectedTags={tags || []}
showAddTagButton={hasTagEditAccess}
size="small"
tagList={allTags}
type="label"
onCancel={() => {
handleTagSelection();
}}
onSelectionChange={(selectedTags) => {
handleTagSelection(selectedTags, record?.name);
}}
/>
<div className="tw-mt-1 tw-flex">
{getRequestTagsElement(record)}
{getFieldThreadElement(
getColumnName(record),
'tags',
entityFieldThreads as EntityFieldThreads[],
onThreadLinkSelect,
EntityType.TABLE,
entityFqn,
`columns${ENTITY_LINK_SEPARATOR}${getColumnName(
record
)}${ENTITY_LINK_SEPARATOR}tags`,
Boolean(record?.name?.length)
)}
{getFieldThreadElement(
getColumnName(record),
EntityField.TAGS,
entityFieldTasks as EntityFieldThreads[],
onThreadLinkSelect,
EntityType.TABLE,
entityFqn,
`${
EntityField.COLUMNS
}${ENTITY_LINK_SEPARATOR}${getColumnName(
record
)}${ENTITY_LINK_SEPARATOR}${EntityField.TAGS}`,
Boolean(record?.name),
ThreadType.Task
)}
</div>
</div>
)}
</div>
);
}, },
[editColumnTag, isTagLoading, handleUpdate, handleTagSelection] [isReadOnly, editColumnTag, hasTagEditAccess, isTagLoading]
); );
const columns = useMemo( const columns: ColumnsType<Column> = useMemo(
() => [ () => [
{ {
title: t('label.name'), title: t('label.name'),
@ -741,8 +621,12 @@ const EntityTable = ({
accessor: 'name', accessor: 'name',
ellipsis: true, ellipsis: true,
width: 180, width: 180,
render: (_: Array<unknown>, record: Column, index: number) => render: (name: Column['name'], record: Column) => (
renderCell(TABLE_HEADERS_V1.name, record, index), <Popover destroyTooltipOnHide content={name} trigger="hover">
{prepareConstraintIcon(name, record.constraint)}
<span>{name}</span>
</Popover>
),
}, },
{ {
title: t('label.type'), title: t('label.type'),
@ -751,17 +635,14 @@ const EntityTable = ({
accessor: 'dataTypeDisplay', accessor: 'dataTypeDisplay',
ellipsis: true, ellipsis: true,
width: 200, width: 200,
render: (_: Array<unknown>, record: Column, index: number) => { render: renderDataTypeDisplay,
return renderCell(TABLE_HEADERS_V1.dataTypeDisplay, record, index);
},
}, },
{ {
title: t('label.description'), title: t('label.description'),
dataIndex: 'description', dataIndex: 'description',
key: 'description', key: 'description',
accessor: 'description', accessor: 'description',
render: (_: Array<unknown>, record: Column, index: number) => render: renderDescription,
renderCell(TABLE_HEADERS_V1.description, record, index),
}, },
{ {
title: t('label.tags'), title: t('label.tags'),
@ -769,11 +650,10 @@ const EntityTable = ({
key: 'tags', key: 'tags',
accessor: 'tags', accessor: 'tags',
width: 272, width: 272,
render: (_: Array<unknown>, record: Column | Column, index: number) => render: renderTags,
renderCell(TABLE_HEADERS_V1.tags, record, index),
}, },
], ],
[editColumnTag, isTagLoading, renderCell] [editColumnTag, isTagLoading, handleUpdate, handleTagSelection]
); );
useEffect(() => { useEffect(() => {

View File

@ -12,6 +12,7 @@
*/ */
import { EntityFieldThreads } from 'Models'; import { EntityFieldThreads } from 'Models';
import { ReactNode } from 'react';
import { ThreadType } from '../../generated/api/feed/createThread'; import { ThreadType } from '../../generated/api/feed/createThread';
import { Column, ColumnJoins, Table } from '../../generated/entity/data/table'; import { Column, ColumnJoins, Table } from '../../generated/entity/data/table';
@ -31,3 +32,13 @@ export interface EntityTableProps {
onThreadLinkSelect?: (value: string, threadType?: ThreadType) => void; onThreadLinkSelect?: (value: string, threadType?: ThreadType) => void;
onEntityFieldSelect?: (value: string) => void; onEntityFieldSelect?: (value: string) => void;
} }
export type TableCellRendered<T, K extends keyof T> = (
value: T[K],
record: T,
index: number
) => ReactNode;
export interface DataTypeDisplayCellProps {
dataTypeDisplay: Column['dataTypeDisplay'];
}

View File

@ -20,7 +20,6 @@ import { Column } from '../../generated/api/data/createTable';
import { Table } from '../../generated/entity/data/table'; import { Table } from '../../generated/entity/data/table';
import { TagCategory, TagClass } from '../../generated/entity/tags/tagCategory'; import { TagCategory, TagClass } from '../../generated/entity/tags/tagCategory';
import EntityTableV1 from './EntityTable.component'; import EntityTableV1 from './EntityTable.component';
import type { ColumnsType } from 'antd/es/table';
const onEntityFieldSelect = jest.fn(); const onEntityFieldSelect = jest.fn();
const onThreadLinkSelect = jest.fn(); const onThreadLinkSelect = jest.fn();
@ -32,15 +31,6 @@ const mockTableConstraints = [
}, },
] as Table['tableConstraints']; ] as Table['tableConstraints'];
type ColumnDataType = {
key: string;
name: string;
dataTypeDisplay: string;
columnTests: string;
description: string;
tags: string;
};
const mockEntityTableProp = { const mockEntityTableProp = {
tableColumns: [ tableColumns: [
{ {
@ -257,35 +247,6 @@ jest.mock('../../utils/TagsUtils', () => ({
}), }),
})); }));
jest.mock('antd', () => ({
Popover: jest
.fn()
.mockImplementation(({ children }) => <div>{children}</div>),
Table: jest.fn().mockImplementation(({ columns, dataSource }) => (
<table data-testid="entity-table">
<thead>
<tr>
{(columns as ColumnsType<ColumnDataType>).map((col) => (
<th key={col.key}>{col.title}</th>
))}
</tr>
</thead>
<tbody key="tbody">
{dataSource.map((row: Column, i: number) => (
<tr key={i}>
{(columns as ColumnsType<ColumnDataType>).map((col, index) => (
<td key={col.key}>
{col.render ? col.render(row, dataSource, index) : 'alt'}
</td>
))}
</tr>
))}
</tbody>
</table>
)),
}));
describe('Test EntityTable Component', () => { describe('Test EntityTable Component', () => {
it('Initially, Table should load', async () => { it('Initially, Table should load', async () => {
render(<EntityTableV1 {...mockEntityTableProp} />, { render(<EntityTableV1 {...mockEntityTableProp} />, {

View File

@ -12,33 +12,10 @@
*/ */
import { lowerCase } from 'lodash'; import { lowerCase } from 'lodash';
import { EntityFieldThreads } from 'Models';
import React, { Fragment, FunctionComponent, useState } from 'react'; import React, { Fragment, FunctionComponent, useState } from 'react';
import {
ColumnJoins,
Table,
TableData,
} from '../../generated/entity/data/table';
import { ThreadType } from '../../generated/entity/feed/thread';
import Searchbar from '../common/searchbar/Searchbar'; import Searchbar from '../common/searchbar/Searchbar';
import EntityTableV1 from '../EntityTable/EntityTable.component'; import EntityTableV1 from '../EntityTable/EntityTable.component';
import { Props } from './SchemaTab.interfaces';
type Props = {
columns: Table['columns'];
joins: Array<ColumnJoins>;
columnName: string;
tableConstraints: Table['tableConstraints'];
sampleData?: TableData;
hasDescriptionEditAccess?: boolean;
hasTagEditAccess?: boolean;
isReadOnly?: boolean;
entityFqn?: string;
entityFieldThreads?: EntityFieldThreads[];
entityFieldTasks?: EntityFieldThreads[];
onThreadLinkSelect?: (value: string, threadType?: ThreadType) => void;
onEntityFieldSelect?: (value: string) => void;
onUpdate?: (columns: Table['columns']) => Promise<void>;
};
const SchemaTab: FunctionComponent<Props> = ({ const SchemaTab: FunctionComponent<Props> = ({
columns, columns,

View File

@ -0,0 +1,37 @@
/*
* Copyright 2022 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 { EntityFieldThreads } from 'Models';
import { ThreadType } from '../../generated/api/feed/createThread';
import {
ColumnJoins,
Table,
TableData,
} from '../../generated/entity/data/table';
export type Props = {
columns: Table['columns'];
joins: Array<ColumnJoins>;
columnName: string;
tableConstraints: Table['tableConstraints'];
sampleData?: TableData;
hasDescriptionEditAccess?: boolean;
hasTagEditAccess?: boolean;
isReadOnly?: boolean;
entityFqn?: string;
entityFieldThreads?: EntityFieldThreads[];
entityFieldTasks?: EntityFieldThreads[];
onThreadLinkSelect?: (value: string, threadType?: ThreadType) => void;
onEntityFieldSelect?: (value: string) => void;
onUpdate?: (columns: Table['columns']) => Promise<void>;
};

View File

@ -0,0 +1,155 @@
/*
* Copyright 2022 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 { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Col, Row, Table } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Column } from '../../generated/entity/data/table';
import {
getFrequentlyJoinedColumns,
searchInColumns,
} from '../../utils/EntityUtils';
import { makeData } from '../../utils/TableUtils';
import RichTextEditorPreviewer from '../common/rich-text-editor/RichTextEditorPreviewer';
import Searchbar from '../common/searchbar/Searchbar';
import TagsViewer from '../tags-viewer/tags-viewer';
import { VersionTableProps } from './VersionTable.interfaces';
const VersionTable = ({ columnName, columns, joins }: VersionTableProps) => {
const [searchedColumns, setSearchedColumns] = useState<Column[]>([]);
const { t } = useTranslation();
const [searchText, setSearchText] = useState('');
const data = useMemo(() => makeData(searchedColumns), [searchedColumns]);
const versionTableColumns = useMemo(
() => [
{
title: t('label.name'),
dataIndex: 'name',
key: 'name',
accessor: 'name',
ellipsis: true,
width: 180,
render: (name: Column['name']) => (
<div className="d-inline-block">
<RichTextEditorPreviewer markdown={name} />
</div>
),
},
{
title: t('label.type'),
dataIndex: 'dataTypeDisplay',
key: 'dataTypeDisplay',
accessor: 'dataTypeDisplay',
ellipsis: true,
width: 200,
render: (dataTypeDisplay: Column['dataTypeDisplay']) =>
dataTypeDisplay ? (
<RichTextEditorPreviewer markdown={dataTypeDisplay.toLowerCase()} />
) : (
'--'
),
},
{
title: t('label.description'),
dataIndex: 'description',
key: 'description',
accessor: 'description',
render: (description: Column['description']) =>
description ? (
<>
<RichTextEditorPreviewer markdown={description} />
{getFrequentlyJoinedColumns(
columnName,
joins,
t('label.frequently-joined-columns')
)}
</>
) : (
<span className="tw-no-description">
{t('label.no-description')}
</span>
),
},
{
title: t('label.tags'),
dataIndex: 'tags',
key: 'tags',
accessor: 'tags',
width: 272,
render: (tags: Column['tags']) => (
<div className="flex flex-wrap">
<TagsViewer sizeCap={-1} tags={tags || []} />
</div>
),
},
],
[]
);
const handleSearchAction = (searchValue: string) => {
setSearchText(searchValue);
};
useEffect(() => {
if (!searchText) {
setSearchedColumns(columns);
} else {
const searchCols = searchInColumns(columns, searchText);
setSearchedColumns(searchCols);
}
}, [searchText, columns]);
return (
<Row>
<Col>
<Searchbar
placeholder={`${t('label.find-in-table')}...`}
searchValue={searchText}
typingInterval={500}
onSearch={handleSearchAction}
/>
</Col>
<Col>
<Table
columns={versionTableColumns}
data-testid="entity-table"
dataSource={data}
expandable={{
defaultExpandedRowKeys: [],
expandIcon: ({ expanded, onExpand, record }) =>
record.children ? (
<FontAwesomeIcon
className="m-r-xs cursor-pointer"
icon={expanded ? faCaretDown : faCaretRight}
onClick={(e) =>
onExpand(
record,
e as unknown as React.MouseEvent<HTMLElement>
)
}
/>
) : null,
}}
pagination={false}
size="small"
/>
</Col>
</Row>
);
};
export default VersionTable;

View File

@ -0,0 +1,20 @@
/*
* Copyright 2022 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 { ColumnJoins, Table } from '../../generated/entity/data/table';
export interface VersionTableProps {
columnName: string;
columns: Table['columns'];
joins: Array<ColumnJoins>;
}

View File

@ -129,12 +129,12 @@ describe('Test CronEditor component', () => {
render(<CronEditor {...mockProps} />); render(<CronEditor {...mockProps} />);
const cronType = await screen.findByTestId('cron-type'); const cronType = await screen.findByTestId('cron-type');
act(async () => { await act(async () => {
await userEvent.selectOptions(cronType, ''); userEvent.selectOptions(cronType, '');
expect(
await screen.findByTestId('manual-segment-container')
).toBeInTheDocument();
}); });
expect(
await screen.findByTestId('manual-segment-container')
).toBeInTheDocument();
}); });
}); });

View File

@ -149,7 +149,6 @@
"bot": "bot", "bot": "bot",
"for": "for", "for": "for",
"are-you-sure": "Are you sure?", "are-you-sure": "Are you sure?",
"email": "Email",
"select-token-expiration": "Select Token Expiration", "select-token-expiration": "Select Token Expiration",
"token-expiration": "Token Expiration", "token-expiration": "Token Expiration",
"select-auth-mechanism": "Select Auth Mechanism", "select-auth-mechanism": "Select Auth Mechanism",
@ -172,6 +171,7 @@
"sql-query": "SQL Query", "sql-query": "SQL Query",
"sql-query-tooltip": "Queries returning 1 or more rows will result in the test failing.", "sql-query-tooltip": "Queries returning 1 or more rows will result in the test failing.",
"scopes-comma-separated": "Scopes value comma separated", "scopes-comma-separated": "Scopes value comma separated",
"find-in-table": "Find in table",
"data-insight-summary": "OpenMetadata health at a glance", "data-insight-summary": "OpenMetadata health at a glance",
"data-insight-description-summary": "Percentage of Entities With Description", "data-insight-description-summary": "Percentage of Entities With Description",
"data-insight-owner-summary": "Percentage of Entities With Owners", "data-insight-owner-summary": "Percentage of Entities With Owners",

View File

@ -32,12 +32,6 @@ jest.mock('react-lazylog', () => ({
LazyLog: jest.fn().mockImplementation(() => <div>LazyLog</div>), LazyLog: jest.fn().mockImplementation(() => <div>LazyLog</div>),
})); }));
jest.mock('react-i18next', () => ({
useTranslation: jest.fn().mockReturnValue({
t: (key: string) => key,
}),
}));
jest.mock('../../axiosAPIs/ingestionPipelineAPI', () => ({ jest.mock('../../axiosAPIs/ingestionPipelineAPI', () => ({
getIngestionPipelineLogById: jest getIngestionPipelineLogById: jest
.fn() .fn()

View File

@ -197,7 +197,7 @@ const AddUsersModalV1 = ({
className="user-list" className="user-list"
data={uniqueUser} data={uniqueUser}
height={ADD_USER_CONTAINER_HEIGHT} height={ADD_USER_CONTAINER_HEIGHT}
itemKey="user" itemKey="id"
onScroll={onScroll}> onScroll={onScroll}>
{(User) => ( {(User) => (
<UserCard <UserCard

View File

@ -53,3 +53,12 @@ window.IntersectionObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(), observe: jest.fn(),
unobserve: jest.fn(), unobserve: jest.fn(),
})); }));
/**
* mock react-i18next
*/
jest.mock('react-i18next', () => ({
useTranslation: jest.fn().mockReturnValue({
t: (key) => key,
}),
}));

View File

@ -62,6 +62,9 @@
text-align: right; text-align: right;
} }
.text-underline {
text-decoration: underline;
}
// Width // Width
.w-8 { .w-8 {
width: 32px; width: 32px;
@ -177,6 +180,9 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.flex-wrap {
flex-wrap: wrap;
}
.break-word { .break-word {
word-break: break-word; word-break: break-word;
@ -252,10 +258,6 @@
} }
} }
.underline {
text-decoration: underline;
}
.no-underline { .no-underline {
text-decoration: none; text-decoration: none;
} }

View File

@ -32,7 +32,12 @@
.d-flex { .d-flex {
display: flex; display: flex;
} }
.d-inline-block {
display: inline-block;
}
.d-block {
display: block;
}
.flex-1 { .flex-1 {
flex: 1; flex: 1;
} }

View File

@ -11,15 +11,18 @@
* limitations under the License. * limitations under the License.
*/ */
import { isEmpty, isNil, isUndefined, startCase } from 'lodash'; import { isEmpty, isNil, isUndefined, lowerCase, startCase } from 'lodash';
import { Bucket, LeafNodes, LineagePos } from 'Models'; import { Bucket, LeafNodes, LineagePos } from 'Models';
import React from 'react'; import React, { Fragment } from 'react';
import { Link } from 'react-router-dom';
import PopOver from '../components/common/popover/PopOver';
import { EntityData } from '../components/common/PopOverCard/EntityPopOverCard'; import { EntityData } from '../components/common/PopOverCard/EntityPopOverCard';
import { ResourceEntity } from '../components/PermissionProvider/PermissionProvider.interface'; import { ResourceEntity } from '../components/PermissionProvider/PermissionProvider.interface';
import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; import { FQN_SEPARATOR_CHAR } from '../constants/char.constants';
import { import {
getDatabaseDetailsPath, getDatabaseDetailsPath,
getServiceDetailsPath, getServiceDetailsPath,
getTableDetailsPath,
getTeamAndUserDetailsPath, getTeamAndUserDetailsPath,
} from '../constants/constants'; } from '../constants/constants';
import { AssetsType, EntityType, FqnPart } from '../enums/entity.enum'; import { AssetsType, EntityType, FqnPart } from '../enums/entity.enum';
@ -28,12 +31,21 @@ import { ServiceCategory } from '../enums/service.enum';
import { PrimaryTableDataTypes } from '../enums/table.enum'; import { PrimaryTableDataTypes } from '../enums/table.enum';
import { Dashboard } from '../generated/entity/data/dashboard'; import { Dashboard } from '../generated/entity/data/dashboard';
import { Pipeline } from '../generated/entity/data/pipeline'; import { Pipeline } from '../generated/entity/data/pipeline';
import { Table } from '../generated/entity/data/table'; import {
Column,
ColumnJoins,
JoinedWith,
Table,
} from '../generated/entity/data/table';
import { Topic } from '../generated/entity/data/topic'; import { Topic } from '../generated/entity/data/topic';
import { Edge, EntityLineage } from '../generated/type/entityLineage'; import { Edge, EntityLineage } from '../generated/type/entityLineage';
import { EntityReference } from '../generated/type/entityUsage'; import { EntityReference } from '../generated/type/entityUsage';
import { TagLabel } from '../generated/type/tagLabel'; import { TagLabel } from '../generated/type/tagLabel';
import { getEntityName, getPartialNameFromTableFQN } from './CommonUtils'; import {
getEntityName,
getPartialNameFromTableFQN,
getTableFQNFromColumnFQN,
} from './CommonUtils';
import { import {
getDataTypeString, getDataTypeString,
getTierFromTableTags, getTierFromTableTags,
@ -400,3 +412,146 @@ export const getResourceEntityFromEntityType = (entityType: string) => {
return ResourceEntity.ALL; return ResourceEntity.ALL;
}; };
/**
* It searches for a given text in a given table and returns a new table with only the columns that
* contain the given text
* @param {Column[]} table - Column[] - the table to search in
* @param {string} searchText - The text to search for.
* @returns An array of columns that have been searched for a specific string.
*/
export const searchInColumns = (
table: Column[],
searchText: string
): Column[] => {
const searchedValue: Column[] = table.reduce((searchedCols, column) => {
const searchLowerCase = lowerCase(searchText);
const isContainData =
lowerCase(column.name).includes(searchLowerCase) ||
lowerCase(column.description).includes(searchLowerCase) ||
lowerCase(getDataTypeString(column.dataType)).includes(searchLowerCase);
if (isContainData) {
return [...searchedCols, column];
} else if (!isUndefined(column.children)) {
const searchedChildren = searchInColumns(column.children, searchText);
if (searchedChildren.length > 0) {
return [
...searchedCols,
{
...column,
children: searchedChildren,
},
];
}
}
return searchedCols;
}, [] as Column[]);
return searchedValue;
};
/**
* It checks if a column has a join
* @param {string} columnName - The name of the column you want to check if joins are available for.
* @param joins - Array<ColumnJoins>
* @returns A boolean value.
*/
export const checkIfJoinsAvailable = (
columnName: string,
joins: Array<ColumnJoins>
): boolean => {
return (
joins &&
Boolean(joins.length) &&
Boolean(joins.find((join) => join.columnName === columnName))
);
};
/**
* It takes a column name and a list of joins and returns the list of joinedWith for the column name
* @param {string} columnName - The name of the column you want to get the frequently joined with
* columns for.
* @param joins - Array<ColumnJoins>
* @returns An array of joinedWith objects
*/
export const getFrequentlyJoinedWithColumns = (
columnName: string,
joins: Array<ColumnJoins>
): Array<JoinedWith> => {
return joins.find((join) => join.columnName === columnName)?.joinedWith || [];
};
export const getFrequentlyJoinedColumns = (
columnName: string,
joins: Array<ColumnJoins>,
columnLabel: string
) => {
const frequentlyJoinedWithColumns = getFrequentlyJoinedWithColumns(
columnName,
joins
);
return checkIfJoinsAvailable(columnName, joins) ? (
<div className="m-t-sm" data-testid="frequently-joined-columns">
<span className="tw-text-grey-muted m-r-xss">{columnLabel}:</span>
<span>
{frequentlyJoinedWithColumns.slice(0, 3).map((columnJoin, index) => (
<Fragment key={index}>
{index > 0 && <span className="m-r-xss">,</span>}
<Link
className="link-text"
to={getTableDetailsPath(
getTableFQNFromColumnFQN(columnJoin.fullyQualifiedName),
getPartialNameFromTableFQN(columnJoin.fullyQualifiedName, [
FqnPart.Column,
])
)}>
{getPartialNameFromTableFQN(
columnJoin.fullyQualifiedName,
[FqnPart.Database, FqnPart.Table, FqnPart.Column],
FQN_SEPARATOR_CHAR
)}
</Link>
</Fragment>
))}
{frequentlyJoinedWithColumns.length > 3 && (
<PopOver
html={
<div className="text-left">
{frequentlyJoinedWithColumns
?.slice(3)
.map((columnJoin, index) => (
<Fragment key={index}>
<a
className="link-text d-block p-y-xss"
href={getTableDetailsPath(
getTableFQNFromColumnFQN(
columnJoin?.fullyQualifiedName
),
getPartialNameFromTableFQN(
columnJoin?.fullyQualifiedName,
[FqnPart.Column]
)
)}>
{getPartialNameFromTableFQN(
columnJoin?.fullyQualifiedName,
[FqnPart.Database, FqnPart.Table, FqnPart.Column]
)}
</a>
</Fragment>
))}
</div>
}
position="bottom"
theme="light"
trigger="click">
<span className="show-more m-l-xss text-underline">...</span>
</PopOver>
)}
</span>
</div>
) : null;
};