From bf1d289e8e81bc99e531f0a2712a842f44349946 Mon Sep 17 00:00:00 2001 From: hupeh Date: Wed, 4 Sep 2024 14:45:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=98=AF=E5=BC=80=E5=A7=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/imsfe/src/App.tsx | 35 +-- apps/simple/package.json | 14 - packages/joy-ui/src/layout/AdminRoot.tsx | 40 --- packages/joy-ui/src/layout/Layout.tsx | 35 +++ packages/joy-ui/src/layout/PageDock.tsx | 18 ++ packages/joy-ui/src/layout/PageRoot.tsx | 9 +- packages/joy-ui/src/layout/PageTitle.tsx | 52 ++++ packages/joy-ui/src/layout/StatusError.tsx | 8 +- packages/joy-ui/src/layout/TitlePortal.tsx | 15 - packages/joy-ui/src/layout/index.ts | 3 +- .../src/accessControl/AccessControlContext.ts | 7 - .../accessControl/AccessControlProvider.tsx | 16 - .../rakit/src/accessControl/CanAccess.tsx | 96 ------ packages/rakit/src/accessControl/index.ts | 5 - packages/rakit/src/accessControl/types.ts | 52 ---- .../src/accessControl/useAccessControl.ts | 18 -- packages/rakit/src/accessControl/useCan.ts | 70 ----- .../rakit/src/auth/AccessControlContext.ts | 21 ++ .../rakit/src/auth/AccessControlProvider.tsx | 37 +++ packages/rakit/src/auth/CanAccess.tsx | 40 +++ packages/rakit/src/auth/index.ts | 3 + packages/rakit/src/auth/types.ts | 40 ++- packages/rakit/src/auth/useCan.ts | 57 ++++ .../AppRouter.tsx => core/AdminRouter.tsx} | 27 +- .../src/{routing => core}/BasenameContext.ts | 0 .../BasenameContextProvider.tsx | 0 packages/rakit/src/core/CoreAdmin.tsx | 134 ++++++++ packages/rakit/src/core/CoreAdminContext.tsx | 182 +++++++---- packages/rakit/src/core/CoreAdminRoutes.tsx | 69 ++--- packages/rakit/src/core/CoreAdminUI.tsx | 293 ++++++++++++++++++ packages/rakit/src/core/CoreAppContext.tsx | 271 ---------------- packages/rakit/src/core/CoreAppRoutes.tsx | 277 ----------------- packages/rakit/src/core/DefaultError.tsx | 18 ++ .../{title => core}/DefaultTitleContext.ts | 5 +- .../DefaultTitleContextProvider.tsx | 0 .../{errorBoundary => core}/ErrorBoundary.tsx | 0 packages/rakit/src/core/ErrorContext.ts | 7 + .../rakit/src/core/ErrorContextProvider.tsx | 13 + .../rakit/src/core/HasDashboardContext.ts | 3 + packages/rakit/src/core/InAdminContext.tsx | 21 -- packages/rakit/src/core/InAppContext.tsx | 21 -- packages/rakit/src/core/index.ts | 18 +- packages/rakit/src/core/types.ts | 20 +- .../src/{routing => core}/useBasename.ts | 0 ...> useConfigureAdminRouterFromChildren.tsx} | 82 ++--- .../src/{title => core}/useDefaultTitle.ts | 0 .../useErrorContext.ts | 3 +- .../src/{routing => core}/useRedirect.ts | 0 .../useResetErrorBoundaryOnLocationChange.ts | 12 +- .../rakit/src/errorBoundary/ErrorContext.ts | 10 - .../errorBoundary/ErrorContextProvider.tsx | 15 - packages/rakit/src/errorBoundary/index.ts | 4 - packages/rakit/src/index.ts | 4 - .../src/routing/InitialLocationContext.ts | 12 - .../InitialLocationContextProvider.tsx | 32 -- packages/rakit/src/routing/Route.tsx | 75 ----- packages/rakit/src/routing/index.ts | 9 - .../rakit/src/routing/useInitialLocation.ts | 17 - .../src/routing/useInitialLocationContext.ts | 20 -- packages/rakit/src/title/PageTitle.tsx | 28 -- .../rakit/src/title/PageTitleConfigurable.tsx | 37 --- packages/rakit/src/title/Title.tsx | 41 --- .../rakit/src/title/TitlePortalProvider.tsx | 19 -- packages/rakit/src/title/constants.ts | 4 - packages/rakit/src/title/index.ts | 8 - packages/rakit/src/title/types.ts | 9 - 66 files changed, 1021 insertions(+), 1490 deletions(-) delete mode 100644 apps/simple/package.json delete mode 100644 packages/joy-ui/src/layout/AdminRoot.tsx create mode 100644 packages/joy-ui/src/layout/Layout.tsx create mode 100644 packages/joy-ui/src/layout/PageDock.tsx create mode 100644 packages/joy-ui/src/layout/PageTitle.tsx delete mode 100644 packages/joy-ui/src/layout/TitlePortal.tsx delete mode 100644 packages/rakit/src/accessControl/AccessControlContext.ts delete mode 100644 packages/rakit/src/accessControl/AccessControlProvider.tsx delete mode 100644 packages/rakit/src/accessControl/CanAccess.tsx delete mode 100644 packages/rakit/src/accessControl/index.ts delete mode 100644 packages/rakit/src/accessControl/types.ts delete mode 100644 packages/rakit/src/accessControl/useAccessControl.ts delete mode 100644 packages/rakit/src/accessControl/useCan.ts create mode 100644 packages/rakit/src/auth/AccessControlContext.ts create mode 100644 packages/rakit/src/auth/AccessControlProvider.tsx create mode 100644 packages/rakit/src/auth/CanAccess.tsx create mode 100644 packages/rakit/src/auth/useCan.ts rename packages/rakit/src/{routing/AppRouter.tsx => core/AdminRouter.tsx} (72%) rename packages/rakit/src/{routing => core}/BasenameContext.ts (100%) rename packages/rakit/src/{routing => core}/BasenameContextProvider.tsx (100%) create mode 100644 packages/rakit/src/core/CoreAdmin.tsx create mode 100644 packages/rakit/src/core/CoreAdminUI.tsx delete mode 100644 packages/rakit/src/core/CoreAppContext.tsx delete mode 100644 packages/rakit/src/core/CoreAppRoutes.tsx create mode 100644 packages/rakit/src/core/DefaultError.tsx rename packages/rakit/src/{title => core}/DefaultTitleContext.ts (75%) rename packages/rakit/src/{title => core}/DefaultTitleContextProvider.tsx (100%) rename packages/rakit/src/{errorBoundary => core}/ErrorBoundary.tsx (100%) create mode 100644 packages/rakit/src/core/ErrorContext.ts create mode 100644 packages/rakit/src/core/ErrorContextProvider.tsx delete mode 100644 packages/rakit/src/core/InAdminContext.tsx delete mode 100644 packages/rakit/src/core/InAppContext.tsx rename packages/rakit/src/{routing => core}/useBasename.ts (100%) rename packages/rakit/src/core/{useConfigureRoutesFromChildren.tsx => useConfigureAdminRouterFromChildren.tsx} (63%) rename packages/rakit/src/{title => core}/useDefaultTitle.ts (100%) rename packages/rakit/src/{errorBoundary => core}/useErrorContext.ts (79%) rename packages/rakit/src/{routing => core}/useRedirect.ts (100%) rename packages/rakit/src/{routing => core}/useResetErrorBoundaryOnLocationChange.ts (72%) delete mode 100644 packages/rakit/src/errorBoundary/ErrorContext.ts delete mode 100644 packages/rakit/src/errorBoundary/ErrorContextProvider.tsx delete mode 100644 packages/rakit/src/errorBoundary/index.ts delete mode 100644 packages/rakit/src/routing/InitialLocationContext.ts delete mode 100644 packages/rakit/src/routing/InitialLocationContextProvider.tsx delete mode 100644 packages/rakit/src/routing/Route.tsx delete mode 100644 packages/rakit/src/routing/index.ts delete mode 100644 packages/rakit/src/routing/useInitialLocation.ts delete mode 100644 packages/rakit/src/routing/useInitialLocationContext.ts delete mode 100644 packages/rakit/src/title/PageTitle.tsx delete mode 100644 packages/rakit/src/title/PageTitleConfigurable.tsx delete mode 100644 packages/rakit/src/title/Title.tsx delete mode 100644 packages/rakit/src/title/TitlePortalProvider.tsx delete mode 100644 packages/rakit/src/title/constants.ts delete mode 100644 packages/rakit/src/title/index.ts delete mode 100644 packages/rakit/src/title/types.ts diff --git a/apps/imsfe/src/App.tsx b/apps/imsfe/src/App.tsx index 672e80d..fd3af10 100644 --- a/apps/imsfe/src/App.tsx +++ b/apps/imsfe/src/App.tsx @@ -1,10 +1,11 @@ -import { CoreAdminContext, CoreAppContext, CoreLayoutProps, Route } from '@rakit/core'; -import { useFetch } from '@rakit/fetch'; +import { CoreAdmin, CoreLayoutProps } from '@rakit/core'; +import { FetchContextProvider, useFetch } from '@rakit/fetch'; import { Error, Loading, ThemeProvider } from '@rakit/joy-ui'; import { createAuthProvider } from './config/createAuthProvider'; import { createDataProvider } from './config/createDataProvider'; import { i18nProvider } from './config/i18nProvider'; import { lazy, Suspense } from 'react'; +import { Route } from 'react-router-dom'; const AppLoading = () => @@ -22,9 +23,11 @@ const PageError = lazy(() => import("./pages/PageError")); export default function App() { return ( - - - + + + + + ); } @@ -35,14 +38,14 @@ function AppContext() { const dataProvider = createDataProvider(fetch); return ( - } dataProvider={dataProvider} error={Error} - homepage={} + dashboard={} initialLocation="/" i18nProvider={i18nProvider} loading={AppLoading} @@ -54,25 +57,17 @@ function AppContext() { title="进销存系统" > } /> 哈哈哈哈} /> - - 哈哈222哈哈} - /> - - + 哈哈222哈哈} + /> + ) } diff --git a/apps/simple/package.json b/apps/simple/package.json deleted file mode 100644 index bd75d56..0000000 --- a/apps/simple/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "simple-admin", - "type": "module", - "description": "low level admin & dashboard scaffold", - "dependencies": { - "@rakit/core": "workspace:*", - "@rakit/use-async": "workspace:*", - "@rakit/use-fetch": "workspace:*", - "@rakit/use-invariant": "workspace:*", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.26.1" - } -} diff --git a/packages/joy-ui/src/layout/AdminRoot.tsx b/packages/joy-ui/src/layout/AdminRoot.tsx deleted file mode 100644 index 052c8df..0000000 --- a/packages/joy-ui/src/layout/AdminRoot.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { CssVarsProvider } from '@mui/joy/styles'; -import CssBaseline from '@mui/joy/CssBaseline'; -import Box from '@mui/joy/Box'; - -import { AppBar } from './AppBar'; -import { Sidebar } from './Sidebar'; -import { Outlet } from 'react-router-dom'; - -export function AdminRoot() { - return ( - - - - - - - - - - - ); -} diff --git a/packages/joy-ui/src/layout/Layout.tsx b/packages/joy-ui/src/layout/Layout.tsx new file mode 100644 index 0000000..94c1581 --- /dev/null +++ b/packages/joy-ui/src/layout/Layout.tsx @@ -0,0 +1,35 @@ +import Box from '@mui/joy/Box'; + +import { AppBar } from './AppBar'; +import { Sidebar } from './Sidebar'; +import { Outlet } from 'react-router-dom'; + +export function Layout() { + return ( + + + + + + + + ); +} diff --git a/packages/joy-ui/src/layout/PageDock.tsx b/packages/joy-ui/src/layout/PageDock.tsx new file mode 100644 index 0000000..fc1d028 --- /dev/null +++ b/packages/joy-ui/src/layout/PageDock.tsx @@ -0,0 +1,18 @@ +import { Portlet } from "@rakit/core"; +import Box, { BoxProps } from "@mui/joy/Box"; + +export interface PageDockProps extends BoxProps { } + +export function PageDock(props: PageDockProps) { + if (props.children == null) { + return null; + } + + return ( + + + + ); +} diff --git a/packages/joy-ui/src/layout/PageRoot.tsx b/packages/joy-ui/src/layout/PageRoot.tsx index f154418..c409331 100644 --- a/packages/joy-ui/src/layout/PageRoot.tsx +++ b/packages/joy-ui/src/layout/PageRoot.tsx @@ -6,7 +6,7 @@ import Typography from '@mui/joy/Typography'; import HomeRoundedIcon from '@mui/icons-material/HomeRounded'; import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded'; import { ReactNode, useState } from 'react'; -import { PortalProvider, TitlePortalProvider } from '@rakit/core'; +import { PortalProvider } from '@rakit/core'; export interface PageRootProps { children: ReactNode; @@ -86,14 +86,17 @@ export function PageRoot(props: PageRootProps) { /> - + {props.children} - + ) } diff --git a/packages/joy-ui/src/layout/PageTitle.tsx b/packages/joy-ui/src/layout/PageTitle.tsx new file mode 100644 index 0000000..e62af21 --- /dev/null +++ b/packages/joy-ui/src/layout/PageTitle.tsx @@ -0,0 +1,52 @@ +import { + Portlet, + useDefaultTitle, + useRecordRepresentation, + useTranslate +} from "@rakit/core"; +import { ReactElement, JSX } from "react"; + +export interface PageTitleProps extends JSX.ElementAttributesProperty { + record?: any; + preferenceKey?: string | false; + title?: string | ReactElement; +} + +export function PageTitle(props: PageTitleProps) { + const { + record, + preferenceKey, + title, + ...rest + } = props; + + const translate = useTranslate(); + const defaultTitle = useDefaultTitle(); + const titleFromPreferences = useRecordRepresentation({ + record, + representation: preferenceKey === false ? undefined : preferenceKey, + }); + + if (!title && !titleFromPreferences && !defaultTitle) { + return null; + } + + return ( + + + { + titleFromPreferences + ? translate( + titleFromPreferences, + { ...record, _: titleFromPreferences }, + ) + : !title + ? defaultTitle + : typeof title === "string" + ? translate(title, { _: title }) + : title + } + + + ); +} diff --git a/packages/joy-ui/src/layout/StatusError.tsx b/packages/joy-ui/src/layout/StatusError.tsx index 29d7c48..39ede59 100644 --- a/packages/joy-ui/src/layout/StatusError.tsx +++ b/packages/joy-ui/src/layout/StatusError.tsx @@ -112,15 +112,19 @@ const statusDescriptions = { "500": "There was an error, please try again later.", }; +const isStatus = (status: string | undefined): status is keyof typeof statusIcons => { + return !!status && Object.hasOwn(statusIcons, status); +} + export interface StatusErrorProps { - status: "403" | "404" | "500"; + status?: string; icon?: ReactNode; title?: ReactNode; description?: ReactNode; } export function StatusError(props: StatusErrorProps) { - const status = statusIcons[props.status] ? props.status : "500"; + const status = isStatus(props.status) ? props.status : "500"; const siteTitle = useDefaultTitle(); const icon = props.icon || statusIcons[status]; const title = props.title || statusTitles[status]; diff --git a/packages/joy-ui/src/layout/TitlePortal.tsx b/packages/joy-ui/src/layout/TitlePortal.tsx deleted file mode 100644 index 20d37f0..0000000 --- a/packages/joy-ui/src/layout/TitlePortal.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import Typography, { TypographyProps } from '@mui/joy/Typography'; - -export function TitlePortal(props: TypographyProps) { - return ( - - ); -} diff --git a/packages/joy-ui/src/layout/index.ts b/packages/joy-ui/src/layout/index.ts index a04dd3a..05617f1 100644 --- a/packages/joy-ui/src/layout/index.ts +++ b/packages/joy-ui/src/layout/index.ts @@ -1,12 +1,11 @@ -export * from "./AdminRoot"; export * from "./AppBar"; export * from "./ColorSchemeToggle"; export * from "./Error"; +export * from "./Layout"; export * from "./Loading"; export * from "./Notification"; export * from "./PageActions"; export * from "./PageRoot"; export * from "./Sidebar"; export * from "./StatusError"; -export * from "./TitlePortal"; export * from "./utils"; diff --git a/packages/rakit/src/accessControl/AccessControlContext.ts b/packages/rakit/src/accessControl/AccessControlContext.ts deleted file mode 100644 index 3f0abac..0000000 --- a/packages/rakit/src/accessControl/AccessControlContext.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createContext } from "react"; -import { AccessControlContextValue } from "./types"; - -/** - * @private - */ -export const AccessControlContext = createContext(undefined); diff --git a/packages/rakit/src/accessControl/AccessControlProvider.tsx b/packages/rakit/src/accessControl/AccessControlProvider.tsx deleted file mode 100644 index 2b7a12d..0000000 --- a/packages/rakit/src/accessControl/AccessControlProvider.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { ReactNode } from "react"; -import { AccessControlContext } from "./AccessControlContext"; -import { CanFunction } from "./types"; - -export type AccessControlProviderProps = { - can?: CanFunction; - children?: ReactNode; -} - -export function AccessControlProvider(props: AccessControlProviderProps) { - return ( - - {props.children} - - ) -} diff --git a/packages/rakit/src/accessControl/CanAccess.tsx b/packages/rakit/src/accessControl/CanAccess.tsx deleted file mode 100644 index ccd04a5..0000000 --- a/packages/rakit/src/accessControl/CanAccess.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { - cloneElement, - createElement, - isValidElement, - ReactNode, - useEffect -} from "react"; -import { - AccessControlOptions, - AccessFallbackComponent, - CanParams -} from "./types"; -import { useCan } from "./useCan"; -import { useAccessControl } from "./useAccessControl"; - -type OnUnauthorizedProps = { - reason?: string; - params: CanParams; -}; - -export interface CanAccessProps extends CanParams { - children: ReactNode; - - /** - * Content to show if access control returns `false` - */ - fallback?: AccessFallbackComponent; - - loading?: ReactNode - - queryOptions?: AccessControlOptions; - - /** - * Callback function to be called if access control returns `can: false` - */ - onUnauthorized?: (props: OnUnauthorizedProps) => void; -} - -export function CanAccess(props: CanAccessProps) { - const { - children, - fallback, - loading, - queryOptions, - onUnauthorized, - ...params - } = props; - - const { - isExecuting: isLoading, - reason, - can, - error, - } = useCan({ - ...params, - queryOptions, - }); - - const { - fallback: fallbackElement, - } = useAccessControl({}); - - useEffect(() => { - if (onUnauthorized && can === false && !isLoading) { - onUnauthorized({ reason, params }); - } - }, [can, isLoading]); - - if (isLoading) { - return loading; - } - - if (can) { - return children; - } - - return resolveFallback( - fallback ?? fallbackElement, - reason, - error, - ); -} - -function resolveFallback( - fallback: AccessFallbackComponent | undefined | null, - reason: string | undefined, - error: unknown, -) { - if (fallback == null) { - return null; - } - if (isValidElement(fallback)) { - return cloneElement(fallback, { reason, error }) - } - return createElement(fallback, { reason, error }) -} diff --git a/packages/rakit/src/accessControl/index.ts b/packages/rakit/src/accessControl/index.ts deleted file mode 100644 index 42577b9..0000000 --- a/packages/rakit/src/accessControl/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./AccessControlProvider"; -export * from "./CanAccess"; -export * from "./types"; -export * from "./useAccessControl"; -export * from "./useCan"; diff --git a/packages/rakit/src/accessControl/types.ts b/packages/rakit/src/accessControl/types.ts deleted file mode 100644 index 6e51f01..0000000 --- a/packages/rakit/src/accessControl/types.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { UseAsyncOptions } from "@rakit/use-async"; -import { ComponentType, ReactElement } from "react"; - -export type CanReturn = { - can: boolean; - reason?: string; -}; - -export interface AccessParamsCustoms {} - -export type AccessParams = Record & AccessParamsCustoms; - -export interface CanParams { - /** - * Resource name for API data interactions - */ - on?: string; - /** - * Intended action on resource - */ - key: string; - /** - * Parameters associated with the resource - * @type { - * resource?: [IResourceItem](https://refine.dev/docs/api-reference/core/interfaceReferences/#canparams), - * id?: [BaseKey](https://refine.dev/docs/api-reference/core/interfaceReferences/#basekey), [key: string]: any - * } - */ - params?: AccessParams; -} - -export type CanFunction = (params: CanParams) => Promise | CanReturn; - -export type AccessControlOptions = Omit< - UseAsyncOptions, - 'executor' | 'variables' ->; - -export type AccessFallbackProps = { - reason?: string; - error?: unknown; -} - -export type AccessFallbackComponent = ComponentType | ReactElement; - -export interface AccessControlContextCustomValue {} - -export interface AccessControlContextValue extends AccessControlContextCustomValue { - can?: CanFunction; - queryOptions?: AccessControlOptions; - fallback?: AccessFallbackComponent; -} diff --git a/packages/rakit/src/accessControl/useAccessControl.ts b/packages/rakit/src/accessControl/useAccessControl.ts deleted file mode 100644 index 4430422..0000000 --- a/packages/rakit/src/accessControl/useAccessControl.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useContext } from "react"; -import { AccessControlContext } from "./AccessControlContext"; -import { AccessControlContextValue } from "./types"; - -export function useAccessControl(): AccessControlContextValue | undefined; -export function useAccessControl(overrides: AccessControlContextValue): AccessControlContextValue; -export function useAccessControl(overrides?: AccessControlContextValue) { - const fromContext = useContext(AccessControlContext) - - if (fromContext != null) { - return { - ...fromContext, - ...overrides, - } - } - - return overrides; -} diff --git a/packages/rakit/src/accessControl/useCan.ts b/packages/rakit/src/accessControl/useCan.ts deleted file mode 100644 index 8bcd9ff..0000000 --- a/packages/rakit/src/accessControl/useCan.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { - useAsync, - UseAsyncResult, - useAutomatic -} from "@rakit/use-async"; -import { - AccessControlOptions, - CanParams, - CanReturn -} from "./types"; -import { useAccessControl } from "./useAccessControl"; -import { useMemo } from "react"; - -export type UseCanProps = CanParams & { - queryOptions?: AccessControlOptions; -} - -export type UseCanResult = - & Omit< - UseAsyncResult, - 'execute' | 'abort' | 'data' | 'isExecuting' - > - & { isLoading: boolean } - & CanReturn; - -export function useCan(options: UseCanProps): UseCanResult { - const { queryOptions, ...params } = options; - const context = useAccessControl({}); - const can = context?.can - - const asyncOptions = { - ...context?.queryOptions, - ...queryOptions, - } - - const { - data, - error, - isExecuting: isLoading, - execute, - } = useAsync({ - ...asyncOptions, - variables: params, - executor: (params) => can?.(params) ?? ({ can: true }), - immediate: typeof can !== "undefined", - }); - - useAutomatic({ - execute, - params, - asyncOptions, - }); - - return useMemo(() => { - if (typeof can === "undefined") { - return { - can: true, - isLoading: false, - error: undefined, - reason: undefined, - } - } - return { - can: data?.can ?? false, - error, - isLoading, - reason: data?.reason, - } - }, [can, data, error, isLoading]); -} diff --git a/packages/rakit/src/auth/AccessControlContext.ts b/packages/rakit/src/auth/AccessControlContext.ts new file mode 100644 index 0000000..470942c --- /dev/null +++ b/packages/rakit/src/auth/AccessControlContext.ts @@ -0,0 +1,21 @@ +import { createContext } from "react"; +import { + AccessFallbackComponent, + CanAccessOptions, + CanAccessResult, + Permission +} from "./types"; + +/** + * @private + */ +export interface AccessControlContextValue { + canAccess?: (options: CanAccessOptions) => CanAccessResult; + permissions?: Permission[]; + accessFallback?: AccessFallbackComponent; +} + +/** + * @private + */ +export const AccessControlContext = createContext({}); diff --git a/packages/rakit/src/auth/AccessControlProvider.tsx b/packages/rakit/src/auth/AccessControlProvider.tsx new file mode 100644 index 0000000..8bc8868 --- /dev/null +++ b/packages/rakit/src/auth/AccessControlProvider.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from "react"; +import { AccessControlContext } from "./AccessControlContext"; +import { + AccessFallbackComponent, + CanAccessOptions, + CanAccessResult +} from "./types"; +import { useAuthProvider } from "./useAuthProvider"; +import { usePermissions } from "./usePermissions"; + +interface AccessControlProviderProps { + accessFallback?: AccessFallbackComponent; + canAccess?: (options: CanAccessOptions) => CanAccessResult; + children?: ReactNode; +} + +export function AccessControlProvider(props: AccessControlProviderProps) { + const authProvider = useAuthProvider(); + const { + accessFallback = authProvider?.accessFallback, + canAccess = authProvider?.canAccess, + children + } = props; + const { permissions } = usePermissions(); + + return ( + + {children} + + ); +} diff --git a/packages/rakit/src/auth/CanAccess.tsx b/packages/rakit/src/auth/CanAccess.tsx new file mode 100644 index 0000000..67872a9 --- /dev/null +++ b/packages/rakit/src/auth/CanAccess.tsx @@ -0,0 +1,40 @@ +import { + createElement, + ReactNode, + useContext +} from "react"; +import { AccessControlContext } from "./AccessControlContext"; +import { + AccessFallbackComponent, + PermissionIdentifier, + PermissionTarget +} from "./types"; +import { useCan } from "./useCan"; + +export interface CanAccessProps { + target: PermissionTarget; + identifier: PermissionIdentifier; + fallback?: AccessFallbackComponent; + children?: ReactNode; +} + +export function CanAccess(props: CanAccessProps) { + const { target, identifier } = props; + const { can, reason } = useCan(target, identifier); + const { accessFallback } = useContext(AccessControlContext); + const { children, fallback = accessFallback } = props; + + if (can) { + return children; + } + + if (fallback == null) { + return null; + } + + return createElement(fallback, { + reason, + target, + identifier + }) +} diff --git a/packages/rakit/src/auth/index.ts b/packages/rakit/src/auth/index.ts index a393045..81fc1fb 100644 --- a/packages/rakit/src/auth/index.ts +++ b/packages/rakit/src/auth/index.ts @@ -1,10 +1,13 @@ +export * from "./AccessControlProvider"; export * from "./AuthContext"; export * from "./Authenticated"; +export * from "./CanAccess"; export * from "./LogoutOnMount"; export * from "./types"; export * from "./useAuthenticated"; export * from "./useAuthProvider"; export * from "./useAuthState"; +export * from "./useCan"; export * from "./useCheckAuth"; export * from "./useGetIdentity"; export * from "./useGetPermissions"; diff --git a/packages/rakit/src/auth/types.ts b/packages/rakit/src/auth/types.ts index f2f82a8..3ab38d1 100644 --- a/packages/rakit/src/auth/types.ts +++ b/packages/rakit/src/auth/types.ts @@ -1,3 +1,4 @@ +import { ComponentType } from "react"; import { To } from "react-router-dom"; type QueryFunctionContext = { @@ -22,6 +23,41 @@ export interface UserIdentity { [key: string]: any; } +export type PermissionIdentifier = string | number; + +export type PermissionTarget = + | 'route' + | 'menu' + | 'view' + | 'element' + | 'api' + | string; + +export interface Permission { + identifier: PermissionIdentifier; + target: PermissionTarget + [key: string]: any; +} + +export interface CanAccessOptions { + permissions: Permission[]; + target: PermissionTarget; + identifier: string | number; +} + +export interface CanAccessResult { + can: boolean; + reason?: string; +} + +export type AccessFallbackProps = { + reason?: string; + target: PermissionTarget; + identifier: PermissionIdentifier; +} + +export type AccessFallbackComponent = ComponentType; + export interface AuthProviderCustoms {} export interface AuthProvider extends AuthProviderCustoms { @@ -29,7 +65,9 @@ export interface AuthProvider extends AuthProviderCustoms { logout: (params: any) => Promise; checkAuth: (params: QueryFunctionContext) => Promise; checkError: (error: any) => Promise; + canAccess?: (options: CanAccessOptions) => CanAccessResult; + accessFallback?: AccessFallbackComponent; getIdentity?: (params?: QueryFunctionContext) => Promise; - getPermissions: (params: QueryFunctionContext) => Promise; + getPermissions: (params: QueryFunctionContext) => Promise; handleCallback?: (params?: QueryFunctionContext) => Promise; } diff --git a/packages/rakit/src/auth/useCan.ts b/packages/rakit/src/auth/useCan.ts new file mode 100644 index 0000000..e076080 --- /dev/null +++ b/packages/rakit/src/auth/useCan.ts @@ -0,0 +1,57 @@ +import { + useContext, + useEffect, + useMemo, + useState +} from "react"; +import { + CanAccessResult, + PermissionIdentifier, + PermissionTarget +} from "./types"; +import { AccessControlContext } from "./AccessControlContext"; + +export function useCan( + target: PermissionTarget, + identifier: PermissionIdentifier, +): CanAccessResult { + const { canAccess, permissions } = useContext(AccessControlContext); + const [can, setCan] = useState(false); + const [reason, setReason] = useState(); + + useEffect(() => { + let can = false; + let reason: string | undefined = "forbidden"; + if (permissions?.length) { + for (const perm of permissions) { + if (perm.identifier !== identifier) { + continue; + } + if (perm.target !== target) { + break; + } + if (canAccess) { + ({ can, reason } = canAccess({ + target, + permissions, + identifier, + })); + } + } + } + setCan(can) + setReason(reason) + }, [ + permissions, + canAccess, + target, + identifier, + ]); + + return useMemo(() => { + return { + can, + reason, + } + }, [can, reason]); +} diff --git a/packages/rakit/src/routing/AppRouter.tsx b/packages/rakit/src/core/AdminRouter.tsx similarity index 72% rename from packages/rakit/src/routing/AppRouter.tsx rename to packages/rakit/src/core/AdminRouter.tsx index 514006b..bcb9f4d 100644 --- a/packages/rakit/src/routing/AppRouter.tsx +++ b/packages/rakit/src/core/AdminRouter.tsx @@ -8,14 +8,14 @@ import { BasenameContextProvider } from './BasenameContextProvider'; export interface AppRouterProps { basename?: string; - children: React.ReactNode; + children: ReactNode; } /** * Creates a react-router Router unless the app is already inside existing router. * Also creates a BasenameContext with the basename prop */ -export const AppRouter = ({ basename = '', children }: AppRouterProps) => { +export function AdminRouter({ basename = '', children }: AppRouterProps) { const isInRouter = useInRouterContext(); const Router = isInRouter ? DummyRouter : InternalRouter; @@ -24,25 +24,16 @@ export const AppRouter = ({ basename = '', children }: AppRouterProps) => { {children} ); -}; +} -const DummyRouter = ({ - children, -}: { - children: ReactNode; - basename?: string; -}) => <>{children}; +function DummyRouter({ children }: AppRouterProps) { + return <>{children}; +} -const InternalRouter = ({ - children, - basename, -}: { - children: ReactNode; - basename?: string; -}) => { +function InternalRouter({ basename, children }: AppRouterProps) { const router = createHashRouter( [{ path: '*', element: <>{children} }], - { basename }, + { basename } ); return ; -}; +} diff --git a/packages/rakit/src/routing/BasenameContext.ts b/packages/rakit/src/core/BasenameContext.ts similarity index 100% rename from packages/rakit/src/routing/BasenameContext.ts rename to packages/rakit/src/core/BasenameContext.ts diff --git a/packages/rakit/src/routing/BasenameContextProvider.tsx b/packages/rakit/src/core/BasenameContextProvider.tsx similarity index 100% rename from packages/rakit/src/routing/BasenameContextProvider.tsx rename to packages/rakit/src/core/BasenameContextProvider.tsx diff --git a/packages/rakit/src/core/CoreAdmin.tsx b/packages/rakit/src/core/CoreAdmin.tsx new file mode 100644 index 0000000..e8283d5 --- /dev/null +++ b/packages/rakit/src/core/CoreAdmin.tsx @@ -0,0 +1,134 @@ +import { CoreAdminContext, CoreAdminContextProps } from './CoreAdminContext'; +import { CoreAdminUI, CoreAdminUIProps } from './CoreAdminUI'; + +export type CoreAdminProps = CoreAdminContextProps & CoreAdminUIProps; + +/** + * Main admin component, entry point to the application. + * + * Initializes the various contexts (auth, data, i18n, router) + * and defines the main routes. + * + * Expects a list of resources as children, or a function returning a list of + * resources based on the permissions. + * + * @example + * + * // static list of resources + * + * import { + * CoreAdmin, + * Resource, + * ListGuesser, + * useDataProvider, + * } from 'ra-core'; + * + * const App = () => ( + * + * + * + * ); + * + * // dynamic list of resources based on permissions + * + * import { + * CoreAdmin, + * Resource, + * ListGuesser, + * useDataProvider, + * } from 'ra-core'; + * + * const App = () => ( + * + * {permissions => [ + * , + * ]} + * + * ); + * + * // If you have to build a dynamic list of resources using a side effect, + * // you can't use . But as it delegates to sub components, + * // it's relatively straightforward to replace it: + * + * import * as React from 'react'; + * import { useEffect, useState } from 'react'; + * import { + * CoreAdminContext, + * CoreAdminUI, + * Resource, + * ListGuesser, + * useDataProvider, + * } from 'ra-core'; + * + * const App = () => ( + * + * + * + * ); + * + * const UI = () => { + * const [resources, setResources] = useState([]); + * const dataProvider = useDataProvider(); + * useEffect(() => { + * dataProvider.introspect().then(r => setResources(r)); + * }, []); + * + * return ( + * + * {resources.map(resource => ( + * + * ))} + * + * ); + * }; + */ +export const CoreAdmin = (props: CoreAdminProps) => { + const { + authCallbackPage, + authProvider, + basename, + catchAll, + children, + dashboard, + dataProvider, + disableTelemetry, + error, + i18nProvider, + initialLocation, + queryClient, + layout, + loading, + loginPage, + ready, + requireAuth, + store, + title = 'React Admin', + } = props; + return ( + + + {children} + + + ); +}; diff --git a/packages/rakit/src/core/CoreAdminContext.tsx b/packages/rakit/src/core/CoreAdminContext.tsx index 84a6e2f..fa5364a 100644 --- a/packages/rakit/src/core/CoreAdminContext.tsx +++ b/packages/rakit/src/core/CoreAdminContext.tsx @@ -1,12 +1,28 @@ -import { ComponentType, ReactElement } from "react"; -import { Route, Routes } from "react-router-dom"; -import { ErrorBoundary } from "../errorBoundary"; -import { DefaultTitleContextProvider } from "../title"; -import { CoreAdminRoutes, CoreAdminRoutesProps } from "./CoreAdminRoutes"; -import { InAdminContext, useInAdminContext } from "./InAdminContext"; -import { useInAppContext } from "./InAppContext"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactNode, useMemo } from "react"; +import { AuthContext, AuthProvider } from "../auth"; +import { DataProvider, DataProviderContext, defaultDataProvider } from "../data"; +import { I18nContextProvider, I18nProvider } from "../i18n"; +import { NotificationContextProvider } from "../notification"; +import { defaultStore, Store, StoreContextProvider } from "../store"; +import { AdminRouter } from "./AdminRouter"; + +export interface CoreAdminContextProps { + /** + * The authentication provider for security and permissions + * + * @see https://marmelab.com/react-admin/Authentication.html + * @example + * import authProvider from './authProvider'; + * + * const App = () => ( + * + * ... + * + * ); + */ + authProvider?: AuthProvider; -export interface CoreAdminContextProps extends CoreAdminRoutesProps { /** * The base path for all URLs generated by react-admin. * @@ -24,92 +40,126 @@ export interface CoreAdminContextProps extends CoreAdminRoutesProps { * * ); */ - basepath?: string; + basename?: string; + + children?: ReactNode; /** - * The component displayed when an error is caught in a child component - * @see https://marmelab.com/react-admin/Admin.html#error + * The data provider used to communicate with the API + * + * @see https://marmelab.com/react-admin/DataProviders.html * @example * import { Admin } from 'react-admin'; - * import { MyError } from './error'; + * import simpleRestProvider from 'ra-data-simple-rest'; + * const dataProvider = simpleRestProvider('http://path.to.my.api/'); * * const App = () => ( - * + * * ... * * ); */ - error?: ComponentType | ReactElement | null; + dataProvider?: DataProvider; /** - * The title of the error page - * @see https://marmelab.com/react-admin/Admin.html#title + * The internationalization provider for translations + * + * @see https://marmelab.com/react-admin/Translation.html * @example + * // in src/i18nProvider.js + * import polyglotI18nProvider from 'ra-i18n-polyglot'; + * import fr from 'ra-language-french'; + * + * export const i18nProvider = polyglotI18nProvider(() => fr, 'fr'); + * + * // in src/App.js * import { Admin } from 'react-admin'; * import { dataProvider } from './dataProvider'; + * import { i18nProvider } from './i18nProvider'; + * + * const App = () => ( + * + * ... + * + * ); + */ + i18nProvider?: I18nProvider; + + /** + * The react-query client + * + * @see https://marmelab.com/react-admin/Admin.html#queryclient + * @example + * import { Admin } from 'react-admin'; + * import { QueryClient } from '@tanstack/react-query'; + * + * const queryClient = new QueryClient({ + * defaultOptions: { + * queries: { + * retry: false, + * structuralSharing: false, + * }, + * mutations: { + * retryDelay: 10000, + * }, + * }, + * }); * * const App = () => ( - * - * ... - * + * + * ... + * * ); */ - title?: string | ReactElement | null; + queryClient?: QueryClient; + + /** + * The adapter for storing user preferences + * + * @see https://marmelab.com/react-admin/Admin.html#store + * @example + * import { Admin, memoryStore } from 'react-admin'; + * + * const App = () => ( + * + * ... + * + * ); + */ + store?: Store; } export function CoreAdminContext(props: CoreAdminContextProps) { const { - catchAll, + authProvider, + basename, children, - dashboard, - error, - layout, - loading, - ready, - requireAuth, - title, + dataProvider = defaultDataProvider, + i18nProvider, + queryClient, + store = defaultStore, } = props; - const inApp = useInAppContext(); - const inAdmin = useInAdminContext(); - - if (!inApp) { - throw new Error( - "You've tried to access admin context outside .\n" + - "Please wrap your code with it first.\n" - ); - } - - if (inAdmin) { - throw new Error( - "You've tried to access admin context inside another .\n" + - "Please unwrap your code with it first.\n" - ); - } + const finalQueryClient = useMemo( + () => queryClient || new QueryClient(), + [queryClient] + ); return ( - - - - - + + + + + + + {children} - - } - /> - - - - + + + + + + + ); } diff --git a/packages/rakit/src/core/CoreAdminRoutes.tsx b/packages/rakit/src/core/CoreAdminRoutes.tsx index c4bd7e9..2c4d777 100644 --- a/packages/rakit/src/core/CoreAdminRoutes.tsx +++ b/packages/rakit/src/core/CoreAdminRoutes.tsx @@ -1,12 +1,12 @@ -import { ComponentType, ReactElement, ReactNode, useEffect, useState } from 'react'; +import { ComponentType, ReactElement, useEffect, useState } from 'react'; import { Navigate, Route, Routes, To } from 'react-router-dom'; import { LogoutOnMount, useCheckAuth } from '../auth'; -import { CoreLayoutProps } from './types'; +import { useScrollToTop } from '../scrollPosition'; import { getReactElement } from '../util'; import { DefaultLayout } from './DefaultLayout'; import { HasDashboardContextProvider } from './HasDashboardContextProvider'; -import { useConfigureRoutesFromChildren } from './useConfigureRoutesFromChildren'; -import { InitialLocationContextProvider } from '../routing'; +import { AdminChildren, CoreLayoutProps } from './types'; +import { useConfigureAdminRouterFromChildren } from './useConfigureAdminRouterFromChildren'; export interface CoreAdminRoutesProps { /** @@ -41,7 +41,7 @@ export interface CoreAdminRoutesProps { */ catchAll?: ComponentType | ReactElement | null; - children?: ReactNode; + children?: AdminChildren; /** * @example @@ -134,16 +134,18 @@ export interface CoreAdminRoutesProps { } export function CoreAdminRoutes(props: CoreAdminRoutesProps) { - const [routes, status] = useConfigureRoutesFromChildren(props.children); + useScrollToTop(); + + const [routes, status] = useConfigureAdminRouterFromChildren(props.children); const { catchAll: catchAllElement, - dashboard: dashboardElement, + dashboard, initialLocation, layout: Layout = DefaultLayout, loading: loadingElement, + requireAuth, ready: readyElement, - requireAuth = false, } = props; const [onlyAnonymousRoutes, setOnlyAnonymousRoutes] = useState(requireAuth); @@ -168,8 +170,8 @@ export function CoreAdminRoutes(props: CoreAdminRoutesProps) { if (status === 'empty') { if (!readyElement) { throw new Error( - 'The app is empty. Please provide an empty component, ' + - 'or pass Resource or CustomRoutes as children.' + 'The admin is empty. Please provide an empty component, ' + + 'or pass Route or CustomRoutes as children.' ); } return getReactElement(readyElement); @@ -206,34 +208,25 @@ export function CoreAdminRoutes(props: CoreAdminRoutesProps) { - - - - {routes} - {dashboardElement ? ( - ) - : null - } - /> - ) : null} - {catchAllElement ? ( - - ) : null} - - - + + + + {routes} + + {dashboard + ? () + : (initialLocation && initialLocation !== "/") + ? (} />) + : null} + + {catchAllElement ? ( + + ) : null} + + } /> diff --git a/packages/rakit/src/core/CoreAdminUI.tsx b/packages/rakit/src/core/CoreAdminUI.tsx new file mode 100644 index 0000000..d933fef --- /dev/null +++ b/packages/rakit/src/core/CoreAdminUI.tsx @@ -0,0 +1,293 @@ +import { ComponentType, ReactElement } from "react"; +import { Route, Routes, To } from "react-router-dom"; +import { DefaultTitleContextProvider } from "../title"; +import { getReactElement } from "../util"; +import { CoreAdminRoutes } from "./CoreAdminRoutes"; +import { DefaultError } from "./DefaultError"; +import { DefaultLayout } from "./DefaultLayout"; +import { ErrorBoundary } from "./ErrorBoundary"; +import { AdminChildren, CoreLayoutProps } from "./types"; + +export interface CoreAdminUIProps { + /** + * The content displayed when the user visits the /auth-callback page, used for redirection by third-party authentication providers + * + * @see https://marmelab.com/react-admin/Admin.html#authcallbackpage + * @example + * import { Admin } from 'react-admin'; + * import { dataProvider } from './dataProvider'; + * import { authProvider } from './authProvider'; + * import MyAuthCallbackPage from './MyAuthCallbackPage'; + * + * const App = () => ( + * + * ... + * + * ); + */ + authCallbackPage?: ComponentType | ReactElement | null; + + /** + * A catch-all react component to display when the URL does not match any + * + * @see https://marmelab.com/react-admin/Admin.html#catchall + * @example + * // in src/NotFound.js + * import Card from '@mui/material/Card'; + * import CardContent from '@mui/material/CardContent'; + * import { Title } from 'react-admin'; + * + * export const NotFound = () => ( + * + * + * <CardContent> + * <h1>404: Page not found</h1> + * </CardContent> + * </Card> + * ); + * + * // in src/App.js + * import { Admin } from 'react-admin'; + * import { dataProvider } from './dataProvider'; + * import { NotFound } from './NotFound'; + * + * const App = () => ( + * <Admin catchAll={NotFound} dataProvider={dataProvider}> + * ... + * </Admin> + * ); + */ + catchAll?: ComponentType<any> | ReactElement | null; + + children?: AdminChildren; + + /** + * The component to use for the dashboard page (displayed on the `/` route). + * + * @see https://marmelab.com/react-admin/Admin.html#dashboard + * @example + * import { Admin } from 'react-admin'; + * import Dashboard from './Dashboard'; + * import { dataProvider } from './dataProvider'; + * + * const App = () => ( + * <Admin dashboard={Dashboard} dataProvider={dataProvider}> + * ... + * </Admin> + * ); + */ + dashboard?: ComponentType | ReactElement | null; + + /** + * Set to true to disable anonymous telemetry collection + * + * @see https://marmelab.com/react-admin/Admin.html#disabletelemetry + * @example + * import { Admin } from 'react-admin'; + * import { dataProvider } from './dataProvider'; + * + * const App = () => ( + * <Admin disableTelemetry dataProvider={dataProvider}> + * ... + * </Admin> + * ); + */ + disableTelemetry?: boolean; + + /** + * The component displayed when an error is caught in a child component + * @see https://marmelab.com/react-admin/Admin.html#error + * @example + * import { Admin } from 'react-admin'; + * import { MyError } from './error'; + * + * const App = () => ( + * <Admin error={MyError}> + * ... + * </Admin> + * ); + */ + error?: ComponentType<any> | ReactElement | null; + + initialLocation?: To; + + /** + * The main app layout component + * + * @see https://marmelab.com/react-admin/Admin.html#layout + * @example + * import { Admin, Layout } from 'react-admin'; + * + * const MyLayout = ({ children }) => ( + * <Layout appBarAlwaysOn> + * {children} + * </Layout> + * ); + * + * export const App = () => ( + * <Admin dataProvider={dataProvider} layout={MyLayout}> + * ... + * </Admin> + * ); + */ + layout?: ComponentType<CoreLayoutProps>; + + /** + * The component displayed while fetching the auth provider if the admin child is an async function + */ + loading?: ComponentType<any> | ReactElement | null; + + /** + * The component displayed when the user visits the /login page + * @see https://marmelab.com/react-admin/Admin.html#loginpage + * @example + * import { Admin } from 'react-admin'; + * import { dataProvider } from './dataProvider'; + * import { authProvider } from './authProvider'; + * import MyLoginPage from './MyLoginPage'; + * + * const App = () => ( + * <Admin + * loginPage={MyLoginPage} + * authProvider={authProvider} + * dataProvider={dataProvider} + * > + * ... + * </Admin> + * ); + */ + loginPage?: ComponentType<any> | ReactElement | null; + + /** + * The page to display when the admin has no Resource children + * + * @see https://marmelab.com/react-admin/Admin.html#ready + * @example + * import { Admin } from 'react-admin'; + * + * const Ready = () => ( + * <div> + * <h1>Admin ready</h1> + * <p>You can now add resources</p> + * </div> + * ) + * + * const App = () => ( + * <Admin ready={Ready}> + * ... + * </Admin> + * ); + */ + ready?: ComponentType<any> | ReactElement | null; + + /** + * Flag to require authentication for all routes. Defaults to false. + * + * @see https://marmelab.com/react-admin/Admin.html#requireauth + * @example + * import { Admin } from 'react-admin'; + * import { dataProvider } from './dataProvider'; + * import { authProvider } from './authProvider'; + * + * const App = () => ( + * <Admin + * requireAuth + * authProvider={authProvider} + * dataProvider={dataProvider} + * > + * ... + * </Admin> + * ); + */ + requireAuth?: boolean; + + /** + * The title of the error page + * @see https://marmelab.com/react-admin/Admin.html#title + * @example + * import { Admin } from 'react-admin'; + * import { dataProvider } from './dataProvider'; + * + * const App = () => ( + * <Admin title="My Admin" dataProvider={dataProvider}> + * ... + * </Admin> + * ); + */ + title?: string | ReactElement | null; +} + +export const CoreAdminUI = (props: CoreAdminUIProps) => { + const { + authCallbackPage, + catchAll, + children, + dashboard, + // disableTelemetry = false, + error = DefaultError, + initialLocation, + layout = DefaultLayout, + loading, + loginPage, + ready = Ready, + requireAuth = false, + title = 'React Admin', + } = props; + + // useEffect(() => { + // if ( + // disableTelemetry || + // process.env.NODE_ENV !== 'production' || + // typeof window === 'undefined' || + // typeof window.location === 'undefined' || + // typeof Image === 'undefined' + // ) { + // return; + // } + // const img = new Image(); + // img.src = `https://react-admin-telemetry.marmelab.com/react-admin-telemetry?domain=${window.location.hostname}`; + // }, [disableTelemetry]); + + return ( + <DefaultTitleContextProvider value={title}> + <ErrorBoundary error={error}> + <Routes> + {loginPage != null ? ( + <Route + path="/login" + element={getReactElement(loginPage)} + /> + ) : null} + + {authCallbackPage != null ? ( + <Route + path="/auth-callback" + element={getReactElement(authCallbackPage)} + /> + ) : null} + + <Route + path="/*" + element={ + <CoreAdminRoutes + catchAll={catchAll} + dashboard={dashboard} + initialLocation={initialLocation} + layout={layout} + loading={loading} + ready={ready} + requireAuth={requireAuth} + > + {children} + </CoreAdminRoutes> + } + /> + </Routes> + </ErrorBoundary> + </DefaultTitleContextProvider> + ); +}; diff --git a/packages/rakit/src/core/CoreAppContext.tsx b/packages/rakit/src/core/CoreAppContext.tsx deleted file mode 100644 index 0be8a93..0000000 --- a/packages/rakit/src/core/CoreAppContext.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import { ComponentType, ReactElement, useMemo } from "react"; -import { Route as ReactRoute, Routes } from "react-router-dom"; -import { CoreAppRoutes, CoreAppRoutesProps } from "./CoreAppRoutes"; -import { defaultStore, Store, StoreContextProvider } from "../store"; -import { AuthContext, AuthProvider } from "../auth"; -import { I18nContextProvider, I18nProvider } from "../i18n"; -import { NotificationContextProvider } from "../notification"; -import { AppRouter } from "../routing"; -import { DefaultTitleContextProvider } from "../title"; -import { ErrorBoundary } from "../errorBoundary"; -import { DataProvider, DataProviderContext } from "../data"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -export interface CoreAppContextProps extends CoreAppRoutesProps { - /** - * The authentication provider for security and permissions - * - * @see https://marmelab.com/react-admin/Authentication.html - * @example - * import authProvider from './authProvider'; - * - * const App = () => ( - * <Admin authProvider={authProvider}> - * ... - * </Admin> - * ); - */ - authProvider?: AuthProvider; - - /** - * The content displayed when the user visits the /auth-callback page, used for redirection by third-party authentication providers - * - * @see https://marmelab.com/react-admin/Admin.html#authcallbackpage - * @example - * import { Admin } from 'react-admin'; - * import { dataProvider } from './dataProvider'; - * import { authProvider } from './authProvider'; - * import MyAuthCallbackPage from './MyAuthCallbackPage'; - * - * const App = () => ( - * <Admin - * authCallbackPage={MyAuthCallbackPage} - * authProvider={authProvider} - * dataProvider={dataProvider} - * > - * ... - * </Admin> - * ); - */ - authCallbackPage?: ComponentType<any> | null; - - /** - * The base path for all URLs generated by react-admin. - * - * @see https://marmelab.com/react-admin/Admin.html#using-react-admin-in-a-sub-path - * @example - * import { Admin } from 'react-admin'; - * import { BrowserRouter } from 'react-router-dom'; - * import { dataProvider } from './dataProvider'; - * - * const App = () => ( - * <BrowserRouter> - * <Admin basename="/admin" dataProvider={dataProvider}> - * ... - * </Admin> - * </BrowserRouter> - * ); - */ - basename?: string; - - dataProvider?: DataProvider; - - /** - * The component displayed when an error is caught in a child component - * @see https://marmelab.com/react-admin/Admin.html#error - * @example - * import { Admin } from 'react-admin'; - * import { MyError } from './error'; - * - * const App = () => ( - * <Admin error={MyError}> - * ... - * </Admin> - * ); - */ - error?: ComponentType<any> | ReactElement | null; - - /** - * The internationalization provider for translations - * - * @see https://marmelab.com/react-admin/Translation.html - * @example - * // in src/i18nProvider.js - * import polyglotI18nProvider from 'ra-i18n-polyglot'; - * import fr from 'ra-language-french'; - * - * export const i18nProvider = polyglotI18nProvider(() => fr, 'fr'); - * - * // in src/App.js - * import { Admin } from 'react-admin'; - * import { dataProvider } from './dataProvider'; - * import { i18nProvider } from './i18nProvider'; - * - * const App = () => ( - * <Admin dataProvider={dataProvider} i18nProvider={i18nProvider}> - * ... - * </Admin> - * ); - */ - i18nProvider?: I18nProvider; - - /** - * The component displayed when the user visits the /login page - * @see https://marmelab.com/react-admin/Admin.html#loginpage - * @example - * import { Admin } from 'react-admin'; - * import { dataProvider } from './dataProvider'; - * import { authProvider } from './authProvider'; - * import MyLoginPage from './MyLoginPage'; - * - * const App = () => ( - * <Admin - * loginPage={MyLoginPage} - * authProvider={authProvider} - * dataProvider={dataProvider} - * > - * ... - * </Admin> - * ); - */ - loginPage?: ComponentType<any> | null; - - /** - * The react-query client - * - * @see https://marmelab.com/react-admin/Admin.html#queryclient - * @example - * import { Admin } from 'react-admin'; - * import { QueryClient } from '@tanstack/react-query'; - * - * const queryClient = new QueryClient({ - * defaultOptions: { - * queries: { - * retry: false, - * structuralSharing: false, - * }, - * mutations: { - * retryDelay: 10000, - * }, - * }, - * }); - * - * const App = () => ( - * <Admin queryClient={queryClient} dataProvider={...}> - * ... - * </Admin> - * ); - */ - queryClient?: QueryClient; - - /** - * The adapter for storing user preferences - * - * @see https://marmelab.com/react-admin/Admin.html#store - * @example - * import { Admin, memoryStore } from 'react-admin'; - * - * const App = () => ( - * <Admin dataProvider={dataProvider} store={memoryStore()}> - * ... - * </Admin> - * ); - */ - store?: Store; - - /** - * The title of the error page - * @see https://marmelab.com/react-admin/Admin.html#title - * @example - * import { Admin } from 'react-admin'; - * import { dataProvider } from './dataProvider'; - * - * const App = () => ( - * <Admin title="My Admin" dataProvider={dataProvider}> - * ... - * </Admin> - * ); - */ - title?: string | ReactElement | null; -} - -export function CoreAppContext(props: CoreAppContextProps) { - const { - authProvider, - authCallbackPage: LoginCallbackPage, - basename, - catchAll, - children, - dashboard, - dataProvider, - error, - i18nProvider, - initialLocation, - layout, - loading, - loginPage: LoginPage, - queryClient, - ready, - requireAuth, - store = defaultStore, - title, - } = props; - - const finalQueryClient = useMemo( - () => queryClient || new QueryClient(), - [queryClient] - ); - - return ( - <AuthContext.Provider value={authProvider}> - <DataProviderContext.Provider value={dataProvider}> - <I18nContextProvider value={i18nProvider}> - <NotificationContextProvider> - <StoreContextProvider value={store}> - <QueryClientProvider client={finalQueryClient}> - <AppRouter basename={basename}> - <DefaultTitleContextProvider value={title}> - <ErrorBoundary error={error}> - <Routes> - {LoginPage != null ? ( - <ReactRoute - path="/login" - element={<LoginPage />} - /> - ) : null} - - {LoginCallbackPage != null ? ( - <ReactRoute - path="/auth-callback" - element={<LoginCallbackPage />} - /> - ) : null} - - <ReactRoute - path="/*" - element={ - <CoreAppRoutes - catchAll={catchAll} - dashboard={dashboard} - initialLocation={initialLocation} - layout={layout} - loading={loading} - requireAuth={requireAuth} - ready={ready} - > - {children} - </CoreAppRoutes> - } - /> - </Routes> - </ErrorBoundary> - </DefaultTitleContextProvider> - </AppRouter> - </QueryClientProvider> - </StoreContextProvider> - </NotificationContextProvider> - </I18nContextProvider> - </DataProviderContext.Provider> - </AuthContext.Provider> - ); -} diff --git a/packages/rakit/src/core/CoreAppRoutes.tsx b/packages/rakit/src/core/CoreAppRoutes.tsx deleted file mode 100644 index 27fc928..0000000 --- a/packages/rakit/src/core/CoreAppRoutes.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { - ComponentType, - ReactElement, - ReactNode, - useEffect, - useState -} from 'react'; -import { Navigate, Route, Routes } from 'react-router-dom'; -import { LogoutOnMount, useCheckAuth } from '../auth'; -import { InitialLocationContextProvider } from '../routing'; -import { getReactElement } from '../util'; -import { DefaultLayout } from './DefaultLayout'; -import { CoreLayoutProps } from './types'; -import { useConfigureRoutesFromChildren } from './useConfigureRoutesFromChildren'; -import { useScrollToTop } from '../scrollPosition'; -import { HasDashboardContextProvider } from './HasDashboardContextProvider'; - -export interface CoreAppRoutesProps { - /** - * A catch-all react component to display when the URL does not match any - * - * @see https://marmelab.com/react-admin/Admin.html#catchall - * @example - * // in src/NotFound.js - * import Card from '@mui/material/Card'; - * import CardContent from '@mui/material/CardContent'; - * import { Title } from 'react-admin'; - * - * export const NotFound = () => ( - * <Card> - * <Title title="Not Found" /> - * <CardContent> - * <h1>404: Page not found</h1> - * </CardContent> - * </Card> - * ); - * - * // in src/App.js - * import { Admin } from 'react-admin'; - * import { dataProvider } from './dataProvider'; - * import { NotFound } from './NotFound'; - * - * const App = () => ( - * <Admin catchAll={NotFound} dataProvider={dataProvider}> - * ... - * </Admin> - * ); - */ - catchAll?: ComponentType<any> | ReactElement | null; - - children?: ReactNode; - - /** - * @example - * import { WithPermissions } from "rwas"; - * import HomepageView from "./views/Homepage"; - * - * const authParams = { - * params: { route: 'homepage' }, - * }; - * - * const firstpage = ( - * <WithPermissions - * authParams={authParams} - * component={HomepageView} - * /> - * ) - */ - dashboard?: ComponentType | ReactElement | null; - - /** - * @example - * import { WithPermissions } from "rwas"; - * import HomepageView from "./views/Homepage"; - * - * const authParams = { - * params: { route: 'homepage' }, - * }; - * - * const firstpage = ( - * <WithPermissions - * authParams={authParams} - * component={HomepageView} - * /> - * ) - */ - homepage?: ComponentType<any> | ReactElement | null; - - initialLocation?: string; - - /** - * The main app layout component - * - * @see https://marmelab.com/react-admin/Admin.html#layout - * @example - * import { Admin, Layout } from 'react-admin'; - * - * const MyLayout = ({ children }) => ( - * <Layout appBarAlwaysOn> - * {children} - * </Layout> - * ); - * - * export const App = () => ( - * <Admin dataProvider={dataProvider} layout={MyLayout}> - * ... - * </Admin> - * ); - */ - layout?: ComponentType<CoreLayoutProps>; - - /** - * The component displayed while fetching the auth provider if the admin child is an async function - */ - loading?: ComponentType<any> | ReactElement | null; - - /** - * The page to display when the admin has no Resource children - * - * @see https://marmelab.com/react-admin/Admin.html#ready - * @example - * import { Admin } from 'react-admin'; - * - * const Ready = () => ( - * <div> - * <h1>Admin ready</h1> - * <p>You can now add resources</p> - * </div> - * ) - * - * const App = () => ( - * <Admin ready={Ready}> - * ... - * </Admin> - * ); - */ - ready?: ComponentType<any> | ReactElement | null; - - /** - * Flag to require authentication for all routes. Defaults to false. - * - * @see https://marmelab.com/react-admin/Admin.html#requireauth - * @example - * import { Admin } from 'react-admin'; - * import { dataProvider } from './dataProvider'; - * import { authProvider } from './authProvider'; - * - * const App = () => ( - * <Admin - * requireAuth - * authProvider={authProvider} - * dataProvider={dataProvider} - * > - * ... - * </Admin> - * ); - */ - requireAuth?: boolean; -} - -export function CoreAppRoutes(props: CoreAppRoutesProps) { - useScrollToTop(); - - const [routes, status] = useConfigureRoutesFromChildren(props.children); - - const { - catchAll: catchAllElement, - dashboard, - homepage: homepageElement, - initialLocation, - layout: Layout = DefaultLayout, - loading: loadingElement, - requireAuth, - ready: readyElement, - } = props; - - const [onlyAnonymousRoutes, setOnlyAnonymousRoutes] = useState(requireAuth); - const [checkAuthLoading, setCheckAuthLoading] = useState(requireAuth); - const checkAuth = useCheckAuth(); - - useEffect(() => { - if (requireAuth) { - // do not log the user out on failure to allow access to custom routes with no layout - // for other routes, the LogoutOnMount component will log the user out - checkAuth(undefined, false) - .then(() => { - setOnlyAnonymousRoutes(false); - }) - .catch(() => { }) - .finally(() => { - setCheckAuthLoading(false); - }); - } - }, [checkAuth, requireAuth]); - - if (status === 'empty') { - if (!readyElement) { - throw new Error( - 'The admin is empty. Please provide an empty component, ' + - 'or pass Route or CustomRoutes as children.' - ); - } - return getReactElement(readyElement); - } - - if (status === 'loading' || checkAuthLoading) { - return ( - <Routes> - <Route - path="*" - element={ - <div style={{ height: '100vh' }}> - {loadingElement ? getReactElement(loadingElement) : 'loading...'} - </div> - } - /> - </Routes> - ); - } - - if (onlyAnonymousRoutes) { - return ( - <Routes> - <Route - path="*" - element={<LogoutOnMount />} - /> - </Routes> - ); - } - - return ( - <InitialLocationContextProvider - location={initialLocation} - target="app" - > - <Routes> - <Route - path="/*" - element={ - <HasDashboardContextProvider value={!!dashboard}> - <Layout> - <Routes> - {routes} - - <Route - path="/" - element={ - homepageElement - ? (getReactElement(homepageElement)) - : initialLocation - ? (<Navigate to={initialLocation} />) - : null - } - /> - - {dashboard - ? (<Route path="/" element={getReactElement(dashboard)} />) - : (initialLocation && initialLocation !== "/") - ? (<Route path="/" element={<Navigate to={initialLocation} />} />) - : null} - - {catchAllElement ? ( - <Route - path="*" - element={getReactElement(catchAllElement)} - /> - ) : null} - </Routes> - </Layout> - </HasDashboardContextProvider> - } - /> - </Routes> - </InitialLocationContextProvider> - ); -} diff --git a/packages/rakit/src/core/DefaultError.tsx b/packages/rakit/src/core/DefaultError.tsx new file mode 100644 index 0000000..6f15838 --- /dev/null +++ b/packages/rakit/src/core/DefaultError.tsx @@ -0,0 +1,18 @@ +import { useErrorContext } from "./useErrorContext"; +import { useResetErrorBoundaryOnLocationChange } from "./useResetErrorBoundaryOnLocationChange"; + +export function DefaultError() { + const { error, errorInfo, resetErrorBoundary } = useErrorContext(); + + useResetErrorBoundaryOnLocationChange(resetErrorBoundary); + + return ( + <div> + <h1>Error</h1> + <pre> + {error.message} + {errorInfo?.componentStack} + </pre> + </div> + ); +} diff --git a/packages/rakit/src/title/DefaultTitleContext.ts b/packages/rakit/src/core/DefaultTitleContext.ts similarity index 75% rename from packages/rakit/src/title/DefaultTitleContext.ts rename to packages/rakit/src/core/DefaultTitleContext.ts index d476e25..324b1fc 100644 --- a/packages/rakit/src/title/DefaultTitleContext.ts +++ b/packages/rakit/src/core/DefaultTitleContext.ts @@ -1,3 +1,6 @@ import { createContext, ReactElement } from "react"; -export const DefaultTitleContext = createContext<string | ReactElement>('React WebApp Scaffold'); +/** + * @private + */ +export const DefaultTitleContext = createContext<string | ReactElement>('Rakit'); diff --git a/packages/rakit/src/title/DefaultTitleContextProvider.tsx b/packages/rakit/src/core/DefaultTitleContextProvider.tsx similarity index 100% rename from packages/rakit/src/title/DefaultTitleContextProvider.tsx rename to packages/rakit/src/core/DefaultTitleContextProvider.tsx diff --git a/packages/rakit/src/errorBoundary/ErrorBoundary.tsx b/packages/rakit/src/core/ErrorBoundary.tsx similarity index 100% rename from packages/rakit/src/errorBoundary/ErrorBoundary.tsx rename to packages/rakit/src/core/ErrorBoundary.tsx diff --git a/packages/rakit/src/core/ErrorContext.ts b/packages/rakit/src/core/ErrorContext.ts new file mode 100644 index 0000000..b417aec --- /dev/null +++ b/packages/rakit/src/core/ErrorContext.ts @@ -0,0 +1,7 @@ +import { createContext } from "react"; +import { ErrorContextValue } from "./types"; + +/** + * @private + */ +export const ErrorContext = createContext<ErrorContextValue | null>(null) diff --git a/packages/rakit/src/core/ErrorContextProvider.tsx b/packages/rakit/src/core/ErrorContextProvider.tsx new file mode 100644 index 0000000..9205e4f --- /dev/null +++ b/packages/rakit/src/core/ErrorContextProvider.tsx @@ -0,0 +1,13 @@ +import { ProviderProps, ReactElement } from "react"; +import { ErrorContext } from "./ErrorContext"; +import { ErrorContextValue } from "./types"; + +export function ErrorContextProvider( + props: ProviderProps<ErrorContextValue> +): ReactElement { + return ( + <ErrorContext.Provider value={props.value}> + {props.children} + </ErrorContext.Provider> + ); +} diff --git a/packages/rakit/src/core/HasDashboardContext.ts b/packages/rakit/src/core/HasDashboardContext.ts index bd83a93..85a6fcb 100644 --- a/packages/rakit/src/core/HasDashboardContext.ts +++ b/packages/rakit/src/core/HasDashboardContext.ts @@ -1,3 +1,6 @@ import { createContext } from "react"; +/** + * @private + */ export const HasDashboardContext = createContext<boolean>(false); diff --git a/packages/rakit/src/core/InAdminContext.tsx b/packages/rakit/src/core/InAdminContext.tsx deleted file mode 100644 index e315597..0000000 --- a/packages/rakit/src/core/InAdminContext.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, PropsWithChildren, useContext } from "react"; - -const InAdmin = createContext(false); - -/** - * @private - */ -export function InAdminContext(props: PropsWithChildren) { - return ( - <InAdmin.Provider value={true}> - {props.children} - </InAdmin.Provider> - ) -} - -/** - * @private - */ -export function useInAdminContext() { - return useContext(InAdmin); -} diff --git a/packages/rakit/src/core/InAppContext.tsx b/packages/rakit/src/core/InAppContext.tsx deleted file mode 100644 index 30c8ffe..0000000 --- a/packages/rakit/src/core/InAppContext.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, PropsWithChildren, useContext } from "react"; - -const InApp = createContext(false); - -/** - * @private - */ -export function InAppContext(props: PropsWithChildren) { - return ( - <InApp.Provider value={true}> - {props.children} - </InApp.Provider> - ) -} - -/** - * @private - */ -export function useInAppContext() { - return useContext(InApp); -} diff --git a/packages/rakit/src/core/index.ts b/packages/rakit/src/core/index.ts index 094aefd..68a1cb2 100644 --- a/packages/rakit/src/core/index.ts +++ b/packages/rakit/src/core/index.ts @@ -1,10 +1,20 @@ +export * from "./AdminRouter"; +export * from "./BasenameContextProvider"; +export * from "./CoreAdmin"; export * from "./CoreAdminContext"; export * from "./CoreAdminRoutes"; -export * from "./CoreAppContext"; -export * from "./CoreAppRoutes"; +export * from "./CoreAdminUI"; +export * from "./DefaultError"; export * from "./DefaultLayout"; -export * from "./HasDashboardContext"; +export * from "./DefaultTitleContextProvider"; +export * from "./ErrorBoundary"; +export * from "./ErrorContextProvider"; export * from "./HasDashboardContextProvider"; export * from "./types"; -export * from "./useConfigureRoutesFromChildren"; +export * from "./useBasename"; +export * from "./useConfigureAdminRouterFromChildren"; +export * from "./useDefaultTitle"; +export * from "./useErrorContext"; export * from "./useHasDashboard"; +export * from "./useRedirect"; +export * from "./useResetErrorBoundaryOnLocationChange"; diff --git a/packages/rakit/src/core/types.ts b/packages/rakit/src/core/types.ts index 8091421..552933b 100644 --- a/packages/rakit/src/core/types.ts +++ b/packages/rakit/src/core/types.ts @@ -1,5 +1,23 @@ -import { ReactNode } from "react"; +import { ErrorInfo, ReactNode } from "react"; +import { FallbackProps } from "react-error-boundary"; export interface CoreLayoutProps { children: ReactNode; } + +export type RenderRoutesFunction = (permissions: any) => + | ReactNode // (permissions) => <><Route /><Route /><Route /></> + | Promise<ReactNode> // (permissions) => fetch().then(() => <><Route /><Route /><Route /></>) + +export type AdminChildren = + | RenderRoutesFunction + | Iterable<ReactNode | RenderRoutesFunction> + | ReactNode; + +export type AdminRouterStatus = 'loading' | 'empty' | 'ready'; + +export interface ErrorContextValue { + errorInfo?: ErrorInfo; + error: Error; + resetErrorBoundary: FallbackProps['resetErrorBoundary']; +} diff --git a/packages/rakit/src/routing/useBasename.ts b/packages/rakit/src/core/useBasename.ts similarity index 100% rename from packages/rakit/src/routing/useBasename.ts rename to packages/rakit/src/core/useBasename.ts diff --git a/packages/rakit/src/core/useConfigureRoutesFromChildren.tsx b/packages/rakit/src/core/useConfigureAdminRouterFromChildren.tsx similarity index 63% rename from packages/rakit/src/core/useConfigureRoutesFromChildren.tsx rename to packages/rakit/src/core/useConfigureAdminRouterFromChildren.tsx index a86c866..c6b170d 100644 --- a/packages/rakit/src/core/useConfigureRoutesFromChildren.tsx +++ b/packages/rakit/src/core/useConfigureAdminRouterFromChildren.tsx @@ -8,28 +8,20 @@ import { useEffect, useState } from "react"; -import { Route as ReactRoute } from "react-router-dom"; +import { Route } from "react-router-dom"; import { useLogout, usePermissions } from "../auth"; import { useSafeSetState } from "../util"; -import { CoreAdminContext, CoreAdminContextProps } from "./CoreAdminContext"; -import { Route, RouteProps } from "../routing"; - -export type RenderRoutesFunction = (permissions: any) => - | ReactNode // (permissions) => <><Route /><Route /><Route /></> - | Promise<ReactNode> // (permissions) => fetch().then(() => <><Route /><Route /><Route /></>) - -export type AppChildren = - | RenderRoutesFunction - | Iterable<ReactNode | RenderRoutesFunction> - | ReactNode; - -export type AppRoutesStatus = 'loading' | 'empty' | 'ready'; +import { + AdminChildren, + AdminRouterStatus, + RenderRoutesFunction +} from "./types"; -function isRenderRoutesFunction(children: AppChildren): children is RenderRoutesFunction { +function isRenderRoutesFunction(children: AdminChildren): children is RenderRoutesFunction { return typeof children === "function"; } -function hasIteratorProtocol(children: AppChildren): children is Iterable<ReactNode | RenderRoutesFunction> { +function hasIteratorProtocol(children: AdminChildren): children is Iterable<ReactNode | RenderRoutesFunction> { return ( typeof Symbol !== "undefined" && children != null && @@ -37,7 +29,7 @@ function hasIteratorProtocol(children: AppChildren): children is Iterable<ReactN ); } -function getRenderRoutesFunctions(children: AppChildren): RenderRoutesFunction[] { +function getRenderRoutesFunctions(children: AdminChildren): RenderRoutesFunction[] { if (isRenderRoutesFunction(children)) { return [children]; } @@ -55,7 +47,7 @@ function getRenderRoutesFunctions(children: AppChildren): RenderRoutesFunction[] return []; } -export function useConfigureRoutesFromChildren(children: AppChildren): [ReactElement[], AppRoutesStatus] { +export function useConfigureAdminRouterFromChildren(children: AdminChildren): [ReactElement[], AdminRouterStatus] { // Gather custom routes that were declared as direct children of AppRouter // e.g. Not returned from the child function (if any) // We need to know right away whether some resources were declared to correctly @@ -63,7 +55,7 @@ export function useConfigureRoutesFromChildren(children: AppChildren): [ReactEle const doLogout = useLogout(); const { permissions, isPending } = usePermissions(); const [routes, setRoutes] = useState(getRoutesFromNodes(children)); - const [status, setStatus] = useSafeSetState<AppRoutesStatus>(() => getStatus(children, routes)); + const [status, setStatus] = useSafeSetState<AdminRouterStatus>(() => getStatus(children, routes)); const mergeRoutes = useCallback((newRoutes: ReactElement[]) => { setRoutes(previous => previous.concat(newRoutes)); @@ -142,21 +134,18 @@ export function useConfigureRoutesFromChildren(children: AppChildren): [ReactEle return [routes, status]; } -const getStatus = ( - children: AppChildren, - routes: ReactNode[], -): AppRoutesStatus => { +function getStatus(children: AdminChildren, routes: ReactNode[]): AdminRouterStatus { return getRenderRoutesFunctions(children).length > 0 ? 'loading' : routes.length > 0 ? 'ready' : 'empty'; -}; +} /** * Inspect the children and return an array of routable elements */ -function getRoutesFromNodes(children: AppChildren) { +function getRoutesFromNodes(children: AdminChildren): ReactElement[] { const routes: ReactElement[] = []; if (isRenderRoutesFunction(children)) { @@ -178,43 +167,16 @@ function getRoutesFromNodes(children: AppChildren) { return; } else if (node.type === Fragment) { routes.push(...getRoutesFromNodes(node.props.children)); - } else if (node.type === CoreAdminContext) { - // TODO ... - const { basepath } = (node as ReactElement<CoreAdminContextProps>).props; - routes.push( - <ReactRoute - key="adminContext" - path={basepath} - element={node} - /> - ) - } else if (node.type === ReactRoute) { - routes.push(node); } else if (node.type === Route) { - const { - name, - remembeScrollPosition: _, - authorised: __, - children, - ...props - } = (node as ReactElement<RouteProps>).props; - routes.push( - // @ts-ignore - <ReactRoute - key={name} - {...props} - element={node} - Component={null} - > - {getRoutesFromNodes(children)} - </ReactRoute> - ); - } else { - // todo 与 react-router 报错保持一致 - // throw new Error(""); - // [div] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment> + routes.push(node); + } else if (process.env.NODE_ENV !== "production") { + // TODO 获取 node.type 的 displayName + const name = typeof node.type === "string" + ? node.type + : ((node.type as any).displayName || node.type.name); throw new Error( - "[" + node.type + "] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>" + `[${name}] is not a <Route> component. ` + + `All component children of <Routes> must be a <Route> or <React.Fragment>` ); } }); diff --git a/packages/rakit/src/title/useDefaultTitle.ts b/packages/rakit/src/core/useDefaultTitle.ts similarity index 100% rename from packages/rakit/src/title/useDefaultTitle.ts rename to packages/rakit/src/core/useDefaultTitle.ts diff --git a/packages/rakit/src/errorBoundary/useErrorContext.ts b/packages/rakit/src/core/useErrorContext.ts similarity index 79% rename from packages/rakit/src/errorBoundary/useErrorContext.ts rename to packages/rakit/src/core/useErrorContext.ts index a0e25ff..396ce4c 100644 --- a/packages/rakit/src/errorBoundary/useErrorContext.ts +++ b/packages/rakit/src/core/useErrorContext.ts @@ -1,5 +1,6 @@ import { useContext } from "react"; -import { ErrorContext, ErrorContextValue } from "./ErrorContext"; +import { ErrorContext } from "./ErrorContext"; +import { ErrorContextValue } from "./types"; export function useErrorContext(): ErrorContextValue { const errorContext = useContext(ErrorContext); diff --git a/packages/rakit/src/routing/useRedirect.ts b/packages/rakit/src/core/useRedirect.ts similarity index 100% rename from packages/rakit/src/routing/useRedirect.ts rename to packages/rakit/src/core/useRedirect.ts diff --git a/packages/rakit/src/routing/useResetErrorBoundaryOnLocationChange.ts b/packages/rakit/src/core/useResetErrorBoundaryOnLocationChange.ts similarity index 72% rename from packages/rakit/src/routing/useResetErrorBoundaryOnLocationChange.ts rename to packages/rakit/src/core/useResetErrorBoundaryOnLocationChange.ts index 3fecd52..39c9e44 100644 --- a/packages/rakit/src/routing/useResetErrorBoundaryOnLocationChange.ts +++ b/packages/rakit/src/core/useResetErrorBoundaryOnLocationChange.ts @@ -1,4 +1,4 @@ -import * as React from 'react'; +import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; /** @@ -7,15 +7,15 @@ import { useLocation } from 'react-router-dom'; * the location changes * @param {Function} resetErrorBoundary */ -export const useResetErrorBoundaryOnLocationChange = ( +export function useResetErrorBoundaryOnLocationChange( resetErrorBoundary: () => void -) => { +): void { const { pathname } = useLocation(); - const originalPathname = React.useRef(pathname); + const originalPathname = useRef(pathname); - React.useEffect(() => { + useEffect(() => { if (pathname !== originalPathname.current) { resetErrorBoundary(); } }, [pathname, resetErrorBoundary]); -}; +} diff --git a/packages/rakit/src/errorBoundary/ErrorContext.ts b/packages/rakit/src/errorBoundary/ErrorContext.ts deleted file mode 100644 index 5649145..0000000 --- a/packages/rakit/src/errorBoundary/ErrorContext.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createContext, ErrorInfo } from "react"; -import { FallbackProps } from "react-error-boundary"; - -export interface ErrorContextValue { - errorInfo?: ErrorInfo; - error: Error; - resetErrorBoundary: FallbackProps['resetErrorBoundary']; -} - -export const ErrorContext = createContext<ErrorContextValue | null>(null) diff --git a/packages/rakit/src/errorBoundary/ErrorContextProvider.tsx b/packages/rakit/src/errorBoundary/ErrorContextProvider.tsx deleted file mode 100644 index f1c4f28..0000000 --- a/packages/rakit/src/errorBoundary/ErrorContextProvider.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import * as React from "react"; -import { - ErrorContext, - ErrorContextValue, -} from "./ErrorContext"; - -export const ErrorContextProvider = ( - props: React.ProviderProps<ErrorContextValue> -): React.ReactElement => { - return ( - <ErrorContext.Provider value={props.value}> - {props.children} - </ErrorContext.Provider> - ) -} diff --git a/packages/rakit/src/errorBoundary/index.ts b/packages/rakit/src/errorBoundary/index.ts deleted file mode 100644 index 6dda8bb..0000000 --- a/packages/rakit/src/errorBoundary/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./ErrorBoundary"; -export * from "./ErrorContext"; -export * from "./ErrorContextProvider"; -export * from "./useErrorContext"; diff --git a/packages/rakit/src/index.ts b/packages/rakit/src/index.ts index af418c8..578770c 100644 --- a/packages/rakit/src/index.ts +++ b/packages/rakit/src/index.ts @@ -1,14 +1,10 @@ -export * from "./accessControl"; export * from "./auth"; export * from "./core"; export * from "./data"; -export * from "./errorBoundary"; export * from "./i18n"; export * from "./notification"; export * from "./portal"; export * from "./record"; -export * from "./routing"; export * from "./scrollPosition"; export * from "./store"; -export * from "./title"; export * from "./util"; diff --git a/packages/rakit/src/routing/InitialLocationContext.ts b/packages/rakit/src/routing/InitialLocationContext.ts deleted file mode 100644 index eca1171..0000000 --- a/packages/rakit/src/routing/InitialLocationContext.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createContext } from "react"; -import { To } from "react-router-dom"; - -export interface InitialLocationContextValue { - app?: To; - admin?: To; -} - -/** - * @private - */ -export const InitialLocationContext = createContext<InitialLocationContextValue | undefined>(undefined); diff --git a/packages/rakit/src/routing/InitialLocationContextProvider.tsx b/packages/rakit/src/routing/InitialLocationContextProvider.tsx deleted file mode 100644 index 7c6e644..0000000 --- a/packages/rakit/src/routing/InitialLocationContextProvider.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { ReactNode } from "react"; -import { To } from "react-router-dom"; -import { - InitialLocationContext, - InitialLocationContextValue -} from "./InitialLocationContext"; -import { useInitialLocationContext } from "./useInitialLocationContext"; - -export interface InitialLocationContextProviderProps { - location?: To; - target: keyof InitialLocationContextValue - children?: ReactNode; -} - -export function InitialLocationContextProvider(props: InitialLocationContextProviderProps) { - const { children, target, location } = props; - const fromContext = useInitialLocationContext({}); - const value = { - ...fromContext, - [target]: location ?? fromContext[target] - } - - if (Object.values(value).some(v => v == null)) { - return children; - } - - return ( - <InitialLocationContext.Provider value={value}> - {children} - </InitialLocationContext.Provider> - ) -} diff --git a/packages/rakit/src/routing/Route.tsx b/packages/rakit/src/routing/Route.tsx deleted file mode 100644 index 76dd180..0000000 --- a/packages/rakit/src/routing/Route.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { - IndexRouteProps as IndexRoutePropsRaw, - PathRouteProps as PathRoutePropsRaw, -} from "react-router-dom"; -import { Authenticated } from "../auth"; -import { RestoreScrollPosition } from "../scrollPosition"; -import { ComponentType, ReactNode } from "react"; -import { AccessParams, CanAccess } from "../accessControl"; - -export type RoutePropsBase = { - accessParams?: AccessParams; - authorised?: boolean; - name: string; - remembeScrollPosition?: boolean; -} - -export type IndexRouteProps = IndexRoutePropsRaw & RoutePropsBase; -export type PathRouteProps = PathRoutePropsRaw & RoutePropsBase; -export type RouteProps = IndexRouteProps | PathRouteProps; - -export function Route(props: RouteProps) { - const { - accessParams, - authorised, - Component, - element, - name, - remembeScrollPosition, - } = props; - - let children = getElement(element, Component); - - if (authorised) { - children = ( - <CanAccess - on="route" - key={name} - params={accessParams} - > - {getElement(element, Component)} - </CanAccess> - ); - } - - if (remembeScrollPosition) { - children = ( - <RestoreScrollPosition storeKey={name}> - {children} - </RestoreScrollPosition> - ); - } - - if (authorised) { - children = ( - <Authenticated> - {children} - </Authenticated> - ); - } - - return children; -} - -function getElement( - element: ReactNode | undefined | null, - Component: ComponentType | undefined | null -) { - if (element != null) { - return element; - } - if (Component != null) { - return (<Component />) - } - return null; -} diff --git a/packages/rakit/src/routing/index.ts b/packages/rakit/src/routing/index.ts deleted file mode 100644 index 9f87cbc..0000000 --- a/packages/rakit/src/routing/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from "./AppRouter"; -export * from "./BasenameContextProvider"; -export * from "./InitialLocationContextProvider"; -export * from "./Route"; -export * from "./useBasename"; -export * from "./useInitialLocation"; -export * from "./useInitialLocationContext"; -export * from "./useRedirect"; -export * from "./useResetErrorBoundaryOnLocationChange"; diff --git a/packages/rakit/src/routing/useInitialLocation.ts b/packages/rakit/src/routing/useInitialLocation.ts deleted file mode 100644 index fd35b48..0000000 --- a/packages/rakit/src/routing/useInitialLocation.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { To } from "react-router-dom"; -import { useInAdminContext } from "../core/InAdminContext"; -import { useInAppContext } from "../core/InAppContext"; -import { useInitialLocationContext } from "./useInitialLocationContext"; - -export function useInitialLocation(fallback?: To): To | undefined { - const inAdmin = useInAdminContext(); - const inApp = useInAppContext(); - const { app, admin } = useInitialLocationContext({}); - if (inAdmin) { - return admin; - } - if (inApp) { - return app; - } - return fallback; -} diff --git a/packages/rakit/src/routing/useInitialLocationContext.ts b/packages/rakit/src/routing/useInitialLocationContext.ts deleted file mode 100644 index cabcfb2..0000000 --- a/packages/rakit/src/routing/useInitialLocationContext.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useContext } from "react"; -import { - InitialLocationContext, - InitialLocationContextValue -} from "./InitialLocationContext"; - -export function useInitialLocationContext(): InitialLocationContextValue; -export function useInitialLocationContext(overrides: InitialLocationContextValue): InitialLocationContextValue; -export function useInitialLocationContext(overrides?: InitialLocationContextValue) { - const fromContext = useContext(InitialLocationContext); - - if (fromContext != null) { - return { - ...fromContext, - ...overrides, - } - } - - return overrides; -} diff --git a/packages/rakit/src/title/PageTitle.tsx b/packages/rakit/src/title/PageTitle.tsx deleted file mode 100644 index 6ddf016..0000000 --- a/packages/rakit/src/title/PageTitle.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useTranslate } from "../i18n"; -import { TitleProps } from "./types"; - -export function PageTitle(props: TitleProps) { - const { - title, - defaultTitle, - className, - ...rest - } = props; - const translate = useTranslate(); - - if (!title && !defaultTitle) { - return null; - } - - return ( - <span className={className}> - {!title ? ( - <span {...rest}>{defaultTitle}</span> - ) : typeof title === 'string' ? ( - <span {...rest}>{translate(title, { _: title })}</span> - ) : ( - title - )} - </span> - ); -} diff --git a/packages/rakit/src/title/PageTitleConfigurable.tsx b/packages/rakit/src/title/PageTitleConfigurable.tsx deleted file mode 100644 index 147ad7e..0000000 --- a/packages/rakit/src/title/PageTitleConfigurable.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { PageTitle } from './PageTitle'; -import { useRecordRepresentation } from '../record'; -import { useTranslate } from '../i18n'; -import { TitleProps } from './types'; - -export const PageTitleConfigurable = ({ - preferenceKey, - title, - defaultTitle, - record, - ...props -}: TitleProps) => { - const translate = useTranslate(); - const titleFromPreferences = useRecordRepresentation({ - record, - representation: preferenceKey === false ? undefined : preferenceKey, - }); - - if (titleFromPreferences) { - return ( - <span className={props.className} {...props}> - {translate(titleFromPreferences, { - ...record, - _: titleFromPreferences, - })} - </span> - ) - } - - return ( - <PageTitle - title={title} - defaultTitle={defaultTitle} - {...props} - /> - ); -}; diff --git a/packages/rakit/src/title/Title.tsx b/packages/rakit/src/title/Title.tsx deleted file mode 100644 index 29a22ce..0000000 --- a/packages/rakit/src/title/Title.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Portlet } from '../portal'; -import { titlePortalName } from './constants'; -import { PageTitle } from './PageTitle'; -import { PageTitleConfigurable } from './PageTitleConfigurable'; -import { TitleProps } from './types'; - -export const Title = (props: TitleProps) => { - const { - defaultTitle, - title, - preferenceKey, - ...rest - } = props; - - return ( - <Portlet to={titlePortalName}> - {() => { - if (!defaultTitle && !title) { - console.warn('Missing title prop in <Title> element'); - } - if (preferenceKey === false) { - return ( - <PageTitle - title={title} - defaultTitle={defaultTitle} - {...rest} - /> - ); - } - return ( - <PageTitleConfigurable - title={title} - defaultTitle={defaultTitle} - preferenceKey={preferenceKey} - {...rest} - /> - ); - }} - </Portlet> - ); -}; diff --git a/packages/rakit/src/title/TitlePortalProvider.tsx b/packages/rakit/src/title/TitlePortalProvider.tsx deleted file mode 100644 index cd50b54..0000000 --- a/packages/rakit/src/title/TitlePortalProvider.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { ReactNode } from "react"; -import { PortalProvider } from "../portal"; -import { titlePortalName } from "./constants"; - -export interface TitleContainerProviderProps { - children?: ReactNode; - value: string | Element | null; -} - -export function TitlePortalProvider(props: TitleContainerProviderProps) { - return ( - <PortalProvider - name={titlePortalName} - container={props.value} - > - {props.children} - </PortalProvider> - ); -} diff --git a/packages/rakit/src/title/constants.ts b/packages/rakit/src/title/constants.ts deleted file mode 100644 index 8c031f2..0000000 --- a/packages/rakit/src/title/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * @private - */ -export const titlePortalName = "@@rakit-title-portal"; diff --git a/packages/rakit/src/title/index.ts b/packages/rakit/src/title/index.ts deleted file mode 100644 index e44e7ed..0000000 --- a/packages/rakit/src/title/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export * from "./DefaultTitleContext"; -export * from "./DefaultTitleContextProvider"; -export * from "./PageTitle"; -export * from "./PageTitleConfigurable"; -export * from "./Title"; -export * from "./TitlePortalProvider"; -export * from "./types"; -export * from "./useDefaultTitle"; diff --git a/packages/rakit/src/title/types.ts b/packages/rakit/src/title/types.ts deleted file mode 100644 index 4f0672c..0000000 --- a/packages/rakit/src/title/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ReactElement } from "react"; - -export interface TitleProps { - className?: string; - defaultTitle?: ReactElement; - record?: any; - title?: string | ReactElement; - preferenceKey?: string | false; -}