mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-11-01 19:18:05 +00:00
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:
parent
5819c45937
commit
ca91bafa39
@ -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"]')
|
||||
.contains(NEW_TAG_CATEGORY.name)
|
||||
.should('be.visible')
|
||||
|
||||
@ -75,6 +75,7 @@ const DashboardDetailsProps = {
|
||||
chartUrl: 'http://localhost',
|
||||
chartType: 'Area',
|
||||
displayName: 'Test chart',
|
||||
id: '1',
|
||||
},
|
||||
] as ChartType[],
|
||||
serviceType: '',
|
||||
|
||||
@ -38,7 +38,7 @@ export const dashboardVersionProp = {
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: '0698ab5d-a122-4b86-a6e5-d10bf3550bd7',
|
||||
id: '0698ab5d-a122-4b86-a6e5-d10bf3550bd6',
|
||||
type: 'chart',
|
||||
name: 'without_description',
|
||||
description: '',
|
||||
|
||||
@ -40,7 +40,7 @@ import TabsPane from '../common/TabsPane/TabsPane';
|
||||
import PageContainer from '../containers/PageContainer';
|
||||
import EntityVersionTimeLine from '../EntityVersionTimeLine/EntityVersionTimeLine';
|
||||
import Loader from '../Loader/Loader';
|
||||
import SchemaTab from '../SchemaTab/SchemaTab.component';
|
||||
import VersionTable from '../VersionTable/VersionTable.component';
|
||||
import { DatasetVersionProp } from './DatasetVersion.interface';
|
||||
|
||||
const DatasetVersion: React.FC<DatasetVersionProp> = ({
|
||||
@ -406,8 +406,7 @@ const DatasetVersion: React.FC<DatasetVersionProp> = ({
|
||||
</div>
|
||||
|
||||
<div className="tw-col-span-full">
|
||||
<SchemaTab
|
||||
isReadOnly
|
||||
<VersionTable
|
||||
columnName={getPartialNameFromTableFQN(
|
||||
datasetFQN,
|
||||
[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`
|
||||
(currentVersionData as Table).joins as ColumnJoins[]
|
||||
}
|
||||
tableConstraints={[]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -13,7 +13,8 @@
|
||||
|
||||
import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons';
|
||||
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 { cloneDeep, isEmpty, isUndefined, lowerCase } from 'lodash';
|
||||
import { EntityFieldThreads, EntityTags, TagOption } from 'Models';
|
||||
@ -25,20 +26,19 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
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 { getTableDetailsPath } from '../../constants/constants';
|
||||
import { EntityField } from '../../constants/feed.constants';
|
||||
import { SettledStatus } from '../../enums/axios.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 { LabelType, State, TagLabel } from '../../generated/type/tagLabel';
|
||||
import { getPartialNameFromTableFQN } from '../../utils/CommonUtils';
|
||||
import {
|
||||
getPartialNameFromTableFQN,
|
||||
getTableFQNFromColumnFQN,
|
||||
} from '../../utils/CommonUtils';
|
||||
import { ENTITY_LINK_SEPARATOR } from '../../utils/EntityUtils';
|
||||
ENTITY_LINK_SEPARATOR,
|
||||
getFrequentlyJoinedColumns,
|
||||
} from '../../utils/EntityUtils';
|
||||
import { getFieldThreadElement } from '../../utils/FeedElementUtils';
|
||||
import {
|
||||
fetchGlossaryTerms,
|
||||
@ -62,8 +62,7 @@ import RichTextEditorPreviewer from '../common/rich-text-editor/RichTextEditorPr
|
||||
import { ModalWithMarkdownEditor } from '../Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor';
|
||||
import TagsContainer from '../tags-container/tags-container';
|
||||
import TagsViewer from '../tags-viewer/tags-viewer';
|
||||
import { TABLE_HEADERS_V1 } from './EntityTable.constants';
|
||||
import { EntityTableProps } from './EntityTable.interface';
|
||||
import { EntityTableProps, TableCellRendered } from './EntityTable.interface';
|
||||
import './EntityTable.style.less';
|
||||
|
||||
const EntityTable = ({
|
||||
@ -252,21 +251,6 @@ const EntityTable = ({
|
||||
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 searchedValue: Column[] = table.reduce((searchedCols, column) => {
|
||||
const isContainData =
|
||||
@ -438,46 +422,30 @@ const EntityTable = ({
|
||||
);
|
||||
};
|
||||
|
||||
const getDataTypeDisplayCell = (record: Column | Column) => {
|
||||
const renderDataTypeDisplay: TableCellRendered<Column, 'dataTypeDisplay'> = (
|
||||
dataTypeDisplay
|
||||
) => {
|
||||
return (
|
||||
<>
|
||||
{record.dataTypeDisplay ? (
|
||||
<>
|
||||
{isReadOnly ? (
|
||||
<div className="tw-flex tw-flex-wrap tw-w-60 tw-overflow-x-auto">
|
||||
<RichTextEditorPreviewer
|
||||
markdown={record.dataTypeDisplay.toLowerCase()}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{record.dataTypeDisplay.length > 25 ? (
|
||||
<span>
|
||||
<PopOver
|
||||
html={
|
||||
<div className="tw-break-words">
|
||||
<span>{record.dataTypeDisplay.toLowerCase()}</span>
|
||||
</div>
|
||||
}
|
||||
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()
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{dataTypeDisplay ? (
|
||||
isReadOnly || (dataTypeDisplay.length < 25 && !isReadOnly) ? (
|
||||
lowerCase(dataTypeDisplay)
|
||||
) : (
|
||||
<PopOver
|
||||
html={
|
||||
<div className="break-word">
|
||||
<span>{lowerCase(dataTypeDisplay)}</span>
|
||||
</div>
|
||||
}
|
||||
key="pop-over"
|
||||
position="bottom"
|
||||
theme="light"
|
||||
trigger="click">
|
||||
<Typography.Text ellipsis className="cursor-pointer">
|
||||
{dataTypeDisplay}
|
||||
</Typography.Text>
|
||||
</PopOver>
|
||||
)
|
||||
) : (
|
||||
'--'
|
||||
)}
|
||||
@ -485,24 +453,28 @@ const EntityTable = ({
|
||||
);
|
||||
};
|
||||
|
||||
const getDescriptionCell = (index: number, record: Column | Column) => {
|
||||
const renderDescription: TableCellRendered<Column, 'description'> = (
|
||||
description,
|
||||
record,
|
||||
index
|
||||
) => {
|
||||
return (
|
||||
<div className="hover-icon-group">
|
||||
<div className="tw-inline-block">
|
||||
<div className="d-inline-block">
|
||||
<div
|
||||
className="tw-flex"
|
||||
className="d-flex"
|
||||
data-testid="description"
|
||||
id={`column-description-${index}`}>
|
||||
<div>
|
||||
{record?.description ? (
|
||||
<RichTextEditorPreviewer markdown={record?.description} />
|
||||
{description ? (
|
||||
<RichTextEditorPreviewer markdown={description} />
|
||||
) : (
|
||||
<span className="tw-no-description">
|
||||
{t('label.no-description')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="tw-flex tw--mt-1.5">
|
||||
<div className="d-flex tw--mt-1.5">
|
||||
{!isReadOnly ? (
|
||||
<Fragment>
|
||||
{hasDescriptionEditAccess && (
|
||||
@ -550,189 +522,97 @@ const EntityTable = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{checkIfJoinsAvailable(record?.name) && (
|
||||
<div className="tw-mt-3" data-testid="frequently-joined-columns">
|
||||
<span className="tw-text-grey-muted tw-mr-1">
|
||||
{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>
|
||||
{getFrequentlyJoinedColumns(
|
||||
record?.name,
|
||||
joins,
|
||||
t('label.frequently-joined-columns')
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getTagsCell = (index: number, record: Column | Column) => {
|
||||
return (
|
||||
<div className="hover-icon-group">
|
||||
{isReadOnly ? (
|
||||
<div className="tw-flex tw-flex-wrap">
|
||||
<TagsViewer sizeCap={-1} tags={record?.tags || []} />
|
||||
</div>
|
||||
) : (
|
||||
<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
|
||||
)}
|
||||
const renderTags: TableCellRendered<Column, 'tags'> = useCallback(
|
||||
(tags, record: Column, index: number) => {
|
||||
return (
|
||||
<div className="hover-icon-group">
|
||||
{isReadOnly ? (
|
||||
<div className="tw-flex tw-flex-wrap">
|
||||
<TagsViewer sizeCap={-1} tags={tags || []} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCell = useCallback(
|
||||
(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>
|
||||
) : (
|
||||
<div
|
||||
className={classNames(
|
||||
`tw-flex tw-justify-content`,
|
||||
editColumnTag?.index === index || !isEmpty(tags)
|
||||
? 'tw-flex-col tw-items-start'
|
||||
: 'tw-items-center'
|
||||
)}
|
||||
</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'),
|
||||
@ -741,8 +621,12 @@ const EntityTable = ({
|
||||
accessor: 'name',
|
||||
ellipsis: true,
|
||||
width: 180,
|
||||
render: (_: Array<unknown>, record: Column, index: number) =>
|
||||
renderCell(TABLE_HEADERS_V1.name, record, index),
|
||||
render: (name: Column['name'], record: Column) => (
|
||||
<Popover destroyTooltipOnHide content={name} trigger="hover">
|
||||
{prepareConstraintIcon(name, record.constraint)}
|
||||
<span>{name}</span>
|
||||
</Popover>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('label.type'),
|
||||
@ -751,17 +635,14 @@ const EntityTable = ({
|
||||
accessor: 'dataTypeDisplay',
|
||||
ellipsis: true,
|
||||
width: 200,
|
||||
render: (_: Array<unknown>, record: Column, index: number) => {
|
||||
return renderCell(TABLE_HEADERS_V1.dataTypeDisplay, record, index);
|
||||
},
|
||||
render: renderDataTypeDisplay,
|
||||
},
|
||||
{
|
||||
title: t('label.description'),
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
accessor: 'description',
|
||||
render: (_: Array<unknown>, record: Column, index: number) =>
|
||||
renderCell(TABLE_HEADERS_V1.description, record, index),
|
||||
render: renderDescription,
|
||||
},
|
||||
{
|
||||
title: t('label.tags'),
|
||||
@ -769,11 +650,10 @@ const EntityTable = ({
|
||||
key: 'tags',
|
||||
accessor: 'tags',
|
||||
width: 272,
|
||||
render: (_: Array<unknown>, record: Column | Column, index: number) =>
|
||||
renderCell(TABLE_HEADERS_V1.tags, record, index),
|
||||
render: renderTags,
|
||||
},
|
||||
],
|
||||
[editColumnTag, isTagLoading, renderCell]
|
||||
[editColumnTag, isTagLoading, handleUpdate, handleTagSelection]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { EntityFieldThreads } from 'Models';
|
||||
import { ReactNode } from 'react';
|
||||
import { ThreadType } from '../../generated/api/feed/createThread';
|
||||
import { Column, ColumnJoins, Table } from '../../generated/entity/data/table';
|
||||
|
||||
@ -31,3 +32,13 @@ export interface EntityTableProps {
|
||||
onThreadLinkSelect?: (value: string, threadType?: ThreadType) => 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'];
|
||||
}
|
||||
|
||||
@ -20,7 +20,6 @@ import { Column } from '../../generated/api/data/createTable';
|
||||
import { Table } from '../../generated/entity/data/table';
|
||||
import { TagCategory, TagClass } from '../../generated/entity/tags/tagCategory';
|
||||
import EntityTableV1 from './EntityTable.component';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
const onEntityFieldSelect = jest.fn();
|
||||
const onThreadLinkSelect = jest.fn();
|
||||
@ -32,15 +31,6 @@ const mockTableConstraints = [
|
||||
},
|
||||
] as Table['tableConstraints'];
|
||||
|
||||
type ColumnDataType = {
|
||||
key: string;
|
||||
name: string;
|
||||
dataTypeDisplay: string;
|
||||
columnTests: string;
|
||||
description: string;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
const mockEntityTableProp = {
|
||||
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', () => {
|
||||
it('Initially, Table should load', async () => {
|
||||
render(<EntityTableV1 {...mockEntityTableProp} />, {
|
||||
|
||||
@ -12,33 +12,10 @@
|
||||
*/
|
||||
|
||||
import { lowerCase } from 'lodash';
|
||||
import { EntityFieldThreads } from 'Models';
|
||||
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 EntityTableV1 from '../EntityTable/EntityTable.component';
|
||||
|
||||
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>;
|
||||
};
|
||||
import { Props } from './SchemaTab.interfaces';
|
||||
|
||||
const SchemaTab: FunctionComponent<Props> = ({
|
||||
columns,
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
@ -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;
|
||||
@ -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>;
|
||||
}
|
||||
@ -129,12 +129,12 @@ describe('Test CronEditor component', () => {
|
||||
render(<CronEditor {...mockProps} />);
|
||||
|
||||
const cronType = await screen.findByTestId('cron-type');
|
||||
act(async () => {
|
||||
await userEvent.selectOptions(cronType, '');
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('manual-segment-container')
|
||||
).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
userEvent.selectOptions(cronType, '');
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('manual-segment-container')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -149,7 +149,6 @@
|
||||
"bot": "bot",
|
||||
"for": "for",
|
||||
"are-you-sure": "Are you sure?",
|
||||
"email": "Email",
|
||||
"select-token-expiration": "Select Token Expiration",
|
||||
"token-expiration": "Token Expiration",
|
||||
"select-auth-mechanism": "Select Auth Mechanism",
|
||||
@ -172,6 +171,7 @@
|
||||
"sql-query": "SQL Query",
|
||||
"sql-query-tooltip": "Queries returning 1 or more rows will result in the test failing.",
|
||||
"scopes-comma-separated": "Scopes value comma separated",
|
||||
"find-in-table": "Find in table",
|
||||
"data-insight-summary": "OpenMetadata health at a glance",
|
||||
"data-insight-description-summary": "Percentage of Entities With Description",
|
||||
"data-insight-owner-summary": "Percentage of Entities With Owners",
|
||||
|
||||
@ -32,12 +32,6 @@ jest.mock('react-lazylog', () => ({
|
||||
LazyLog: jest.fn().mockImplementation(() => <div>LazyLog</div>),
|
||||
}));
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: jest.fn().mockReturnValue({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../axiosAPIs/ingestionPipelineAPI', () => ({
|
||||
getIngestionPipelineLogById: jest
|
||||
.fn()
|
||||
|
||||
@ -197,7 +197,7 @@ const AddUsersModalV1 = ({
|
||||
className="user-list"
|
||||
data={uniqueUser}
|
||||
height={ADD_USER_CONTAINER_HEIGHT}
|
||||
itemKey="user"
|
||||
itemKey="id"
|
||||
onScroll={onScroll}>
|
||||
{(User) => (
|
||||
<UserCard
|
||||
|
||||
@ -53,3 +53,12 @@ window.IntersectionObserver = jest.fn().mockImplementation(() => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* mock react-i18next
|
||||
*/
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: jest.fn().mockReturnValue({
|
||||
t: (key) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -62,6 +62,9 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
// Width
|
||||
.w-8 {
|
||||
width: 32px;
|
||||
@ -177,6 +180,9 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.break-word {
|
||||
word-break: break-word;
|
||||
@ -252,10 +258,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.no-underline {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@ -32,7 +32,12 @@
|
||||
.d-flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.d-inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
.d-block {
|
||||
display: block;
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@ -11,15 +11,18 @@
|
||||
* 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 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 { ResourceEntity } from '../components/PermissionProvider/PermissionProvider.interface';
|
||||
import { FQN_SEPARATOR_CHAR } from '../constants/char.constants';
|
||||
import {
|
||||
getDatabaseDetailsPath,
|
||||
getServiceDetailsPath,
|
||||
getTableDetailsPath,
|
||||
getTeamAndUserDetailsPath,
|
||||
} from '../constants/constants';
|
||||
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 { Dashboard } from '../generated/entity/data/dashboard';
|
||||
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 { Edge, EntityLineage } from '../generated/type/entityLineage';
|
||||
import { EntityReference } from '../generated/type/entityUsage';
|
||||
import { TagLabel } from '../generated/type/tagLabel';
|
||||
import { getEntityName, getPartialNameFromTableFQN } from './CommonUtils';
|
||||
import {
|
||||
getEntityName,
|
||||
getPartialNameFromTableFQN,
|
||||
getTableFQNFromColumnFQN,
|
||||
} from './CommonUtils';
|
||||
import {
|
||||
getDataTypeString,
|
||||
getTierFromTableTags,
|
||||
@ -400,3 +412,146 @@ export const getResourceEntityFromEntityType = (entityType: string) => {
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user