Feat: Data Quality Tab (#3183)

This commit is contained in:
Shailesh Parmar 2022-03-06 16:00:50 +05:30 committed by GitHub
parent a9290bf1a0
commit 34dc6cb3e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 2328 additions and 11 deletions

View File

@ -13,6 +13,10 @@
import { AxiosResponse } from 'axios';
import { Table } from 'Models';
import { ColumnTestType } from '../enums/columnTest.enum';
import { CreateTableTest } from '../generated/api/tests/createTableTest';
import { TableTestType } from '../generated/tests/tableTest';
import { CreateColumnTest } from '../interface/dataQuality.interface';
import { getURLWithQueryFields } from '../utils/APIUtils';
import APIClient from './index';
@ -111,3 +115,48 @@ export const removeFollower: Function = (
configOptions
);
};
export const addTableTestCase = (tableId: string, data: CreateTableTest) => {
const configOptions = {
headers: { 'Content-type': 'application/json' },
};
return APIClient.put(`/tables/${tableId}/tableTest`, data, configOptions);
};
export const deleteTableTestCase = (
tableId: string,
tableTestType: TableTestType
): Promise<AxiosResponse> => {
const configOptions = {
headers: { 'Content-type': 'application/json' },
};
return APIClient.delete(
`/tables/${tableId}/tableTest/${tableTestType}`,
configOptions
);
};
export const addColumnTestCase = (tableId: string, data: CreateColumnTest) => {
const configOptions = {
headers: { 'Content-type': 'application/json' },
};
return APIClient.put(`/tables/${tableId}/columnTest`, data, configOptions);
};
export const deleteColumnTestCase = (
tableId: string,
columnName: string,
columnTestType: ColumnTestType
): Promise<AxiosResponse> => {
const configOptions = {
headers: { 'Content-type': 'application/json' },
};
return APIClient.delete(
`/tables/${tableId}/columnTest/${columnName}/${columnTestType}`,
configOptions
);
};

View File

@ -0,0 +1,65 @@
/*
* 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 React from 'react';
import { CreateTableTest } from '../../generated/api/tests/createTableTest';
import { Table } from '../../generated/entity/data/table';
import { TableTest } from '../../generated/tests/tableTest';
import {
CreateColumnTest,
TableTestDataType,
} from '../../interface/dataQuality.interface';
import ColumnTestForm from './Forms/ColumnTestForm';
import TableTestForm from './Forms/TableTestForm';
type Props = {
data?: TableTestDataType;
testMode: 'table' | 'column';
columnOptions: Table['columns'];
tableTestCase: TableTest[];
handleAddTableTestCase: (data: CreateTableTest) => void;
handleAddColumnTestCase: (data: CreateColumnTest) => void;
onFormCancel: () => void;
};
const AddDataQualityTest = ({
tableTestCase,
data,
testMode,
columnOptions = [],
handleAddTableTestCase,
handleAddColumnTestCase,
onFormCancel,
}: Props) => {
return (
<div className="tw-max-w-xl tw-mx-auto tw-pb-6">
{testMode === 'table' ? (
<TableTestForm
data={data as TableTest}
handleAddTableTestCase={handleAddTableTestCase}
tableTestCase={tableTestCase}
onFormCancel={onFormCancel}
/>
) : (
<ColumnTestForm
column={columnOptions}
data={data as CreateColumnTest}
handleAddColumnTestCase={handleAddColumnTestCase}
onFormCancel={onFormCancel}
/>
)}
</div>
);
};
export default AddDataQualityTest;

View File

@ -0,0 +1,618 @@
/*
* 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 classNames from 'classnames';
import { cloneDeep, isEmpty, isUndefined } from 'lodash';
import { EditorContentRef } from 'Models';
import React, { Fragment, useEffect, useRef, useState } from 'react';
import { ColumnTestType } from '../../../enums/columnTest.enum';
import { TestCaseExecutionFrequency } from '../../../generated/api/tests/createTableTest';
import { Table } from '../../../generated/entity/data/table';
import {
CreateColumnTest,
ModifiedTableColumn,
} from '../../../interface/dataQuality.interface';
import {
errorMsg,
getCurrentUserId,
requiredField,
} from '../../../utils/CommonUtils';
import SVGIcons from '../../../utils/SvgUtils';
import { getDataTypeString } from '../../../utils/TableUtils';
import { Button } from '../../buttons/Button/Button';
import MarkdownWithPreview from '../../common/editor/MarkdownWithPreview';
type Props = {
data: CreateColumnTest;
column: ModifiedTableColumn[];
handleAddColumnTestCase: (data: CreateColumnTest) => void;
onFormCancel: () => void;
};
export const Field = ({
children,
className = '',
}: {
children: React.ReactNode;
className?: string;
}) => {
return <div className={classNames('tw-mt-4', className)}>{children}</div>;
};
const ColumnTestForm = ({
data,
column,
handleAddColumnTestCase,
onFormCancel,
}: Props) => {
const markdownRef = useRef<EditorContentRef>();
const [description] = useState(data?.description || '');
const isAcceptedTypeIsString = useRef<boolean>(true);
const [columnTest, setColumnTest] = useState<ColumnTestType>(
data?.testCase?.columnTestType
);
const [columnOptions, setColumnOptions] = useState<Table['columns']>([]);
const [testTypeOptions, setTestTypeOptions] = useState<string[]>([]);
const [minValue, setMinValue] = useState<number | undefined>(
data?.testCase?.config?.minValue
);
const [maxValue, setMaxValue] = useState<number | undefined>(
data?.testCase?.config?.maxValue
);
const [frequency, setFrequency] = useState<TestCaseExecutionFrequency>(
data?.executionFrequency || TestCaseExecutionFrequency.Daily
);
const [forbiddenValues, setForbiddenValues] = useState<(string | number)[]>(
data?.testCase?.config?.forbiddenValues || ['']
);
const [isShowError, setIsShowError] = useState({
columnName: false,
regex: false,
minOrMax: false,
missingCountValue: false,
values: false,
minMaxValue: false,
});
const [columnName, setColumnName] = useState(data?.columnName);
const [missingValueMatch, setMissingValueMatch] = useState<string>(
data?.testCase?.config?.missingValueMatch || ''
);
const [missingCountValue, setMissingCountValue] = useState<
number | undefined
>(data?.testCase?.config?.missingCountValue);
const [regex, setRegex] = useState<string>(
data?.testCase?.config?.regex || ''
);
const addValueFields = () => {
setForbiddenValues([...forbiddenValues, '']);
};
const removeValueFields = (i: number) => {
const newFormValues = [...forbiddenValues];
newFormValues.splice(i, 1);
setForbiddenValues(newFormValues);
};
const handleValueFieldsChange = (i: number, value: string) => {
const newFormValues = [...forbiddenValues];
newFormValues[i] = value;
setForbiddenValues(newFormValues);
setIsShowError({ ...isShowError, values: false });
};
const handleTestTypeOptionChange = (name: string) => {
const selectedColumn = column.find((d) => d.name === name);
const existingTests =
selectedColumn?.columnTests?.map(
(d: CreateColumnTest) => d.testCase.columnTestType
) || [];
if (existingTests.length) {
const newTest = Object.values(ColumnTestType).filter(
(d) => !existingTests.includes(d)
);
setTestTypeOptions(newTest);
setColumnTest(newTest[0]);
} else {
const newTest = Object.values(ColumnTestType);
setTestTypeOptions(newTest);
setColumnTest(newTest[0]);
}
};
useEffect(() => {
if (isUndefined(data)) {
const allOption = column.filter((value) => {
return (
value?.dataType !== 'STRUCT' &&
value.columnTests?.length !== Object.values(ColumnTestType).length
);
});
setColumnOptions(allOption);
setColumnName(allOption[0]?.name || '');
handleTestTypeOptionChange(allOption[0]?.name || '');
} else {
setColumnOptions(column);
setTestTypeOptions(Object.values(ColumnTestType));
setColumnName(data.columnName || '');
}
}, [column]);
const validateForm = () => {
const errMsg = cloneDeep(isShowError);
errMsg.columnName = isEmpty(columnName);
switch (columnTest) {
case ColumnTestType.columnValueLengthsToBeBetween:
case ColumnTestType.columnValuesToBeBetween:
errMsg.minOrMax = isEmpty(minValue) && isEmpty(maxValue);
if (!isEmpty(minValue) && !isEmpty(maxValue)) {
errMsg.minMaxValue = (minValue as number) > (maxValue as number);
}
break;
case ColumnTestType.columnValuesMissingCountToBeEqual:
errMsg.missingCountValue = isEmpty(missingCountValue);
break;
case ColumnTestType.columnValuesToBeNotInSet: {
const actualValues = forbiddenValues.filter((v) => !isEmpty(v));
errMsg.values = actualValues.length < 1;
break;
}
case ColumnTestType.columnValuesToMatchRegex:
errMsg.regex = isEmpty(regex);
break;
}
setIsShowError(errMsg);
return !Object.values(errMsg).includes(true);
};
const getTestConfi = () => {
switch (columnTest) {
case ColumnTestType.columnValueLengthsToBeBetween:
case ColumnTestType.columnValuesToBeBetween:
return {
minValue: minValue,
maxValue: maxValue,
};
case ColumnTestType.columnValuesMissingCountToBeEqual:
return {
missingCountValue: missingCountValue,
missingValueMatch: missingValueMatch,
};
case ColumnTestType.columnValuesToBeNotInSet:
return {
forbiddenValues: forbiddenValues.filter((v) => !isEmpty(v)),
};
case ColumnTestType.columnValuesToMatchRegex:
return {
regex: regex,
};
case ColumnTestType.columnValuesToBeNotNull:
case ColumnTestType.columnValuesToBeUnique:
default:
return {};
}
};
const handleSave = () => {
if (validateForm()) {
const columnTestObj: CreateColumnTest = {
columnName: columnName,
description: markdownRef.current?.getEditorContent() || undefined,
executionFrequency: frequency,
testCase: {
config: getTestConfi(),
columnTestType: columnTest,
},
owner: {
type: 'user',
id: getCurrentUserId(),
},
};
handleAddColumnTestCase(columnTestObj);
}
};
const handleValidation = (
event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const value = event.target.value;
const eleName = event.target.name;
const errorMsg = cloneDeep(isShowError);
switch (eleName) {
case 'columTestType': {
const selectedColumn = column.find((d) => d.name === columnName);
const columnDataType = getDataTypeString(
selectedColumn?.dataType as string
);
isAcceptedTypeIsString.current =
columnDataType === 'varchar' || columnDataType === 'boolean';
setForbiddenValues(['']);
setColumnTest(value as ColumnTestType);
errorMsg.columnName = false;
errorMsg.regex = false;
errorMsg.minOrMax = false;
errorMsg.missingCountValue = false;
errorMsg.values = false;
errorMsg.minMaxValue = false;
break;
}
case 'min': {
setMinValue(value as unknown as number);
errorMsg.minOrMax = false;
errorMsg.minMaxValue = false;
break;
}
case 'max': {
setMaxValue(value as unknown as number);
errorMsg.minOrMax = false;
errorMsg.minMaxValue = false;
break;
}
case 'frequency':
setFrequency(value as TestCaseExecutionFrequency);
break;
case 'columnName': {
const selectedColumn = column.find((d) => d.name === value);
const columnDataType = getDataTypeString(
selectedColumn?.dataType as string
);
isAcceptedTypeIsString.current =
columnDataType === 'varchar' || columnDataType === 'boolean';
setForbiddenValues(['']);
setColumnName(value);
handleTestTypeOptionChange(value);
errorMsg.columnName = false;
break;
}
case 'missingValueMatch':
setMissingValueMatch(value);
break;
case 'missingCountValue':
setMissingCountValue(value as unknown as number);
errorMsg.missingCountValue = false;
break;
case 'regex':
setRegex(value);
errorMsg.regex = false;
break;
default:
break;
}
setIsShowError(errorMsg);
};
const getMinMaxField = () => {
return (
<Fragment>
<div className="tw-flex tw-gap-4 tw-w-full">
<div className="tw-flex-1">
<label className="tw-block tw-form-label" htmlFor="min">
Min:
</label>
<input
className="tw-form-inputs tw-px-3 tw-py-1"
data-testid="min"
id="min"
name="min"
placeholder="10"
type="number"
value={minValue}
onChange={handleValidation}
/>
</div>
<div className="tw-flex-1">
<label className="tw-block tw-form-label" htmlFor="max">
Max:
</label>
<input
className="tw-form-inputs tw-px-3 tw-py-1"
data-testid="max"
id="max"
name="max"
placeholder="100"
type="number"
value={maxValue}
onChange={handleValidation}
/>
</div>
</div>
{isShowError.minOrMax && errorMsg('Please enter atleast one value.')}
{isShowError.minMaxValue &&
errorMsg('Min value should be lower than Max value.')}
</Fragment>
);
};
const getMissingCountToBeEqualFields = () => {
return (
<Fragment>
<div className="tw-flex tw-gap-4 tw-w-full">
<div className="tw-flex-1">
<label
className="tw-block tw-form-label"
htmlFor="missingCountValue">
{requiredField('Count:')}
</label>
<input
className="tw-form-inputs tw-px-3 tw-py-1"
data-testid="missingCountValue"
id="missingCountValue"
name="missingCountValue"
placeholder="Missing count value"
type="number"
value={missingCountValue}
onChange={handleValidation}
/>
{isShowError.missingCountValue &&
errorMsg('Count value is required.')}
</div>
<div className="tw-flex-1">
<label
className="tw-block tw-form-label"
htmlFor="missingValueMatch">
Match:
</label>
<input
className="tw-form-inputs tw-px-3 tw-py-1"
data-testid="missingValueMatch"
id="missingValueMatch"
name="missingValueMatch"
placeholder="Missing value match"
value={missingValueMatch}
onChange={handleValidation}
/>
</div>
</div>
</Fragment>
);
};
const getColumnValuesToMatchRegexFields = () => {
return (
<Field>
<label className="tw-block tw-form-label" htmlFor="regex">
{requiredField('Regex:')}
</label>
<input
className="tw-form-inputs tw-px-3 tw-py-1"
data-testid="regex"
id="regex"
name="regex"
placeholder="Regex column entries should match"
value={regex}
onChange={handleValidation}
/>
{isShowError.regex && errorMsg('Regex is required.')}
</Field>
);
};
const getColumnValuesToBeNotInSetField = () => {
return (
<div data-testid="not-in-set-fiel">
<div className="tw-flex tw-items-center tw-mt-6">
<p className="w-form-label tw-mr-3">{requiredField('Values')}</p>
<Button
className="tw-h-5 tw-px-2"
size="x-small"
theme="primary"
variant="contained"
onClick={addValueFields}>
<i aria-hidden="true" className="fa fa-plus" />
</Button>
</div>
{forbiddenValues.map((value, i) => (
<div className="tw-flex tw-items-center" key={i}>
<div className="tw-w-11/12">
<Field>
<input
className="tw-form-inputs tw-px-3 tw-py-1"
id={`option-key-${i}`}
name="key"
placeholder="Values not to be in the set"
type={isAcceptedTypeIsString.current ? 'text' : 'number'}
value={value}
onChange={(e) => handleValueFieldsChange(i, e.target.value)}
/>
</Field>
</div>
<button
className="focus:tw-outline-none tw-mt-3 tw-w-1/12"
onClick={(e) => {
removeValueFields(i);
e.preventDefault();
}}>
<SVGIcons
alt="delete"
icon="icon-delete"
title="Delete"
width="12px"
/>
</button>
</div>
))}
{isShowError.values && errorMsg('Value is required.')}
</div>
);
};
const getColumnTestConfig = () => {
switch (columnTest) {
case ColumnTestType.columnValueLengthsToBeBetween:
case ColumnTestType.columnValuesToBeBetween:
return getMinMaxField();
case ColumnTestType.columnValuesMissingCountToBeEqual:
return getMissingCountToBeEqualFields();
case ColumnTestType.columnValuesToBeNotInSet:
return getColumnValuesToBeNotInSetField();
case ColumnTestType.columnValuesToMatchRegex:
return getColumnValuesToMatchRegexFields();
case ColumnTestType.columnValuesToBeNotNull:
case ColumnTestType.columnValuesToBeUnique:
default:
return <></>;
}
};
return (
<div>
<p className="tw-font-medium tw-px-4">
{isUndefined(data) ? 'Add' : 'Edit'} Column Test
</p>
<form className="tw-w-screen-sm" data-testid="form">
<div className="tw-px-4 tw-mx-auto">
<Field>
<label className="tw-block tw-form-label" htmlFor="columnName">
{requiredField('Column Name:')}
</label>
<select
className={classNames('tw-form-inputs tw-px-3 tw-py-1', {
'tw-cursor-not-allowed': !isUndefined(data),
})}
disabled={!isUndefined(data)}
id="columnName"
name="columnName"
value={columnName}
onChange={handleValidation}>
{columnOptions.map((option) => (
<option key={option.name} value={option.name}>
{option.name}
</option>
))}
</select>
{isShowError.columnName && errorMsg('Column name is required.')}
</Field>
<Field>
<label className="tw-block tw-form-label" htmlFor="columTestType">
{requiredField('Test Type:')}
</label>
<select
className={classNames('tw-form-inputs tw-px-3 tw-py-1', {
'tw-cursor-not-allowed': !isUndefined(data),
})}
disabled={!isUndefined(data)}
id="columTestType"
name="columTestType"
value={columnTest}
onChange={handleValidation}>
{testTypeOptions &&
testTypeOptions.length > 0 &&
testTypeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</Field>
<Field>
<label
className="tw-block tw-form-label tw-mb-0"
htmlFor="description">
Description:
</label>
<MarkdownWithPreview
data-testid="description"
ref={markdownRef}
value={description}
/>
</Field>
{getColumnTestConfig()}
<Field>
<label className="tw-block tw-form-label" htmlFor="frequency">
Frequency of Test Run:
</label>
<select
className="tw-form-inputs tw-px-3 tw-py-1"
id="frequency"
name="frequency"
value={frequency}
onChange={handleValidation}>
{Object.values(TestCaseExecutionFrequency).map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</Field>
</div>
<Field>
<Field className="tw-flex tw-justify-end">
<Button
data-testid="cancel-test"
size="regular"
theme="primary"
variant="text"
onClick={onFormCancel}>
Discard
</Button>
<Button
className="tw-w-16 tw-h-10"
size="regular"
theme="primary"
variant="contained"
onClick={handleSave}>
Save
</Button>
</Field>
</Field>
</form>
</div>
);
};
export default ColumnTestForm;

View File

@ -0,0 +1,345 @@
/*
* 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 classNames from 'classnames';
import { cloneDeep, isEmpty, isUndefined } from 'lodash';
import { EditorContentRef } from 'Models';
import React, { Fragment, useEffect, useRef, useState } from 'react';
import {
CreateTableTest,
TableTestType,
TestCaseExecutionFrequency,
} from '../../../generated/api/tests/createTableTest';
import { TableTest } from '../../../generated/tests/tableTest';
import {
errorMsg,
getCurrentUserId,
requiredField,
} from '../../../utils/CommonUtils';
import { Button } from '../../buttons/Button/Button';
import MarkdownWithPreview from '../../common/editor/MarkdownWithPreview';
type Props = {
data: TableTest;
tableTestCase: TableTest[];
handleAddTableTestCase: (data: CreateTableTest) => void;
onFormCancel: () => void;
};
export const Field = ({
children,
className = '',
}: {
children: React.ReactNode;
className?: string;
}) => {
return <div className={classNames('tw-mt-4', className)}>{children}</div>;
};
const TableTestForm = ({
data,
tableTestCase,
handleAddTableTestCase,
onFormCancel,
}: Props) => {
const markdownRef = useRef<EditorContentRef>();
const [tableTest, setTableTest] = useState<TableTestType | undefined>(
data?.testCase?.tableTestType
);
const [description] = useState(data?.description || '');
const [minValue, setMinValue] = useState<number | undefined>(
data?.testCase?.config?.minValue
);
const [maxValue, setMaxValue] = useState<number | undefined>(
data?.testCase?.config?.maxValue
);
const [value, setValue] = useState<number | undefined>(
data?.testCase.config?.value
);
const [frequency, setFrequency] = useState<TestCaseExecutionFrequency>(
data?.executionFrequency
? data.executionFrequency
: TestCaseExecutionFrequency.Daily
);
const [isShowError, setIsShowError] = useState({
minOrMax: false,
values: false,
minMaxValue: false,
});
const [testTypeOptions, setTestTypeOptions] = useState<string[]>();
useEffect(() => {
if (tableTestCase.length && isUndefined(data)) {
const existingTest = tableTestCase?.map(
(d) => d.testCase.tableTestType as string
);
const newTest = Object.values(TableTestType).filter(
(d) => !existingTest.includes(d)
);
setTestTypeOptions(newTest);
setTableTest(newTest[0]);
} else {
const testValue = Object.values(TableTestType);
setTestTypeOptions(testValue);
setTableTest(data?.testCase?.tableTestType || testValue[0]);
}
}, [tableTestCase]);
const validateForm = () => {
const errMsg = cloneDeep(isShowError);
const isTableRowCountToBeBetweenTest =
tableTest === TableTestType.TableRowCountToBeBetween;
if (isTableRowCountToBeBetweenTest) {
errMsg.minOrMax = isEmpty(minValue) && isEmpty(maxValue);
if (!isEmpty(minValue) && !isEmpty(maxValue)) {
errMsg.minMaxValue = (minValue as number) > (maxValue as number);
}
} else {
errMsg.values = isEmpty(value);
}
setIsShowError(errMsg);
return !Object.values(errMsg).includes(true);
};
const handleSave = () => {
const isTableRowCountToBeBetweenTest =
tableTest === TableTestType.TableRowCountToBeBetween;
if (validateForm()) {
const createTest: CreateTableTest = {
description: markdownRef.current?.getEditorContent() || undefined,
executionFrequency: frequency,
testCase: {
config: {
maxValue: isTableRowCountToBeBetweenTest ? maxValue : undefined,
minValue: isTableRowCountToBeBetweenTest ? minValue : undefined,
value: isTableRowCountToBeBetweenTest ? undefined : value,
},
tableTestType: tableTest,
},
owner: {
type: 'user',
id: getCurrentUserId(),
},
};
handleAddTableTestCase(createTest);
}
};
const handleValidation = (
event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const value = event.target.value;
const eleName = event.target.name;
const errorMsg = cloneDeep(isShowError);
switch (eleName) {
case 'tableTestType':
setTableTest(value as TableTestType);
errorMsg.minMaxValue = false;
errorMsg.minOrMax = false;
errorMsg.values = false;
break;
case 'min':
setMinValue(value as unknown as number);
errorMsg.minMaxValue = false;
errorMsg.minOrMax = false;
break;
case 'max':
setMaxValue(value as unknown as number);
errorMsg.minMaxValue = false;
errorMsg.minOrMax = false;
break;
case 'value':
setValue(value as unknown as number);
errorMsg.values = false;
break;
case 'frequency':
setFrequency(value as TestCaseExecutionFrequency);
break;
default:
break;
}
setIsShowError(errorMsg);
};
const getValueField = () => {
return (
<Field>
<label className="tw-block tw-form-label" htmlFor="value">
{requiredField('Value:')}
</label>
<input
className="tw-form-inputs tw-px-3 tw-py-1"
data-testid="value"
id="value"
name="value"
placeholder="100"
type="number"
value={value}
onChange={handleValidation}
/>
{isShowError.values && errorMsg('Value is required.')}
</Field>
);
};
const getMinMaxField = () => {
return (
<Fragment>
<div className="tw-flex tw-gap-4 tw-w-full">
<div className="tw-flex-1">
<label className="tw-block tw-form-label" htmlFor="min">
Min:
</label>
<input
className="tw-form-inputs tw-px-3 tw-py-1"
data-testid="min"
id="min"
name="min"
placeholder="10"
type="number"
value={minValue}
onChange={handleValidation}
/>
</div>
<div className="tw-flex-1">
<label className="tw-block tw-form-label" htmlFor="max">
Max:
</label>
<input
className="tw-form-inputs tw-px-3 tw-py-1"
data-testid="max"
id="max"
name="max"
placeholder="100"
type="number"
value={maxValue}
onChange={handleValidation}
/>
</div>
</div>
{isShowError.minOrMax && errorMsg('Please enter atleast one value')}
{isShowError.minMaxValue &&
errorMsg('Min value should be lower than Max value.')}
</Fragment>
);
};
return (
<Fragment>
<p className="tw-font-medium tw-px-4">
{isUndefined(data) ? 'Add' : 'Edit'} Table Test
</p>
<form className="tw-w-screen-sm" data-testid="form">
<div className="tw-px-4 tw-mx-auto">
<Field>
<label className="tw-block tw-form-label" htmlFor="tableTestType">
{requiredField('Test Type:')}
</label>
<select
className={classNames('tw-form-inputs tw-px-3 tw-py-1', {
'tw-cursor-not-allowed': !isUndefined(data),
})}
disabled={!isUndefined(data)}
id="tableTestType"
name="tableTestType"
value={tableTest}
onChange={handleValidation}>
{testTypeOptions &&
testTypeOptions.length > 0 &&
testTypeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</Field>
<Field>
<label
className="tw-block tw-form-label tw-mb-0"
htmlFor="description">
Description:
</label>
<MarkdownWithPreview
data-testid="description"
ref={markdownRef}
value={description}
/>
</Field>
<Field>
{tableTest === TableTestType.TableRowCountToBeBetween
? getMinMaxField()
: getValueField()}
</Field>
<Field>
<label className="tw-block tw-form-label" htmlFor="frequency">
Frequency of Test Run:
</label>
<select
className="tw-form-inputs tw-px-3 tw-py-1"
id="frequency"
name="frequency"
value={frequency}
onChange={handleValidation}>
{Object.values(TestCaseExecutionFrequency).map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</Field>
</div>
<Field className="tw-flex tw-justify-end">
<Button
data-testid="cancel-test"
size="regular"
theme="primary"
variant="text"
onClick={onFormCancel}>
Discard
</Button>
<Button
className="tw-w-16 tw-h-10"
size="regular"
theme="primary"
variant="contained"
onClick={handleSave}>
Save
</Button>
</Field>
</form>
</Fragment>
);
};
export default TableTestForm;

View File

@ -0,0 +1,129 @@
/*
* 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 React, { useState } from 'react';
import { ColumnTestType } from '../../enums/columnTest.enum';
import { CreateTableTest } from '../../generated/api/tests/createTableTest';
import { Table } from '../../generated/entity/data/table';
import { TableTest, TableTestType } from '../../generated/tests/tableTest';
import {
CreateColumnTest,
DatasetTestModeType,
ModifiedTableColumn,
TableTestDataType,
} from '../../interface/dataQuality.interface';
import AddDataQualityTest from '../AddDataQualityTest/AddDataQualityTest';
import DataQualityTest from '../DataQualityTest/DataQualityTest';
type Props = {
handleAddTableTestCase: (data: CreateTableTest) => void;
handleAddColumnTestCase: (data: CreateColumnTest) => void;
columnOptions: Table['columns'];
testMode: DatasetTestModeType;
handleTestModeChange: (mode: DatasetTestModeType) => void;
showTestForm: boolean;
handleShowTestForm: (value: boolean) => void;
tableTestCase: TableTest[];
handleRemoveTableTest: (testType: TableTestType) => void;
handleRemoveColumnTest: (
columnName: string,
testType: ColumnTestType
) => void;
};
const DataQualityTab = ({
columnOptions,
showTestForm,
handleTestModeChange,
handleShowTestForm,
handleAddTableTestCase,
handleAddColumnTestCase,
handleRemoveTableTest,
handleRemoveColumnTest,
testMode,
tableTestCase,
}: Props) => {
const [showDropDown, setShowDropDown] = useState(false);
const [activeData, setActiveData] = useState<TableTestDataType>();
const onFormCancel = () => {
handleShowTestForm(false);
setActiveData(undefined);
};
const handleShowDropDown = (value: boolean) => {
setShowDropDown(value);
};
const handleTestSelection = (mode: DatasetTestModeType) => {
handleTestModeChange(mode);
handleShowTestForm(true);
};
const haandleDropDownClick = (
_e: React.MouseEvent<HTMLElement, MouseEvent>,
value?: string
) => {
if (value) {
handleTestSelection(value as DatasetTestModeType);
}
setShowDropDown(false);
};
const handleEditTest = (
mode: DatasetTestModeType,
obj: TableTestDataType
) => {
setActiveData(obj);
handleTestSelection(mode);
};
const onTableTestSave = (data: CreateTableTest) => {
handleAddTableTestCase(data);
setActiveData(undefined);
};
const onColumnTestSave = (data: CreateColumnTest) => {
handleAddColumnTestCase(data);
setActiveData(undefined);
};
return (
<div>
{showTestForm ? (
<AddDataQualityTest
columnOptions={columnOptions}
data={activeData}
handleAddColumnTestCase={onColumnTestSave}
handleAddTableTestCase={onTableTestSave}
tableTestCase={tableTestCase}
testMode={testMode}
onFormCancel={onFormCancel}
/>
) : (
<DataQualityTest
columns={columnOptions as ModifiedTableColumn[]}
haandleDropDownClick={haandleDropDownClick}
handleEditTest={handleEditTest}
handleRemoveColumnTest={handleRemoveColumnTest}
handleRemoveTableTest={handleRemoveTableTest}
handleShowDropDown={handleShowDropDown}
showDropDown={showDropDown}
tableTestCase={tableTestCase}
/>
)}
</div>
);
};
export default DataQualityTab;

View File

@ -0,0 +1,158 @@
/*
* 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 React, { useEffect, useState } from 'react';
import { TITLE_FOR_NON_ADMIN_ACTION } from '../../constants/constants';
import { ColumnTestType } from '../../enums/columnTest.enum';
import { TableTest, TableTestType } from '../../generated/tests/tableTest';
import {
DatasetTestModeType,
ModifiedTableColumn,
TableTestDataType,
} from '../../interface/dataQuality.interface';
import { normalLink } from '../../utils/styleconstant';
import { dropdownIcon as DropdownIcon } from '../../utils/svgconstant';
import ErrorPlaceHolder from '../common/error-with-placeholder/ErrorPlaceHolder';
import NonAdminAction from '../common/non-admin-action/NonAdminAction';
import DropDownList from '../dropdown/DropDownList';
import { DropDownListItem } from '../dropdown/types';
import DataQualityTable from './Table/DataQualityTable';
type Props = {
tableTestCase: TableTest[];
columns: ModifiedTableColumn[];
showDropDown: boolean;
handleEditTest: (mode: DatasetTestModeType, obj: TableTestDataType) => void;
handleRemoveTableTest: (testType: TableTestType) => void;
haandleDropDownClick: (
_e: React.MouseEvent<HTMLElement, MouseEvent>,
value?: string | undefined
) => void;
handleShowDropDown: (value: boolean) => void;
handleRemoveColumnTest: (
columnName: string,
testType: ColumnTestType
) => void;
};
const DataQualityTest = ({
showDropDown,
tableTestCase,
columns,
handleEditTest,
handleShowDropDown,
handleRemoveTableTest,
handleRemoveColumnTest,
haandleDropDownClick,
}: Props) => {
const [columnsData, setColumnsData] = useState<ModifiedTableColumn[]>([]);
const isColumnTestDisable = () => {
const remainingTest = columns?.filter((d) => {
return d?.columnTests?.length !== Object.values(ColumnTestType).length;
});
return !(remainingTest.length > 0);
};
const dropdownList: DropDownListItem[] = [
{
name: 'Table Test',
value: 'table',
disabled: tableTestCase.length >= Object.values(TableTestType).length,
},
{
name: 'Column Test',
value: 'column',
disabled: isColumnTestDisable(),
},
];
useEffect(() => {
if (columns.length) {
setColumnsData(
columns.filter((d) => d?.columnTests && d?.columnTests.length > 0)
);
}
}, [columns]);
const addTestButton = (horzPosRight: boolean) => {
return (
<div className="tw-flex tw-justify-end">
<NonAdminAction position="bottom" title={TITLE_FOR_NON_ADMIN_ACTION}>
<span className="tw-relative">
<button onClick={() => handleShowDropDown(true)}>
Add Test{' '}
<DropdownIcon style={{ marginTop: '1px', color: normalLink }} />
</button>
{showDropDown && (
<DropDownList
dropDownList={dropdownList}
horzPosRight={horzPosRight}
onSelect={haandleDropDownClick}
/>
)}
</span>
</NonAdminAction>
</div>
);
};
return (
<>
{tableTestCase.length > 0 || columnsData.length > 0 ? (
<div>
{addTestButton(true)}
{tableTestCase.length > 0 && (
<div className="tw-mb-5">
<p className="tw-form-label">Table Tests</p>
<DataQualityTable
isTableTest
handleEditTest={handleEditTest}
handleRemoveTableTest={handleRemoveTableTest}
testCase={tableTestCase}
/>
</div>
)}
<div>
{columnsData.map((data, index) => {
return (
<div className="tw-mb-5" key={index}>
<p className="tw-form-label">{`Column Tests - ${data?.name}`}</p>
<DataQualityTable
handleEditTest={handleEditTest}
handleRemoveColumnTest={handleRemoveColumnTest}
isTableTest={false}
testCase={
data.columnTests && data.columnTests?.length > 0
? (data.columnTests as TableTestDataType[])
: []
}
/>
</div>
);
})}
</div>
</div>
) : (
<ErrorPlaceHolder>
<p>No test available.</p>
{addTestButton(false)}
</ErrorPlaceHolder>
)}
</>
);
};
export default DataQualityTest;

View File

@ -0,0 +1,223 @@
/*
* 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 classNames from 'classnames';
import { isEmpty } from 'lodash';
import React, { useState } from 'react';
import { TITLE_FOR_NON_ADMIN_ACTION } from '../../../constants/constants';
import { ColumnTestType } from '../../../enums/columnTest.enum';
import {
TableTestType,
TestCaseStatus,
} from '../../../generated/tests/tableTest';
import {
DatasetTestModeType,
TableTestDataType,
} from '../../../interface/dataQuality.interface';
import { isEven } from '../../../utils/CommonUtils';
import NonAdminAction from '../../common/non-admin-action/NonAdminAction';
import Loader from '../../Loader/Loader';
import ConfirmationModal from '../../Modals/ConfirmationModal/ConfirmationModal';
type Props = {
testCase: TableTestDataType[];
isTableTest: boolean;
handleEditTest: (mode: DatasetTestModeType, obj: TableTestDataType) => void;
handleRemoveTableTest?: (testType: TableTestType) => void;
handleRemoveColumnTest?: (
columnName: string,
testType: ColumnTestType
) => void;
};
const DataQualityTable = ({
testCase,
isTableTest,
handleEditTest,
handleRemoveTableTest,
handleRemoveColumnTest,
}: Props) => {
const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false);
const [deleteSelection, setDeleteSelection] = useState<{
data?: TableTestDataType;
state: string;
}>({
data: undefined,
state: '',
});
const handleCancelConfirmationModal = () => {
setIsConfirmationModalOpen(false);
setDeleteSelection({ data: undefined, state: '' });
};
const confirmDelete = (data: TableTestDataType) => {
setDeleteSelection({ data, state: '' });
setIsConfirmationModalOpen(true);
};
const handleDelete = (data: TableTestDataType) => {
if (isTableTest) {
handleRemoveTableTest &&
handleRemoveTableTest(data.testCase.tableTestType as TableTestType);
} else {
handleRemoveColumnTest &&
handleRemoveColumnTest(
data?.columnName || '',
data.testCase?.columnTestType as ColumnTestType
);
}
handleCancelConfirmationModal();
};
return (
<div className="tw-table-responsive">
<table className="tw-w-full">
<thead>
<tr className="tableHead-row">
<th className="tableHead-cell">Test Case</th>
<th className="tableHead-cell">Config</th>
<th className="tableHead-cell">Last Run</th>
<th className="tableHead-cell">Value</th>
<th className="tableHead-cell">Action</th>
</tr>
</thead>
<tbody className="tableBody">
{testCase.map((column, index) => {
return (
<tr
className={classNames(
'tableBody-row',
!isEven(index + 1) ? 'odd-row' : null
)}
data-testid="column"
id={column.name}
key={index}>
<td className="tableBody-cell tw-w-3/12">
<span>
{isTableTest
? column.testCase.tableTestType
: column.testCase.columnTestType}
</span>
</td>
<td className="tableBody-cell tw-w-2/12">
{!isEmpty(column.testCase?.config) && column.testCase?.config
? Object.entries(column.testCase?.config).map((d, i) => (
<p key={i}>{`${d[0]}: ${
!isEmpty(d[1]) ? d[1] : '--'
}`}</p>
))
: '--'}
</td>
<td className="tableBody-cell tw-w-1/12">
{column.results && column.results.length > 0 ? (
<span
className={classNames(
'tw-block tw-w-full tw-h-full tw-text-white tw-text-center tw-py-1',
{
'tw-bg-success':
column.results[0].testCaseStatus ===
TestCaseStatus.Success,
'tw-bg-failed':
column.results[0].testCaseStatus ===
TestCaseStatus.Failed,
'tw-bg-status-queued':
column.results[0].testCaseStatus ===
TestCaseStatus.Aborted,
}
)}>
{column.results[0].testCaseStatus}
</span>
) : (
'--'
)}
</td>
<td className="tableBody-cell tw-w-4/12">
<span>
{column.results && column.results.length > 0
? column.results[0].result || '--'
: '--'}
</span>
</td>
<td className="tableBody-cell tw-w-2/12">
<div className="tw-flex tw-items-center">
<NonAdminAction
position="left"
title={TITLE_FOR_NON_ADMIN_ACTION}>
<button
className="link-text tw-mr-2"
data-testid="edit"
onClick={() =>
handleEditTest(
isTableTest ? 'table' : 'column',
column
)
}>
Edit
</button>
</NonAdminAction>
<NonAdminAction
position="left"
title={TITLE_FOR_NON_ADMIN_ACTION}>
<button
className="link-text tw-mr-2"
data-testid="delete"
onClick={() => confirmDelete(column)}>
{deleteSelection.data?.id === column.id ? (
deleteSelection.state === 'success' ? (
<i aria-hidden="true" className="fa fa-check" />
) : (
<Loader size="small" type="default" />
)
) : (
'Delete'
)}
</button>
</NonAdminAction>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{isConfirmationModalOpen && (
<ConfirmationModal
bodyText={`You want to delete test ${
deleteSelection.data?.testCase?.columnTestType ||
deleteSelection.data?.testCase?.tableTestType
} permanently? This action cannot be reverted.`}
cancelText="Discard"
confirmButtonCss="tw-bg-error hover:tw-bg-error focus:tw-bg-error"
confirmText={
deleteSelection.state === 'waiting' ? (
<Loader size="small" type="white" />
) : deleteSelection.state === 'success' ? (
<i aria-hidden="true" className="fa fa-check" />
) : (
'Delete'
)
}
header="Are you sure?"
onCancel={handleCancelConfirmationModal}
onConfirm={() =>
handleDelete(deleteSelection.data as TableTestDataType)
}
/>
)}
</div>
);
};
export default DataQualityTable;

View File

@ -43,6 +43,7 @@ import Description from '../common/description/Description';
import EntityPageInfo from '../common/entityPageInfo/EntityPageInfo';
import TabsPane from '../common/TabsPane/TabsPane';
import PageContainer from '../containers/PageContainer';
import DataQualityTab from '../DataQualityTab/DataQualityTab';
import Entitylineage from '../EntityLineage/EntityLineage.component';
import FrequentlyJoinedTables from '../FrequentlyJoinedTables/FrequentlyJoinedTables.component';
import ManageTab from '../ManageTab/ManageTab.component';
@ -100,7 +101,16 @@ const DatasetDetails: React.FC<DatasetDetailsProps> = ({
postFeedHandler,
feedCount,
entityFieldThreadCount,
testMode,
tableTestCase,
handleTestModeChange,
createThread,
handleAddTableTestCase,
handleAddColumnTestCase,
showTestForm,
handleShowTestForm,
handleRemoveTableTest,
handleRemoveColumnTest,
}: DatasetDetailsProps) => {
const { isAuthDisabled } = useAuth();
const [isEdit, setIsEdit] = useState(false);
@ -209,6 +219,17 @@ const DatasetDetails: React.FC<DatasetDetailsProps> = ({
isProtected: false,
position: 5,
},
{
name: 'Data Quality',
icon: {
alt: 'data-quality',
name: 'icon-quality',
title: 'Data Quality',
selectedName: '',
},
isProtected: false,
position: 6,
},
{
name: 'Lineage',
icon: {
@ -218,7 +239,7 @@ const DatasetDetails: React.FC<DatasetDetailsProps> = ({
selectedName: 'icon-lineagecolor',
},
isProtected: false,
position: 6,
position: 7,
},
{
name: 'DBT',
@ -230,7 +251,7 @@ const DatasetDetails: React.FC<DatasetDetailsProps> = ({
},
isProtected: false,
isHidden: !dataModel?.sql,
position: 7,
position: 8,
},
{
name: 'Manage',
@ -243,7 +264,7 @@ const DatasetDetails: React.FC<DatasetDetailsProps> = ({
isProtected: false,
isHidden: deleted,
protectedState: !owner || hasEditAccess(),
position: 8,
position: 9,
},
];
@ -600,7 +621,23 @@ const DatasetDetails: React.FC<DatasetDetailsProps> = ({
/>
</div>
)}
{activeTab === 6 && (
<DataQualityTab
columnOptions={columns}
handleAddColumnTestCase={handleAddColumnTestCase}
handleAddTableTestCase={handleAddTableTestCase}
handleRemoveColumnTest={handleRemoveColumnTest}
handleRemoveTableTest={handleRemoveTableTest}
handleShowTestForm={handleShowTestForm}
handleTestModeChange={handleTestModeChange}
showTestForm={showTestForm}
tableTestCase={tableTestCase}
testMode={testMode}
/>
)}
{activeTab === 7 && (
<div
className={classNames(
location.pathname.includes(ROUTES.TOUR)
@ -622,7 +659,7 @@ const DatasetDetails: React.FC<DatasetDetailsProps> = ({
/>
</div>
)}
{activeTab === 7 && Boolean(dataModel?.sql) && (
{activeTab === 8 && Boolean(dataModel?.sql) && (
<div className="tw-border tw-border-main tw-rounded-md tw-py-4 tw-h-full cm-h-full">
<SchemaEditor
className="tw-h-full"
@ -631,7 +668,7 @@ const DatasetDetails: React.FC<DatasetDetailsProps> = ({
/>
</div>
)}
{activeTab === 8 && !deleted && (
{activeTab === 9 && !deleted && (
<div>
<ManageTab
currentTier={tier?.tagFQN}

View File

@ -19,7 +19,9 @@ import {
LineagePos,
LoadingNodeState,
} from 'Models';
import { ColumnTestType } from '../../enums/columnTest.enum';
import { CreateThread } from '../../generated/api/feed/createThread';
import { CreateTableTest } from '../../generated/api/tests/createTableTest';
import {
EntityReference,
Table,
@ -28,8 +30,13 @@ import {
TypeUsedToReturnUsageDetailsOfAnEntity,
} from '../../generated/entity/data/table';
import { User } from '../../generated/entity/teams/user';
import { TableTest, TableTestType } from '../../generated/tests/tableTest';
import { EntityLineage } from '../../generated/type/entityLineage';
import { TagLabel } from '../../generated/type/tagLabel';
import {
CreateColumnTest,
DatasetTestModeType,
} from '../../interface/dataQuality.interface';
import { TitleBreadcrumbProps } from '../common/title-breadcrumb/title-breadcrumb.interface';
import { Edge, EdgeData } from '../EntityLineage/EntityLineage.interface';
@ -68,6 +75,11 @@ export interface DatasetDetailsProps {
isentityThreadLoading: boolean;
feedCount: number;
entityFieldThreadCount: EntityFieldThreadCount[];
testMode: DatasetTestModeType;
tableTestCase: TableTest[];
showTestForm: boolean;
handleShowTestForm: (value: boolean) => void;
handleTestModeChange: (mode: DatasetTestModeType) => void;
createThread: (data: CreateThread) => void;
setActiveTabHandler: (value: number) => void;
followTableHandler: () => void;
@ -81,4 +93,11 @@ export interface DatasetDetailsProps {
removeLineageHandler: (data: EdgeData) => void;
entityLineageHandler: (lineage: EntityLineage) => void;
postFeedHandler: (value: string, id: string) => void;
handleAddTableTestCase: (data: CreateTableTest) => void;
handleAddColumnTestCase: (data: CreateColumnTest) => void;
handleRemoveTableTest: (testType: TableTestType) => void;
handleRemoveColumnTest: (
columnName: string,
testType: ColumnTestType
) => void;
}

View File

@ -23,6 +23,7 @@ import {
} from '../../generated/entity/data/table';
import { EntityLineage } from '../../generated/type/entityLineage';
import { TagLabel } from '../../generated/type/tagLabel';
import { DatasetTestModeType } from '../../interface/dataQuality.interface';
import DatasetDetails from './DatasetDetails.component';
import { DatasetOwner } from './DatasetDetails.interface';
@ -83,7 +84,16 @@ const DatasetDetailsProps = {
postFeedHandler: jest.fn(),
feedCount: 0,
entityFieldThreadCount: [],
showTestForm: false,
testMode: 'table' as DatasetTestModeType,
handleAddTableTestCase: jest.fn(),
tableTestCase: [],
handleAddColumnTestCase: jest.fn(),
createThread: jest.fn(),
handleShowTestForm: jest.fn(),
handleRemoveTableTest: jest.fn(),
handleRemoveColumnTest: jest.fn(),
handleTestModeChange: jest.fn(),
};
jest.mock('../ManageTab/ManageTab.component', () => {
return jest.fn().mockReturnValue(<p>ManageTab</p>);

View File

@ -73,13 +73,16 @@ const DropDownList: FunctionComponent<DropDownListProp> = ({
aria-disabled={item.disabled as boolean}
className={classNames(
'tw-text-gray-700 tw-block tw-px-4 tw-py-2 tw-text-sm hover:tw-bg-body-hover tw-cursor-pointer',
!isNil(value) && item.value === value ? 'tw-bg-primary-lite' : null
!isNil(value) && item.value === value ? 'tw-bg-primary-lite' : null,
{
'tw-cursor-not-allowed': item.disabled,
}
)}
data-testid="list-item"
id={`menu-item-${index}`}
key={index}
role="menuitem"
onClick={(e) => onSelect && onSelect(e, item.value)}>
onClick={(e) => !item.disabled && onSelect?.(e, item.value)}>
<p className="tw-truncate tw-w-52" title={item.name as string}>
{item.name}
</p>

View File

@ -0,0 +1,22 @@
/*
* 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.
*/
export enum ColumnTestType {
columnValuesToBeUnique = 'columnValuesToBeUnique',
columnValuesToBeNotNull = 'columnValuesToBeNotNull',
columnValuesToMatchRegex = 'columnValuesToMatchRegex',
columnValuesToBeNotInSet = 'columnValuesToBeNotInSet',
columnValuesToBeBetween = 'columnValuesToBeBetween',
columnValuesMissingCountToBeEqual = 'columnValuesMissingCountToBeEqual',
columnValueLengthsToBeBetween = 'columnValueLengthsToBeBetween',
}

View File

@ -46,4 +46,5 @@ export enum TabSpecificField {
CHARTS = 'charts',
TASKS = 'tasks',
TABLE_QUERIES = 'tableQueries',
TESTS = 'tests',
}

View File

@ -0,0 +1,159 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
* 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.
*/
/**
* TableTest is a test definition to capture data quality tests against tables and columns.
*/
export interface CreateTableTest {
/**
* Description of the testcase.
*/
description?: string;
executionFrequency?: TestCaseExecutionFrequency;
/**
* Owner of this Pipeline.
*/
owner?: EntityReference;
result?: TestCaseResult;
testCase: TableTestCase;
/**
* Last update time corresponding to the new version of the entity in Unix epoch time
* milliseconds.
*/
updatedAt?: number;
/**
* User who made the update.
*/
updatedBy?: string;
}
/**
* How often the test case should run.
*/
export enum TestCaseExecutionFrequency {
Daily = 'Daily',
Hourly = 'Hourly',
Weekly = 'Weekly',
}
/**
* Owner of this Pipeline.
*
* This schema defines the EntityReference type used for referencing an entity.
* EntityReference is used for capturing relationships from one entity to another. For
* example, a table has an attribute called database of type EntityReference that captures
* the relationship of a table `belongs to a` database.
*/
export interface EntityReference {
/**
* Optional description of entity.
*/
description?: string;
/**
* Display Name that identifies this entity.
*/
displayName?: string;
/**
* Link to the entity resource.
*/
href?: string;
/**
* Unique identifier that identifies an entity instance.
*/
id: string;
/**
* Name of the entity instance. For entities such as tables, databases where the name is not
* unique, fullyQualifiedName is returned in this field.
*/
name?: string;
/**
* Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`,
* `dashboardService`...
*/
type: string;
}
/**
* Schema to capture test case result.
*/
export interface TestCaseResult {
/**
* Data one which profile is taken.
*/
executionTime?: number;
/**
* Details of test case results.
*/
result?: string;
/**
* sample data to capture rows/columns that didn't match the expressed testcase.
*/
sampleData?: string;
/**
* Status of Test Case run.
*/
testCaseStatus?: TestCaseStatus;
}
/**
* Status of Test Case run.
*/
export enum TestCaseStatus {
Aborted = 'Aborted',
Failed = 'Failed',
Success = 'Success',
}
/**
* Table Test Case.
*/
export interface TableTestCase {
config?: TableRowCountToBeBetween;
tableTestType?: TableTestType;
}
/**
* This schema defines the test TableRowCountToEqual. Test the number of rows equal to a
* value.
*
* This scheam defines the test TableRowCountToBeBetween. Test the number of rows to between
* to two values.
*
* This scheam defines the test TableColumnCountToEqual. Test the number of columns equal to
* a value.
*/
export interface TableRowCountToBeBetween {
/**
* Expected number of rows {value}
*
* Expected number of columns to equal to a {value}
*/
value?: number;
/**
* Expected number of rows should be lower than or equal to {maxValue}. if maxValue is not
* included, minValue is treated as lowerBound and there will eb no maximum number of rows
*/
maxValue?: number;
/**
* Expected number of rows should be greater than or equal to {minValue}. If minValue is not
* included, maxValue is treated as upperBound and there will be no minimum number of rows
*/
minValue?: number;
}
export enum TableTestType {
TableColumnCountToEqual = 'tableColumnCountToEqual',
TableRowCountToBeBetween = 'tableRowCountToBeBetween',
TableRowCountToEqual = 'tableRowCountToEqual',
}

View File

@ -0,0 +1,24 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
* 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.
*/
/**
* This scheam defines the test TableColumnCountToEqual. Test the number of columns equal to
* a value.
*/
export interface TableColumnCountToEqual {
/**
* Expected number of columns to equal to a {value}
*/
value: number;
}

View File

@ -0,0 +1,30 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
* 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.
*/
/**
* This scheam defines the test TableRowCountToBeBetween. Test the number of rows to between
* to two values.
*/
export interface TableRowCountToBeBetween {
/**
* Expected number of rows should be lower than or equal to {maxValue}. if maxValue is not
* included, minValue is treated as lowerBound and there will eb no maximum number of rows
*/
maxValue?: number;
/**
* Expected number of rows should be greater than or equal to {minValue}. If minValue is not
* included, maxValue is treated as upperBound and there will be no minimum number of rows
*/
minValue?: number;
}

View File

@ -0,0 +1,24 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
* 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.
*/
/**
* This schema defines the test TableRowCountToEqual. Test the number of rows equal to a
* value.
*/
export interface TableRowCountToEqual {
/**
* Expected number of rows {value}
*/
value: number;
}

View File

@ -0,0 +1,170 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/*
* 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.
*/
/**
* TableTest is a test definition to capture data quality tests against tables and columns.
*/
export interface TableTest {
/**
* Description of the testcase.
*/
description?: string;
executionFrequency?: TestCaseExecutionFrequency;
/**
* Unique identifier of this table instance.
*/
id?: string;
/**
* Name that identifies this test case.
*/
name: string;
/**
* Owner of this Pipeline.
*/
owner?: EntityReference;
/**
* List of results of the test case.
*/
results?: TestCaseResult[];
testCase: TableTestCase;
/**
* Last update time corresponding to the new version of the entity in Unix epoch time
* milliseconds.
*/
updatedAt?: number;
/**
* User who made the update.
*/
updatedBy?: string;
}
/**
* How often the test case should run.
*/
export enum TestCaseExecutionFrequency {
Daily = 'Daily',
Hourly = 'Hourly',
Weekly = 'Weekly',
}
/**
* Owner of this Pipeline.
*
* This schema defines the EntityReference type used for referencing an entity.
* EntityReference is used for capturing relationships from one entity to another. For
* example, a table has an attribute called database of type EntityReference that captures
* the relationship of a table `belongs to a` database.
*/
export interface EntityReference {
/**
* Optional description of entity.
*/
description?: string;
/**
* Display Name that identifies this entity.
*/
displayName?: string;
/**
* Link to the entity resource.
*/
href?: string;
/**
* Unique identifier that identifies an entity instance.
*/
id: string;
/**
* Name of the entity instance. For entities such as tables, databases where the name is not
* unique, fullyQualifiedName is returned in this field.
*/
name?: string;
/**
* Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`,
* `dashboardService`...
*/
type: string;
}
/**
* Schema to capture test case result.
*/
export interface TestCaseResult {
/**
* Data one which profile is taken.
*/
executionTime?: number;
/**
* Details of test case results.
*/
result?: string;
/**
* sample data to capture rows/columns that didn't match the expressed testcase.
*/
sampleData?: string;
/**
* Status of Test Case run.
*/
testCaseStatus?: TestCaseStatus;
}
/**
* Status of Test Case run.
*/
export enum TestCaseStatus {
Aborted = 'Aborted',
Failed = 'Failed',
Success = 'Success',
}
/**
* Table Test Case.
*/
export interface TableTestCase {
config?: TableRowCountToBeBetween;
tableTestType?: TableTestType;
}
/**
* This schema defines the test TableRowCountToEqual. Test the number of rows equal to a
* value.
*
* This scheam defines the test TableRowCountToBeBetween. Test the number of rows to between
* to two values.
*
* This scheam defines the test TableColumnCountToEqual. Test the number of columns equal to
* a value.
*/
export interface TableRowCountToBeBetween {
/**
* Expected number of rows {value}
*
* Expected number of columns to equal to a {value}
*/
value?: number;
/**
* Expected number of rows should be lower than or equal to {maxValue}. if maxValue is not
* included, minValue is treated as lowerBound and there will eb no maximum number of rows
*/
maxValue?: number;
/**
* Expected number of rows should be greater than or equal to {minValue}. If minValue is not
* included, maxValue is treated as upperBound and there will be no minimum number of rows
*/
minValue?: number;
}
export enum TableTestType {
TableColumnCountToEqual = 'tableColumnCountToEqual',
TableRowCountToBeBetween = 'tableRowCountToBeBetween',
TableRowCountToEqual = 'tableRowCountToEqual',
}

View File

@ -0,0 +1,66 @@
/*
* 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 { ColumnTestType } from '../enums/columnTest.enum';
import { Column } from '../generated/entity/data/table';
import {
EntityReference,
TableTestType,
TestCaseExecutionFrequency,
TestCaseResult,
} from '../generated/tests/tableTest';
export interface TestCaseConfigType {
value?: number;
maxValue?: number;
minValue?: number;
regex?: string;
forbiddenValues?: Array<number | string>;
missingCountValue?: number;
missingValueMatch?: string;
}
export interface CreateColumnTest {
id?: string;
columnName: string;
description?: string;
executionFrequency?: TestCaseExecutionFrequency;
owner?: EntityReference;
testCase: {
columnTestType: ColumnTestType;
config?: TestCaseConfigType;
};
}
export type DatasetTestModeType = 'table' | 'column';
export interface ModifiedTableColumn extends Column {
columnTests?: CreateColumnTest[];
}
export interface TableTestDataType {
description?: string;
executionFrequency?: TestCaseExecutionFrequency;
columnName?: string;
id?: string;
name: string;
owner?: EntityReference;
results?: TestCaseResult[];
testCase: {
config?: TestCaseConfigType;
tableTestType?: TableTestType;
columnTestType?: ColumnTestType;
};
updatedAt?: number;
updatedBy?: string;
}

View File

@ -35,7 +35,11 @@ import {
import { getLineageByFQN } from '../../axiosAPIs/lineageAPI';
import { addLineage, deleteLineageEdge } from '../../axiosAPIs/miscAPI';
import {
addColumnTestCase,
addFollower,
addTableTestCase,
deleteColumnTestCase,
deleteTableTestCase,
getTableDetailsByFQN,
patchTableDetails,
removeFollower,
@ -54,10 +58,13 @@ import {
getTableTabPath,
getVersionPath,
} from '../../constants/constants';
import { ColumnTestType } from '../../enums/columnTest.enum';
import { EntityType, TabSpecificField } from '../../enums/entity.enum';
import { ServiceCategory } from '../../enums/service.enum';
import { CreateThread } from '../../generated/api/feed/createThread';
import { CreateTableTest } from '../../generated/api/tests/createTableTest';
import {
Column,
EntityReference,
Table,
TableData,
@ -65,9 +72,15 @@ import {
TypeUsedToReturnUsageDetailsOfAnEntity,
} from '../../generated/entity/data/table';
import { User } from '../../generated/entity/teams/user';
import { TableTest, TableTestType } from '../../generated/tests/tableTest';
import { EntityLineage } from '../../generated/type/entityLineage';
import { TagLabel } from '../../generated/type/tagLabel';
import useToastContext from '../../hooks/useToastContext';
import {
CreateColumnTest,
DatasetTestModeType,
ModifiedTableColumn,
} from '../../interface/dataQuality.interface';
import {
addToRecentViewed,
getCurrentUserId,
@ -149,6 +162,19 @@ const DatasetDetailsPage: FunctionComponent = () => {
EntityFieldThreadCount[]
>([]);
// Data Quality tab state
const [testMode, setTestMode] = useState<DatasetTestModeType>('table');
const [showTestForm, setShowTestForm] = useState(false);
const [tableTestCase, setTableTestCase] = useState<TableTest[]>([]);
const handleTestModeChange = (mode: DatasetTestModeType) => {
setTestMode(mode);
};
const handleShowTestForm = (value: boolean) => {
setShowTestForm(value);
};
const activeTabHandler = (tabValue: number) => {
const currentTabIndex = tabValue - 1;
if (datasetTableTabs[currentTabIndex].path !== tab) {
@ -161,6 +187,7 @@ const DatasetDetailsPage: FunctionComponent = () => {
datasetTableTabs[currentTabIndex].path
),
});
handleShowTestForm(false);
}
};
@ -252,6 +279,10 @@ const DatasetDetailsPage: FunctionComponent = () => {
},
]);
if (res.data.tableTests && res.data.tableTests.length > 0) {
setTableTestCase(res.data.tableTests);
}
addToRecentViewed({
entityType: EntityType.TABLE,
fqn: fullyQualifiedName,
@ -588,6 +619,113 @@ const DatasetDetailsPage: FunctionComponent = () => {
});
};
const handleAddTableTestCase = (data: CreateTableTest) => {
addTableTestCase(tableDetails.id, data)
.then((res: AxiosResponse) => {
const { tableTests } = res.data;
let itsNewTest = true;
const existingData = tableTestCase.map((test) => {
if (test.name === tableTests[0].name) {
itsNewTest = false;
return tableTests[0];
}
return test;
});
if (itsNewTest) {
existingData.push(tableTests[0]);
}
setTableTestCase(existingData);
handleShowTestForm(false);
})
.catch(() => {
showToast({
variant: 'error',
body: 'Something went wrong.',
});
});
};
const handleAddColumnTestCase = (data: CreateColumnTest) => {
addColumnTestCase(tableDetails.id, data)
.then((res: AxiosResponse) => {
const columnTestRes = res.data.columns.find(
(d: Column) => d.name === data.columnName
);
const updatedColumns = columns.map((d) => {
if (d.name === data.columnName) {
const oldTest =
(d as ModifiedTableColumn)?.columnTests?.filter(
(test) => test.id !== columnTestRes.columnTests[0].id
) || [];
return {
...d,
columnTests: [...oldTest, columnTestRes.columnTests[0]],
};
}
return d;
});
setColumns(updatedColumns);
handleShowTestForm(false);
})
.catch(() => {
showToast({
variant: 'error',
body: 'Something went wrong.',
});
});
};
const handleRemoveTableTest = (testType: TableTestType) => {
deleteTableTestCase(tableDetails.id, testType)
.then(() => {
const updatedTest = tableTestCase.filter(
(d) => d.testCase.tableTestType !== testType
);
setTableTestCase(updatedTest);
})
.catch(() => {
showToast({
variant: 'error',
body: 'Something went wrong.',
});
});
};
const handleRemoveColumnTest = (
columnName: string,
testType: ColumnTestType
) => {
deleteColumnTestCase(tableDetails.id, columnName, testType)
.then(() => {
const updatedColumns = columns.map((d) => {
if (d.name === columnName) {
const updatedTest =
(d as ModifiedTableColumn)?.columnTests?.filter(
(test) => test.testCase.columnTestType !== testType
) || [];
return {
...d,
columnTests: updatedTest,
};
}
return d;
});
setColumns(updatedColumns);
})
.catch(() => {
showToast({
variant: 'error',
body: 'Something went wrong.',
});
});
};
useEffect(() => {
fetchTableDetail();
setActiveTab(getCurrentDatasetTab(tab));
@ -632,6 +770,12 @@ const DatasetDetailsPage: FunctionComponent = () => {
feedCount={feedCount}
followTableHandler={followTable}
followers={followers}
handleAddColumnTestCase={handleAddColumnTestCase}
handleAddTableTestCase={handleAddTableTestCase}
handleRemoveColumnTest={handleRemoveColumnTest}
handleRemoveTableTest={handleRemoveTableTest}
handleShowTestForm={handleShowTestForm}
handleTestModeChange={handleTestModeChange}
isLineageLoading={isLineageLoading}
isNodeLoading={isNodeLoading}
isQueriesLoading={isTableQueriesLoading}
@ -646,11 +790,14 @@ const DatasetDetailsPage: FunctionComponent = () => {
sampleData={sampleData}
setActiveTabHandler={activeTabHandler}
settingsUpdateHandler={settingsUpdateHandler}
showTestForm={showTestForm}
slashedTableName={slashedTableName}
tableDetails={tableDetails}
tableProfile={tableProfile}
tableQueries={tableQueries}
tableTags={tableTags}
tableTestCase={tableTestCase}
testMode={testMode}
tier={tier as TagLabel}
unfollowTableHandler={unfollowTable}
usageSummary={usageSummary}

View File

@ -189,6 +189,12 @@ const TourPage = () => {
feedCount={0}
followTableHandler={handleCountChange}
followers={mockDatasetData.followers}
handleAddColumnTestCase={handleCountChange}
handleAddTableTestCase={handleCountChange}
handleRemoveColumnTest={handleCountChange}
handleRemoveTableTest={handleCountChange}
handleShowTestForm={handleCountChange}
handleTestModeChange={handleCountChange}
isNodeLoading={{
id: undefined,
state: false,
@ -203,6 +209,7 @@ const TourPage = () => {
sampleData={mockDatasetData.sampleData}
setActiveTabHandler={(tab) => setdatasetActiveTab(tab)}
settingsUpdateHandler={() => Promise.resolve()}
showTestForm={false}
slashedTableName={mockDatasetData.slashedTableName}
tableDetails={mockDatasetData.tableDetails as unknown as Table}
tableProfile={
@ -210,6 +217,8 @@ const TourPage = () => {
}
tableQueries={[]}
tableTags={mockDatasetData.tableTags}
tableTestCase={[]}
testMode="table"
tier={'' as unknown as TagLabel}
unfollowTableHandler={handleCountChange}
usageSummary={

View File

@ -15,7 +15,7 @@ import { TabSpecificField } from '../enums/entity.enum';
export const defaultFields = `${TabSpecificField.COLUMNS}, ${TabSpecificField.USAGE_SUMMARY},
${TabSpecificField.FOLLOWERS}, ${TabSpecificField.JOINS}, ${TabSpecificField.TAGS}, ${TabSpecificField.OWNER},
${TabSpecificField.DATAMODEL},${TabSpecificField.TABLE_PROFILE}`;
${TabSpecificField.DATAMODEL},${TabSpecificField.TABLE_PROFILE},${TabSpecificField.TESTS}`;
export const datasetTableTabs = [
{
@ -41,6 +41,10 @@ export const datasetTableTabs = [
name: 'Profiler',
path: 'profiler',
},
{
name: 'Data Quality',
path: 'data-quality',
},
{
name: 'Lineage',
path: 'lineage',
@ -77,21 +81,26 @@ export const getCurrentDatasetTab = (tab: string) => {
break;
case 'lineage':
case 'data-quality':
currentTab = 6;
break;
case 'dbt':
case 'lineage':
currentTab = 7;
break;
case 'manage':
case 'dbt':
currentTab = 8;
break;
case 'manage':
currentTab = 9;
break;
case 'schema':
default:
currentTab = 1;