diff --git a/apps/imsfe/package.json b/apps/imsfe/package.json index 56bae35..b362d49 100644 --- a/apps/imsfe/package.json +++ b/apps/imsfe/package.json @@ -12,6 +12,7 @@ "@emotion/styled": "^11.13.0", "@mui/icons-material": "^5.16.7", "@mui/joy": "^5.0.0-beta.48", + "@mui/system": "^6.0.2", "@mui/utils": "^5.16.6", "@rakit/core": "workspace:*", "@rakit/fetch": "workspace:*", diff --git a/apps/imsfe/src/App.tsx b/apps/imsfe/src/App.tsx index fd3af10..4d766c9 100644 --- a/apps/imsfe/src/App.tsx +++ b/apps/imsfe/src/App.tsx @@ -1,6 +1,6 @@ -import { CoreAdmin, CoreLayoutProps } from '@rakit/core'; +import { CoreAdmin, CoreLayoutProps, RoutesWithoutLayout } from '@rakit/core'; import { FetchContextProvider, useFetch } from '@rakit/fetch'; -import { Error, Loading, ThemeProvider } from '@rakit/joy-ui'; +import { RuntimeError, Loading, ThemeProvider, NotFound } from '@rakit/joy-ui'; import { createAuthProvider } from './config/createAuthProvider'; import { createDataProvider } from './config/createDataProvider'; import { i18nProvider } from './config/i18nProvider'; @@ -17,7 +17,6 @@ const Layout = (props: CoreLayoutProps) => { ) } -const CatchAll = lazy(() => import("./pages/CatchAll")); const SignIn = lazy(() => import("./pages/SignIn")); const PageError = lazy(() => import("./pages/PageError")); @@ -42,32 +41,24 @@ function AppContext() { authProvider={authProvider} // authCallbackPage={ } // basename={ } - catchAll={} + catchAll={NotFound} dataProvider={dataProvider} - error={Error} - dashboard={} - initialLocation="/" + error={RuntimeError} + // dashboard={} + // initialLocation="/" i18nProvider={i18nProvider} loading={AppLoading} layout={Layout} loginPage={SignIn} // ready={ } - requireAuth={false} + requireAuth={true} // store={ } title="进销存系统" > - } - /> - 哈哈哈哈} - /> - 哈哈222哈哈} - /> + } /> + + sss} /> + ) } diff --git a/apps/imsfe/src/config/i18nProvider.ts b/apps/imsfe/src/config/i18nProvider.ts index 5093374..6bd1d9c 100644 --- a/apps/imsfe/src/config/i18nProvider.ts +++ b/apps/imsfe/src/config/i18nProvider.ts @@ -1,25 +1,26 @@ import Polyglot from 'node-polyglot'; import { I18nProvider, Locale } from "@rakit/core"; +import merge from 'lodash/merge'; +import { enUS, zhCN } from '@rakit/joy-ui'; const locale: Locale = { code: 'zh', name: "简体中文" }; const polyglot = new Polyglot({ locale: locale.code, - phrases: { + phrases: merge({ '': '', - ra: { - page: { - error: 'ra.page.error222', - }, - message: { - error: "ra.message.error222", - } - }, - }, + }, enUS, zhCN), }); +function translate(key: string, options: any = {}) { + if (polyglot.has(key)) { + return polyglot.t(key, options); + } + return key; +} + export const i18nProvider: I18nProvider = { - translate: (key: string, options: any = {}) => polyglot.t(key, options), + translate, changeLocale: () => Promise.resolve(), getLocale: () => "zh", getLocales: () => [locale], diff --git a/apps/imsfe/src/pages/CatchAll.tsx b/apps/imsfe/src/pages/CatchAll.tsx deleted file mode 100644 index 90d486f..0000000 --- a/apps/imsfe/src/pages/CatchAll.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import { Navigate } from "react-router-dom"; - -export default function CatchAll() { - return ( - - ); -} diff --git a/apps/imsfe/src/pages/PageError.tsx b/apps/imsfe/src/pages/PageError.tsx index 4a5f5fc..9d08487 100644 --- a/apps/imsfe/src/pages/PageError.tsx +++ b/apps/imsfe/src/pages/PageError.tsx @@ -1,10 +1,20 @@ -import { StatusError } from "@rakit/joy-ui"; +import { + Forbidden, + NotFound, + PageError as Error +} from "@rakit/joy-ui"; import { useParams } from "react-router-dom"; export default function PageError() { const { status } = useParams<'status'>(); - return ( - - ); + switch (status) { + case "403": + return + case "404": + return + case "500": + default: + return + } } diff --git a/apps/imsfe/src/pages/SignIn.tsx b/apps/imsfe/src/pages/SignIn.tsx index ce5ad90..33f8fd0 100644 --- a/apps/imsfe/src/pages/SignIn.tsx +++ b/apps/imsfe/src/pages/SignIn.tsx @@ -3,10 +3,13 @@ import Box from '@mui/joy/Box'; import IconButton from '@mui/joy/IconButton'; import Typography from '@mui/joy/Typography'; import BadgeRoundedIcon from '@mui/icons-material/BadgeRounded'; -import { ColorSchemeToggle } from '@rakit/joy-ui'; +import { ColorSchemeToggle, useMediaQuery } from '@rakit/joy-ui'; import { SignInCard } from 'src/views/SignInCard'; +import { useDefaultTitle } from '@rakit/core'; export default function SignIn() { + const isSmall = useMediaQuery((theme) => theme.breakpoints.down('md')); + const title = useDefaultTitle(); return ( - Company logo + {title} - + diff --git a/apps/imsfe/src/views/SignInCard.tsx b/apps/imsfe/src/views/SignInCard.tsx index 84ca244..bd263cf 100644 --- a/apps/imsfe/src/views/SignInCard.tsx +++ b/apps/imsfe/src/views/SignInCard.tsx @@ -9,9 +9,21 @@ import Box from '@mui/joy/Box'; import Divider from '@mui/joy/Divider'; import Stack from '@mui/joy/Stack'; import Checkbox from '@mui/joy/Checkbox'; -import { GoogleIcon } from '@rakit/joy-ui'; +import { useMediaQuery, WechatIcon } from '@rakit/joy-ui'; + +interface FormElements extends HTMLFormControlsCollection { + email: HTMLInputElement; + password: HTMLInputElement; + persistent: HTMLInputElement; +} + +interface SignInFormElement extends HTMLFormElement { + readonly elements: FormElements; +} export function SignInCard() { + const isSmall = useMediaQuery((theme) => theme.breakpoints.down('md')); + return ( - Sign in + 登录 - New to company?{' '} + 还没有账号?{' '} Sign up! @@ -67,15 +79,16 @@ export function SignInCard() { variant="soft" color="neutral" fullWidth - startDecorator={} + startDecorator={} + size="lg" > - Continue with Google + 使用微信登录 ({ [theme.getColorSchemeSelector('light')]: { - color: { xs: '#FFF', md: 'text.tertiary' }, + color: 'text.tertiary', }, })} > @@ -96,11 +109,23 @@ export function SignInCard() { > 您的账户 - + 登录密码 - + - diff --git a/packages/joy-ui/src/auth/LoginForm.tsx b/packages/joy-ui/src/auth/LoginForm.tsx new file mode 100644 index 0000000..da1b7e4 --- /dev/null +++ b/packages/joy-ui/src/auth/LoginForm.tsx @@ -0,0 +1,77 @@ +import Sheet from '@mui/joy/Sheet'; +import Typography from '@mui/joy/Typography'; +import FormControl from '@mui/joy/FormControl'; +import FormLabel from '@mui/joy/FormLabel'; +import Input from '@mui/joy/Input'; +import Button from '@mui/joy/Button'; +import Link from '@mui/joy/Link'; +import Box from '@mui/joy/Box'; +import Divider from '@mui/joy/Divider'; +import Stack from '@mui/joy/Stack'; +import Checkbox from '@mui/joy/Checkbox'; +import { FormEvent } from "react"; + +interface FormElements extends HTMLFormControlsCollection { + email: HTMLInputElement; + password: HTMLInputElement; + persistent: HTMLInputElement; +} + +interface SignInFormElement extends HTMLFormElement { + readonly elements: FormElements; +} + +export function LoginForm() { + return ( +
) => { + event.preventDefault(); + const formElements = event.currentTarget.elements; + const data = { + email: formElements.email.value, + password: formElements.password.value, + persistent: formElements.persistent.checked, + }; + alert(JSON.stringify(data, null, 2)); + }} + > + + 您的账户 + + + + 登录密码 + + + + + + + 忘记了密码? + + + + +
+ ) +} diff --git a/packages/joy-ui/src/icons/WechatIcon.tsx b/packages/joy-ui/src/icons/WechatIcon.tsx index 3bc2e39..189fe0b 100644 --- a/packages/joy-ui/src/icons/WechatIcon.tsx +++ b/packages/joy-ui/src/icons/WechatIcon.tsx @@ -2,25 +2,31 @@ import SvgIcon from '@mui/joy/SvgIcon'; export function WechatIcon() { return ( - - + + - - - + ); } diff --git a/packages/joy-ui/src/index.ts b/packages/joy-ui/src/index.ts index 050788a..798a7e9 100644 --- a/packages/joy-ui/src/index.ts +++ b/packages/joy-ui/src/index.ts @@ -1,3 +1,5 @@ export * from "./icons"; +export * from "./language"; export * from "./layout"; export * from "./theme"; +export * from "./utils"; diff --git a/packages/joy-ui/src/language/en_US.ts b/packages/joy-ui/src/language/en_US.ts new file mode 100644 index 0000000..09f095a --- /dev/null +++ b/packages/joy-ui/src/language/en_US.ts @@ -0,0 +1,39 @@ +export const enUS = { + ra: { + action: { + back: "back", + }, + page: { + create: 'Create %{name}', + dashboard: 'Dashboard', + edit: '%{name} %{recordRepresentation}', + empty: 'No %{name} yet.', + error: 'Something went wrong', + forbidden: "No permission", + invite: 'Do you want to add one?', + list: '%{name}', + loading: 'Loading', + not_found: "Sorry, page not found!", + show: '%{name} %{recordRepresentation}', + }, + message: { + about: 'About', + are_you_sure: 'Are you sure?', + auth_error: 'An error occurred while validating the authentication token.', + bulk_delete_content: 'Are you sure you want to delete this %{name}? |||| Are you sure you want to delete these %{smart_count} items?', + bulk_delete_title: 'Delete %{name} |||| Delete %{smart_count} %{name}', + clear_array_input: 'Are you sure you want to clear the whole list?', + delete_content: 'Are you sure you want to delete this item?', + delete_title: 'Delete %{name} #%{id}', + details: 'Details', + error: "A client error occurred and your request couldn't be completed, please try again later.", + forbidden: 'The page you’re trying to access has restricted access. Please refer to your system administrator.', + invalid_form: 'The form is not valid. Please check for errors', + loading: 'Please wait', + no: 'No', + not_found: 'Sorry, we couldn’t find the page you’re looking for. Perhaps you’ve mistyped the URL? Be sure to check your spelling.', + yes: 'Yes', + unsaved_changes: "Some of your changes weren't saved. Are you sure you want to ignore them?", + }, + }, +}; diff --git a/packages/joy-ui/src/language/index.ts b/packages/joy-ui/src/language/index.ts new file mode 100644 index 0000000..5a01b84 --- /dev/null +++ b/packages/joy-ui/src/language/index.ts @@ -0,0 +1,2 @@ +export * from "./zh_CN"; +export * from "./en_US"; diff --git a/packages/joy-ui/src/language/zh_CN.ts b/packages/joy-ui/src/language/zh_CN.ts new file mode 100644 index 0000000..bbc8e1c --- /dev/null +++ b/packages/joy-ui/src/language/zh_CN.ts @@ -0,0 +1 @@ +export const zhCN = {} diff --git a/packages/joy-ui/src/layout/ColorSchemeToggle.tsx b/packages/joy-ui/src/layout/ColorSchemeToggle.tsx index 420cfc5..f634cfb 100644 --- a/packages/joy-ui/src/layout/ColorSchemeToggle.tsx +++ b/packages/joy-ui/src/layout/ColorSchemeToggle.tsx @@ -38,10 +38,10 @@ export function ColorSchemeToggle(props: IconButtonProps) { }} sx={[ { - "& > *:first-child": { + "& > *:first-of-type": { display: mode === "dark" ? "none" : "initial", }, - "& > *:last-child": { + "& > *:last-of-type": { display: mode === "light" ? "none" : "initial", }, }, diff --git a/packages/joy-ui/src/layout/Forbidden.tsx b/packages/joy-ui/src/layout/Forbidden.tsx new file mode 100644 index 0000000..84b0307 --- /dev/null +++ b/packages/joy-ui/src/layout/Forbidden.tsx @@ -0,0 +1,11 @@ +import { PageError } from "./PageError"; + +export function Forbidden() { + return ( + + ); +} diff --git a/packages/joy-ui/src/layout/LoadingIndicator.tsx b/packages/joy-ui/src/layout/LoadingIndicator.tsx new file mode 100644 index 0000000..d25090b --- /dev/null +++ b/packages/joy-ui/src/layout/LoadingIndicator.tsx @@ -0,0 +1,18 @@ +import IconButton, { IconButtonProps } from '@mui/joy/IconButton'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { useLoading } from '@rakit/core'; + +type LoadingIndicatorProps = Omit; + +export function LoadingIndicator(props: LoadingIndicatorProps) { + const loading = useLoading(); + + return ( + + + + ); +} diff --git a/packages/joy-ui/src/layout/NotFound.tsx b/packages/joy-ui/src/layout/NotFound.tsx new file mode 100644 index 0000000..4d6c438 --- /dev/null +++ b/packages/joy-ui/src/layout/NotFound.tsx @@ -0,0 +1,11 @@ +import { PageError } from "./PageError"; + +export function NotFound() { + return ( + + ); +} diff --git a/packages/joy-ui/src/layout/StatusError.tsx b/packages/joy-ui/src/layout/PageError.tsx similarity index 66% rename from packages/joy-ui/src/layout/StatusError.tsx rename to packages/joy-ui/src/layout/PageError.tsx index 39ede59..8354576 100644 --- a/packages/joy-ui/src/layout/StatusError.tsx +++ b/packages/joy-ui/src/layout/PageError.tsx @@ -2,7 +2,8 @@ import AspectRatio from '@mui/joy/AspectRatio'; import Box from '@mui/joy/Box'; import Button from '@mui/joy/Button'; import Typography from '@mui/joy/Typography'; -import { useDefaultTitle } from '@rakit/core'; +import { useDefaultTitle, useTranslate } from '@rakit/core'; +import HistoryIcon from '@mui/icons-material/History'; import { ColorSchemeToggle } from './ColorSchemeToggle'; import { ReactNode } from 'react'; @@ -10,22 +11,22 @@ const Icon403 = ( - - + + - + - + - - + + @@ -35,11 +36,11 @@ const Icon404 = ( - - + + - + @@ -48,8 +49,8 @@ const Icon404 = ( - - + + @@ -59,11 +60,11 @@ const Icon500 = ( - - + + - + @@ -83,52 +84,76 @@ const Icon500 = ( - - + + - - + + ); -const statusIcons = { - "403": Icon403, - "404": Icon404, - "500": Icon500, -}; +function resolveMessageByTitlte(title: ReactNode): string | undefined { + switch (title) { + case "ra.page.error": + return "ra.message.error"; + case "ra.page.forbidden": + return "ra.message.forbidden"; + case "ra.page.not_found": + return "ra.message.not_found"; + default: + return undefined; + } +} -const statusTitles = { - "403": "No permission", - "404": "Sorry, page not found!", - "500": "Internal server error", -}; +function resolveImageByTitle(title: ReactNode): string | undefined { + switch (title) { + case "ra.page.error": + return "error"; + case "forbidden": + return "ra.message.forbidden"; + case "ra.page.not_found": + return "not_found"; + default: + return undefined; + } +} -const statusDescriptions = { - "403": "The page you’re trying to access has restricted access. Please refer to your system administrator.", - "404": "Sorry, we couldn’t find the page you’re looking for. Perhaps you’ve mistyped the URL? Be sure to check your spelling.", - "500": "There was an error, please try again later.", -}; +function goBack() { + window.history.go(-1); +} -const isStatus = (status: string | undefined): status is keyof typeof statusIcons => { - return !!status && Object.hasOwn(statusIcons, status); +function resolveImage(image: string) { + switch (image) { + case "forbidden": + return Icon403; + case "not_found": + return Icon404; + case "error": + default: // TODO + return Icon500; + } } -export interface StatusErrorProps { - status?: string; - icon?: ReactNode; +export interface PageErrorProps { + image?: ReactNode; title?: ReactNode; - description?: ReactNode; + message?: ReactNode; + button?: ReactNode; } -export function StatusError(props: StatusErrorProps) { - const status = isStatus(props.status) ? props.status : "500"; +export function PageError(props: PageErrorProps) { + const { title = "ra.page.error", button } = props; + const image = props.image || resolveImageByTitle(title); + const message = props.message || resolveMessageByTitlte(title); + + const translate = useTranslate(); const siteTitle = useDefaultTitle(); - const icon = props.icon || statusIcons[status]; - const title = props.title || statusTitles[status]; - const description = props.description ?? statusDescriptions[status]; + const imageNode = typeof image === 'string' ? resolveImage(image) : image; + const titleNode = typeof title === 'string' ? translate(title) : title; + const messageNode = typeof message === 'string' ? translate(message) : message; return ( - - {title} - - - {description} - - ({ - maxWidth: "320px", - pt: 4, flexShrink: 1, - mx: "auto", - "--palette-primary-light": theme.palette.primary[300], - "--palette-primary-main": theme.palette.primary[500], - "--palette-primary-dark": theme.palette.primary[600], - "--palette-primary-darker": theme.palette.primary[700] - })} - variant="plain" - > - {icon} - + {titleNode != null ? ( + + {titleNode} + + ) : null} + {messageNode != null ? ( + + {messageNode} + + ) : null} + {imageNode != null ? ( + ({ + maxWidth: "320px", + pt: 4, flexShrink: 1, + mx: "auto", + "--palette-primary-light": theme.palette.primary[300], + "--palette-primary-main": theme.palette.primary[500], + "--palette-primary-dark": theme.palette.primary[600], + "--palette-primary-darker": theme.palette.primary[700] + })} + variant="plain" + > + {imageNode} + + ) : null} - + {button != null ? button : ( + + )}
diff --git a/packages/joy-ui/src/layout/Error.tsx b/packages/joy-ui/src/layout/RuntimeError.tsx similarity index 86% rename from packages/joy-ui/src/layout/Error.tsx rename to packages/joy-ui/src/layout/RuntimeError.tsx index 365b86e..fccded3 100644 --- a/packages/joy-ui/src/layout/Error.tsx +++ b/packages/joy-ui/src/layout/RuntimeError.tsx @@ -3,19 +3,19 @@ import ErrorIcon from '@mui/icons-material/Report'; import Accordion from '@mui/joy/Accordion'; import AccordionDetails from '@mui/joy/AccordionDetails'; import AccordionSummary from '@mui/joy/AccordionSummary'; +import Box from '@mui/joy/Box'; import Button from '@mui/joy/Button'; import { styled } from '@mui/joy/styles'; import Typography from '@mui/joy/Typography'; import { - Title, useDefaultTitle, useErrorContext, useResetErrorBoundaryOnLocationChange, useTranslate } from '@rakit/core'; -import { Fragment } from 'react'; +import { ColorSchemeToggle } from './ColorSchemeToggle'; -export function Error() { +export function RuntimeError() { const title = useDefaultTitle(); const { error, errorInfo, resetErrorBoundary } = useErrorContext(); const translate = useTranslate(); @@ -23,8 +23,31 @@ export function Error() { useResetErrorBoundaryOnLocationChange(resetErrorBoundary); return ( - - {title && } + <Box + sx={{ + display: "flex", + flexDirection: "column", + justifyContent: { + sm: "flex-start", + md: "center", + }, + alignItems: "center", + pt: 12, + pb: 8, + minHeight: "100dvh", + }} + > + <Box sx={{ + position: "fixed", + top: 0, + insetInline: 0, + display: "flex", + justifyContent: "space-between", + p: 4, + }}> + <div>{title}</div> + <ColorSchemeToggle variant="plain" /> + </Box> <Root> <h1 className={ErrorClasses.title} role="alert"> <ErrorIcon className={ErrorClasses.icon} /> @@ -84,7 +107,7 @@ export function Error() { </Button> </div> </Root> - </Fragment> + </Box> ); } diff --git a/packages/joy-ui/src/layout/index.ts b/packages/joy-ui/src/layout/index.ts index 05617f1..a960f50 100644 --- a/packages/joy-ui/src/layout/index.ts +++ b/packages/joy-ui/src/layout/index.ts @@ -1,11 +1,16 @@ export * from "./AppBar"; export * from "./ColorSchemeToggle"; -export * from "./Error"; +export * from "./Forbidden"; export * from "./Layout"; export * from "./Loading"; +export * from "./LoadingIndicator"; +export * from "./NotFound"; export * from "./Notification"; export * from "./PageActions"; +export * from "./PageDock"; +export * from "./PageError"; export * from "./PageRoot"; +export * from "./PageTitle"; export * from "./Sidebar"; -export * from "./StatusError"; +export * from "./RuntimeError"; export * from "./utils"; diff --git a/packages/joy-ui/src/utils/index.ts b/packages/joy-ui/src/utils/index.ts new file mode 100644 index 0000000..592e3ea --- /dev/null +++ b/packages/joy-ui/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./useMediaQuery" diff --git a/packages/joy-ui/src/utils/useMediaQuery.ts b/packages/joy-ui/src/utils/useMediaQuery.ts new file mode 100644 index 0000000..b86e7fc --- /dev/null +++ b/packages/joy-ui/src/utils/useMediaQuery.ts @@ -0,0 +1,9 @@ +import { Theme } from "@mui/joy/styles"; +import { + UseMediaQueryOptions, + useMediaQuery as useMediaQueryForMuiSystem +} from "@mui/system"; + +export function useMediaQuery(queryInput: string | ((theme: Theme) => string), options?: UseMediaQueryOptions): boolean { + return useMediaQueryForMuiSystem<Theme>(queryInput); +} diff --git a/packages/rakit/src/auth/useAuthState.ts b/packages/rakit/src/auth/useAuthState.ts index 14b1ec4..b8887d3 100644 --- a/packages/rakit/src/auth/useAuthState.ts +++ b/packages/rakit/src/auth/useAuthState.ts @@ -7,7 +7,7 @@ import { import noop from 'lodash/noop'; import { useEffect, useMemo } from "react"; import { useNotify } from "../notification"; -import { useBasename } from "../routing"; +import { useBasename } from "../core"; import { getErrorMessage, removeDoubleSlashes } from "../util"; import { defaultAuthParams, useAuthProvider } from "./useAuthProvider"; import { useLogout } from "./useLogout"; diff --git a/packages/rakit/src/auth/useCheckAuth.ts b/packages/rakit/src/auth/useCheckAuth.ts index 2445398..6d0bc33 100644 --- a/packages/rakit/src/auth/useCheckAuth.ts +++ b/packages/rakit/src/auth/useCheckAuth.ts @@ -1,7 +1,7 @@ import { useCallback } from "react"; import { To } from "react-router-dom"; import { useNotify } from "../notification"; -import { useBasename } from "../routing"; +import { useBasename } from "../core"; import { getErrorMessage, removeDoubleSlashes } from "../util"; import { defaultAuthParams, useAuthProvider } from "./useAuthProvider"; import { useLogout } from "./useLogout"; diff --git a/packages/rakit/src/auth/useHandleAuthCallback.ts b/packages/rakit/src/auth/useHandleAuthCallback.ts index a8bcf27..7b8bd1a 100644 --- a/packages/rakit/src/auth/useHandleAuthCallback.ts +++ b/packages/rakit/src/auth/useHandleAuthCallback.ts @@ -3,7 +3,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query'; import noop from 'lodash/noop'; import { useEffect } from 'react'; import { useLocation } from "react-router-dom"; -import { useRedirect } from "../routing"; +import { useRedirect } from "../core"; import { AuthRedirectResult } from "./types"; import { useAuthProvider } from "./useAuthProvider"; diff --git a/packages/rakit/src/auth/useLogin.ts b/packages/rakit/src/auth/useLogin.ts index c8d3f33..6512aac 100644 --- a/packages/rakit/src/auth/useLogin.ts +++ b/packages/rakit/src/auth/useLogin.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useNotificationContext } from '../notification'; import { defaultAuthParams, useAuthProvider } from './useAuthProvider'; -import { useBasename } from '../routing'; +import { useBasename } from '../core'; import { removeDoubleSlashes } from '../util'; /** diff --git a/packages/rakit/src/auth/useLogout.ts b/packages/rakit/src/auth/useLogout.ts index 43d6b6d..bc76764 100644 --- a/packages/rakit/src/auth/useLogout.ts +++ b/packages/rakit/src/auth/useLogout.ts @@ -1,10 +1,10 @@ import { useQueryClient } from '@tanstack/react-query'; import { useCallback, useEffect, useRef } from "react"; import { Path, useLocation, useNavigate } from "react-router-dom"; -import { useBasename } from "../routing"; import { useResetStore } from "../store"; import { removeDoubleSlashes } from "../util"; import { defaultAuthParams, useAuthProvider } from "./useAuthProvider"; +import { useBasename } from '../core'; /** * Log the current user out by calling the authProvider.logout() method, diff --git a/packages/rakit/src/core/CoreAdminRoutes.tsx b/packages/rakit/src/core/CoreAdminRoutes.tsx index 2c4d777..c89a7d7 100644 --- a/packages/rakit/src/core/CoreAdminRoutes.tsx +++ b/packages/rakit/src/core/CoreAdminRoutes.tsx @@ -1,11 +1,26 @@ -import { ComponentType, ReactElement, useEffect, useState } from 'react'; -import { Navigate, Route, Routes, To } from 'react-router-dom'; -import { LogoutOnMount, useCheckAuth } from '../auth'; +import { + ComponentType, + useEffect, + useState +} from 'react'; +import { + Navigate, + Route, + Routes, + To +} from 'react-router-dom'; +import { + AccessControlProvider, + LogoutOnMount, + useCheckAuth +} from '../auth'; import { useScrollToTop } from '../scrollPosition'; -import { getReactElement } from '../util'; import { DefaultLayout } from './DefaultLayout'; import { HasDashboardContextProvider } from './HasDashboardContextProvider'; -import { AdminChildren, CoreLayoutProps } from './types'; +import { + AdminChildren, + CoreLayoutProps +} from './types'; import { useConfigureAdminRouterFromChildren } from './useConfigureAdminRouterFromChildren'; export interface CoreAdminRoutesProps { @@ -39,7 +54,7 @@ export interface CoreAdminRoutesProps { * </Admin> * ); */ - catchAll?: ComponentType<any> | ReactElement | null; + catchAll?: ComponentType<any> | null; children?: AdminChildren; @@ -59,7 +74,7 @@ export interface CoreAdminRoutesProps { * /> * ) */ - dashboard?: ComponentType | ReactElement | null; + dashboard?: ComponentType<any> | null; initialLocation?: To; @@ -87,7 +102,7 @@ export interface CoreAdminRoutesProps { /** * The component displayed while fetching the auth provider if the admin child is an async function */ - loading?: ComponentType<any> | ReactElement | null; + loading?: ComponentType<any> | null; /** * The page to display when the admin has no Resource children @@ -109,7 +124,7 @@ export interface CoreAdminRoutesProps { * </Admin> * ); */ - ready?: ComponentType<any> | ReactElement | null; + ready?: ComponentType<any> | null; /** * Flag to require authentication for all routes. Defaults to false. @@ -136,16 +151,20 @@ export interface CoreAdminRoutesProps { export function CoreAdminRoutes(props: CoreAdminRoutesProps) { useScrollToTop(); - const [routes, status] = useConfigureAdminRouterFromChildren(props.children); + const { + routesWithLayout, + routesWithoutLayout, + status, + } = useConfigureAdminRouterFromChildren(props.children); const { - catchAll: catchAllElement, - dashboard, + catchAll: CatchAll, + dashboard: Dashboard, initialLocation, layout: Layout = DefaultLayout, - loading: loadingElement, + loading: Loading, requireAuth, - ready: readyElement, + ready: Ready, } = props; const [onlyAnonymousRoutes, setOnlyAnonymousRoutes] = useState(requireAuth); @@ -168,23 +187,25 @@ export function CoreAdminRoutes(props: CoreAdminRoutesProps) { }, [checkAuth, requireAuth]); if (status === 'empty') { - if (!readyElement) { + if (!Ready) { throw new Error( 'The admin is empty. Please provide an empty component, ' + 'or pass Route or CustomRoutes as children.' ); } - return getReactElement(readyElement); + return <Ready />; } if (status === 'loading' || checkAuthLoading) { return ( <Routes> + {/* Render the routes that were outside the child function. */} + {routesWithoutLayout} <Route path="*" element={ <div style={{ height: '100vh' }}> - {loadingElement ? getReactElement(loadingElement) : 'loading...'} + {Loading ? <Loading /> : null} </div> } /> @@ -195,6 +216,7 @@ export function CoreAdminRoutes(props: CoreAdminRoutesProps) { if (onlyAnonymousRoutes) { return ( <Routes> + {routesWithoutLayout} <Route path="*" element={<LogoutOnMount />} @@ -205,28 +227,31 @@ export function CoreAdminRoutes(props: CoreAdminRoutesProps) { return ( <Routes> + {routesWithoutLayout} <Route path="/*" element={ - <HasDashboardContextProvider value={!!dashboard}> - <Layout> - <Routes> - {routes} - - {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 value={!!Dashboard}> + <AccessControlProvider> + <Layout> + <Routes> + {routesWithLayout} + + {Dashboard + ? (<Route path="/" element={<Dashboard />} />) + : (initialLocation && initialLocation !== "/") + ? (<Route path="/" element={<Navigate to={initialLocation} />} />) + : null} + + {CatchAll ? ( + <Route + path="*" + element={<CatchAll />} + /> + ) : null} + </Routes> + </Layout> + </AccessControlProvider> </HasDashboardContextProvider> } /> diff --git a/packages/rakit/src/core/CoreAdminUI.tsx b/packages/rakit/src/core/CoreAdminUI.tsx index d933fef..12a595c 100644 --- a/packages/rakit/src/core/CoreAdminUI.tsx +++ b/packages/rakit/src/core/CoreAdminUI.tsx @@ -1,12 +1,12 @@ 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 { DefaultTitleContextProvider } from "./DefaultTitleContextProvider"; import { ErrorBoundary } from "./ErrorBoundary"; import { AdminChildren, CoreLayoutProps } from "./types"; +import { DefaultReady } from "./DefaultReady"; export interface CoreAdminUIProps { /** @@ -29,7 +29,7 @@ export interface CoreAdminUIProps { * </Admin> * ); */ - authCallbackPage?: ComponentType<any> | ReactElement | null; + authCallbackPage?: ComponentType<any> | null; /** * A catch-all react component to display when the URL does not match any @@ -61,7 +61,7 @@ export interface CoreAdminUIProps { * </Admin> * ); */ - catchAll?: ComponentType<any> | ReactElement | null; + catchAll?: ComponentType<any> | null; children?: AdminChildren; @@ -80,7 +80,7 @@ export interface CoreAdminUIProps { * </Admin> * ); */ - dashboard?: ComponentType | ReactElement | null; + dashboard?: ComponentType | null; /** * Set to true to disable anonymous telemetry collection @@ -111,7 +111,7 @@ export interface CoreAdminUIProps { * </Admin> * ); */ - error?: ComponentType<any> | ReactElement | null; + error?: ComponentType<any> | null; initialLocation?: To; @@ -139,7 +139,7 @@ export interface CoreAdminUIProps { /** * The component displayed while fetching the auth provider if the admin child is an async function */ - loading?: ComponentType<any> | ReactElement | null; + loading?: ComponentType<any> | null; /** * The component displayed when the user visits the /login page @@ -160,7 +160,7 @@ export interface CoreAdminUIProps { * </Admin> * ); */ - loginPage?: ComponentType<any> | ReactElement | null; + loginPage?: ComponentType<any> | null; /** * The page to display when the admin has no Resource children @@ -182,7 +182,7 @@ export interface CoreAdminUIProps { * </Admin> * ); */ - ready?: ComponentType<any> | ReactElement | null; + ready?: ComponentType<any> | null; /** * Flag to require authentication for all routes. Defaults to false. @@ -223,7 +223,7 @@ export interface CoreAdminUIProps { export const CoreAdminUI = (props: CoreAdminUIProps) => { const { - authCallbackPage, + authCallbackPage: AuthCallbackPage, catchAll, children, dashboard, @@ -232,8 +232,8 @@ export const CoreAdminUI = (props: CoreAdminUIProps) => { initialLocation, layout = DefaultLayout, loading, - loginPage, - ready = Ready, + loginPage: LoginPage, + ready = DefaultReady, requireAuth = false, title = 'React Admin', } = props; @@ -256,17 +256,17 @@ export const CoreAdminUI = (props: CoreAdminUIProps) => { <DefaultTitleContextProvider value={title}> <ErrorBoundary error={error}> <Routes> - {loginPage != null ? ( + {LoginPage != null ? ( <Route path="/login" - element={getReactElement(loginPage)} + element={<LoginPage />} /> ) : null} - {authCallbackPage != null ? ( + {AuthCallbackPage != null ? ( <Route path="/auth-callback" - element={getReactElement(authCallbackPage)} + element={<AuthCallbackPage />} /> ) : null} diff --git a/packages/rakit/src/core/DefaultReady.tsx b/packages/rakit/src/core/DefaultReady.tsx new file mode 100644 index 0000000..089821b --- /dev/null +++ b/packages/rakit/src/core/DefaultReady.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; + +const styles = { + root: { + width: '100vw', + height: '100vh', + display: 'flex', + flexDirection: 'column' as 'column', + fontFamily: '"Roboto", sans-serif', + }, + main: { + flex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + textAlign: 'center' as 'center', + flexDirection: 'column' as 'column', + background: 'linear-gradient(135deg, #00023b 0%, #00023b 50%, #313264 100%)', + color: 'white', + fontSize: '1.5em', + fontWeight: 'bold' as 'bold', + }, + secondary: { + height: '20vh', + background: '#e8e8e8', + color: 'black', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-evenly', + }, + link: { + textAlign: 'center' as 'center', + width: 150, + display: 'block', + textDecoration: 'none', + color: 'black', + opacity: 0.7, + }, + linkHovered: { + opacity: 1, + }, + image: { + width: 50, + }, + logo: { + height: 100, + }, +}; + +const Button = ({ img, label }: { + img: string; + label: string; + href: string +}) => { + const [hovered, setHovered] = useState(false); + return ( + <div> + <a + href="#" + style={ + hovered + ? { ...styles.link, ...styles.linkHovered } + : styles.link + } + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + <img src={img} alt={label} style={styles.image} /> + <br /> + {label} + </a> + </div> + ); +}; + +export function DefaultReady() { + if (process.env.NODE_ENV === 'production') { + return <span /> + } + return ( + <div style={styles.root}> + <div style={styles.main}> + <img + style={styles.logo} + src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDIzLjAuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkNhbHF1ZV8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCIKCSB2aWV3Qm94PSIwIDAgMTMxIDEzMSIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMTMxIDEzMTsiIHhtbDpzcGFjZT0icHJlc2VydmUiPgo8c3R5bGUgdHlwZT0idGV4dC9jc3MiPgoJLnN0MHtmaWxsOiMyMjI0NTg7fQoJLnN0MXtmaWxsOiM1MTUzN0Q7fQoJLnN0MntmaWxsOiNBM0E0Qjk7fQoJLnN0M3tmaWxsOiMwMDAyM0I7fQoJLnN0NHtmaWxsOiNGRkZGRkY7fQoJLnN0NXtlbmFibGUtYmFja2dyb3VuZDpuZXcgICAgO30KPC9zdHlsZT4KPHRpdGxlPkxvZ29fc29tYnJlX2FpPC90aXRsZT4KPGcgaWQ9IlJlY3RhbmdsZV81NiI+Cgk8Zz4KCQk8cmVjdCB4PSIxOS4xIiB5PSIxOSIgdHJhbnNmb3JtPSJtYXRyaXgoMC41IC0wLjg2NiAwLjg2NiAwLjUgLTIzLjkyMjYgODkuNTQ2KSIgY2xhc3M9InN0MCIgd2lkdGg9IjkyLjkiIGhlaWdodD0iOTIuOSIvPgoJCTxwYXRoIGNsYXNzPSJzdDEiIGQ9Ik04MywxMzAuM0wwLjgsODIuOUw0OC4yLDAuN2w4Mi4yLDQ3LjVMODMsMTMwLjN6IE0zLjUsODIuMWw3OC43LDQ1LjVsNDUuNS03OC43TDQ5LDMuNEwzLjUsODIuMXoiLz4KCTwvZz4KPC9nPgo8ZyBpZD0iUmVjdGFuZ2xlXzU2LTIiPgoJPGc+CgkJPHJlY3QgeD0iMTkiIHk9IjE5LjEiIHRyYW5zZm9ybT0ibWF0cml4KDAuODY2IC0wLjUgMC41IDAuODY2IC0yMy45Nzc3IDQxLjUyNykiIGNsYXNzPSJzdDAiIHdpZHRoPSI5Mi45IiBoZWlnaHQ9IjkyLjkiLz4KCQk8cGF0aCBjbGFzcz0ic3QyIiBkPSJNNDcuOSwxMzFMMCw0OEw4My4xLDBsNDgsODMuMUw0Ny45LDEzMXogTTQuMSw0OS4xbDQ1LDc3LjlsNzcuOS00NUw4Miw0LjFMNC4xLDQ5LjF6Ii8+Cgk8L2c+CjwvZz4KPGcgaWQ9IlJlY3RhbmdsZV81Ni0zIj4KCTxnPgoJCTxyZWN0IHg9IjE5LjEiIHk9IjE5IiBjbGFzcz0ic3QzIiB3aWR0aD0iOTIuOSIgaGVpZ2h0PSI5Mi45Ii8+CgkJPHBhdGggY2xhc3M9InN0NCIgZD0iTTExNC41LDExNC41SDE2LjZWMTYuNWg5Ny45VjExNC41eiBNMjEuNiwxMDkuNWg4Ny45VjIxLjVIMjEuNlYxMDkuNXoiLz4KCTwvZz4KPC9nPgo8ZyBpZD0iUmEiPgoJPGcgY2xhc3M9InN0NSI+CgkJPHBhdGggY2xhc3M9InN0NCIgZD0iTTU5LDg2LjdsLTYuNy0xOS4yaC0xLjJIMzguOXYxOS4yaC01LjZWMzguNWgxOC41YzMuNiwwLDYuMywwLjYsOC4xLDEuOGMxLjgsMS4yLDMsMi44LDMuNSw0LjgKCQkJYzAuNSwyLDAuOCw0LjYsMC44LDcuOGMwLDMuNS0wLjQsNi40LTEuMyw4LjdjLTAuOCwyLjMtMi42LDMuOS01LjMsNC44TDY1LDg2LjdINTl6IE01NS43LDYxLjZjMS4yLTAuNywyLTEuNywyLjQtMwoJCQljMC40LTEuMywwLjYtMy4yLDAuNi01LjZjMC0yLjUtMC4yLTQuMy0wLjUtNS42Yy0wLjMtMS4zLTEuMS0yLjItMi4zLTIuOWMtMS4yLTAuNy0zLTEtNS41LTFIMzguOXYxOS4xSDUwCgkJCUM1Mi41LDYyLjYsNTQuNCw2Mi4zLDU1LjcsNjEuNnoiLz4KCQk8cGF0aCBjbGFzcz0ic3Q0IiBkPSJNNzQuMyw4NWMtMS42LTEuNS0yLjUtNC4yLTIuNS04LjJjMC0yLjcsMC4zLTQuOCwwLjktNi4zYzAuNi0xLjUsMS42LTIuNiwzLTMuM2MxLjQtMC43LDMuNC0xLDYtMQoJCQljMS4zLDAsNS4xLDAuMSwxMS40LDAuM3YtMi40YzAtMi45LTAuMi01LTAuNy02LjJjLTAuNS0xLjItMS4zLTItMi42LTIuNGMtMS4yLTAuMy0zLjMtMC41LTYuMy0wLjVjLTEuMywwLTMsMC4xLTQuOSwwLjIKCQkJYy0yLDAuMS0zLjYsMC4zLTQuOCwwLjV2LTQuM2MzLjMtMC43LDcuMS0xLDExLjQtMWMzLjcsMCw2LjUsMC40LDguNCwxLjJjMS44LDAuOCwzLjEsMi4yLDMuOCw0LjFjMC43LDEuOSwxLDQuNywxLDguNHYyMi41aC00LjgKCQkJbC0wLjMtNWgtMC4zYy0wLjgsMi4yLTIuMiwzLjctNC4xLDQuNGMtMS45LDAuNy00LjEsMS4xLTYuNiwxLjFDNzguNiw4Ny4yLDc2LDg2LjUsNzQuMyw4NXogTTg5LjEsODJjMS4yLTAuNCwyLjItMS4yLDIuOC0yLjQKCQkJYzAuOS0xLjgsMS4zLTQuMywxLjMtNy4zdi0yaC0xMGMtMS43LDAtMywwLjItMy44LDAuNWMtMC44LDAuMy0xLjQsMC45LTEuNywxLjhjLTAuMywwLjktMC41LDIuMi0wLjUsNGMwLDEuOCwwLjIsMy4xLDAuNiwzLjkKCQkJYzAuNCwwLjgsMS4xLDEuNCwyLDEuOGMxLDAuMywyLjUsMC41LDQuNSwwLjVDODYuMiw4Mi42LDg3LjgsODIuNCw4OS4xLDgyeiIvPgoJPC9nPgo8L2c+Cjwvc3ZnPgo=" + alt="react-admin logo" /> + <h1>Welcome to Rakit</h1> + <div> + Your application is properly configured. + <br /> + Now you can add a <Route> as child of + <Admin>. + </div> + </div> + <div style={styles.secondary}> + <Button + href="https://marmelab.com/react-admin/documentation.html" + img="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDIzLjAuMCwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiIHdpZHRoPSIyNHB4IgoJIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAyNCAyNCIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnIGlkPSJCb3VuZGluZ19Cb3giPgoJPHJlY3QgZmlsbD0ibm9uZSIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ii8+CjwvZz4KPGcgaWQ9IkZsYXQiPgoJPGcgaWQ9InVpX3g1Rl9zcGVjX3g1Rl9oZWFkZXJfY29weV8yIj4KCTwvZz4KCTxnPgoJCTxwYXRoIGQ9Ik0yMSw1Yy0xLjExLTAuMzUtMi4zMy0wLjUtMy41LTAuNWMtMS45NSwwLTQuMDUsMC40LTUuNSwxLjVjLTEuNDUtMS4xLTMuNTUtMS41LTUuNS0xLjVTMi40NSw0LjksMSw2djE0LjY1CgkJCWMwLDAuMjUsMC4yNSwwLjUsMC41LDAuNWMwLjEsMCwwLjE1LTAuMDUsMC4yNS0wLjA1QzMuMSwyMC40NSw1LjA1LDIwLDYuNSwyMGMxLjk1LDAsNC4wNSwwLjQsNS41LDEuNWMxLjM1LTAuODUsMy44LTEuNSw1LjUtMS41CgkJCWMxLjY1LDAsMy4zNSwwLjMsNC43NSwxLjA1YzAuMSwwLjA1LDAuMTUsMC4wNSwwLjI1LDAuMDVjMC4yNSwwLDAuNS0wLjI1LDAuNS0wLjVWNkMyMi40LDUuNTUsMjEuNzUsNS4yNSwyMSw1eiBNMywxOC41VjcKCQkJYzEuMS0wLjM1LDIuMy0wLjUsMy41LTAuNWMxLjM0LDAsMy4xMywwLjQxLDQuNSwwLjk5djExLjVDOS42MywxOC40MSw3Ljg0LDE4LDYuNSwxOEM1LjMsMTgsNC4xLDE4LjE1LDMsMTguNXogTTIxLDE4LjUKCQkJYy0xLjEtMC4zNS0yLjMtMC41LTMuNS0wLjVjLTEuMzQsMC0zLjEzLDAuNDEtNC41LDAuOTlWNy40OWMxLjM3LTAuNTksMy4xNi0wLjk5LDQuNS0wLjk5YzEuMiwwLDIuNCwwLjE1LDMuNSwwLjVWMTguNXoiLz4KCQk8cGF0aCBvcGFjaXR5PSIwLjMiIGQ9Ik0xMSw3LjQ5QzkuNjMsNi45MSw3Ljg0LDYuNSw2LjUsNi41QzUuMyw2LjUsNC4xLDYuNjUsMyw3djExLjVDNC4xLDE4LjE1LDUuMywxOCw2LjUsMTgKCQkJYzEuMzQsMCwzLjEzLDAuNDEsNC41LDAuOTlWNy40OXoiLz4KCTwvZz4KCTxnPgoJCTxwYXRoIGQ9Ik0xNy41LDEwLjVjMC44OCwwLDEuNzMsMC4wOSwyLjUsMC4yNlY5LjI0QzE5LjIxLDkuMDksMTguMzYsOSwxNy41LDljLTEuMjgsMC0yLjQ2LDAuMTYtMy41LDAuNDd2MS41NwoJCQlDMTQuOTksMTAuNjksMTYuMTgsMTAuNSwxNy41LDEwLjV6Ii8+CgkJPHBhdGggZD0iTTE3LjUsMTMuMTZjMC44OCwwLDEuNzMsMC4wOSwyLjUsMC4yNlYxMS45Yy0wLjc5LTAuMTUtMS42NC0wLjI0LTIuNS0wLjI0Yy0xLjI4LDAtMi40NiwwLjE2LTMuNSwwLjQ3djEuNTcKCQkJQzE0Ljk5LDEzLjM2LDE2LjE4LDEzLjE2LDE3LjUsMTMuMTZ6Ii8+CgkJPHBhdGggZD0iTTE3LjUsMTUuODNjMC44OCwwLDEuNzMsMC4wOSwyLjUsMC4yNnYtMS41MmMtMC43OS0wLjE1LTEuNjQtMC4yNC0yLjUtMC4yNGMtMS4yOCwwLTIuNDYsMC4xNi0zLjUsMC40N3YxLjU3CgkJCUMxNC45OSwxNi4wMiwxNi4xOCwxNS44MywxNy41LDE1LjgzeiIvPgoJPC9nPgo8L2c+Cjwvc3ZnPgo=" + label="Documentation" /> + <Button + href="https://github.com/marmelab/react-admin/tree/master/examples" + img="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSJub25lIiBkPSJNMCAwaDI0djI0SDBWMHoiLz48cGF0aCBkPSJNOS40IDE2LjZMNC44IDEybDQuNi00LjZMOCA2bC02IDYgNiA2IDEuNC0xLjR6bTUuMiAwbDQuNi00LjYtNC42LTQuNkwxNiA2bDYgNi02IDYtMS40LTEuNHoiLz48L3N2Zz4=" + label="Examples" /> + <Button + href="https://stackoverflow.com/questions/tagged/react-admin" + img="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDIzLjAuMSwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiIHdpZHRoPSIyNHB4IgoJIGhlaWdodD0iMjRweCIgdmlld0JveD0iMCAwIDI0IDI0IiBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAyNCAyNCIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+CjxnIGlkPSJCb3VuZGluZ19Cb3giPgoJPHJlY3QgZmlsbD0ibm9uZSIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ii8+CjwvZz4KPGcgaWQ9IkZsYXQiPgoJPGcgaWQ9InVpX3g1Rl9zcGVjX3g1Rl9oZWFkZXJfY29weV8yIj4KCTwvZz4KCTxnPgoJCTxjaXJjbGUgb3BhY2l0eT0iMC4zIiBjeD0iOSIgY3k9IjgiIHI9IjIiLz4KCQk8cGF0aCBvcGFjaXR5PSIwLjMiIGQ9Ik05LDE1Yy0yLjcsMC01LjgsMS4yOS02LDIuMDFMMywxOGgxMnYtMUMxNC44LDE2LjI5LDExLjcsMTUsOSwxNXoiLz4KCQk8cGF0aCBkPSJNMTYuNjcsMTMuMTNDMTguMDQsMTQuMDYsMTksMTUuMzIsMTksMTd2M2g0di0zQzIzLDE0LjgyLDE5LjQzLDEzLjUzLDE2LjY3LDEzLjEzeiIvPgoJCTxwYXRoIGQ9Ik0xNSwxMmMyLjIxLDAsNC0xLjc5LDQtNGMwLTIuMjEtMS43OS00LTQtNGMtMC40NywwLTAuOTEsMC4xLTEuMzMsMC4yNEMxNC41LDUuMjcsMTUsNi41OCwxNSw4cy0wLjUsMi43My0xLjMzLDMuNzYKCQkJQzE0LjA5LDExLjksMTQuNTMsMTIsMTUsMTJ6Ii8+CgkJPHBhdGggZD0iTTksMTJjMi4yMSwwLDQtMS43OSw0LTRjMC0yLjIxLTEuNzktNC00LTRTNSw1Ljc5LDUsOEM1LDEwLjIxLDYuNzksMTIsOSwxMnogTTksNmMxLjEsMCwyLDAuOSwyLDJjMCwxLjEtMC45LDItMiwyCgkJCVM3LDkuMSw3LDhDNyw2LjksNy45LDYsOSw2eiIvPgoJCTxwYXRoIGQ9Ik05LDEzYy0yLjY3LDAtOCwxLjM0LTgsNHYzaDE2di0zQzE3LDE0LjM0LDExLjY3LDEzLDksMTN6IE0xNSwxOEgzbDAtMC45OUMzLjIsMTYuMjksNi4zLDE1LDksMTVzNS44LDEuMjksNiwyVjE4eiIvPgoJPC9nPgo8L2c+Cjwvc3ZnPgo=" + label="Community" /> + </div> + </div> + ); +}; diff --git a/packages/rakit/src/core/RoutesWithoutLayout.tsx b/packages/rakit/src/core/RoutesWithoutLayout.tsx new file mode 100644 index 0000000..79869af --- /dev/null +++ b/packages/rakit/src/core/RoutesWithoutLayout.tsx @@ -0,0 +1,5 @@ +import { ReactNode } from "react"; + +export function RoutesWithoutLayout(_props: { children?: ReactNode }) { + return null; +} diff --git a/packages/rakit/src/core/index.ts b/packages/rakit/src/core/index.ts index 68a1cb2..30976f0 100644 --- a/packages/rakit/src/core/index.ts +++ b/packages/rakit/src/core/index.ts @@ -6,10 +6,12 @@ export * from "./CoreAdminRoutes"; export * from "./CoreAdminUI"; export * from "./DefaultError"; export * from "./DefaultLayout"; +export * from "./DefaultReady"; export * from "./DefaultTitleContextProvider"; export * from "./ErrorBoundary"; export * from "./ErrorContextProvider"; export * from "./HasDashboardContextProvider"; +export * from "./RoutesWithoutLayout"; export * from "./types"; export * from "./useBasename"; export * from "./useConfigureAdminRouterFromChildren"; diff --git a/packages/rakit/src/core/useConfigureAdminRouterFromChildren.tsx b/packages/rakit/src/core/useConfigureAdminRouterFromChildren.tsx index c6b170d..2cac995 100644 --- a/packages/rakit/src/core/useConfigureAdminRouterFromChildren.tsx +++ b/packages/rakit/src/core/useConfigureAdminRouterFromChildren.tsx @@ -1,9 +1,12 @@ import { Children, + Dispatch, Fragment, isValidElement, + PropsWithChildren, ReactElement, ReactNode, + SetStateAction, useCallback, useEffect, useState @@ -11,6 +14,7 @@ import { import { Route } from "react-router-dom"; import { useLogout, usePermissions } from "../auth"; import { useSafeSetState } from "../util"; +import { RoutesWithoutLayout } from "./RoutesWithoutLayout"; import { AdminChildren, AdminRouterStatus, @@ -47,20 +51,16 @@ function getRenderRoutesFunctions(children: AdminChildren): RenderRoutesFunction return []; } -export function useConfigureAdminRouterFromChildren(children: AdminChildren): [ReactElement[], AdminRouterStatus] { +export function useConfigureAdminRouterFromChildren(children: AdminChildren) { // 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 // initialize the status at the next stop const doLogout = useLogout(); const { permissions, isPending } = usePermissions(); - const [routes, setRoutes] = useState(getRoutesFromNodes(children)); + const [routes, setRoutes, mergeRoutes] = useRoutesState(getRoutesFromNodes(children)); const [status, setStatus] = useSafeSetState<AdminRouterStatus>(() => getStatus(children, routes)); - const mergeRoutes = useCallback((newRoutes: ReactElement[]) => { - setRoutes(previous => previous.concat(newRoutes)); - }, [setRoutes]); - if (!status) { throw new Error('Status should be defined'); } @@ -70,9 +70,15 @@ export function useConfigureAdminRouterFromChildren(children: AdminChildren): [R const onResolve = () => { funcCounts -= 1; - if (funcCounts <= 0) { - setStatus('ready'); - } + setTimeout(() => { + if (funcCounts <= 0) { + setStatus( + routes.withLayout.length > 0 || routes.withoutLayout.length > 0 + ? 'ready' + : 'empty' + ); + } + }) } const resolveChildFunction = async (childFunc: RenderRoutesFunction) => { @@ -99,11 +105,11 @@ export function useConfigureAdminRouterFromChildren(children: AdminChildren): [R const updateFromChildren = async () => { const functionChild = getRenderRoutesFunctions(children); const newRoutes = getRoutesFromNodes(children); - mergeRoutes(newRoutes); + setRoutes(newRoutes); setStatus( functionChild.length > 0 ? 'loading' - : newRoutes.length > 0 + : newRoutes.withLayout.length > 0 || newRoutes.withoutLayout.length > 0 ? 'ready' : 'empty' ); @@ -131,13 +137,52 @@ export function useConfigureAdminRouterFromChildren(children: AdminChildren): [R setStatus, ]); - return [routes, status]; + return { + routesWithLayout: routes.withLayout, + routesWithoutLayout: routes.withoutLayout, + status, + }; } -function getStatus(children: AdminChildren, routes: ReactNode[]): AdminRouterStatus { +type Routes = { + withLayout: ReactNode[], + withoutLayout: ReactNode[], +} + +/* + * A hook that store the routes and resources just like setState but also provides an additional function + * to merge new routes and resources with the existing ones. + */ +const useRoutesState = (initialState: Routes): [ + Routes, + Dispatch<SetStateAction<Routes>>, + (newRoutes: Routes) => void, +] => { + const [routes, setRoutes] = useState(initialState); + + const mergeRoutes = useCallback( + (newRouteGroups: Routes) => { + setRoutes(previous => ({ + withLayout: previous.withLayout.concat( + newRouteGroups.withLayout + ), + withoutLayout: + previous.withoutLayout.concat( + newRouteGroups.withoutLayout + ), + })); + }, + [] + ); + + return [routes, setRoutes, mergeRoutes]; +}; + + +function getStatus(children: AdminChildren, routes: Routes): AdminRouterStatus { return getRenderRoutesFunctions(children).length > 0 ? 'loading' - : routes.length > 0 + : routes.withLayout.length > 0 || routes.withoutLayout.length ? 'ready' : 'empty'; } @@ -145,11 +190,15 @@ function getStatus(children: AdminChildren, routes: ReactNode[]): AdminRouterSta /** * Inspect the children and return an array of routable elements */ -function getRoutesFromNodes(children: AdminChildren): ReactElement[] { - const routes: ReactElement[] = []; +function getRoutesFromNodes(children: AdminChildren): Routes { + const withLayout: ReactNode[] = []; + const withoutLayout: ReactNode[] = []; if (isRenderRoutesFunction(children)) { - return routes; + return { + withLayout, + withoutLayout, + } } if ( @@ -166,9 +215,16 @@ function getRoutesFromNodes(children: AdminChildren): ReactElement[] { // conditionals in their route config. return; } else if (node.type === Fragment) { - routes.push(...getRoutesFromNodes(node.props.children)); + const customRoutesFromFragment = getRoutesFromNodes(node.props.children); + withLayout.push(...customRoutesFromFragment.withLayout); + withoutLayout.push(...customRoutesFromFragment.withoutLayout); } else if (node.type === Route) { - routes.push(node); + withLayout.push(node); + } else if (node.type === RoutesWithoutLayout) { + const customRoutesElement = node as ReactElement<PropsWithChildren>; + if (customRoutesElement.props.children != null) { + withoutLayout.push(customRoutesElement.props.children); + } } else if (process.env.NODE_ENV !== "production") { // TODO 获取 node.type 的 displayName const name = typeof node.type === "string" @@ -176,10 +232,13 @@ function getRoutesFromNodes(children: AdminChildren): ReactElement[] { : ((node.type as any).displayName || node.type.name); throw new Error( `[${name}] is not a <Route> component. ` + - `All component children of <Routes> must be a <Route> or <React.Fragment>` + `All component children of <Routes> must be a <Route> or <RoutesWithoutLayout> or <React.Fragment>` ); } }); - return routes; + return { + withLayout, + withoutLayout, + }; }