diff --git a/superset-frontend/src/components/ImportModal/ImportModal.test.tsx b/superset-frontend/src/components/ImportModal/ImportModal.test.tsx index 718d4d63d70..ebae34c9c62 100644 --- a/superset-frontend/src/components/ImportModal/ImportModal.test.tsx +++ b/superset-frontend/src/components/ImportModal/ImportModal.test.tsx @@ -34,6 +34,7 @@ const requiredProps = { resourceLabel: 'database', icon: , passwordsNeededMessage: 'Passwords are needed', + confirmOverwriteMessage: 'Database exists', addDangerToast: () => {}, addSuccessToast: () => {}, onModelImport: () => {}, diff --git a/superset-frontend/src/components/ImportModal/index.tsx b/superset-frontend/src/components/ImportModal/index.tsx index 5b2d1ac8cf7..1de3f75bb7d 100644 --- a/superset-frontend/src/components/ImportModal/index.tsx +++ b/superset-frontend/src/components/ImportModal/index.tsx @@ -102,6 +102,7 @@ export interface ImportModelsModalProps { resourceLabel: string; icon: React.ReactNode; passwordsNeededMessage: string; + confirmOverwriteMessage: string; addDangerToast: (msg: string) => void; addSuccessToast: (msg: string) => void; onModelImport: () => void; @@ -116,6 +117,7 @@ const ImportModelsModal: FunctionComponent = ({ resourceLabel, icon, passwordsNeededMessage, + confirmOverwriteMessage, addDangerToast, addSuccessToast, onModelImport, @@ -124,9 +126,14 @@ const ImportModelsModal: FunctionComponent = ({ passwordFields = [], setPasswordFields = () => {}, }) => { - const [uploadFile, setUploadFile] = useState(null); const [isHidden, setIsHidden] = useState(true); + const [uploadFile, setUploadFile] = useState(null); const [passwords, setPasswords] = useState>({}); + const [needsOverwriteConfirm, setNeedsOverwriteConfirm] = useState( + false, + ); + const [confirmedOverwrite, setConfirmedOverwrite] = useState(false); + const fileInputRef = useRef(null); const clearModal = () => { @@ -144,7 +151,7 @@ const ImportModelsModal: FunctionComponent = ({ }; const { - state: { passwordsNeeded }, + state: { alreadyExists, passwordsNeeded }, importResource, } = useImportResource(resourceName, resourceLabel, handleErrorMsg); @@ -152,6 +159,10 @@ const ImportModelsModal: FunctionComponent = ({ setPasswordFields(passwordsNeeded); }, [passwordsNeeded, setPasswordFields]); + useEffect(() => { + setNeedsOverwriteConfirm(alreadyExists.length > 0); + }, [alreadyExists]); + // Functions const hide = () => { setIsHidden(true); @@ -163,7 +174,7 @@ const ImportModelsModal: FunctionComponent = ({ return; } - importResource(uploadFile, passwords).then(result => { + importResource(uploadFile, passwords, confirmedOverwrite).then(result => { if (result) { addSuccessToast(t('The import was successful')); clearModal(); @@ -177,6 +188,11 @@ const ImportModelsModal: FunctionComponent = ({ setUploadFile((files && files[0]) || null); }; + const confirmOverwrite = (event: React.ChangeEvent) => { + const targetValue = (event.currentTarget?.value as string) ?? ''; + setConfirmedOverwrite(targetValue.toUpperCase() === t('OVERWRITE')); + }; + const renderPasswordFields = () => { if (passwordFields.length === 0) { return null; @@ -209,6 +225,31 @@ const ImportModelsModal: FunctionComponent = ({ ); }; + const renderOverwriteConfirmation = () => { + if (alreadyExists.length === 0) { + return null; + } + + return ( + <> + + {confirmOverwriteMessage} + + + {t('Type "%s" to confirm', t('OVERWRITE'))} + + + + + > + ); + }; + // Show/hide if (isHidden && show) { setIsHidden(false); @@ -218,10 +259,13 @@ const ImportModelsModal: FunctionComponent = ({ = ({ /> {renderPasswordFields()} + {renderOverwriteConfirmation()} ); }; diff --git a/superset-frontend/src/views/CRUD/chart/ChartList.tsx b/superset-frontend/src/views/CRUD/chart/ChartList.tsx index 48b8701f717..1e07c03d66a 100644 --- a/superset-frontend/src/views/CRUD/chart/ChartList.tsx +++ b/superset-frontend/src/views/CRUD/chart/ChartList.tsx @@ -582,6 +582,9 @@ function ChartList(props: ChartListProps) { resourceLabel={t('chart')} icon={} passwordsNeededMessage={PASSWORDS_NEEDED_MESSAGE} + confirmOverwriteMessage={t( + 'One or more charts to be imported already exist.', + )} addDangerToast={addDangerToast} addSuccessToast={addSuccessToast} onModelImport={handleChartImport} diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx index 0296a0e5fc5..6254c0b948f 100644 --- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx +++ b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx @@ -543,6 +543,9 @@ function DashboardList(props: DashboardListProps) { resourceLabel={t('dashboard')} icon={} passwordsNeededMessage={PASSWORDS_NEEDED_MESSAGE} + confirmOverwriteMessage={t( + 'One or more dashboards to be imported already exist.', + )} addDangerToast={addDangerToast} addSuccessToast={addSuccessToast} onModelImport={handleDashboardImport} diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx index 856e35ee5d0..fea9ad4393d 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx @@ -438,6 +438,9 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { resourceLabel={t('database')} icon={} passwordsNeededMessage={PASSWORDS_NEEDED_MESSAGE} + confirmOverwriteMessage={t( + 'One or more databases to be imported already exist.', + )} addDangerToast={addDangerToast} addSuccessToast={addSuccessToast} onModelImport={handleDatabaseImport} diff --git a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx index 8f86885f770..565541eff78 100644 --- a/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx +++ b/superset-frontend/src/views/CRUD/data/dataset/DatasetList.tsx @@ -661,6 +661,9 @@ const DatasetList: FunctionComponent = ({ resourceLabel={t('dataset')} icon={} passwordsNeededMessage={PASSWORDS_NEEDED_MESSAGE} + confirmOverwriteMessage={t( + 'One or more datasets to be imported already exist.', + )} addDangerToast={addDangerToast} addSuccessToast={addSuccessToast} onModelImport={handleDatasetImport} diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index 14c97ba24c7..fedcc347d1d 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -320,6 +320,7 @@ export function useSingleViewResource( interface ImportResourceState { loading: boolean; passwordsNeeded: string[]; + alreadyExists: string[]; } export function useImportResource( @@ -330,24 +331,51 @@ export function useImportResource( const [state, setState] = useState({ loading: false, passwordsNeeded: [], + alreadyExists: [], }); function updateState(update: Partial) { setState(currentState => ({ ...currentState, ...update })); } - const needsPassword = (errMsg: Record>) => - Object.values(errMsg).every(validationErrors => - Object.entries(validationErrors as Object).every( - ([field, messages]) => - field === '_schema' && - messages.length === 1 && - messages[0] === 'Must provide a password for the database', - ), + /* eslint-disable no-underscore-dangle */ + const isNeedsPassword = (payload: any) => + typeof payload === 'object' && + Array.isArray(payload._schema) && + payload._schema.length === 1 && + payload._schema[0] === 'Must provide a password for the database'; + + const isAlreadyExists = (payload: any) => + typeof payload === 'string' && + payload.includes('already exists and `overwrite=true` was not passed'); + + const getPasswordsNeeded = ( + errMsg: Record>, + ) => + Object.entries(errMsg) + .filter(([, validationErrors]) => isNeedsPassword(validationErrors)) + .map(([fileName]) => fileName); + + const getAlreadyExists = (errMsg: Record>) => + Object.entries(errMsg) + .filter(([, validationErrors]) => isAlreadyExists(validationErrors)) + .map(([fileName]) => fileName); + + const hasTerminalValidation = ( + errMsg: Record>, + ) => + Object.values(errMsg).some( + validationErrors => + !isNeedsPassword(validationErrors) && + !isAlreadyExists(validationErrors), ); const importResource = useCallback( - (bundle: File, databasePasswords: Record = {}) => { + ( + bundle: File, + databasePasswords: Record = {}, + overwrite = false, + ) => { // Set loading state updateState({ loading: true, @@ -362,6 +390,12 @@ export function useImportResource( if (databasePasswords) { formData.append('passwords', JSON.stringify(databasePasswords)); } + /* If the imported model already exists the user needs to confirm + * that they want to overwrite it. + */ + if (overwrite) { + formData.append('overwrite', 'true'); + } return SupersetClient.post({ endpoint: `/api/v1/${resourceName}/import/`, @@ -370,25 +404,31 @@ export function useImportResource( .then(() => true) .catch(response => getClientErrorObject(response).then(error => { - /* When importing a bundle, if all validation errors are because - * the databases need passwords we return a list of the database - * files so that the user can type in the passwords and resubmit - * the file. - */ const errMsg = error.message || error.error; - if (typeof errMsg !== 'string' && needsPassword(errMsg)) { - updateState({ - passwordsNeeded: Object.keys(errMsg), - }); + if (typeof errMsg === 'string') { + handleErrorMsg( + t( + 'An error occurred while importing %s: %s', + resourceLabel, + errMsg, + ), + ); return false; } - handleErrorMsg( - t( - 'An error occurred while importing %s: %s', - resourceLabel, - JSON.stringify(errMsg), - ), - ); + if (hasTerminalValidation(errMsg)) { + handleErrorMsg( + t( + 'An error occurred while importing %s: %s', + resourceLabel, + JSON.stringify(errMsg), + ), + ); + } else { + updateState({ + passwordsNeeded: getPasswordsNeeded(errMsg), + alreadyExists: getAlreadyExists(errMsg), + }); + } return false; }), ) @@ -399,13 +439,7 @@ export function useImportResource( [], ); - return { - state: { - loading: state.loading, - passwordsNeeded: state.passwordsNeeded, - }, - importResource, - }; + return { state, importResource }; } enum FavStarClassName {