diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourceCredentialsModal.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourceCredentialsModal.tsx index 6ddd5d644..a02181ec4 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourceCredentialsModal.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourceCredentialsModal.tsx @@ -28,7 +28,7 @@ import { } from "../../../projectsV2/api/projectV2.enhanced-api"; import type { CloudStorageGetRead } from "../../../projectsV2/api/storagesV2.api"; import type { SessionStartCloudStorageConfiguration } from "../../../sessionsV2/startSessionOptionsV2.types"; -import CloudStorageSecretsModal from "../../../sessionsV2/CloudStorageSecretsModal"; +import CloudStorageSecretsModal from "../../../sessionsV2/DataConnectorSecretsModal"; import useDataSourceConfiguration from "./useDataSourceConfiguration.hook"; import { Loader } from "../../../../components/Loader"; diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourceDisplay.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourceDisplay.tsx index 0f7c2d2de..1b849144e 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourceDisplay.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourceDisplay.tsx @@ -30,15 +30,15 @@ import { Row, } from "reactstrap"; -import { Loader } from "../../../../components/Loader.tsx"; -import AddCloudStorageModal from "../../../project/components/cloudStorage/CloudStorageModal.tsx"; +import { Loader } from "../../../../components/Loader"; +import DataConnectorModal from "../../../dataConnectorsV2/components/DataConnectorModal"; import { - CloudStorageGetRead, + type CloudStorageGetRead, useDeleteStoragesV2ByStorageIdMutation, } from "../../../projectsV2/api/storagesV2.api"; -import DataSourceCredentialsModal from "./DataSourceCredentialsModal.tsx"; -import { DataSourceView } from "./DataSourceView.tsx"; -import { ButtonWithMenuV2 } from "../../../../components/buttons/Button.tsx"; +import DataSourceCredentialsModal from "./DataSourceCredentialsModal"; +import { DataSourceView } from "./DataSourceView"; +import { ButtonWithMenuV2 } from "../../../../components/buttons/Button"; interface DataSourceDeleteModalProps { storage: CloudStorageGetRead; @@ -174,12 +174,11 @@ export function DataSourceActions({ isOpen={isDeleteOpen} toggleModal={toggleDelete} /> - ); diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourcesBox.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourcesBox.tsx index 90425b331..a7f883492 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourcesBox.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/DataSources/DataSourcesBox.tsx @@ -18,13 +18,13 @@ import cx from "classnames"; import { useCallback, useState } from "react"; import { Database, PlusLg } from "react-bootstrap-icons"; -import { Loader } from "../../../../components/Loader.tsx"; -import AddCloudStorageModal from "../../../project/components/cloudStorage/CloudStorageModal.tsx"; +import { Loader } from "../../../../components/Loader"; +import DataConnectorModal from "../../../dataConnectorsV2/components/DataConnectorModal"; import { Project } from "../../../projectsV2/api/projectV2.api"; import { useGetStoragesV2Query } from "../../../projectsV2/api/storagesV2.api"; -import AccessGuard from "../../utils/AccessGuard.tsx"; +import AccessGuard from "../../utils/AccessGuard"; import useProjectAccess from "../../utils/useProjectAccess.hook"; -import { DataSourceDisplay } from "./DataSourceDisplay.tsx"; +import { DataSourceDisplay } from "./DataSourceDisplay"; import { Badge, Button, @@ -117,12 +117,11 @@ export function DataSourcesDisplay({ project }: { project: Project }) { )} - ); diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorModal.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorModal.tsx new file mode 100644 index 000000000..099a1dd38 --- /dev/null +++ b/client/src/features/dataConnectorsV2/components/DataConnectorModal.tsx @@ -0,0 +1,572 @@ +/*! + * Copyright 2023 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed 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 { skipToken } from "@reduxjs/toolkit/query"; +import cx from "classnames"; +import { isEqual } from "lodash-es"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ArrowCounterclockwise } from "react-bootstrap-icons"; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from "reactstrap"; + +import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert"; +import { usePostStoragesV2ByStorageIdSecretsMutation } from "../../projectsV2/api/projectV2.enhanced-api"; +import { + CloudStorageGetV2Read, + CloudStoragePatch, + PostStoragesV2ApiArg, + RCloneConfig, + usePatchStoragesV2ByStorageIdMutation, + usePostStoragesV2Mutation, +} from "../../projectsV2/api/storagesV2.api"; + +import AddStorageBreadcrumbNavbar from "../../project/components/cloudStorage/AddStorageBreadcrumbNavbar"; +import { + useGetCloudStorageSchemaQuery, + useTestCloudStorageConnectionMutation, +} from "../../project/components/cloudStorage/projectCloudStorage.api"; +import { + CLOUD_STORAGE_SENSITIVE_FIELD_TOKEN, + CLOUD_STORAGE_TOTAL_STEPS, + EMPTY_CLOUD_STORAGE_DETAILS, + EMPTY_CLOUD_STORAGE_STATE, +} from "../../project/components/cloudStorage/projectCloudStorage.constants"; +import { + AddCloudStorageForProjectParams, + AddCloudStorageState, + CloudStorageDetails, + CloudStorageDetailsOptions, + CredentialSaveStatus, + TestCloudStorageConnectionParams, +} from "../../project/components/cloudStorage/projectCloudStorage.types"; + +import { + AddCloudStorageContinueButton, + AddCloudStorageBackButton, + AddCloudStorageConnectionTestResult, + AddCloudStorageHeaderContent, +} from "../../project/components/cloudStorage/cloudStorageModalComponents"; +import { + findSensitive, + getCurrentStorageDetails, + getSchemaProviders, + hasProviderShortlist, +} from "../../project/utils/projectCloudStorage.utils"; + +import styles from "../../project/components/cloudStorage/CloudStorage.module.scss"; + +import DataConnectorModalBody from "./DataConnectorModalBody"; + +interface DataConnectorModalProps { + currentStorage?: CloudStorageGetV2Read | null; + isOpen: boolean; + toggle: () => void; + projectId: string; +} +export default function DataConnectorModal({ + currentStorage = null, + isOpen, + toggle: originalToggle, + projectId, +}: DataConnectorModalProps) { + const storageId = currentStorage?.storage.storage_id ?? null; + // Fetch available schema when users open the modal + const { + data: schema, + error: schemaError, + isFetching: schemaIsFetching, + } = useGetCloudStorageSchemaQuery(isOpen ? undefined : skipToken); + + // Reset state on props change + useEffect(() => { + const cloudStorageDetails: CloudStorageDetails = + currentStorage != null + ? getCurrentStorageDetails(currentStorage) + : EMPTY_CLOUD_STORAGE_DETAILS; + const cloudStorageState: AddCloudStorageState = + currentStorage != null + ? { + ...EMPTY_CLOUD_STORAGE_STATE, + step: 2, + completedSteps: CLOUD_STORAGE_TOTAL_STEPS, + } + : EMPTY_CLOUD_STORAGE_STATE; + setStorageDetails(cloudStorageDetails); + setState(cloudStorageState); + }, [currentStorage]); + + const [success, setSuccess] = useState(false); + const [credentialSaveStatus, setCredentialSaveStatus] = + useState("none"); + const [validationSucceeded, setValidationSucceeded] = useState(false); + const [state, setState] = useState( + EMPTY_CLOUD_STORAGE_STATE + ); + const [storageDetails, setStorageDetails] = useState( + EMPTY_CLOUD_STORAGE_DETAILS + ); + + // Enhanced setters + const setStateSafe = useCallback( + (newState: Partial) => { + const fullNewState = { + ...state, + ...newState, + }; + if (isEqual(fullNewState, state)) { + return; + } + + // Handle advanced mode changes + if ( + fullNewState.advancedMode !== state.advancedMode && + fullNewState.step !== 3 + ) { + if (fullNewState.advancedMode) { + fullNewState.step = 0; + } else { + if ( + // schema and provider (where necessary) must also exist in the list + !storageDetails.schema || + !schema?.find((s) => s.prefix === storageDetails.schema) || + (hasProviderShortlist(storageDetails.schema) && + (!storageDetails.provider || + !getSchemaProviders(schema, false, storageDetails.schema)?.find( + (p) => p.name === storageDetails.provider + ))) + ) { + fullNewState.step = 1; + } else { + fullNewState.step = 2; + } + } + } + setState(fullNewState); + }, + [state, storageDetails, schema] + ); + + // Reset + const [redraw, setRedraw] = useState(false); + useEffect(() => { + if (redraw) setRedraw(false); + }, [redraw]); + + // Mutations + const [addCloudStorageForProjectV2, addResultV2] = + usePostStoragesV2Mutation(); + const [modifyCloudStorageV2ForProject, modifyResultV2] = + usePatchStoragesV2ByStorageIdMutation(); + const [saveCredentials, saveCredentialsResult] = + usePostStoragesV2ByStorageIdSecretsMutation(); + const [validateCloudStorageConnection, validationResult] = + useTestCloudStorageConnectionMutation(); + + const reset = useCallback(() => { + const resetStatus = getCurrentStorageDetails(currentStorage); + setState((prevState) => + currentStorage != null + ? { + ...EMPTY_CLOUD_STORAGE_STATE, + step: prevState.step, + completedSteps: prevState.completedSteps, + } + : { + ...EMPTY_CLOUD_STORAGE_STATE, + } + ); + addResultV2.reset(); + validationResult.reset(); + setStorageDetails(resetStatus); + setSuccess(false); + setCredentialSaveStatus("none"); + setValidationSucceeded(false); + setRedraw(true); // This forces re-loading the useForm fields + }, [addResultV2, currentStorage, validationResult]); + + const setStorageDetailsSafe = useCallback( + (newStorageDetails: Partial) => { + const fullNewDetails = { + ...storageDetails, + ...newStorageDetails, + }; + if (isEqual(fullNewDetails, storageDetails)) { + return; + } + // reset follow-up properties: schema > provider > options + if (fullNewDetails.schema !== storageDetails.schema) { + fullNewDetails.provider = undefined; + fullNewDetails.options = undefined; + fullNewDetails.sourcePath = undefined; + } else if (fullNewDetails.provider !== storageDetails.provider) { + fullNewDetails.options = undefined; + fullNewDetails.sourcePath = undefined; + } + if (!validationResult.isUninitialized) validationResult.reset(); + setStorageDetails(fullNewDetails); + }, + [storageDetails, validationResult] + ); + + const validateConnection = useCallback(() => { + const validateParameters: TestCloudStorageConnectionParams = { + configuration: { + type: storageDetails.schema, + }, + source_path: storageDetails.sourcePath ?? "/", + }; + if (storageDetails.provider) { + validateParameters.configuration.provider = storageDetails.provider; + } + if ( + storageDetails.options && + Object.keys(storageDetails.options).length > 0 + ) { + const options = storageDetails.options as CloudStorageDetailsOptions; + Object.entries(options).forEach(([key, value]) => { + if (value != undefined && value !== "") { + validateParameters.configuration[key] = value; + } + }); + } + + validateCloudStorageConnection(validateParameters); + }, [storageDetails, validateCloudStorageConnection]); + + const addOrEditStorage = useCallback(() => { + const storageParameters: + | AddCloudStorageForProjectParams + | CloudStoragePatch = { + name: storageDetails.name as string, + readonly: storageDetails.readOnly ?? true, + project_id: `${projectId}`, + source_path: storageDetails.sourcePath ?? "/", + target_path: storageDetails.mountPoint as string, + configuration: { type: storageDetails.schema }, + private: false, + }; + // Add provider when required + if (storageDetails.provider) { + storageParameters.configuration = { + ...storageParameters.configuration, + provider: storageDetails.provider, + }; + } + // Add options if any + if ( + storageDetails.options && + Object.keys(storageDetails.options).length > 0 + ) { + const allOptions = storageDetails.options as CloudStorageDetailsOptions; + const sensitiveFields = schema + ? findSensitive(schema.find((s) => s.prefix === storageDetails.schema)) + : currentStorage?.sensitive_fields + ? currentStorage.sensitive_fields.map((field) => field.name) + : []; + const validOptions = Object.keys( + storageDetails.options + ).reduce((options, key) => { + const value = allOptions[key]; + if (value != undefined && value !== "") { + options[key] = sensitiveFields.includes(key) + ? CLOUD_STORAGE_SENSITIVE_FIELD_TOKEN + : value; + } + return options; + }, {}); + + storageParameters.configuration = { + ...storageParameters.configuration, + ...validOptions, + }; + } + + // We manually set success only when we get an ID back. That's just to show a success message + if (storageId) { + const cloudStoragePatch: CloudStoragePatch = { + project_id: projectId, + name: storageParameters.name, + configuration: storageParameters.configuration as RCloneConfig, + source_path: storageParameters.source_path, + target_path: storageParameters.target_path, + readonly: storageParameters.readonly, + }; + modifyCloudStorageV2ForProject({ + storageId: storageId, + cloudStoragePatch, + }).then((result) => { + if ("data" in result && result.data.storage.storage_id) { + setSuccess(true); + } + }); + } else { + const parameterV2 = { + body: storageParameters, + } as PostStoragesV2ApiArg; + addCloudStorageForProjectV2(parameterV2).then((result) => { + if ("data" in result && result.data.storage.storage_id) { + setSuccess(true); + } + }); + } + }, [ + addCloudStorageForProjectV2, + currentStorage, + modifyCloudStorageV2ForProject, + projectId, + schema, + storageDetails, + storageId, + ]); + + const toggle = useCallback(() => { + originalToggle(); + setCredentialSaveStatus("none"); + setValidationSucceeded(false); + if (success) { + setSuccess(false); + reset(); + } else { + addResultV2.reset(); + validationResult.reset(); + } + }, [addResultV2, originalToggle, reset, success, validationResult]); + + // Handle unmount + useEffect(() => { + const cleanup = () => { + reset(); + }; + + return cleanup; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const schemaRequiresProvider = useMemo( + () => hasProviderShortlist(storageDetails.schema), + [storageDetails.schema] + ); + + useEffect(() => { + const storageId = addResultV2.data?.storage?.storage_id; + if (storageId == null) return; + const shouldSaveCredentials = shouldSaveDataConnectorCredentials( + storageDetails.options, + state.saveCredentials, + validationSucceeded + ); + if (!shouldSaveCredentials) { + return; + } + const options = storageDetails.options as CloudStorageDetailsOptions; + if (!schema) return; + const sensitiveFieldNames = findSensitive( + schema.find((s) => s.prefix === storageDetails.schema) + ); + const cloudStorageSecretPostList = sensitiveFieldNames + .map((name) => ({ + name, + value: options[name], + })) + .filter((secret) => secret.value != undefined && secret.value != "") + .map((secret) => ({ + name: secret.name, + value: "" + secret.value, + })); + saveCredentials({ + storageId, + cloudStorageSecretPostList, + }); + }, [ + addResultV2.data?.storage?.storage_id, + saveCredentials, + state.saveCredentials, + schema, + storageDetails.options, + storageDetails.schema, + validationSucceeded, + ]); + + useEffect(() => { + if (!validationSucceeded) { + setCredentialSaveStatus("none"); + return; + } + if ( + addResultV2.data?.storage?.storage_id == null || + saveCredentialsResult.isUninitialized + ) { + setCredentialSaveStatus("none"); + return; + } + if (saveCredentialsResult.isLoading) { + setCredentialSaveStatus("trying"); + return; + } + if (saveCredentialsResult.isSuccess) { + setCredentialSaveStatus("success"); + return; + } + if (saveCredentialsResult.isError) { + setCredentialSaveStatus("failure"); + return; + } + setCredentialSaveStatus("none"); + }, [addResultV2, saveCredentialsResult, validationSucceeded]); + + // Visual elements + const disableContinueButton = + state.step === 1 && + (!storageDetails.schema || + (schemaRequiresProvider && !storageDetails.provider)); + + const isAddResultLoading = addResultV2.isLoading; + const isModifyResultLoading = modifyResultV2.isLoading; + const addResultError = addResultV2.error; + const modifyResultError = modifyResultV2.error; + const addResultStorageName = addResultV2?.data?.storage?.name; + + const disableAddButton = + isAddResultLoading || + isModifyResultLoading || + !storageDetails.name || + !storageDetails.mountPoint || + !storageDetails.schema || + (hasProviderShortlist(storageDetails.schema) && !storageDetails.provider); + const addButtonDisableReason = isAddResultLoading + ? "Please wait, the storage is being added" + : modifyResultV2.isLoading + ? "Please wait, the storage is being modified" + : !storageDetails.name + ? "Please provide a name" + : !storageDetails.mountPoint + ? "Please provide a mount point" + : !storageDetails.schema + ? "Please go back and select a storage type" + : "Please go back and select a provider"; + const isResultLoading = isAddResultLoading || isModifyResultLoading; + + const storageSecrets = + currentStorage != null && "secrets" in currentStorage + ? currentStorage.secrets ?? [] + : []; + const hasStoredCredentialsInConfig = storageSecrets.length > 0; + + return ( + + + + + + + + + + + + {(addResultError || modifyResultError) && ( +
+ +
+ )} +
+ +
+ {!isResultLoading && !success && ( + + )} + {!isResultLoading && ( + + )} + {!success && ( + + )} +
+
+ ); +} + +function shouldSaveDataConnectorCredentials( + storageDetailsOptions: CloudStorageDetailsOptions | undefined, + stateSaveCredentials: boolean, + validationSucceeded: boolean +) { + return !!( + storageDetailsOptions && + stateSaveCredentials && + validationSucceeded + ); +} diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorModalBody.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorModalBody.tsx new file mode 100644 index 000000000..446310aa7 --- /dev/null +++ b/client/src/features/dataConnectorsV2/components/DataConnectorModalBody.tsx @@ -0,0 +1,161 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed 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 cx from "classnames"; + +import { Loader } from "../../../components/Loader"; +import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert"; + +import { CLOUD_STORAGE_TOTAL_STEPS } from "../../project/components/cloudStorage/projectCloudStorage.constants"; +import type { + AddCloudStorageState, + CloudStorageDetails, + CloudStorageSchema, +} from "../../project/components/cloudStorage/projectCloudStorage.types"; +import { + AddCloudStorageSuccessAlert, + type AddCloudStorageBodyContentProps, +} from "../../project/components/cloudStorage/cloudStorageModalComponents"; +import { + AddStorageAdvanced, + AddStorageAdvancedToggle, + AddStorageMount, + AddStorageOptions, + AddStorageType, +} from "../../project/components/cloudStorage/AddOrEditCloudStorage"; +import type { CloudStorageSecretGet } from "../../projectsV2/api/storagesV2.api"; + +interface AddOrEditCloudStorageProps { + schema: CloudStorageSchema[]; + setStorage: (newDetails: Partial) => void; + setState: (newState: Partial) => void; + state: AddCloudStorageState; + storage: CloudStorageDetails; + storageSecrets: CloudStorageSecretGet[]; +} + +interface AddOrEditCloudStoragePropsV2 extends AddOrEditCloudStorageProps { + validationSucceeded: boolean; +} + +interface DataConnectorModalBodyProps extends AddCloudStorageBodyContentProps { + storageSecrets: CloudStorageSecretGet[]; +} + +export default function DataConnectorModalBody({ + addResultStorageName, + credentialSaveStatus, + redraw, + schema, + schemaError, + schemaIsFetching, + setStateSafe, + setStorageDetailsSafe, + state, + storageDetails, + storageId, + storageSecrets, + success, + validationSucceeded, +}: DataConnectorModalBodyProps) { + if (redraw) return ; + if (success) { + return ( + + ); + } + if (schemaIsFetching || !schema) return ; + if (schemaError) return ; + return ( + <> + {!storageId && ( +

+ Add published datasets from data repositories for use in your project. + Or, connect to cloud storage to read and write custom data. +

+ )} + + + ); +} + +function AddOrEditCloudStorageV2({ + schema, + setStorage, + setState, + state, + storage, + storageSecrets, + validationSucceeded, +}: AddOrEditCloudStoragePropsV2) { + const ContentByStep = + state.step >= 0 && state.step <= CLOUD_STORAGE_TOTAL_STEPS + ? mapStepToElement[state.step] + : null; + + if (ContentByStep) + return ( + <> +
+ +
+ + + ); + return

Error - not implemented yet

; +} + +// *** Add storage: helpers *** // +interface AddStorageStepProps { + schema: CloudStorageSchema[]; + setStorage: (newDetails: Partial) => void; + setState: (newState: Partial) => void; + state: AddCloudStorageState; + storage: CloudStorageDetails; + storageSecrets: CloudStorageSecretGet[]; + isV2?: boolean; + validationSucceeded: boolean; +} + +const mapStepToElement: { + [key: number]: React.ComponentType; +} = { + 0: AddStorageAdvanced, + 1: AddStorageType, + 2: AddStorageOptions, + 3: AddStorageMount, +}; diff --git a/client/src/features/project/components/cloudStorage/AddOrEditCloudStorage.tsx b/client/src/features/project/components/cloudStorage/AddOrEditCloudStorage.tsx index 578c03266..f70e5071f 100644 --- a/client/src/features/project/components/cloudStorage/AddOrEditCloudStorage.tsx +++ b/client/src/features/project/components/cloudStorage/AddOrEditCloudStorage.tsx @@ -35,8 +35,6 @@ import { import { Control, Controller, FieldValues, useForm } from "react-hook-form"; import { Badge, - Breadcrumb, - BreadcrumbItem, Button, Input, InputGroup, @@ -70,6 +68,7 @@ import { ExternalLink } from "../../../../components/ExternalLinks"; import { WarnAlert } from "../../../../components/Alert"; import type { CloudStorageSecretGet } from "../../../../features/projectsV2/api/storagesV2.api"; +import AddStorageBreadcrumbNavbar from "./AddStorageBreadcrumbNavbar"; import AddStorageMountSaveCredentialsInfo from "./AddStorageMountSaveCredentialsInfo"; import styles from "./CloudStorage.module.scss"; @@ -114,104 +113,11 @@ export default function AddOrEditCloudStorage({ return

Error - not implemented yet

; } -interface AddOrEditCloudStoragePropsV2 extends AddOrEditCloudStorageProps { - validationSucceeded: boolean; -} - -export function AddOrEditCloudStorageV2({ - schema, - setStorage, - setState, - state, - storage, - storageSecrets, - validationSucceeded, -}: AddOrEditCloudStoragePropsV2) { - const ContentByStep = - state.step >= 0 && state.step <= CLOUD_STORAGE_TOTAL_STEPS - ? mapStepToElement[state.step] - : null; - - if (ContentByStep) - return ( - <> -
- -
- - - ); - return

Error - not implemented yet

; -} - -// *** Navigation: breadcrumbs and advanced mode selector *** // - -interface AddStorageBreadcrumbNavbarProps { - setState: (newState: Partial) => void; - state: AddCloudStorageState; -} - -export function AddStorageBreadcrumbNavbar({ - setState, - state, -}: AddStorageBreadcrumbNavbarProps) { - const { step, completedSteps } = state; - const items = useMemo(() => { - const steps = state.advancedMode - ? [0, CLOUD_STORAGE_TOTAL_STEPS] - : Array.from( - { length: CLOUD_STORAGE_TOTAL_STEPS }, - (_, index) => index + 1 - ); - const items = steps.map((stepNumber) => { - const active = stepNumber === step; - const disabled = stepNumber > completedSteps + 1; - return ( - - {active ? ( - <>{mapStepToName[stepNumber]} - ) : ( - <> - - - )} - - ); - }); - return items; - }, [completedSteps, setState, step, state.advancedMode]); - - return ( - {items} - ); -} - interface AddStorageAdvancedToggleProps { setState: (newState: Partial) => void; state: AddCloudStorageState; } -function AddStorageAdvancedToggle({ +export function AddStorageAdvancedToggle({ setState, state, }: AddStorageAdvancedToggleProps) { @@ -275,18 +181,15 @@ const mapStepToElement: { 2: AddStorageOptions, 3: AddStorageMount, }; -const mapStepToName: { [key: number]: string } = { - 0: "Advanced configuration", - 1: "Storage", - 2: "Options", - 3: "Mount", -}; interface AddStorageAdvancedForm { sourcePath: string; configuration: string; } -function AddStorageAdvanced({ storage, setStorage }: AddStorageStepProps) { +export function AddStorageAdvanced({ + storage, + setStorage, +}: AddStorageStepProps) { const { control, formState: { errors }, @@ -707,7 +610,7 @@ function InputOptionItem({ // *** Add storage: page 1 of 3, with storage type and provider *** // -function AddStorageType({ +export function AddStorageType({ schema, state, storage, @@ -937,7 +840,7 @@ function AddStorageType({ } // *** Add storage: page 2 of 3, with storage options *** // -function AddStorageOptions({ +export function AddStorageOptions({ isV2, schema, setState, @@ -1110,7 +1013,7 @@ type AddStorageMountFormFields = | "mountPoint" | "readOnly" | "saveCredentials"; -function AddStorageMount({ +export function AddStorageMount({ isV2, schema, setStorage, diff --git a/client/src/features/project/components/cloudStorage/AddOrEditCloudStorageButton.tsx b/client/src/features/project/components/cloudStorage/AddOrEditCloudStorageButton.tsx index 86b64bcc9..34500c6a1 100644 --- a/client/src/features/project/components/cloudStorage/AddOrEditCloudStorageButton.tsx +++ b/client/src/features/project/components/cloudStorage/AddOrEditCloudStorageButton.tsx @@ -72,7 +72,6 @@ export default function AddOrEditCloudStorageButton({ isOpen={isOpen} toggle={toggle} projectId={projectId} - isV2={false} /> diff --git a/client/src/features/project/components/cloudStorage/AddStorageBreadcrumbNavbar.tsx b/client/src/features/project/components/cloudStorage/AddStorageBreadcrumbNavbar.tsx new file mode 100644 index 000000000..c99681629 --- /dev/null +++ b/client/src/features/project/components/cloudStorage/AddStorageBreadcrumbNavbar.tsx @@ -0,0 +1,85 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed 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 cx from "classnames"; +import { useMemo } from "react"; +import { Breadcrumb, BreadcrumbItem, Button } from "reactstrap"; + +import { CLOUD_STORAGE_TOTAL_STEPS } from "./projectCloudStorage.constants"; +import { AddCloudStorageState } from "./projectCloudStorage.types"; + +// *** Navigation: breadcrumbs and advanced mode selector *** // + +interface AddStorageBreadcrumbNavbarProps { + setState: (newState: Partial) => void; + state: AddCloudStorageState; +} + +export default function AddStorageBreadcrumbNavbar({ + setState, + state, +}: AddStorageBreadcrumbNavbarProps) { + const { step, completedSteps } = state; + const items = useMemo(() => { + const steps = state.advancedMode + ? [0, CLOUD_STORAGE_TOTAL_STEPS] + : Array.from( + { length: CLOUD_STORAGE_TOTAL_STEPS }, + (_, index) => index + 1 + ); + const items = steps.map((stepNumber) => { + const active = stepNumber === step; + const disabled = stepNumber > completedSteps + 1; + return ( + + {active ? ( + <>{mapStepToName[stepNumber]} + ) : ( + <> + + + )} + + ); + }); + return items; + }, [completedSteps, setState, step, state.advancedMode]); + + return ( + {items} + ); +} + +const mapStepToName: { [key: number]: string } = { + 0: "Advanced configuration", + 1: "Storage", + 2: "Options", + 3: "Mount", +}; diff --git a/client/src/features/project/components/cloudStorage/CloudStorageModal.tsx b/client/src/features/project/components/cloudStorage/CloudStorageModal.tsx index 34d34bd99..e40e4ed85 100644 --- a/client/src/features/project/components/cloudStorage/CloudStorageModal.tsx +++ b/client/src/features/project/components/cloudStorage/CloudStorageModal.tsx @@ -24,16 +24,6 @@ import { ArrowCounterclockwise } from "react-bootstrap-icons"; import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from "reactstrap"; import { RtkOrNotebooksError } from "../../../../components/errors/RtkErrorAlert"; -import { usePostStoragesV2ByStorageIdSecretsMutation } from "../../../projectsV2/api/projectV2.enhanced-api"; -import { - CloudStorageGetRead, - CloudStorageGetV2Read, - CloudStoragePatch, - PostStoragesV2ApiArg, - RCloneConfig, - usePatchStoragesV2ByStorageIdMutation, - usePostStoragesV2Mutation, -} from "../../../projectsV2/api/storagesV2.api"; import { findSensitive, @@ -41,7 +31,6 @@ import { getSchemaProviders, hasProviderShortlist, } from "../../utils/projectCloudStorage.utils"; -import { AddStorageBreadcrumbNavbar } from "./AddOrEditCloudStorage"; import { useAddCloudStorageForProjectMutation, useGetCloudStorageSchemaQuery, @@ -76,22 +65,16 @@ import { import styles from "./CloudStorage.module.scss"; interface CloudStorageModalProps { - currentStorage?: - | CloudStorage - | CloudStorageGetRead - | CloudStorageGetV2Read - | null; + currentStorage?: CloudStorage | null; isOpen: boolean; toggle: () => void; projectId: string; - isV2: boolean; } export default function CloudStorageModal({ currentStorage = null, isOpen, toggle: originalToggle, projectId, - isV2 = false, }: CloudStorageModalProps) { const storageId = currentStorage?.storage.storage_id ?? null; // Fetch available schema when users open the modal @@ -179,14 +162,8 @@ export default function CloudStorageModal({ // Mutations const [addCloudStorageForProject, addResult] = useAddCloudStorageForProjectMutation(); - const [addCloudStorageForProjectV2, addResultV2] = - usePostStoragesV2Mutation(); const [modifyCloudStorageForProject, modifyResult] = useUpdateCloudStorageMutation(); - const [modifyCloudStorageV2ForProject, modifyResultV2] = - usePatchStoragesV2ByStorageIdMutation(); - const [saveCredentials, saveCredentialsResult] = - usePostStoragesV2ByStorageIdSecretsMutation(); const [validateCloudStorageConnection, validationResult] = useTestCloudStorageConnectionMutation(); @@ -203,14 +180,13 @@ export default function CloudStorageModal({ ...EMPTY_CLOUD_STORAGE_STATE, } ); - addResultV2.reset(); validationResult.reset(); setStorageDetails(resetStatus); setSuccess(false); setCredentialSaveStatus("none"); setValidationSucceeded(false); setRedraw(true); // This forces re-loading the useForm fields - }, [addResultV2, currentStorage, validationResult]); + }, [currentStorage, validationResult]); const setStorageDetailsSafe = useCallback( (newStorageDetails: Partial) => { @@ -262,10 +238,7 @@ export default function CloudStorageModal({ }, [storageDetails, validateCloudStorageConnection]); const addOrEditStorage = useCallback(() => { - const storageParameters: - | AddCloudStorageForProjectParams - | CloudStorage - | CloudStoragePatch = { + const storageParameters: AddCloudStorageForProjectParams | CloudStorage = { name: storageDetails.name as string, readonly: storageDetails.readOnly ?? true, project_id: `${projectId}`, @@ -313,59 +286,26 @@ export default function CloudStorageModal({ // We manually set success only when we get an ID back. That's just to sho a success message if (storageId) { // v1 - if (isV2) { - const cloudStoragePatch: CloudStoragePatch = { - project_id: projectId, - name: storageParameters.name, - configuration: storageParameters.configuration as RCloneConfig, - source_path: storageParameters.source_path, - target_path: storageParameters.target_path, - readonly: storageParameters.readonly, - }; - modifyCloudStorageV2ForProject({ - storageId: storageId, - cloudStoragePatch, - }).then((result) => { - if ("data" in result && result.data.storage.storage_id) { - setSuccess(true); - } - }); - } else { - const storageParametersWithId: UpdateCloudStorageParams = { - ...storageParameters, - storage_id: storageId as string, - }; - modifyCloudStorageForProject(storageParametersWithId).then((result) => { - if ("data" in result && result.data.storage.storage_id) { - setSuccess(true); - } - }); - } + const storageParametersWithId: UpdateCloudStorageParams = { + ...storageParameters, + storage_id: storageId as string, + }; + modifyCloudStorageForProject(storageParametersWithId).then((result) => { + if ("data" in result && result.data.storage.storage_id) { + setSuccess(true); + } + }); } else { - if (isV2) { - const parameterV2 = { - body: storageParameters, - } as PostStoragesV2ApiArg; - addCloudStorageForProjectV2(parameterV2).then((result) => { - if ("data" in result && result.data.storage.storage_id) { - setSuccess(true); - } - }); - } else { - addCloudStorageForProject(storageParameters).then((result) => { - if ("data" in result && result.data.storage.storage_id) { - setSuccess(true); - } - }); - } + addCloudStorageForProject(storageParameters).then((result) => { + if ("data" in result && result.data.storage.storage_id) { + setSuccess(true); + } + }); } }, [ addCloudStorageForProject, - addCloudStorageForProjectV2, currentStorage, - isV2, modifyCloudStorageForProject, - modifyCloudStorageV2ForProject, projectId, schema, storageDetails, @@ -380,10 +320,9 @@ export default function CloudStorageModal({ setSuccess(false); reset(); } else { - addResultV2.reset(); validationResult.reset(); } - }, [addResultV2, originalToggle, reset, success, validationResult]); + }, [originalToggle, reset, success, validationResult]); // Handle unmount useEffect(() => { @@ -399,89 +338,17 @@ export default function CloudStorageModal({ [storageDetails.schema] ); - useEffect(() => { - const storageId = addResultV2.data?.storage?.storage_id; - if (storageId == null) return; - const shouldSaveCredentials = shouldSaveCredentialsV2( - isV2, - storageDetails.options, - state.saveCredentials, - validationSucceeded - ); - if (!shouldSaveCredentials) { - return; - } - const options = storageDetails.options as CloudStorageDetailsOptions; - if (!schema) return; - const sensitiveFieldNames = findSensitive( - schema.find((s) => s.prefix === storageDetails.schema) - ); - const cloudStorageSecretPostList = sensitiveFieldNames - .map((name) => ({ - name, - value: options[name], - })) - .filter((secret) => secret.value != undefined && secret.value != "") - .map((secret) => ({ - name: secret.name, - value: "" + secret.value, - })); - saveCredentials({ - storageId, - cloudStorageSecretPostList, - }); - }, [ - addResultV2.data?.storage?.storage_id, - isV2, - saveCredentials, - state.saveCredentials, - schema, - storageDetails.options, - storageDetails.schema, - validationSucceeded, - ]); - - useEffect(() => { - if (!validationSucceeded) { - setCredentialSaveStatus("none"); - return; - } - if ( - addResultV2.data?.storage?.storage_id == null || - saveCredentialsResult.isUninitialized - ) { - setCredentialSaveStatus("none"); - return; - } - if (saveCredentialsResult.isLoading) { - setCredentialSaveStatus("trying"); - return; - } - if (saveCredentialsResult.isSuccess) { - setCredentialSaveStatus("success"); - return; - } - if (saveCredentialsResult.isError) { - setCredentialSaveStatus("failure"); - return; - } - setCredentialSaveStatus("none"); - }, [addResultV2, saveCredentialsResult, validationSucceeded]); - // Visual elements const disableContinueButton = state.step === 1 && (!storageDetails.schema || (schemaRequiresProvider && !storageDetails.provider)); - const isAddResultLoading = addResult.isLoading || addResultV2.isLoading; - const isModifyResultLoading = - modifyResult.isLoading || modifyResultV2.isLoading; - const addResultError = isV2 ? addResultV2.error : addResult.error; - const modifyResultError = isV2 ? modifyResultV2.error : modifyResult.error; - const addResultStorageName = isV2 - ? addResultV2?.data?.storage?.name - : addResult?.data?.storage?.name; + const isAddResultLoading = addResult.isLoading; + const isModifyResultLoading = modifyResult.isLoading; + const addResultError = addResult.error; + const modifyResultError = modifyResult.error; + const addResultStorageName = addResult?.data?.storage?.name; const disableAddButton = isAddResultLoading || @@ -503,12 +370,6 @@ export default function CloudStorageModal({ : "Please go back and select a provider"; const isResultLoading = isAddResultLoading || isModifyResultLoading; - const storageSecrets = - currentStorage != null && "secrets" in currentStorage - ? currentStorage.secrets ?? [] - : []; - const hasStoredCredentialsInConfig = storageSecrets.length > 0; - return ( - + )} - {isV2 && ( -
- -
- )} {!isResultLoading && !success && (