feat: Certify Charts and Dashboards (#17335)

* Certify charts

* Format

* Certify dashboards

* Format

* Refactor card certification

* Clear details when certified by empty

* Show certification in detail page

* Add RTL tests

* Test charts api

* Enhance integration tests

* Lint

* Fix dashboards count

* Format

* Handle empty value

* Handle empty slice

* Downgrade migration

* Indent

* Use alter

* Fix revision

* Fix revision
This commit is contained in:
Geido
2021-11-24 13:42:52 +02:00
committed by GitHub
parent 9576478a5d
commit 83e49fc9ee
28 changed files with 589 additions and 60 deletions

View File

@@ -27,6 +27,8 @@ import PropertiesModal from '.';
const createProps = () => ({
slice: {
cache_timeout: null,
certified_by: 'John Doe',
certification_details: 'Sample certification',
changed_on: '2021-03-19T16:30:56.750230',
changed_on_humanized: '7 days ago',
datasource: 'FCC 2018 Survey',
@@ -87,6 +89,8 @@ fetchMock.get('http://localhost/api/v1/chart/318', {
},
result: {
cache_timeout: null,
certified_by: 'John Doe',
certification_details: 'Sample certification',
dashboards: [
{
dashboard_title: 'FCC New Coder Survey 2018',
@@ -145,6 +149,8 @@ fetchMock.put('http://localhost/api/v1/chart/318', {
id: 318,
result: {
cache_timeout: null,
certified_by: 'John Doe',
certification_details: 'Sample certification',
description: null,
owners: [],
slice_name: 'Age distribution of respondents',
@@ -211,7 +217,7 @@ test('Should render all elements inside modal', async () => {
const props = createProps();
render(<PropertiesModal {...props} />);
await waitFor(() => {
expect(screen.getAllByRole('textbox')).toHaveLength(3);
expect(screen.getAllByRole('textbox')).toHaveLength(5);
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'Basic information' }),
@@ -226,6 +232,12 @@ test('Should render all elements inside modal', async () => {
expect(screen.getByRole('heading', { name: 'Access' })).toBeVisible();
expect(screen.getByText('Owners')).toBeVisible();
expect(
screen.getByRole('heading', { name: 'Configuration' }),
).toBeVisible();
expect(screen.getByText('Certified by')).toBeVisible();
expect(screen.getByText('Certification details')).toBeVisible();
});
});
@@ -275,3 +287,19 @@ test('"Save" button should call only "onSave"', async () => {
expect(props.onHide).toBeCalledTimes(1);
});
});
test('Empty "Certified by" should clear "Certification details"', async () => {
const props = createProps();
const noCertifiedByProps = {
...props,
slice: {
...props.slice,
certified_by: '',
},
};
render(<PropertiesModal {...noCertifiedByProps} />);
expect(
screen.getByRole('textbox', { name: 'Certification details' }),
).toHaveValue('');
});

View File

@@ -18,14 +18,13 @@
*/
import React, { useMemo, useState, useCallback, useEffect } from 'react';
import Modal from 'src/components/Modal';
import { Row, Col, Input, TextArea } from 'src/common/components';
import { Form, Row, Col, Input, TextArea } from 'src/common/components';
import Button from 'src/components/Button';
import { Select } from 'src/components';
import { SelectValue } from 'antd/lib/select';
import rison from 'rison';
import { t, SupersetClient } from '@superset-ui/core';
import { t, SupersetClient, styled } from '@superset-ui/core';
import Chart, { Slice } from 'src/types/Chart';
import { Form, FormItem } from 'src/components/Form';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
type PropertiesModalProps = {
@@ -37,6 +36,16 @@ type PropertiesModalProps = {
existingOwners?: SelectValue;
};
const FormItem = Form.Item;
const StyledFormItem = styled(Form.Item)`
margin-bottom: 0;
`;
const StyledHelpBlock = styled.span`
margin-bottom: 0;
`;
export default function PropertiesModal({
slice,
onHide,
@@ -44,14 +53,11 @@ export default function PropertiesModal({
show,
}: PropertiesModalProps) {
const [submitting, setSubmitting] = useState(false);
const [selectedOwners, setSelectedOwners] = useState<SelectValue | null>(
null,
);
const [form] = Form.useForm();
// values of form inputs
const [name, setName] = useState(slice.slice_name || '');
const [description, setDescription] = useState(slice.description || '');
const [cacheTimeout, setCacheTimeout] = useState(
slice.cache_timeout != null ? slice.cache_timeout : '',
const [selectedOwners, setSelectedOwners] = useState<SelectValue | null>(
null,
);
function showError({ error, statusText, message }: any) {
@@ -110,14 +116,26 @@ export default function PropertiesModal({
[],
);
const onSubmit = async (event: React.FormEvent) => {
event.stopPropagation();
event.preventDefault();
const onSubmit = async (values: {
certified_by?: string;
certification_details?: string;
description?: string;
cache_timeout?: number;
}) => {
setSubmitting(true);
const {
certified_by: certifiedBy,
certification_details: certificationDetails,
description,
cache_timeout: cacheTimeout,
} = values;
const payload: { [key: string]: any } = {
slice_name: name || null,
description: description || null,
cache_timeout: cacheTimeout || null,
certified_by: certifiedBy || null,
certification_details:
certifiedBy && certificationDetails ? certificationDetails : null,
};
if (selectedOwners) {
payload.owners = (
@@ -177,11 +195,10 @@ export default function PropertiesModal({
</Button>
<Button
data-test="properties-modal-save-button"
htmlType="button"
htmlType="submit"
buttonSize="small"
buttonStyle="primary"
// @ts-ignore
onClick={onSubmit}
onClick={form.submit}
disabled={submitting || !name}
cta
>
@@ -192,7 +209,21 @@ export default function PropertiesModal({
responsive
wrapProps={{ 'data-test': 'properties-edit-modal' }}
>
<Form onFinish={onSubmit} layout="vertical">
<Form
form={form}
onFinish={onSubmit}
layout="vertical"
initialValues={{
name: slice.slice_name || '',
description: slice.description || '',
cache_timeout: slice.cache_timeout != null ? slice.cache_timeout : '',
certified_by: slice.certified_by || '',
certification_details:
slice.certified_by && slice.certification_details
? slice.certification_details
: '',
}}
>
<Row gutter={16}>
<Col xs={24} md={12}>
<h3>{t('Basic information')}</h3>
@@ -208,40 +239,50 @@ export default function PropertiesModal({
}
/>
</FormItem>
<FormItem label={t('Description')}>
<TextArea
rows={3}
name="description"
value={description}
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) =>
setDescription(event.target.value ?? '')
}
style={{ maxWidth: '100%' }}
/>
<p className="help-block">
<FormItem>
<StyledFormItem label={t('Description')} name="description">
<TextArea rows={3} style={{ maxWidth: '100%' }} />
</StyledFormItem>
<StyledHelpBlock className="help-block">
{t(
'The description can be displayed as widget headers in the dashboard view. Supports markdown.',
)}
</p>
</StyledHelpBlock>
</FormItem>
<h3>{t('Certification')}</h3>
<FormItem>
<StyledFormItem label={t('Certified by')} name="certified_by">
<Input />
</StyledFormItem>
<StyledHelpBlock className="help-block">
{t('Person or group that has certified this chart.')}
</StyledHelpBlock>
</FormItem>
<FormItem>
<StyledFormItem
label={t('Certification details')}
name="certification_details"
>
<Input />
</StyledFormItem>
<StyledHelpBlock className="help-block">
{t(
'Any additional detail to show in the certification tooltip.',
)}
</StyledHelpBlock>
</FormItem>
</Col>
<Col xs={24} md={12}>
<h3>{t('Configuration')}</h3>
<FormItem label={t('Cache timeout')}>
<Input
name="cacheTimeout"
type="text"
value={cacheTimeout}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const targetValue = event.target.value ?? '';
setCacheTimeout(targetValue.replace(/[^0-9]/, ''));
}}
/>
<p className="help-block">
<FormItem>
<StyledFormItem label={t('Cache timeout')} name="cacheTimeout">
<Input />
</StyledFormItem>
<StyledHelpBlock className="help-block">
{t(
"Duration (in seconds) of the caching timeout for this chart. Note this defaults to the dataset's timeout if undefined.",
)}
</p>
</StyledHelpBlock>
</FormItem>
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
<FormItem label={ownersLabel}>
@@ -255,11 +296,11 @@ export default function PropertiesModal({
disabled={!selectedOwners}
allowClear
/>
<p className="help-block">
<StyledHelpBlock className="help-block">
{t(
'A list of users who can alter the chart. Searchable by name or username.',
)}
</p>
</StyledHelpBlock>
</FormItem>
</Col>
</Row>