diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx index 48e5b693d1..79b9d6f60b 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx @@ -71,7 +71,7 @@ function notificationProjectUpdated( function ProjectReadOnlyNamespaceField({ namespace }: { namespace: string }) { return ( -
+
@@ -93,7 +93,7 @@ function ProjectReadOnlyVisibilityField({ visibility: string; }) { return ( -
+
@@ -193,8 +193,13 @@ function ProjectSettingsEditForm({ project }: ProjectPageSettingsProps) { )} -
+ + @@ -212,11 +217,19 @@ function ProjectSettingsEditForm({ project }: ProjectPageSettingsProps) { userPermissions={permissions} /> {currentNamespace !== project.namespace && ( - - Modifying the namespace also change the project's URL. Once the - change is saved, it will redirect to the updated project URL. - +
+ + Modifying the owner also change the project's URL. Once the + change is saved, it will redirect to the updated project URL. + +
)} + @@ -231,20 +244,29 @@ function ProjectSettingsEditForm({ project }: ProjectPageSettingsProps) { requestedPermission="delete" userPermissions={permissions} /> - - !areKeywordsDirty })} - setDirty={setKeywordsDirty} - value={project.keywords as string[]} - /> + +
+ +
+ +
+ !areKeywordsDirty, + })} + setDirty={setKeywordsDirty} + value={project.keywords as string[]} + /> +
+
+ ); +} diff --git a/client/src/features/groupsV2/new/GroupNew.tsx b/client/src/features/groupsV2/new/GroupNew.tsx new file mode 100644 index 0000000000..1fe4592e44 --- /dev/null +++ b/client/src/features/groupsV2/new/GroupNew.tsx @@ -0,0 +1,285 @@ +/*! + * Copyright 2024 - Swiss Data Science Center (SDSC) + * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and + * Eidgenössische Technische Hochschule Zürich (ETHZ). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import cx from "classnames"; +import { useCallback, useEffect, useState } from "react"; +import { CheckLg, ChevronDown, People, XLg } from "react-bootstrap-icons"; +import { useForm } from "react-hook-form"; +import { generatePath, useNavigate } from "react-router-dom-v5-compat"; +import { + Button, + Collapse, + Form, + FormGroup, + FormText, + Modal, + ModalBody, + ModalFooter, + ModalHeader, +} from "reactstrap"; + +import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert"; +import { Loader } from "../../../components/Loader"; +import LoginAlert from "../../../components/loginAlert/LoginAlert"; +import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; +import useLocationHash from "../../../utils/customHooks/useLocationHash.hook"; +import { slugFromTitle } from "../../../utils/helpers/HelperFunctions"; +import type { GroupPostRequest } from "../../projectsV2/api/namespace.api"; +import { usePostGroupsMutation } from "../../projectsV2/api/projectV2.enhanced-api"; +import DescriptionFormField from "../../projectsV2/fields/DescriptionFormField"; +import NameFormField from "../../projectsV2/fields/NameFormField"; +import SlugFormField from "../../projectsV2/fields/SlugFormField"; +import { useGetUserQuery } from "../../usersV2/api/users.api"; + +export default function GroupNew() { + const { data: userInfo, isLoading: userLoading } = useGetUserQuery(); + + const [hash, setHash] = useLocationHash(); + const groupCreationHash = "createGroup"; + const showGroupCreationModal = hash === groupCreationHash; + const toggleModal = useCallback(() => { + setHash((prev) => { + const isOpen = prev === groupCreationHash; + return isOpen ? "" : groupCreationHash; + }); + }, [setHash]); + + return ( + <> + + +

+ Create a new group +

+

+ Groups let you group together related projects and control who can + access them. +

+
+ +
+ {userLoading ? ( + + + + ) : userInfo?.isLoggedIn ? ( + + ) : ( + + + + )} +
+
+ + ); +} + +function GroupV2CreationDetails() { + const [isCollapseOpen, setIsCollapseOpen] = useState(false); + const toggleCollapse = () => setIsCollapseOpen(!isCollapseOpen); + + const [createGroup, result] = usePostGroupsMutation(); + const navigate = useNavigate(); + + const [, setHash] = useLocationHash(); + const closeModal = useCallback(() => { + setHash(); + }, [setHash]); + + // Form initialization + const { + control, + formState: { dirtyFields, errors }, + handleSubmit, + setValue, + watch, + } = useForm({ + mode: "onChange", + defaultValues: { + description: "", + name: "", + slug: "", + }, + }); + + // We watch for changes in the name and derive the slug from it + const currentName = watch("name"); + useEffect(() => { + setValue("slug", slugFromTitle(currentName, true, true), { + shouldValidate: true, + }); + }, [currentName, setValue]); + + // Slug is use to show the projected URL + const currentSlug = watch("slug"); + + // Group creation utilities + const onSubmit = useCallback( + (groupPostRequest: GroupPostRequest) => { + createGroup({ groupPostRequest }); + }, + [createGroup] + ); + + useEffect(() => { + if (result.isSuccess) { + const groupUrl = generatePath(ABSOLUTE_ROUTES.v2.groups.show.root, { + slug: result.data.slug, + }); + navigate(groupUrl); + } + }, [result, navigate]); + + const nameHelpText = ( + + The URL for this group will be{" "} + + renkulab.io/v2/groups/{currentSlug || ""} + + + ); + + const resetUrl = useCallback(() => { + setValue("slug", slugFromTitle(currentName, true, true), { + shouldValidate: true, + }); + }, [setValue, currentName]); + + return ( + <> + + + +
+
+
+ +
+
+ + +
+ renkulab.io/v2/groups/ + +
+
+ + {dirtyFields.slug && !dirtyFields.name ? ( +
+

+ Mind the URL will be updated once you provide a name. +

+
+ ) : ( + errors.slug && + dirtyFields.slug && ( +
+

{errors.slug.message}

+
+ ) + )} +
+
+ + + + {result.error && } +
+
+ +
+ + + + + + + ); +} diff --git a/client/src/features/groupsV2/settings/GroupSettingsMetadata.tsx b/client/src/features/groupsV2/settings/GroupSettingsMetadata.tsx index 5095e26503..376485a2f1 100644 --- a/client/src/features/groupsV2/settings/GroupSettingsMetadata.tsx +++ b/client/src/features/groupsV2/settings/GroupSettingsMetadata.tsx @@ -165,40 +165,45 @@ export default function GroupMetadataForm({ group }: GroupMetadataFormProps) { )}
- - - -
- - +
+ + + + + + +
+ + +
diff --git a/client/src/features/projectsV2/LazyGroupNew.tsx b/client/src/features/projectsV2/LazyGroupNew.tsx index 6153b73fc3..22293456a8 100644 --- a/client/src/features/projectsV2/LazyGroupNew.tsx +++ b/client/src/features/projectsV2/LazyGroupNew.tsx @@ -18,7 +18,7 @@ import { Suspense, lazy } from "react"; import PageLoader from "../../components/PageLoader"; -const NamespaceNew = lazy(() => import("./new/GroupNew")); +const NamespaceNew = lazy(() => import("../groupsV2/new/GroupNew")); export default function LazyGroupNew() { return ( diff --git a/client/src/features/projectsV2/fields/DescriptionFormField.tsx b/client/src/features/projectsV2/fields/DescriptionFormField.tsx index 684a36e15e..9de4f75377 100644 --- a/client/src/features/projectsV2/fields/DescriptionFormField.tsx +++ b/client/src/features/projectsV2/fields/DescriptionFormField.tsx @@ -31,7 +31,7 @@ export default function DescriptionFormField({ name, }: GenericFormFieldProps) { return ( -
+
diff --git a/client/src/features/projectsV2/fields/NameFormField.tsx b/client/src/features/projectsV2/fields/NameFormField.tsx index fa187315d2..0083f094e6 100644 --- a/client/src/features/projectsV2/fields/NameFormField.tsx +++ b/client/src/features/projectsV2/fields/NameFormField.tsx @@ -28,10 +28,11 @@ export default function NameFormField({ control, entityName, errors, + helpText, name, }: GenericFormFieldProps) { return ( -
+
@@ -40,7 +41,7 @@ export default function NameFormField({ name={name} render={({ field }) => ( ({ rules={{ required: true, maxLength: 99 }} />
Please provide a name
- - The name you will use to refer to the {entityName}. - + {helpText && typeof helpText === "string" && ( + + {helpText} + + )} + {helpText && typeof helpText !== "string" && <>{helpText}}
); } diff --git a/client/src/features/projectsV2/fields/ProjectNameFormField.tsx b/client/src/features/projectsV2/fields/ProjectNameFormField.tsx index 9dc3363510..adb00f427f 100644 --- a/client/src/features/projectsV2/fields/ProjectNameFormField.tsx +++ b/client/src/features/projectsV2/fields/ProjectNameFormField.tsx @@ -24,6 +24,7 @@ import type { GenericProjectFormFieldProps } from "./formField.types"; export default function ProjectNameFormField({ control, errors, + helpText, name, }: GenericProjectFormFieldProps) { return ( @@ -31,6 +32,7 @@ export default function ProjectNameFormField({ control={control} entityName="project" errors={errors} + helpText={helpText} name={name} /> ); diff --git a/client/src/features/projectsV2/fields/ProjectNamespaceFormField.tsx b/client/src/features/projectsV2/fields/ProjectNamespaceFormField.tsx index 80e9a3ff02..1962fb4052 100644 --- a/client/src/features/projectsV2/fields/ProjectNamespaceFormField.tsx +++ b/client/src/features/projectsV2/fields/ProjectNamespaceFormField.tsx @@ -16,9 +16,10 @@ * limitations under the License. */ +import { skipToken } from "@reduxjs/toolkit/query"; import cx from "classnames"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ArrowRepeat, ChevronDown, ThreeDots } from "react-bootstrap-icons"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ChevronDown, ThreeDots } from "react-bootstrap-icons"; import type { FieldValues } from "react-hook-form"; import { Controller } from "react-hook-form"; import Select, { @@ -31,21 +32,20 @@ import Select, { SingleValueProps, components, } from "react-select"; -import { Button, FormText, Label, UncontrolledTooltip } from "reactstrap"; +import { Button, FormText, Label } from "reactstrap"; + import { ErrorAlert } from "../../../components/Alert"; import { Loader } from "../../../components/Loader"; -import useAppDispatch from "../../../utils/customHooks/useAppDispatch.hook"; import type { PaginatedState } from "../../session/components/options/fetchMore.types"; import type { GetNamespacesApiResponse } from "../api/projectV2.enhanced-api"; import { - projectV2Api, + useGetNamespacesByNamespaceSlugQuery, useGetNamespacesQuery, useLazyGetNamespacesQuery, - useGetNamespacesByNamespaceSlugQuery, } from "../api/projectV2.enhanced-api"; import type { GenericFormFieldProps } from "./formField.types"; + import styles from "./ProjectNamespaceFormField.module.scss"; -import { skipToken } from "@reduxjs/toolkit/query"; type ResponseNamespaces = GetNamespacesApiResponse["namespaces"]; type ResponseNamespace = ResponseNamespaces[number]; @@ -225,18 +225,13 @@ export default function ProjectNamespaceFormField({ entityName, ensureNamespace, errors, + helpText, name, }: ProjectNamespaceFormFieldProps) { - // Handle forced refresh - const dispatch = useAppDispatch(); - const refetch = useCallback(() => { - dispatch(projectV2Api.util.invalidateTags(["Namespace"])); - }, [dispatch]); return ( -
+
({ return ( ({ /^(?!.*\.git$|.*\.atom$|.*[-._][-._].*)[a-zA-Z0-9][a-zA-Z0-9\-_.]*$/, }} /> -
- A project must belong to a namespace. -
- - The user or group namespace where this project should be located. - +
A project must belong to a owner.
+ {helpText && typeof helpText === "string" && ( + + {helpText} + + )} + {helpText && typeof helpText !== "string" && <>{helpText}}
); } @@ -451,28 +446,3 @@ function OptionOrSingleValueContent({ ); } - -interface RefreshNamespaceButtonProps { - refresh: () => void; -} - -function RefreshNamespaceButton({ refresh }: RefreshNamespaceButtonProps) { - const ref = useRef(null); - - return ( - <> - - - Refresh namespaces - - - ); -} diff --git a/client/src/features/projectsV2/fields/ProjectRepositoryFormField.tsx b/client/src/features/projectsV2/fields/ProjectRepositoryFormField.tsx deleted file mode 100644 index b823448fe3..0000000000 --- a/client/src/features/projectsV2/fields/ProjectRepositoryFormField.tsx +++ /dev/null @@ -1,101 +0,0 @@ -/*! - * Copyright 2023 - Swiss Data Science Center (SDSC) - * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and - * Eidgenössische Technische Hochschule Zürich (ETHZ). - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import cx from "classnames"; -import { Button, FormText, Input, Label } from "reactstrap"; -import { XLg } from "react-bootstrap-icons"; -import { Controller } from "react-hook-form"; -import type { FieldValues } from "react-hook-form"; - -import type { Repository } from "../projectV2.types"; -import type { GenericProjectFormFieldProps } from "./formField.types"; - -interface ProjectRepositoryFormFieldProps - extends GenericProjectFormFieldProps { - id: string; - index: number; - onDelete: () => void; -} - -interface ProjectV2Repositories extends FieldValues { - repositories: Repository[]; -} - -export default function ProjectRepositoryFormField({ - control, - defaultValue, - errors, - id, - index, - name, - onDelete, -}: ProjectRepositoryFormFieldProps) { - return ( -
- -
0 && - (errors.repositories == null || - errors.repositories[index] == null) && - "mb-2" - )} - > - ( - - )} - rules={{ - required: true, - pattern: /^(http|https):\/\/[^ "]+$/, - }} - /> - -
-
- Please provide a valid URL or remove the repository. -
- {index == 0 && ( - - A URL that refers to a git repository. - - )} -
- ); -} diff --git a/client/src/features/projectsV2/fields/ProjectSlugFormField.tsx b/client/src/features/projectsV2/fields/ProjectSlugFormField.tsx index 4c29aa3b5d..4038f069c4 100644 --- a/client/src/features/projectsV2/fields/ProjectSlugFormField.tsx +++ b/client/src/features/projectsV2/fields/ProjectSlugFormField.tsx @@ -19,19 +19,25 @@ import type { FieldValues } from "react-hook-form"; import SlugFormField from "./SlugFormField"; -import type { GenericProjectFormFieldProps } from "./formField.types"; +import type { SlugProjectFormFieldProps } from "./formField.types"; export default function ProjectSlugFormField({ + compact = false, control, errors, + countAsDirty, name, -}: GenericProjectFormFieldProps) { + resetFunction, +}: SlugProjectFormFieldProps) { return ( ); } diff --git a/client/src/features/projectsV2/fields/ProjectVisibilityFormField.tsx b/client/src/features/projectsV2/fields/ProjectVisibilityFormField.tsx index 51b07ed1d9..a9ecc107f4 100644 --- a/client/src/features/projectsV2/fields/ProjectVisibilityFormField.tsx +++ b/client/src/features/projectsV2/fields/ProjectVisibilityFormField.tsx @@ -22,66 +22,77 @@ import type { FieldValues } from "react-hook-form"; import { Controller } from "react-hook-form"; import { Globe, Lock } from "react-bootstrap-icons"; -import { FormText, Input, Label } from "reactstrap"; +import { ButtonGroup, Input, Label } from "reactstrap"; import type { GenericProjectFormFieldProps } from "./formField.types"; export default function ProjectVisibilityFormField({ control, - errors, name, }: GenericProjectFormFieldProps) { return ( -
+
- ( -
-
- - -
-
- -
- )} - rules={{ required: true }} - /> + {field.value === "public" + ? "Your project is visible for everyone." + : "Your project is visible for you and people you add to the project."} +
+ + )} + rules={{ required: true }} + /> +
Please select a visibility
- - Should the project be visible to everyone or only to members? -
); } diff --git a/client/src/features/projectsV2/fields/SlugFormField.tsx b/client/src/features/projectsV2/fields/SlugFormField.tsx index c994afa745..e6cfcba62f 100644 --- a/client/src/features/projectsV2/fields/SlugFormField.tsx +++ b/client/src/features/projectsV2/fields/SlugFormField.tsx @@ -17,44 +17,71 @@ */ import cx from "classnames"; - -import { Controller } from "react-hook-form"; +import { ArrowCounterclockwise } from "react-bootstrap-icons"; import type { FieldValues } from "react-hook-form"; +import { Controller } from "react-hook-form"; +import { Button, FormText, Input, InputGroup, Label } from "reactstrap"; -import { FormText, Input, Label } from "reactstrap"; -import type { GenericFormFieldProps } from "./formField.types"; +import type { SlugFormFieldProps } from "./formField.types"; export default function SlugFormField({ + compact, control, entityName, errors, + countAsDirty, + resetFunction, name, -}: GenericFormFieldProps) { +}: SlugFormFieldProps) { + const content = ( + { + const isDirtyOrCountAsDirty = + countAsDirty == undefined ? isDirty : countAsDirty; + return ( + + + + {errors.slug && isDirtyOrCountAsDirty && resetFunction && ( + + )} + + ); + }} + rules={{ + required: true, + maxLength: 99, + pattern: { + message: + "You can customize the slug only with lowercase letters, numbers, and hyphens.", + value: /^(?!.*\.git$|.*\.atom$|.*[-._][-._].*)[a-z0-9][a-z0-9\-_.]*$/, + }, + }} + /> + ); + + if (compact) return content; return ( -
+
- ( - - )} - rules={{ - required: true, - maxLength: 99, - pattern: - /^(?!.*\.git$|.*\.atom$|.*[-._][-._].*)[a-z0-9][a-z0-9\-_.]*$/, - }} - /> + {content}
Please provide a slug consisting of lowercase letters, numbers, and hyphens. diff --git a/client/src/features/projectsV2/fields/formField.types.ts b/client/src/features/projectsV2/fields/formField.types.ts index 2174de533e..f652830ebf 100644 --- a/client/src/features/projectsV2/fields/formField.types.ts +++ b/client/src/features/projectsV2/fields/formField.types.ts @@ -7,10 +7,26 @@ import type { export interface GenericProjectFormFieldProps extends UseControllerProps { errors: FieldErrors; + helpText?: React.ReactNode; } export interface GenericFormFieldProps extends UseControllerProps { entityName: string; errors: FieldErrors; + helpText?: React.ReactNode; +} + +export interface SlugFormFieldProps + extends GenericFormFieldProps { + compact?: boolean; + countAsDirty?: boolean; + resetFunction?: () => void; +} + +export interface SlugProjectFormFieldProps + extends GenericProjectFormFieldProps { + compact?: boolean; + countAsDirty?: boolean; + resetFunction?: () => void; } diff --git a/client/src/features/projectsV2/new/CreateProjectV2Button.tsx b/client/src/features/projectsV2/new/CreateProjectV2Button.tsx new file mode 100644 index 0000000000..abd27f7af3 --- /dev/null +++ b/client/src/features/projectsV2/new/CreateProjectV2Button.tsx @@ -0,0 +1,53 @@ +/*! + * 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 } from "react"; +import { Button } from "reactstrap"; + +import useLocationHash from "../../../utils/customHooks/useLocationHash.hook"; + +export interface CreateProjectV2ButtonProps { + children?: React.ReactNode; + className?: string; + color?: string; + dataCy?: string; +} +export default function CreateProjectV2Button({ + children, + className, + color, + dataCy, +}: CreateProjectV2ButtonProps) { + const [, setHash] = useLocationHash(); + const projectCreationHash = "createProject"; + const openModal = useCallback(() => { + setHash(projectCreationHash); + }, [setHash]); + + return ( + + ); +} diff --git a/client/src/features/projectsV2/new/GroupNew.tsx b/client/src/features/projectsV2/new/GroupNew.tsx deleted file mode 100644 index 8018058383..0000000000 --- a/client/src/features/projectsV2/new/GroupNew.tsx +++ /dev/null @@ -1,192 +0,0 @@ -/*! - * 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 { useForm } from "react-hook-form"; -import { Link, generatePath, useNavigate } from "react-router-dom-v5-compat"; -import { Button, Form } from "reactstrap"; - -import { Loader } from "../../../components/Loader"; -import ContainerWrap from "../../../components/container/ContainerWrap"; -import FormSchema from "../../../components/formschema/FormSchema"; -import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; -import useLegacySelector from "../../../utils/customHooks/useLegacySelector.hook"; -import { slugFromTitle } from "../../../utils/helpers/HelperFunctions"; - -import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert"; -import LoginAlert from "../../../components/loginAlert/LoginAlert"; -import type { GroupPostRequest } from "../api/namespace.api"; -import { usePostGroupsMutation } from "../api/projectV2.enhanced-api"; -import DescriptionFormField from "../fields/DescriptionFormField"; -import NameFormField from "../fields/NameFormField"; -import SlugFormField from "../fields/SlugFormField"; -import WipBadge from "../shared/WipBadge"; - -function GroupNewHeader() { - return ( -

- Groups let you group together related projects and control who can access - them. -

- ); -} - -function GroupBeingCreatedLoader() { - return ( -
-
- -
Creating group...
-
-
- ); -} - -function GroupBeingCreated({ - result, -}: { - result: ReturnType[1]; -}) { - const navigate = useNavigate(); - - useEffect(() => { - if (result.isSuccess && result.data.slug) { - const groupUrl = generatePath(ABSOLUTE_ROUTES.v2.groups.show.root, { - slug: result.data.slug, - }); - navigate(groupUrl); - } - }, [result, navigate]); - - if (result.isLoading) { - return ; - } - - return ( -
-

Something went wrong.

- {result.error && } -
- -
-
- ); -} - -function GroupMetadataForm() { - const { - control, - formState: { errors }, - handleSubmit, - setValue, - watch, - } = useForm({ - defaultValues: { - name: "", - slug: "", - description: "", - }, - }); - - const name = watch("name"); - useEffect(() => { - setValue("slug", slugFromTitle(name, true, true)); - }, [setValue, name]); - - const [createGroup, result] = usePostGroupsMutation(); - - const onSubmit = useCallback( - (groupPostRequest: GroupPostRequest) => { - createGroup({ groupPostRequest }); - }, - [createGroup] - ); - - if (result != null && !result.isUninitialized) { - return ; - } - - return ( - <> -

Describe the group

-
- - - -
- - Cancel - -
- -
-
- - - ); -} - -export default function GroupNew() { - const user = useLegacySelector((state) => state.stateModel.user); - if (!user.logged) { - const textIntro = "Only authenticated users can create new groups."; - const textPost = "to create a new group."; - return ( - -

New group

- -
- ); - } - return ( - - } - > - - - - ); -} diff --git a/client/src/features/projectsV2/new/ProjectV2FormSubmitGroup.tsx b/client/src/features/projectsV2/new/ProjectV2FormSubmitGroup.tsx deleted file mode 100644 index 851398cc92..0000000000 --- a/client/src/features/projectsV2/new/ProjectV2FormSubmitGroup.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/*! - * Copyright 2023 - Swiss Data Science Center (SDSC) - * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and - * Eidgenössische Technische Hochschule Zürich (ETHZ). - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import cx from "classnames"; -import { useCallback } from "react"; -import { useDispatch } from "react-redux"; -import { Button } from "reactstrap"; - -import type { NewProjectV2State } from "./projectV2New.slice"; -import { setCurrentStep } from "./projectV2New.slice"; -import { ArrowLeft, ArrowRight } from "react-bootstrap-icons"; - -interface ProjectV2NewFormProps { - currentStep: NewProjectV2State["currentStep"]; -} -export default function ProjectFormSubmitGroup({ - currentStep, -}: ProjectV2NewFormProps) { - const dispatch = useDispatch(); - - const previousStep = useCallback(() => { - if (currentStep < 1) return; - const previousStep = (currentStep - 1) as typeof currentStep; - dispatch(setCurrentStep(previousStep)); - }, [currentStep, dispatch]); - - return ( -
- -
- {currentStep === 0 && ( - - )} - {currentStep === 1 && ( - - )} - {currentStep === 2 && ( - - )} - {currentStep === 3 && ( - - )} -
-
- ); -} diff --git a/client/src/features/projectsV2/new/ProjectV2New.tsx b/client/src/features/projectsV2/new/ProjectV2New.tsx index f5895b7331..ca1bb41082 100644 --- a/client/src/features/projectsV2/new/ProjectV2New.tsx +++ b/client/src/features/projectsV2/new/ProjectV2New.tsx @@ -17,182 +17,302 @@ */ import cx from "classnames"; -import { FormEvent, useCallback, useEffect } from "react"; -import { useDispatch } from "react-redux"; +import { useCallback, useEffect, useState } from "react"; +import { + CheckLg, + ChevronDown, + Folder, + InfoCircle, + XLg, +} from "react-bootstrap-icons"; +import { useForm } from "react-hook-form"; import { generatePath, useNavigate } from "react-router-dom-v5-compat"; -import { Form, Label } from "reactstrap"; +import { + Button, + Collapse, + Form, + FormGroup, + FormText, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, +} from "reactstrap"; -import { RtkErrorAlert } from "../../../components/errors/RtkErrorAlert"; -import FormSchema from "../../../components/formschema/FormSchema"; +import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert"; +import { Loader } from "../../../components/Loader"; +import LoginAlert from "../../../components/loginAlert/LoginAlert"; import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; -import useAppSelector from "../../../utils/customHooks/useAppSelector.hook"; -import useLegacySelector from "../../../utils/customHooks/useLegacySelector.hook"; - -import type { ProjectPost } from "../api/projectV2.api"; +import useLocationHash from "../../../utils/customHooks/useLocationHash.hook"; +import { slugFromTitle } from "../../../utils/helpers/HelperFunctions"; +import { useGetUserQuery } from "../../usersV2/api/users.api"; import { usePostProjectsMutation } from "../api/projectV2.enhanced-api"; +import ProjectDescriptionFormField from "../fields/ProjectDescriptionFormField"; +import ProjectNameFormField from "../fields/ProjectNameFormField"; +import ProjectNamespaceFormField from "../fields/ProjectNamespaceFormField"; +import ProjectSlugFormField from "../fields/ProjectSlugFormField"; +import ProjectVisibilityFormField from "../fields/ProjectVisibilityFormField"; +import { NewProjectForm } from "./projectV2New.types"; -import LoginAlert from "../../../components/loginAlert/LoginAlert"; -import WipBadge from "../shared/WipBadge"; -import { ProjectV2DescriptionAndRepositories } from "../show/ProjectV2DescriptionAndRepositories"; -import ProjectFormSubmitGroup from "./ProjectV2FormSubmitGroup"; -import ProjectV2NewForm from "./ProjectV2NewForm"; -import type { NewProjectV2State } from "./projectV2New.slice"; -import { setCurrentStep } from "./projectV2New.slice"; - -function projectToProjectPost( - project: NewProjectV2State["project"] -): ProjectPost { - return { - name: project.metadata.name, - namespace: project.metadata.namespace, - slug: project.metadata.slug, - description: project.metadata.description, - visibility: project.access.visibility, - repositories: project.content.repositories - .map((r) => r.url.trim()) - .filter((r) => r.length > 0), - }; -} +export default function ProjectV2New() { + const { data: userInfo, isLoading: userLoading } = useGetUserQuery(); -function ProjectV2NewAccessStepHeader() { - return ( - <> - Set up visibility and access -

Decide who can see your project and who is allowed to work in it.

- - ); -} + const [hash, setHash] = useLocationHash(); + const projectCreationHash = "createProject"; + const showProjectCreationModal = hash === projectCreationHash; + const toggleModal = useCallback(() => { + setHash((prev) => { + const isOpen = prev === projectCreationHash; + return isOpen ? "" : projectCreationHash; + }); + }, [setHash]); -function ProjectV2NewHeader({ - currentStep, -}: Pick) { return ( <> -

- V2 Projects let you group together related resources and control who can - access them. - -

- {currentStep === 0 && } - {currentStep === 1 && } - {currentStep === 2 && } - {currentStep === 3 && } - - ); -} + + +

+ Create a new project +

+

+ A Renku project groups together data, code, and compute resources + for you and your collaborators. +

+
-function ProjectV2NewMetadataStepHeader() { - return ( - <> -

Describe your project

-

Provide some information to explain what your project is about.

+ {userLoading ? ( + + + + ) : userInfo?.isLoggedIn ? ( + + ) : ( + + + + )} +
); } -function ProjectV2NewProjectCreatingStepHeader() { - return ( - <> -

Review and create

-

Review what has been entered and, if ready, create the project.

- - ); -} +function ProjectV2CreationDetails() { + const [isCollapseOpen, setIsCollapseOpen] = useState(false); + const toggleCollapse = () => setIsCollapseOpen(!isCollapseOpen); -function ProjectV2NewRepositoryStepHeader() { - return ( - <> -

Associate some repositories (optional)

-

- You can associate one or more repositories with the project now if you - want. This can also be done later at any time. -

- - ); -} - -function ProjectV2NewReviewCreateStep({ - currentStep, -}: Pick) { - const { project } = useAppSelector((state) => state.newProjectV2); const [createProject, result] = usePostProjectsMutation(); - const newProject = projectToProjectPost(project); const navigate = useNavigate(); + + const [, setHash] = useLocationHash(); + const closeModal = useCallback(() => { + setHash(); + }, [setHash]); + + // Form initialization + const { + control, + formState: { dirtyFields, errors }, + handleSubmit, + setValue, + watch, + } = useForm({ + mode: "onChange", + defaultValues: { + description: "", + name: "", + namespace: "", + slug: "", + visibility: "private", + }, + }); + + // We watch for changes in the name and derive the slug from it + const currentName = watch("name"); + useEffect(() => { + setValue("slug", slugFromTitle(currentName, true, true), { + shouldValidate: true, + }); + }, [currentName, setValue]); + + // Slug and namespace are use to show the projected URL + const currentNamespace = watch("namespace"); + const currentSlug = watch("slug"); + + // Project creation utilities const onSubmit = useCallback( - (e: FormEvent) => { - e.preventDefault(); - createProject({ projectPost: newProject }); + (data: NewProjectForm) => { + createProject({ projectPost: data }); }, - [createProject, newProject] + [createProject] ); useEffect(() => { - if ( - result.isSuccess && - project.metadata.namespace && - project.metadata.slug - ) { + if (result.isSuccess) { const projectUrl = generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { - namespace: project.metadata.namespace, - slug: project.metadata.slug, + namespace: result.data.namespace, + slug: result.data.slug, }); navigate(projectUrl); } - }, [result, project, navigate]); + }, [result, navigate]); - const errorAlert = result.error && ; - - return ( -
- {errorAlert} -

Review

-
- -

{newProject.name}

- -

{newProject.namespace}

- -

{newProject.slug}

- -

{newProject.visibility}

-
- - - + const ownerHelpText = ( + + The URL for this project will be{" "} + + renkulab.io/v2/projects/{currentNamespace || ""}/ + {currentSlug || ""} + + ); -} -export default function ProjectV2New() { - const user = useLegacySelector((state) => state.stateModel.user); - const dispatch = useDispatch(); - useEffect(() => { - dispatch(setCurrentStep(0)); - }, [dispatch]); - const { currentStep } = useAppSelector((state) => state.newProjectV2); - if (!user.logged) { - const textIntro = "Only authenticated users can create new projects."; - const textPost = "to create a new project."; - return ( -
-

New project

- -
- ); - } + const resetUrl = useCallback(() => { + setValue("slug", slugFromTitle(currentName, true, true), { + shouldValidate: true, + }); + }, [setValue, currentName]); + return ( - } - > - {currentStep < 3 && } - {currentStep == 3 && ( - - )} - + <> + +
+ + {/* //? FormGroup hard codes an additional mb-3. Adding "d-inline" makes it ineffective. */} +
+
+ +
+ +
+
+ +
+ + +
+ + renkulab.io/v2/projects/{currentNamespace || ""}/ + + +
+
+ + {dirtyFields.slug && !dirtyFields.name ? ( +
+

+ Mind the URL will be updated once you provide a name. +

+
+ ) : ( + errors.slug && + dirtyFields.slug && ( +
+

{errors.slug.message}

+
+ ) + )} +
+ +
+
+ +
+ +
+ + + + {result.error && } +
+
+
+
+ + + + + + ); } diff --git a/client/src/features/projectsV2/new/ProjectV2NewForm.tsx b/client/src/features/projectsV2/new/ProjectV2NewForm.tsx deleted file mode 100644 index 54f83e5f92..0000000000 --- a/client/src/features/projectsV2/new/ProjectV2NewForm.tsx +++ /dev/null @@ -1,230 +0,0 @@ -/*! - * Copyright 2023 - Swiss Data Science Center (SDSC) - * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and - * Eidgenössische Technische Hochschule Zürich (ETHZ). - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import cx from "classnames"; - -import { useCallback, useEffect } from "react"; -import { useFieldArray, useForm } from "react-hook-form"; -import { useDispatch } from "react-redux"; -import { Button, Form, Label } from "reactstrap"; - -import useAppSelector from "../../../utils/customHooks/useAppSelector.hook"; -import { slugFromTitle } from "../../../utils/helpers/HelperFunctions"; - -import ProjectDescriptionFormField from "../fields/ProjectDescriptionFormField"; -import ProjectNameFormField from "../fields/ProjectNameFormField"; -import ProjectNamespaceFormField from "../fields/ProjectNamespaceFormField"; -import ProjectRepositoryFormField from "../fields/ProjectRepositoryFormField"; -import ProjectSlugFormField from "../fields/ProjectSlugFormField"; -import ProjectVisibilityFormField from "../fields/ProjectVisibilityFormField"; - -import ProjectFormSubmitGroup from "./ProjectV2FormSubmitGroup"; -import type { NewProjectV2State } from "./projectV2New.slice"; -import { - setAccess, - setContent, - setCurrentStep, - setMetadata, -} from "./projectV2New.slice"; -import { PlusLg } from "react-bootstrap-icons"; - -interface ProjectV2NewFormProps { - currentStep: NewProjectV2State["currentStep"]; -} -export default function ProjectV2NewForm({ - currentStep, -}: ProjectV2NewFormProps) { - return ( -
- {currentStep === 0 && ( - - )} - {currentStep === 1 && ( - - )} - {currentStep === 2 && ( - - )} -
- ); -} - -function ProjectV2NewAccessStepForm({ currentStep }: ProjectV2NewFormProps) { - const dispatch = useDispatch(); - const { project } = useAppSelector((state) => state.newProjectV2); - const { - control, - formState: { errors }, - handleSubmit, - } = useForm({ - defaultValues: project.access, - }); - - const onSubmit = useCallback( - (data: NewProjectV2State["project"]["access"]) => { - dispatch(setAccess(data)); - const nextStep = (currentStep + 1) as typeof currentStep; - dispatch(setCurrentStep(nextStep)); - }, - [currentStep, dispatch] - ); - return ( - <> -

Define access

-
- -
-
- -
-
- - - - ); -} - -function ProjectV2NewMetadataStepForm({ currentStep }: ProjectV2NewFormProps) { - const dispatch = useDispatch(); - const { project } = useAppSelector((state) => state.newProjectV2); - const { - control, - formState: { errors }, - handleSubmit, - setValue, - watch, - } = useForm({ - defaultValues: project.metadata, - }); - - const name = watch("name"); - useEffect(() => { - setValue("slug", slugFromTitle(name, true, true)); - }, [setValue, name]); - - const onSubmit = useCallback( - (data: NewProjectV2State["project"]["metadata"]) => { - dispatch(setMetadata(data)); - const nextStep = (currentStep + 1) as typeof currentStep; - dispatch(setCurrentStep(nextStep)); - }, - [currentStep, dispatch] - ); - - return ( - <> -

Describe the project

-
- - - - - - - - ); -} - -function ProjectV2NewRepositoryStepForm({ - currentStep, -}: ProjectV2NewFormProps) { - const dispatch = useDispatch(); - const { project } = useAppSelector((state) => state.newProjectV2); - const { - control, - formState: { errors }, - handleSubmit, - } = useForm({ - defaultValues: project.content, - }); - const { fields, append, remove } = useFieldArray< - NewProjectV2State["project"]["content"] - >({ - control, - name: "repositories", - }); - - const onAppend = useCallback(() => { - append({ url: "" }); - }, [append]); - const onDelete = useCallback( - (index: number) => { - remove(index); - }, - [remove] - ); - - const onSubmit = useCallback( - (data: NewProjectV2State["project"]["content"]) => { - dispatch(setContent(data)); - const nextStep = (currentStep + 1) as typeof currentStep; - dispatch(setCurrentStep(nextStep)); - }, - [currentStep, dispatch] - ); - return ( - <> -

Add Repositories

-
-
- {fields.length === 0 && ( -

No repositories added yet.

- )} - {fields.map((f, i) => { - return ( -
- onDelete(i)} - /> -
- ); - })} - -
- - - - ); -} diff --git a/client/src/features/projectsV2/new/projectV2New.slice.ts b/client/src/features/projectsV2/new/projectV2New.slice.ts deleted file mode 100644 index 0e4f73a2af..0000000000 --- a/client/src/features/projectsV2/new/projectV2New.slice.ts +++ /dev/null @@ -1,79 +0,0 @@ -/*! - * Copyright 2023 - Swiss Data Science Center (SDSC) - * A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and - * Eidgenössische Technische Hochschule Zürich (ETHZ). - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT - * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; - -import type { Project } from "../projectV2.types"; - -type NewProjectV2Step = 0 | 1 | 2 | 3; - -export interface NewProjectV2State { - project: Project; - currentStep: NewProjectV2Step; -} - -const initialState: NewProjectV2State = { - project: { - access: { - members: [], - visibility: "private", - }, - content: { - repositories: [], - }, - metadata: { - name: "", - namespace: "", - slug: "", - description: "", - }, - }, - currentStep: 0, -}; - -export const projectV2NewSlice = createSlice({ - name: "newProjectV2", - initialState, - reducers: { - projectWasCreated: (state) => { - state.project = initialState.project; - }, - setAccess: (state, action: PayloadAction) => { - state.project.access = action.payload; - }, - setContent: (state, action: PayloadAction) => { - state.project.content = action.payload; - }, - setCurrentStep: (state, action: PayloadAction) => { - state.currentStep = action.payload; - }, - setMetadata: (state, action: PayloadAction) => { - state.project.metadata = action.payload; - }, - reset: () => initialState, - }, -}); - -export const { - projectWasCreated, - setAccess, - setContent, - setCurrentStep, - setMetadata, - reset, -} = projectV2NewSlice.actions; diff --git a/client/src/features/projectsV2/new/projectV2New.types.ts b/client/src/features/projectsV2/new/projectV2New.types.ts new file mode 100644 index 0000000000..10e37a4c04 --- /dev/null +++ b/client/src/features/projectsV2/new/projectV2New.types.ts @@ -0,0 +1,27 @@ +/*! + * 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 { Visibility } from "../api/projectV2.api"; + +export interface NewProjectForm { + description: string; + name: string; + namespace: string; + slug: string; + visibility: Visibility; +} diff --git a/client/src/features/projectsV2/show/ProjectV2DescriptionAndRepositories.tsx b/client/src/features/projectsV2/show/ProjectV2DescriptionAndRepositories.tsx deleted file mode 100644 index d64a570019..0000000000 --- a/client/src/features/projectsV2/show/ProjectV2DescriptionAndRepositories.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/*! - * 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 { Label } from "reactstrap"; - -import type { Project } from "../api/projectV2.api"; - -function ProjectV2Description({ description }: Pick) { - const desc = - description == null || description.length < 1 ? ( - (no description) - ) : ( - description - ); - return

{desc}

; -} - -function ProjectV2Repositories({ - repositories, -}: Pick) { - if (repositories == null || repositories.length < 1) - return

(no repositories)

; - return ( -
- {repositories?.map((repo, i) => ( -

- {repo} -

- ))} -
- ); -} - -interface ProjectV2DisplayProps { - project: Pick; -} -export function ProjectV2DescriptionAndRepositories({ - project, -}: ProjectV2DisplayProps) { - return ( - <> -
- - -
-
- - -
- - ); -} diff --git a/client/src/features/rootV2/NavbarV2.tsx b/client/src/features/rootV2/NavbarV2.tsx index 63c71c36b9..6ac62125a5 100644 --- a/client/src/features/rootV2/NavbarV2.tsx +++ b/client/src/features/rootV2/NavbarV2.tsx @@ -42,9 +42,11 @@ import { RenkuToolbarItemUser } from "../../components/navbar/NavBarItems"; import { ABSOLUTE_ROUTES } from "../../routing/routes.constants"; import { Links } from "../../utils/constants/Docs"; import AppContext from "../../utils/context/appContext"; +import CreateGroupButton from "../groupsV2/new/CreateGroupButton"; +import StatusBanner from "../platform/components/StatusBanner"; +import CreateProjectV2Button from "../projectsV2/new/CreateProjectV2Button"; import BackToV1Button from "../projectsV2/shared/BackToV1Button"; import WipBadge from "../projectsV2/shared/WipBadge"; -import StatusBanner from "../platform/components/StatusBanner"; const RENKU_ALPHA_LOGO = "/static/public/img/logo-yellow.svg"; @@ -63,22 +65,23 @@ function NavbarItemPlus() { end > - Project - + + - Group - + diff --git a/client/src/features/rootV2/RootV2.tsx b/client/src/features/rootV2/RootV2.tsx index c23f7b20b3..149b9f7152 100644 --- a/client/src/features/rootV2/RootV2.tsx +++ b/client/src/features/rootV2/RootV2.tsx @@ -18,21 +18,29 @@ import cx from "classnames"; import { useEffect, useState } from "react"; -import { Route, Routes, useNavigate } from "react-router-dom-v5-compat"; +import { + Navigate, + Route, + Routes, + useNavigate, +} from "react-router-dom-v5-compat"; import ContainerWrap from "../../components/container/ContainerWrap"; import LazyNotFound from "../../not-found/LazyNotFound"; -import { RELATIVE_ROUTES } from "../../routing/routes.constants"; +import { + ABSOLUTE_ROUTES, + RELATIVE_ROUTES, +} from "../../routing/routes.constants"; import useAppDispatch from "../../utils/customHooks/useAppDispatch.hook"; import useAppSelector from "../../utils/customHooks/useAppSelector.hook"; import { setFlag } from "../../utils/feature-flags/featureFlags.slice"; - import LazyProjectPageV2Show from "../ProjectPageV2/LazyProjectPageV2Show"; import LazyProjectPageOverview from "../ProjectPageV2/ProjectPageContent/LazyProjectPageOverview"; import LazyProjectPageSettings from "../ProjectPageV2/ProjectPageContent/LazyProjectPageSettings"; import LazyConnectedServicesPage from "../connectedServices/LazyConnectedServicesPage"; import LazyDashboardV2 from "../dashboardV2/LazyDashboardV2"; import LazyHelpV2 from "../dashboardV2/LazyHelpV2"; +import LazyGroupV2Settings from "../groupsV2/LazyGroupV2Settings"; import LazyGroupV2Show from "../groupsV2/LazyGroupV2Show"; import LazyGroupV2New from "../projectsV2/LazyGroupNew"; import LazyProjectV2New from "../projectsV2/LazyProjectV2New"; @@ -43,7 +51,6 @@ import LazyShowSessionPage from "../sessionsV2/LazyShowSessionPage"; import LazyUserRedirect from "../usersV2/LazyUserRedirect"; import LazyUserShow from "../usersV2/LazyUserShow"; import NavbarV2 from "./NavbarV2"; -import LazyGroupV2Settings from "../groupsV2/LazyGroupV2Settings"; export default function RootV2() { const navigate = useNavigate(); @@ -70,6 +77,8 @@ export default function RootV2() { return (
+ +
@@ -140,8 +149,11 @@ function GroupsV2Routes() { } + element={ + + } /> + } /> - - + } /> diff --git a/client/src/features/usersV2/api/users.api.ts b/client/src/features/usersV2/api/users.api.ts index e4f4eb1338..eccb2d915d 100644 --- a/client/src/features/usersV2/api/users.api.ts +++ b/client/src/features/usersV2/api/users.api.ts @@ -44,11 +44,14 @@ const withFixedEndpoints = usersGeneratedApi.injectEndpoints({ }, }), transformResponse: (result: GetUserApiResponse | null | undefined) => { - if (result == null) { + if (result == null || "error" in result) { return { isLoggedIn: false }; } return { ...result, isLoggedIn: true }; }, + transformErrorResponse: () => { + return { isLoggedIn: false }; + }, }), getUsers: build.query({ query: ({ userParams }) => ({ diff --git a/client/src/utils/helpers/EnhancedState.ts b/client/src/utils/helpers/EnhancedState.ts index bc1a692280..a11a6ef715 100644 --- a/client/src/utils/helpers/EnhancedState.ts +++ b/client/src/utils/helpers/EnhancedState.ts @@ -49,7 +49,6 @@ import projectGitLabApi from "../../features/project/projectGitLab.api"; import { projectKgApi } from "../../features/project/projectKg.api"; import { projectsApi } from "../../features/projects/projects.api"; import { projectV2Api } from "../../features/projectsV2/api/projectV2.enhanced-api"; -import { projectV2NewSlice } from "../../features/projectsV2/new/projectV2New.slice"; import { recentUserActivityApi } from "../../features/recentUserActivity/RecentUserActivityApi"; import repositoriesApi from "../../features/repositories/repositories.api"; import { searchV2EmptyApi as searchV2Api } from "../../features/searchV2/api/searchV2-empty.api"; @@ -85,7 +84,6 @@ export const createStore = ( [startSessionSlice.name]: startSessionSlice.reducer, [startSessionOptionsSlice.name]: startSessionOptionsSlice.reducer, [startSessionOptionsV2Slice.name]: startSessionOptionsV2Slice.reducer, - [projectV2NewSlice.name]: projectV2NewSlice.reducer, [workflowsSlice.name]: workflowsSlice.reducer, // APIs [adminKeycloakApi.reducerPath]: adminKeycloakApi.reducer, diff --git a/tests/cypress/e2e/groupV2.spec.ts b/tests/cypress/e2e/groupV2.spec.ts index b7bd8cf9e5..2d2ce81fb8 100644 --- a/tests/cypress/e2e/groupV2.spec.ts +++ b/tests/cypress/e2e/groupV2.spec.ts @@ -23,20 +23,42 @@ describe("Add new v2 group", () => { const slug = "new-group"; beforeEach(() => { - fixtures.config().versions().userTest().namespaces(); - fixtures.projects().landingUserProjects(); + fixtures.config().versions().userTest(); fixtures .createGroupV2() + .listNamespaceV2() .readGroupV2({ groupSlug: slug }) .readGroupV2Namespace({ groupSlug: slug }); cy.visit("/v2/groups/new"); }); it("create a new group", () => { - cy.contains("New Group").should("be.visible"); + cy.contains("Create a new group").should("be.visible"); + cy.getDataCy("group-name-input").clear().type(newGroupName); + cy.getDataCy("group-slug-input").should("have.value", slug); + cy.getDataCy("group-create-button").click(); + + cy.wait("@createGroupV2"); + cy.wait("@readGroupV2"); + cy.wait("@readGroupV2Namespace"); + cy.url().should("contain", `v2/groups/${slug}`); + cy.contains("test 2 group-v2").should("be.visible"); + }); + + it("cannot create a new group with invalid slug", () => { + cy.contains("Create a new group").should("be.visible"); cy.getDataCy("group-name-input").clear().type(newGroupName); cy.getDataCy("group-slug-input").should("have.value", slug); - cy.contains("Create").click(); + + cy.getDataCy("group-slug-toggle").click(); + cy.getDataCy("group-slug-input").clear().type(newGroupName); + cy.getDataCy("group-create-button").click(); + cy.contains( + "You can customize the slug only with lowercase letters, numbers, and hyphens." + ).should("be.visible"); + + cy.getDataCy("group-slug-input").clear().type(slug); + cy.getDataCy("group-create-button").click(); cy.wait("@createGroupV2"); cy.wait("@readGroupV2"); cy.wait("@readGroupV2Namespace"); diff --git a/tests/cypress/e2e/navV2.spec.ts b/tests/cypress/e2e/navV2.spec.ts index e5e126603b..60da4560b6 100644 --- a/tests/cypress/e2e/navV2.spec.ts +++ b/tests/cypress/e2e/navV2.spec.ts @@ -20,14 +20,7 @@ import fixtures from "../support/renkulab-fixtures"; describe("View v2 landing page", () => { beforeEach(() => { - fixtures.config().versions().userTest().namespaces(); - fixtures - .getSessions({ fixture: "sessions/sessionsV2.json" }) - .projects() - .landingUserProjects() - .listManyGroupV2() - .listManyProjectV2() - .readProjectV2ById(); + fixtures.config().versions().userTest(); cy.visit("/v2"); }); @@ -40,12 +33,15 @@ describe("View v2 landing page", () => { it("create new group", () => { cy.get("#plus-dropdown").click(); cy.getDataCy("navbar-group-new").click(); - cy.contains("New Group").should("be.visible"); + cy.getDataCy("new-group-modal").should("be.visible"); + cy.contains("Create a new group").should("be.visible"); }); it("create new project", () => { + fixtures.listNamespaceV2(); cy.get("#plus-dropdown").click(); cy.getDataCy("navbar-project-new").click(); - cy.contains("New Project").should("be.visible"); + cy.getDataCy("new-project-modal").should("be.visible"); + cy.contains("Create a new project").should("be.visible"); }); }); diff --git a/tests/cypress/e2e/projectV2.spec.ts b/tests/cypress/e2e/projectV2.spec.ts index 0597d85dd9..57fd195fbd 100644 --- a/tests/cypress/e2e/projectV2.spec.ts +++ b/tests/cypress/e2e/projectV2.spec.ts @@ -25,81 +25,51 @@ describe("Add new v2 project", () => { beforeEach(() => { fixtures.config().versions().userTest().namespaces(); fixtures.projects().landingUserProjects(); - fixtures.createProjectV2().listNamespaceV2().readProjectV2(); + fixtures + .createProjectV2({ + slug, + namespace: "user1-uuid", + }) + .listNamespaceV2() + .readProjectV2(); cy.visit("/v2/projects/new"); }); it("create a new project", () => { - cy.contains("New Project").should("be.visible"); + cy.contains("Create a new project").should("be.visible"); cy.getDataCy("project-name-input").clear().type(newProjectTitle); cy.getDataCy("project-slug-input").should("have.value", slug); cy.wait("@listNamespaceV2"); cy.findReactSelectOptions("project-namespace-input", "namespace-select") .first() - .click(); // click on first option - cy.contains("Set visibility").click(); - cy.contains("Add repositories").click(); - cy.getDataCy("project-add-repository").click(); - cy.getDataCy("project-repository-input-0") - .clear() - .type("https://domain.name/repo1.git"); - cy.contains("button", "Review").click(); + .click(); + cy.contains("Visibility").click(); cy.contains("button", "Create").click(); cy.wait("@createProjectV2"); cy.location("pathname").should("eq", `/v2/projects/user1-uuid/${slug}`); }); - it("keeps namespace set after going back", () => { - cy.contains("New Project").should("be.visible"); - cy.getDataCy("project-name-input").clear().type(newProjectTitle); - cy.getDataCy("project-slug-input").should("have.value", slug); - cy.wait("@listNamespaceV2"); - cy.findReactSelectOptions("project-namespace-input", "namespace-select") - .first() - .click(); - cy.contains("user1-uuid").should("exist"); - cy.contains("Set visibility").click(); - cy.get("button").contains("Back").click(); - cy.contains("user1-uuid").should("exist"); - }); - it("prevents invalid input", () => { - cy.contains("button", "Set visibility").click(); - cy.contains("Please provide a name").should("be.visible"); + cy.contains("Name").should("be.visible"); + cy.contains("Owner").should("be.visible"); + cy.contains("Visibility").should("be.visible"); + cy.contains("Description").should("be.visible"); + + cy.getDataCy("project-slug-toggle").click(); cy.getDataCy("project-name-input").clear().type(newProjectTitle); cy.getDataCy("project-slug-input").clear().type(newProjectTitle); - cy.contains("button", "Set visibility").click(); + cy.getDataCy("project-create-button").click(); cy.contains( - "Please provide a slug consisting of lowercase letters, numbers, and hyphens." + "You can customize the slug only with lowercase letters, numbers, and hyphens." ).should("be.visible"); + cy.getDataCy("project-slug-input").clear().type(slug); cy.wait("@listNamespaceV2"); cy.findReactSelectOptions("project-namespace-input", "namespace-select") .first() .click(); - cy.contains("Set visibility").click(); - - cy.contains("Define access").should("be.visible"); cy.getDataCy("project-visibility-public").click(); - cy.contains("button", "Add repositories").click(); - - cy.contains("button", "Review").click(); - cy.contains("button", "Back").click(); - cy.getDataCy("project-add-repository").click(); - cy.contains("button", "Review").click(); - cy.contains("Please provide a valid URL or remove the repository").should( - "be.visible" - ); - cy.getDataCy("project-repository-input-0") - .clear() - .type("https://domain.name/repo1.git"); - - cy.contains("button", "Review").click(); - cy.contains(newProjectTitle).should("be.visible"); - cy.contains(slug).should("be.visible"); - cy.contains("public").should("be.visible"); - cy.contains("https://domain.name/repo1.git").should("be.visible"); cy.contains("button", "Create").click(); cy.wait("@createProjectV2"); diff --git a/tests/cypress/fixtures/projectV2/create-projectV2.json b/tests/cypress/fixtures/projectV2/create-projectV2.json index 6941d4c03f..dcfa6d0c4a 100644 --- a/tests/cypress/fixtures/projectV2/create-projectV2.json +++ b/tests/cypress/fixtures/projectV2/create-projectV2.json @@ -1,6 +1,7 @@ { "id": "THEPROJECTULID26CHARACTERS", "name": "Renku R Project", + "namespace": "owner-kc-id", "slug": "r-project", "created_by": { "id": "owner-KC-id" diff --git a/tests/cypress/support/renkulab-fixtures/projectV2.ts b/tests/cypress/support/renkulab-fixtures/projectV2.ts index 7599a78321..10391fb93f 100644 --- a/tests/cypress/support/renkulab-fixtures/projectV2.ts +++ b/tests/cypress/support/renkulab-fixtures/projectV2.ts @@ -85,8 +85,15 @@ export function ProjectV2(Parent: T) { fixture = "projectV2/create-projectV2.json", name = "createProjectV2", } = args ?? {}; - const response = { fixture, statusCode: 201 }; - cy.intercept("POST", "/ui-server/api/data/projects", response).as(name); + cy.fixture(fixture).then((values) => { + cy.intercept("POST", "/ui-server/api/data/projects", { + body: { + values, + ...args, + }, + statusCode: 201, + }).as(name); + }); return this; } diff --git a/tests/cypress/support/renkulab-fixtures/user.ts b/tests/cypress/support/renkulab-fixtures/user.ts index 9fb0cb8cb3..81f2d59c73 100644 --- a/tests/cypress/support/renkulab-fixtures/user.ts +++ b/tests/cypress/support/renkulab-fixtures/user.ts @@ -63,15 +63,26 @@ export function User(Parent: T) { } ) as DeepRequired; - const response = { + const responseGitLab = { body: {}, statusCode: 401, ...(delay != null ? { delay } : {}), }; + cy.intercept("GET", "/ui-server/api/user", responseGitLab).as(user.name); - cy.intercept("GET", "/ui-server/api/user", response).as(user.name); - - cy.intercept("GET", "/api/data/user", response).as(dataServiceUser.name); + const responseDataService = { + body: { + error: { + code: 1401, + message: "You have to be authenticated to perform this operation.", + }, + }, + statusCode: 401, + ...(delay != null ? { delay } : {}), + }; + cy.intercept("GET", "/api/data/user", responseDataService).as( + dataServiceUser.name + ); return this; }