diff --git a/client/src/components/EntityWatermark.tsx b/client/src/components/EntityWatermark.tsx new file mode 100644 index 000000000..dd3c190d0 --- /dev/null +++ b/client/src/components/EntityWatermark.tsx @@ -0,0 +1,43 @@ +/*! + * 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 { CSSProperties } from "react"; +import { Folder, People, Person } from "react-bootstrap-icons"; + +interface EntityWatermarkProps { + type: "project" | "user" | "group"; +} +export function EntityWatermark({ type }: EntityWatermarkProps) { + const watermarkStyles: CSSProperties = { + right: "0", + fontSize: "150px", + lineHeight: "0", + color: "rgba(0, 0, 0, 0.1)", + }; + return ( +
+ {type === "group" && } + {type === "user" && } + {type === "project" && } +
+ ); +} diff --git a/client/src/components/PageNav.tsx b/client/src/components/PageNav.tsx new file mode 100644 index 000000000..fb80614e1 --- /dev/null +++ b/client/src/components/PageNav.tsx @@ -0,0 +1,56 @@ +/*! + * 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 { Eye, Sliders } from "react-bootstrap-icons"; +import { Nav, NavItem } from "reactstrap"; +import RenkuNavLinkV2 from "./RenkuNavLinkV2"; + +export interface PageNavOptions { + overviewUrl: string; + settingsUrl: string; +} +export default function PageNav({ options }: { options: PageNavOptions }) { + return ( + <> + + + ); +} diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorsBox.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorsBox.tsx index 8b1ed9286..e67a53f56 100644 --- a/client/src/features/dataConnectorsV2/components/DataConnectorsBox.tsx +++ b/client/src/features/dataConnectorsV2/components/DataConnectorsBox.tsx @@ -212,16 +212,20 @@ function DataConnectorBoxContent({ totalConnectors={data.total} /> - {data.total === 0 && ( -

- Add published datasets from data repositories, and connect to - cloud storage to read and write custom data. -

+ {data.total === 0 && namespaceKind === "group" && ( + + )} + {data.total === 0 && namespaceKind === "user" && ( + )} {data.total > 0 && ( {data.dataConnectors?.map((dc) => ( - + ))} )} @@ -320,3 +324,35 @@ function DataConnectorLoadingBoxContent() { ); } + +function AddEmptyListForGroupNamespace({ namespace }: { namespace: string }) { + const { permissions } = useGroupPermissions({ groupSlug: namespace }); + + return ( + This group has no visible data connectors.

} + enabled={ +

+ Add published datasets from data repositories, and connect to cloud + storage to read and write custom data. +

+ } + requestedPermission="write" + userPermissions={permissions} + /> + ); +} + +function AddEmptyListForUserNamespace({ namespace }: { namespace: string }) { + const { data: currentUser } = useGetUserQuery(); + + if (currentUser?.isLoggedIn && currentUser.username === namespace) { + return ( +

+ Add published datasets from data repositories, and connect to cloud + storage to read and write custom data. +

+ ); + } + return

This user has no visible data connectors.

; +} diff --git a/client/src/features/dataConnectorsV2/components/DataConnectorsBoxListDisplay.tsx b/client/src/features/dataConnectorsV2/components/DataConnectorsBoxListDisplay.tsx index 181338226..6a1fcf91d 100644 --- a/client/src/features/dataConnectorsV2/components/DataConnectorsBoxListDisplay.tsx +++ b/client/src/features/dataConnectorsV2/components/DataConnectorsBoxListDisplay.tsx @@ -18,11 +18,12 @@ import cx from "classnames"; import { useCallback, useState } from "react"; -import { Globe2, Lock } from "react-bootstrap-icons"; +import { EyeFill, Globe2, Lock, Pencil } from "react-bootstrap-icons"; import { Col, ListGroupItem, Row } from "reactstrap"; import ClampedParagraph from "../../../components/clamped/ClampedParagraph"; import { TimeCaption } from "../../../components/TimeCaption"; +import UserAvatar, { UserAvatarSize } from "../../usersV2/show/UserAvatar.tsx"; import type { DataConnector, DataConnectorToProjectLink, @@ -33,16 +34,20 @@ import DataConnectorView from "./DataConnectorView"; interface DataConnectorBoxListDisplayProps { dataConnector: DataConnector; dataConnectorLink?: DataConnectorToProjectLink; + extendedPreview?: boolean; } export default function DataConnectorBoxListDisplay({ dataConnector, dataConnectorLink, + extendedPreview, }: DataConnectorBoxListDisplayProps) { const { name, description, visibility, creation_date: creationDate, + storage, + namespace, } = dataConnector; const [showDetails, setShowDetails] = useState(false); @@ -50,6 +55,21 @@ export default function DataConnectorBoxListDisplay({ setShowDetails((open) => !open); }, []); + const type = `${storage?.configuration?.type?.toString() ?? ""} ${ + storage?.configuration?.provider?.toString() ?? "" + }`; + const readOnly = storage?.readonly ? ( +
+ + Read only +
+ ) : ( +
+ + Allow Read-write +
+ ); + return ( <> - + {name} +
+ +

+ {namespace} +

+
{description && {description}} + {extendedPreview &&
{type}
}
-
+
{visibility.toLowerCase() === "private" ? ( - <> +
Private - +
) : ( - <> +
Public - +
)} + {extendedPreview && readOnly}
import("./show/GroupPageContainer")); + +export default function LazyGroupContainer() { + return ( + }> + + + ); +} diff --git a/client/src/features/groupsV2/LazyGroupV2Show.tsx b/client/src/features/groupsV2/LazyGroupV2Overview.tsx similarity index 86% rename from client/src/features/groupsV2/LazyGroupV2Show.tsx rename to client/src/features/groupsV2/LazyGroupV2Overview.tsx index f666626d6..0f2adabcb 100644 --- a/client/src/features/groupsV2/LazyGroupV2Show.tsx +++ b/client/src/features/groupsV2/LazyGroupV2Overview.tsx @@ -19,12 +19,12 @@ import { Suspense, lazy } from "react"; import PageLoader from "../../components/PageLoader"; -const GroupV2Show = lazy(() => import("./show/GroupV2Show")); +const GroupV2Overview = lazy(() => import("./show/GroupV2Show")); -export default function LazyGroupV2Show() { +export default function LazyGroupV2Overview() { return ( }> - + ); } diff --git a/client/src/features/groupsV2/members/GroupV2MemberListDisplay.tsx b/client/src/features/groupsV2/members/GroupV2MemberListDisplay.tsx index c0a5e7372..b87f9366a 100644 --- a/client/src/features/groupsV2/members/GroupV2MemberListDisplay.tsx +++ b/client/src/features/groupsV2/members/GroupV2MemberListDisplay.tsx @@ -19,8 +19,9 @@ import cx from "classnames"; import { capitalize } from "lodash-es"; import { useMemo } from "react"; +import { People } from "react-bootstrap-icons"; import { Link, generatePath } from "react-router-dom-v5-compat"; -import { ListGroup } from "reactstrap"; +import { Badge } from "reactstrap"; import { Loader } from "../../../components/Loader"; import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert"; @@ -28,7 +29,7 @@ import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; import { toSortedMembers } from "../../ProjectPageV2/utils/roleUtils"; import type { ProjectMemberResponse } from "../../projectsV2/api/projectV2.api"; import { useGetGroupsByGroupSlugMembersQuery } from "../../projectsV2/api/projectV2.enhanced-api"; -import UserAvatar from "../../usersV2/show/UserAvatar"; +import { GroupInformationBox } from "../show/GroupV2Information"; interface GroupV2MemberListDisplayProps { group: string; @@ -48,30 +49,33 @@ export default function GroupV2MemberListDisplay({ [members] ); - if (isLoading) - return ( -
-
- -
Retrieving group members...
-
-
- ); - if (error || sortedMembers == null) { return ; } - if (!sortedMembers.length) { - return

There are no members in this group.

; - } - return ( - + } + title={ + <> + Members + {sortedMembers.length ?? 0} + + } + > + {!sortedMembers.length &&

There are no members in this group.

} + {isLoading && ( +
+
+ +
Retrieving group members...
+
+
+ )} {sortedMembers?.map((member) => ( ))} -
+ ); } @@ -95,32 +99,22 @@ function GroupV2Member({ member }: GroupV2MemberProps) { return ( <> -
+
-
- - {name ?? "Unknown user"}{" "} -
-

{`@${username}`}

+

+ {name ?? "Unknown user"} ({capitalize(role)}) +

-

{capitalize(role)}

diff --git a/client/src/features/groupsV2/settings/GroupSettingsMetadata.tsx b/client/src/features/groupsV2/settings/GroupSettingsMetadata.tsx index 5095e2650..97275710b 100644 --- a/client/src/features/groupsV2/settings/GroupSettingsMetadata.tsx +++ b/client/src/features/groupsV2/settings/GroupSettingsMetadata.tsx @@ -17,7 +17,7 @@ */ import cx from "classnames"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { CheckLg, Pencil, XLg } from "react-bootstrap-icons"; import { useForm } from "react-hook-form"; import { generatePath, useNavigate } from "react-router-dom-v5-compat"; @@ -25,6 +25,7 @@ import { Button, Form, Input, + Label, Modal, ModalBody, ModalFooter, @@ -33,6 +34,8 @@ import { import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert"; import { Loader } from "../../../components/Loader"; import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; +import AppContext from "../../../utils/context/appContext.ts"; +import PermissionsGuard from "../../permissionsV2/PermissionsGuard.tsx"; import type { GroupPatchRequest, GroupResponse, @@ -44,6 +47,7 @@ import { import DescriptionFormField from "../../projectsV2/fields/DescriptionFormField"; import NameFormField from "../../projectsV2/fields/NameFormField"; import SlugFormField from "../../projectsV2/fields/SlugFormField"; +import useGroupPermissions from "../utils/useGroupPermissions.hook.ts"; type GroupMetadata = Omit; @@ -58,7 +62,9 @@ function GroupDeleteConfirmation({ toggle, group, }: GroupDeleteConfirmationProps) { + const navigate = useNavigate(); const [deleteGroup, result] = useDeleteGroupsByGroupSlugMutation(); + const { notifications } = useContext(AppContext); const onDelete = useCallback(() => { deleteGroup({ groupSlug: group.slug }); }, [deleteGroup, group.slug]); @@ -71,10 +77,15 @@ function GroupDeleteConfirmation({ ); useEffect(() => { - if (result.isSuccess || result.isError) { - toggle(); + if (result.isError) + notifications?.addError(`Error deleting the group ${group.name}`); + if (result.isSuccess) { + notifications?.addSuccess( + `Group ${group.name} has been successfully deleted.` + ); + navigate(generatePath(ABSOLUTE_ROUTES.v2.root)); } - }, [result.isError, result.isSuccess, toggle]); + }, [result.isError, result.isSuccess, notifications, group.name, navigate]); return ( @@ -131,6 +142,7 @@ export default function GroupMetadataForm({ group }: GroupMetadataFormProps) { slug: group.slug ?? "", }, }); + const permissions = useGroupPermissions({ groupSlug: group.slug }); const [updateGroup, updateGroupResult] = usePatchGroupsByGroupSlugMutation(); @@ -165,42 +177,113 @@ export default function GroupMetadataForm({ group }: GroupMetadataFormProps) { )}
- } + enabled={ + + } + requestedPermission="write" + userPermissions={permissions.permissions} /> - } + enabled={ + + } + requestedPermission="write" + userPermissions={permissions.permissions} /> - + } + enabled={ + + } + requestedPermission="write" + userPermissions={permissions.permissions} /> +
- - + + Delete + + } + requestedPermission="delete" + userPermissions={permissions.permissions} + /> + + + {isUpdating ? ( + + ) : ( + + )} + Update + + } + requestedPermission="write" + userPermissions={permissions.permissions} + />
); } +function GroupReadOnlyField({ + title, + value, +}: { + title: string; + value: string; +}) { + return ( +
+ + +
+ ); +} diff --git a/client/src/features/groupsV2/settings/GroupV2Settings.tsx b/client/src/features/groupsV2/settings/GroupV2Settings.tsx index d9f324d98..7f16e99af 100644 --- a/client/src/features/groupsV2/settings/GroupV2Settings.tsx +++ b/client/src/features/groupsV2/settings/GroupV2Settings.tsx @@ -18,14 +18,12 @@ import { skipToken } from "@reduxjs/toolkit/query"; import cx from "classnames"; -import { ArrowLeft, Sliders } from "react-bootstrap-icons"; -import { Link, generatePath, useParams } from "react-router-dom-v5-compat"; +import { Sliders } from "react-bootstrap-icons"; +import { useParams } from "react-router-dom-v5-compat"; import { Card, CardBody, CardHeader } from "reactstrap"; import { Loader } from "../../../components/Loader"; -import ContainerWrap from "../../../components/container/ContainerWrap"; import LazyNotFound from "../../../not-found/LazyNotFound"; -import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; import { useGetGroupsByGroupSlugQuery } from "../../projectsV2/api/projectV2.enhanced-api"; import GroupNotFound from "../../projectsV2/notFound/GroupNotFound"; import GroupSettingsMembers from "./GroupSettingsMembers"; @@ -53,42 +51,26 @@ export default function GroupV2Settings() { } return ( - -
-
-

{group.name ?? "Unknown group"}

-
- - - Back to group - -
-
+
+
+ + +

+ + General settings +

+
+ + + +
+
-
- - -

- - General settings -

-
- - - -
-
- -
- - - -
-
- +
+ + + +
+
); } diff --git a/client/src/features/groupsV2/show/GroupPageContainer.tsx b/client/src/features/groupsV2/show/GroupPageContainer.tsx new file mode 100644 index 000000000..35380c579 --- /dev/null +++ b/client/src/features/groupsV2/show/GroupPageContainer.tsx @@ -0,0 +1,144 @@ +/*! + * 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 { skipToken } from "@reduxjs/toolkit/query"; +import cx from "classnames"; +import { useEffect } from "react"; +import { + generatePath, + Outlet, + useNavigate, + useOutletContext, + useParams, +} from "react-router-dom-v5-compat"; +import { Col, Row } from "reactstrap"; +import ContainerWrap from "../../../components/container/ContainerWrap.tsx"; +import { EntityWatermark } from "../../../components/EntityWatermark.tsx"; +import { Loader } from "../../../components/Loader.tsx"; +import PageNav, { PageNavOptions } from "../../../components/PageNav.tsx"; +import LazyNotFound from "../../../not-found/LazyNotFound.tsx"; +import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants.ts"; +import { GroupResponse } from "../../projectsV2/api/namespace.api.ts"; +import { + useGetGroupsByGroupSlugQuery, + useGetNamespacesByNamespaceSlugQuery, +} from "../../projectsV2/api/projectV2.enhanced-api.ts"; +import GroupNotFound from "../../projectsV2/notFound/GroupNotFound.tsx"; +import UserAvatar, { + AvatarTypeWrap, + UserAvatarSize, +} from "../../usersV2/show/UserAvatar.tsx"; + +export default function GroupPageContainer() { + const { slug } = useParams<{ slug: string }>(); + + const navigate = useNavigate(); + + const { + data: namespace, + isLoading: isLoadingNamespace, + error: namespaceError, + } = useGetNamespacesByNamespaceSlugQuery( + slug ? { namespaceSlug: slug } : skipToken + ); + const { + data: group, + isLoading: isLoadingGroup, + error: groupError, + } = useGetGroupsByGroupSlugQuery(slug ? { groupSlug: slug } : skipToken); + + const isLoading = isLoadingNamespace || isLoadingGroup; + const error = namespaceError ?? groupError; + + useEffect(() => { + if (slug && namespace?.namespace_kind === "user") { + navigate( + generatePath(ABSOLUTE_ROUTES.v2.users.show, { username: slug }), + { + replace: true, + } + ); + } + }, [namespace?.namespace_kind, navigate, slug]); + + if (!slug) { + return ; + } + + if (isLoading) { + return ; + } + + if (error || !namespace || !group) { + return ; + } + + const options: PageNavOptions = { + overviewUrl: generatePath(ABSOLUTE_ROUTES.v2.groups.show.root, { + slug: group.slug, + }), + settingsUrl: generatePath(ABSOLUTE_ROUTES.v2.groups.show.settings, { + slug: group.slug, + }), + }; + return ( + + + + + + + +
+ +
+ + +
+ +
+ +
+
+ ); +} + +type ContextType = { group: GroupResponse }; +export function useGroup() { + return useOutletContext(); +} + +function GroupHeader({ group, slug }: { group: GroupResponse; slug: string }) { + return ( +
+
+ + + +
+

{group.name ?? "Unknown group"}

+ {group.description && ( +
+

{group.description}

+
+ )} +
+
+
+ ); +} diff --git a/client/src/features/groupsV2/show/GroupV2Information.tsx b/client/src/features/groupsV2/show/GroupV2Information.tsx new file mode 100644 index 000000000..50ee0f611 --- /dev/null +++ b/client/src/features/groupsV2/show/GroupV2Information.tsx @@ -0,0 +1,92 @@ +/*! + * 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 { Clock, InfoCircle, JournalAlbum } from "react-bootstrap-icons"; +import { Card, CardBody, CardHeader } from "reactstrap"; +import { TimeCaption } from "../../../components/TimeCaption.tsx"; +import GroupV2MemberListDisplay from "../members/GroupV2MemberListDisplay.tsx"; +import { useGroup } from "./GroupPageContainer.tsx"; + +interface GroupInformationProps { + output?: "plain" | "card"; +} +export default function GroupInformation({ + output = "plain", +}: GroupInformationProps) { + const { group } = useGroup(); + + const information = ( +
+ } + title="Identifier:" + > +

@{group.slug}

+
+ } title="Created:"> +

+ +

+
+ +
+ ); + return output === "plain" ? ( + information + ) : ( + + +
+

+ + Info +

+
+
+ {information} +
+ ); +} + +interface GroupInformationBoxProps { + children: React.ReactNode; + icon: React.ReactNode; + title: React.ReactNode; +} +export function GroupInformationBox({ + children, + icon, + title, +}: GroupInformationBoxProps) { + return ( +
+

+ {icon} + {title} +

+
{children}
+
+ ); +} diff --git a/client/src/features/groupsV2/show/GroupV2Show.tsx b/client/src/features/groupsV2/show/GroupV2Show.tsx index 5205e7d80..cebb1d3f9 100644 --- a/client/src/features/groupsV2/show/GroupV2Show.tsx +++ b/client/src/features/groupsV2/show/GroupV2Show.tsx @@ -16,163 +16,38 @@ * limitations under the License. */ -import { skipToken } from "@reduxjs/toolkit/query"; -import cx from "classnames"; -import { useEffect } from "react"; -import { Pencil } from "react-bootstrap-icons"; -import { - Link, - generatePath, - useNavigate, - useParams, -} from "react-router-dom-v5-compat"; import { Col, Row } from "reactstrap"; - -import { Loader } from "../../../components/Loader"; -import ContainerWrap from "../../../components/container/ContainerWrap"; -import LazyNotFound from "../../../not-found/LazyNotFound"; -import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; - import DataConnectorsBox from "../../dataConnectorsV2/components/DataConnectorsBox"; -import PermissionsGuard from "../../permissionsV2/PermissionsGuard"; -import type { GroupResponse } from "../../projectsV2/api/namespace.api"; -import { - useGetGroupsByGroupSlugQuery, - useGetNamespacesByNamespaceSlugQuery, -} from "../../projectsV2/api/projectV2.enhanced-api"; import ProjectV2ListDisplay from "../../projectsV2/list/ProjectV2ListDisplay"; -import GroupNotFound from "../../projectsV2/notFound/GroupNotFound"; -import { EntityPill } from "../../searchV2/components/SearchV2Results"; -import UserAvatar, { UserAvatarSize } from "../../usersV2/show/UserAvatar"; -import GroupV2MemberListDisplay from "../members/GroupV2MemberListDisplay"; -import useGroupPermissions from "../utils/useGroupPermissions.hook"; +import { useGroup } from "./GroupPageContainer.tsx"; +import GroupInformation from "./GroupV2Information.tsx"; export default function GroupV2Show() { - const { slug } = useParams<{ slug: string }>(); - - const navigate = useNavigate(); - - const { - data: namespace, - isLoading: isLoadingNamespace, - error: namespaceError, - } = useGetNamespacesByNamespaceSlugQuery( - slug ? { namespaceSlug: slug } : skipToken - ); - const { - data: group, - isLoading: isLoadingGroup, - error: groupError, - } = useGetGroupsByGroupSlugQuery(slug ? { groupSlug: slug } : skipToken); - - const isLoading = isLoadingNamespace || isLoadingGroup; - const error = namespaceError ?? groupError; - - useEffect(() => { - if (slug && namespace?.namespace_kind === "user") { - navigate( - generatePath(ABSOLUTE_ROUTES.v2.users.show, { username: slug }), - { - replace: true, - } - ); - } - }, [namespace?.namespace_kind, navigate, slug]); - - if (!slug) { - return ; - } - - if (isLoading) { - return ; - } - - if (error || !namespace || !group) { - return ; - } + const { group } = useGroup(); return ( - -
-
-
-
- -

{group.name ?? "Unknown group"}

-
-
- -
-
-

{`@${slug}`}

-
-
- -
-
- - {group.description && ( -
-

{group.description}

-
- )} -
-

Group Members

- -
- -
-

Group Projects

- No visible projects.

} - /> -
- -
- - + + + + + + + -
-
- ); -} - -interface GroupSettingsButtonProps { - group: GroupResponse; -} - -function GroupSettingsButton({ group }: GroupSettingsButtonProps) { - const { permissions } = useGroupPermissions({ groupSlug: group.slug }); - - return ( - - - Edit settings - - } - disabled={null} - requestedPermission="write" - userPermissions={permissions} - /> + + + + + ); } diff --git a/client/src/features/projectsV2/list/ProjectV2ListDisplay.tsx b/client/src/features/projectsV2/list/ProjectV2ListDisplay.tsx index 7787793e5..ea4b36d34 100644 --- a/client/src/features/projectsV2/list/ProjectV2ListDisplay.tsx +++ b/client/src/features/projectsV2/list/ProjectV2ListDisplay.tsx @@ -17,42 +17,36 @@ */ import cx from "classnames"; -import { ReactNode, useCallback, useEffect, useMemo } from "react"; -import { Globe2, Lock } from "react-bootstrap-icons"; -import { - Link, - generatePath, - useSearchParams, -} from "react-router-dom-v5-compat"; -import { Card, CardBody, Col, Row } from "reactstrap"; +import { useCallback, useEffect, useMemo } from "react"; +import { Folder, PlusLg } from "react-bootstrap-icons"; +import { Link, useSearchParams } from "react-router-dom-v5-compat"; +import { Badge, Card, CardBody, CardHeader, ListGroup } from "reactstrap"; import { Loader } from "../../../components/Loader"; import Pagination from "../../../components/Pagination"; -import { TimeCaption } from "../../../components/TimeCaption"; import { RtkOrNotebooksError } from "../../../components/errors/RtkErrorAlert"; -import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; -import type { Project } from "../api/projectV2.api"; -import { - useGetNamespacesByNamespaceSlugQuery, - useGetProjectsQuery, -} from "../api/projectV2.enhanced-api"; -import ClampedParagraph from "../../../components/clamped/ClampedParagraph"; - -const DEFAULT_PER_PAGE = 12; +import useGroupPermissions from "../../groupsV2/utils/useGroupPermissions.hook.ts"; +import PermissionsGuard from "../../permissionsV2/PermissionsGuard.tsx"; +import { useGetUserQuery } from "../../usersV2/api/users.api.ts"; +import { NamespaceKind } from "../api/namespace.api.ts"; +import { useGetProjectsQuery } from "../api/projectV2.enhanced-api"; +import ProjectShortHandDisplay from "../show/ProjectShortHandDisplay.tsx"; + +const DEFAULT_PER_PAGE = 5; const DEFAULT_PAGE_PARAM = "page"; interface ProjectListDisplayProps { namespace?: string; pageParam?: string; perPage?: number; - emptyListElement?: ReactNode; + namespaceKind: NamespaceKind; } export default function ProjectListDisplay({ namespace: ns, pageParam: pageParam_, perPage: perPage_, - emptyListElement, + namespaceKind, }: ProjectListDisplayProps) { const pageParam = useMemo( () => (pageParam_ ? pageParam_ : DEFAULT_PAGE_PARAM), @@ -115,120 +109,179 @@ export default function ProjectListDisplay({ } }, [data?.totalPages, page, pageParam, setSearchParams]); - if (isLoading) - return ( -
-
- -
Retrieving projects...
-
-
- ); - if (error || data == null) { return ; } - if (!data.total) { - return emptyListElement ??

The project list is empty.

; - } + const emptyListElement = + namespaceKind === "group" ? ( + + ) : ( + + ); return (
- - {data.projects?.map((project) => ( - - ))} - - + + + {isLoading && ( +
+
+ +
Retrieving projects...
+
+
+ )} + {!data.total && emptyListElement} + {data.projects.length > 0 && ( + <> +
+ + {data?.projects?.map((project) => ( + + ))} + +
+ + + )} +
+ +
+ ); +} + +interface ProjectBoxHeaderProps { + totalProjects: number; + namespace: string; + namespaceKind: NamespaceKind; +} +function ProjectBoxHeader({ + totalProjects, + namespaceKind, + namespace, +}: ProjectBoxHeaderProps) { + return ( + +
-
+ > +
+

+ + Projects +

+ {totalProjects} +
+ {namespaceKind === "group" && ( + + )} + {namespaceKind === "user" && ( + + )} +
+ ); } -interface ProjectV2ListProjectProps { - project: Project; +function AddButtonForGroupNamespace({ namespace }: { namespace: string }) { + const { permissions } = useGroupPermissions({ groupSlug: namespace }); + + return ( + + + + } + requestedPermission="write" + userPermissions={permissions} + /> + ); } -function ProjectV2ListProject({ project }: ProjectV2ListProjectProps) { - const { data: namespaceData } = useGetNamespacesByNamespaceSlugQuery({ - namespaceSlug: project.namespace, - }); - const { - name, - namespace, - description, - visibility, - creation_date: creationDate, - } = project; - - const projectUrl = generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { - namespace: project.namespace, - slug: project.slug, - }); - const namespaceUrl = - namespaceData && namespaceData.namespace_kind === "group" - ? generatePath(ABSOLUTE_ROUTES.v2.groups.show.root, { slug: namespace }) - : generatePath(ABSOLUTE_ROUTES.v2.users.show, { - username: project.namespace, - }); +function AddButtonForUserNamespace({ namespace }: { namespace: string }) { + const { data: currentUser } = useGetUserQuery(); + + if (currentUser?.isLoggedIn && currentUser.username === namespace) { + return ( + + + + ); + } + return null; +} + +function AddEmptyListForGroupNamespace({ namespace }: { namespace: string }) { + const { permissions } = useGroupPermissions({ groupSlug: namespace }); return ( - - - -
- {name} -
-

- - {"@"} - {namespace} - -

- {description && {description}} -
-
- {visibility.toLowerCase() === "private" ? ( - <> - - Private - - ) : ( - <> - - Public - - )} -
- -
-
-
- + This group has no visible projects.

} + enabled={ +

+ Collaborate on projects with anyone, with data, code, and compute + together in one place. +

+ } + requestedPermission="write" + userPermissions={permissions} + /> ); } + +function AddEmptyListForUserNamespace({ namespace }: { namespace: string }) { + const { data: currentUser } = useGetUserQuery(); + + if (currentUser?.isLoggedIn && currentUser.username === namespace) { + return ( +

+ Collaborate on projects with anyone, with data, code, and compute + together in one place. +

+ ); + } + return

This user has no visible personal projects.

; +} diff --git a/client/src/features/rootV2/RootV2.tsx b/client/src/features/rootV2/RootV2.tsx index 19b8a5620..617ac28ab 100644 --- a/client/src/features/rootV2/RootV2.tsx +++ b/client/src/features/rootV2/RootV2.tsx @@ -26,6 +26,7 @@ import { 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 LazyGroupContainer from "../groupsV2/LazyGroupContainer.tsx"; import LazyProjectPageV2Show from "../ProjectPageV2/LazyProjectPageV2Show"; import LazyProjectPageOverview from "../ProjectPageV2/ProjectPageContent/LazyProjectPageOverview"; @@ -33,7 +34,7 @@ import LazyProjectPageSettings from "../ProjectPageV2/ProjectPageContent/LazyPro import LazyConnectedServicesPage from "../connectedServices/LazyConnectedServicesPage"; import LazyDashboardV2 from "../dashboardV2/LazyDashboardV2"; import LazyHelpV2 from "../dashboardV2/LazyHelpV2"; -import LazyGroupV2Show from "../groupsV2/LazyGroupV2Show"; +import LazyGroupV2Overview from "../groupsV2/LazyGroupV2Overview.tsx"; import LazyGroupV2New from "../projectsV2/LazyGroupNew"; import LazyProjectV2New from "../projectsV2/LazyProjectV2New"; import LazyProjectV2ShowByProjectId from "../projectsV2/LazyProjectV2ShowByProjectId"; @@ -143,11 +144,13 @@ function GroupsV2Routes() { element={} /> - } /> - } - /> + }> + } /> + } + /> + acc + char.charCodeAt(0), 0); } + +interface AvatarType { + type: "User" | "Group"; + children: ReactNode; +} +export function AvatarTypeWrap({ type, children }: AvatarType) { + const styles: CSSProperties = { + width: "75px", + height: "65px", + }; + + return ( +
+ {children} +
+ +
+
+ ); +} diff --git a/client/src/features/usersV2/show/UserShow.tsx b/client/src/features/usersV2/show/UserShow.tsx index 9801e88c2..3c9cd67e6 100644 --- a/client/src/features/usersV2/show/UserShow.tsx +++ b/client/src/features/usersV2/show/UserShow.tsx @@ -128,9 +128,7 @@ export default function UserShow() { {name ?? username} has no visible personal projects.

- } + namespaceKind="user" />
diff --git a/tests/cypress/e2e/groupV2.spec.ts b/tests/cypress/e2e/groupV2.spec.ts index d98ae4c3d..bafd9b08d 100644 --- a/tests/cypress/e2e/groupV2.spec.ts +++ b/tests/cypress/e2e/groupV2.spec.ts @@ -122,8 +122,7 @@ describe("Edit v2 group", () => { cy.contains("test 2 group-v2").should("be.visible").click(); cy.wait("@readGroupV2"); cy.contains("test 2 group-v2").should("be.visible"); - cy.wait("@getGroupV2Permissions"); - cy.contains("Edit settings").should("be.visible").click(); + cy.getDataCy("nav-link-settings").should("be.visible").click(); cy.getDataCy("group-name-input").clear().type("new name"); cy.getDataCy("group-slug-input").clear().type("new-slug"); cy.getDataCy("group-description-input").clear().type("new description"); @@ -159,8 +158,7 @@ describe("Edit v2 group", () => { cy.contains("test 2 group-v2").should("be.visible").click(); cy.wait("@readGroupV2"); cy.contains("test 2 group-v2").should("be.visible"); - cy.wait("@getGroupV2Permissions"); - cy.contains("Edit settings").should("be.visible").click(); + cy.getDataCy("nav-link-settings").should("be.visible").click(); cy.contains("@user1").should("be.visible"); cy.contains("user3-uuid").should("be.visible"); fixtures @@ -206,8 +204,7 @@ describe("Edit v2 group", () => { cy.contains("test 2 group-v2").should("be.visible").click(); cy.wait("@readGroupV2"); cy.contains("test 2 group-v2").should("be.visible"); - cy.wait("@getGroupV2Permissions"); - cy.contains("Edit settings").should("be.visible").click(); + cy.getDataCy("nav-link-settings").should("be.visible").click(); cy.getDataCy("group-description-input").clear().type("new description"); cy.get("button").contains("Delete").should("be.visible").click(); cy.get("button") @@ -225,7 +222,9 @@ describe("Edit v2 group", () => { fixture: "groupV2/list-groupV2-post-delete.json", name: "listGroupV2PostDelete", }); - cy.contains("Group with slug test-2-group-v2 does not exist"); + cy.contains("Group test 2 group-v2 has been successfully deleted.").should( + "be.visible" + ); }); });