diff --git a/superset-frontend/cypress-base/cypress/e2e/chart_list/list.test.ts b/superset-frontend/cypress-base/cypress/e2e/chart_list/list.test.ts index cd99250a9e5b..06bb91926563 100644 --- a/superset-frontend/cypress-base/cypress/e2e/chart_list/list.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/chart_list/list.test.ts @@ -126,10 +126,11 @@ describe('Charts list', () => { cy.get('[aria-label="checkbox-on"]').should('have.length', 26); cy.getBySel('bulk-select-copy').contains('25 Selected'); cy.getBySel('bulk-select-action') - .should('have.length', 2) + .should('have.length', 3) .then($btns => { expect($btns).to.contain('Delete'); expect($btns).to.contain('Export'); + expect($btns).to.contain('Certify'); }); cy.getBySel('bulk-select-deselect-all').click(); cy.get('[aria-label="checkbox-on"]').should('have.length', 0); @@ -154,10 +155,11 @@ describe('Charts list', () => { cy.getBySel('styled-card').click({ multiple: true }); cy.getBySel('bulk-select-copy').contains('25 Selected'); cy.getBySel('bulk-select-action') - .should('have.length', 2) + .should('have.length', 3) .then($btns => { expect($btns).to.contain('Delete'); expect($btns).to.contain('Export'); + expect($btns).to.contain('Certify'); }); cy.getBySel('bulk-select-deselect-all').click(); cy.getBySel('bulk-select-copy').contains('0 Selected'); diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard_list/list.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard_list/list.test.ts index 322306a4c64e..d30170891170 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard_list/list.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard_list/list.test.ts @@ -91,10 +91,11 @@ describe('Dashboards list', () => { cy.get('[aria-label="checkbox-on"]').should('have.length', 6); cy.getBySel('bulk-select-copy').contains('5 Selected'); cy.getBySel('bulk-select-action') - .should('have.length', 2) + .should('have.length', 3) .then($btns => { expect($btns).to.contain('Delete'); expect($btns).to.contain('Export'); + expect($btns).to.contain('Certify'); }); cy.getBySel('bulk-select-deselect-all').click(); cy.get('[aria-label="checkbox-on"]').should('have.length', 0); @@ -119,10 +120,11 @@ describe('Dashboards list', () => { cy.getBySel('styled-card').click({ multiple: true }); cy.getBySel('bulk-select-copy').contains('5 Selected'); cy.getBySel('bulk-select-action') - .should('have.length', 2) + .should('have.length', 3) .then($btns => { expect($btns).to.contain('Delete'); expect($btns).to.contain('Export'); + expect($btns).to.contain('Certify'); }); cy.getBySel('bulk-select-deselect-all').click(); cy.getBySel('bulk-select-copy').contains('0 Selected'); diff --git a/superset-frontend/src/features/bulkUpdate/BulkCertifyModal.test.tsx b/superset-frontend/src/features/bulkUpdate/BulkCertifyModal.test.tsx new file mode 100644 index 000000000000..1b0faab9f60d --- /dev/null +++ b/superset-frontend/src/features/bulkUpdate/BulkCertifyModal.test.tsx @@ -0,0 +1,171 @@ +/** + * 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(), + refreshData: jest.fn(), + addSuccessToast: jest.fn(), + addDangerToast: jest.fn(), + 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(), + refreshData: jest.fn(), + addSuccessToast: jest.fn(), + addDangerToast: jest.fn(), + 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(content => + content.startsWith('You are certifying 2 charts'), + ), + ).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(content => + content.startsWith('You are certifying 2 dashboards'), + ), + ).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(content => + content.startsWith('You are certifying 2 charts'), + ), + ).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(content => + content.startsWith('You are certifying 2 dashboards'), + ), + ).toBeInTheDocument(); + expect( + screen.getByText(/bulk certify dashboards/i), + ).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/superset-frontend/src/features/bulkUpdate/BulkCertifyModal.tsx b/superset-frontend/src/features/bulkUpdate/BulkCertifyModal.tsx new file mode 100644 index 000000000000..16b619cfb896 --- /dev/null +++ b/superset-frontend/src/features/bulkUpdate/BulkCertifyModal.tsx @@ -0,0 +1,158 @@ +/** + * 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 { useState, 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, +}) => { + const [certifiedBy, setCertifiedBy] = useState<string>(''); + const [certificationDetails, setCertificationDetails] = useState<string>(''); + + const resourceLabelPlural = t( + '%s', + selected.length > 1 ? `${resourceLabel}s` : resourceLabel, + ); + + 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; diff --git a/superset-frontend/src/pages/ChartList/index.tsx b/superset-frontend/src/pages/ChartList/index.tsx index 197e0a425f24..710ded444d7a 100644 --- a/superset-frontend/src/pages/ChartList/index.tsx +++ b/superset-frontend/src/pages/ChartList/index.tsx @@ -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/bulkUpdate/BulkCertifyModal'; const FlexRowContainer = styled.div` align-items: center; @@ -213,6 +214,8 @@ function ChartList(props: ChartListProps) { sshTunnelPrivateKeyPasswordFields, setSSHTunnelPrivateKeyPasswordFields, ] = useState<string[]>([]); + const [showBulkCertifyModal, setShowBulkCertifyModal] = useState(false); + const [bulkSelected, setBulkSelected] = useState<Chart[]>([]); // TODO: Fix usage of localStorage keying on the user id const userSettings = dangerouslyGetItemDoNotUse(userId?.toString(), null) as { @@ -233,6 +236,16 @@ function ChartList(props: ChartListProps) { addSuccessToast(t('Chart imported')); }; + const openBulkCertifyModal = (selected: Chart[]) => { + setBulkSelected(selected); + setShowBulkCertifyModal(true); + }; + + const closeBulkCertifyModal = () => { + setShowBulkCertifyModal(false); + setBulkSelected([]); + }; + const canCreate = hasPerm('can_write'); const canEdit = hasPerm('can_write'); const canDelete = hasPerm('can_write'); @@ -830,6 +843,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} @@ -865,7 +886,16 @@ function ChartList(props: ChartListProps) { ); }} </ConfirmStatusChange> - + <BulkCertifyModal + show={showBulkCertifyModal} + onHide={closeBulkCertifyModal} + selected={bulkSelected} + resourceName="chart" + resourceLabel={t('chart')} + refreshData={refreshData} + addSuccessToast={addSuccessToast} + addDangerToast={addDangerToast} + /> <ImportModelsModal resourceName="chart" resourceLabel={t('chart')} diff --git a/superset-frontend/src/pages/DashboardList/index.tsx b/superset-frontend/src/pages/DashboardList/index.tsx index a0c9e624df12..c5c729c94e3a 100644 --- a/superset-frontend/src/pages/DashboardList/index.tsx +++ b/superset-frontend/src/pages/DashboardList/index.tsx @@ -71,6 +71,7 @@ import { DashboardStatus } from 'src/features/dashboards/types'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import { findPermission } from 'src/utils/findPermission'; import { ModifiedInfo } from 'src/components/AuditInfo'; +import BulkCertifyModal from 'src/features/bulkUpdate/BulkCertifyModal'; import { navigateTo } from 'src/utils/navigationUtils'; const PAGE_SIZE = 25; @@ -195,6 +196,8 @@ function DashboardList(props: DashboardListProps) { sshTunnelPrivateKeyPasswordFields, setSSHTunnelPrivateKeyPasswordFields, ] = useState<string[]>([]); + const [showBulkCertifyModal, setShowBulkCertifyModal] = useState(false); + const [bulkSelected, setBulkSelected] = useState<Dashboard[]>([]); const openDashboardImportModal = () => { showImportModal(true); @@ -210,6 +213,16 @@ function DashboardList(props: DashboardListProps) { addSuccessToast(t('Dashboard imported')); }; + const openBulkCertifyModal = (selected: Dashboard[]) => { + setBulkSelected(selected); + setShowBulkCertifyModal(true); + }; + + const closeBulkCertifyModal = () => { + setShowBulkCertifyModal(false); + setBulkSelected([]); + }; + // TODO: Fix usage of localStorage keying on the user id const userKey = dangerouslyGetItemDoNotUse(user?.userId?.toString(), null); @@ -745,6 +758,14 @@ function DashboardList(props: DashboardListProps) { onSelect: handleBulkDashboardExport, }); } + if (canEdit) { + bulkActions.push({ + key: 'certify', + name: t('Certify'), + type: 'primary', + onSelect: openBulkCertifyModal, + }); + } return ( <> {dashboardToEdit && ( @@ -814,7 +835,16 @@ function DashboardList(props: DashboardListProps) { ); }} </ConfirmStatusChange> - + <BulkCertifyModal + show={showBulkCertifyModal} + onHide={closeBulkCertifyModal} + selected={bulkSelected} + resourceName="dashboard" + resourceLabel={t('dashboard')} + refreshData={refreshData} + addSuccessToast={addSuccessToast} + addDangerToast={addDangerToast} + /> <ImportModelsModal resourceName="dashboard" resourceLabel={t('dashboard')}