diff --git a/client/package-lock.json b/client/package-lock.json
index e0e413098c..04e3eb9540 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -9651,13 +9651,10 @@
}
},
"node_modules/dompurify": {
- "version": "3.3.2",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
- "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
+ "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
- "engines": {
- "node": ">=20"
- },
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -12893,9 +12890,9 @@
"license": "MIT"
},
"node_modules/isbot": {
- "version": "5.1.35",
- "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.35.tgz",
- "integrity": "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==",
+ "version": "5.1.36",
+ "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.36.tgz",
+ "integrity": "sha512-C/ZtXyJqDPZ7G7JPr06ApWyYoHjYexQbS6hPYD4WYCzpv2Qes6Z+CCEfTX4Owzf+1EJ933PoI2p+B9v7wpGZBQ==",
"license": "Unlicense",
"engines": {
"node": ">=18"
@@ -18661,13 +18658,13 @@
"license": "MIT"
},
"node_modules/sass": {
- "version": "1.97.3",
- "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz",
- "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==",
+ "version": "1.98.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz",
+ "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==",
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
- "immutable": "^5.0.2",
+ "immutable": "^5.1.5",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
diff --git a/client/src/features/groupsV2/LazyGroupContainer.tsx b/client/src/features/groupsV2/LazyGroupContainer.tsx
deleted file mode 100644
index 25c99683cd..0000000000
--- a/client/src/features/groupsV2/LazyGroupContainer.tsx
+++ /dev/null
@@ -1,31 +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 { lazy, Suspense } from "react";
-
-import PageLoader from "../../components/PageLoader";
-
-const GroupV2Container = lazy(() => import("./show/GroupPageContainer"));
-
-export default function LazyGroupContainer() {
- return (
- }>
-
-
- );
-}
diff --git a/client/src/features/groupsV2/show/GroupPageContainer.tsx b/client/src/features/groupsV2/show/GroupPageLayout.tsx
similarity index 52%
rename from client/src/features/groupsV2/show/GroupPageContainer.tsx
rename to client/src/features/groupsV2/show/GroupPageLayout.tsx
index 4150be99fc..dbacf038a3 100644
--- a/client/src/features/groupsV2/show/GroupPageContainer.tsx
+++ b/client/src/features/groupsV2/show/GroupPageLayout.tsx
@@ -16,82 +16,29 @@
* limitations under the License.
*/
-import { skipToken } from "@reduxjs/toolkit/query";
import cx from "classnames";
-import { useEffect } from "react";
-import { generatePath, Outlet, useNavigate, useParams } from "react-router";
+import { ReactNode } from "react";
+import { generatePath } from "react-router";
import { Col, Row } from "reactstrap";
-import { NamespaceContextType } from "~/features/searchV2/hooks/useNamespaceContext.hook";
+import ProjectV2New from "~/features/projectsV2/new/ProjectV2New";
import ContainerWrap from "../../../components/container/ContainerWrap";
import { EntityWatermark } from "../../../components/entityWatermark/EntityWatermark";
-import { Loader } from "../../../components/Loader";
import PageNav, { PageNavOptions } from "../../../components/PageNav";
-import LazyNotFound from "../../../not-found/LazyNotFound";
import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants";
-import { GroupResponse } from "../../projectsV2/api/namespace.api";
-import {
- useGetGroupsByGroupSlugQuery,
- useGetNamespacesByNamespaceSlugQuery,
-} from "../../projectsV2/api/projectV2.enhanced-api";
-import GroupNotFound from "../../projectsV2/notFound/GroupNotFound";
+import type { GroupResponse } from "../../projectsV2/api/namespace.api";
import UserAvatar, { AvatarTypeWrap } from "../../usersV2/show/UserAvatar";
+import GroupNew from "../new/GroupNew";
-export default function GroupPageContainer() {
- const { slug } = useParams<{ slug: string }>();
-
- const navigate = useNavigate();
-
- const {
- currentData: 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.root, { username: slug }),
- {
- replace: true,
- }
- );
- } else if (
- slug &&
- namespace?.namespace_kind === "group" &&
- namespace.slug !== slug
- ) {
- navigate(
- generatePath(ABSOLUTE_ROUTES.v2.groups.show.root, {
- slug: namespace.slug,
- }),
- { replace: true }
- );
- }
- }, [namespace?.namespace_kind, namespace?.slug, navigate, slug]);
-
- if (!slug) {
- return ;
- }
-
- if (isLoading) {
- return ;
- }
-
- if (error || !namespace || !group) {
- return ;
- }
+interface GroupPageLayoutProps {
+ group: GroupResponse;
+ children?: ReactNode;
+}
+export default function GroupPageLayout({
+ group,
+ children,
+}: GroupPageLayoutProps) {
const options: PageNavOptions = {
overviewUrl: generatePath(ABSOLUTE_ROUTES.v2.groups.show.root, {
slug: group.slug,
@@ -105,10 +52,13 @@ export default function GroupPageContainer() {
};
return (
+
+
+
-
+
@@ -116,17 +66,7 @@ export default function GroupPageContainer() {
-
-
-
+ {children}
diff --git a/client/src/features/rootV2/RootV2.tsx b/client/src/features/rootV2/RootV2.tsx
index 133f737cda..6f8308e144 100644
--- a/client/src/features/rootV2/RootV2.tsx
+++ b/client/src/features/rootV2/RootV2.tsx
@@ -27,8 +27,6 @@ import {
useNavigate,
} from "react-router";
-import LazyUserContainer from "~/features/usersV2/LazyUserContainer";
-import LazyUserV2Search from "~/features/usersV2/LazyUserV2Search";
import ContainerWrap from "../../components/container/ContainerWrap";
import LazyNotFound from "../../not-found/LazyNotFound";
import {
@@ -40,10 +38,6 @@ import useAppSelector from "../../utils/customHooks/useAppSelector.hook";
import { setFlag } from "../../utils/feature-flags/featureFlags.slice";
import LazyConnectedServicesPage from "../connectedServices/LazyConnectedServicesPage";
import LazyDashboardV2 from "../dashboardV2/LazyDashboardV2";
-import LazyGroupContainer from "../groupsV2/LazyGroupContainer";
-import LazyGroupV2Overview from "../groupsV2/LazyGroupV2Overview";
-import LazyGroupV2Search from "../groupsV2/LazyGroupV2Search";
-import LazyGroupV2Settings from "../groupsV2/LazyGroupV2Settings";
import GroupNew from "../groupsV2/new/GroupNew";
import LazyProjectV2ShowByProjectId from "../projectsV2/LazyProjectV2ShowByProjectId";
import ProjectV2New from "../projectsV2/new/ProjectV2New";
@@ -52,7 +46,6 @@ import LazySecretsV2 from "../secretsV2/LazySecretsV2";
import LazySessionStartPage from "../sessionsV2/LazySessionStartPage";
import LazyShowSessionPage from "../sessionsV2/LazyShowSessionPage";
import LazyUserRedirect from "../usersV2/LazyUserRedirect";
-import LazyUserShow from "../usersV2/LazyUserShow";
function BetaV2Redirect() {
const navigate = useNavigate();
@@ -134,14 +127,6 @@ export default function RootV2() {
path={RELATIVE_ROUTES.v2.user}
element={}
/>
- }
- />
- }
- />
}
@@ -184,35 +169,6 @@ export default function RootV2() {
);
}
-function GroupsV2Routes() {
- return (
-
- } />
-
- }>
- } />
- }
- />
- }
- />
-
-
-
-
-
- }
- />
-
- );
-}
-
function RedirectToSearch({ entityType }: { entityType: string }) {
const navigate = useNavigate();
useEffect(() => {
@@ -284,28 +240,3 @@ function ProjectSessionsRoutes() {
);
}
-
-function UserV2Routes() {
- return (
-
- } />
-
- }>
- } />
- }
- />
-
-
-
-
-
- }
- />
-
- );
-}
diff --git a/client/src/features/usersV2/LazyUserContainer.tsx b/client/src/features/usersV2/LazyUserContainer.tsx
deleted file mode 100644
index 37d9ff337c..0000000000
--- a/client/src/features/usersV2/LazyUserContainer.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-/*!
- * Copyright 2026 - 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 { lazy, Suspense } from "react";
-
-import PageLoader from "../../components/PageLoader";
-
-const UserV2Container = lazy(() => import("./show/UserPageContainer"));
-
-export default function LazyUserContainer() {
- return (
- }>
-
-
- );
-}
diff --git a/client/src/features/usersV2/show/UserPageContainer.tsx b/client/src/features/usersV2/show/UserPageLayout.tsx
similarity index 55%
rename from client/src/features/usersV2/show/UserPageContainer.tsx
rename to client/src/features/usersV2/show/UserPageLayout.tsx
index 77aafc60e9..0c14ee4648 100644
--- a/client/src/features/usersV2/show/UserPageContainer.tsx
+++ b/client/src/features/usersV2/show/UserPageLayout.tsx
@@ -16,99 +16,53 @@
* limitations under the License.
*/
-import { skipToken } from "@reduxjs/toolkit/query";
import cx from "classnames";
-import { useEffect } from "react";
-import { generatePath, Outlet, useNavigate, useParams } from "react-router";
+import { ReactNode } from "react";
+import { generatePath } from "react-router";
import { Badge, Col, Row } from "reactstrap";
import { EntityWatermark } from "~/components/entityWatermark/EntityWatermark";
-import { Loader } from "~/components/Loader";
-import UserNotFound from "~/features/projectsV2/notFound/UserNotFound";
-import { NamespaceContextType } from "~/features/searchV2/hooks/useNamespaceContext.hook";
+import GroupNew from "~/features/groupsV2/new/GroupNew";
+import ProjectV2New from "~/features/projectsV2/new/ProjectV2New";
import {
- useGetUserByIdQuery,
useGetUserQueryState,
- UserWithId,
+ type UserWithId,
} from "~/features/usersV2/api/users.api";
import { ABSOLUTE_ROUTES } from "~/routing/routes.constants";
import ContainerWrap from "../../../components/container/ContainerWrap";
import PageNav, { PageNavOptions } from "../../../components/PageNav";
-import { useGetNamespacesByNamespaceSlugQuery } from "../../projectsV2/api/projectV2.enhanced-api";
-import UserAvatar, { AvatarTypeWrap } from "../../usersV2/show/UserAvatar";
+import UserAvatar, { AvatarTypeWrap } from "./UserAvatar";
-export default function UserPageContainer() {
- const { username } = useParams<{ username: string }>();
- const navigate = useNavigate();
-
- const {
- currentData: namespace,
- isLoading: isLoadingNamespace,
- error: namespaceError,
- } = useGetNamespacesByNamespaceSlugQuery(
- username ? { namespaceSlug: username } : skipToken
- );
-
- const {
- data: user,
- isLoading: isLoadingUser,
- error: userError,
- } = useGetUserByIdQuery(
- namespace?.namespace_kind === "user" && namespace.created_by
- ? { userId: namespace.created_by }
- : skipToken
- );
+interface UserPageLayoutProps {
+ user: UserWithId;
+ children?: ReactNode;
+}
+export default function UserPageLayout({
+ user,
+ children,
+}: UserPageLayoutProps) {
const name =
user?.first_name && user?.last_name
? `${user.first_name} ${user.last_name}`
: user?.first_name || user?.last_name;
-
- 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 }
- );
- } else if (
- username &&
- namespace?.namespace_kind === "user" &&
- namespace.slug !== username
- ) {
- navigate(
- generatePath(ABSOLUTE_ROUTES.v2.users.show.root, {
- username: namespace.slug,
- }),
- { replace: true }
- );
- }
- }, [namespace?.namespace_kind, namespace?.slug, navigate, username]);
-
- if (isLoading) {
- return ;
- }
-
- if (error || !username || !namespace || !user) {
- return ;
- }
-
const options: PageNavOptions = {
overviewUrl: generatePath(ABSOLUTE_ROUTES.v2.users.show.root, {
- username: namespace.slug,
+ username: user.username,
}),
searchUrl: generatePath(ABSOLUTE_ROUTES.v2.users.show.search, {
- username: namespace.slug,
+ username: user.username,
}),
};
return (
+
+
+
-
+
@@ -116,17 +70,7 @@ export default function UserPageContainer() {
-
-
-
+ {children}
diff --git a/client/src/root.tsx b/client/src/root.tsx
index 134ea11132..d174b45ce9 100644
--- a/client/src/root.tsx
+++ b/client/src/root.tsx
@@ -149,8 +149,8 @@ export function Layout({ children }: { children: ReactNode }) {
{children}
-
+
diff --git a/client/src/routes.ts b/client/src/routes.ts
index 5266d31068..4276f13023 100644
--- a/client/src/routes.ts
+++ b/client/src/routes.ts
@@ -36,6 +36,30 @@ export default [
),
]),
]),
+ // Group pages
+ ...prefix(RELATIVE_ROUTES.v2.groups.root, [
+ index("routes/groups/searchRedirect.tsx"),
+ route(RELATIVE_ROUTES.v2.groups.show.root, "routes/groups/root.tsx", [
+ index("routes/groups/index.tsx"),
+ route(RELATIVE_ROUTES.v2.groups.show.search, "routes/groups/search.tsx"),
+ route(
+ RELATIVE_ROUTES.v2.groups.show.settings,
+ "routes/groups/settings.tsx"
+ ),
+ ]),
+ // Not found page for /g/*
+ route("*", "routes/groups/catchall.tsx"),
+ ]),
+ // User pages
+ ...prefix(RELATIVE_ROUTES.v2.users.root, [
+ index("routes/users/searchRedirect.tsx"),
+ route(RELATIVE_ROUTES.v2.users.show.root, "routes/users/root.tsx", [
+ index("routes/users/index.tsx"),
+ route(RELATIVE_ROUTES.v2.users.show.search, "routes/users/search.tsx"),
+ ]),
+ // Not found page for /u/*
+ route("*", "routes/users/catchall.tsx"),
+ ]),
// * matches all URLs, the ? makes it optional so it will match / as well
route("*?", "routes/catchall.tsx"),
] satisfies RouteConfig;
diff --git a/client/src/routes/groups/catchall.tsx b/client/src/routes/groups/catchall.tsx
new file mode 100644
index 0000000000..890b557120
--- /dev/null
+++ b/client/src/routes/groups/catchall.tsx
@@ -0,0 +1,18 @@
+import { data, type MetaDescriptor } from "react-router";
+
+import NotFound from "~/not-found/NotFound";
+import { makeMeta, makeMetaTitle } from "~/utils/meta/meta";
+
+const title = makeMetaTitle(["Page Not Found", "Renku"]);
+const meta_ = makeMeta({ title });
+
+export function meta(): MetaDescriptor[] {
+ return meta_;
+}
+export async function loader() {
+ return data(undefined, { status: 404 });
+}
+
+export default function GroupCatchallPage() {
+ return ;
+}
diff --git a/client/src/routes/groups/index.tsx b/client/src/routes/groups/index.tsx
new file mode 100644
index 0000000000..51a6422985
--- /dev/null
+++ b/client/src/routes/groups/index.tsx
@@ -0,0 +1,5 @@
+import LazyGroupV2Overview from "~/features/groupsV2/LazyGroupV2Overview";
+
+export default function GroupOverviewPage() {
+ return ;
+}
diff --git a/client/src/routes/groups/root.tsx b/client/src/routes/groups/root.tsx
new file mode 100644
index 0000000000..f57a8f293e
--- /dev/null
+++ b/client/src/routes/groups/root.tsx
@@ -0,0 +1,277 @@
+import { skipToken } from "@reduxjs/toolkit/query";
+import { useEffect, useState } from "react";
+import {
+ data,
+ generatePath,
+ matchPath,
+ Outlet,
+ useNavigate,
+ type MetaDescriptor,
+} from "react-router";
+
+import { Loader } from "~/components/Loader";
+import GroupPageLayout from "~/features/groupsV2/show/GroupPageLayout";
+import {
+ projectV2Api,
+ useGetGroupsByGroupSlugQuery,
+ useGetNamespacesByNamespaceSlugQuery,
+} from "~/features/projectsV2/api/projectV2.enhanced-api";
+import GroupNotFound from "~/features/projectsV2/notFound/GroupNotFound";
+import type { NamespaceContextType } from "~/features/searchV2/hooks/useNamespaceContext.hook";
+import { ABSOLUTE_ROUTES } from "~/routing/routes.constants";
+import { store } from "~/store/store";
+import { storeContext } from "~/store/store.utils.server";
+import renkuGroupSocialCard from "~/styles/assets/renkuGroupSocialCard.png";
+import useAppDispatch from "~/utils/customHooks/useAppDispatch.hook";
+import { makeMeta, makeMetaTitle } from "~/utils/meta/meta";
+import type { Route } from "./+types/root";
+
+export async function loader({ context, params }: Route.LoaderArgs) {
+ const store = context.get(storeContext);
+ const clientSideFetch = store == null || process.env.CYPRESS === "1";
+ if (clientSideFetch) {
+ //? In testing, we load the group data client-side
+ return data({
+ clientSideFetch,
+ namespace: undefined,
+ group: undefined,
+ error: undefined,
+ });
+ }
+
+ //? Otherwise, we load the group data to generate meta tags
+ const { slug } = params;
+ const namespaceEndpoint = projectV2Api.endpoints.getNamespacesByNamespaceSlug;
+ const namespaceApiArgs = { namespaceSlug: slug };
+ store.dispatch(namespaceEndpoint.initiate(namespaceApiArgs));
+ const groupEndpoint = projectV2Api.endpoints.getGroupsByGroupSlug;
+ const groupApiArgs = {
+ groupSlug: slug,
+ };
+ store.dispatch(groupEndpoint.initiate(groupApiArgs));
+ await Promise.all(store.dispatch(projectV2Api.util.getRunningQueriesThunk()));
+ const namespaceSelector = namespaceEndpoint.select(namespaceApiArgs);
+ const { data: namespace, error: namespaceError } = namespaceSelector(
+ store.getState()
+ );
+ const groupSelector = groupEndpoint.select(groupApiArgs);
+ const { data: group, error: groupError } = groupSelector(store.getState());
+ store.dispatch(projectV2Api.util.resetApiState());
+ const error = namespaceError ?? groupError;
+ if (error && "status" in error && typeof error.status === "number") {
+ return data({ clientSideFetch, namespace, group, error }, error.status);
+ }
+ // TODO: redirect to the canonical page, see below the effects which navigate()
+ return data({ clientSideFetch, namespace, group, error });
+}
+
+export async function clientLoader({ params }: Route.ClientLoaderArgs) {
+ //? We fetch (or use cached data) on the client-side to allow the meta() function to work as intended
+ const { slug } = params;
+ const namespaceEndpoint = projectV2Api.endpoints.getNamespacesByNamespaceSlug;
+ const namespaceApiArgs = { namespaceSlug: slug };
+ const namespacePromise = store.dispatch(
+ namespaceEndpoint.initiate(namespaceApiArgs)
+ );
+ const groupEndpoint = projectV2Api.endpoints.getGroupsByGroupSlug;
+ const groupApiArgs = {
+ groupSlug: slug,
+ };
+ const groupPromise = store.dispatch(groupEndpoint.initiate(groupApiArgs));
+ await Promise.all(store.dispatch(projectV2Api.util.getRunningQueriesThunk()));
+ const namespaceSelector = namespaceEndpoint.select(namespaceApiArgs);
+ const { data: namespace, error: namespaceError } = namespaceSelector(
+ store.getState()
+ );
+ const groupSelector = groupEndpoint.select(groupApiArgs);
+ const { data: group, error: groupError } = groupSelector(store.getState());
+ //? Unsubscribe to let the cache expire when navigating to other pages
+ namespacePromise.unsubscribe();
+ groupPromise.unsubscribe();
+ const error = namespaceError ?? groupError;
+ return {
+ clientSideFetch: true,
+ namespace,
+ group,
+ error,
+ };
+}
+
+const metaNotFound = makeMeta({
+ title: makeMetaTitle(["Group Not Found", "Renku"]),
+});
+const metaError = makeMeta({
+ title: makeMetaTitle(["Error", "Renku"]),
+});
+
+export function meta({
+ loaderData,
+ location,
+}: Route.MetaArgs): MetaDescriptor[] {
+ const { group, error } = loaderData;
+ if (error && "status" in error && error.status == 404) {
+ return metaNotFound;
+ }
+ if (error) {
+ return metaError;
+ }
+ if (group == null) {
+ return makeMeta({
+ title: makeMetaTitle(["Group Page", "Renku"]),
+ image: renkuGroupSocialCard,
+ });
+ }
+ const matchSearch = matchPath(
+ ABSOLUTE_ROUTES.v2.groups.show.search,
+ location.pathname
+ );
+ const matchSettings = matchPath(
+ ABSOLUTE_ROUTES.v2.groups.show.settings,
+ location.pathname
+ );
+ const title = makeMetaTitle([
+ ...(matchSearch ? ["Search"] : matchSettings ? ["Settings"] : []),
+ group.name,
+ "Group",
+ "Renku",
+ ]);
+ return makeMeta({
+ title,
+ description: group.description || undefined,
+ image: renkuGroupSocialCard,
+ });
+}
+
+export default function GroupPagesRoot({
+ loaderData,
+ params,
+}: Route.ComponentProps) {
+ const { slug } = params;
+
+ const navigate = useNavigate();
+
+ const dispatch = useAppDispatch();
+
+ const [isNamespaceCacheReady, setIsNamespaceCacheReady] =
+ useState(false);
+ const [isGroupCacheReady, setIsGroupCacheReady] = useState(false);
+ const isCacheReady = isNamespaceCacheReady && isGroupCacheReady;
+
+ //? Inject the server-side data into the RTK Query cache
+ useEffect(() => {
+ if (loaderData.namespace != null) {
+ let ignore: boolean = false;
+ const namespaceApiArgs = { namespaceSlug: loaderData.namespace.slug };
+ const namespacePromise = dispatch(
+ projectV2Api.util.upsertQueryData(
+ "getNamespacesByNamespaceSlug",
+ namespaceApiArgs,
+ loaderData.namespace
+ )
+ );
+ namespacePromise.then(() => {
+ if (!ignore) {
+ setIsNamespaceCacheReady(true);
+ }
+ });
+ return () => {
+ ignore = true;
+ };
+ }
+ }, [dispatch, loaderData.namespace]);
+ useEffect(() => {
+ if (loaderData.group != null) {
+ let ignore: boolean = false;
+ const groupApiArgs = { groupSlug: loaderData.group.slug };
+ const groupPromise = dispatch(
+ projectV2Api.util.upsertQueryData(
+ "getGroupsByGroupSlug",
+ groupApiArgs,
+ loaderData.group
+ )
+ );
+ groupPromise.then(() => {
+ if (!ignore) {
+ setIsGroupCacheReady(true);
+ }
+ });
+ return () => {
+ ignore = true;
+ };
+ }
+ }, [dispatch, loaderData.group]);
+
+ //? Subscribe this component to the namespace and group queries:
+ //? * if the data is loaded client-side
+ //? * once the cache is ready (will use cache data)
+ const {
+ currentData: namespace,
+ isLoading: isLoadingNamespace,
+ error: namespaceError,
+ } = useGetNamespacesByNamespaceSlugQuery(
+ loaderData.clientSideFetch || isNamespaceCacheReady
+ ? { namespaceSlug: slug }
+ : skipToken
+ );
+ const {
+ currentData: group,
+ isLoading: isLoadingGroup,
+ error: groupError,
+ } = useGetGroupsByGroupSlugQuery(
+ loaderData.clientSideFetch || isGroupCacheReady
+ ? { 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.root, { username: slug }),
+ {
+ replace: true,
+ }
+ );
+ } else if (
+ slug &&
+ namespace?.namespace_kind === "group" &&
+ namespace.slug !== slug
+ ) {
+ navigate(
+ generatePath(ABSOLUTE_ROUTES.v2.groups.show.root, {
+ slug: namespace.slug,
+ }),
+ { replace: true }
+ );
+ }
+ }, [namespace?.namespace_kind, namespace?.slug, navigate, slug]);
+
+ if (
+ isLoading ||
+ (!loaderData.clientSideFetch &&
+ loaderData.namespace != null &&
+ loaderData.group != null &&
+ !isCacheReady)
+ ) {
+ return ;
+ }
+
+ if (error || namespace == null || group == null) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/client/src/routes/groups/search.tsx b/client/src/routes/groups/search.tsx
new file mode 100644
index 0000000000..1b489c21ce
--- /dev/null
+++ b/client/src/routes/groups/search.tsx
@@ -0,0 +1,5 @@
+import LazyGroupV2Search from "~/features/groupsV2/LazyGroupV2Search";
+
+export default function GroupSearchPage() {
+ return ;
+}
diff --git a/client/src/routes/groups/searchRedirect.tsx b/client/src/routes/groups/searchRedirect.tsx
new file mode 100644
index 0000000000..760556695b
--- /dev/null
+++ b/client/src/routes/groups/searchRedirect.tsx
@@ -0,0 +1,13 @@
+import { redirect } from "react-router";
+
+import { ABSOLUTE_ROUTES } from "~/routing/routes.constants";
+
+const GROUP_SEARCH = `${ABSOLUTE_ROUTES.v2.search}?type=Group`;
+
+export async function loader() {
+ throw redirect(GROUP_SEARCH);
+}
+
+export async function clientLoader() {
+ throw redirect(GROUP_SEARCH);
+}
diff --git a/client/src/routes/groups/settings.tsx b/client/src/routes/groups/settings.tsx
new file mode 100644
index 0000000000..c5910b3d54
--- /dev/null
+++ b/client/src/routes/groups/settings.tsx
@@ -0,0 +1,5 @@
+import LazyGroupV2Settings from "~/features/groupsV2/LazyGroupV2Settings";
+
+export default function GroupOverviewPage() {
+ return ;
+}
diff --git a/client/src/routes/projects/root.tsx b/client/src/routes/projects/root.tsx
index 9a0cacbdd4..c32e5ebed5 100644
--- a/client/src/routes/projects/root.tsx
+++ b/client/src/routes/projects/root.tsx
@@ -22,6 +22,7 @@ import ProjectNotFound from "~/features/projectsV2/notFound/ProjectNotFound";
import { ABSOLUTE_ROUTES } from "~/routing/routes.constants";
import { store } from "~/store/store";
import { storeContext } from "~/store/store.utils.server";
+import renkuProjectSocialCard from "~/styles/assets/renkuProjectSocialCard.png";
import useAppDispatch from "~/utils/customHooks/useAppDispatch.hook";
import { makeMeta, makeMetaTitle } from "~/utils/meta/meta";
import type { Route } from "./+types/root";
@@ -55,6 +56,7 @@ export async function loader({ context, params }: Route.LoaderArgs) {
if (error && "status" in error && typeof error.status === "number") {
return data({ clientSideFetch, project, error }, error.status);
}
+ // TODO: redirect to the canonical page, see below the effects which navigate()
return data({ clientSideFetch, project, error });
}
@@ -100,7 +102,10 @@ export function meta({
return metaError;
}
if (project == null) {
- return makeMeta({ title: makeMetaTitle(["Project Page", "Renku"]) });
+ return makeMeta({
+ title: makeMetaTitle(["Project Page", "Renku"]),
+ image: renkuProjectSocialCard,
+ });
}
const matchSettings = matchPath(
@@ -116,6 +121,7 @@ export function meta({
return makeMeta({
title,
description: project.description || undefined,
+ image: renkuProjectSocialCard,
});
}
@@ -212,7 +218,7 @@ export default function ProjectPagesRoot({
}
if (error || project == null) {
- return ;
+ return ;
}
return (
diff --git a/client/src/routes/users/catchall.tsx b/client/src/routes/users/catchall.tsx
new file mode 100644
index 0000000000..d1a602a044
--- /dev/null
+++ b/client/src/routes/users/catchall.tsx
@@ -0,0 +1,18 @@
+import { data, type MetaDescriptor } from "react-router";
+
+import NotFound from "~/not-found/NotFound";
+import { makeMeta, makeMetaTitle } from "~/utils/meta/meta";
+
+const title = makeMetaTitle(["Page Not Found", "Renku"]);
+const meta_ = makeMeta({ title });
+
+export function meta(): MetaDescriptor[] {
+ return meta_;
+}
+export async function loader() {
+ return data(undefined, { status: 404 });
+}
+
+export default function UserCatchallPage() {
+ return ;
+}
diff --git a/client/src/routes/users/index.tsx b/client/src/routes/users/index.tsx
new file mode 100644
index 0000000000..09c2e710e4
--- /dev/null
+++ b/client/src/routes/users/index.tsx
@@ -0,0 +1,5 @@
+import LazyUserShow from "~/features/usersV2/LazyUserShow";
+
+export default function UserOverviewPage() {
+ return ;
+}
diff --git a/client/src/routes/users/root.tsx b/client/src/routes/users/root.tsx
new file mode 100644
index 0000000000..b30e1ed25d
--- /dev/null
+++ b/client/src/routes/users/root.tsx
@@ -0,0 +1,322 @@
+import { skipToken } from "@reduxjs/toolkit/query";
+import { useEffect, useState } from "react";
+import {
+ data,
+ generatePath,
+ matchPath,
+ Outlet,
+ useNavigate,
+ type MetaDescriptor,
+} from "react-router";
+
+import { Loader } from "~/components/Loader";
+import {
+ projectV2Api,
+ useGetNamespacesByNamespaceSlugQuery,
+} from "~/features/projectsV2/api/projectV2.enhanced-api";
+import UserNotFound from "~/features/projectsV2/notFound/UserNotFound";
+import type { NamespaceContextType } from "~/features/searchV2/hooks/useNamespaceContext.hook";
+import {
+ useGetUserByIdQuery,
+ usersApi,
+} from "~/features/usersV2/api/users.api";
+import UserPageLayout from "~/features/usersV2/show/UserPageLayout";
+import { ABSOLUTE_ROUTES } from "~/routing/routes.constants";
+import { store, type RootState } from "~/store/store";
+import { storeContext } from "~/store/store.utils.server";
+import renkuUserSocialCard from "~/styles/assets/renkuUserSocialCard.png";
+import useAppDispatch from "~/utils/customHooks/useAppDispatch.hook";
+import { makeMeta, makeMetaTitle } from "~/utils/meta/meta";
+import type { Route } from "./+types/root";
+
+export async function loader({ context, params }: Route.LoaderArgs) {
+ const store = context.get(storeContext);
+ const clientSideFetch = store == null || process.env.CYPRESS === "1";
+ if (clientSideFetch) {
+ //? In testing, we load the user data client-side
+ return data({
+ clientSideFetch,
+ namespace: undefined,
+ user: undefined,
+ error: undefined,
+ });
+ }
+
+ //? Otherwise, we load the group data to generate meta tags
+ const { username } = params;
+ const namespaceEndpoint = projectV2Api.endpoints.getNamespacesByNamespaceSlug;
+ const namespaceApiArgs = { namespaceSlug: username };
+ await store.dispatch(namespaceEndpoint.initiate(namespaceApiArgs));
+ const namespaceSelector = namespaceEndpoint.select(namespaceApiArgs);
+ const { data: namespace, error: namespaceError } = namespaceSelector(
+ store.getState()
+ );
+ // Early return if the namespace is not a user
+ if (namespace?.namespace_kind !== "user" || !namespace.created_by) {
+ await Promise.all(
+ store.dispatch(projectV2Api.util.getRunningQueriesThunk())
+ );
+ store.dispatch(projectV2Api.util.resetApiState());
+ if (
+ namespaceError &&
+ "status" in namespaceError &&
+ typeof namespaceError.status === "number"
+ ) {
+ return data(
+ { clientSideFetch, namespace, user: undefined, error: namespaceError },
+ namespaceError.status
+ );
+ }
+ return data({
+ clientSideFetch,
+ namespace,
+ user: undefined,
+ error: namespaceError,
+ });
+ }
+ const userEndpoint = usersApi.endpoints.getUsersByUserId;
+ const userApiArgs = { userId: namespace.created_by };
+ store.dispatch(userEndpoint.initiate(userApiArgs));
+ await Promise.all(store.dispatch(projectV2Api.util.getRunningQueriesThunk()));
+ await Promise.all(store.dispatch(usersApi.util.getRunningQueriesThunk()));
+ const userSelector = userEndpoint.select(userApiArgs);
+ const { data: user, error: userError } = userSelector(store.getState());
+ store.dispatch(projectV2Api.util.resetApiState());
+ store.dispatch(usersApi.util.resetApiState());
+ const error = namespaceError ?? userError;
+ if (error && "status" in error && typeof error.status === "number") {
+ return data({ clientSideFetch, namespace, user, error }, error.status);
+ }
+ // TODO: redirect to the canonical page, see below the effects which navigate()
+ return data({ clientSideFetch, namespace, user, error });
+}
+
+export async function clientLoader({ params }: Route.ClientLoaderArgs) {
+ //? We fetch (or use cached data) on the client-side to allow the meta() function to work as intended
+ const { username } = params;
+ const namespaceEndpoint = projectV2Api.endpoints.getNamespacesByNamespaceSlug;
+ const namespaceApiArgs = { namespaceSlug: username };
+ const namespacePromise = store.dispatch(
+ namespaceEndpoint.initiate(namespaceApiArgs)
+ );
+ await namespacePromise;
+ const namespaceSelector = namespaceEndpoint.select(namespaceApiArgs);
+ const { data: namespace, error: namespaceError } = namespaceSelector(
+ store.getState()
+ );
+ // Early return if the namespace is not a user
+ if (namespace?.namespace_kind !== "user" || !namespace.created_by) {
+ await Promise.all(
+ store.dispatch(projectV2Api.util.getRunningQueriesThunk())
+ );
+ //? Unsubscribe to let the cache expire when navigating to other pages
+ namespacePromise.unsubscribe();
+ return {
+ clientSideFetch: true,
+ namespace,
+ user: undefined,
+ error: namespaceError,
+ };
+ }
+
+ const userEndpoint = usersApi.endpoints.getUsersByUserId;
+ const userApiArgs = { userId: namespace.created_by };
+ const userPromise = store.dispatch(userEndpoint.initiate(userApiArgs));
+ await Promise.all(store.dispatch(projectV2Api.util.getRunningQueriesThunk()));
+ await Promise.all(store.dispatch(usersApi.util.getRunningQueriesThunk()));
+ const userSelector = userEndpoint.select(userApiArgs);
+ const { data: user, error: userError } = userSelector(
+ store.getState() as RootState & {
+ [usersApi.reducerPath]: ReturnType;
+ }
+ );
+ //? Unsubscribe to let the cache expire when navigating to other pages
+ namespacePromise.unsubscribe();
+ userPromise.unsubscribe();
+ const error = namespaceError ?? userError;
+ return {
+ clientSideFetch: true,
+ namespace,
+ user,
+ error,
+ };
+}
+
+const metaNotFound = makeMeta({
+ title: makeMetaTitle(["User Not Found", "Renku"]),
+});
+const metaError = makeMeta({
+ title: makeMetaTitle(["Error", "Renku"]),
+});
+
+export function meta({
+ loaderData,
+ location,
+}: Route.MetaArgs): MetaDescriptor[] {
+ const { user, error } = loaderData;
+ if (error && "status" in error && error.status == 404) {
+ return metaNotFound;
+ }
+ if (error) {
+ return metaError;
+ }
+ if (user == null) {
+ return makeMeta({
+ title: makeMetaTitle(["User Page", "Renku"]),
+ image: renkuUserSocialCard,
+ });
+ }
+ const matchSearch = matchPath(
+ ABSOLUTE_ROUTES.v2.users.show.search,
+ location.pathname
+ );
+ const name =
+ user?.first_name && user?.last_name
+ ? `${user.first_name} ${user.last_name}`
+ : user?.first_name || user?.last_name;
+ const title = makeMetaTitle([
+ ...(matchSearch ? ["Search"] : []),
+ ...(name ? [name] : []),
+ `@${user.username}`,
+ "User",
+ "Renku",
+ ]);
+ return makeMeta({
+ title,
+ image: renkuUserSocialCard,
+ });
+}
+
+export default function UserPagesRoot({
+ loaderData,
+ params,
+}: Route.ComponentProps) {
+ const { username } = params;
+
+ const navigate = useNavigate();
+
+ const dispatch = useAppDispatch();
+
+ const [isNamespaceCacheReady, setIsNamespaceCacheReady] =
+ useState(false);
+ const [isUserCacheReady, setIsUserCacheReady] = useState(false);
+ const isCacheReady = isNamespaceCacheReady && isUserCacheReady;
+
+ //? Inject the server-side data into the RTK Query cache
+ useEffect(() => {
+ if (loaderData.namespace != null) {
+ let ignore: boolean = false;
+ const namespaceApiArgs = { namespaceSlug: loaderData.namespace.slug };
+ const namespacePromise = dispatch(
+ projectV2Api.util.upsertQueryData(
+ "getNamespacesByNamespaceSlug",
+ namespaceApiArgs,
+ loaderData.namespace
+ )
+ );
+ namespacePromise.then(() => {
+ if (!ignore) {
+ setIsNamespaceCacheReady(true);
+ }
+ });
+ return () => {
+ ignore = true;
+ };
+ }
+ }, [dispatch, loaderData.namespace]);
+ useEffect(() => {
+ if (loaderData.user != null) {
+ let ignore: boolean = false;
+ const userApiArgs = { userId: loaderData.user.id };
+ const userPromise = dispatch(
+ usersApi.util.upsertQueryData(
+ "getUsersByUserId",
+ userApiArgs,
+ loaderData.user
+ )
+ );
+ userPromise.then(() => {
+ if (!ignore) {
+ setIsUserCacheReady(true);
+ }
+ });
+ return () => {
+ ignore = true;
+ };
+ }
+ }, [dispatch, loaderData.user]);
+
+ //? Subscribe this component to the namespace and user queries:
+ //? * if the data is loaded client-side
+ //? * once the cache is ready (will use cache data)
+ const {
+ currentData: namespace,
+ isLoading: isLoadingNamespace,
+ error: namespaceError,
+ } = useGetNamespacesByNamespaceSlugQuery(
+ loaderData.clientSideFetch || isNamespaceCacheReady
+ ? { namespaceSlug: username }
+ : skipToken
+ );
+ const {
+ currentData: user,
+ isLoading: isLoadingUser,
+ error: userError,
+ } = useGetUserByIdQuery(
+ (loaderData.clientSideFetch || isUserCacheReady) &&
+ 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 }
+ );
+ } else if (
+ username &&
+ namespace?.namespace_kind === "user" &&
+ namespace.slug !== username
+ ) {
+ navigate(
+ generatePath(ABSOLUTE_ROUTES.v2.users.show.root, {
+ username: namespace.slug,
+ }),
+ { replace: true }
+ );
+ }
+ }, [namespace?.namespace_kind, namespace?.slug, navigate, username]);
+
+ if (
+ isLoading ||
+ (!loaderData.clientSideFetch &&
+ loaderData.namespace != null &&
+ loaderData.user != null &&
+ !isCacheReady)
+ ) {
+ return ;
+ }
+
+ if (error || namespace == null || user == null) {
+ return ;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/client/src/routes/users/search.tsx b/client/src/routes/users/search.tsx
new file mode 100644
index 0000000000..18edb65705
--- /dev/null
+++ b/client/src/routes/users/search.tsx
@@ -0,0 +1,5 @@
+import LazyUserV2Search from "~/features/usersV2/LazyUserV2Search";
+
+export default function UserOverviewPage() {
+ return ;
+}
diff --git a/client/src/routes/users/searchRedirect.tsx b/client/src/routes/users/searchRedirect.tsx
new file mode 100644
index 0000000000..bb68383a19
--- /dev/null
+++ b/client/src/routes/users/searchRedirect.tsx
@@ -0,0 +1,13 @@
+import { redirect } from "react-router";
+
+import { ABSOLUTE_ROUTES } from "~/routing/routes.constants";
+
+const USER_SEARCH = `${ABSOLUTE_ROUTES.v2.search}?type=User`;
+
+export async function loader() {
+ throw redirect(USER_SEARCH);
+}
+
+export async function clientLoader() {
+ throw redirect(USER_SEARCH);
+}
diff --git a/client/src/routing/routes.constants.ts b/client/src/routing/routes.constants.ts
index ff1b3b6ec2..c4c33f99d4 100644
--- a/client/src/routing/routes.constants.ts
+++ b/client/src/routing/routes.constants.ts
@@ -122,10 +122,9 @@ export const RELATIVE_ROUTES = {
betaRoot: "/v2/*",
integrations: "integrations",
groups: {
- root: "g/*",
- new: "new",
+ root: "g",
show: {
- root: ":slug/*",
+ root: ":slug",
search: "search",
settings: "settings",
},
@@ -139,7 +138,6 @@ export const RELATIVE_ROUTES = {
},
projects: {
root: "p/*",
- new: "new",
show: {
root: ":namespace/:slug/*",
settings: "settings",
@@ -155,9 +153,9 @@ export const RELATIVE_ROUTES = {
secrets: "secrets",
user: "user",
users: {
- root: "u/*",
+ root: "u",
show: {
- root: ":username/*",
+ root: ":username",
search: "search",
},
},
diff --git a/client/src/styles/assets/renkuDataSocialCard.png b/client/src/styles/assets/renkuDataSocialCard.png
new file mode 100644
index 0000000000..73ebfa50bb
Binary files /dev/null and b/client/src/styles/assets/renkuDataSocialCard.png differ
diff --git a/client/src/styles/assets/renkuGroupSocialCard.png b/client/src/styles/assets/renkuGroupSocialCard.png
new file mode 100644
index 0000000000..e449175a57
Binary files /dev/null and b/client/src/styles/assets/renkuGroupSocialCard.png differ
diff --git a/client/src/styles/assets/renkuProjectSocialCard.png b/client/src/styles/assets/renkuProjectSocialCard.png
new file mode 100644
index 0000000000..0f7cec5b0f
Binary files /dev/null and b/client/src/styles/assets/renkuProjectSocialCard.png differ
diff --git a/client/src/styles/assets/renkuUserSocialCard.png b/client/src/styles/assets/renkuUserSocialCard.png
new file mode 100644
index 0000000000..d972f56fec
Binary files /dev/null and b/client/src/styles/assets/renkuUserSocialCard.png differ
diff --git a/client/storybook-vite.config.ts b/client/storybook-vite.config.ts
index b8359801ef..6263dee8df 100644
--- a/client/storybook-vite.config.ts
+++ b/client/storybook-vite.config.ts
@@ -27,4 +27,18 @@ export default defineConfig({
"~bootstrap": resolve(__dirname, "node_modules/bootstrap"),
},
},
+ css: {
+ preprocessorOptions: {
+ scss: {
+ api: "modern-compiler",
+ // See: https://github.com/twbs/bootstrap/issues/40849
+ silenceDeprecations: [
+ "color-functions",
+ "import",
+ "global-builtin",
+ "if-function",
+ ],
+ },
+ },
+ },
});