Skip to content

Commit

Permalink
feat: Virtual dataset duplication (apache#20309)
Browse files Browse the repository at this point in the history
* Inital duplicate functionality

* Fix formatting

* Create dedicated duplicate API

* Make use of new API

* Make use of new api permissions

* Add integration tests for duplicating datasets

* Add licenses

* Fix linting errors

* Change confirm button to 'Duplicate'

* Fix HTTP status code and response

* Add missing import

* Use user id instead of user object

* Remove stray debug print

* Fix sqlite tests

* Specify type of extra

* Add frontend tests

* Add match statement to test
  • Loading branch information
reesercollins authored Aug 26, 2022
1 parent f09c432 commit 16032ed
Show file tree
Hide file tree
Showing 9 changed files with 1,567 additions and 217 deletions.
1,277 changes: 1,067 additions & 210 deletions docs/static/resources/openapi.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const store = mockStore({});
const datasetsInfoEndpoint = 'glob:*/api/v1/dataset/_info*';
const datasetsOwnersEndpoint = 'glob:*/api/v1/dataset/related/owners*';
const datasetsSchemaEndpoint = 'glob:*/api/v1/dataset/distinct/schema*';
const datasetsDuplicateEndpoint = 'glob:*/api/v1/dataset/duplicate*';
const databaseEndpoint = 'glob:*/api/v1/dataset/related/database*';
const datasetsEndpoint = 'glob:*/api/v1/dataset/?*';

Expand All @@ -63,14 +64,17 @@ const mockUser = {
};

fetchMock.get(datasetsInfoEndpoint, {
permissions: ['can_read', 'can_write'],
permissions: ['can_read', 'can_write', 'can_duplicate'],
});
fetchMock.get(datasetsOwnersEndpoint, {
result: [],
});
fetchMock.get(datasetsSchemaEndpoint, {
result: [],
});
fetchMock.post(datasetsDuplicateEndpoint, {
result: [],
});
fetchMock.get(datasetsEndpoint, {
result: mockdatasets,
dataset_count: 3,
Expand Down Expand Up @@ -181,6 +185,44 @@ describe('DatasetList', () => {
wrapper.find('[data-test="bulk-select-copy"]').text(),
).toMatchInlineSnapshot(`"3 Selected (2 Physical, 1 Virtual)"`);
});

it('shows duplicate modal when duplicate action is clicked', async () => {
await waitForComponentToPaint(wrapper);
expect(
wrapper.find('[data-test="duplicate-modal-input"]').exists(),
).toBeFalsy();
act(() => {
wrapper
.find('#duplicate-action-tooltop')
.at(0)
.find('.action-button')
.props()
.onClick();
});
await waitForComponentToPaint(wrapper);
expect(
wrapper.find('[data-test="duplicate-modal-input"]').exists(),
).toBeTruthy();
});

it('calls the duplicate endpoint', async () => {
await waitForComponentToPaint(wrapper);
await act(async () => {
wrapper
.find('#duplicate-action-tooltop')
.at(0)
.find('.action-button')
.props()
.onClick();
await waitForComponentToPaint(wrapper);
wrapper
.find('[data-test="duplicate-modal-input"]')
.at(0)
.props()
.onPressEnter();
});
expect(fetchMock.calls(/dataset\/duplicate/)).toHaveLength(1);
});
});

jest.mock('react-router-dom', () => ({
Expand Down
70 changes: 67 additions & 3 deletions superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
PASSWORDS_NEEDED_MESSAGE,
CONFIRM_OVERWRITE_MESSAGE,
} from './constants';
import DuplicateDatasetModal from './DuplicateDatasetModal';

const FlexRowContainer = styled.div`
align-items: center;
Expand Down Expand Up @@ -119,6 +120,11 @@ type Dataset = {
table_name: string;
};

interface VirtualDataset extends Dataset {
extra: Record<string, any>;
sql: string;
}

interface DatasetListProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
Expand Down Expand Up @@ -157,6 +163,9 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const [datasetCurrentlyEditing, setDatasetCurrentlyEditing] =
useState<Dataset | null>(null);

const [datasetCurrentlyDuplicating, setDatasetCurrentlyDuplicating] =
useState<VirtualDataset | null>(null);

const [importingDataset, showImportModal] = useState<boolean>(false);
const [passwordFields, setPasswordFields] = useState<string[]>([]);
const [preparingExport, setPreparingExport] = useState<boolean>(false);
Expand All @@ -178,6 +187,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const canEdit = hasPerm('can_write');
const canDelete = hasPerm('can_write');
const canCreate = hasPerm('can_write');
const canDuplicate = hasPerm('can_duplicate');
const canExport =
hasPerm('can_export') && isFeatureEnabled(FeatureFlag.VERSIONED_EXPORT);

Expand Down Expand Up @@ -241,6 +251,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
),
);

const openDatasetDuplicateModal = (dataset: VirtualDataset) => {
setDatasetCurrentlyDuplicating(dataset);
};

const handleBulkDatasetExport = (datasetsToExport: Dataset[]) => {
const ids = datasetsToExport.map(({ id }) => id);
handleResourceExport('dataset', ids, () => {
Expand Down Expand Up @@ -397,7 +411,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const handleEdit = () => openDatasetEditModal(original);
const handleDelete = () => openDatasetDeleteModal(original);
const handleExport = () => handleBulkDatasetExport([original]);
if (!canEdit && !canDelete && !canExport) {
const handleDuplicate = () => openDatasetDuplicateModal(original);
if (!canEdit && !canDelete && !canExport && !canDuplicate) {
return null;
}
return (
Expand Down Expand Up @@ -456,16 +471,32 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
</span>
</Tooltip>
)}
{canDuplicate && original.kind === 'virtual' && (
<Tooltip
id="duplicate-action-tooltop"
title={t('Duplicate')}
placement="bottom"
>
<span
role="button"
tabIndex={0}
className="action-button"
onClick={handleDuplicate}
>
<Icons.Copy />
</span>
</Tooltip>
)}
</Actions>
);
},
Header: t('Actions'),
id: 'actions',
hidden: !canEdit && !canDelete,
hidden: !canEdit && !canDelete && !canDuplicate,
disableSortBy: true,
},
],
[canEdit, canDelete, canExport, openDatasetEditModal],
[canEdit, canDelete, canExport, openDatasetEditModal, canDuplicate],
);

const filterTypes: Filters = useMemo(
Expand Down Expand Up @@ -625,6 +656,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
setDatasetCurrentlyEditing(null);
};

const closeDatasetDuplicateModal = () => {
setDatasetCurrentlyDuplicating(null);
};

const handleDatasetDelete = ({ id, table_name: tableName }: Dataset) => {
SupersetClient.delete({
endpoint: `/api/v1/dataset/${id}`,
Expand Down Expand Up @@ -660,6 +695,30 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
);
};

const handleDatasetDuplicate = (newDatasetName: string) => {
if (datasetCurrentlyDuplicating === null) {
addDangerToast(t('There was an issue duplicating the dataset.'));
}

SupersetClient.post({
endpoint: `/api/v1/dataset/duplicate`,
postPayload: {
base_model_id: datasetCurrentlyDuplicating?.id,
table_name: newDatasetName,
},
}).then(
() => {
setDatasetCurrentlyDuplicating(null);
refreshData();
},
createErrorHandler(errMsg =>
addDangerToast(
t('There was an issue duplicating the selected datasets: %s', errMsg),
),
),
);
};

return (
<>
<SubMenu {...menuData} />
Expand Down Expand Up @@ -694,6 +753,11 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
show
/>
)}
<DuplicateDatasetModal
dataset={datasetCurrentlyDuplicating}
onHide={closeDatasetDuplicateModal}
onDuplicate={handleDatasetDuplicate}
/>
<ConfirmStatusChange
title={t('Please confirm')}
description={t(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* 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 { t } from '@superset-ui/core';
import React, { FunctionComponent, useEffect, useState } from 'react';
import { FormLabel } from 'src/components/Form';
import { Input } from 'src/components/Input';
import Modal from 'src/components/Modal';
import Dataset from 'src/types/Dataset';

interface DuplicateDatasetModalProps {
dataset: Dataset | null;
onHide: () => void;
onDuplicate: (newDatasetName: string) => void;
}

const DuplicateDatasetModal: FunctionComponent<DuplicateDatasetModalProps> = ({
dataset,
onHide,
onDuplicate,
}) => {
const [show, setShow] = useState<boolean>(false);
const [disableSave, setDisableSave] = useState<boolean>(false);
const [newDuplicateDatasetName, setNewDuplicateDatasetName] =
useState<string>('');

const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const targetValue = event.target.value ?? '';
setNewDuplicateDatasetName(targetValue);
setDisableSave(targetValue === '');
};

const duplicateDataset = () => {
onDuplicate(newDuplicateDatasetName);
};

useEffect(() => {
setNewDuplicateDatasetName('');
setShow(dataset !== null);
}, [dataset]);

return (
<Modal
show={show}
onHide={onHide}
title={t('Duplicate dataset')}
disablePrimaryButton={disableSave}
onHandledPrimaryAction={duplicateDataset}
primaryButtonName={t('Duplicate')}
>
<FormLabel htmlFor="duplicate">{t('New dataset name')}</FormLabel>
<Input
data-test="duplicate-modal-input"
type="text"
id="duplicate"
autoComplete="off"
value={newDuplicateDatasetName}
onChange={onChange}
onPressEnter={duplicateDataset}
/>
</Modal>
);
};

export default DuplicateDatasetModal;
Loading

0 comments on commit 16032ed

Please sign in to comment.