From a56860b2e8260aa4fca5b28dbae1c788a8996b5b Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Thu, 5 Dec 2024 10:30:15 +0100 Subject: [PATCH 1/7] feat!: support template projects (#3393) (#3408) BREAKING CHANGES: required renku-data-services >= 0.28.0 --- client/.eslintignore | 1 + .../src/components/PrimaryAlert.module.scss | 9 + client/src/components/PrimaryAlert.tsx | 47 +++ client/src/components/buttons/Button.tsx | 56 +++- .../ProjectInformation/ProjectInformation.tsx | 103 ++++-- .../ProjectInformationButton.tsx | 74 +++++ .../ProjectOverviewPage.tsx | 4 +- .../Settings/ProjectSettings.tsx | 95 +++++- .../ProjectPageHeader/ProjectCopyBanner.tsx | 309 ++++++++++++++++++ .../ProjectPageHeader/ProjectCopyButton.tsx | 63 ++++ .../ProjectPageHeader/ProjectCopyModal.tsx | 233 +++++++++++++ .../ProjectPageHeader/ProjectPageHeader.tsx | 11 + .../ProjectTemplateInfoBanner.module.css | 9 + .../ProjectTemplateInfoBanner.tsx | 159 +++++++++ .../projectsV2/api/projectV2.enhanced-api.ts | 14 +- .../projectsV2/api/projectV2.openapi.json | 8 + .../fields/ProjectOwnerSlugFormField.tsx | 71 ++++ tests/cypress/e2e/projectV2.spec.ts | 276 +++++++++++++++- .../namespaceV2/list-namespaceV2.json | 6 + .../namespaceV2/namespaceV2-user1.json | 3 +- .../projectV2/update-projectV2-metadata.json | 1 + .../support/renkulab-fixtures/projectV2.ts | 121 ++++++- 22 files changed, 1628 insertions(+), 45 deletions(-) create mode 100644 client/src/components/PrimaryAlert.module.scss create mode 100644 client/src/components/PrimaryAlert.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformationButton.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageHeader/ProjectCopyBanner.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageHeader/ProjectCopyButton.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageHeader/ProjectCopyModal.tsx create mode 100644 client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.module.css create mode 100644 client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx create mode 100644 client/src/features/projectsV2/fields/ProjectOwnerSlugFormField.tsx diff --git a/client/.eslintignore b/client/.eslintignore index 9553264193..e8891d7569 100644 --- a/client/.eslintignore +++ b/client/.eslintignore @@ -1,6 +1,7 @@ *.css *.svg # Generated files should not be linted +src/features/projectsV2/api/projectV2.api.ts src/features/projectsV2/api/storagesV2.api.ts src/features/dataConnectorsV2/api/data-connectors.api.ts src/features/usersV2/api/users.generated-api.ts diff --git a/client/src/components/PrimaryAlert.module.scss b/client/src/components/PrimaryAlert.module.scss new file mode 100644 index 0000000000..f92a1ad6b0 --- /dev/null +++ b/client/src/components/PrimaryAlert.module.scss @@ -0,0 +1,9 @@ +@import "~bootstrap/scss/functions"; +@import "~bootstrap/scss/variables"; +@import "~bootstrap/scss/variables-dark"; +@import "../styles/renku_bootstrap_customization.scss"; + +.primaryAlert { + --bs-primary-bg-subtle: #{tint-color($primary, 90%)}; + max-height: 50vh; +} diff --git a/client/src/components/PrimaryAlert.tsx b/client/src/components/PrimaryAlert.tsx new file mode 100644 index 0000000000..d79af333b5 --- /dev/null +++ b/client/src/components/PrimaryAlert.tsx @@ -0,0 +1,47 @@ +/*! + * 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 { Alert } from "reactstrap"; +import styles from "./PrimaryAlert.module.scss"; + +interface PrimaryAlertProps { + children: React.ReactNode; + "data-cy"?: string; + icon?: React.ReactNode; + className?: string; +} +export default function PrimaryAlert({ + children, + icon, + ...props +}: PrimaryAlertProps) { + return ( + +
+ {icon &&
{icon}
} +
{children}
+
+
+ ); +} diff --git a/client/src/components/buttons/Button.tsx b/client/src/components/buttons/Button.tsx index c68b3797db..c84a9bb6ac 100644 --- a/client/src/components/buttons/Button.tsx +++ b/client/src/components/buttons/Button.tsx @@ -27,7 +27,13 @@ import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import cx from "classnames"; import { Fragment, ReactNode, useRef, useState } from "react"; -import { ArrowRight, ChevronDown, Pencil, PlusLg } from "react-bootstrap-icons"; +import { + ArrowRight, + ChevronDown, + Pencil, + PlusLg, + ThreeDotsVertical, +} from "react-bootstrap-icons"; import { Link } from "react-router-dom"; import { Button, @@ -114,7 +120,7 @@ function ButtonWithMenu(props: ButtonWithMenuProps) { ); } -interface BButtonWithMenuV2Props { +interface ButtonWithMenuV2Props { children?: React.ReactNode; className?: string; color?: string; @@ -125,7 +131,9 @@ interface BButtonWithMenuV2Props { preventPropagation?: boolean; size?: string; } -export function ButtonWithMenuV2({ +export const ButtonWithMenuV2 = SplitButtonWithMenu; + +export function SplitButtonWithMenu({ children, className, color, @@ -135,7 +143,7 @@ export function ButtonWithMenuV2({ id, preventPropagation, size, -}: BButtonWithMenuV2Props) { +}: ButtonWithMenuV2Props) { // ! Temporary workaround to quickly implement a design solution -- to be removed ASAP #3250 const additionalProps = preventPropagation ? { onClick: (e: React.MouseEvent) => e.stopPropagation() } @@ -143,7 +151,7 @@ export function ButtonWithMenuV2({ return ( ) { + // ! Temporary workaround to quickly implement a design solution -- to be removed ASAP #3250 + const additionalProps = preventPropagation + ? { onClick: (e: React.MouseEvent) => e.stopPropagation() } + : {}; + return ( + + + + + {children} + + ); +} + type RefreshButtonProps = { action: () => void; updating?: boolean; diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformation.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformation.tsx index c46f4f669a..05d812473d 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformation.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformation.tsx @@ -16,11 +16,13 @@ * limitations under the License. */ +import { skipToken } from "@reduxjs/toolkit/query"; import cx from "classnames"; import { useMemo } from "react"; import { Bookmarks, Clock, + Diagram3Fill, Eye, InfoCircle, JournalAlbum, @@ -28,29 +30,101 @@ import { } from "react-bootstrap-icons"; import { Link, generatePath } from "react-router-dom-v5-compat"; import { Badge, Card, CardBody, CardHeader } from "reactstrap"; + +import { Loader } from "../../../../components/Loader"; import { TimeCaption } from "../../../../components/TimeCaption"; -import { - EditButtonLink, - UnderlineArrowLink, -} from "../../../../components/buttons/Button"; +import { UnderlineArrowLink } from "../../../../components/buttons/Button"; import { ABSOLUTE_ROUTES } from "../../../../routing/routes.constants"; import projectPreviewImg from "../../../../styles/assets/projectImagePreview.svg"; -import PermissionsGuard from "../../../permissionsV2/PermissionsGuard"; import type { ProjectMemberListResponse, ProjectMemberResponse, } from "../../../projectsV2/api/projectV2.api"; import { useGetNamespacesByNamespaceSlugQuery, + useGetProjectsByProjectIdQuery, useGetProjectsByProjectIdMembersQuery, } from "../../../projectsV2/api/projectV2.enhanced-api"; +import type { Project } from "../../../projectsV2/api/projectV2.api"; import { useProject } from "../../ProjectPageContainer/ProjectPageContainer"; import { getMemberNameToDisplay, toSortedMembers } from "../../utils/roleUtils"; import useProjectPermissions from "../../utils/useProjectPermissions.hook"; + +import ProjectInformationButton from "./ProjectInformationButton"; import styles from "./ProjectInformation.module.scss"; const MAX_MEMBERS_DISPLAYED = 5; +function ProjectCopyTemplateInformationBox({ project }: { project: Project }) { + const { data: templateProject, isLoading: isLoadingTemplateInformation } = + useGetProjectsByProjectIdQuery( + project.template_id + ? { + projectId: project.template_id, + } + : skipToken + ); + const { data: templateProjectNamespace } = + useGetNamespacesByNamespaceSlugQuery( + templateProject + ? { + namespaceSlug: templateProject.namespace, + } + : skipToken + ); + + if (!project.template_id) return null; + if (isLoadingTemplateInformation) return ; + if (!templateProject || !templateProjectNamespace) { + const projectUrl = generatePath(ABSOLUTE_ROUTES.v2.projects.showById, { + id: project.template_id, + }); + return ( + } + title="Copied from:" + > +
+
+ + {project.template_id} + +
+
+
+ ); + } + const projectUrl = generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { + namespace: templateProject.namespace, + slug: templateProject.slug, + }); + return ( + } + title="Copied from:" + > +
+
+ + {templateProjectNamespace.name ?? templateProjectNamespace.slug} /{" "} + {templateProject.name} + +
+
+
+ ); +} + interface ProjectInformationProps { output?: "plain" | "card"; } @@ -140,6 +214,7 @@ export default function ProjectInformation({

))} + ); return output === "plain" ? ( @@ -160,23 +235,9 @@ export default function ProjectInformation({
- - } - enabled={ - - } - requestedPermission="write" +
diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformationButton.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformationButton.tsx new file mode 100644 index 0000000000..e9237493c3 --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformationButton.tsx @@ -0,0 +1,74 @@ +/*! + * 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, useState } from "react"; + +import { DropdownItem } from "reactstrap"; + +import { SingleButtonWithMenu } from "../../../../components/buttons/Button"; +import BootstrapCopyIcon from "../../../../components/icons/BootstrapCopyIcon"; +import useLegacySelector from "../../../../utils/customHooks/useLegacySelector.hook"; + +import type { Project } from "../../../projectsV2/api/projectV2.api"; +import { useGetUserQuery } from "../../../usersV2/api/users.api"; + +import useProjectPermissions from "../../utils/useProjectPermissions.hook"; +import ProjectCopyModal from "../../ProjectPageHeader/ProjectCopyModal"; + +export default function ProjectInformationButton({ + project, +}: { + userPermissions: ReturnType; + project: Project; +}) { + const { data: currentUser } = useGetUserQuery(); + const [isCopyModalOpen, setCopyModalOpen] = useState(false); + const toggleCopyModal = useCallback(() => { + setCopyModalOpen((open) => !open); + }, []); + const userLogged = useLegacySelector( + (state) => state.stateModel.user.logged + ); + if (!userLogged) return null; + return ( + <> + + + + Copy project + + + { + + } + + ); +} diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx index c83b56da83..bdcb693f48 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx @@ -43,7 +43,9 @@ export default function ProjectOverviewPage() { - +
+ +
); diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx index 4ee8fc2157..ebf1e8a346 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx @@ -17,8 +17,8 @@ */ import cx from "classnames"; import { useCallback, useContext, useEffect, useState } from "react"; -import { Pencil, Sliders } from "react-bootstrap-icons"; -import { useForm } from "react-hook-form"; +import { Diagram3Fill, Pencil, Sliders } from "react-bootstrap-icons"; +import { Controller, useForm } from "react-hook-form"; import { generatePath, useLocation, @@ -30,6 +30,8 @@ import { CardBody, CardHeader, Form, + FormGroup, + FormText, Input, Label, } from "reactstrap"; @@ -126,6 +128,7 @@ function ProjectSettingsEditForm({ project }: ProjectPageSettingsProps) { namespace: project.namespace, visibility: project.visibility, keywords: project.keywords ?? [], + is_template: project.is_template ?? false, }, }); const currentNamespace = watch("namespace"); @@ -161,6 +164,7 @@ function ProjectSettingsEditForm({ project }: ProjectPageSettingsProps) { namespace: updatedProject.namespace, visibility: updatedProject.visibility, keywords: updatedProject.keywords ?? [], + is_template: updatedProject.is_template ?? false, }); } }, [isSuccess, reset, updatedProject]); @@ -261,6 +265,44 @@ function ProjectSettingsEditForm({ project }: ProjectPageSettingsProps) { setDirty={setKeywordsDirty} value={project.keywords as string[]} /> +
+
Template
+ { + const { value, ...props } = field; + return ( +
+ + + + +
+ ); + }} + /> + + Make this a template project to indicate to viewers that this + project should be copied before being used. + +
+
+
Template
+
+ + + + +
+
); } +function ProjectSettingsTemplateLink({ project }: { project: Project }) { + if (project.template_id === null) return null; + return ( + + +

+ + Break template link +

+

+ This will break the link between this project and the template it was + created from. +

+
+ +
+
(Not yet implemented)
+
+
+
+
+ ); +} + function ProjectSettingsMetadata({ project }: ProjectPageSettingsProps) { const permissions = useProjectPermissions({ projectId: project.id }); @@ -393,6 +478,12 @@ export default function ProjectPageSettings() { + } + requestedPermission="write" + userPermissions={permissions} + /> } diff --git a/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectCopyBanner.tsx b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectCopyBanner.tsx new file mode 100644 index 0000000000..eeda509e6e --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectCopyBanner.tsx @@ -0,0 +1,309 @@ +/*! + * 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, useState } from "react"; +import { Button } from "reactstrap"; +import { + ArrowRight, + BoxArrowInRight, + Diagram3Fill, +} from "react-bootstrap-icons"; +import { Link, generatePath } from "react-router-dom-v5-compat"; + +import useLegacySelector from "../../../utils/customHooks/useLegacySelector.hook"; +import { useLoginUrl } from "../../../authentication/useLoginUrl.hook"; +import { Loader } from "../../../components/Loader"; +import PrimaryAlert from "../../../components/PrimaryAlert"; +import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; + +import PermissionsGuard from "../../permissionsV2/PermissionsGuard"; +import type { Project } from "../../projectsV2/api/projectV2.api"; +import { useGetProjectsByProjectIdCopiesQuery } from "../../projectsV2/api/projectV2.enhanced-api"; +import { useGetUserQuery } from "../../usersV2/api/users.api"; + +import useProjectPermissions from "../utils/useProjectPermissions.hook"; + +import ProjectCopyModal from "./ProjectCopyModal"; +import { ProjectCopyListModal } from "./ProjectTemplateInfoBanner"; + +import BootstrapCopyIcon from "../../../components/icons/BootstrapCopyIcon"; + +interface ProjectCopyBannerComponentProps { + currentUser: ReturnType["data"]; + project: Project; + toggleModalOpen: () => void; +} + +interface ProjectCopyButtonProps + extends Omit { + color: string; +} +function ProjectCopyButton({ color, toggleModalOpen }: ProjectCopyButtonProps) { + const buttonColor = `outline-${color}`; + + return ( +
+ +
+ ); +} + +function ProjectViewerMakeCopyBanner({ + project, + toggleModalOpen, +}: Omit) { + const isUserLoggedIn = useLegacySelector( + (state) => state.stateModel.user.logged + ); + const loginUrl = useLoginUrl(); + return ( + }> +
+
+
+ This project is a template +
+
+ To work with this project, first make a copy. + {!isUserLoggedIn && ( + To make a copy, you must first log in. + )} +
+
+
+ {isUserLoggedIn ? ( + + ) : ( + + )} +
+
+
+ ); +} + +interface ProjectGoToCopyBannerProps + extends Omit< + ProjectCopyBannerComponentProps, + "currentUser" | "toggleModalOpen" + > { + writableCopies: ReturnType< + typeof useGetProjectsByProjectIdCopiesQuery + >["data"]; +} + +function ProjectViewerGoToCopyBanner({ + project, + writableCopies, +}: ProjectGoToCopyBannerProps) { + const [isModalOpen, setModalOpen] = useState(false); + const toggleOpen = useCallback(() => { + setModalOpen((open) => !open); + }, []); + const firstCopy: Project = writableCopies[0]; + const firstUrl = generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { + namespace: firstCopy.namespace, + slug: firstCopy.slug, + }); + return ( + <> + }> +
+
+
This project is a template
+
+ {writableCopies.length > 1 ? ( + + You have{" "} + + {writableCopies.length} + {" "} + copies of this project. + + ) : ( + You already have a project created from this template. + )} +
+
+
+
+ {writableCopies.length > 1 ? ( + + ) : ( + + + Go to my copy + + )} +
+
+
+
+ {isModalOpen && ( + + )} + + ); +} + +function ProjectViewerCopyBanner({ + currentUser, + project, + toggleModalOpen, +}: ProjectCopyBannerComponentProps) { + const { data: writableCopies } = useGetProjectsByProjectIdCopiesQuery({ + projectId: project.id, + writable: true, + }); + const isUserLoggedIn = useLegacySelector( + (state) => state.stateModel.user.logged + ); + if (currentUser == null) return null; + if (project.template_id === null) return null; + if (!isUserLoggedIn) + return ( + + ); + if (writableCopies == null) + return ( + }> +
+
+
+ This project is a template +
+
+
+ +
+
+
+ ); + + if (writableCopies.length > 0) + return ( + + ); + + return ( + + ); +} + +export default function ProjectCopyBanner({ project }: { project: Project }) { + const { data: currentUser } = useGetUserQuery(); + const userPermissions = useProjectPermissions({ projectId: project.id }); + + const [isModalOpen, setModalOpen] = useState(false); + const toggleOpen = useCallback(() => { + setModalOpen((open) => !open); + }, []); + if (currentUser == null) return null; + if (project.template_id === null) return null; + return ( + <> + + } + enabled={null} + requestedPermission="write" + userPermissions={userPermissions} + /> + {isModalOpen && ( + + )} + + ); +} diff --git a/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectCopyButton.tsx b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectCopyButton.tsx new file mode 100644 index 0000000000..530a9c35e3 --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectCopyButton.tsx @@ -0,0 +1,63 @@ +/*! + * 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, useState } from "react"; +import { Button } from "reactstrap"; + +import BootstrapCopyIcon from "../../../components/icons/BootstrapCopyIcon"; + +import { type Project } from "../../projectsV2/api/projectV2.api"; +import { useGetUserQuery } from "../../usersV2/api/users.api"; +import ProjectCopyModal from "./ProjectCopyModal"; + +export default function ProjectCopyButton({ + color, + project, +}: { + color: string; + project: Project; +}) { + const { data: currentUser } = useGetUserQuery(); + const buttonColor = `outline-${color}`; + + const [isModalOpen, setModalOpen] = useState(false); + const toggleOpen = useCallback(() => { + setModalOpen((open) => !open); + }, []); + return ( +
+ + {isModalOpen && ( + + )} +
+ ); +} diff --git a/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectCopyModal.tsx b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectCopyModal.tsx new file mode 100644 index 0000000000..086ce18810 --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectCopyModal.tsx @@ -0,0 +1,233 @@ +/*! + * 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 } from "react"; +import { XLg } from "react-bootstrap-icons"; +import { useForm } from "react-hook-form"; +import { generatePath, useNavigate } from "react-router-dom-v5-compat"; +import { + Button, + Form, + Modal, + ModalBody, + ModalFooter, + ModalHeader, +} from "reactstrap"; + +import { SuccessAlert } from "../../../components/Alert"; +import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert"; +import BootstrapCopyIcon from "../../../components/icons/BootstrapCopyIcon"; +import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; +import { slugFromTitle } from "../../../utils/helpers/HelperFunctions"; + +import { + type Project, + type Visibility, +} from "../../projectsV2/api/projectV2.api"; +import { usePostProjectsByProjectIdCopiesMutation } from "../../projectsV2/api/projectV2.enhanced-api"; +import { useGetUserQuery } from "../../usersV2/api/users.api"; +import ProjectNameFormField from "../../projectsV2/fields/ProjectNameFormField"; +import ProjectNamespaceFormField from "../../projectsV2/fields/ProjectNamespaceFormField"; +import ProjectOwnerSlugFormField from "../../projectsV2/fields/ProjectOwnerSlugFormField"; +import ProjectVisibilityFormField from "../../projectsV2/fields/ProjectVisibilityFormField"; + +interface ProjectCopyModalProps { + currentUser: ReturnType["data"]; + isOpen: boolean; + project: Project; + toggle: () => void; +} + +interface ProjectCopyFormValues { + name: string; + namespace: string; + slug: string; + visibility: Visibility; +} + +export default function ProjectCopyModal({ + currentUser, + isOpen, + project, + toggle, +}: ProjectCopyModalProps) { + const [copyProject, copyProjectResult] = + usePostProjectsByProjectIdCopiesMutation(); + + useEffect(() => { + if (!isOpen) copyProjectResult.reset(); + }, [copyProjectResult, isOpen]); + const { + control, + formState: { errors }, + getValues, + handleSubmit, + setValue, + watch, + } = useForm({ + defaultValues: { + name: project.name, + namespace: currentUser ? currentUser.username : project.namespace, + slug: project.slug, + visibility: project.visibility, + }, + }); + const name = watch("name"); + useEffect(() => { + setValue("slug", slugFromTitle(name, true, true)); + }, [setValue, name]); + const onSubmit = useCallback( + (values: ProjectCopyFormValues) => { + copyProject({ + projectId: project.id, + projectPost: { + name: values.name, + namespace: values.namespace, + slug: values.slug, + visibility: values.visibility, + }, + }); + }, + [copyProject, project.id] + ); + return ( + +
+ + Make a copy of + {project.namespace}/{project.slug} + + +
+ Copying a project will create a new project with the same data + connectors, repositories, and launchers as the original. +
+ {copyProjectResult.error != null && ( +
+ +
+ )} + + + + + {copyProjectResult.data != null && ( +
+ +
+ )} +
+ + + + +
+
+ ); +} + +interface ProjectCopySuccessAlertProps + extends Pick { + project: Project; + hasError: boolean; +} + +function ProjectCopySuccessAlert({ + hasError, + project, + toggle, +}: ProjectCopySuccessAlertProps) { + const navigate = useNavigate(); + const namespace = project.namespace; + const slug = project.slug; + const projectUrl = generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { + namespace, + slug, + }); + return ( + +
+
+ Your project has been copied. + {hasError && ( + + Check the error message for limitations on the new project. + + )} +
+
+ +
+
+
+ ); +} diff --git a/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectPageHeader.tsx b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectPageHeader.tsx index aec83bee2d..171f389a5a 100644 --- a/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectPageHeader.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectPageHeader.tsx @@ -24,6 +24,9 @@ import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; import { Project } from "../../projectsV2/api/projectV2.api"; import { ProjectImageView } from "../ProjectPageContent/ProjectInformation/ProjectInformation"; +import ProjectCopyBanner from "./ProjectCopyBanner"; +import ProjectTemplateInfoBanner from "./ProjectTemplateInfoBanner"; + interface ProjectPageHeaderProps { project: Project; } @@ -60,10 +63,18 @@ export default function ProjectPageHeader({ project }: ProjectPageHeaderProps) { />

)} + {project.is_template && ( + + )} + + + {project.is_template && } + + ); } diff --git a/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.module.css b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.module.css new file mode 100644 index 0000000000..1b541d150a --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.module.css @@ -0,0 +1,9 @@ +.modalBody { + max-height: 50vh; +} + +/* this is needed for correct alignment */ +.projectCopiesButton { + position: relative; + top: -2px; +} diff --git a/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx new file mode 100644 index 0000000000..6dcb00d1cf --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx @@ -0,0 +1,159 @@ +/*! + * 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, useState } from "react"; +import { Button, ListGroup, Modal, ModalBody, ModalHeader } from "reactstrap"; +import { Diagram3Fill } from "react-bootstrap-icons"; + +import PrimaryAlert from "../../../components/PrimaryAlert"; + +import PermissionsGuard from "../../permissionsV2/PermissionsGuard"; +import type { Project } from "../../projectsV2/api/projectV2.api"; +import ProjectShortHandDisplay from "../../projectsV2/show/ProjectShortHandDisplay"; +import { useGetProjectsByProjectIdCopiesQuery } from "../../projectsV2/api/projectV2.api"; +import { useGetUserQuery } from "../../usersV2/api/users.api"; + +import useProjectPermissions from "../utils/useProjectPermissions.hook"; +import styles from "./ProjectTemplateInfoBanner.module.css"; + +interface ProjectCopyListModalProps { + copies: Project[]; + isOpen: boolean; + project: Project; + title: string; + toggle: () => void; +} + +export function ProjectCopyListModal({ + copies, + isOpen, + project, + title, + toggle, +}: ProjectCopyListModalProps) { + return ( + + + {title} + {project.namespace}/{project.slug} + + + + {copies.map((project) => ( + + ))} + + + + ); +} + +function ProjectTemplateEditorBanner({ project }: { project: Project }) { + const { data: currentUser } = useGetUserQuery(); + const { data: copies } = useGetProjectsByProjectIdCopiesQuery({ + projectId: project.id, + }); + const [isModalOpen, setModalOpen] = useState(false); + const toggleOpen = useCallback(() => { + setModalOpen((open) => !open); + }, []); + if (currentUser == null) return null; + if (project.template_id === null) return null; + return ( + <> + +
+ + This project is a template.{" "} + {copies != null && + (copies.length > 1 ? ( + + There are{" "} + {" "} + visible to you. + + ) : copies.length === 1 ? ( + + There is{" "} + {" "} + visible to you. + + ) : ( + + There are{" "} + 0{" "} + copies visible to you. + + ))} +
+
+ {isModalOpen && ( + + )} + + ); +} + +export default function ProjectTemplateInfoBanner({ + project, +}: { + project: Project; +}) { + const { data: currentUser } = useGetUserQuery(); + const userPermissions = useProjectPermissions({ projectId: project.id }); + if (currentUser == null) return null; + if (project.template_id === null) return null; + return ( + } + requestedPermission="write" + userPermissions={userPermissions} + /> + ); +} diff --git a/client/src/features/projectsV2/api/projectV2.enhanced-api.ts b/client/src/features/projectsV2/api/projectV2.enhanced-api.ts index 5350f2acb9..56440923f2 100644 --- a/client/src/features/projectsV2/api/projectV2.enhanced-api.ts +++ b/client/src/features/projectsV2/api/projectV2.enhanced-api.ts @@ -242,6 +242,9 @@ const enhancedApi = injectedApi.enhanceEndpoints({ }), providesTags: ["Project"], }, + getProjectsByProjectIdCopies: { + providesTags: ["Project"], + }, getProjectsByProjectIdDataConnectorLinks: { providesTags: ["DataConnectors"], }, @@ -330,6 +333,9 @@ const enhancedApi = injectedApi.enhanceEndpoints({ ] : ["SessionSecretSlot"], }, + postProjectsByProjectIdCopies: { + invalidatesTags: ["Project"], + }, }, }); @@ -346,17 +352,19 @@ const withInvalidation = enhancedApi.injectEndpoints({ export { withInvalidation as projectV2Api }; export const { // project hooks + useDeleteProjectsByProjectIdMembersAndMemberIdMutation, useGetProjectsPagedQuery: useGetProjectsQuery, - usePostProjectsMutation, useGetNamespacesByNamespaceProjectsAndSlugQuery, + useGetProjectsByProjectIdCopiesQuery, + useGetProjectsByProjectIdPermissionsQuery, useGetProjectsByProjectIdQuery, useGetProjectsByProjectIdsQuery, usePatchProjectsByProjectIdMutation, useDeleteProjectsByProjectIdMutation, useGetProjectsByProjectIdMembersQuery, usePatchProjectsByProjectIdMembersMutation, - useDeleteProjectsByProjectIdMembersAndMemberIdMutation, - useGetProjectsByProjectIdPermissionsQuery, + usePostProjectsMutation, + usePostProjectsByProjectIdCopiesMutation, // project session secret hooks useGetProjectsByProjectIdSessionSecretSlotsQuery, diff --git a/client/src/features/projectsV2/api/projectV2.openapi.json b/client/src/features/projectsV2/api/projectV2.openapi.json index fe8a714950..e7470bd48f 100644 --- a/client/src/features/projectsV2/api/projectV2.openapi.json +++ b/client/src/features/projectsV2/api/projectV2.openapi.json @@ -115,6 +115,14 @@ "$ref": "#/components/schemas/Ulid" } }, + { + "in": "query", + "name": "with_documentation", + "required": false, + "schema": { + "$ref": "#/components/schemas/WithDocumentation" + } + }, { "in": "query", "name": "with_documentation", diff --git a/client/src/features/projectsV2/fields/ProjectOwnerSlugFormField.tsx b/client/src/features/projectsV2/fields/ProjectOwnerSlugFormField.tsx new file mode 100644 index 0000000000..7d3abe63a6 --- /dev/null +++ b/client/src/features/projectsV2/fields/ProjectOwnerSlugFormField.tsx @@ -0,0 +1,71 @@ +/*! + * 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 { useState } from "react"; +import type { + FieldValues, + UseFormGetValues, + UseFormWatch, +} from "react-hook-form"; + +import SlugFormField from "./SlugFormField"; +import type { GenericProjectFormFieldProps } from "./formField.types"; +import { Button } from "reactstrap"; + +interface ProjectOwnerSlugFormFieldProps + extends GenericProjectFormFieldProps { + getValues: UseFormGetValues; + namespaceName: GenericProjectFormFieldProps["name"]; + watch: UseFormWatch; +} + +export default function ProjectOwnerSlugFormField({ + control, + errors, + getValues, + name, + namespaceName, + watch, +}: ProjectOwnerSlugFormFieldProps) { + const [configure, setConfigure] = useState(false); + if (configure) { + return ( +
+ +
+ ); + } + const slug = watch(name); + return ( +
+ The identifier for the copy will be{" "} + + {getValues(namespaceName)}/{slug} + + .{" "} + +
+ ); +} diff --git a/tests/cypress/e2e/projectV2.spec.ts b/tests/cypress/e2e/projectV2.spec.ts index 4927f4e524..40a7514812 100644 --- a/tests/cypress/e2e/projectV2.spec.ts +++ b/tests/cypress/e2e/projectV2.spec.ts @@ -182,9 +182,10 @@ describe("Edit v2 project", () => { .click(); cy.wait("@readProjectV2"); cy.contains("test 2 v2-project").should("be.visible"); - cy.getDataCy("project-settings-edit").should("be.visible").click(); + cy.get("a[title='Settings']").should("be.visible").click(); cy.getDataCy("project-name-input").clear().type("new name"); cy.getDataCy("project-description-input").clear().type("new description"); + cy.getDataCy("project-template").click(); fixtures.readProjectV2({ fixture: "projectV2/update-projectV2-metadata.json", name: "readPostUpdate", @@ -210,7 +211,7 @@ describe("Edit v2 project", () => { .click(); cy.wait("@readProjectV2"); cy.contains("test 2 v2-project").should("be.visible"); - cy.getDataCy("project-settings-edit").should("be.visible").click(); + cy.get("a[title='Settings']").should("be.visible").click(); // Fetch the second page of namespaces cy.wait("@listNamespaceV2"); cy.wait("@readUserV2Namespace"); @@ -444,7 +445,7 @@ describe("Editor cannot maintain members", () => { it("can change project metadata", () => { cy.contains("test 2 v2-project").should("be.visible"); - cy.getDataCy("project-settings-edit").should("be.visible").click(); + cy.get("a[title='Settings']").should("be.visible").click(); cy.contains("a", "Overview").click(); }); @@ -511,3 +512,272 @@ describe("Viewer cannot edit project", () => { cy.getDataCy("add-code-repository").should("not.exist"); }); }); + +describe("Project templates and copies", () => { + beforeEach(() => { + fixtures + .config() + .versions() + .userTest() + .namespaces() + .projects() + .landingUserProjects() + .readProjectV2(); + }); + + it("copy a regular project with edit access", () => { + fixtures.getProjectV2Permissions().listNamespaceV2().copyProjectV2(); + cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + + cy.getDataCy("project-info-card") + .find("[data-cy=button-with-menu-dropdown]") + .click(); + cy.getDataCy("project-copy-menu-item").click(); + cy.contains("Make a copy of user1-uuid/test-2-v2-project").should( + "be.visible" + ); + cy.wait("@listNamespaceV2"); + cy.getDataCy("copy-modal") + .find("[data-cy=project-name-input]") + .clear() + .type("copy project name"); + cy.getDataCy("copy-modal").find("button").contains("Copy").click(); + fixtures.readProjectV2({ + namespace: "e2e", + projectSlug: "copy-project-name", + name: "readProjectCopy", + }); + cy.wait("@copyProjectV2"); + cy.contains("Go to new project").should("be.visible").click(); + cy.wait("@readProjectCopy"); + cy.location("pathname").should("eq", "/v2/projects/e2e/copy-project-name"); + }); + + it("copy a regular project without edit access", () => { + fixtures.listNamespaceV2().copyProjectV2(); + cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + + cy.getDataCy("project-info-card") + .find("[data-cy=button-with-menu-dropdown]") + .click(); + cy.getDataCy("project-copy-menu-item").click(); + cy.contains("Make a copy of user1-uuid/test-2-v2-project").should( + "be.visible" + ); + cy.wait("@listNamespaceV2"); + cy.getDataCy("project-name-input").clear().type("copy project name"); + cy.getDataCy("copy-modal").find("button").contains("Copy").click(); + fixtures.readProjectV2({ + namespace: "e2e", + projectSlug: "copy-project-name", + name: "readProjectCopy", + }); + cy.wait("@copyProjectV2"); + cy.contains("Go to new project").should("be.visible").click(); + cy.wait("@readProjectCopy"); + cy.location("pathname").should("eq", "/v2/projects/e2e/copy-project-name"); + }); + + it("copy a template project", () => { + fixtures + .readProjectV2({ overrides: { is_template: true } }) + .listNamespaceV2() + .copyProjectV2() + .listProjectV2Copies({ count: 0, writeable: true }); + cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + cy.getDataCy("copy-project-button").click(); + cy.contains("Make a copy of user1-uuid/test-2-v2-project").should( + "be.visible" + ); + cy.wait("@listNamespaceV2"); + cy.getDataCy("project-name-input").clear().type("copy project name"); + cy.getDataCy("copy-modal").find("button").contains("Copy").click(); + fixtures.readProjectV2({ + namespace: "e2e", + projectSlug: "copy-project-name", + name: "readProjectCopy", + }); + cy.wait("@copyProjectV2"); + cy.contains("Go to new project").should("be.visible").click(); + cy.wait("@readProjectCopy"); + cy.location("pathname").should("eq", "/v2/projects/e2e/copy-project-name"); + }); + + it("navigate to a template project copy", () => { + fixtures + .readProjectV2({ + projectSlug: "test-2-v2-template", + overrides: { is_template: true }, + }) + .listNamespaceV2() + .copyProjectV2() + .listProjectV2Copies({ count: 1, writeable: true }); + cy.visit("/v2/projects/user1-uuid/test-2-v2-template"); + cy.wait("@readProjectV2"); + cy.wait("@listProjectV2Copies"); + cy.getDataCy("copy-project-button").should("not.exist"); + cy.contains( + "You already have a project created from this template." + ).should("be.visible"); + fixtures.readProjectV2({ + projectSlug: "test-2-v2-project", + name: "readProjectCopy", + }); + cy.contains("Go to my copy").should("be.visible").click(); + cy.wait("@readProjectCopy"); + cy.location("pathname").should( + "eq", + "/v2/projects/user1-uuid/test-2-v2-project" + ); + }); + + it("list template project copies", () => { + fixtures + .readProjectV2({ + projectSlug: "test-2-v2-template", + overrides: { is_template: true }, + }) + .listNamespaceV2() + .listProjectV2Copies({ writeable: true }) + .copyProjectV2(); + cy.visit("/v2/projects/user1-uuid/test-2-v2-template"); + cy.wait("@readProjectV2"); + cy.wait("@listProjectV2Copies"); + cy.getDataCy("copy-project-button").should("not.exist"); + cy.contains("copies of this project.").should("be.visible"); + cy.contains("View my copies").should("be.visible").click(); + cy.contains("My copies of").should("be.visible"); + }); + + it("copy a project with data-connector-error", () => { + fixtures + .readProjectV2({ overrides: { is_template: true } }) + .listNamespaceV2() + .listProjectV2Copies({ count: 0, writeable: true }) + .copyProjectV2({ dataConnectorError: true }); + cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + cy.getDataCy("copy-project-button").click(); + cy.contains("Make a copy of user1-uuid/test-2-v2-project").should( + "be.visible" + ); + cy.wait("@listNamespaceV2"); + cy.getDataCy("project-name-input").clear().type("copy project name"); + cy.getDataCy("copy-modal").find("button").contains("Copy").click(); + fixtures.readProjectV2({ + namespace: "e2e", + projectSlug: "copy-project-name", + name: "readProjectCopy", + }); + cy.wait("@copyProjectV2"); + cy.contains("not all data connectors were included") + .should("be.visible") + .click(); + cy.contains("Close").should("be.visible").click(); + cy.getDataCy("copy-project-button").click(); + cy.getDataCy("copy-modal") + .find("button") + .contains("Copy") + .should("be.enabled"); + }); + + it("copy a project, overriding the slug", () => { + fixtures + .readProjectV2({ overrides: { is_template: true } }) + .listNamespaceV2() + .listProjectV2Copies({ count: 0, writeable: true }) + .copyProjectV2({ dataConnectorError: true, name: "copyProjectV2Fail" }); + cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + cy.getDataCy("copy-project-button").click(); + cy.contains("Make a copy of user1-uuid/test-2-v2-project").should( + "be.visible" + ); + cy.wait("@listNamespaceV2"); + cy.getDataCy("project-name-input").clear().type("copy project name"); + cy.getDataCy("copy-modal").find("button").contains("Copy").click(); + cy.wait("@copyProjectV2Fail"); + cy.get("button").contains("Configure").click(); + cy.getDataCy("project-slug-input").clear().type("copy-of-test2"); + fixtures.copyProjectV2().readProjectV2({ + namespace: "e2e", + projectSlug: "copy-of-test2", + name: "readProjectCopy", + }); + cy.getDataCy("copy-modal").find("button").contains("Copy").click(); + cy.wait("@copyProjectV2"); + cy.contains("Go to new project").should("be.visible").click(); + cy.wait("@readProjectCopy"); + cy.location("pathname").should("eq", "/v2/projects/e2e/copy-of-test2"); + }); + + it("show a template project as editor", () => { + fixtures + .readProjectV2({ overrides: { is_template: true } }) + .getProjectV2Permissions() + .listNamespaceV2() + .listProjectV2Copies({ count: 15 }); + cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + cy.wait("@getProjectV2Permissions"); + cy.wait("@listProjectV2Copies"); + cy.getDataCy("copy-project-button").should("not.exist"); + cy.contains("copies visible to you").should("be.visible"); + cy.getDataCy("list-copies-link").click(); + cy.contains("Projects copied from").should("be.visible"); + }); + + it("show a copied project", () => { + fixtures + .readProjectV2({ + overrides: { + template_id: "TEMPLATE-ULID", + }, + }) + .readProjectV2ById({ + projectId: "TEMPLATE-ULID", + overrides: { + name: "template project", + namespace: "user1-uuid", + slug: "template-project", + }, + }) + .readUserV2Namespace(); + cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + cy.wait("@readProjectV2ById"); + cy.contains("Copied from:").should("be.visible"); + }); + + it("break the template link", () => { + fixtures.getProjectV2Permissions().listNamespaceV2().copyProjectV2(); + cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + + cy.get("a[title='Settings']").should("be.visible").click(); + cy.contains("Break template link").should("be.visible").click(); + }); +}); + +describe("Anonymous project copy experience", () => { + beforeEach(() => { + fixtures + .config() + .versions() + .userNone() + .namespaces() + .projects() + .landingUserProjects() + .readProjectV2(); + }); + + it("copy as an anonymous user", () => { + fixtures.readProjectV2({ overrides: { is_template: true } }); + cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); + cy.wait("@readProjectV2"); + cy.contains("To make a copy, you must first log in.").should("be.visible"); + }); +}); diff --git a/tests/cypress/fixtures/namespaceV2/list-namespaceV2.json b/tests/cypress/fixtures/namespaceV2/list-namespaceV2.json index 7097ea55a5..54448c60f8 100644 --- a/tests/cypress/fixtures/namespaceV2/list-namespaceV2.json +++ b/tests/cypress/fixtures/namespaceV2/list-namespaceV2.json @@ -5,6 +5,12 @@ "created_by": "user1-uuid", "namespace_kind": "user" }, + { + "id": "AB0134CD43Z3JF9DX88B7XB405N0", + "slug": "e2e", + "created_by": "user1-uuid", + "namespace_kind": "user" + }, { "id": "THEPROJECTULID26CHARACTERS", "name": "test 2 group-v2", diff --git a/tests/cypress/fixtures/namespaceV2/namespaceV2-user1.json b/tests/cypress/fixtures/namespaceV2/namespaceV2-user1.json index f0b9311f53..9cbb7ab835 100644 --- a/tests/cypress/fixtures/namespaceV2/namespaceV2-user1.json +++ b/tests/cypress/fixtures/namespaceV2/namespaceV2-user1.json @@ -2,5 +2,6 @@ "id": "AB0134CD43Z3JF9DX88B7XB405N0", "slug": "user1-uuid", "created_by": "user1-uuid", - "namespace_kind": "user" + "namespace_kind": "user", + "name": "user1" } diff --git a/tests/cypress/fixtures/projectV2/update-projectV2-metadata.json b/tests/cypress/fixtures/projectV2/update-projectV2-metadata.json index 0092a5b0f7..f3df9e52a1 100644 --- a/tests/cypress/fixtures/projectV2/update-projectV2-metadata.json +++ b/tests/cypress/fixtures/projectV2/update-projectV2-metadata.json @@ -11,5 +11,6 @@ ], "visibility": "public", "description": "new description", + "is_template": true, "secrets_mount_directory": "/secrets" } diff --git a/tests/cypress/support/renkulab-fixtures/projectV2.ts b/tests/cypress/support/renkulab-fixtures/projectV2.ts index 3f93f91c9a..d548fb2114 100644 --- a/tests/cypress/support/renkulab-fixtures/projectV2.ts +++ b/tests/cypress/support/renkulab-fixtures/projectV2.ts @@ -19,6 +19,18 @@ import { FixturesConstructor } from "./fixtures"; import { NameOnlyFixture, SimpleFixture } from "./fixtures.types"; +interface ProjectOverrides { + id: string; + name: string; + namespace: string; + slug: string; + visibility: string; + description?: string; + keywords?: string[]; + template_id?: string; + is_template?: boolean; +} + /** * Fixtures for New Project */ @@ -40,15 +52,27 @@ interface ListProjectV2MembersFixture extends ProjectV2IdArgs { interface ProjectV2IdArgs extends SimpleFixture { projectId?: string; + overrides?: Partial; +} + +interface ProjectV2CopyFixture extends ProjectV2IdArgs { + dataConnectorError?: boolean; } interface ProjectV2DeleteFixture extends NameOnlyFixture { projectId?: string; } +interface ProjectV2ListCopiesFixture + extends Omit { + writeable?: boolean; + count?: 0 | 1 | undefined | null; +} + interface ProjectV2NameArgs extends SimpleFixture { namespace?: string; projectSlug?: string; + overrides?: Partial; } interface ProjectV2PatchOrDeleteMemberFixture extends ProjectV2IdArgs { @@ -80,6 +104,41 @@ export function generateProjects(numberOfProjects: number, start: number) { export function ProjectV2(Parent: T) { return class ProjectV2Fixtures extends Parent { + copyProjectV2(args?: ProjectV2CopyFixture) { + const { + fixture = "projectV2/create-projectV2.json", + projectId = "THEPROJECTULID26CHARACTERS", + name = "copyProjectV2", + dataConnectorError = false, + } = args ?? {}; + cy.fixture(fixture).then((project) => { + cy.intercept( + "POST", + `/ui-server/api/data/projects/${projectId}/copies`, + (req) => { + const newProject = req.body; + expect(newProject.name).to.not.be.undefined; + expect(newProject.namespace).to.not.be.undefined; + expect(newProject.slug).to.not.be.undefined; + expect(newProject.visibility).to.not.be.undefined; + if (dataConnectorError) { + const body = { + error: { + code: 1404, + message: `The project was copied to ${newProject.namespace}/${newProject.slug}, but not all data connectors were included.`, + }, + }; + req.reply({ body, statusCode: 403, delay: 1000 }); + return; + } + const body = { ...project, ...newProject }; + req.reply({ body, statusCode: 201, delay: 1000 }); + } + ).as(name); + }); + return this; + } + createProjectV2(args?: SimpleFixture) { const { fixture = "projectV2/create-projectV2.json", @@ -181,6 +240,41 @@ export function ProjectV2(Parent: T) { return this; } + listProjectV2Copies(args?: ProjectV2ListCopiesFixture) { + const { + fixture = "projectV2/list-projectV2.json", + name = "listProjectV2Copies", + projectId = "THEPROJECTULID26CHARACTERS", + writeable = false, + count = null, + } = args ?? {}; + + cy.fixture(fixture).then((projects) => { + const url = writeable + ? `/ui-server/api/data/projects/${projectId}/copies?writable=true` + : `/ui-server/api/data/projects/${projectId}/copies?`; + cy.intercept("GET", url, (req) => { + if (count === 0) { + req.reply({ body: [], statusCode: 200, delay: 1000 }); + return; + } + if (count === 1) { + const body = [projects[0]]; + req.reply({ body, statusCode: 200, delay: 1000 }); + return; + } + if (count > 2) { + const body = generateProjects(count, 0); + req.reply({ body, statusCode: 200, delay: 1000 }); + return; + } + const body = projects; + req.reply({ body, statusCode: 200, delay: 1000 }); + }).as(name); + }); + return this; + } + getProjectV2Permissions(args?: ProjectV2IdArgs) { const { fixture = "projectV2/projectV2-permissions.json", @@ -251,7 +345,7 @@ export function ProjectV2(Parent: T) { }; cy.intercept( "GET", - `/ui-server/api/data/namespaces/${namespace}/projects/${projectSlug}`, + `/ui-server/api/data/namespaces/${namespace}/projects/${projectSlug}*`, response ).as(name); return this; @@ -263,13 +357,21 @@ export function ProjectV2(Parent: T) { name = "readProjectV2", namespace = "user1-uuid", projectSlug = "test-2-v2-project", + overrides = {}, } = args ?? {}; - const response = { fixture }; - cy.intercept( - "GET", - `/ui-server/api/data/namespaces/${namespace}/projects/${projectSlug}`, - response - ).as(name); + cy.fixture(fixture).then((project) => { + const response = { + ...project, + namespace, + slug: projectSlug, + ...overrides, + }; + cy.intercept( + "GET", + `/ui-server/api/data/namespaces/${namespace}/projects/${projectSlug}*`, + response + ).as(name); + }); return this; } @@ -278,13 +380,14 @@ export function ProjectV2(Parent: T) { fixture = "projectV2/read-projectV2.json", name = "readProjectV2ById", projectId = "THEPROJECTULID26CHARACTERS", + overrides = {}, } = args ?? {}; cy.fixture(fixture).then((project) => { cy.intercept( "GET", - `/ui-server/api/data/projects/${projectId}`, + `/ui-server/api/data/projects/${projectId}*`, (req) => { - const response = { ...project, id: projectId }; + const response = { ...project, ...overrides, id: projectId }; req.reply({ body: response, delay: 1000 }); } ).as(name); From 20b95e50aa3e79a39869f437917f31480df83727 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Wed, 18 Dec 2024 15:49:18 +0100 Subject: [PATCH 2/7] feat: break copy link (#3448) --- .../Settings/ProjectSettings.tsx | 27 +--- .../Settings/ProjectUnlinkTemplate.tsx | 122 ++++++++++++++++++ tests/cypress/e2e/projectV2.spec.ts | 19 ++- .../e2e/projectV2SessionSecrets.spec.ts | 16 +-- .../support/renkulab-fixtures/projectV2.ts | 9 +- 5 files changed, 156 insertions(+), 37 deletions(-) create mode 100644 client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectUnlinkTemplate.tsx diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx index ebf1e8a346..c361ce8aba 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx @@ -59,6 +59,7 @@ import useProjectPermissions from "../../utils/useProjectPermissions.hook"; import ProjectSessionSecrets from "../SessionSecrets/ProjectSessionSecrets"; import ProjectPageDelete from "./ProjectDelete"; import ProjectPageSettingsMembers from "./ProjectSettingsMembers"; +import ProjectUnlinkTemplate from "./ProjectUnlinkTemplate"; function notificationProjectUpdated( notifications: NotificationsManager, @@ -381,30 +382,6 @@ function ProjectSettingsDisplay({ project }: ProjectPageSettingsProps) { ); } -function ProjectSettingsTemplateLink({ project }: { project: Project }) { - if (project.template_id === null) return null; - return ( - - -

- - Break template link -

-

- This will break the link between this project and the template it was - created from. -

-
- -
-
(Not yet implemented)
-
-
-
-
- ); -} - function ProjectSettingsMetadata({ project }: ProjectPageSettingsProps) { const permissions = useProjectPermissions({ projectId: project.id }); @@ -480,7 +457,7 @@ export default function ProjectPageSettings() { } + enabled={} requestedPermission="write" userPermissions={permissions} /> diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectUnlinkTemplate.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectUnlinkTemplate.tsx new file mode 100644 index 0000000000..9cab25404b --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectUnlinkTemplate.tsx @@ -0,0 +1,122 @@ +/*! + * 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, useContext, useEffect, useState } from "react"; +import { Diagram3Fill, NodeMinus } from "react-bootstrap-icons"; +import { Button, Card, CardBody, CardHeader, Input } from "reactstrap"; + +import { Loader } from "../../../../components/Loader"; +import { NOTIFICATION_TOPICS } from "../../../../notifications/Notifications.constants"; +import { NotificationsManager } from "../../../../notifications/notifications.types"; +import AppContext from "../../../../utils/context/appContext"; +import { Project } from "../../../projectsV2/api/projectV2.api"; +import { usePatchProjectsByProjectIdMutation } from "../../../projectsV2/api/projectV2.enhanced-api"; + +export function notificationProjectDeleted( + notifications: NotificationsManager, + projectName: string +) { + notifications.addSuccess( + NOTIFICATION_TOPICS.PROJECT_UPDATED, + <> + Project {projectName} successfully unlinked. + + ); +} + +interface ProjectUnlinkTemplateProps { + project: Project; +} +export default function ProjectUnlinkTemplate({ + project, +}: ProjectUnlinkTemplateProps) { + const [patchProject, result] = usePatchProjectsByProjectIdMutation(); + const { notifications } = useContext(AppContext); + const onUnlink = useCallback(() => { + patchProject({ + projectId: project.id, + "If-Match": project.etag ?? "", + projectPatch: { + template_id: "", + }, + }); + }, [patchProject, project.etag, project.id]); + + useEffect(() => { + if (result.isSuccess) { + if (notifications) + notificationProjectDeleted(notifications, project.name); + result.reset(); + } + }, [notifications, project.name, result]); + + const [typedName, setTypedName] = useState(""); + const onChange = useCallback( + (e: React.ChangeEvent) => { + setTypedName(e.target.value.trim()); + }, + [setTypedName] + ); + + if (project.template_id == null) return null; + return ( + + +

+ + Break template link +

+

+ This will break the link between this project and the template it was + created from. +

+
+ +

+ Are you sure you want to unlink this project from its template? +

+

+ This cannot be undone. Please type {project.slug}, + the slug of the project, to confirm. +

+
+ +
+
+ +
+
+
+ ); +} diff --git a/tests/cypress/e2e/projectV2.spec.ts b/tests/cypress/e2e/projectV2.spec.ts index 40a7514812..6a312dbc07 100644 --- a/tests/cypress/e2e/projectV2.spec.ts +++ b/tests/cypress/e2e/projectV2.spec.ts @@ -425,6 +425,7 @@ describe("Editor cannot maintain members", () => { .dataServicesUser({ response: { id: "user3-uuid", + username: "user3", }, }) .namespaces(); @@ -477,6 +478,7 @@ describe("Viewer cannot edit project", () => { .dataServicesUser({ response: { id: "user2-uuid", + username: "user2", }, }) .namespaces(); @@ -753,12 +755,25 @@ describe("Project templates and copies", () => { }); it("break the template link", () => { - fixtures.getProjectV2Permissions().listNamespaceV2().copyProjectV2(); + fixtures + .readProjectV2({ + overrides: { + template_id: "TEMPLATE-ULID", + }, + }) + .getProjectV2Permissions() + .listNamespaceV2() + .copyProjectV2() + .updateProjectV2(); cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); cy.wait("@readProjectV2"); cy.get("a[title='Settings']").should("be.visible").click(); - cy.contains("Break template link").should("be.visible").click(); + cy.contains("Break template link").should("be.visible"); + cy.contains("Unlink project").should("be.disabled"); + cy.getDataCy("unlink-confirmation-input").clear().type("test-2-v2-project"); + cy.contains("Unlink project").should("be.enabled").click(); + cy.wait("@updateProjectV2"); }); }); diff --git a/tests/cypress/e2e/projectV2SessionSecrets.spec.ts b/tests/cypress/e2e/projectV2SessionSecrets.spec.ts index 8bd8b0a64c..97543375e3 100644 --- a/tests/cypress/e2e/projectV2SessionSecrets.spec.ts +++ b/tests/cypress/e2e/projectV2SessionSecrets.spec.ts @@ -31,7 +31,7 @@ describe("Project Session secret slots", () => { it("Shows the session secrets section", () => { cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); cy.contains("test 2 v2-project").should("be.visible"); - cy.getDataCy("project-settings-edit").click(); + cy.get("a[title='Settings']").should("be.visible").click(); cy.getDataCy("project-settings-session-secrets") .contains("Session secret slots") @@ -43,7 +43,7 @@ describe("Project Session secret slots", () => { cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); cy.contains("test 2 v2-project").should("be.visible"); - cy.getDataCy("project-settings-edit").click(); + cy.get("a[title='Settings']").should("be.visible").click(); cy.getDataCy("project-settings-session-secrets") .contains("Session secret slots") @@ -70,7 +70,7 @@ describe("Project Session secret slots", () => { cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); cy.contains("test 2 v2-project").should("be.visible"); - cy.getDataCy("project-settings-edit").click(); + cy.get("a[title='Settings']").should("be.visible").click(); cy.getDataCy("project-settings-session-secrets") .contains("Session secret slots") @@ -125,7 +125,7 @@ describe("Project Session secret slots", () => { cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); cy.contains("test 2 v2-project").should("be.visible"); - cy.getDataCy("project-settings-edit").click(); + cy.get("a[title='Settings']").should("be.visible").click(); cy.getDataCy("project-settings-session-secrets") .contains("Session secret slots") @@ -174,7 +174,7 @@ describe("Project Session secret slots", () => { cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); cy.contains("test 2 v2-project").should("be.visible"); - cy.getDataCy("project-settings-edit").click(); + cy.get("a[title='Settings']").should("be.visible").click(); cy.getDataCy("project-settings-session-secrets") .contains("Session secret slots") @@ -224,7 +224,7 @@ describe("Project Session secret slots", () => { cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); cy.contains("test 2 v2-project").should("be.visible"); - cy.getDataCy("project-settings-edit").click(); + cy.get("a[title='Settings']").should("be.visible").click(); cy.getDataCy("project-settings-session-secrets") .contains("Session secret slots") @@ -281,7 +281,7 @@ describe("Project Session secret slots", () => { cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); cy.contains("test 2 v2-project").should("be.visible"); - cy.getDataCy("project-settings-edit").click(); + cy.get("a[title='Settings']").should("be.visible").click(); cy.getDataCy("project-settings-session-secrets") .contains("Session secret slots") @@ -344,7 +344,7 @@ describe("Project Session secret slots", () => { cy.visit("/v2/projects/user1-uuid/test-2-v2-project"); cy.contains("test 2 v2-project").should("be.visible"); - cy.getDataCy("project-settings-edit").click(); + cy.get("a[title='Settings']").should("be.visible").click(); cy.getDataCy("project-settings-session-secrets") .contains("Session secret slots") diff --git a/tests/cypress/support/renkulab-fixtures/projectV2.ts b/tests/cypress/support/renkulab-fixtures/projectV2.ts index d548fb2114..2a8842c35e 100644 --- a/tests/cypress/support/renkulab-fixtures/projectV2.ts +++ b/tests/cypress/support/renkulab-fixtures/projectV2.ts @@ -50,6 +50,11 @@ interface ListProjectV2MembersFixture extends ProjectV2IdArgs { }; } +interface ProjectV2CreateArgs extends SimpleFixture { + slug?: string; + namespace?: string; +} + interface ProjectV2IdArgs extends SimpleFixture { projectId?: string; overrides?: Partial; @@ -66,7 +71,7 @@ interface ProjectV2DeleteFixture extends NameOnlyFixture { interface ProjectV2ListCopiesFixture extends Omit { writeable?: boolean; - count?: 0 | 1 | undefined | null; + count?: number | null; } interface ProjectV2NameArgs extends SimpleFixture { @@ -139,7 +144,7 @@ export function ProjectV2(Parent: T) { return this; } - createProjectV2(args?: SimpleFixture) { + createProjectV2(args?: ProjectV2CreateArgs) { const { fixture = "projectV2/create-projectV2.json", name = "createProjectV2", From a4a6efa3ea3fbe8354b109d94020ba41b5c51246 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Mon, 6 Jan 2025 11:13:19 +0100 Subject: [PATCH 3/7] minor: remove unnecessary button workaround --- client/src/components/buttons/Button.tsx | 8 +------- .../ProjectInformation/ProjectInformationButton.tsx | 6 +----- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/client/src/components/buttons/Button.tsx b/client/src/components/buttons/Button.tsx index c84a9bb6ac..7818b7b2dc 100644 --- a/client/src/components/buttons/Button.tsx +++ b/client/src/components/buttons/Button.tsx @@ -180,16 +180,10 @@ export function SingleButtonWithMenu({ direction, disabled, id, - preventPropagation, size, -}: Omit) { - // ! Temporary workaround to quickly implement a design solution -- to be removed ASAP #3250 - const additionalProps = preventPropagation - ? { onClick: (e: React.MouseEvent) => e.stopPropagation() } - : {}; +}: Omit) { return ( - + Date: Mon, 6 Jan 2025 11:14:14 +0100 Subject: [PATCH 4/7] minor: remove alert height limit rule --- client/src/components/PrimaryAlert.module.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/components/PrimaryAlert.module.scss b/client/src/components/PrimaryAlert.module.scss index f92a1ad6b0..55c7ebb156 100644 --- a/client/src/components/PrimaryAlert.module.scss +++ b/client/src/components/PrimaryAlert.module.scss @@ -5,5 +5,4 @@ .primaryAlert { --bs-primary-bg-subtle: #{tint-color($primary, 90%)}; - max-height: 50vh; } From a195420b5cc58a0eb6a2b64f060a927c881512ca Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Mon, 6 Jan 2025 11:18:00 +0100 Subject: [PATCH 5/7] minor: make only the word copies a click target --- .../ProjectPageHeader/ProjectTemplateInfoBanner.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx index 6dcb00d1cf..1ca54462c1 100644 --- a/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx @@ -91,15 +91,15 @@ function ProjectTemplateEditorBanner({ project }: { project: Project }) { (copies.length > 1 ? ( There are{" "} + + {copies.length} + {" "} {" "} visible to you. @@ -107,13 +107,14 @@ function ProjectTemplateEditorBanner({ project }: { project: Project }) { ) : copies.length === 1 ? ( There is{" "} + 1{" "} {" "} visible to you. From c86fcc9f6a73812cc3ac90a140cf7589af09cd61 Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Mon, 6 Jan 2025 11:35:50 +0100 Subject: [PATCH 6/7] minor: switch to ScrollableModal --- .../ProjectPageHeader/ProjectTemplateInfoBanner.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx index 1ca54462c1..02b7083cf6 100644 --- a/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx @@ -17,10 +17,11 @@ */ import cx from "classnames"; import { useCallback, useState } from "react"; -import { Button, ListGroup, Modal, ModalBody, ModalHeader } from "reactstrap"; +import { Button, ListGroup, ModalBody, ModalHeader } from "reactstrap"; import { Diagram3Fill } from "react-bootstrap-icons"; import PrimaryAlert from "../../../components/PrimaryAlert"; +import ScrollableModal from "../../../components/modal/ScrollableModal"; import PermissionsGuard from "../../permissionsV2/PermissionsGuard"; import type { Project } from "../../projectsV2/api/projectV2.api"; @@ -47,7 +48,7 @@ export function ProjectCopyListModal({ toggle, }: ProjectCopyListModalProps) { return ( - - + ); } From 44455c28e59968780314cc8c2bcddaa7c33649ac Mon Sep 17 00:00:00 2001 From: Chandrasekhar Ramakrishnan Date: Mon, 6 Jan 2025 11:58:41 +0100 Subject: [PATCH 7/7] minor: no need for extra scrollbar --- .../ProjectPageHeader/ProjectTemplateInfoBanner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx index 02b7083cf6..7382f6780f 100644 --- a/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageHeader/ProjectTemplateInfoBanner.tsx @@ -60,7 +60,7 @@ export function ProjectCopyListModal({ {title} {project.namespace}/{project.slug} - + {copies.map((project) => (