From 0ade890c50a6aabfb698752cd6ee760856b5edb7 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Mon, 7 Oct 2024 22:27:32 +0200 Subject: [PATCH] support linking a data connector to a project --- .../ProjectConnectDataConnectorsModal.tsx | 301 ++++++++++++++++++ .../ProjectDataConnectorsBox.tsx | 13 +- .../api/data-connectors.enhanced-api.ts | 8 + .../components/DataConnectorModal/index.tsx | 72 +++-- tests/cypress/e2e/projectV2setup.spec.ts | 22 +- .../renkulab-fixtures/dataConnectors.ts | 30 ++ 6 files changed, 414 insertions(+), 32 deletions(-) create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectConnectDataConnectorsModal.tsx diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectConnectDataConnectorsModal.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectConnectDataConnectorsModal.tsx new file mode 100644 index 000000000..4d26a076c --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectConnectDataConnectorsModal.tsx @@ -0,0 +1,301 @@ +/*! + * 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 { useCallback, useEffect, useState } from "react"; +import { Database, NodePlus, PlusLg, XLg } from "react-bootstrap-icons"; +import { Controller, useForm } from "react-hook-form"; +import { + Button, + ButtonGroup, + Form, + Input, + Label, + Modal, + ModalBody, + ModalHeader, + ModalFooter, +} from "reactstrap"; + +import { RtkOrNotebooksError } from "../../../../components/errors/RtkErrorAlert"; +import { Loader } from "../../../../components/Loader"; +import useAppDispatch from "../../../../utils/customHooks/useAppDispatch.hook"; + +import { + dataConnectorsApi, + usePostDataConnectorsByDataConnectorIdProjectLinksMutation, +} from "../../../dataConnectorsV2/api/data-connectors.enhanced-api"; +import DataConnectorModal, { + DataConnectorModalBodyAndFooter, +} from "../../../dataConnectorsV2/components/DataConnectorModal/index"; +import styles from "../../../dataConnectorsV2/components/DataConnectorModal/DataConnectorModal.module.scss"; + +import { projectV2Api } from "../../../projectsV2/api/projectV2.enhanced-api"; + +interface ProjectConnectDataConnectorsModalProps + extends Omit< + Parameters[0], + "dataConnector" | "projectId" + > { + projectId: string; +} + +type ProjectConnectDataConnectorMode = "create" | "link"; + +export default function ProjectConnectDataConnectorsModal({ + isOpen, + namespace, + projectId, + toggle, +}: ProjectConnectDataConnectorsModalProps) { + const [mode, setMode] = useState("link"); + return ( + + + + + {mode === "create" ? ( + + ) : ( + + )} + + ); +} + +function ProjectConnectDataConnectorModalHeader({ + mode, + setMode, +}: { + mode: ProjectConnectDataConnectorMode; + setMode: (mode: ProjectConnectDataConnectorMode) => void; +}) { + return ( + <> +
+ Link or create data connector +
+
+ + { + setMode("link"); + }} + /> + + { + setMode("create"); + }} + /> + + +
+ + ); +} + +function ProjectCreateDataConnectorBodyAndFooter({ + isOpen, + namespace, + projectId, + toggle, +}: ProjectConnectDataConnectorsModalProps) { + return ( + + ); +} + +interface DataConnectorLinkFormFields { + dataConnectorIdentifier: string; +} + +function ProjectLinkDataConnectorBodyAndFooter({ + projectId, + toggle, +}: ProjectConnectDataConnectorsModalProps) { + const dispatch = useAppDispatch(); + const [ + linkDataConnector, + { error: linkDataConnectorError, isLoading, isSuccess }, + ] = usePostDataConnectorsByDataConnectorIdProjectLinksMutation(); + const { + control, + formState: { errors }, + handleSubmit, + setError, + } = useForm({ + defaultValues: { + dataConnectorIdentifier: "", + }, + }); + + const onSubmit = useCallback( + async (values: DataConnectorLinkFormFields) => { + const [namespace, slug] = values.dataConnectorIdentifier.split("/"); + const dataConnectorPromise = dispatch( + dataConnectorsApi.endpoints.getNamespacesByNamespaceDataConnectorsAndSlug.initiate( + { namespace, slug } + ) + ); + const { data: dataConnector, isSuccess } = await dataConnectorPromise; + dataConnectorPromise.unsubscribe(); + if (!isSuccess || dataConnector == null) { + setError("dataConnectorIdentifier", { + type: "manual", + message: "Data connector not found", + }); + return false; + } + linkDataConnector({ + dataConnectorId: dataConnector.id, + dataConnectorToProjectLinkPost: { + project_id: projectId, + }, + }); + }, + [dispatch, linkDataConnector, projectId, setError] + ); + + useEffect(() => { + if (isSuccess) { + dispatch(projectV2Api.util.invalidateTags(["DataConnectors"])); + toggle(); + } + }, [dispatch, isSuccess, toggle]); + + return ( +
+ +
+ + ( + + )} + rules={{ + required: true, + pattern: /^(.+)\/(.+)$/, + }} + /> +
+ Please provide an identifier (namespace/group) for the data + connector +
+
+ {isSuccess != null && !isSuccess && ( + + )} +
+ + + + + +
+ ); +} diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectDataConnectorsBox.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectDataConnectorsBox.tsx index c474c28c6..df591fb1b 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectDataConnectorsBox.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/DataConnectors/ProjectDataConnectorsBox.tsx @@ -35,15 +35,16 @@ import type { DataConnectorToProjectLink, Project, GetProjectsByProjectIdDataConnectorLinksApiResponse, -} from "../../../projectsV2/api/projectV2.api.ts"; -import { useGetDataConnectorsByDataConnectorIdQuery } from "../../../dataConnectorsV2/api/data-connectors.api.ts"; +} from "../../../projectsV2/api/projectV2.api"; +import { useGetDataConnectorsByDataConnectorIdQuery } from "../../../dataConnectorsV2/api/data-connectors.api"; import { useGetProjectsByProjectIdDataConnectorLinksQuery } from "../../../projectsV2/api/projectV2.enhanced-api"; -import DataConnectorBoxListDisplay from "../../../dataConnectorsV2/components/DataConnectorsBoxListDisplay.tsx"; -import DataConnectorModal from "../../../dataConnectorsV2/components/DataConnectorModal"; +import DataConnectorBoxListDisplay from "../../../dataConnectorsV2/components/DataConnectorsBoxListDisplay"; -import AccessGuard from "../../utils/AccessGuard.tsx"; +import AccessGuard from "../../utils/AccessGuard"; import useProjectAccess from "../../utils/useProjectAccess.hook"; +import ProjectConnectDataConnectorsModal from "./ProjectConnectDataConnectorsModal"; + interface DataConnectorListDisplayProps { project: Project; } @@ -108,7 +109,7 @@ function ProjectDataConnectorBoxContent({ - void; -} -export default function DataConnectorModal({ +export function DataConnectorModalBodyAndFooter({ dataConnector = null, isOpen, namespace, @@ -450,23 +443,7 @@ export default function DataConnectorModal({ connectorSecrets != null && connectorSecrets.length > 0; return ( - - - - - + <> )} + + ); +} + +interface DataConnectorModalProps { + dataConnector?: DataConnectorRead | null; + isOpen: boolean; + namespace: string; + projectId?: string; + toggle: () => void; +} +export default function DataConnectorModal({ + dataConnector = null, + isOpen, + namespace, + projectId, + toggle, +}: DataConnectorModalProps) { + const dataConnectorId = dataConnector?.id ?? null; + return ( + + + + + ); } diff --git a/tests/cypress/e2e/projectV2setup.spec.ts b/tests/cypress/e2e/projectV2setup.spec.ts index 00bf35570..5139fd407 100644 --- a/tests/cypress/e2e/projectV2setup.spec.ts +++ b/tests/cypress/e2e/projectV2setup.spec.ts @@ -43,7 +43,7 @@ describe("Set up project components", () => { fixtures.projects().landingUserProjects().readProjectV2(); }); - it("set up simple data connector", () => { + it("create a simple data connector", () => { fixtures .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" }) .listProjectDataConnectors() @@ -58,6 +58,7 @@ describe("Set up project components", () => { // add data connector cy.getDataCy("add-data-connector").should("be.visible").click(); + cy.getDataCy("project-data-controller-mode-create").click(); // Pick a provider cy.getDataCy("data-storage-s3").click(); cy.getDataCy("data-provider-AWS").click(); @@ -87,6 +88,25 @@ describe("Set up project components", () => { cy.wait("@listProjectDataConnectors"); }); + it("link a data connector", () => { + fixtures + .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" }) + .listProjectDataConnectors() + .getDataConnectorByNamespaceAndSlug() + .postDataConnectorProjectLink({ dataConnectorId: "ULID-1" }); + cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + cy.wait("@listProjectDataConnectors"); + + // add data connector + cy.getDataCy("add-data-connector").should("be.visible").click(); + cy.getDataCy("project-data-controller-mode-link").click(); + cy.get("#data-connector-identifier").type("user1-uuid/example-storage"); + cy.getDataCy("link-data-connector-button").click(); + cy.wait("@postDataConnectorProjectLink"); + cy.wait("@listProjectDataConnectors"); + }); + it("delete a data connector", () => { fixtures .readProjectV2({ fixture: "projectV2/read-projectV2-empty.json" }) diff --git a/tests/cypress/support/renkulab-fixtures/dataConnectors.ts b/tests/cypress/support/renkulab-fixtures/dataConnectors.ts index 81f8b7177..0f50cb13b 100644 --- a/tests/cypress/support/renkulab-fixtures/dataConnectors.ts +++ b/tests/cypress/support/renkulab-fixtures/dataConnectors.ts @@ -32,6 +32,11 @@ interface DataConnectorIdArgs extends SimpleFixture { dataConnectorId?: string; } +interface DataConnectorIdentifierArgs extends SimpleFixture { + namespace?: string; + slug?: string; +} + interface DeleteDataConnectorProjectLinkArgs extends DataConnectorIdArgs { linkId?: string; } @@ -134,6 +139,31 @@ export function DataConnector(Parent: T) { return this; } + getDataConnectorByNamespaceAndSlug(args?: DataConnectorIdentifierArgs) { + const { + fixture = "dataConnector/data-connector.json", + name = "getDataConnectorByNamespaceAndSlug", + namespace = "user1-uuid", + slug = "example-storage", + } = args ?? {}; + cy.fixture(fixture).then((dcs) => { + // eslint-disable-next-line max-nested-callbacks + cy.intercept( + "GET", + `/ui-server/api/data/namespaces/${namespace}/data_connectors/${slug}`, + (req) => { + const response = dcs.map((dc) => { + return { + ...dc, + }; + })[0]; + req.reply({ body: response }); + } + ).as(name); + }); + return this; + } + listDataConnectors(args?: DataConnectorListArgs) { const { fixture = "dataConnector/data-connector-multiple.json",