diff --git a/client/src/CatchallApp.tsx b/client/src/CatchallApp.tsx index 0dcb391134..c8135593ec 100644 --- a/client/src/CatchallApp.tsx +++ b/client/src/CatchallApp.tsx @@ -23,7 +23,6 @@ import { Route, Routes } from "react-router"; import LazyAdminPage from "./features/admin/LazyAdminPage"; import LegacyDatasetAddToProject from "./features/legacy/LegacyDatasetAddToProject"; import LegacyDatasets from "./features/legacy/LegacyDatasets"; -import LegacyProjectView from "./features/legacy/LegacyProjectView"; import LegacyRoot from "./features/legacy/LegacyRoot"; import LegacyShowDataset from "./features/legacy/LegacyShowDataset"; import LazyRootV2 from "./features/rootV2/LazyRootV2"; @@ -39,7 +38,6 @@ export default function CatchallApp() { const { data: user } = useGetUserQueryState(); return ( - } /> } diff --git a/client/src/features/legacy/CheckForRedirect.tsx b/client/src/features/legacy/ClientSideCheckForRedirects.tsx similarity index 80% rename from client/src/features/legacy/CheckForRedirect.tsx rename to client/src/features/legacy/ClientSideCheckForRedirects.tsx index 71ef679882..c22ab8665d 100644 --- a/client/src/features/legacy/CheckForRedirect.tsx +++ b/client/src/features/legacy/ClientSideCheckForRedirects.tsx @@ -22,42 +22,25 @@ import { useEffect, useMemo } from "react"; import { useLocation, useNavigate } from "react-router"; import ContainerWrap from "~/components/container/ContainerWrap"; +import { Loader } from "~/components/Loader"; import { useGetPlatformRedirectsBySourceUrlQuery } from "~/features/platform/api/platform.api"; import { locationPathnameToSourceUrl } from "~/features/platform/api/platform.utils"; import NoLegacySupportForProjects from "./NoLegacySupportForProjects"; -function CheckingForRedirect() { - return ( - -
-
-

- Checking for redirect... -

-
-
-
- ); +interface ClientSideCheckForRedirectsProps { + projectSlug: string; } -export default function CheckForRedirect() { +export default function ClientSideCheckForRedirects({ + projectSlug, +}: ClientSideCheckForRedirectsProps) { const location = useLocation(); const searchParams = useMemo( () => new URLSearchParams(location.search), [location.search] ); - const sourceUrl = locationPathnameToSourceUrl(location.pathname); + + const sourceUrl = locationPathnameToSourceUrl(projectSlug); const { data: redirectPlan, isFetching: isFetchingRedirects } = useGetPlatformRedirectsBySourceUrlQuery( sourceUrl ? { sourceUrl } : skipToken @@ -69,7 +52,11 @@ export default function CheckForRedirect() { navigate( { pathname: redirectPlan.target_url, - search: autostart ? `?autostartRedirect=true` : undefined, + search: autostart + ? new URLSearchParams({ + autostartRedirect: "true", + }).toString() + : undefined, }, { replace: true, @@ -80,6 +67,23 @@ export default function CheckForRedirect() { if (isFetchingRedirects || redirectPlan?.target_url != null) { return ; } - return ; } + +function CheckingForRedirect() { + return ( + +
+
+

+ + Checking for redirect... +

+
+
+
+ ); +} diff --git a/client/src/features/legacy/LegacyProjectView.tsx b/client/src/features/legacy/LegacyProjectView.tsx deleted file mode 100644 index f68d62d527..0000000000 --- a/client/src/features/legacy/LegacyProjectView.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/*! - * Copyright 2025 - 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 CheckForRedirect from "./CheckForRedirect"; - -export default function LegacyProjectView() { - return ; -} diff --git a/client/src/features/platform/api/platform-empty.api.ts b/client/src/features/platform/api/platform-empty.api.ts index 5f7f54c317..f325b60c5b 100644 --- a/client/src/features/platform/api/platform-empty.api.ts +++ b/client/src/features/platform/api/platform-empty.api.ts @@ -18,9 +18,15 @@ import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; +import { API_BASE_URL } from "~/utils/api/api.constants"; +import { prepareHeaders } from "~/utils/api/api.utils"; + // initialize an empty api service that we'll inject endpoints into later as needed export const platformEmptyApi = createApi({ - baseQuery: fetchBaseQuery({ baseUrl: "/api/data" }), + baseQuery: fetchBaseQuery({ + baseUrl: API_BASE_URL, + prepareHeaders, + }), endpoints: () => ({}), reducerPath: "platformApi", }); diff --git a/client/src/routes.ts b/client/src/routes.ts index ca72c9716f..675614d412 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -62,6 +62,8 @@ export default [ // Not found page for /u/* route("*", "routes/users/catchall.tsx"), ]), + // Legacy projects (may redirect) + route(RELATIVE_ROUTES.projects.splat, "routes/legacy/projects.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/legacy/projects.tsx b/client/src/routes/legacy/projects.tsx new file mode 100644 index 0000000000..77ab79deb3 --- /dev/null +++ b/client/src/routes/legacy/projects.tsx @@ -0,0 +1,101 @@ +import { data, redirect } from "react-router"; + +import ClientSideCheckForRedirects from "~/features/legacy/ClientSideCheckForRedirects"; +import NoLegacySupportForProjects from "~/features/legacy/NoLegacySupportForProjects"; +import { platformApi } from "~/features/platform/api/platform.api"; +import { locationPathnameToSourceUrl } from "~/features/platform/api/platform.utils"; +import { storeContext } from "~/store/store.utils.server"; +import type { Route } from "./+types/projects"; + +export async function loader({ context, params, request }: Route.LoaderArgs) { + const store = context.get(storeContext); + const clientSideFetch = store == null || process.env.CYPRESS === "1"; + if (clientSideFetch) { + //? In testing, we load the redirects data client-side + return data({ + clientSideFetch, + redirectPlan: undefined, + error: undefined, + }); + } + + //? Otherwise, we load the redirects data to send a redirect if there is one + const splat = params["*"]; + const sourceUrl = locationPathnameToSourceUrl(splat); + if (!sourceUrl) { + // The is no project slug to try to redirect to + return data({ clientSideFetch, redirectPlan: undefined, error: undefined }); + } + + const url = new URL(request.url); + const autostart = !!url.searchParams.get("autostart"); + + const endpoint = platformApi.endpoints.getPlatformRedirectsBySourceUrl; + const apiArgs = { sourceUrl }; + store.dispatch(endpoint.initiate(apiArgs)); + await Promise.all(store.dispatch(platformApi.util.getRunningQueriesThunk())); + const selector = endpoint.select(apiArgs); + const { data: redirectPlan, error } = selector(store.getState()); + store.dispatch(platformApi.util.resetApiState()); + if (error && "status" in error && typeof error.status === "number") { + if (error.status == 404) { + // Ignore 404s: there is no redirect + return data({ + clientSideFetch, + redirectPlan: undefined, + error: undefined, + }); + } + return data({ clientSideFetch, redirectPlan, error }, error.status); + } + + // Send redirect response if we found a match + if (redirectPlan?.target_url != null) { + const redirectUrl = makeRedirectUrl( + redirectPlan.target_url, + autostart, + request.url + ); + throw redirect(redirectUrl, 301); + } + + return data({ clientSideFetch, redirectPlan, error }); +} + +//? NOTE: we do not provide a client-side loader since there is no +//? navigation link to legacy pages in the UI. + +export default function LegacyProjectPage({ + loaderData, + params, +}: Route.ComponentProps) { + if (loaderData.clientSideFetch) { + return ; + } + return ; +} + +function makeRedirectUrl( + targetUrl: string, + autostart: boolean, + requestUrl: string +) { + // Local redirect to a Renku v2 project + if (targetUrl.startsWith("/") && autostart) { + const search = new URLSearchParams({ + autostartRedirect: "true", + }).toString(); + return `${targetUrl}?${search}`; + } + if (targetUrl.startsWith("/")) { + return targetUrl; + } + // Redirect to an external URL + const redirectUrl = new URL(targetUrl, requestUrl); + if (autostart) { + redirectUrl.search = new URLSearchParams({ + autostartRedirect: "true", + }).toString(); + } + return redirectUrl.toString(); +} diff --git a/client/src/routing/routes.constants.ts b/client/src/routing/routes.constants.ts index e08cfc4e89..02f07a4a78 100644 --- a/client/src/routing/routes.constants.ts +++ b/client/src/routing/routes.constants.ts @@ -18,14 +18,14 @@ export const ABSOLUTE_ROUTES = { root: "/", + /** @deprecated Renku Legacy route */ datasets: { root: "/datasets", splat: "/datasets/*", }, - projects: { - root: "/projects", - splat: "/projects/*", - }, + /** @deprecated Renku Legacy route */ + projects: { splat: "/projects/*" }, + /** @deprecated Renku Legacy routes */ v1: { splat: "/v1/*" }, v2: { index: "/", @@ -74,8 +74,11 @@ export const ABSOLUTE_ROUTES = { export const RELATIVE_ROUTES = { root: "/", + /** @deprecated Renku Legacy route */ datasets: "/datasets", - projects: "/projects", + /** @deprecated Renku Legacy route */ + projects: { splat: "projects/*" }, + /** @deprecated Renku Legacy routes */ v1: { splat: "v1/*" }, v2: { index: "/", diff --git a/client/src/store/store.utils.server.ts b/client/src/store/store.utils.server.ts index 4f727759b4..c9ecbdc566 100644 --- a/client/src/store/store.utils.server.ts +++ b/client/src/store/store.utils.server.ts @@ -20,6 +20,7 @@ import { configureStore } from "@reduxjs/toolkit"; import { parseCookie } from "cookie"; import { createContext, type MiddlewareFunction } from "react-router"; +import { platformApi } from "~/features/platform/api/platform.api"; import { projectV2Api } from "~/features/projectsV2/api/projectV2.enhanced-api"; import { usersApi } from "~/features/usersV2/api/users.api"; import cookieSlice from "./cookie.slice.server"; @@ -36,11 +37,15 @@ function makeStore() { // Slices [cookieSlice.reducerPath]: cookieSlice.reducer, // APIs + [platformApi.reducerPath]: platformApi.reducer, [projectV2Api.reducerPath]: projectV2Api.reducer, [usersApi.reducerPath]: usersApi.reducer, }, middleware: (gDM) => - gDM().concat(projectV2Api.middleware).concat(usersApi.middleware), + gDM() + .concat(platformApi.middleware) + .concat(projectV2Api.middleware) + .concat(usersApi.middleware), }); }