diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 516598bc9c..f42437d8e6 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -300,6 +300,7 @@ "uploadable", "uploader", "uploadprogress", + "upsert", "urls", "validators", "versioned", diff --git a/client/react-router.config.ts b/client/react-router.config.ts index 85a58d4a96..e488a237bd 100644 --- a/client/react-router.config.ts +++ b/client/react-router.config.ts @@ -5,6 +5,9 @@ import type { Config } from "@react-router/dev/config"; export default { appDirectory: "src", ssr: true, + future: { + v8_middleware: true, + }, // TODO: configure Sentry integration for source maps // TODO: Reference: https://docs.sentry.io/platforms/javascript/guides/react-router/manual-setup/#step-3-add-readable-stack-traces-with-source-maps-optional diff --git a/client/server/constants.ts b/client/server/constants.ts index ea19346754..796f389963 100644 --- a/client/server/constants.ts +++ b/client/server/constants.ts @@ -49,7 +49,7 @@ export const CONFIG_JSON = { RENKU_CHART_VERSION: process.env.RENKU_CHART_VERSION, UI_SHORT_SHA: process.env.RENKU_UI_SHORT_SHA, BASE_URL: process.env.BASE_URL || "http://renku.build", - GATEWAY_URL: process.env.GATEWAY_URL || "http://gateway.renku.build", + GATEWAY_URL: process.env.GATEWAY_URL || "http://gateway.renku.build/api", UISERVER_URL: process.env.UISERVER_URL || "http://uiserver.renku.build", KEYCLOAK_REALM: process.env.KEYCLOAK_REALM || "Renku", DASHBOARD_MESSAGE: safeJsonToObject(process.env.DASHBOARD_MESSAGE), diff --git a/client/src/entry.server.tsx b/client/src/entry.server.tsx index 861593d477..c89aab6d28 100644 --- a/client/src/entry.server.tsx +++ b/client/src/entry.server.tsx @@ -4,7 +4,7 @@ import * as Sentry from "@sentry/react-router"; import { isbot } from "isbot"; import type { RenderToPipeableStreamOptions } from "react-dom/server"; import { renderToPipeableStream } from "react-dom/server"; -import type { AppLoadContext, EntryContext } from "react-router"; +import type { EntryContext, RouterContextProvider } from "react-router"; import { ServerRouter } from "react-router"; import { @@ -23,7 +23,7 @@ function handleRequest( responseHeaders: Headers, routerContext: EntryContext, // eslint-disable-next-line @typescript-eslint/no-unused-vars - _loadContext: AppLoadContext + _loadContext: RouterContextProvider // If you have middleware enabled: // loadContext: RouterContextProvider ): Promise { diff --git a/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx b/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx deleted file mode 100644 index 4e2bf50455..0000000000 --- a/client/src/features/ProjectPageV2/ProjectPageContainer/ProjectPageContainer.tsx +++ /dev/null @@ -1,117 +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 { useEffect } from "react"; -import { - generatePath, - Outlet, - useLocation, - useNavigate, - useOutletContext, - useParams, -} from "react-router"; -import { Col, Row } from "reactstrap"; - -import ContainerWrap from "../../../components/container/ContainerWrap"; -import { Loader } from "../../../components/Loader"; -import { ABSOLUTE_ROUTES } from "../../../routing/routes.constants"; -import type { Project } from "../../projectsV2/api/projectV2.api"; -import { useGetNamespacesByNamespaceProjectsAndSlugQuery } from "../../projectsV2/api/projectV2.enhanced-api"; -import ProjectNotFound from "../../projectsV2/notFound/ProjectNotFound"; -import ProjectPageHeader from "../ProjectPageHeader/ProjectPageHeader"; -import ProjectPageNav from "../ProjectPageNav/ProjectPageNav"; - -export default function ProjectPageContainer() { - const { namespace, slug } = useParams<{ - namespace: string | undefined; - slug: string | undefined; - }>(); - const { data, currentData, isLoading, error } = - useGetNamespacesByNamespaceProjectsAndSlugQuery({ - namespace: namespace ?? "", - slug: slug ?? "", - withDocumentation: true, - }); - - const navigate = useNavigate(); - const { pathname } = useLocation(); - - useEffect(() => { - if (namespace && currentData && currentData.namespace !== namespace) { - const previousBasePath = generatePath( - ABSOLUTE_ROUTES.v2.projects.show.root, - { - namespace: namespace, - slug: currentData.slug, - } - ); - const deltaUrl = pathname.slice(previousBasePath.length); - const newUrl = generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { - namespace: currentData.namespace, - slug: currentData.slug, - }); - navigate(newUrl + deltaUrl, { replace: true }); - } else if (slug && currentData && currentData.slug !== slug) { - const previousBasePath = generatePath( - ABSOLUTE_ROUTES.v2.projects.show.root, - { - namespace: currentData.namespace, - slug: slug, - } - ); - const deltaUrl = pathname.slice(previousBasePath.length); - const newUrl = generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { - namespace: currentData.namespace, - slug: currentData.slug, - }); - navigate(newUrl + deltaUrl, { replace: true }); - } - }, [currentData, namespace, navigate, pathname, slug]); - - if (isLoading) return ; - - if (error || data == null) { - return ; - } - - return ( - - - - - - -
- -
- - -
- -
- -
-
- ); -} - -type ContextType = { project: Project }; - -export function useProject() { - return useOutletContext(); -} diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformation.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformation.tsx index dacd8c5aa6..bba98fff4f 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformation.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectInformation/ProjectInformation.tsx @@ -33,6 +33,7 @@ import { Badge, Card, CardBody, CardHeader } from "reactstrap"; import KeywordBadge from "~/components/keywords/KeywordBadge"; import KeywordContainer from "~/components/keywords/KeywordContainer"; +import { useProject } from "~/routes/projects/root"; import { UnderlineArrowLink } from "../../../../components/buttons/Button"; import { Loader } from "../../../../components/Loader"; import { TimeCaption } from "../../../../components/TimeCaption"; @@ -48,7 +49,6 @@ import { useGetProjectsByProjectIdMembersQuery, useGetProjectsByProjectIdQuery, } from "../../../projectsV2/api/projectV2.enhanced-api"; -import { useProject } from "../../ProjectPageContainer/ProjectPageContainer"; import { getMemberNameToDisplay, toSortedMembers } from "../../utils/roleUtils"; import useProjectPermissions from "../../utils/useProjectPermissions.hook"; import ProjectInformationButton from "./ProjectInformationButton"; diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx index edbed9494b..25816c82d2 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/ProjectOverviewPage.tsx @@ -18,8 +18,8 @@ import { Col, Row } from "reactstrap"; +import { useProject } from "~/routes/projects/root"; import SessionsV2 from "../../sessionsV2/SessionsV2"; -import { useProject } from "../ProjectPageContainer/ProjectPageContainer"; import { CodeRepositoriesDisplay } from "./CodeRepositories/RepositoriesBox"; import ProjectDataConnectorsBox from "./DataConnectors/ProjectDataConnectorsBox"; import Documentation from "./Documentation/Documentation"; diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/AddSessionSecretButton.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/AddSessionSecretButton.tsx index 3716ddf57c..82fea8b92d 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/AddSessionSecretButton.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/AddSessionSecretButton.tsx @@ -30,12 +30,12 @@ import { UncontrolledTooltip, } from "reactstrap"; +import { useProject } from "~/routes/projects/root"; import { SuccessAlert } from "../../../../components/Alert"; import RtkOrDataServicesError from "../../../../components/errors/RtkOrDataServicesError"; import { Loader } from "../../../../components/Loader"; import type { SessionSecretSlot } from "../../../projectsV2/api/projectV2.api"; import { usePostSessionSecretSlotsMutation } from "../../../projectsV2/api/projectV2.enhanced-api"; -import { useProject } from "../../ProjectPageContainer/ProjectPageContainer"; import DescriptionField from "./fields/DescriptionField"; import FilenameField from "./fields/FilenameField"; import NameField from "./fields/NameField"; diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/ProjectSessionSecrets.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/ProjectSessionSecrets.tsx index 7b27e74f01..15d0dd14fd 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/ProjectSessionSecrets.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/ProjectSessionSecrets.tsx @@ -23,6 +23,7 @@ import { ShieldLock } from "react-bootstrap-icons"; import { Badge, Card, CardBody, CardHeader, ListGroup } from "reactstrap"; import { useGetUserQueryState } from "~/features/usersV2/api/users.api"; +import { useProject } from "~/routes/projects/root"; import { InfoAlert } from "../../../../components/Alert"; import RtkOrDataServicesError from "../../../../components/errors/RtkOrDataServicesError"; import { Loader } from "../../../../components/Loader"; @@ -36,7 +37,6 @@ import { useGetProjectsByProjectIdSessionSecretSlotsQuery, useGetProjectsByProjectIdSessionSecretsQuery, } from "../../../projectsV2/api/projectV2.enhanced-api"; -import { useProject } from "../../ProjectPageContainer/ProjectPageContainer"; import useProjectPermissions from "../../utils/useProjectPermissions.hook"; import AddSessionSecretButton from "./AddSessionSecretButton"; import SecretsMountDirectoryComponent from "./SecretsMountDirectoryComponent"; diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SecretsMountDirectoryComponent.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SecretsMountDirectoryComponent.tsx index 28992f1206..f83c41a6d8 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SecretsMountDirectoryComponent.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SecretsMountDirectoryComponent.tsx @@ -31,13 +31,13 @@ import { UncontrolledTooltip, } from "reactstrap"; +import { useProject } from "~/routes/projects/root"; import RtkOrDataServicesError from "../../../../components/errors/RtkOrDataServicesError"; import { Loader } from "../../../../components/Loader"; import ScrollableModal from "../../../../components/modal/ScrollableModal"; import PermissionsGuard from "../../../permissionsV2/PermissionsGuard"; import { usePatchProjectsByProjectIdMutation } from "../../../projectsV2/api/projectV2.enhanced-api"; import SecretsMountDirectoryField from "../../../projectsV2/fields/SecretsMountDirectoryField"; -import { useProject } from "../../ProjectPageContainer/ProjectPageContainer"; import useProjectPermissions from "../../utils/useProjectPermissions.hook"; export default function SecretsMountDirectoryComponent() { diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SessionSecretActions.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SessionSecretActions.tsx index a76be2b468..d6b0b9679e 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SessionSecretActions.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SessionSecretActions.tsx @@ -40,6 +40,7 @@ import { ModalHeader, } from "reactstrap"; +import { useProject } from "~/routes/projects/root"; import { ButtonWithMenuV2 } from "../../../../components/buttons/Button"; import RtkOrDataServicesError from "../../../../components/errors/RtkOrDataServicesError"; import { Loader } from "../../../../components/Loader"; @@ -54,7 +55,6 @@ import { useGetUserQueryState, useGetUserSecretByIdQuery, } from "../../../usersV2/api/users.api"; -import { useProject } from "../../ProjectPageContainer/ProjectPageContainer"; import useProjectPermissions from "../../utils/useProjectPermissions.hook"; import DescriptionField from "./fields/DescriptionField"; import FilenameField from "./fields/FilenameField"; diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SessionViewSessionSecrets.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SessionViewSessionSecrets.tsx index 99b3ae67b1..75eb41f35e 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SessionViewSessionSecrets.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/SessionSecrets/SessionViewSessionSecrets.tsx @@ -24,6 +24,7 @@ import { generatePath, Link } from "react-router"; import { Badge, ListGroup } from "reactstrap"; import { useGetUserQueryState } from "~/features/usersV2/api/users.api"; +import { useProject } from "~/routes/projects/root"; import { InfoAlert } from "../../../../components/Alert"; import RtkOrDataServicesError from "../../../../components/errors/RtkOrDataServicesError"; import { Loader } from "../../../../components/Loader"; @@ -36,7 +37,6 @@ import { useGetProjectsByProjectIdSessionSecretSlotsQuery, useGetProjectsByProjectIdSessionSecretsQuery, } from "../../../projectsV2/api/projectV2.enhanced-api"; -import { useProject } from "../../ProjectPageContainer/ProjectPageContainer"; import { SESSION_SECRETS_CARD_ID } from "./sessionSecrets.constants"; import { getSessionSecretSlotsWithSecrets } from "./sessionSecrets.utils"; import SessionSecretSlotItem from "./SessionSecretSlotItem"; diff --git a/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx index 8289268db4..6ae686a753 100644 --- a/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx +++ b/client/src/features/ProjectPageV2/ProjectPageContent/Settings/ProjectSettings.tsx @@ -35,6 +35,7 @@ import { import useRenkuToast from "~/components/toast/useRenkuToast"; import SlugFormField from "~/features/projectsV2/fields/SlugFormField"; +import { useProject } from "~/routes/projects/root"; import { RenkuAlert, SuccessAlert } from "../../../../components/Alert"; import RtkOrDataServicesError from "../../../../components/errors/RtkOrDataServicesError"; import { Loader } from "../../../../components/Loader"; @@ -48,7 +49,6 @@ import ProjectDescriptionFormField from "../../../projectsV2/fields/ProjectDescr import ProjectNameFormField from "../../../projectsV2/fields/ProjectNameFormField"; import ProjectNamespaceFormField from "../../../projectsV2/fields/ProjectNamespaceFormField"; import ProjectVisibilityFormField from "../../../projectsV2/fields/ProjectVisibilityFormField"; -import { useProject } from "../../ProjectPageContainer/ProjectPageContainer"; import type { ProjectV2Metadata, ProjectV2MetadataWithKeyword, diff --git a/client/src/features/ProjectPageV2/ProjectPageLayout/ProjectPageLayout.tsx b/client/src/features/ProjectPageV2/ProjectPageLayout/ProjectPageLayout.tsx new file mode 100644 index 0000000000..b5fed6cf89 --- /dev/null +++ b/client/src/features/ProjectPageV2/ProjectPageLayout/ProjectPageLayout.tsx @@ -0,0 +1,58 @@ +/*! + * 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 { ReactNode } from "react"; +import { Col, Row } from "reactstrap"; + +import GroupNew from "~/features/groupsV2/new/GroupNew"; +import type { Project } from "~/features/projectsV2/api/projectV2.api"; +import ProjectV2New from "~/features/projectsV2/new/ProjectV2New"; +import ContainerWrap from "../../../components/container/ContainerWrap"; +import ProjectPageHeader from "../ProjectPageHeader/ProjectPageHeader"; +import ProjectPageNav from "../ProjectPageNav/ProjectPageNav"; + +interface ProjectPageLayoutProps { + project: Project; + children?: ReactNode; +} + +export default function ProjectPageLayout({ + project, + children, +}: ProjectPageLayoutProps) { + return ( + + + + + + + + + +
+ +
+ + +
{children}
+ +
+
+ ); +} diff --git a/client/src/features/projectsV2/api/projectV2-empty.api.ts b/client/src/features/projectsV2/api/projectV2-empty.api.ts index 923e1892b0..9f3285a023 100644 --- a/client/src/features/projectsV2/api/projectV2-empty.api.ts +++ b/client/src/features/projectsV2/api/projectV2-empty.api.ts @@ -19,10 +19,14 @@ import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; import queryString from "query-string"; +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 projectV2EmptyApi = createApi({ baseQuery: fetchBaseQuery({ - baseUrl: "/api/data", + baseUrl: API_BASE_URL, + prepareHeaders, paramsSerializer: (params: Record) => // NOTE: arrayFormat: none will serialize arrays by using duplicate keys // like foo: [1, 2, 3] => 'foo=1&foo=2&foo=3' -> this is compatible diff --git a/client/src/features/rootV2/RootV2.tsx b/client/src/features/rootV2/RootV2.tsx index 87c7a1e874..d0670d99c3 100644 --- a/client/src/features/rootV2/RootV2.tsx +++ b/client/src/features/rootV2/RootV2.tsx @@ -43,9 +43,6 @@ import LazyGroupV2Overview from "../groupsV2/LazyGroupV2Overview"; import LazyGroupV2Search from "../groupsV2/LazyGroupV2Search"; import LazyGroupV2Settings from "../groupsV2/LazyGroupV2Settings"; import GroupNew from "../groupsV2/new/GroupNew"; -import LazyProjectPageV2Show from "../ProjectPageV2/LazyProjectPageV2Show"; -import LazyProjectPageOverview from "../ProjectPageV2/ProjectPageContent/LazyProjectPageOverview"; -import LazyProjectPageSettings from "../ProjectPageV2/ProjectPageContent/LazyProjectPageSettings"; import LazyProjectV2ShowByProjectId from "../projectsV2/LazyProjectV2ShowByProjectId"; import ProjectV2New from "../projectsV2/new/ProjectV2New"; import LazySearchV2 from "../searchV2/LazySearchV2"; @@ -232,13 +229,6 @@ function ProjectsV2Routes() { } /> - }> - } /> - } - /> - } diff --git a/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx b/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx index bc2d8f7871..1fff1297a5 100644 --- a/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx +++ b/client/src/features/sessionsV2/components/SessionForm/BuilderEnvironmentFields.tsx @@ -21,12 +21,12 @@ import cx from "classnames"; import { useContext, useMemo } from "react"; import { type Control } from "react-hook-form"; +import { useProject } from "~/routes/projects/root"; import { ErrorAlert, WarnAlert } from "../../../../components/Alert"; import RtkOrDataServicesError from "../../../../components/errors/RtkOrDataServicesError"; import { Loader } from "../../../../components/Loader"; import AppContext from "../../../../utils/context/appContext"; import { DEFAULT_APP_PARAMS } from "../../../../utils/context/appParams.constants"; -import { useProject } from "../../../ProjectPageV2/ProjectPageContainer/ProjectPageContainer"; import { useGetRepositoriesQuery } from "../../../repositories/api/repositories.api"; import type { SessionLauncherForm } from "../../sessionsV2.types"; import BuilderAdvancedSettings from "./BuilderAdvancedSettings"; diff --git a/client/src/features/usersV2/api/users.api.ts b/client/src/features/usersV2/api/users.api.ts index bd35801107..99dc12e574 100644 --- a/client/src/features/usersV2/api/users.api.ts +++ b/client/src/features/usersV2/api/users.api.ts @@ -50,9 +50,9 @@ const withFixedEndpoints = usersGeneratedApi.injectEndpoints({ } return { ...result, isLoggedIn: true }; }, - transformErrorResponse: () => { - return { isLoggedIn: false }; - }, + // transformErrorResponse: () => { + // return { isLoggedIn: false }; + // }, }), getUsers: build.query({ query: ({ userParams }) => ({ diff --git a/client/src/features/usersV2/api/users.empty-api.ts b/client/src/features/usersV2/api/users.empty-api.ts index 35ece718c2..48e7a66a1b 100644 --- a/client/src/features/usersV2/api/users.empty-api.ts +++ b/client/src/features/usersV2/api/users.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 usersEmptyApi = createApi({ - baseQuery: fetchBaseQuery({ baseUrl: "/api/data" }), + baseQuery: fetchBaseQuery({ + baseUrl: API_BASE_URL, + prepareHeaders, + }), endpoints: () => ({}), reducerPath: "usersApi", }); diff --git a/client/src/root.tsx b/client/src/root.tsx index dc348eefcd..f7f5eb0d2e 100644 --- a/client/src/root.tsx +++ b/client/src/root.tsx @@ -36,6 +36,7 @@ import { Scripts, ScrollRestoration, type MetaDescriptor, + type MiddlewareFunction, } from "react-router"; import { clientOnly$ } from "vite-env-only/macros"; @@ -43,8 +44,8 @@ import type { Route } from "./+types/root"; import AppRoot from "./AppRoot"; import PageLoader from "./components/PageLoader"; import NotFound from "./not-found/NotFound"; +import { storeMiddleware } from "./store/store.utils.server"; import { CONFIG_JSON } from "./utils/.server/config.constants"; -import type { AppParams } from "./utils/context/appParams.types"; import { validatedAppParams } from "./utils/context/appParams.utils"; import { initClientSideSentry } from "./utils/helpers/sentry/utils"; import { makeMeta, makeMetaTitle } from "./utils/meta/meta"; @@ -57,10 +58,13 @@ type ServerLoaderReturn_ = | { clientSideFetch: false; config: typeof CONFIG_JSON }; type ServerLoaderReturn = ReturnType>; +export const middleware = [storeMiddleware] satisfies MiddlewareFunction[]; + export async function loader(): Promise { const clientSideFetch = process.env.NODE_ENV === "development" || process.env.CYPRESS === "1"; if (clientSideFetch) { + //? In development, we load the /config.json data client-side return data({ clientSideFetch, config: undefined, @@ -246,11 +250,7 @@ export default function Root({ loaderData }: Route.ComponentProps) { } return ( - + ); } - -export type RootOutletContext = { - params: AppParams; -}; diff --git a/client/src/routes.ts b/client/src/routes.ts index ac9c17637c..5266d31068 100644 --- a/client/src/routes.ts +++ b/client/src/routes.ts @@ -1,4 +1,9 @@ -import { index, route, type RouteConfig } from "@react-router/dev/routes"; +import { + index, + prefix, + route, + type RouteConfig, +} from "@react-router/dev/routes"; import { RELATIVE_ROUTES } from "./routing/routes.constants"; @@ -21,6 +26,16 @@ export default [ ]), // Not found page for /help/* route(`${RELATIVE_ROUTES.v2.help.root}/*`, "routes/help/catchall.tsx"), + // Some project pages + ...prefix("p", [ + route(":namespace/:slug", "routes/projects/root.tsx", [ + index("routes/projects/index.tsx"), + route( + RELATIVE_ROUTES.v2.projects.show.settings, + "routes/projects/settings.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/projects/index.tsx b/client/src/routes/projects/index.tsx new file mode 100644 index 0000000000..3267560979 --- /dev/null +++ b/client/src/routes/projects/index.tsx @@ -0,0 +1,5 @@ +import LazyProjectPageOverview from "~/features/ProjectPageV2/ProjectPageContent/LazyProjectPageOverview"; + +export default function ProjectOverviewPage() { + return ; +} diff --git a/client/src/routes/projects/root.tsx b/client/src/routes/projects/root.tsx new file mode 100644 index 0000000000..9a0cacbdd4 --- /dev/null +++ b/client/src/routes/projects/root.tsx @@ -0,0 +1,229 @@ +import { skipToken } from "@reduxjs/toolkit/query"; +import { useEffect, useState } from "react"; +import { + data, + generatePath, + matchPath, + Outlet, + useLocation, + useNavigate, + useOutletContext, + type MetaDescriptor, +} from "react-router"; + +import { Loader } from "~/components/Loader"; +import ProjectPageLayout from "~/features/ProjectPageV2/ProjectPageLayout/ProjectPageLayout"; +import { type Project } from "~/features/projectsV2/api/projectV2.api"; +import { + projectV2Api, + useGetNamespacesByNamespaceProjectsAndSlugQuery, +} from "~/features/projectsV2/api/projectV2.enhanced-api"; +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 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 project data client-side + return data({ + clientSideFetch, + project: undefined, + error: undefined, + }); + } + + //? Otherwise, we load the project data to generate meta tags + const { namespace, slug } = params; + const endpoint = + projectV2Api.endpoints.getNamespacesByNamespaceProjectsAndSlug; + const apiArgs = { + namespace, + slug, + withDocumentation: true, + }; + store.dispatch(endpoint.initiate(apiArgs)); + await Promise.all(store.dispatch(projectV2Api.util.getRunningQueriesThunk())); + const projectSelector = endpoint.select(apiArgs); + const { data: project, error } = projectSelector(store.getState()); + store.dispatch(projectV2Api.util.resetApiState()); + if (error && "status" in error && typeof error.status === "number") { + return data({ clientSideFetch, project, error }, error.status); + } + return data({ clientSideFetch, project, 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 { namespace, slug } = params; + const endpoint = + projectV2Api.endpoints.getNamespacesByNamespaceProjectsAndSlug; + const apiArgs = { + namespace, + slug, + withDocumentation: true, + }; + const promise = store.dispatch(endpoint.initiate(apiArgs)); + await Promise.all(store.dispatch(projectV2Api.util.getRunningQueriesThunk())); + const projectSelector = endpoint.select(apiArgs); + const { data: project, error } = projectSelector(store.getState()); + //? Unsubscribe to let the cache expire when navigating to other pages + promise.unsubscribe(); + return { + clientSideFetch: true, + project, + error, + }; +} + +const metaNotFound = makeMeta({ + title: makeMetaTitle(["Project Not Found", "Renku"]), +}); +const metaError = makeMeta({ + title: makeMetaTitle(["Error", "Renku"]), +}); + +export function meta({ + loaderData, + location, +}: Route.MetaArgs): MetaDescriptor[] { + const { project, error } = loaderData; + if (error && "status" in error && error.status == 404) { + return metaNotFound; + } + if (error) { + return metaError; + } + if (project == null) { + return makeMeta({ title: makeMetaTitle(["Project Page", "Renku"]) }); + } + + const matchSettings = matchPath( + ABSOLUTE_ROUTES.v2.projects.show.settings, + location.pathname + ); + const title = makeMetaTitle([ + ...(matchSettings ? ["Settings"] : []), + project.name, + `Project in @${project.namespace}`, + "Renku", + ]); + return makeMeta({ + title, + description: project.description || undefined, + }); +} + +export default function ProjectPagesRoot({ + loaderData, + params, +}: Route.ComponentProps) { + const { namespace, slug } = params; + + const { pathname } = useLocation(); + const navigate = useNavigate(); + + const dispatch = useAppDispatch(); + + const [isCacheReady, setIsCacheReady] = useState(false); + + //? Inject the server-side data into the RTK Query cache + useEffect(() => { + if (loaderData.project != null) { + const apiArgs = { + namespace, + slug, + withDocumentation: true, + }; + let ignore: boolean = false; + const promise = dispatch( + projectV2Api.util.upsertQueryData( + "getNamespacesByNamespaceProjectsAndSlug", + apiArgs, + loaderData.project + ) + ); + promise.then(() => { + if (!ignore) { + setIsCacheReady(true); + } + }); + return () => { + ignore = true; + }; + } + }, [dispatch, loaderData.project, namespace, slug]); + + //? Subscribe this component to the project query: + //? * if the data is loaded client-side + //? * once the cache is ready (will use cache data) + const { + currentData: project, + isLoading, + error, + } = useGetNamespacesByNamespaceProjectsAndSlugQuery( + loaderData.clientSideFetch || isCacheReady + ? { namespace, slug, withDocumentation: true } + : skipToken + ); + + useEffect(() => { + if (namespace && project && project.namespace !== namespace) { + const previousBasePath = generatePath( + ABSOLUTE_ROUTES.v2.projects.show.root, + { + namespace: namespace, + slug: project.slug, + } + ); + const deltaUrl = pathname.slice(previousBasePath.length); + const newUrl = generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { + namespace: project.namespace, + slug: project.slug, + }); + navigate(newUrl + deltaUrl, { replace: true }); + } else if (slug && project && project.slug !== slug) { + const previousBasePath = generatePath( + ABSOLUTE_ROUTES.v2.projects.show.root, + { + namespace: project.namespace, + slug: slug, + } + ); + const deltaUrl = pathname.slice(previousBasePath.length); + const newUrl = generatePath(ABSOLUTE_ROUTES.v2.projects.show.root, { + namespace: project.namespace, + slug: project.slug, + }); + navigate(newUrl + deltaUrl, { replace: true }); + } + }, [namespace, navigate, pathname, project, slug]); + + if ( + isLoading || + (!loaderData.clientSideFetch && loaderData.project != null && !isCacheReady) + ) { + return ; + } + + if (error || project == null) { + return ; + } + + return ( + + + + ); +} + +type ContextType = { project: Project }; + +export function useProject() { + return useOutletContext(); +} diff --git a/client/src/routes/projects/settings.tsx b/client/src/routes/projects/settings.tsx new file mode 100644 index 0000000000..4e186efe7d --- /dev/null +++ b/client/src/routes/projects/settings.tsx @@ -0,0 +1,5 @@ +import LazyProjectPageSettings from "~/features/ProjectPageV2/ProjectPageContent/LazyProjectPageSettings"; + +export default function ProjectSettingsPage() { + return ; +} diff --git a/client/src/store/cookie.slice.server.ts b/client/src/store/cookie.slice.server.ts new file mode 100644 index 0000000000..d0b307548f --- /dev/null +++ b/client/src/store/cookie.slice.server.ts @@ -0,0 +1,40 @@ +/*! + * 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 { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +type CookieState = { + renkuSessionCookie: string; +}; + +const initialState: CookieState = { + renkuSessionCookie: "", +}; + +export const cookieSlice = createSlice({ + name: "cookie", + initialState, + reducers: { + setRenkuSessionCookie: (state, action: PayloadAction) => { + state.renkuSessionCookie = action.payload; + }, + }, +}); + +export const { setRenkuSessionCookie } = cookieSlice.actions; +export default cookieSlice; diff --git a/client/src/store/store.utils.server.ts b/client/src/store/store.utils.server.ts new file mode 100644 index 0000000000..4f727759b4 --- /dev/null +++ b/client/src/store/store.utils.server.ts @@ -0,0 +1,74 @@ +/*! + * 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 { configureStore } from "@reduxjs/toolkit"; +import { parseCookie } from "cookie"; +import { createContext, type MiddlewareFunction } from "react-router"; + +import { projectV2Api } from "~/features/projectsV2/api/projectV2.enhanced-api"; +import { usersApi } from "~/features/usersV2/api/users.api"; +import cookieSlice from "./cookie.slice.server"; + +// Server-side redux utilities + +/** Cookie name for the browser session used in Renku. */ +const RENKU_SESSION_COOKIE = "_renku_session"; + +/** Creates a redux store which can be used server-side. */ +function makeStore() { + return configureStore({ + reducer: { + // Slices + [cookieSlice.reducerPath]: cookieSlice.reducer, + // APIs + [projectV2Api.reducerPath]: projectV2Api.reducer, + [usersApi.reducerPath]: usersApi.reducer, + }, + middleware: (gDM) => + gDM().concat(projectV2Api.middleware).concat(usersApi.middleware), + }); +} + +export type ServerStoreType = ReturnType; + +export type ServerRootState = ReturnType; + +export type ServerAppDispatch = ServerStoreType["dispatch"]; + +/** React-router context for the server-side redux store. */ +export const storeContext = createContext( + undefined +); + +/** React-router middleware which sets up the redux store. */ +export const storeMiddleware: MiddlewareFunction = function ({ + context, + request, +}) { + const store = makeStore(); + context.set(storeContext, store); + + const cookie = request.headers.get("cookie"); + if (cookie) { + const cookies = parseCookie(cookie); + const renkuSessionCookie = cookies[RENKU_SESSION_COOKIE]; + if (renkuSessionCookie) { + store.dispatch(cookieSlice.actions.setRenkuSessionCookie(cookie)); + } + } +}; diff --git a/client/src/features/ProjectPageV2/LazyProjectPageV2Show.tsx b/client/src/utils/api/api.constants.ts similarity index 60% rename from client/src/features/ProjectPageV2/LazyProjectPageV2Show.tsx rename to client/src/utils/api/api.constants.ts index 60351f5e0d..aad6c1df4d 100644 --- a/client/src/features/ProjectPageV2/LazyProjectPageV2Show.tsx +++ b/client/src/utils/api/api.constants.ts @@ -1,5 +1,5 @@ /*! - * Copyright 2024 - Swiss Data Science Center (SDSC) + * 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). * @@ -13,21 +13,12 @@ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and - * limitations under the License + * limitations under the License. */ -import { lazy, Suspense } from "react"; +import { CONFIG_JSON_SERVER_ONLY } from "../constants/config.constants"; -import PageLoader from "../../components/PageLoader"; - -const ProjectPageV2Show = lazy( - () => import("./ProjectPageContainer/ProjectPageContainer") -); - -export default function LazyProjectPageV2Show() { - return ( - }> - - - ); -} +/** The base URL for the Renku API, works both client-side and server-side. */ +export const API_BASE_URL = CONFIG_JSON_SERVER_ONLY?.GATEWAY_URL + ? `${CONFIG_JSON_SERVER_ONLY.GATEWAY_URL}/data` + : "/api/data"; diff --git a/client/src/utils/api/api.utils.ts b/client/src/utils/api/api.utils.ts new file mode 100644 index 0000000000..7cad44fca0 --- /dev/null +++ b/client/src/utils/api/api.utils.ts @@ -0,0 +1,38 @@ +/*! + * 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 type { FetchBaseQueryArgs } from "@reduxjs/toolkit/query/react"; +import { serverOnly$ } from "vite-env-only/macros"; + +import cookieSlice from "~/store/cookie.slice.server"; +import type { ServerRootState } from "~/store/store.utils.server"; + +/** The `prepareHeaders` method for RTK Query; on the server-side, it sets up the + * cookie header to perform authenticated requests. + */ +export const prepareHeaders: FetchBaseQueryArgs["prepareHeaders"] = serverOnly$( + function (headers, { getState }) { + // TODO: Setup Sentry trace headers (propagate from incoming query if needed) + const { renkuSessionCookie } = cookieSlice.selectSlice( + getState() as ServerRootState + ); + if (renkuSessionCookie) { + headers.set("cookie", renkuSessionCookie); + } + } +); diff --git a/client/storybook-vite.config.ts b/client/storybook-vite.config.ts index acc4e9cb82..b8359801ef 100644 --- a/client/storybook-vite.config.ts +++ b/client/storybook-vite.config.ts @@ -2,6 +2,7 @@ import { resolve } from "path"; import eslintPlugin from "@nabla/vite-plugin-eslint"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +import { envOnlyMacros } from "vite-env-only"; import tsconfigPaths from "vite-tsconfig-paths"; // https://vitejs.dev/config/ @@ -19,6 +20,7 @@ export default defineConfig({ }), eslintPlugin(), tsconfigPaths(), + envOnlyMacros(), ], resolve: { alias: {