(Feat) UI: Add Profiler Tab as per new mock (#6627)

* add initial setup and added profiler tab

* added unit test for tableprofiler component

* added more coverage for table profiler component

* added get and put request and functionality for profiler setting

* added unit test for new components

* miner fix

* added data-testid for profiler settings component

* changed input box to slider for profile sample

* updated setting model design

* replaced select with treeSelect

* added unit test for setting modal

* fixing flaky cypress related to resizeObserver loop limit

* fixed failed unit test and added selected sample profiler text

* addressing comments
This commit is contained in:
Shailesh Parmar 2022-08-10 14:46:50 +05:30 committed by GitHub
parent 9eff5f1ed3
commit 455c2d039e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1901 additions and 28 deletions

View File

@ -162,7 +162,7 @@
"nullCount": 0.0,
"nullProportion": 0.0,
"uniqueCount": 14509.0,
"uniqueProportion": 100.0,
"uniqueProportion": 1.0,
"distinctCount": 14509.0,
"distinctProportion": 1.0,
"minLength": 6.0,
@ -330,7 +330,7 @@
"nullCount": 0.0,
"nullProportion": 0.0,
"uniqueCount": 14509.0,
"uniqueProportion": 100.0,
"uniqueProportion": 1.0,
"distinctCount": 14509.0,
"distinctProportion": 1.0,
"minLength": 6.0,

View File

@ -72,3 +72,12 @@ Cypress.Commands.add('goToHomePage', () => {
Cypress.Commands.add('clickOnLogo', () => {
cy.get('#openmetadata_logo > [data-testid="image"]').click();
});
const resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/
Cypress.on('uncaught:exception', (err) => {
/* returning false here prevents Cypress from failing the test */
if (resizeObserverLoopErrRe.test(err.message)) {
return false
}
})

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.47068 15.5H7.52929C6.76789 15.5 6.14841 14.8805 6.14841 14.1192V13.8007C5.82471 13.6972 5.5102 13.5667 5.20807 13.4103L4.98236 13.636C4.43571 14.1834 3.56006 14.1673 3.02929 13.6358L2.36393 12.9705C1.83222 12.4394 1.81692 11.564 2.3641 11.0174L2.58963 10.7919C2.43327 10.4898 2.30275 10.1753 2.19931 9.85156H1.88085C1.11948 9.85156 0.5 9.23211 0.5 8.47071V7.52929C0.5 6.76789 1.11948 6.14844 1.88088 6.14844H2.19934C2.30278 5.82471 2.4333 5.51023 2.58966 5.2081L2.36396 4.98242C1.8171 4.43615 1.83219 3.5607 2.36413 3.02935L3.02955 2.36396C3.56155 1.83122 4.437 1.81792 4.9826 2.36413L5.2081 2.58963C5.51023 2.4333 5.82474 2.30275 6.14844 2.19931V1.88085C6.14844 1.11945 6.76789 0.5 7.52932 0.5H8.47071C9.23211 0.5 9.85156 1.11945 9.85156 1.88085V2.19934C10.1753 2.30275 10.4898 2.4333 10.7919 2.58966L11.0176 2.36396C11.5643 1.81663 12.4399 1.83269 12.9707 2.36416L13.636 3.02949C14.1678 3.56062 14.183 4.43598 13.6359 4.98257L13.4103 5.2081C13.5667 5.51023 13.6972 5.82468 13.8007 6.14844H14.1191C14.8805 6.14844 15.5 6.76789 15.5 7.52929V8.47071C15.5 9.23211 14.8805 9.85156 14.1191 9.85156H13.8007C13.6972 10.1753 13.5667 10.4898 13.4103 10.7919L13.636 11.0176C14.1829 11.5639 14.1678 12.4393 13.6359 12.9707L12.9704 13.6361C12.4384 14.1688 11.563 14.1821 11.0174 13.6359L10.7919 13.4104C10.4898 13.5667 10.1753 13.6973 9.85156 13.8007V14.1192C9.85156 14.8805 9.23211 15.5 8.47068 15.5ZM5.35499 12.4874C5.77473 12.7356 6.22648 12.9232 6.6977 13.0448C6.89176 13.0948 7.02734 13.2699 7.02734 13.4703V14.1192C7.02734 14.3959 7.25255 14.6211 7.52932 14.6211H8.47071C8.74748 14.6211 8.97268 14.3959 8.97268 14.1192V13.4703C8.97268 13.2699 9.10827 13.0948 9.30233 13.0448C9.77355 12.9232 10.2253 12.7356 10.645 12.4874C10.8177 12.3853 11.0376 12.413 11.1795 12.5549L11.6391 13.0146C11.8373 13.213 12.1555 13.2084 12.3488 13.0148L13.0146 12.349C13.2075 12.1564 13.2139 11.8381 13.0148 11.6393L12.555 11.1794C12.4131 11.0376 12.3854 10.8177 12.4875 10.645C12.7357 10.2253 12.9232 9.77354 13.0448 9.3023C13.0949 9.10824 13.2699 8.97268 13.4703 8.97268H14.1192C14.3959 8.97268 14.6211 8.74751 14.6211 8.47074V7.52932C14.6211 7.25255 14.3959 7.02737 14.1192 7.02737H13.4703C13.2699 7.02737 13.0949 6.89179 13.0448 6.69775C12.9232 6.22651 12.7357 5.77476 12.4875 5.35505C12.3854 5.18237 12.4131 4.96247 12.555 4.82062L13.0146 4.36098C13.2133 4.16252 13.2081 3.84436 13.0148 3.65126L12.349 2.98549C12.1561 2.79225 11.8378 2.78659 11.6393 2.98531L11.1795 3.44513C11.0377 3.58701 10.8177 3.61479 10.6451 3.51266C10.2253 3.26442 9.77357 3.07689 9.30236 2.95528C9.1083 2.90521 8.97271 2.7302 8.97271 2.52978V1.88085C8.97271 1.60408 8.74751 1.37891 8.47074 1.37891H7.52935C7.25258 1.37891 7.02737 1.60408 7.02737 1.88085V2.52972C7.02737 2.73014 6.89179 2.90516 6.69772 2.95522C6.22651 3.07684 5.77476 3.26437 5.35502 3.5126C5.18229 3.6147 4.96241 3.58692 4.82056 3.44507L4.36095 2.98543C4.16278 2.78703 3.84453 2.79163 3.65126 2.98522L2.98543 3.65103C2.7926 3.84362 2.78615 4.16188 2.98525 4.36074L3.44507 4.82056C3.58692 4.96241 3.6147 5.18231 3.5126 5.35499C3.26437 5.7747 3.07687 6.22646 2.95525 6.6977C2.90516 6.89176 2.73014 7.02731 2.52975 7.02731H1.88088C1.60411 7.02734 1.37891 7.25252 1.37891 7.52929V8.47071C1.37891 8.74748 1.60411 8.97266 1.88088 8.97266H2.52972C2.73014 8.97266 2.90513 9.10824 2.95522 9.30227C3.07684 9.77352 3.26437 10.2253 3.51257 10.645C3.61467 10.8177 3.58689 11.0376 3.44504 11.1794L2.9854 11.6391C2.78671 11.8375 2.79189 12.1557 2.98522 12.3488L3.651 13.0145C3.84397 13.2078 4.16226 13.2134 4.36071 13.0147L4.8205 12.5549C4.92503 12.4504 5.1425 12.3617 5.35499 12.4874Z" fill="#7147E8" stroke="#7147E8" stroke-width="0.1"/>
<path d="M8 11.2656C6.20038 11.2656 4.73633 9.80154 4.73633 8.00195C4.73633 6.20236 6.20038 4.73828 8 4.73828C9.79962 4.73828 11.2637 6.20236 11.2637 8.00195C11.2637 9.80154 9.79962 11.2656 8 11.2656ZM8 5.61719C6.68501 5.61719 5.61523 6.68699 5.61523 8.00195C5.61523 9.31691 6.68504 10.3867 8 10.3867C9.31496 10.3867 10.3848 9.31691 10.3848 8.00195C10.3848 6.68699 9.31499 5.61719 8 5.61719Z" fill="#7147E8" stroke="#7147E8" stroke-width="0.1"/>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -15,7 +15,11 @@ import { AxiosResponse } from 'axios';
import { Operation } from 'fast-json-patch';
import { CreateColumnTest } from '../generated/api/tests/createColumnTest';
import { CreateTableTest } from '../generated/api/tests/createTableTest';
import { ColumnTestType, Table } from '../generated/entity/data/table';
import {
ColumnTestType,
Table,
TableProfilerConfig,
} from '../generated/entity/data/table';
import { TableTestType } from '../generated/tests/tableTest';
import { EntityHistory } from '../generated/type/entityHistory';
import { EntityReference } from '../generated/type/entityReference';
@ -184,3 +188,27 @@ export const deleteColumnTestCase = (
configOptions
);
};
export const getTableProfilerConfig = async (tableId: string) => {
const response = await APIClient.get<Table>(
`/tables/${tableId}/tableProfilerConfig`
);
return response.data;
};
export const putTableProfileConfig = async (
tableId: string,
data: TableProfilerConfig
) => {
const configOptions = {
headers: { 'Content-type': 'application/json' },
};
const response = await APIClient.put<
TableProfilerConfig,
AxiosResponse<Table>
>(`/tables/${tableId}/tableProfilerConfig`, data, configOptions);
return response.data;
};

View File

@ -73,8 +73,8 @@ import SampleDataTable, {
} from '../SampleDataTable/SampleDataTable.component';
import SchemaEditor from '../schema-editor/SchemaEditor';
import SchemaTab from '../SchemaTab/SchemaTab.component';
import TableProfiler from '../TableProfiler/TableProfiler.component';
import TableProfilerGraph from '../TableProfiler/TableProfilerGraph.component';
import TableProfilerV1 from '../TableProfiler/TableProfilerV1';
import TableQueries from '../TableQueries/TableQueries';
import { DatasetDetailsProps } from './DatasetDetails.interface';
@ -727,20 +727,10 @@ const DatasetDetails: React.FC<DatasetDetailsProps> = ({
</div>
)}
{activeTab === 5 && (
<div>
<TableProfiler
columns={columns.map((col) => ({
constraint: col.constraint as string,
colName: col.name,
colType: col.dataTypeDisplay as string,
dataType: col.dataType as string,
colTests: col.columnTests,
}))}
isTableDeleted={deleted}
qualityTestFormHandler={qualityTestFormHandler}
tableProfiles={tableProfile}
/>
</div>
<TableProfilerV1
table={tableDetails}
onAddTestClick={qualityTestFormHandler}
/>
)}
{activeTab === 6 && (

View File

@ -171,7 +171,7 @@ jest.mock('../EntityLineage/EntityLineage.component', () => {
return jest.fn().mockReturnValue(<p data-testid="lineage">Lineage</p>);
});
jest.mock('../TableProfiler/TableProfiler.component', () => {
jest.mock('../TableProfiler/TableProfilerV1', () => {
return jest
.fn()
.mockReturnValue(<p data-testid="TableProfiler">TableProfiler</p>);

View File

@ -0,0 +1,159 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
* 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 {
act,
cleanup,
fireEvent,
render,
screen,
} from '@testing-library/react';
import { ColumnsType } from 'antd/lib/table';
import React from 'react';
import { Column, ColumnProfile } from '../../../generated/entity/data/table';
import { MOCK_TABLE } from '../../../mocks/TableData.mock';
import { ColumnProfileTableProps } from '../TableProfiler.interface';
import ColumnProfileTable from './ColumnProfileTable';
jest.mock('antd', () => ({
Button: jest
.fn()
.mockImplementation(({ children, ...props }) => (
<button {...props}>{children}</button>
)),
Space: jest
.fn()
.mockImplementation(({ children, ...props }) => (
<div {...props}>{children}</div>
)),
Table: jest.fn().mockImplementation(({ columns, dataSource }) => (
<table>
<thead>
<tr>
{(columns as ColumnsType<ColumnProfile>).map((col) => (
<th key={col.key}>{col.title}</th>
))}
</tr>
</thead>
<tbody key="tbody">
{dataSource.map((row: any, i: number) => (
<tr key={i}>
{columns.map((col: any) => (
<td key={col.key}>
{col.render
? col.render(row[col.dataIndex], col)
: row[col.dataIndex]}
</td>
))}
</tr>
))}
</tbody>
</table>
)),
}));
jest.mock('../../../utils/CommonUtils', () => ({
formatNumberWithComma: jest.fn(),
}));
jest.mock('../../common/Ellipses/Ellipses', () => {
return jest.fn().mockImplementation(({ children }) => <div>{children}</div>);
});
jest.mock('../../common/searchbar/Searchbar', () => {
return jest
.fn()
.mockImplementation(({ searchValue, onSearch }) => (
<input
data-testid="searchbar"
value={searchValue}
onChange={(e) => onSearch(e.target.value)}
/>
));
});
jest.mock('./ProfilerProgressWidget', () => {
return jest.fn().mockImplementation(({ value }) => (
<span data-testid="profiler-progress-widget">
{value} <span>Progress bar</span>{' '}
</span>
));
});
jest.mock('./TestIndicator', () => {
return jest.fn().mockImplementation(({ value, type }) => (
<span data-testid="test-indicator">
{value} <span>{type}</span>
</span>
));
});
jest.mock('../../../utils/DatasetDetailsUtils');
const mockProps: ColumnProfileTableProps = {
columns: MOCK_TABLE.columns,
columnProfile: MOCK_TABLE.tableProfile?.columnProfile || [],
onAddTestClick: jest.fn,
};
describe('Test ColumnProfileTable component', () => {
beforeEach(() => {
cleanup();
});
it('should render without crashing', async () => {
render(<ColumnProfileTable {...mockProps} />);
const container = await screen.findByTestId(
'column-profile-table-container'
);
const searchbox = await screen.findByTestId('searchbar');
expect(searchbox).toBeInTheDocument();
expect(container).toBeInTheDocument();
});
it('should render without crashing even if column is undefined', async () => {
render(
<ColumnProfileTable
{...mockProps}
columns={undefined as unknown as Column[]}
/>
);
const container = await screen.findByTestId(
'column-profile-table-container'
);
const searchbox = await screen.findByTestId('searchbar');
expect(searchbox).toBeInTheDocument();
expect(container).toBeInTheDocument();
});
it('search box should work as expected', async () => {
render(<ColumnProfileTable {...mockProps} />);
const searchbox = await screen.findByTestId('searchbar');
expect(searchbox).toBeInTheDocument();
await act(async () => {
fireEvent.change(searchbox, { target: { value: 'test' } });
});
expect(searchbox).toHaveValue('test');
await act(async () => {
fireEvent.change(searchbox, { target: { value: '' } });
});
expect(searchbox).toHaveValue('');
});
});

View File

@ -0,0 +1,188 @@
/*
* 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 { Button, Space, Table } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import React, { FC, useEffect, useMemo, useState } from 'react';
import {
PRIMERY_COLOR,
SECONDARY_COLOR,
SUCCESS_COLOR,
} from '../../../constants/constants';
import { ColumnProfile } from '../../../generated/entity/data/table';
import { TestCaseStatus } from '../../../generated/tests/tableTest';
import { formatNumberWithComma } from '../../../utils/CommonUtils';
import { getCurrentDatasetTab } from '../../../utils/DatasetDetailsUtils';
import Ellipses from '../../common/Ellipses/Ellipses';
import Searchbar from '../../common/searchbar/Searchbar';
import { ColumnProfileTableProps } from '../TableProfiler.interface';
import ProfilerProgressWidget from './ProfilerProgressWidget';
import TestIndicator from './TestIndicator';
const ColumnProfileTable: FC<ColumnProfileTableProps> = ({
columnProfile,
onAddTestClick,
columns = [],
}) => {
const [searchText, setSearchText] = useState<string>('');
const [data, setData] = useState(columnProfile);
// TODO:- Once column level test filter is implemented in test case API, remove this hardcoded value
const testDetails = [
{
value: 0,
type: TestCaseStatus.Success,
},
{
value: 0,
type: TestCaseStatus.Aborted,
},
{
value: 0,
type: TestCaseStatus.Failed,
},
];
const tableColumn: ColumnsType<ColumnProfile> = useMemo(() => {
return [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
},
{
title: 'Data Type',
dataIndex: 'name',
key: 'dataType',
render: (name) => {
const dataType = columns.find((col) => col.name === name);
return (
<Ellipses tooltip className="tw-w-24">
{dataType?.dataTypeDisplay || 'N/A'}
</Ellipses>
);
},
},
{
title: 'Null %',
dataIndex: 'nullProportion',
key: 'nullProportion',
width: 200,
render: (nullValue) => {
return (
<ProfilerProgressWidget
strokeColor={PRIMERY_COLOR}
value={nullValue}
/>
);
},
},
{
title: 'Unique %',
dataIndex: 'uniqueProportion',
key: 'uniqueProportion',
width: 200,
render: (uniqueValue) => (
<ProfilerProgressWidget
strokeColor={SECONDARY_COLOR}
value={uniqueValue}
/>
),
},
{
title: 'Distinct %',
dataIndex: 'distinctProportion',
key: 'distinctProportion',
width: 200,
render: (distValue) => (
<ProfilerProgressWidget
strokeColor={SUCCESS_COLOR}
value={distValue}
/>
),
},
{
title: 'Value Count',
dataIndex: 'valuesCount',
key: 'valuesCount',
render: (valuesCount) => formatNumberWithComma(valuesCount),
},
{
title: 'Test',
dataIndex: 'dataQualityTest',
key: 'dataQualityTest',
render: () => {
return (
<Space size={16}>
{testDetails.map((test, i) => (
<TestIndicator key={i} type={test.type} value={test.value} />
))}
</Space>
);
},
},
{
title: 'Actions',
dataIndex: 'actions',
key: 'actions',
render: (_, record) => (
<Button
className="tw-border tw-border-primary tw-rounded tw-text-primary"
size="small"
onClick={() =>
onAddTestClick(
getCurrentDatasetTab('data-quality'),
'column',
record.name
)
}>
Add Test
</Button>
),
},
];
}, [columns]);
const handleSearchAction = (searchText: string) => {
setSearchText(searchText);
if (searchText) {
setData(columnProfile.filter((col) => col.name?.includes(searchText)));
} else {
setData(columnProfile);
}
};
useEffect(() => {
setData(columnProfile);
}, [columnProfile]);
return (
<div data-testid="column-profile-table-container">
<div className="tw-w-2/6">
<Searchbar
placeholder="Find in table..."
searchValue={searchText}
typingInterval={500}
onSearch={handleSearchAction}
/>
</div>
<Table
columns={tableColumn}
dataSource={data}
pagination={false}
size="small"
/>
</div>
);
};
export default ColumnProfileTable;

View File

@ -0,0 +1,48 @@
/*
* 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 { cleanup, render, screen } from '@testing-library/react';
import React from 'react';
import { ProfilerProgressWidgetProps } from '../TableProfiler.interface';
import ProfilerProgressWidget from './ProfilerProgressWidget';
jest.mock('antd', () => ({
Progress: jest.fn().mockImplementation(() => <span>progress bar</span>),
}));
const mockProps: ProfilerProgressWidgetProps = {
value: 0.2,
};
describe('Test ProfilerProgressWidget component', () => {
beforeEach(() => {
cleanup();
});
it('should render without crashing', async () => {
render(<ProfilerProgressWidget {...mockProps} />);
const container = await screen.findByTestId(
'profiler-progress-bar-container'
);
const percentInfo = await screen.findByTestId('percent-info');
const progressBar = await screen.findByTestId('progress-bar');
expect(container).toBeInTheDocument();
expect(percentInfo).toBeInTheDocument();
expect(percentInfo.textContent).toBe(
`${Math.round(mockProps.value * 100)}%`
);
expect(progressBar).toBeInTheDocument();
});
});

View File

@ -0,0 +1,43 @@
/*
* 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 { Progress } from 'antd';
import React from 'react';
import { ProfilerProgressWidgetProps } from '../TableProfiler.interface';
const ProfilerProgressWidget: React.FC<ProfilerProgressWidgetProps> = ({
value,
strokeColor,
}) => {
const modifedValue = Math.round(value * 100);
return (
<div
className="profiler-progress-bar-container"
data-testid="profiler-progress-bar-container">
<p className="percent-info" data-testid="percent-info">
{modifedValue}%
</p>
<div className="progress-bar" data-testid="progress-bar">
<Progress
percent={modifedValue}
showInfo={false}
size="small"
strokeColor={strokeColor}
/>
</div>
</div>
);
};
export default ProfilerProgressWidget;

View File

@ -0,0 +1,70 @@
/*
* 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 { cleanup, render, screen } from '@testing-library/react';
import React from 'react';
import { MOCK_TABLE } from '../../../mocks/TableData.mock';
import { ProfilerSettingsModalProps } from '../TableProfiler.interface';
import ProfilerSettingsModal from './ProfilerSettingsModal';
jest.mock('antd/lib/form', () => {
return jest
.fn()
.mockImplementation(({ children }) => <form>{children}</form>);
});
jest.mock('antd/lib/grid', () => ({
Row: jest.fn().mockImplementation(({ children }) => <div>{children}</div>),
Col: jest
.fn()
.mockImplementation(({ children, ...props }) => (
<div data-testid={props['data-testid']}>{children}</div>
)),
}));
jest.mock('../../../axiosAPIs/tableAPI', () => ({
getTableProfilerConfig: jest
.fn()
.mockImplementation(() => Promise.resolve(MOCK_TABLE)),
putTableProfileConfig: jest.fn(),
}));
const mockProps: ProfilerSettingsModalProps = {
tableId: MOCK_TABLE.id,
columnProfile: MOCK_TABLE.tableProfile?.columnProfile || [],
visible: true,
onVisibilityChange: jest.fn(),
};
describe('Test ProfilerSettingsModal component', () => {
beforeEach(() => {
cleanup();
});
it('should render without crashing', async () => {
render(<ProfilerSettingsModal {...mockProps} />);
const modal = await screen.findByTestId('profiler-settings-modal');
const sampleContainer = await screen.findByTestId(
'profile-sample-container'
);
const sqlEditor = await screen.findByTestId('sql-editor-container');
const includeSelect = await screen.findByTestId('include-column-container');
const excludeSelect = await screen.findByTestId('exclude-column-container');
expect(modal).toBeInTheDocument();
expect(sampleContainer).toBeInTheDocument();
expect(sqlEditor).toBeInTheDocument();
expect(includeSelect).toBeInTheDocument();
expect(excludeSelect).toBeInTheDocument();
});
});

View File

@ -0,0 +1,325 @@
/*
* 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 { PlusOutlined } from '@ant-design/icons';
import { Button, Modal, Select, Slider, TreeSelect } from 'antd';
import Form from 'antd/lib/form';
import { List } from 'antd/lib/form/Form';
import { Col, Row } from 'antd/lib/grid';
import { AxiosError } from 'axios';
import classNames from 'classnames';
import 'codemirror/addon/fold/foldgutter.css';
import { isEmpty, isEqual, isUndefined, startCase } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { Controlled as CodeMirror } from 'react-codemirror2';
import {
getTableProfilerConfig,
putTableProfileConfig,
} from '../../../axiosAPIs/tableAPI';
import {
codeMirrorOption,
DEFAULT_INCLUDE_PROFILE,
PROFILER_METRIC,
} from '../../../constants/entity.constants';
import {
ColumnProfilerConfig,
TableProfilerConfig,
} from '../../../generated/entity/data/table';
import jsonData from '../../../jsons/en';
import SVGIcons, { Icons } from '../../../utils/SvgUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import { ProfilerSettingsModalProps } from '../TableProfiler.interface';
import '../tableProfiler.less';
const ProfilerSettingsModal: React.FC<ProfilerSettingsModalProps> = ({
tableId,
columnProfile,
visible,
onVisibilityChange,
}) => {
const [data, setData] = useState<TableProfilerConfig>();
const [sqlQuery, setSqlQuery] = useState<string>('');
const [profileSample, setProfileSample] = useState<number>();
const [excludeCol, setExcludeCol] = useState<string[]>([]);
const [includeCol, setIncludeCol] = useState<ColumnProfilerConfig[]>(
DEFAULT_INCLUDE_PROFILE
);
const selectOptions = useMemo(() => {
return columnProfile.map(({ name }) => ({
label: name,
value: name,
}));
}, [columnProfile]);
const metricsOptions = useMemo(() => {
const metricsOptions = [
{
title: 'All',
value: 'all',
key: 'all',
children: PROFILER_METRIC.map((metric) => ({
title: startCase(metric),
value: metric,
key: metric,
})),
},
];
return metricsOptions;
}, [columnProfile]);
const updateInitialConfig = (tableProfilerConfig: TableProfilerConfig) => {
const { includeColumns } = tableProfilerConfig;
setSqlQuery(tableProfilerConfig.profileQuery || '');
setProfileSample(tableProfilerConfig.profileSample);
setExcludeCol(tableProfilerConfig.excludeColumns || []);
if (includeColumns && includeColumns?.length > 0) {
const includeColValue = includeColumns.map((col) => {
if (
isUndefined(col.metrics) ||
(col.metrics && col.metrics.length === 0)
) {
col.metrics = ['all'];
}
return col;
});
setIncludeCol(includeColValue);
}
};
const fetchProfileConfig = async () => {
try {
const response = await getTableProfilerConfig(tableId);
if (response) {
const { tableProfilerConfig } = response;
if (tableProfilerConfig) {
setData(tableProfilerConfig);
updateInitialConfig(tableProfilerConfig);
}
} else {
throw jsonData['api-error-messages'][
'fetch-table-profiler-config-error'
];
}
} catch (error) {
showErrorToast(
error as AxiosError,
jsonData['api-error-messages']['fetch-table-profiler-config-error']
);
}
};
const getIncludesColumns = () => {
const includeCols = includeCol.filter(
({ columnName }) => !isUndefined(columnName)
);
setIncludeCol(includeCols);
return includeCols.map((col) => {
if (col.metrics && col.metrics[0] === 'all') {
return {
columnName: col.columnName,
};
}
return col;
});
};
const handleSave = async () => {
const profileConfig: TableProfilerConfig = {
excludeColumns: excludeCol.length > 0 ? excludeCol : undefined,
profileQuery: !isEmpty(sqlQuery) ? sqlQuery : undefined,
profileSample: !isUndefined(profileSample) ? profileSample : undefined,
includeColumns: !isEqual(includeCol, DEFAULT_INCLUDE_PROFILE)
? getIncludesColumns()
: undefined,
};
try {
const data = await putTableProfileConfig(tableId, profileConfig);
if (data) {
showSuccessToast(
jsonData['api-success-messages']['update-profile-congif-success']
);
onVisibilityChange(false);
} else {
throw jsonData['api-error-messages']['update-profiler-config-error'];
}
} catch (error) {
showErrorToast(
error as AxiosError,
jsonData['api-error-messages']['update-profiler-config-error']
);
}
};
const handleCancel = () => {
data && updateInitialConfig(data);
onVisibilityChange(false);
};
useEffect(() => {
fetchProfileConfig();
}, []);
return (
<Modal
centered
destroyOnClose
cancelButtonProps={{
type: 'link',
}}
data-testid="profiler-settings-modal"
maskClosable={false}
okText="Save"
title="Settings"
visible={visible}
width={630}
onCancel={handleCancel}
onOk={handleSave}>
<Row gutter={[16, 16]}>
<Col data-testid="profile-sample-container" span={24}>
<p>Profile Sample %</p>
<div className="tw-px-2 tw-mb-1.5">
<Slider
className="profiler-slider"
marks={{
0: '0%',
100: '100%',
[profileSample as number]: `${profileSample}%`,
}}
max={100}
min={0}
tooltipPlacement="bottom"
tooltipVisible={false}
value={profileSample}
onChange={(value) => {
setProfileSample(value);
}}
/>
</div>
</Col>
<Col data-testid="sql-editor-container" span={24}>
<p className="tw-mb-1.5">Profile Sample Query</p>
<CodeMirror
className="profiler-setting-sql-editor"
data-testid="profiler-setting-sql-editor"
options={codeMirrorOption}
value={sqlQuery}
onBeforeChange={(_Editor, _EditorChange, value) => {
setSqlQuery(value);
}}
onChange={(_Editor, _EditorChange, value) => {
setSqlQuery(value);
}}
/>
</Col>
<Col data-testid="exclude-column-container" span={24}>
<p className="tw-mb-4">Enable column profile</p>
<p className="tw-text-xs tw-mb-1.5">Exclude:</p>
<Select
allowClear
className="tw-w-full"
data-testid="exclude-column-select"
mode="tags"
options={selectOptions}
placeholder="Select columns to exclude"
size="middle"
value={excludeCol}
onChange={(value) => setExcludeCol(value)}
/>
</Col>
<Col span={24}>
<Form
autoComplete="off"
initialValues={{
includeColumns: includeCol,
}}
layout="vertical"
name="includeColumnsProfiler"
onValuesChange={(_, data) => {
setIncludeCol(data.includeColumns);
}}>
<List name="includeColumns">
{(fields, { add, remove }) => (
<>
<div className="tw-flex tw-items-center tw-mb-1.5">
<p className="w-form-label tw-text-xs tw-mr-3">Include:</p>
<Button
className="include-columns-add-button"
icon={<PlusOutlined />}
size="small"
type="primary"
onClick={() => add({ metrics: ['all'] })}
/>
</div>
<div
className={classNames({
'tw-h-40 tw-overflow-auto': includeCol.length > 1,
})}
data-testid="include-column-container">
{fields.map(({ key, name, ...restField }) => (
<div className="tw-flex tw-gap-2 tw-w-full" key={key}>
<Form.Item
className="tw-w-11/12 tw-mb-4"
{...restField}
name={[name, 'columnName']}>
<Select
className="tw-w-full"
data-testid="exclude-column-select"
options={selectOptions}
placeholder="Select columns to include"
size="middle"
/>
</Form.Item>
<Form.Item
className="tw-w-11/12 tw-mb-4"
{...restField}
name={[name, 'metrics']}>
<TreeSelect
treeCheckable
className="tw-w-full"
maxTagCount={2}
placeholder="Please select"
showCheckedStrategy="SHOW_PARENT"
treeData={metricsOptions}
/>
</Form.Item>
<Button
icon={
<SVGIcons
alt="delete"
className="tw-w-4"
icon={Icons.DELETE}
/>
}
type="text"
onClick={() => remove(name)}
/>
</div>
))}
</div>
</>
)}
</List>
</Form>
</Col>
</Row>
</Modal>
);
};
export default ProfilerSettingsModal;

View File

@ -0,0 +1,41 @@
/*
* 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 { cleanup, render, screen } from '@testing-library/react';
import React from 'react';
import { TestCaseStatus } from '../../../generated/tests/tableTest';
import { TestIndicatorProps } from '../TableProfiler.interface';
import TestIndicator from './TestIndicator';
const mockProps: TestIndicatorProps = {
value: 0,
type: TestCaseStatus.Success,
};
describe('Test TestIndicator component', () => {
beforeEach(() => {
cleanup();
});
it('should render without crashing', async () => {
render(<TestIndicator {...mockProps} />);
const container = await screen.findByTestId('indicator-container');
const testStatus = await screen.findByTestId('test-status');
const testValue = await screen.findByTestId('test-value');
expect(container).toBeInTheDocument();
expect(testStatus).toBeInTheDocument();
expect(testValue).toBeInTheDocument();
});
});

View File

@ -0,0 +1,32 @@
/*
* 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 classNames from 'classnames';
import React from 'react';
import { TestIndicatorProps } from '../TableProfiler.interface';
const TestIndicator: React.FC<TestIndicatorProps> = ({ value, type }) => {
return (
<span
className="tw-flex tw-gap-1.5 tw-items-center"
data-testid="indicator-container">
<span
className={classNames('test-indicator', type.toLowerCase())}
data-testid="test-status"
/>
<span data-testid="test-value">{value}</span>
</span>
);
};
export default TestIndicator;

View File

@ -0,0 +1,62 @@
/*
* Copyright 2021 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 {
Column,
ColumnProfile,
Table,
} from '../../generated/entity/data/table';
import { TestCaseStatus } from '../../generated/tests/tableTest';
import { DatasetTestModeType } from '../../interface/dataQuality.interface';
export interface TableProfilerProps {
onAddTestClick: (
tabValue: number,
testMode?: DatasetTestModeType,
columnName?: string
) => void;
table: Table;
}
export interface ColumnProfileTableProps {
columns: Column[];
columnProfile: ColumnProfile[];
onAddTestClick: (
tabValue: number,
testMode?: DatasetTestModeType,
columnName?: string
) => void;
}
export interface ProfilerProgressWidgetProps {
value: number;
strokeColor?: string;
}
export interface ProfilerSettingsModalProps {
tableId: string;
columnProfile: ColumnProfile[];
visible: boolean;
onVisibilityChange: (visible: boolean) => void;
}
export interface TestIndicatorProps {
value: number;
type: TestCaseStatus;
}
export type OverallTableSummeryType = {
title: string;
value: number | string;
className?: string;
};

View File

@ -0,0 +1,146 @@
/*
* 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.
*/
// Library imports
import {
act,
cleanup,
fireEvent,
render,
screen,
} from '@testing-library/react';
import React from 'react';
import { MOCK_TABLE } from '../../mocks/TableData.mock';
import { getCurrentDatasetTab } from '../../utils/DatasetDetailsUtils';
import { TableProfilerProps } from './TableProfiler.interface';
// internel imports
import TableProfilerV1 from './TableProfilerV1';
// mock library imports
jest.mock('react-router-dom', () => ({
Link: jest
.fn()
.mockImplementation(({ children }) => <a href="#">{children}</a>),
}));
jest.mock('antd', () => ({
Button: jest
.fn()
.mockImplementation(({ children, ...props }) => (
<button {...props}>{children}</button>
)),
Col: jest
.fn()
.mockImplementation(({ children, ...props }) => (
<div {...props}>{children}</div>
)),
Row: jest
.fn()
.mockImplementation(({ children, ...props }) => (
<div {...props}>{children}</div>
)),
}));
// mock internel imports
jest.mock('./Component/ProfilerSettingsModal', () => {
return jest.fn().mockImplementation(() => {
return <div>ProfilerSettingsModal.component</div>;
});
});
jest.mock('./Component/ColumnProfileTable', () => {
return jest.fn().mockImplementation(() => {
return <div>ColumnProfileTable.component</div>;
});
});
jest.mock('../../utils/DatasetDetailsUtils');
jest.mock('../../utils/CommonUtils', () => ({
formatNumberWithComma: jest.fn(),
formTwoDigitNmber: jest.fn(),
}));
const mockGetCurrentDatasetTab = getCurrentDatasetTab as jest.Mock;
const mockProps: TableProfilerProps = {
table: MOCK_TABLE,
onAddTestClick: jest.fn(),
};
describe('Test TableProfiler component', () => {
beforeEach(() => {
cleanup();
});
it('should render without crashing', async () => {
render(<TableProfilerV1 {...mockProps} />);
const profileContainer = await screen.findByTestId(
'table-profiler-container'
);
const settingBtn = await screen.findByTestId('profiler-setting-btn');
const addTableTest = await screen.findByTestId(
'profiler-add-table-test-btn'
);
expect(profileContainer).toBeInTheDocument();
expect(settingBtn).toBeInTheDocument();
expect(addTableTest).toBeInTheDocument();
});
it('No data placeholder should be visible where there is no profiler', async () => {
render(
<TableProfilerV1
{...mockProps}
table={{ ...mockProps.table, tableProfile: undefined }}
/>
);
const noProfiler = await screen.findByTestId(
'no-profiler-placeholder-container'
);
expect(noProfiler).toBeInTheDocument();
});
it('CTA: Add table test should work properly', async () => {
render(<TableProfilerV1 {...mockProps} />);
const addTableTest = await screen.findByTestId(
'profiler-add-table-test-btn'
);
expect(addTableTest).toBeInTheDocument();
await act(async () => {
fireEvent.click(addTableTest);
});
expect(mockProps.onAddTestClick).toHaveBeenCalledTimes(1);
expect(mockGetCurrentDatasetTab).toHaveBeenCalledTimes(1);
});
it('CTA: Setting button should work properly', async () => {
const setSettingModalVisible = jest.fn();
const handleClick = jest.spyOn(React, 'useState');
handleClick.mockImplementation(() => [false, setSettingModalVisible]);
render(<TableProfilerV1 {...mockProps} />);
const settingBtn = await screen.findByTestId('profiler-setting-btn');
expect(settingBtn).toBeInTheDocument();
await act(async () => {
fireEvent.click(settingBtn);
});
expect(setSettingModalVisible).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,157 @@
/*
* 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 { Button, Col, Row } from 'antd';
import classNames from 'classnames';
import { isUndefined } from 'lodash';
import React, { FC, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import {
formatNumberWithComma,
formTwoDigitNmber,
} from '../../utils/CommonUtils';
import { getCurrentDatasetTab } from '../../utils/DatasetDetailsUtils';
import SVGIcons, { Icons } from '../../utils/SvgUtils';
import ColumnProfileTable from './Component/ColumnProfileTable';
import ProfilerSettingsModal from './Component/ProfilerSettingsModal';
import {
OverallTableSummeryType,
TableProfilerProps,
} from './TableProfiler.interface';
import './tableProfiler.less';
const TableProfilerV1: FC<TableProfilerProps> = ({ table, onAddTestClick }) => {
const { tableProfile, columns } = table;
const [settingModalVisible, setSettingModalVisible] = useState(false);
const handleSettingModal = (value: boolean) => {
setSettingModalVisible(value);
};
const overallSummery: OverallTableSummeryType[] = useMemo(() => {
return [
{
title: 'Row Count',
value: formatNumberWithComma(tableProfile?.rowCount ?? 0),
},
{
title: 'Column Count',
value: tableProfile?.columnCount ?? 0,
},
{
title: 'Table Sample %',
value: `${tableProfile?.profileSample ?? 0}%`,
},
{
title: 'Success',
value: formTwoDigitNmber(0),
className: 'success',
},
{
title: 'Aborted',
value: formTwoDigitNmber(0),
className: 'aborted',
},
{
title: 'Failed',
value: formTwoDigitNmber(0),
className: 'failed',
},
];
}, [tableProfile]);
if (isUndefined(tableProfile)) {
return (
<div
className="tw-mt-4 tw-ml-4 tw-flex tw-justify-center tw-font-medium tw-items-center tw-border tw-border-main tw-rounded-md tw-p-8"
data-testid="no-profiler-placeholder-container">
<span>
Data Profiler is an optional configuration in Ingestion. Please enable
the data profiler by following the documentation
</span>
<Link
className="tw-ml-1"
target="_blank"
to={{
pathname: 'https://docs.open-metadata.org/connectors',
}}>
here.
</Link>
</div>
);
}
return (
<div
className="table-profiler-container"
data-testid="table-profiler-container">
<div className="tw-flex tw-justify-end tw-gap-4 tw-mb-4">
<Button
className="tw-rounded"
data-testid="profiler-add-table-test-btn"
type="primary"
onClick={() =>
onAddTestClick(getCurrentDatasetTab('data-quality'), 'table')
}>
Add Test
</Button>
<Button
className="profiler-setting-btn tw-border tw-border-primary tw-rounded tw-text-primary"
data-testid="profiler-setting-btn"
icon={<SVGIcons alt="setting" icon={Icons.SETTINGS_PRIMERY} />}
type="default"
onClick={() => handleSettingModal(true)}>
Settings
</Button>
</div>
<Row className="tw-rounded tw-border tw-p-4 tw-mb-4">
{overallSummery.map((summery) => (
<Col
className="overall-summery-card"
data-testid={`header-card-${summery.title}`}
key={summery.title}
span={4}>
<p className="overall-summery-card-title tw-font-medium tw-text-grey-muted tw-mb-1">
{summery.title}
</p>
<p
className={classNames(
'tw-text-2xl tw-font-semibold',
summery.className
)}>
{summery.value}
</p>
</Col>
))}
</Row>
<ColumnProfileTable
columnProfile={(tableProfile?.columnProfile || []).map((col) => ({
...col,
key: col.name,
}))}
columns={columns}
onAddTestClick={onAddTestClick}
/>
<ProfilerSettingsModal
columnProfile={tableProfile.columnProfile || []}
tableId={table.id}
visible={settingModalVisible}
onVisibilityChange={handleSettingModal}
/>
</div>
);
};
export default TableProfilerV1;

View File

@ -0,0 +1,102 @@
/*
* 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.
*/
@succesColor: #28a745;
@failedColor: #cb2431;
@abortedColor: #efae2f;
@grayColor: #dde3ea;
@primary-color: #7147e8;
.table-profiler-container {
.profiler-setting-btn {
display: flex;
gap: 4px;
align-items: center;
}
.overall-summery-card:not(:first-child) {
border-left: 1px solid rgb(229, 231, 235);
padding-left: 16px;
}
.success {
color: @succesColor;
}
.failed {
color: @failedColor;
}
.aborted {
color: @abortedColor;
}
.profiler-progress-bar-container {
display: flex;
justify-content: space-between;
align-items: center;
gap: 4px;
.progress-bar {
width: 100px;
}
}
.test-indicator {
display: inline-block;
height: 8px;
width: 8px;
border-radius: 50%;
&.success {
background: @succesColor;
}
&.failed {
background: @failedColor;
}
&.aborted {
background: @abortedColor;
}
}
.ant-table-thead > tr > th {
font-weight: bold;
background: #fff;
}
.ant-table-row .ant-table-cell:first-child,
.ant-table-thead .ant-table-cell:first-child {
padding-left: 16px;
}
table {
border: 1px solid @grayColor;
box-shadow: 1px 1px 3px 0px rgba(0, 0, 0, 0.12);
}
}
.profiler-setting-sql-editor {
border: 1px solid @grayColor;
border-radius: 2px;
.CodeMirror {
height: 200px;
}
}
.include-columns-add-button.ant-btn-icon-only.ant-btn-sm {
width: 18px;
height: 18px;
font-size: 8px;
}
.profiler-slider {
.ant-slider-track {
background: @primary-color;
}
}

View File

@ -15,8 +15,10 @@ import { COOKIE_VERSION } from '../components/Modals/WhatsNewModal/whatsNewData'
import { FQN_SEPARATOR_CHAR } from './char.constants';
export const PRIMERY_COLOR = '#7147E8';
export const SECONDARY_COLOR = '#B02AAC';
export const LITE_GRAY_COLOR = '#DBE0EB';
export const TEXT_BODY_COLOR = '#37352F';
export const SUCCESS_COLOR = '#008376';
export const SUPPORTED_FIELD_TYPES = ['string', 'markdown', 'integer'];
export const SUPPORTED_DOMAIN_TYPES = [

View File

@ -11,8 +11,61 @@
* limitations under the License.
*/
import { CSMode } from '../enums/codemirror.enum';
import { ColumnProfilerConfig } from '../generated/entity/data/table';
import { JSON_TAB_SIZE } from './constants';
export const ENTITY_DELETE_STATE = {
loading: 'initial',
state: false,
softDelete: true,
};
export const PROFILER_METRIC = [
'valuesCount',
'valuesPercentage',
'validCount',
'duplicateCount',
'nullCount',
'nullProportion',
'missingPercentage',
'missingCount',
'uniqueCount',
'uniqueProportion',
'distinctCount',
'distinctProportion',
'min',
'max',
'minLength',
'maxLength',
'mean',
'sum',
'stddev',
'variance',
'median',
'histogram',
'customMetricsProfile',
];
export const DEFAULT_INCLUDE_PROFILE: ColumnProfilerConfig[] = [
{
columnName: undefined,
metrics: ['all'],
},
];
export const codeMirrorOption = {
tabSize: JSON_TAB_SIZE,
indentUnit: JSON_TAB_SIZE,
indentWithTabs: true,
lineNumbers: true,
lineWrapping: true,
styleActiveLine: true,
matchBrackets: true,
autoCloseBrackets: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
mode: {
name: CSMode.SQL,
},
};

View File

@ -109,6 +109,8 @@ const jsonData = {
'fetch-webhook-error': 'Error while fetching webhooks!',
'fetch-user-count-error': 'Error while getting users count!',
'fetch-users-error': 'Error while fetching users!',
'fetch-table-profiler-config-error':
'Error while fetching table profiler config!',
'test-connection-error': 'Error while testing connection!',
@ -135,6 +137,7 @@ const jsonData = {
'Error while updating the admin user profile!',
'update-service-error': 'Error while updating service!',
'update-reviewer-error': 'Error while updating reviewer!',
'update-profiler-config-error': 'Error while updating profiler config!',
'feed-post-error': 'Error while posting the message!',
@ -155,6 +158,8 @@ const jsonData = {
'test-connection-success': 'Connection tested successfully!',
'user-restored-success': 'User restored successfully!',
'update-profile-congif-success': 'Profile config updated successfully!',
},
'form-error-messages': {
'empty-email': 'Email is required.',

View File

@ -0,0 +1,394 @@
/*
* 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 { Table } from '../generated/entity/data/table';
export const MOCK_TABLE = {
id: 'cb726d24-774b-4603-8ec8-1975760ac2f8',
name: 'dim_address',
fullyQualifiedName: 'sample_data.ecommerce_db.shopify.dim_address',
description:
// eslint-disable-next-line max-len
'This dimension table contains the billing and shipping addresses of customers. You can join this table with the sales table to generate lists of the billing and shipping addresses. Customers can enter their addresses more than once, so the same address can appear in more than one row in this table. This table contains one row per customer address.',
version: 0.1,
updatedAt: 1659764891329,
updatedBy: 'anonymous',
href: 'http://localhost:8585/api/v1/tables/cb726d24-774b-4603-8ec8-1975760ac2f8',
tableType: 'Regular',
columns: [
{
name: 'address_id',
dataType: 'NUMERIC',
dataTypeDisplay: 'numeric',
description: 'Unique identifier for the address.',
fullyQualifiedName:
'sample_data.ecommerce_db.shopify.dim_address.address_id',
tags: [],
ordinalPosition: 1,
},
{
name: 'shop_id',
dataType: 'NUMERIC',
dataTypeDisplay: 'numeric',
description:
'The ID of the store. This column is a foreign key reference to the shop_id column in the dim_shop table.',
fullyQualifiedName:
'sample_data.ecommerce_db.shopify.dim_address.shop_id',
tags: [],
ordinalPosition: 2,
},
{
name: 'first_name',
dataType: 'VARCHAR',
dataLength: 100,
dataTypeDisplay: 'varchar',
description: 'First name of the customer.',
fullyQualifiedName:
'sample_data.ecommerce_db.shopify.dim_address.first_name',
tags: [],
ordinalPosition: 3,
},
{
name: 'last_name',
dataType: 'VARCHAR',
dataLength: 100,
dataTypeDisplay: 'varchar',
description: 'Last name of the customer.',
fullyQualifiedName:
'sample_data.ecommerce_db.shopify.dim_address.last_name',
tags: [],
ordinalPosition: 4,
},
{
name: 'address1',
dataType: 'VARCHAR',
dataLength: 500,
dataTypeDisplay: 'varchar',
description: 'The first address line. For example, 150 Elgin St.',
fullyQualifiedName:
'sample_data.ecommerce_db.shopify.dim_address.address1',
tags: [],
ordinalPosition: 5,
},
{
name: 'address2',
dataType: 'VARCHAR',
dataLength: 500,
dataTypeDisplay: 'varchar',
description: 'The second address line. For example, Suite 800.',
fullyQualifiedName:
'sample_data.ecommerce_db.shopify.dim_address.address2',
tags: [],
ordinalPosition: 6,
},
{
name: 'company',
dataType: 'VARCHAR',
dataLength: 100,
dataTypeDisplay: 'varchar',
description: "The name of the customer's business, if one exists.",
fullyQualifiedName:
'sample_data.ecommerce_db.shopify.dim_address.company',
tags: [],
ordinalPosition: 7,
},
{
name: 'city',
dataType: 'VARCHAR',
dataLength: 100,
dataTypeDisplay: 'varchar',
description: 'The name of the city. For example, Palo Alto.',
fullyQualifiedName: 'sample_data.ecommerce_db.shopify.dim_address.city',
tags: [],
ordinalPosition: 8,
},
{
name: 'region',
dataType: 'VARCHAR',
dataLength: 512,
dataTypeDisplay: 'varchar',
description:
'The name of the region, such as a province or state, where the customer is located. For example, Ontario or New York. This column is the same as CustomerAddress.province in the Admin API.',
fullyQualifiedName: 'sample_data.ecommerce_db.shopify.dim_address.region',
tags: [],
ordinalPosition: 9,
},
{
name: 'zip',
dataType: 'VARCHAR',
dataLength: 10,
dataTypeDisplay: 'varchar',
description: 'The ZIP or postal code. For example, 90210.',
fullyQualifiedName: 'sample_data.ecommerce_db.shopify.dim_address.zip',
tags: [],
ordinalPosition: 10,
},
{
name: 'country',
dataType: 'VARCHAR',
dataLength: 50,
dataTypeDisplay: 'varchar',
description: 'The full name of the country. For example, Canada.',
fullyQualifiedName:
'sample_data.ecommerce_db.shopify.dim_address.country',
tags: [],
ordinalPosition: 11,
},
{
name: 'phone',
dataType: 'VARCHAR',
dataLength: 15,
dataTypeDisplay: 'varchar',
description: 'The phone number of the customer.',
fullyQualifiedName: 'sample_data.ecommerce_db.shopify.dim_address.phone',
tags: [],
ordinalPosition: 12,
},
],
tableConstraints: [
{
constraintType: 'PRIMARY_KEY',
columns: ['address_id', 'shop_id'],
},
],
databaseSchema: {
id: '0106638e-cadf-43a3-885d-cd2fd4b53df9',
type: 'databaseSchema',
name: 'shopify',
fullyQualifiedName: 'sample_data.ecommerce_db.shopify',
description:
'This **mock** database contains schema related to shopify sales and orders with related dimension tables.',
deleted: false,
href: 'http://localhost:8585/api/v1/databaseSchemas/0106638e-cadf-43a3-885d-cd2fd4b53df9',
},
database: {
id: '27960526-0b15-4794-b3ff-1162da9b070d',
type: 'database',
name: 'ecommerce_db',
fullyQualifiedName: 'sample_data.ecommerce_db',
description:
'This **mock** database contains schemas related to shopify sales and orders with related dimension tables.',
deleted: false,
href: 'http://localhost:8585/api/v1/databases/27960526-0b15-4794-b3ff-1162da9b070d',
},
service: {
id: '0c1dac5d-f802-454d-a1dc-e219f4fcc60c',
type: 'databaseService',
name: 'sample_data',
fullyQualifiedName: 'sample_data',
deleted: false,
href: 'http://localhost:8585/api/v1/services/databaseServices/0c1dac5d-f802-454d-a1dc-e219f4fcc60c',
},
serviceType: 'BigQuery',
tags: [],
usageSummary: {
dailyStats: {
count: 1,
percentileRank: 0,
},
weeklyStats: {
count: 1,
percentileRank: 0,
},
monthlyStats: {
count: 1,
percentileRank: 0,
},
date: '2022-08-06',
},
followers: [],
joins: {
startDate: '2022-07-09',
dayCount: 30,
columnJoins: [],
directTableJoins: [],
},
tableProfile: {
timestamp: 1659764894,
columnCount: 12,
rowCount: 14567,
columnProfile: [
{
name: 'shop_id',
valuesCount: 14567,
nullCount: 0,
nullProportion: 0,
uniqueCount: 14567,
uniqueProportion: 1,
distinctCount: 14509,
distinctProportion: 1,
min: 1,
max: 587,
mean: 45,
sum: 1367,
stddev: 35,
median: 7654,
},
{
name: 'address_id',
valuesCount: 14567,
nullCount: 0,
nullProportion: 0,
uniqueCount: 14567,
uniqueProportion: 1,
distinctCount: 14509,
distinctProportion: 1,
min: 1,
max: 14509,
mean: 567,
sum: 34526,
stddev: 190,
median: 7654,
},
{
name: 'first_name',
valuesCount: 14509,
nullCount: 25,
nullProportion: 0.001,
uniqueCount: 0,
uniqueProportion: 0,
distinctCount: 5,
distinctProportion: 0.050505050505050504,
minLength: 6,
maxLength: 14,
mean: 8,
},
{
name: 'last_name',
valuesCount: 14509,
nullCount: 167,
nullProportion: 0.01,
uniqueCount: 1398,
uniqueProportion: 0.7976,
distinctCount: 5,
distinctProportion: 0.050505050505050504,
minLength: 6,
maxLength: 15,
mean: 8,
},
{
name: 'address1',
valuesCount: 14509,
nullCount: 167,
nullProportion: 0.01,
uniqueCount: 1398,
uniqueProportion: 0.7976,
distinctCount: 5,
distinctProportion: 0.050505050505050504,
minLength: 6,
maxLength: 15,
mean: 8,
},
{
name: 'address2',
valuesCount: 10,
nullCount: 14499,
nullProportion: 0.9987,
uniqueCount: 10,
uniqueProportion: 1,
distinctCount: 10,
distinctProportion: 0.1,
minLength: 6,
maxLength: 156,
mean: 98,
},
{
name: 'company',
valuesCount: 560,
nullCount: 14457,
nullProportion: 0.9876,
uniqueCount: 560,
uniqueProportion: 1,
distinctCount: 560,
distinctProportion: 0.1,
minLength: 6,
maxLength: 156,
mean: 98,
},
{
name: 'city',
valuesCount: 12567,
nullCount: 234,
nullProportion: 0.8976,
uniqueCount: 789,
uniqueProportion: 0.6134,
distinctCount: 560,
distinctProportion: 0.1,
minLength: 6,
maxLength: 156,
mean: 98,
},
{
name: 'region',
valuesCount: 12567,
nullCount: 234,
nullProportion: 0.8976,
uniqueCount: 789,
uniqueProportion: 0.6134,
distinctCount: 560,
distinctProportion: 0.1,
minLength: 6,
maxLength: 156,
mean: 98,
},
{
name: 'zip',
valuesCount: 12567,
nullCount: 234,
nullProportion: 0.8976,
uniqueCount: 789,
uniqueProportion: 0.6134,
distinctCount: 560,
distinctProportion: 0.1,
minLength: 6,
maxLength: 156,
mean: 98,
},
{
name: 'country',
valuesCount: 12567,
nullCount: 234,
nullProportion: 0.8976,
uniqueCount: 789,
uniqueProportion: 0.6134,
distinctCount: 560,
distinctProportion: 0.1,
minLength: 6,
maxLength: 156,
mean: 98,
},
{
name: 'phone',
valuesCount: 14509,
nullCount: 0,
nullProportion: 0,
uniqueCount: 14509,
uniqueProportion: 1,
distinctCount: 14509,
distinctProportion: 1,
minLength: 6,
maxLength: 10,
mean: 9,
},
],
},
tableQueries: [
{
query:
'create table shopify.dim_address_clean as select address_id, shop_id, first_name, last_name, address1 as address, company, city, region, zip, country, phone from shopify.dim_address',
vote: 1,
checksum: 'cd59a9d0d0b8a245f7382264afac8bdc',
},
],
deleted: false,
} as unknown as Table;

View File

@ -220,10 +220,12 @@ const DatasetDetailsPage: FunctionComponent = () => {
columnName?: string
) => {
activeTabHandler(tabValue);
if (testMode && columnName) {
if (testMode) {
setTestMode(testMode as DatasetTestModeType);
setSelectedColumn(columnName);
setShowTestForm(true);
if (columnName) {
setSelectedColumn(columnName);
}
}
};

View File

@ -1320,13 +1320,6 @@ div.ant-typography-ellipsis-custom {
overflow: hidden;
}
.ant-input:hover,
.ant-input:focus {
border-color: #7147e8;
border-right-width: 0px;
box-shadow: none;
}
.add-webhook-container #center {
padding-right: 0px;
}

View File

@ -776,3 +776,16 @@ export const getTeamsText = (teams: EntityReference[]) => {
`${getEntityName(teams[0])}`
);
};
export const formatNumberWithComma = (number: number) => {
return new Intl.NumberFormat('en-IN', { maximumSignificantDigits: 3 }).format(
number
);
};
export const formTwoDigitNmber = (number: number) => {
return number.toLocaleString('en-US', {
minimumIntegerDigits: 2,
useGrouping: false,
});
};

View File

@ -40,6 +40,7 @@ export const datasetTableTabs = [
{
name: 'Profiler',
path: 'profiler',
field: TabSpecificField.TABLE_QUERIES,
},
{
name: 'Data Quality',

View File

@ -92,6 +92,7 @@ import IconReports from '../assets/svg/ic-reports.svg';
import IconRestore from '../assets/svg/ic-restore.svg';
import IconSchema from '../assets/svg/ic-schema.svg';
import IconSearch from '../assets/svg/ic-search.svg';
import IconSettingPrimery from '../assets/svg/ic-settings-primery.svg';
import IconSettings from '../assets/svg/ic-settings.svg';
import IconSQLBuilder from '../assets/svg/ic-sql-builder.svg';
import IconStar from '../assets/svg/ic-star.svg';
@ -189,6 +190,7 @@ export const Icons = {
MY_DATA: 'icon-my-data',
REPORTS: 'icon-reports',
SETTINGS: 'icon-settings',
SETTINGS_PRIMERY: 'icon-settings-primery',
SQL_BUILDER: 'icon-sql-builder',
TEAMS: 'icon-teams',
TEAMS_GREY: 'icon-teams-grey',
@ -378,6 +380,10 @@ const SVGIcons: FunctionComponent<Props> = ({
case Icons.SETTINGS:
IconComponent = IconSettings;
break;
case Icons.SETTINGS_PRIMERY:
IconComponent = IconSettingPrimery;
break;
case Icons.LOGO:
IconComponent = Logo;