Start searching by typing in the search bar above.
;
}
- if (!searchResults.data?.items.length) {
+ if (!searchResults.data?.items?.length) {
return (
<>
@@ -102,105 +103,188 @@ function SearchV2ResultsContent() {
project={entity}
/>
);
+ } else if (entity.type === "Group") {
+ return (
+
+ );
} else if (entity.type === "User") {
return (
);
}
// Unknown entity type, in case backend introduces new types before the UI catches up
- return (
-
- );
+ return ;
});
return {resultsOutput}
;
}
interface SearchV2ResultsCardProps {
- cardId: string;
- children: React.ReactNode;
- url?: string;
+ children?: ReactNode;
}
-function SearchV2ResultsCard({ cardId, children }: SearchV2ResultsCardProps) {
+function SearchV2ResultsCard({ children }: SearchV2ResultsCardProps) {
return (
-
-
- {project.name}
-
-
- {project.slug} - {project.visibility}
+
+
+ {name}
+
+
+
+ {"@"}
+ {namespace?.namespace}
+
-
- searchByUser(project.createdBy.id)}
- >
- user-{project.createdBy.id}
-
-
- {project.description}
-
-
+ {description &&
{description}
}
+
+
+ {visibility.toLowerCase() === "private" ? (
+ <>
+
+ Private
+ >
+ ) : (
+ <>
+
+ Public
+ >
+ )}
+
+
+
+
+
+
+ );
+}
+
+interface SearchV2ResultGroupProps {
+ group: Group;
+}
+function SearchV2ResultGroup({ group }: SearchV2ResultGroupProps) {
+ const { name, namespace, description } = group;
+
+ const groupUrl = generatePath(ABSOLUTE_ROUTES.v2.groups.show.root, {
+ slug: namespace,
+ });
+
+ return (
+
+
+ {name}
+
+
+ {"@"}
+ {namespace}
+ {description && {description}
}
);
}
interface SearchV2ResultUserProps {
- user: UserSearchResult;
+ user: User;
}
function SearchV2ResultUser({ user }: SearchV2ResultUserProps) {
+ const { firstName, lastName, namespace } = user;
+
+ const userUrl = generatePath(ABSOLUTE_ROUTES.v2.users.show, {
+ username: namespace ?? "",
+ });
+
+ const displayName =
+ firstName && lastName
+ ? `${firstName} ${lastName}`
+ : firstName || lastName || namespace;
+
return (
-
- {user.id}
-
- {user.firstName} {user.lastName}
-
- {user.email}
+
+
+ {displayName}
+
+
+ {"@"}
+ {namespace}
+
);
}
-interface SearchV2ResultsUnknownProps {
- index: number;
-}
-function SearchV2ResultsUnknown({ index }: SearchV2ResultsUnknownProps) {
+function SearchV2ResultsUnknown() {
return (
-
+
Unknown entity
This entity type is not supported yet.
diff --git a/client/src/features/searchV2/searchV2.api.ts b/client/src/features/searchV2/searchV2.api.ts
deleted file mode 100644
index b819b3cc12..0000000000
--- a/client/src/features/searchV2/searchV2.api.ts
+++ /dev/null
@@ -1,50 +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 { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
-import { SearchApiParams, SearchApiResponse } from "./searchV2.types";
-
-const searchV2Api = createApi({
- reducerPath: "searchV2Api",
- baseQuery: fetchBaseQuery({
- // eslint-disable-next-line spellcheck/spell-checker
- baseUrl: "/ui-server/api/search",
- }),
- tagTypes: ["SearchV2"],
- endpoints: (builder) => ({
- getSearchResults: builder.query({
- query: (params) => {
- return {
- method: "GET",
- params: {
- q: params.searchString,
- page: params.page,
- per_page: params.perPage,
- },
- url: "",
- };
- },
- extraOptions: {
- refetchOnMountOrArgChange: 1,
- },
- }),
- }),
-});
-
-export default searchV2Api;
-export const { useGetSearchResultsQuery } = searchV2Api;
diff --git a/client/src/features/searchV2/searchV2.types.ts b/client/src/features/searchV2/searchV2.types.ts
index 81c5a4adf1..c49a2c17e1 100644
--- a/client/src/features/searchV2/searchV2.types.ts
+++ b/client/src/features/searchV2/searchV2.types.ts
@@ -16,55 +16,8 @@
* limitations under the License.
*/
-import { UserV2 } from "../userV2/userV2.types";
import { DateFilterTypes } from "../../components/dateFilter/DateFilter.tsx";
-export type EntityType = "Project" | "User";
-
-export interface SearchApiParams {
- searchString: string;
- page: number;
- perPage: number;
-}
-
-export interface SearchApiResponse {
- items: SearchResult[];
- pagingInfo: {
- page: {
- limit: number;
- offset: number;
- };
- totalResult: number;
- totalPages: number;
- prevPage: number;
- nextPage: number;
- };
-}
-
-export type SearchResult = ProjectSearchResult | UserSearchResult;
-export interface ProjectSearchResult {
- createdBy: UserV2;
- creationDate: Date;
- description: string;
- id: string;
- members: UserV2[];
- name: string;
- namespace: string;
- repositories: string[];
- score: number;
- slug: string;
- type: "Project";
- visibility: string;
-}
-
-export interface UserSearchResult {
- id: string;
- firstName: string;
- lastName: string;
- type: "User";
- email: string;
-}
-
export interface DateFilter {
option: DateFilterTypes;
from?: string;
diff --git a/client/src/features/searchV2/searchV2.utils.ts b/client/src/features/searchV2/searchV2.utils.ts
index 72b61d7640..6e833a912e 100644
--- a/client/src/features/searchV2/searchV2.utils.ts
+++ b/client/src/features/searchV2/searchV2.utils.ts
@@ -35,6 +35,7 @@ export const AVAILABLE_FILTERS = {
role: ROLE_FILTER,
type: {
project: "Project",
+ group: "Group",
user: "User",
},
visibility: {
@@ -155,7 +156,9 @@ export const buildSearchQuery = (searchState: SearchV2State): string => {
if (searchState.filters.createdBy)
searchQueryItems.push(`createdBy:${searchState.filters.createdBy}`);
- searchQueryItems.push(query);
+ if (query) {
+ searchQueryItems.push(query);
+ }
return searchQueryItems.join(" ");
};
diff --git a/client/src/features/searchV2/useStartSearch.hook.ts b/client/src/features/searchV2/useStartSearch.hook.ts
index 7b512792a7..6b132e3952 100644
--- a/client/src/features/searchV2/useStartSearch.hook.ts
+++ b/client/src/features/searchV2/useStartSearch.hook.ts
@@ -20,15 +20,14 @@ import { useCallback, useEffect } from "react";
import { useDispatch } from "react-redux";
import { setPage, setSearch, setTotals } from "./searchV2.slice";
-import searchV2Api from "./searchV2.api";
import useAppSelector from "../../utils/customHooks/useAppSelector.hook";
import { buildSearchQuery } from "./searchV2.utils";
+import { searchV2Api } from "./api/searchV2Api.api";
const useStartNewSearch = () => {
const dispatch = useDispatch();
const searchState = useAppSelector((state) => state.searchV2);
- const [startSearch, searchResult] =
- searchV2Api.useLazyGetSearchResultsQuery();
+ const [startSearch, searchResult] = searchV2Api.endpoints.$get.useLazyQuery();
// update the search slice and start the new query
const startNewSearch = useCallback(() => {
@@ -45,7 +44,7 @@ const useStartNewSearch = () => {
const resetPage = searchState.search.lastSearch !== searchQuery;
if (resetPage) dispatch(setPage(1));
startSearch({
- searchString: searchQuery,
+ q: searchQuery,
page: resetPage ? 1 : searchState.search.page,
perPage: searchState.search.perPage,
});
diff --git a/client/src/features/user/dataServicesUser.api/dataServicesUser.api.ts b/client/src/features/user/dataServicesUser.api/dataServicesUser.api.ts
index 97c9fbfc35..07d9314899 100644
--- a/client/src/features/user/dataServicesUser.api/dataServicesUser.api.ts
+++ b/client/src/features/user/dataServicesUser.api/dataServicesUser.api.ts
@@ -45,15 +45,16 @@ export type GetErrorApiArg = void;
export type GetVersionApiResponse = /** status 200 The error */ Version;
export type GetVersionApiArg = void;
export type UserId = string;
+export type Username = string;
export type UserEmail = string;
export type UserFirstLastName = string;
export type UserWithId = {
id: UserId;
+ username: Username;
email?: UserEmail;
first_name?: UserFirstLastName;
last_name?: UserFirstLastName;
};
-export type UsersWithId = UserWithId[];
export type ErrorResponse = {
error: {
code: number;
@@ -61,6 +62,7 @@ export type ErrorResponse = {
message: string;
};
};
+export type UsersWithId = UserWithId[];
export type Version = {
version: string;
};
diff --git a/client/src/features/user/dataServicesUser.api/dataServicesUser.openapi.json b/client/src/features/user/dataServicesUser.api/dataServicesUser.openapi.json
index 007e6b2811..9841d639f6 100644
--- a/client/src/features/user/dataServicesUser.api/dataServicesUser.openapi.json
+++ b/client/src/features/user/dataServicesUser.api/dataServicesUser.openapi.json
@@ -1,8 +1,8 @@
{
"openapi": "3.0.2",
"info": {
- "title": "Renku data services.",
- "description": "Endpoints that provide different information from different backends.\n",
+ "title": "Renku Data Services API",
+ "description": "This service is the main backend for Renku. It provides information about users, projects,\ncloud storage, access to compute resources and many other things.\n",
"version": "v1"
},
"servers": [
@@ -27,6 +27,9 @@
}
}
}
+ },
+ "default": {
+ "$ref": "#/components/responses/Error"
}
},
"tags": ["users"]
@@ -56,6 +59,9 @@
}
}
}
+ },
+ "default": {
+ "$ref": "#/components/responses/Error"
}
},
"tags": ["users"]
@@ -84,6 +90,19 @@
}
}
}
+ },
+ "404": {
+ "description": "The user does not exist",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ErrorResponse"
+ }
+ }
+ }
+ },
+ "default": {
+ "$ref": "#/components/responses/Error"
}
},
"tags": ["users"]
@@ -133,6 +152,9 @@
"id": {
"$ref": "#/components/schemas/UserId"
},
+ "username": {
+ "$ref": "#/components/schemas/Username"
+ },
"email": {
"$ref": "#/components/schemas/UserEmail"
},
@@ -143,10 +165,12 @@
"$ref": "#/components/schemas/UserFirstLastName"
}
},
- "required": ["id"],
+ "required": ["id", "username"],
"example": {
"id": "some-random-keycloak-id",
- "email": "user@gmail.com"
+ "username": "some-username",
+ "first_name": "Jane",
+ "last_name": "Doe"
}
},
"UsersWithId": {
@@ -156,6 +180,16 @@
},
"uniqueItems": true
},
+ "UserSecretKey": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "secret_key": {
+ "type": "string",
+ "description": "The users secret key"
+ }
+ }
+ },
"Version": {
"type": "object",
"properties": {
@@ -168,7 +202,15 @@
"UserId": {
"type": "string",
"description": "Keycloak user ID",
- "example": "f74a228b-1790-4276-af5f-25c2424e9b0c"
+ "example": "f74a228b-1790-4276-af5f-25c2424e9b0c",
+ "pattern": "^[A-Za-z0-9]{1}[A-Za-z0-9-]+$"
+ },
+ "Username": {
+ "type": "string",
+ "description": "Handle of the user",
+ "example": "some-username",
+ "minLength": 1,
+ "maxLength": 99
},
"UserFirstLastName": {
"type": "string",
@@ -183,6 +225,88 @@
"description": "User email",
"example": "some-user@gmail.com"
},
+ "SecretsList": {
+ "description": "A list of secrets",
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/SecretWithId"
+ },
+ "minItems": 0
+ },
+ "SecretWithId": {
+ "description": "A Renku secret",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "$ref": "#/components/schemas/Ulid"
+ },
+ "name": {
+ "$ref": "#/components/schemas/SecretName"
+ },
+ "modification_date": {
+ "$ref": "#/components/schemas/ModificationDate"
+ }
+ },
+ "required": ["id", "name", "modification_date"],
+ "example": {
+ "id": "01AN4Z79ZS5XN0F25N3DB94T4R",
+ "name": "S3-Credentials",
+ "modification_date": "2024-01-16T11:42:05Z"
+ }
+ },
+ "SecretPost": {
+ "description": "Secret metadata to be created",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "$ref": "#/components/schemas/SecretName"
+ },
+ "value": {
+ "$ref": "#/components/schemas/SecretValue"
+ }
+ },
+ "required": ["name", "value"]
+ },
+ "SecretPatch": {
+ "type": "object",
+ "description": "Secret metadata to be modified",
+ "additionalProperties": false,
+ "properties": {
+ "value": {
+ "$ref": "#/components/schemas/SecretValue"
+ }
+ },
+ "required": ["value"]
+ },
+ "SecretName": {
+ "description": "Secret name",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 99,
+ "pattern": "^[a-zA-Z0-9_\\-.]*$",
+ "example": "Data-S3-Secret_1"
+ },
+ "Ulid": {
+ "description": "ULID identifier",
+ "type": "string",
+ "minLength": 26,
+ "maxLength": 26,
+ "pattern": "^[A-Z0-9]{26}$"
+ },
+ "ModificationDate": {
+ "description": "The date and time the secret was created or modified (this is always in UTC)",
+ "type": "string",
+ "format": "date-time",
+ "example": "2023-11-01T17:32:28Z"
+ },
+ "SecretValue": {
+ "description": "Secret value that can be any text",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 5000
+ },
"ErrorResponse": {
"type": "object",
"properties": {
diff --git a/client/src/features/usersV2/LazyUserRedirect.tsx b/client/src/features/usersV2/LazyUserRedirect.tsx
new file mode 100644
index 0000000000..571c77b31c
--- /dev/null
+++ b/client/src/features/usersV2/LazyUserRedirect.tsx
@@ -0,0 +1,30 @@
+/*!
+ * 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 { Suspense, lazy } from "react";
+import PageLoader from "../../components/PageLoader";
+
+const UserRedirect = lazy(() => import("./show/UserRedirect"));
+
+export default function LazyUserRedirect() {
+ return (
+ }>
+
+
+ );
+}
diff --git a/client/src/features/projectsV2/LazyGroupShow.tsx b/client/src/features/usersV2/LazyUserShow.tsx
similarity index 85%
rename from client/src/features/projectsV2/LazyGroupShow.tsx
rename to client/src/features/usersV2/LazyUserShow.tsx
index 2f2b2cfc6a..36660489e2 100644
--- a/client/src/features/projectsV2/LazyGroupShow.tsx
+++ b/client/src/features/usersV2/LazyUserShow.tsx
@@ -13,17 +13,18 @@
* 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
+ * limitations under the License.
*/
+
import { Suspense, lazy } from "react";
import PageLoader from "../../components/PageLoader";
-const GroupShow = lazy(() => import("./show/GroupShow"));
+const UserShow = lazy(() => import("./show/UserShow"));
-export default function LazyGroupList() {
+export default function LazyUserShow() {
return (
}>
-
+
);
}
diff --git a/client/src/features/usersV2/show/UserAvatar.module.scss b/client/src/features/usersV2/show/UserAvatar.module.scss
new file mode 100644
index 0000000000..34ddc567eb
--- /dev/null
+++ b/client/src/features/usersV2/show/UserAvatar.module.scss
@@ -0,0 +1,24 @@
+@import "~bootstrap/scss/functions";
+@import "~bootstrap/scss/variables";
+@import "~bootstrap/scss/variables-dark";
+@import "~bootstrap/scss/maps";
+@import "~bootstrap/scss/mixins";
+
+.avatar {
+ --size: 20px;
+ --font-size: 11px;
+
+ height: var(--size);
+ width: var(--size);
+ font-size: var(--font-size);
+
+ &.large {
+ --size: 48px;
+ --font-size: 18px;
+
+ @include media-breakpoint-up(sm) {
+ --size: 64px;
+ --font-size: 28px;
+ }
+ }
+}
diff --git a/client/src/features/usersV2/show/UserAvatar.tsx b/client/src/features/usersV2/show/UserAvatar.tsx
new file mode 100644
index 0000000000..a0fe969e10
--- /dev/null
+++ b/client/src/features/usersV2/show/UserAvatar.tsx
@@ -0,0 +1,61 @@
+/*!
+ * 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 styles from "./UserAvatar.module.scss";
+
+interface UserAvatarProps {
+ firstName?: string;
+ large?: boolean;
+ lastName?: string;
+ username?: string;
+}
+
+export default function UserAvatar({
+ firstName,
+ lastName,
+ large,
+ username,
+}: UserAvatarProps) {
+ const firstLetters =
+ firstName && lastName
+ ? `${firstName.slice(0, 1)}${lastName.slice(0, 1)}`
+ : firstName || lastName
+ ? `${firstName}${lastName}`.slice(0, 2)
+ : username?.slice(0, 2) ?? "??";
+ const firstLettersUpper = firstLetters.toUpperCase();
+
+ return (
+
+ {firstLettersUpper}
+
+ );
+}
diff --git a/client/src/features/usersV2/show/UserRedirect.tsx b/client/src/features/usersV2/show/UserRedirect.tsx
new file mode 100644
index 0000000000..1b508d71f7
--- /dev/null
+++ b/client/src/features/usersV2/show/UserRedirect.tsx
@@ -0,0 +1,108 @@
+/*!
+ * 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 { ArrowLeft, BoxArrowInRight } from "react-bootstrap-icons";
+import {
+ Link,
+ generatePath,
+ useLocation,
+ useNavigate,
+} from "react-router-dom-v5-compat";
+import { Col, Row } from "reactstrap";
+
+import { Loader } from "../../../components/Loader";
+import ContainerWrap from "../../../components/container/ContainerWrap";
+import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants";
+import useLegacySelector from "../../../utils/customHooks/useLegacySelector.hook";
+import { Url } from "../../../utils/helpers/url";
+import UserNotFound from "../../projectsV2/notFound/UserNotFound";
+import { useGetUserQuery } from "../../user/dataServicesUser.api";
+
+export default function UserRedirect() {
+ const navigate = useNavigate();
+
+ const isUserLoggedIn = useLegacySelector(
+ (state) => state.stateModel.user.logged
+ );
+
+ const {
+ data: user,
+ isLoading,
+ error,
+ } = useGetUserQuery(isUserLoggedIn ? undefined : skipToken);
+
+ useEffect(() => {
+ if (user?.username) {
+ navigate(
+ generatePath(ABSOLUTE_ROUTES.v2.users.show, {
+ username: user.username,
+ }),
+ { replace: true }
+ );
+ }
+ }, [navigate, user?.username]);
+
+ if (!isUserLoggedIn) {
+ return ;
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ return ;
+}
+
+function NotLoggedIn() {
+ const location = useLocation();
+
+ const loginUrl = Url.get(Url.pages.login.link, {
+ pathname: location.pathname,
+ });
+
+ return (
+
+
+
+ You must be logged in to view this page.
+
+ You can only view your own user page if you are logged in.
+
+
+
+ Log in
+
+
+
+
+
+
+ Return to the home page
+
+
+
+
+
+ );
+}
diff --git a/client/src/features/usersV2/show/UserShow.tsx b/client/src/features/usersV2/show/UserShow.tsx
new file mode 100644
index 0000000000..93d6a12aa0
--- /dev/null
+++ b/client/src/features/usersV2/show/UserShow.tsx
@@ -0,0 +1,177 @@
+/*!
+ * 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,
+ useNavigate,
+ useParams,
+} from "react-router-dom-v5-compat";
+import { Badge } from "reactstrap";
+
+import { Loader } from "../../../components/Loader";
+import ContainerWrap from "../../../components/container/ContainerWrap";
+import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants";
+import { useGetNamespacesByNamespaceSlugQuery } from "../../projectsV2/api/projectV2.enhanced-api";
+import ProjectV2ListDisplay from "../../projectsV2/list/ProjectV2ListDisplay";
+import UserNotFound from "../../projectsV2/notFound/UserNotFound";
+import {
+ useGetUserQuery,
+ useGetUsersByUserIdQuery,
+} from "../../user/dataServicesUser.api";
+import UserAvatar from "./UserAvatar";
+
+export default function UserShow() {
+ const { username } = useParams<{ username: string }>();
+
+ const navigate = useNavigate();
+
+ const {
+ data: namespace,
+ isLoading: isLoadingNamespace,
+ error: namespaceError,
+ } = useGetNamespacesByNamespaceSlugQuery(
+ username ? { namespaceSlug: username } : skipToken
+ );
+ const {
+ data: user,
+ isLoading: isLoadingUser,
+ error: userError,
+ } = useGetUsersByUserIdQuery(
+ namespace?.namespace_kind === "user" && namespace.created_by
+ ? { userId: namespace.created_by }
+ : skipToken
+ );
+
+ const isLoading = isLoadingNamespace || isLoadingUser;
+ const error = namespaceError ?? userError;
+
+ useEffect(() => {
+ if (username && namespace?.namespace_kind === "group") {
+ navigate(
+ generatePath(ABSOLUTE_ROUTES.v2.groups.show.root, { slug: username }),
+ {
+ replace: true,
+ }
+ );
+ }
+ }, [namespace?.namespace_kind, navigate, username]);
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error || !username || !namespace || !user) {
+ return ;
+ }
+
+ const name =
+ user.first_name && user.last_name
+ ? `${user.first_name} ${user.last_name}`
+ : user.first_name || user.last_name;
+
+ return (
+
+
+
+
+
+
+
+
{name ?? "Unknown user"}
+
+
+
+
+
+
{`@${username}`}
+
+
+
+
+ Personal Projects
+ {name ?? username} has no visible personal projects.
+ }
+ />
+
+
+ );
+}
+
+function UserBadge() {
+ return (
+
+ User
+
+ );
+}
+
+interface ItsYouBadgeProps {
+ username: string;
+}
+
+function ItsYouBadge({ username }: ItsYouBadgeProps) {
+ const { data: currentUser } = useGetUserQuery();
+
+ if (currentUser?.username === username) {
+ return (
+
+ It's you!
+
+ );
+ }
+
+ return null;
+}
diff --git a/client/src/features/userV2/userV2.types.ts b/client/src/features/usersV2/userV2.types.ts
similarity index 100%
rename from client/src/features/userV2/userV2.types.ts
rename to client/src/features/usersV2/userV2.types.ts
diff --git a/client/src/project/overview/ProjectOverview.present.jsx b/client/src/project/overview/ProjectOverview.present.jsx
index a7024725a5..2174bac418 100644
--- a/client/src/project/overview/ProjectOverview.present.jsx
+++ b/client/src/project/overview/ProjectOverview.present.jsx
@@ -47,7 +47,7 @@ import { CommitsView } from "../../components/commits/Commits";
import { Loader } from "../../components/Loader";
import { ExternalLink } from "../../components/ExternalLinks";
import { RefreshButton } from "../../components/buttons/Button";
-import { Pagination } from "../../components/Pagination";
+import Pagination from "../../components/Pagination";
class OverviewStats extends Component {
valueOrEmptyOrLoading(value, fetching, readableSize = true) {
diff --git a/client/src/routing/routes.constants.ts b/client/src/routing/routes.constants.ts
index 9b81726199..fa3b185b6c 100644
--- a/client/src/routing/routes.constants.ts
+++ b/client/src/routing/routes.constants.ts
@@ -20,10 +20,17 @@ export const ABSOLUTE_ROUTES = {
root: "/",
v2: {
root: "/v2",
+ user: "/v2/user",
+ users: {
+ show: "/v2/users/:username",
+ },
groups: {
root: "/v2/groups",
new: "/v2/groups/new",
- show: "/v2/groups/:slug",
+ show: {
+ root: "/v2/groups/:slug",
+ settings: "/v2/groups/:slug/settings",
+ },
},
projects: {
root: "/v2/projects",
@@ -57,10 +64,17 @@ export const RELATIVE_ROUTES = {
root: "/",
v2: {
root: "v2/*",
+ user: "user",
+ users: {
+ show: "users/:username",
+ },
groups: {
root: "groups/*",
new: "new",
- show: ":slug",
+ show: {
+ root: ":slug/*",
+ settings: "settings",
+ },
},
projects: {
root: "projects/*",
diff --git a/client/src/styleguide/ListsGuide.jsx b/client/src/styleguide/ListsGuide.jsx
index fd885b1f8c..4dcaaff837 100644
--- a/client/src/styleguide/ListsGuide.jsx
+++ b/client/src/styleguide/ListsGuide.jsx
@@ -21,7 +21,7 @@ import { Fragment } from "react";
import { Link } from "react-router-dom";
import { Card, CardBody, CardHeader } from "reactstrap";
import { TimeCaption } from "../components/TimeCaption";
-import { Pagination } from "../components/Pagination";
+import Pagination from "../components/Pagination";
function createDateGradient() {
const now = new Date();
diff --git a/client/src/utils/helpers/EnhancedState.ts b/client/src/utils/helpers/EnhancedState.ts
index 77bf5c8e9e..20e884f782 100644
--- a/client/src/utils/helpers/EnhancedState.ts
+++ b/client/src/utils/helpers/EnhancedState.ts
@@ -48,7 +48,7 @@ import { projectV2Api } from "../../features/projectsV2/api/projectV2.enhanced-a
import { projectV2NewSlice } from "../../features/projectsV2/new/projectV2New.slice";
import { recentUserActivityApi } from "../../features/recentUserActivity/RecentUserActivityApi";
import repositoriesApi from "../../features/repositories/repositories.api";
-import searchV2Api from "../../features/searchV2/searchV2.api";
+import { searchV2EmptyApi as searchV2Api } from "../../features/searchV2/api/searchV2-empty.api";
import { searchV2Slice } from "../../features/searchV2/searchV2.slice";
import secretsApi from "../../features/secrets/secrets.api";
import sessionsApi from "../../features/session/sessions.api";
diff --git a/client/src/utils/helpers/safeNewUrl.utils.ts b/client/src/utils/helpers/safeNewUrl.utils.ts
new file mode 100644
index 0000000000..99eaa85855
--- /dev/null
+++ b/client/src/utils/helpers/safeNewUrl.utils.ts
@@ -0,0 +1,32 @@
+/*!
+ * 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.
+ */
+
+/** Returns a new URL instance or null if `url` is not a valid URL string. */
+export function safeNewUrl(url: string | undefined | null): URL | null {
+ if (url == null) {
+ return null;
+ }
+ try {
+ return new URL(url);
+ } catch (error) {
+ if (error instanceof TypeError) {
+ return null;
+ }
+ throw error;
+ }
+}
diff --git a/tests/cypress/e2e/groupV2.spec.ts b/tests/cypress/e2e/groupV2.spec.ts
index 61abf20059..29b6fd6d28 100644
--- a/tests/cypress/e2e/groupV2.spec.ts
+++ b/tests/cypress/e2e/groupV2.spec.ts
@@ -25,7 +25,10 @@ describe("Add new v2 group", () => {
beforeEach(() => {
fixtures.config().versions().userTest().namespaces();
fixtures.projects().landingUserProjects();
- fixtures.createGroupV2().readGroupV2({ groupSlug: slug });
+ fixtures
+ .createGroupV2()
+ .readGroupV2({ groupSlug: slug })
+ .readGroupV2Namespace({ groupSlug: slug });
cy.visit("/v2/groups/new");
});
@@ -36,6 +39,7 @@ describe("Add new v2 group", () => {
cy.contains("Create").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");
});
@@ -67,7 +71,7 @@ describe("List v2 groups", () => {
});
it("shows groups", () => {
- fixtures.readGroupV2();
+ fixtures.readGroupV2().readGroupV2Namespace();
cy.contains("List Groups").should("be.visible");
cy.contains("test 2 group-v2").should("be.visible").click();
cy.wait("@readGroupV2");
@@ -77,30 +81,47 @@ describe("List v2 groups", () => {
describe("Edit v2 group", () => {
beforeEach(() => {
- fixtures.config().versions().userTest().namespaces();
+ fixtures
+ .config()
+ .versions()
+ .userTest()
+ .dataServicesUser({
+ response: { id: "0945f006-e117-49b7-8966-4c0842146313" },
+ })
+ .namespaces();
fixtures.projects().landingUserProjects().listGroupV2();
cy.visit("/v2/groups");
});
it("allows editing group metadata", () => {
- fixtures.readGroupV2().updateGroupV2();
+ fixtures
+ .readGroupV2()
+ .readGroupV2Namespace()
+ .listGroupV2Members()
+ .updateGroupV2();
cy.contains("List Groups").should("be.visible");
cy.contains("test 2 group-v2").should("be.visible").click();
cy.wait("@readGroupV2");
cy.contains("test 2 group-v2").should("be.visible");
- cy.contains("Edit Settings").should("be.visible").click();
- cy.get("button").contains("Metadata").should("be.visible").click();
+ cy.contains("Edit group 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");
- fixtures.readGroupV2({
- fixture: "groupV2/update-groupV2-metadata.json",
- groupSlug: "new-slug",
- name: "readPostUpdate",
- });
+ fixtures
+ .readGroupV2({
+ fixture: "groupV2/update-groupV2-metadata.json",
+ groupSlug: "new-slug",
+ name: "readPostUpdate",
+ })
+ .readGroupV2Namespace({
+ fixture: "groupV2/update-groupV2-namespace.json",
+ groupSlug: "new-slug",
+ name: "readNamespacePostUpdate",
+ });
cy.get("button").contains("Update").should("be.visible").click();
cy.wait("@updateGroupV2");
cy.wait("@readPostUpdate");
+ cy.wait("@readNamespacePostUpdate");
cy.contains("new name").should("be.visible");
});
@@ -126,14 +147,14 @@ describe("Edit v2 group", () => {
response: [],
})
.listGroupV2Members()
- .readGroupV2();
+ .readGroupV2()
+ .readGroupV2Namespace();
cy.contains("List Groups").should("be.visible");
cy.contains("test 2 group-v2").should("be.visible").click();
cy.wait("@readGroupV2");
cy.contains("test 2 group-v2").should("be.visible");
- cy.contains("Edit Settings").should("be.visible").click();
- cy.get("button").contains("Members").should("be.visible").click();
+ cy.contains("Edit group settings").should("be.visible").click();
cy.contains("user1@email.com").should("be.visible");
cy.contains("user3-uuid").should("be.visible");
fixtures
@@ -165,13 +186,16 @@ describe("Edit v2 group", () => {
});
it("deletes group", () => {
- fixtures.readGroupV2().deleteGroupV2();
+ fixtures
+ .readGroupV2()
+ .readGroupV2Namespace()
+ .listGroupV2Members()
+ .deleteGroupV2();
cy.contains("List Groups").should("be.visible");
cy.contains("test 2 group-v2").should("be.visible").click();
cy.wait("@readGroupV2");
cy.contains("test 2 group-v2").should("be.visible");
- cy.contains("Edit Settings").should("be.visible").click();
- cy.get("button").contains("Metadata").should("be.visible").click();
+ cy.contains("Edit group 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")
diff --git a/tests/cypress/e2e/projectV2.spec.ts b/tests/cypress/e2e/projectV2.spec.ts
index 25eb355202..bf7f579782 100644
--- a/tests/cypress/e2e/projectV2.spec.ts
+++ b/tests/cypress/e2e/projectV2.spec.ts
@@ -214,7 +214,7 @@ describe("Edit v2 project", () => {
.userTest()
.dataServicesUser({
response: {
- id: "user1-uuid",
+ id: "0945f006-e117-49b7-8966-4c0842146313",
email: "user1@email.com",
},
})
@@ -229,8 +229,10 @@ describe("Edit v2 project", () => {
it("changes project metadata", () => {
fixtures.readProjectV2().updateProjectV2().listNamespaceV2();
cy.contains("List Projects (V2)").should("be.visible");
- cy.contains("test 2 v2-project").should("be.visible");
- cy.getDataCy("list-card").first().find("a").click();
+ cy.getDataCy("project-card")
+ .contains("a", "test 2 v2-project")
+ .should("be.visible")
+ .click();
cy.wait("@readProjectV2");
cy.contains("test 2 v2-project").should("be.visible");
cy.getDataCy("project-settings-edit").should("be.visible").click();
@@ -252,8 +254,10 @@ describe("Edit v2 project", () => {
it("changes project namespace", () => {
fixtures.readProjectV2().updateProjectV2().listManyNamespaceV2();
cy.contains("List Projects (V2)").should("be.visible");
- cy.contains("test 2 v2-project").should("be.visible");
- cy.getDataCy("list-card").first().find("a").click();
+ cy.getDataCy("project-card")
+ .contains("a", "test 2 v2-project")
+ .should("be.visible")
+ .click();
cy.wait("@readProjectV2");
cy.contains("test 2 v2-project").should("be.visible");
cy.getDataCy("project-settings-edit").should("be.visible").click();
@@ -284,7 +288,10 @@ describe("Edit v2 project", () => {
fixture: "projectV2/update-projectV2-repositories.json",
});
cy.contains("List Projects (V2)").should("be.visible");
- cy.getDataCy("list-card").first().find("a").click();
+ cy.getDataCy("project-card")
+ .contains("a", "test 2 v2-project")
+ .should("be.visible")
+ .click();
cy.wait("@readProjectV2");
cy.contains("test 2 v2-project").should("be.visible");
cy.getDataCy("add-repository").click();
diff --git a/tests/cypress/e2e/projectV2setup.spec.ts b/tests/cypress/e2e/projectV2setup.spec.ts
index 12f01384e1..7118a777a0 100644
--- a/tests/cypress/e2e/projectV2setup.spec.ts
+++ b/tests/cypress/e2e/projectV2setup.spec.ts
@@ -27,7 +27,7 @@ describe("Navigate to project page", () => {
.namespaces()
.dataServicesUser({
response: {
- id: "user1-uuid",
+ id: "0945f006-e117-49b7-8966-4c0842146313",
email: "user1@email.com",
},
})
diff --git a/tests/cypress/fixtures/groupV2/list-groupV2-members.json b/tests/cypress/fixtures/groupV2/list-groupV2-members.json
index 04c8a7cd96..5bfc823fb4 100644
--- a/tests/cypress/fixtures/groupV2/list-groupV2-members.json
+++ b/tests/cypress/fixtures/groupV2/list-groupV2-members.json
@@ -1,16 +1,16 @@
[
{
- "id": "user1-uuid",
+ "id": "0945f006-e117-49b7-8966-4c0842146313",
"email": "user1@email.com",
"role": "owner"
},
{
"id": "user2-uuid",
"email": "user2@email.com",
- "role": "member"
+ "role": "editor"
},
{
"id": "user3-uuid",
- "role": "member"
+ "role": "viewer"
}
]
diff --git a/tests/cypress/fixtures/groupV2/read-groupV2-namespace.json b/tests/cypress/fixtures/groupV2/read-groupV2-namespace.json
new file mode 100644
index 0000000000..143dc84292
--- /dev/null
+++ b/tests/cypress/fixtures/groupV2/read-groupV2-namespace.json
@@ -0,0 +1,8 @@
+{
+ "id": "namespace-id",
+ "name": "test 2 group-v2",
+ "slug": "test-2-group-v2",
+ "creation_date": "2023-11-15T09:55:59Z",
+ "created_by": "user-id",
+ "namespace_kind": "group"
+}
diff --git a/tests/cypress/fixtures/groupV2/update-groupV2-namespace.json b/tests/cypress/fixtures/groupV2/update-groupV2-namespace.json
new file mode 100644
index 0000000000..1362eeab63
--- /dev/null
+++ b/tests/cypress/fixtures/groupV2/update-groupV2-namespace.json
@@ -0,0 +1,8 @@
+{
+ "id": "namespace-id",
+ "name": "new name",
+ "slug": "new-slug",
+ "creation_date": "2023-11-15T09:55:59Z",
+ "created_by": "user-id",
+ "namespace_kind": "group"
+}
diff --git a/tests/cypress/fixtures/projectV2/list-projectV2-members-many.json b/tests/cypress/fixtures/projectV2/list-projectV2-members-many.json
index 4419072343..e212b3d1bc 100644
--- a/tests/cypress/fixtures/projectV2/list-projectV2-members-many.json
+++ b/tests/cypress/fixtures/projectV2/list-projectV2-members-many.json
@@ -1,6 +1,6 @@
[
{
- "id": "user1-uuid",
+ "id": "0945f006-e117-49b7-8966-4c0842146313",
"first_name": "User",
"last_name": "One",
"email": "user1@email.com",
diff --git a/tests/cypress/fixtures/projectV2/list-projectV2-members.json b/tests/cypress/fixtures/projectV2/list-projectV2-members.json
index 90c6e919e0..66a3a89532 100644
--- a/tests/cypress/fixtures/projectV2/list-projectV2-members.json
+++ b/tests/cypress/fixtures/projectV2/list-projectV2-members.json
@@ -1,6 +1,6 @@
[
{
- "id": "user1-uuid",
+ "id": "0945f006-e117-49b7-8966-4c0842146313",
"email": "user1@email.com",
"role": "owner"
},
diff --git a/tests/cypress/fixtures/projectV2/update-projectV2-one-repository.json b/tests/cypress/fixtures/projectV2/update-projectV2-one-repository.json
index 390dcbc9d6..8225a903b2 100644
--- a/tests/cypress/fixtures/projectV2/update-projectV2-one-repository.json
+++ b/tests/cypress/fixtures/projectV2/update-projectV2-one-repository.json
@@ -2,6 +2,7 @@
"id": "THEPROJECTULID26CHARACTERS",
"name": "test 2 v2-project",
"slug": "test-2-v2-project",
+ "namespace": "user1-uuid",
"creation_date": "2023-11-15T09:55:59Z",
"created_by": "user1-uuid",
"repositories": ["https://gitlab.dev.renku.ch/url-repo.git"],
diff --git a/tests/cypress/support/renkulab-fixtures/namespaceV2.ts b/tests/cypress/support/renkulab-fixtures/namespaceV2.ts
index 676e464dfe..f768395238 100644
--- a/tests/cypress/support/renkulab-fixtures/namespaceV2.ts
+++ b/tests/cypress/support/renkulab-fixtures/namespaceV2.ts
@@ -151,9 +151,11 @@ export function NamespaceV2(Parent: T) {
listManyNamespaceV2(args?: ListManyNamespacesArgs) {
const { numberOfNamespaces = 50, name = "listNamespaceV2" } = args ?? {};
cy.intercept("GET", `/ui-server/api/data/namespaces?*`, (req) => {
- const page = +req.query["page"] ?? 1;
+ const pageRaw = +req.query["page"];
+ const page = isNaN(pageRaw) ? 1 : pageRaw;
// TODO the request parameter is per_page, the result is per-page. These should be the same.
- const perPage = +req.query["per_page"] ?? 20;
+ const perPageRaw = +req.query["per_page"];
+ const perPage = isNaN(pageRaw) ? 20 : perPageRaw;
const start = (page - 1) * perPage;
const totalPages = Math.ceil(numberOfNamespaces / perPage);
const numToGen = Math.min(
@@ -266,6 +268,21 @@ export function NamespaceV2(Parent: T) {
return this;
}
+ readGroupV2Namespace(args?: GroupV2Args) {
+ const {
+ fixture = "groupV2/read-groupV2-namespace.json",
+ name = "readGroupV2Namespace",
+ groupSlug = "test-2-group-v2",
+ } = args ?? {};
+ const response = { fixture };
+ cy.intercept(
+ "GET",
+ `/ui-server/api/data/namespaces/${groupSlug}`,
+ response
+ ).as(name);
+ return this;
+ }
+
updateGroupV2(args?: GroupV2Args) {
const {
fixture = "groupV2/update-groupV2-metadata.json",
diff --git a/tests/cypress/support/renkulab-fixtures/searchV2.ts b/tests/cypress/support/renkulab-fixtures/searchV2.ts
index 0eb16037e8..7ee46a0f58 100644
--- a/tests/cypress/support/renkulab-fixtures/searchV2.ts
+++ b/tests/cypress/support/renkulab-fixtures/searchV2.ts
@@ -78,7 +78,7 @@ export function SearchV2(Parent: T) {
};
}
- cy.intercept("GET", "/ui-server/api/search?*", (req) => {
+ cy.intercept("GET", "/ui-server/api/search/?*", (req) => {
let body = null;
const queryString = req.query["q"].toString();
if (queryString.includes("type:")) {