Skip to content

feat: add bulk certify functionality to charts and dashboards #33002

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information. The ASF licenses this file
* to you 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 { render, screen, fireEvent } from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import { Chart } from 'src/types/Chart';
import { Dashboard } from 'src/pages/DashboardList';
import BulkCertifyModal from './BulkCertifyModal';

const mockedChartProps = {
onHide: jest.fn() as jest.Mock<any, any>,
refreshData: jest.fn() as jest.Mock<any, any>,
addSuccessToast: jest.fn() as jest.Mock<any, any>,
addDangerToast: jest.fn() as jest.Mock<any, any>,
show: true,
selected: [
{
id: 1,
slice_name: 'Chart 1',
url: '/chart/1',
viz_type: 'table',
creator: 'user',
changed_on: 'now',
},
{
id: 2,
slice_name: 'Chart 2',
url: '/chart/2',
viz_type: 'line',
creator: 'another_user',
changed_on: 'then',
},
] as Chart[],
resourceName: 'chart' as 'chart' | 'dashboard',
resourceLabel: 'chart',
};

const mockedDashboardProps = {
onHide: jest.fn() as Mock<any, any, any>,
refreshData: jest.fn() as Mock<any, any, any>,
addSuccessToast: jest.fn() as Mock<any, any, any>,
addDangerToast: jest.fn() as Mock<any, any, any>,
show: true,
selected: [
{
id: 1,
dashboard_title: 'Dashboard 1',
url: '/dashboard/1',
published: true,
changed_by_name: 'user',
changed_on_delta_humanized: 'a while ago',
changed_by: 'user',
},
{
id: 2,
dashboard_title: 'Dashboard 2',
url: '/dashboard/2',
published: false,
changed_by_name: 'admin',
changed_on_delta_humanized: 'recently',
changed_by: 'admin',
},
] as Dashboard[],
resourceName: 'dashboard' as 'chart' | 'dashboard',
resourceLabel: 'dashboard',
};

describe('BulkCertifyModal', () => {
afterEach(() => {
fetchMock.reset();
jest.clearAllMocks();
});

describe('when resourceName is chart', () => {
test('should render', () => {
const { container } = render(<BulkCertifyModal {...mockedChartProps} />);
expect(container).toBeInTheDocument();
});

test('renders the correct title and message for charts', () => {
render(<BulkCertifyModal {...mockedChartProps} />);
expect(
screen.getByText(/you are certifying 2 charts/i),
).toBeInTheDocument();
expect(screen.getByText(/bulk certify charts/i)).toBeInTheDocument();
});
});

describe('when resourceName is dashboard', () => {
test('should render', () => {
const { container } = render(
<BulkCertifyModal {...mockedDashboardProps} />,
);
expect(container).toBeInTheDocument();
});

test('renders the correct title and message for dashboards', () => {
render(<BulkCertifyModal {...mockedDashboardProps} />);
expect(
screen.getByText(/you are certifying 2 dashboards/i),
).toBeInTheDocument();
expect(screen.getByText(/bulk certify dashboards/i)).toBeInTheDocument();
});
});

test('calls onHide when the Cancel button is clicked', () => {
render(<BulkCertifyModal {...mockedChartProps} />);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockedChartProps.onHide).toHaveBeenCalled();
});

describe('rendering', () => {
describe('when resourceName is chart', () => {
test('should render', () => {
const { container } = render(
<BulkCertifyModal {...mockedChartProps} />,
);
expect(container).toBeInTheDocument();
});

test('renders the correct title and message for charts', () => {
render(<BulkCertifyModal {...mockedChartProps} />);
expect(
screen.getByText(/you are certifying 2 charts/i),
).toBeInTheDocument();
expect(screen.getByText(/bulk certify charts/i)).toBeInTheDocument();
});
});

describe('when resourceName is dashboard', () => {
test('should render', () => {
const { container } = render(
<BulkCertifyModal {...mockedDashboardProps} />,
);
expect(container).toBeInTheDocument();
});

test('renders the correct title and message for dashboards', () => {
render(<BulkCertifyModal {...mockedDashboardProps} />);
expect(
screen.getByText(/you are certifying 2 dashboards/i),
).toBeInTheDocument();
expect(
screen.getByText(/bulk certify dashboards/i),
).toBeInTheDocument();
});
});
});
});
159 changes: 159 additions & 0 deletions superset-frontend/src/features/bulkCertifyModal/BulkCertifyModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 { useState, useEffect, FC } from 'react';

import { t, SupersetClient } from '@superset-ui/core';
import { FormLabel } from 'src/components/Form';
import Modal from 'src/components/Modal';
import { Input } from 'src/components/Input';
import Button from 'src/components/Button';

import Chart from 'src/types/Chart';
import { Dashboard } from 'src/pages/DashboardList';
import { Row, Col } from 'src/components';

interface BulkCertifyModalProps {
onHide: () => void;
refreshData: () => void;
addSuccessToast: (msg: string) => void;
addDangerToast: (msg: string) => void;
show: boolean;
resourceName: 'chart' | 'dashboard';
resourceLabel: string;
selected: Chart[] | Dashboard[];
}

const BulkCertifyModal: FC<BulkCertifyModalProps> = ({
show,
selected = [],
resourceName,
resourceLabel,
onHide,
refreshData,
addSuccessToast,
addDangerToast,
}) => {
useEffect(() => {}, []);
const [certifiedBy, setCertifiedBy] = useState<string>('');
const [certificationDetails, setCertificationDetails] = useState<string>('');

const resourceLabelPlural = resourceLabel + (selected.length > 1 ? 's' : '');

const onSave = async () => {
if (!certifiedBy) {
addDangerToast(t('Please enter who certified these items'));
return;
}

Promise.all(
selected.map(item => {
const url = `/api/v1/${resourceName}/${item.id}`;
const payload = {
certified_by: certifiedBy,
certification_details: certificationDetails,
};

return SupersetClient.put({
url,
headers: { 'Content-Type': 'application/json' },
jsonPayload: payload,
});
}),
)
.then(() => {
addSuccessToast(t('Successfully certified %s', resourceLabelPlural));
})
.catch(() => {
addDangerToast(t('Failed to certify %s', resourceLabelPlural));
});

refreshData();
onHide();
setCertifiedBy('');
setCertificationDetails('');
};

return (
<Modal
title={<h4>{t('Bulk certify %s', resourceLabelPlural)}</h4>}
show={show}
onHide={() => {
setCertifiedBy('');
setCertificationDetails('');
onHide();
}}
footer={
<div>
<Button
data-test="modal-cancel-certify-button"
buttonStyle="secondary"
onClick={onHide}
>
{t('Cancel')}
</Button>
<Button
data-test="modal-save-certify-button"
buttonStyle="primary"
onClick={onSave}
disabled={!certifiedBy}
>
{t('Certify')}
</Button>
</div>
}
>
<Row gutter={16}>
<Col xs={24} md={12}>
<div className="bulk-certify-text">
{t(
'You are certifying %s %s',
selected.length,
resourceLabelPlural,
)}
</div>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<FormLabel>{t('Certified by')}</FormLabel>
<Input
value={certifiedBy}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setCertifiedBy(event.target.value)
}
placeholder={t('e.g., Data Governance Team')}
/>
</Col>
<Col xs={24} md={12}>
<FormLabel>{t('Certification details')} (optional)</FormLabel>
<Input.TextArea
rows={1}
value={certificationDetails}
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) =>
setCertificationDetails(event.target.value)
}
placeholder={t('Optional details about the certification')}
/>
</Col>
</Row>
</Modal>
);
};

export default BulkCertifyModal;
34 changes: 33 additions & 1 deletion superset-frontend/src/pages/ChartList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import { findPermission } from 'src/utils/findPermission';
import { DashboardCrossLinks } from 'src/components/ListView/DashboardCrossLinks';
import { ModifiedInfo } from 'src/components/AuditInfo';
import { QueryObjectColumns } from 'src/views/CRUD/types';
import BulkCertifyModal from 'src/features/bulkCertify/BulkCertifyModal';

const FlexRowContainer = styled.div`
align-items: center;
Expand Down Expand Up @@ -213,6 +214,10 @@ function ChartList(props: ChartListProps) {
sshTunnelPrivateKeyPasswordFields,
setSSHTunnelPrivateKeyPasswordFields,
] = useState<string[]>([]);
const [showBulkCertifyModal, setShowBulkCertifyModal] = useState(false);
const [selectedChartsForCert, setSelectedChartsForCert] = useState<Chart[]>(
[],
);

// TODO: Fix usage of localStorage keying on the user id
const userSettings = dangerouslyGetItemDoNotUse(userId?.toString(), null) as {
Expand All @@ -233,6 +238,16 @@ function ChartList(props: ChartListProps) {
addSuccessToast(t('Chart imported'));
};

const openBulkCertifyModal = (selected: Chart[]) => {
setSelectedChartsForCert(selected);
setShowBulkCertifyModal(true);
};

const closeBulkCertifyModal = () => {
setShowBulkCertifyModal(false);
setSelectedChartsForCert([]);
};

const canCreate = hasPerm('can_write');
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
Expand Down Expand Up @@ -830,6 +845,14 @@ function ChartList(props: ChartListProps) {
onSelect: handleBulkChartExport,
});
}
if (canEdit) {
bulkActions.push({
key: 'certify',
name: t('Certify'),
type: 'primary',
onSelect: openBulkCertifyModal,
});
}
return (
<ListView<Chart>
bulkActions={bulkActions}
Expand Down Expand Up @@ -865,7 +888,16 @@ function ChartList(props: ChartListProps) {
);
}}
</ConfirmStatusChange>

<BulkCertifyModal
show={showBulkCertifyModal}
onHide={closeBulkCertifyModal}
selected={selectedChartsForCert}
resourceName="chart"
resourceLabel={t('chart')}
refreshData={refreshData}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
/>
<ImportModelsModal
resourceName="chart"
resourceLabel={t('chart')}
Expand Down
Loading
Loading