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 (
+
+
+
+
+
+ Mark this project as a template
+
+
+
+ );
+ }}
+ />
+
+ Make this a template project to indicate to viewers that this
+ project should be copied before being used.
+
+
+
+
Template
+
+
+
+
+
+ Template Project
+
+
+
+
);
}
+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 (
+
+
+
+ Copy project
+
+
+ );
+}
+
+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 ? (
+
+
+ View my copies
+
+ ) : (
+
+
+ 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 (
+
+
+
+ Copy project
+
+ {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 (
+
+
+
+ );
+}
+
+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.
+
+ )}
+
+
+ {
+ toggle();
+ navigate(projectUrl);
+ }}
+ >
+ Go to 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{" "}
+
+
+ {copies.length}
+ {" "}
+ copies
+ {" "}
+ visible to you.
+
+ ) : copies.length === 1 ? (
+
+ There is{" "}
+
+ 1 copy
+ {" "}
+ 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}
+
+ .{" "}
+ setConfigure(true)}>
+ Configure
+
+
+ );
+}
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.
+
+
+
+
+
+
+ {result.isLoading ? (
+
+ ) : (
+
+ )}
+ Unlink project
+
+
+
+
+ );
+}
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}
+ {" "}
-
- {copies.length}
- {" "}
copies
{" "}
visible to you.
@@ -107,13 +107,14 @@ function ProjectTemplateEditorBanner({ project }: { project: Project }) {
) : copies.length === 1 ? (
There is{" "}
+ 1 {" "}
- 1 copy
+ copy
{" "}
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) => (