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..55c7ebb156
--- /dev/null
+++ b/client/src/components/PrimaryAlert.module.scss
@@ -0,0 +1,8 @@
+@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%)};
+}
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..7818b7b2dc 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 (
) {
+ 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:"
+ >
+
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..a427a815e8
--- /dev/null
+++ b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformationButton.tsx
@@ -0,0 +1,70 @@
+/*!
+ * 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() {
+ );
+ }}
+ />
+
+ Make this a template project to indicate to viewers that this
+ project should be copied before being used.
+
+
+
+
Template
+
+
+
+
+
+
+
);
@@ -393,6 +455,12 @@ export default function ProjectPageSettings() {
+ }
+ 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/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 (
+
+ {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
+
+ )}
+