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", + ], + }, + }, + }, });