Merge branch '2.0' of github.com:payloadcms/payload into 2.0
This commit is contained in:
10
package.json
10
package.json
@@ -29,6 +29,7 @@
|
||||
"test:e2e": "npx playwright install --with-deps && ts-node -T ./test/runE2E.ts",
|
||||
"test:e2e:debug": "cross-env PWDEBUG=1 DISABLE_LOGGING=true playwright test",
|
||||
"test:e2e:headed": "cross-env DISABLE_LOGGING=true playwright test --headed",
|
||||
"test:int:postgres": "cross-env PAYLOAD_DATABASE=postgres DISABLE_LOGGING=true jest --forceExit --detectOpenHandles",
|
||||
"test:int": "cross-env DISABLE_LOGGING=true jest --forceExit --detectOpenHandles",
|
||||
"translateNewKeys": "pnpm --filter payload run translateNewKeys"
|
||||
},
|
||||
@@ -48,9 +49,18 @@
|
||||
"express": "4.18.2",
|
||||
"form-data": "3.0.1",
|
||||
"get-port": "5.1.1",
|
||||
"graphql-request": "3.7.0",
|
||||
"isomorphic-fetch": "3.0.0",
|
||||
"jest": "29.6.4",
|
||||
"jest-environment-jsdom": "29.6.4",
|
||||
"jwt-decode": "3.1.2",
|
||||
"mongodb-memory-server": "8.13.0",
|
||||
"node-fetch": "2.6.12",
|
||||
"prettier": "^3.0.3",
|
||||
"qs": "6.11.2",
|
||||
"react": "18.2.0",
|
||||
"react-i18next": "11.18.6",
|
||||
"react-router-dom": "5.3.4",
|
||||
"rimraf": "3.0.2",
|
||||
"shelljs": "0.8.5",
|
||||
"ts-node": "10.9.1",
|
||||
|
||||
@@ -213,7 +213,6 @@
|
||||
"get-port": "5.1.1",
|
||||
"glob": "8.1.0",
|
||||
"graphql-request": "3.7.0",
|
||||
"mongodb-memory-server": "8.13.0",
|
||||
"node-fetch": "2.6.12",
|
||||
"nodemon": "3.0.1",
|
||||
"object.assign": "4.1.4",
|
||||
|
||||
@@ -10,7 +10,6 @@ import React from 'react'
|
||||
import { BrowserRouter as Router } from 'react-router-dom'
|
||||
import { Slide, ToastContainer } from 'react-toastify'
|
||||
|
||||
import Routes from './components/Routes'
|
||||
import { StepNavProvider } from './components/elements/StepNav'
|
||||
import { AuthProvider } from './components/utilities/Auth'
|
||||
import { ConfigProvider } from './components/utilities/Config'
|
||||
@@ -21,6 +20,7 @@ import { LocaleProvider } from './components/utilities/Locale'
|
||||
import { PreferencesProvider } from './components/utilities/Preferences'
|
||||
import { SearchParamsProvider } from './components/utilities/SearchParams'
|
||||
import { ThemeProvider } from './components/utilities/Theme'
|
||||
import { Routes } from './components/views/Routes'
|
||||
import './scss/app.scss'
|
||||
|
||||
const Root = () => (
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
import React, { Fragment, Suspense, lazy, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Redirect, Route, Switch } from 'react-router-dom'
|
||||
|
||||
import { requests } from '../api'
|
||||
import { LoadingOverlayToggle } from './elements/Loading'
|
||||
import StayLoggedIn from './modals/StayLoggedIn'
|
||||
import DefaultTemplate from './templates/Default'
|
||||
import { useAuth } from './utilities/Auth'
|
||||
import { useConfig } from './utilities/Config'
|
||||
import { DocumentInfoProvider } from './utilities/DocumentInfo'
|
||||
import { useLocale } from './utilities/Locale'
|
||||
import Version from './views/Version'
|
||||
import Versions from './views/Versions'
|
||||
import List from './views/collections/List'
|
||||
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Dashboard = lazy(() => import('./views/Dashboard'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const ForgotPassword = lazy(() => import('./views/ForgotPassword'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Login = lazy(() => import('./views/Login'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Logout = lazy(() => import('./views/Logout'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const NotFound = lazy(() => import('./views/NotFound'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Verify = lazy(() => import('./views/Verify'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const CreateFirstUser = lazy(() => import('./views/CreateFirstUser'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Edit = lazy(() => import('./views/collections/Edit'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const EditGlobal = lazy(() => import('./views/Global'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const ResetPassword = lazy(() => import('./views/ResetPassword'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Unauthorized = lazy(() => import('./views/Unauthorized'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Account = lazy(() => import('./views/Account'))
|
||||
|
||||
const Routes: React.FC = () => {
|
||||
const [initialized, setInitialized] = useState(null)
|
||||
const { permissions, refreshCookie, user } = useAuth()
|
||||
const { i18n } = useTranslation()
|
||||
const { code: locale } = useLocale()
|
||||
|
||||
const canAccessAdmin = permissions?.canAccessAdmin
|
||||
|
||||
const config = useConfig()
|
||||
|
||||
const {
|
||||
admin: {
|
||||
components: { routes: customRoutes } = {},
|
||||
inactivityRoute: logoutInactivityRoute,
|
||||
logoutRoute,
|
||||
user: userSlug,
|
||||
},
|
||||
collections,
|
||||
globals,
|
||||
routes,
|
||||
} = config
|
||||
|
||||
const isLoadingUser = Boolean(
|
||||
typeof user === 'undefined' || (user && typeof canAccessAdmin === 'undefined'),
|
||||
)
|
||||
const userCollection = collections.find(({ slug }) => slug === userSlug)
|
||||
|
||||
useEffect(() => {
|
||||
const { slug } = userCollection
|
||||
|
||||
if (!userCollection.auth.disableLocalStrategy) {
|
||||
requests
|
||||
.get(`${routes.api}/${slug}/init`, {
|
||||
headers: {
|
||||
'Accept-Language': i18n.language,
|
||||
},
|
||||
})
|
||||
.then((res) =>
|
||||
res.json().then((data) => {
|
||||
if (data && 'initialized' in data) {
|
||||
setInitialized(data.initialized)
|
||||
}
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
setInitialized(true)
|
||||
}
|
||||
}, [i18n.language, routes, userCollection])
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LoadingOverlayToggle name="route-suspense" show />}>
|
||||
<LoadingOverlayToggle name="route-loader" show={isLoadingUser} />
|
||||
<Route
|
||||
path={routes.admin}
|
||||
render={({ match }) => {
|
||||
if (initialized === false) {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={`${match.url}/create-first-user`}>
|
||||
<CreateFirstUser setInitialized={setInitialized} />
|
||||
</Route>
|
||||
<Route>
|
||||
<Redirect to={`${match.url}/create-first-user`} />
|
||||
</Route>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
if (initialized === true && !isLoadingUser) {
|
||||
return (
|
||||
<Switch>
|
||||
{Array.isArray(customRoutes) &&
|
||||
customRoutes.map(({ Component, exact, path, sensitive, strict }) => (
|
||||
<Route
|
||||
exact={exact}
|
||||
key={`${match.url}${path}`}
|
||||
path={`${match.url}${path}`}
|
||||
sensitive={sensitive}
|
||||
strict={strict}
|
||||
>
|
||||
<Component canAccessAdmin={canAccessAdmin} user={user} />
|
||||
</Route>
|
||||
))}
|
||||
<Route path={`${match.url}/login`}>
|
||||
<Login />
|
||||
</Route>
|
||||
<Route path={`${match.url}${logoutRoute}`}>
|
||||
<Logout />
|
||||
</Route>
|
||||
<Route path={`${match.url}${logoutInactivityRoute}`}>
|
||||
<Logout inactivity />
|
||||
</Route>
|
||||
{!userCollection.auth.disableLocalStrategy && (
|
||||
<Route path={`${match.url}/forgot`}>
|
||||
<ForgotPassword />
|
||||
</Route>
|
||||
)}
|
||||
|
||||
{!userCollection.auth.disableLocalStrategy && (
|
||||
<Route path={`${match.url}/reset/:token`}>
|
||||
<ResetPassword />
|
||||
</Route>
|
||||
)}
|
||||
|
||||
{collections.map((collection) => {
|
||||
if (collection?.auth?.verify && !collection.auth.disableLocalStrategy) {
|
||||
return (
|
||||
<Route
|
||||
exact
|
||||
key={`${collection.slug}-verify`}
|
||||
path={`${match.url}/${collection.slug}/verify/:token`}
|
||||
>
|
||||
<Verify collection={collection} />
|
||||
</Route>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
|
||||
<Route>
|
||||
{user ? (
|
||||
<Fragment>
|
||||
{canAccessAdmin && (
|
||||
<DefaultTemplate>
|
||||
<Switch>
|
||||
<Route exact path={`${match.url}/`}>
|
||||
<Dashboard />
|
||||
</Route>
|
||||
<Route path={`${match.url}/account`}>
|
||||
<DocumentInfoProvider
|
||||
collection={collections.find(({ slug }) => slug === userSlug)}
|
||||
id={user.id}
|
||||
>
|
||||
<Account />
|
||||
</DocumentInfoProvider>
|
||||
</Route>
|
||||
{collections
|
||||
.filter(
|
||||
({ admin: { hidden } }) =>
|
||||
!(typeof hidden === 'function' ? hidden({ user }) : hidden),
|
||||
)
|
||||
.reduce((collectionRoutes, collection) => {
|
||||
const routesToReturn = [
|
||||
...collectionRoutes,
|
||||
<Route
|
||||
exact
|
||||
key={`${collection.slug}-list`}
|
||||
path={`${match.url}/collections/${collection.slug}`}
|
||||
>
|
||||
{permissions?.collections?.[collection.slug]?.read
|
||||
?.permission ? (
|
||||
<List collection={collection} />
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
<Route
|
||||
exact
|
||||
key={`${collection.slug}-create`}
|
||||
path={`${match.url}/collections/${collection.slug}/create`}
|
||||
>
|
||||
{permissions?.collections?.[collection.slug]?.create
|
||||
?.permission ? (
|
||||
<DocumentInfoProvider collection={collection} idFromParams>
|
||||
<Edit collection={collection} />
|
||||
</DocumentInfoProvider>
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
<Route
|
||||
exact
|
||||
key={`${collection.slug}-edit`}
|
||||
path={`${match.url}/collections/${collection.slug}/:id`}
|
||||
>
|
||||
{permissions?.collections?.[collection.slug]?.read
|
||||
?.permission ? (
|
||||
<DocumentInfoProvider collection={collection} idFromParams>
|
||||
<Edit collection={collection} isEditing />
|
||||
</DocumentInfoProvider>
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
]
|
||||
|
||||
if (collection.versions) {
|
||||
routesToReturn.push(
|
||||
<Route
|
||||
exact
|
||||
key={`${collection.slug}-versions`}
|
||||
path={`${match.url}/collections/${collection.slug}/:id/versions`}
|
||||
>
|
||||
{permissions?.collections?.[collection.slug]?.readVersions
|
||||
?.permission ? (
|
||||
<Versions collection={collection} />
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
)
|
||||
|
||||
routesToReturn.push(
|
||||
<Route
|
||||
exact
|
||||
key={`${collection.slug}-view-version`}
|
||||
path={`${match.url}/collections/${collection.slug}/:id/versions/:versionID`}
|
||||
>
|
||||
{permissions?.collections?.[collection.slug]?.readVersions
|
||||
?.permission ? (
|
||||
<DocumentInfoProvider collection={collection} idFromParams>
|
||||
<Version collection={collection} />
|
||||
</DocumentInfoProvider>
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
)
|
||||
}
|
||||
|
||||
return routesToReturn
|
||||
}, [])}
|
||||
{globals &&
|
||||
globals
|
||||
.filter(
|
||||
({ admin: { hidden } }) =>
|
||||
!(typeof hidden === 'function' ? hidden({ user }) : hidden),
|
||||
)
|
||||
.reduce((globalRoutes, global) => {
|
||||
const routesToReturn = [
|
||||
...globalRoutes,
|
||||
<Route
|
||||
exact
|
||||
key={global.slug}
|
||||
path={`${match.url}/globals/${global.slug}`}
|
||||
>
|
||||
{permissions?.globals?.[global.slug]?.read?.permission ? (
|
||||
<DocumentInfoProvider
|
||||
global={global}
|
||||
idFromParams
|
||||
key={`${global.slug}-${locale}`}
|
||||
>
|
||||
<EditGlobal global={global} />
|
||||
</DocumentInfoProvider>
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
]
|
||||
|
||||
if (global.versions) {
|
||||
routesToReturn.push(
|
||||
<Route
|
||||
exact
|
||||
key={`${global.slug}-versions`}
|
||||
path={`${match.url}/globals/${global.slug}/versions`}
|
||||
>
|
||||
{permissions?.globals?.[global.slug]?.readVersions
|
||||
?.permission ? (
|
||||
<Versions global={global} />
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
)
|
||||
|
||||
routesToReturn.push(
|
||||
<Route
|
||||
exact
|
||||
key={`${global.slug}-view-version`}
|
||||
path={`${match.url}/globals/${global.slug}/versions/:versionID`}
|
||||
>
|
||||
{permissions?.globals?.[global.slug]?.readVersions
|
||||
?.permission ? (
|
||||
<Version global={global} />
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
)
|
||||
}
|
||||
|
||||
return routesToReturn
|
||||
}, [])}
|
||||
|
||||
<Route path={`${match.url}*`}>
|
||||
<NotFound />
|
||||
</Route>
|
||||
</Switch>
|
||||
</DefaultTemplate>
|
||||
)}
|
||||
{canAccessAdmin === false && <Unauthorized />}
|
||||
</Fragment>
|
||||
) : (
|
||||
<Redirect
|
||||
to={`${match.url}/login${
|
||||
window.location.pathname.startsWith(routes.admin)
|
||||
? `?redirect=${encodeURIComponent(
|
||||
window.location.pathname.replace(routes.admin, ''),
|
||||
)}`
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</Route>
|
||||
<Route path={`${match.url}*`}>
|
||||
<NotFound />
|
||||
</Route>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
<StayLoggedIn refreshCookie={refreshCookie} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export default Routes
|
||||
@@ -86,9 +86,7 @@ export const Drawer: React.FC<Props> = ({
|
||||
id={`close-drawer__${slug}`}
|
||||
onClick={() => closeModal(slug)}
|
||||
style={{
|
||||
width: `calc(${midBreak ? 'var(--gutter-h)' : 'var(--nav-width)'} + ${
|
||||
drawerDepth - 1
|
||||
} * 25px)`,
|
||||
width: 'var(--gutter-h)',
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
|
||||
@@ -8,11 +8,10 @@ import './index.scss'
|
||||
|
||||
const baseClass = 'eyebrow'
|
||||
|
||||
const Eyebrow: React.FC<Props> = ({ actions }) => (
|
||||
const Eyebrow: React.FC<Props> = () => (
|
||||
<div className={baseClass}>
|
||||
<Gutter className={`${baseClass}__wrap`}>
|
||||
<StepNav />
|
||||
{actions}
|
||||
</Gutter>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
@import '../../../scss/styles';
|
||||
|
||||
.hamburger {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
width: calc(var(--base) / 1.5);
|
||||
height: calc(var(--base) / 1.5);
|
||||
background-color: transparent;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&__line {
|
||||
background-color: var(--theme-text);
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&__top {
|
||||
top: 2px;
|
||||
transform: translate3d(0, 0, 0) rotate(0);
|
||||
}
|
||||
|
||||
&__middle {
|
||||
top: 50%;
|
||||
transform: translate3d(0, -50%, 0) rotate(0);
|
||||
}
|
||||
|
||||
&__bottom {
|
||||
bottom: 2px;
|
||||
transform: translate3d(0, 0, 0) rotate(0);
|
||||
}
|
||||
|
||||
&__x-left {
|
||||
opacity: 0;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate3d(-50%, -50%, 0) rotate(45deg);
|
||||
}
|
||||
|
||||
&__x-right {
|
||||
opacity: 0;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate3d(-50%, -50%, 0) rotate(-45deg);
|
||||
}
|
||||
|
||||
&--active {
|
||||
.hamburger {
|
||||
&__x-left,
|
||||
&__x-right {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__top,
|
||||
&__middle,
|
||||
&__bottom {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'hamburger'
|
||||
|
||||
const Hamburger: React.FC<{
|
||||
isActive?: boolean
|
||||
}> = (props) => {
|
||||
const { isActive = false } = props
|
||||
|
||||
return (
|
||||
<div className={[baseClass, isActive && `${baseClass}--active`].filter(Boolean).join(' ')}>
|
||||
<div className={`${baseClass}__line ${baseClass}__top`} />
|
||||
<div className={`${baseClass}__line ${baseClass}__middle`} />
|
||||
<div className={`${baseClass}__line ${baseClass}__bottom`} />
|
||||
<div className={`${baseClass}__line ${baseClass}__x-left`} />
|
||||
<div className={`${baseClass}__line ${baseClass}__x-right`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Hamburger
|
||||
@@ -28,11 +28,6 @@
|
||||
animation: fade-out ease;
|
||||
}
|
||||
|
||||
&.loading-overlay--withoutNav {
|
||||
left: var(--nav-width);
|
||||
width: calc(100% - var(--nav-width));
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
@@ -16,6 +16,7 @@ type Props = {
|
||||
overlayType?: string
|
||||
show?: boolean
|
||||
}
|
||||
|
||||
export const LoadingOverlay: React.FC<Props> = ({
|
||||
animationDuration,
|
||||
loadingText,
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
@import '../../../../scss/styles.scss';
|
||||
|
||||
.nav-group {
|
||||
width: 100%;
|
||||
margin-bottom: base(0.5);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--base) * 0.25);
|
||||
|
||||
&__toggle {
|
||||
cursor: pointer;
|
||||
color: var(--theme-elevation-400);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
margin-top: base(0.25);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
|
||||
[dir='ltr'] & {
|
||||
padding-right: base(0.5);
|
||||
padding-left: 0;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[dir='rtl'] & {
|
||||
padding-left: base(0.5);
|
||||
padding-right: 0;
|
||||
align-items: flex-start;
|
||||
text-align: right;
|
||||
@@ -27,7 +30,6 @@
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: base(-0.2);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
@@ -44,6 +46,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--base) * 0.25);
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
[dir='ltr'] & {
|
||||
margin-left: auto;
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import AnimateHeight from 'react-animate-height'
|
||||
|
||||
import Chevron from '../../icons/Chevron'
|
||||
import { usePreferences } from '../../utilities/Preferences'
|
||||
import Chevron from '../../../icons/Chevron'
|
||||
import { usePreferences } from '../../../utilities/Preferences'
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'nav-group'
|
||||
@@ -44,7 +44,7 @@ const NavGroup: React.FC<Props> = ({ children, label }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[`${baseClass}`, `${label}`, collapsed && `${baseClass}--collapsed`]
|
||||
className={[baseClass, `${label}`, collapsed && `${baseClass}--collapsed`]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
id={`nav-group-${label}`}
|
||||
@@ -59,7 +59,7 @@ const NavGroup: React.FC<Props> = ({ children, label }) => {
|
||||
onClick={toggleCollapsed}
|
||||
type="button"
|
||||
>
|
||||
<div className={`${baseClass}__label`}>{label}</div>
|
||||
<h4 className={`${baseClass}__label`}>{label}</h4>
|
||||
<Chevron className={`${baseClass}__indicator`} />
|
||||
</button>
|
||||
<AnimateHeight duration={animate ? 200 : 0} height={collapsed ? 0 : 'auto'}>
|
||||
@@ -0,0 +1,126 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
$transTime: 200ms;
|
||||
|
||||
.main-menu {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
|
||||
&__blur-bg {
|
||||
@include blur-bg();
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transition: all $transTime linear;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: base(1) 0 base(2);
|
||||
position: relative;
|
||||
opacity: 0;
|
||||
transform: translateX(calc(var(--base) * -1));
|
||||
transition: transform $transTime linear;
|
||||
overflow: auto;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
&__content-children {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--base);
|
||||
}
|
||||
|
||||
&__close {
|
||||
@extend %btn-reset;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex-shrink: 0;
|
||||
text-indent: -9999px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
transition: none;
|
||||
transition-delay: 0ms;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: calc(var(--base) * 4);
|
||||
content: ' ';
|
||||
background: linear-gradient(to right, rgba(0, 0, 0, 0.25) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
&--is-open {
|
||||
.main-menu {
|
||||
&__content,
|
||||
&__blur-bg,
|
||||
&__close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__close {
|
||||
transition: opacity $transTime ease-in-out;
|
||||
transition-delay: $transTime;
|
||||
}
|
||||
|
||||
&__content {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__link {
|
||||
margin: 0;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&__controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--base) / 2);
|
||||
}
|
||||
|
||||
&.payload__modal-item--exitActive {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@include mid-break {
|
||||
.main-menu {
|
||||
&__close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__content {
|
||||
width: 100%;
|
||||
padding-top: calc(var(--base) * 2);
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { Modal, useModal } from '@faceless-ui/modal'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, NavLink } from 'react-router-dom'
|
||||
|
||||
import type { EntityToGroup, Group } from '../../../utilities/groupNavItems'
|
||||
|
||||
import { getTranslation } from '../../../../utilities/getTranslation'
|
||||
import { EntityType, groupNavItems } from '../../../utilities/groupNavItems'
|
||||
import Account from '../../graphics/Account'
|
||||
import { useAuth } from '../../utilities/Auth'
|
||||
import { useConfig } from '../../utilities/Config'
|
||||
import { Gutter } from '../Gutter'
|
||||
import Localizer from '../Localizer'
|
||||
import Logout from '../Logout'
|
||||
import NavGroup from './NavGroup'
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'main-menu'
|
||||
|
||||
export const mainMenuSlug = 'main-menu'
|
||||
|
||||
export const MainMenuDrawer: React.FC = () => {
|
||||
const { permissions, user } = useAuth()
|
||||
const { closeModal, modalState } = useModal()
|
||||
|
||||
const { i18n, t } = useTranslation('general')
|
||||
|
||||
const {
|
||||
admin: {
|
||||
components: { afterNavLinks, beforeNavLinks },
|
||||
},
|
||||
collections,
|
||||
globals,
|
||||
routes: { admin },
|
||||
} = useConfig()
|
||||
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(modalState[mainMenuSlug]?.isOpen)
|
||||
}, [modalState])
|
||||
|
||||
useEffect(() => {
|
||||
setGroups(
|
||||
groupNavItems(
|
||||
[
|
||||
...collections
|
||||
.filter(
|
||||
({ admin: { hidden } }) =>
|
||||
!(typeof hidden === 'function' ? hidden({ user }) : hidden),
|
||||
)
|
||||
.map((collection) => {
|
||||
const entityToGroup: EntityToGroup = {
|
||||
entity: collection,
|
||||
type: EntityType.collection,
|
||||
}
|
||||
|
||||
return entityToGroup
|
||||
}),
|
||||
...globals
|
||||
.filter(
|
||||
({ admin: { hidden } }) =>
|
||||
!(typeof hidden === 'function' ? hidden({ user }) : hidden),
|
||||
)
|
||||
.map((global) => {
|
||||
const entityToGroup: EntityToGroup = {
|
||||
entity: global,
|
||||
type: EntityType.global,
|
||||
}
|
||||
|
||||
return entityToGroup
|
||||
}),
|
||||
],
|
||||
permissions,
|
||||
i18n,
|
||||
),
|
||||
)
|
||||
}, [collections, globals, permissions, i18n, i18n.language, user])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={[baseClass, isOpen && `${baseClass}--is-open`].filter(Boolean).join(' ')}
|
||||
slug={mainMenuSlug}
|
||||
>
|
||||
<div className={`${baseClass}__blur-bg`} />
|
||||
<nav className={`${baseClass}__content`}>
|
||||
<Gutter className={`${baseClass}__content-children`}>
|
||||
{Array.isArray(beforeNavLinks) &&
|
||||
beforeNavLinks.map((Component, i) => <Component key={i} />)}
|
||||
<h4 className={`${baseClass}__link`}>
|
||||
<NavLink activeClassName="active" id="nav-dashboard" to={admin}>
|
||||
{t('dashboard')}
|
||||
</NavLink>
|
||||
</h4>
|
||||
{groups.map(({ entities, label }, key) => {
|
||||
return (
|
||||
<NavGroup {...{ key, label }}>
|
||||
{entities.map(({ entity, type }, i) => {
|
||||
let entityLabel: string
|
||||
let href: string
|
||||
let id: string
|
||||
|
||||
if (type === EntityType.collection) {
|
||||
href = `${admin}/collections/${entity.slug}`
|
||||
entityLabel = getTranslation(entity.labels.plural, i18n)
|
||||
id = `nav-${entity.slug}`
|
||||
}
|
||||
|
||||
if (type === EntityType.global) {
|
||||
href = `${admin}/globals/${entity.slug}`
|
||||
entityLabel = getTranslation(entity.label, i18n)
|
||||
id = `nav-global-${entity.slug}`
|
||||
}
|
||||
|
||||
return (
|
||||
<h4 className={`${baseClass}__link`} key={i}>
|
||||
<NavLink activeClassName="active" id={id} to={href}>
|
||||
{entityLabel}
|
||||
</NavLink>
|
||||
</h4>
|
||||
)
|
||||
})}
|
||||
</NavGroup>
|
||||
)
|
||||
})}
|
||||
{Array.isArray(afterNavLinks) &&
|
||||
afterNavLinks.map((Component, i) => <Component key={i} />)}
|
||||
<div className={`${baseClass}__controls`}>
|
||||
<Localizer />
|
||||
<Link
|
||||
aria-label={t('authentication:account')}
|
||||
className={`${baseClass}__account`}
|
||||
to={`${admin}/account`}
|
||||
>
|
||||
<Account />
|
||||
</Link>
|
||||
<Logout />
|
||||
</div>
|
||||
</Gutter>
|
||||
</nav>
|
||||
<button
|
||||
aria-label={t('close')}
|
||||
className={`${baseClass}__close`}
|
||||
id={`close-drawer__${mainMenuSlug}`}
|
||||
onClick={() => closeModal(mainMenuSlug)}
|
||||
type="button"
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,208 +1,83 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
.nav {
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
width: var(--nav-width);
|
||||
overflow: hidden;
|
||||
[dir='ltr'] & {
|
||||
border-right: 1px solid var(--theme-elevation-100);
|
||||
}
|
||||
[dir='rtl'] & {
|
||||
border-left: 1px solid var(--theme-elevation-100);
|
||||
}
|
||||
width: 100%;
|
||||
height: calc(var(--base) * 3);
|
||||
z-index: $z-status;
|
||||
// TODO: remove this once the nav has been refactored
|
||||
// eventually it will contain nav items with click events
|
||||
pointer-events: none;
|
||||
|
||||
header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
margin-bottom: base(1.5);
|
||||
|
||||
a,
|
||||
button {
|
||||
display: block;
|
||||
padding: 0;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__brand {
|
||||
[dir='ltr'] & {
|
||||
margin-right: base(1);
|
||||
}
|
||||
[dir='rtl'] & {
|
||||
margin-left: base(1);
|
||||
}
|
||||
}
|
||||
|
||||
&__mobile-menu-btn {
|
||||
background: none;
|
||||
border: 0;
|
||||
&__bg {
|
||||
@include blur-bg;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&--show-bg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// when the modal is open, the nav overlays the top portion
|
||||
// we need to make sure the modal content is clickable
|
||||
// so we disable pointer events on the nav
|
||||
// but reenable them on the modal toggler
|
||||
&--main-menu-open {
|
||||
pointer-events: none;
|
||||
|
||||
.nav__modalToggler {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 0 calc(var(--gutter-h) / 2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__modalToggler {
|
||||
pointer-events: all;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
z-index: 999999;
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
|
||||
&:active,
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__scroll {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: base(1.5) base(1);
|
||||
width: calc(100% + #{base(1)});
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
&__wrap {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: var(--theme-elevation-400);
|
||||
}
|
||||
|
||||
&__controls {
|
||||
margin-top: auto;
|
||||
margin-bottom: 0;
|
||||
|
||||
> * {
|
||||
margin-top: base(1);
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: var(--accessibility-outline);
|
||||
}
|
||||
}
|
||||
|
||||
&__log-out {
|
||||
&:hover {
|
||||
g {
|
||||
transform: translateX(-#{base(0.125)});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nav {
|
||||
a {
|
||||
position: relative;
|
||||
padding: base(0.125) base(1.5) base(0.125) 0;
|
||||
display: flex;
|
||||
text-decoration: none;
|
||||
[dir='rtl'] & {
|
||||
padding: base(0.125) 0 base(0.125) base(1.5);
|
||||
}
|
||||
|
||||
&:focus:not(:focus-visible) {
|
||||
box-shadow: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: normal;
|
||||
font-weight: 600;
|
||||
[dir='ltr'] & {
|
||||
padding-left: base(0.6);
|
||||
}
|
||||
[dir='rtl'] & {
|
||||
padding-right: base(0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__link {
|
||||
svg {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
[dir='ltr'] & {
|
||||
left: - base(0.5);
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
[dir='rtl'] & {
|
||||
right: - base(0.5);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&__scrollable {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
@include mid-break {
|
||||
@include blur-bg;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: base(3);
|
||||
z-index: var(--z-modal);
|
||||
height: calc(var(--base) * 2);
|
||||
|
||||
&__scroll {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
header,
|
||||
&__wrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: $baseline var(--gutter-h);
|
||||
}
|
||||
|
||||
header {
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__mobile-menu-btn {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&__wrap {
|
||||
padding-top: 0;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
overflow-y: scroll;
|
||||
position: fixed;
|
||||
top: base(4);
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&.nav--menu-active {
|
||||
height: 100vh;
|
||||
|
||||
.nav__wrap {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
.nav {
|
||||
&__content {
|
||||
padding: 0 var(--gutter-h);
|
||||
}
|
||||
}
|
||||
|
||||
nav a {
|
||||
font-size: base(0.875);
|
||||
line-height: base(1.25);
|
||||
font-weight: 600;
|
||||
&__modalToggler {
|
||||
transform: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,159 +1,48 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, NavLink, useHistory } from 'react-router-dom'
|
||||
import { ModalToggler, useModal } from '@faceless-ui/modal'
|
||||
import React, { Fragment, useEffect } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
|
||||
import type { EntityToGroup, Group } from '../../../utilities/groupNavItems'
|
||||
|
||||
import { getTranslation } from '../../../../utilities/getTranslation'
|
||||
import { EntityType, groupNavItems } from '../../../utilities/groupNavItems'
|
||||
import Account from '../../graphics/Account'
|
||||
import Icon from '../../graphics/Icon'
|
||||
import Chevron from '../../icons/Chevron'
|
||||
import CloseMenu from '../../icons/CloseMenu'
|
||||
import Menu from '../../icons/Menu'
|
||||
import { useAuth } from '../../utilities/Auth'
|
||||
import { useConfig } from '../../utilities/Config'
|
||||
import RenderCustomComponent from '../../utilities/RenderCustomComponent'
|
||||
import Localizer from '../Localizer'
|
||||
import Logout from '../Logout'
|
||||
import NavGroup from '../NavGroup'
|
||||
import Hamburger from '../Hamburger'
|
||||
import { MainMenuDrawer, mainMenuSlug } from '../MainMenu'
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'nav'
|
||||
|
||||
const DefaultNav = () => {
|
||||
const { permissions, user } = useAuth()
|
||||
const [menuActive, setMenuActive] = useState(false)
|
||||
const [groups, setGroups] = useState<Group[]>([])
|
||||
const history = useHistory()
|
||||
const { i18n, t } = useTranslation('general')
|
||||
const {
|
||||
admin: {
|
||||
components: { afterNavLinks, beforeNavLinks },
|
||||
},
|
||||
collections,
|
||||
globals,
|
||||
routes: { admin },
|
||||
} = useConfig()
|
||||
|
||||
const classes = [baseClass, menuActive && `${baseClass}--menu-active`].filter(Boolean).join(' ')
|
||||
|
||||
useEffect(() => {
|
||||
setGroups(
|
||||
groupNavItems(
|
||||
[
|
||||
...collections
|
||||
.filter(
|
||||
({ admin: { hidden } }) =>
|
||||
!(typeof hidden === 'function' ? hidden({ user }) : hidden),
|
||||
)
|
||||
.map((collection) => {
|
||||
const entityToGroup: EntityToGroup = {
|
||||
entity: collection,
|
||||
type: EntityType.collection,
|
||||
}
|
||||
|
||||
return entityToGroup
|
||||
}),
|
||||
...globals
|
||||
.filter(
|
||||
({ admin: { hidden } }) =>
|
||||
!(typeof hidden === 'function' ? hidden({ user }) : hidden),
|
||||
)
|
||||
.map((global) => {
|
||||
const entityToGroup: EntityToGroup = {
|
||||
entity: global,
|
||||
type: EntityType.global,
|
||||
}
|
||||
|
||||
return entityToGroup
|
||||
}),
|
||||
],
|
||||
permissions,
|
||||
i18n,
|
||||
),
|
||||
)
|
||||
}, [collections, globals, permissions, i18n, i18n.language, user])
|
||||
const { closeModal, isModalOpen } = useModal()
|
||||
const isOpen = isModalOpen(mainMenuSlug)
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
history.listen(() => {
|
||||
setMenuActive(false)
|
||||
closeModal(mainMenuSlug)
|
||||
}),
|
||||
[history],
|
||||
[history, closeModal],
|
||||
)
|
||||
|
||||
return (
|
||||
<aside className={classes}>
|
||||
<div className={`${baseClass}__scroll`}>
|
||||
<header>
|
||||
<Link aria-label={t('dashboard')} className={`${baseClass}__brand`} to={admin}>
|
||||
<Icon />
|
||||
</Link>
|
||||
<button
|
||||
className={`${baseClass}__mobile-menu-btn`}
|
||||
onClick={() => setMenuActive(!menuActive)}
|
||||
type="button"
|
||||
>
|
||||
{menuActive && <CloseMenu />}
|
||||
{!menuActive && <Menu />}
|
||||
</button>
|
||||
</header>
|
||||
<nav className={`${baseClass}__wrap`}>
|
||||
{Array.isArray(beforeNavLinks) &&
|
||||
beforeNavLinks.map((Component, i) => <Component key={i} />)}
|
||||
{groups.map(({ entities, label }, key) => {
|
||||
return (
|
||||
<NavGroup {...{ key, label }}>
|
||||
{entities.map(({ entity, type }, i) => {
|
||||
let entityLabel: string
|
||||
let href: string
|
||||
let id: string
|
||||
|
||||
if (type === EntityType.collection) {
|
||||
href = `${admin}/collections/${entity.slug}`
|
||||
entityLabel = getTranslation(entity.labels.plural, i18n)
|
||||
id = `nav-${entity.slug}`
|
||||
}
|
||||
|
||||
if (type === EntityType.global) {
|
||||
href = `${admin}/globals/${entity.slug}`
|
||||
entityLabel = getTranslation(entity.label, i18n)
|
||||
id = `nav-global-${entity.slug}`
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
activeClassName="active"
|
||||
className={`${baseClass}__link`}
|
||||
id={id}
|
||||
key={i}
|
||||
to={href}
|
||||
>
|
||||
<Chevron />
|
||||
{entityLabel}
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</NavGroup>
|
||||
)
|
||||
})}
|
||||
{Array.isArray(afterNavLinks) &&
|
||||
afterNavLinks.map((Component, i) => <Component key={i} />)}
|
||||
<div className={`${baseClass}__controls`}>
|
||||
<Localizer />
|
||||
<Link
|
||||
aria-label={t('authentication:account')}
|
||||
className={`${baseClass}__account`}
|
||||
to={`${admin}/account`}
|
||||
>
|
||||
<Account />
|
||||
</Link>
|
||||
<Logout />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
<Fragment>
|
||||
<header
|
||||
className={[
|
||||
baseClass,
|
||||
!isOpen && `${baseClass}--show-bg`,
|
||||
isOpen && `${baseClass}--main-menu-open`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className={`${baseClass}__bg`} />
|
||||
<div className={`${baseClass}__content`}>
|
||||
<ModalToggler className={`${baseClass}__modalToggler`} slug={mainMenuSlug}>
|
||||
<Hamburger isActive={isOpen} />
|
||||
</ModalToggler>
|
||||
</div>
|
||||
</header>
|
||||
<MainMenuDrawer />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
.template-default {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
|
||||
&__wrap {
|
||||
min-width: 0;
|
||||
@@ -11,15 +10,15 @@
|
||||
}
|
||||
|
||||
@include mid-break {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
[dir='ltr'] & {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
[dir='rtl'] & {
|
||||
margin-right: 0;
|
||||
}
|
||||
padding-top: base(3);
|
||||
|
||||
&__wrap {
|
||||
padding: 0 0 $baseline;
|
||||
|
||||
@@ -20,7 +20,7 @@ const GlobalView: React.FC<IndexProps> = (props) => {
|
||||
const { state: locationState } = useLocation<{ data?: Record<string, unknown> }>()
|
||||
const { code: locale } = useLocale()
|
||||
const { setStepNav } = useStepNav()
|
||||
const { user } = useAuth()
|
||||
const { permissions, user } = useAuth()
|
||||
const [initialState, setInitialState] = useState<Fields>()
|
||||
const [updatedAt, setUpdatedAt] = useState<string>()
|
||||
const { docPermissions, getDocPermissions, getDocPreferences, getVersions, preferencesKey } =
|
||||
@@ -36,12 +36,26 @@ const GlobalView: React.FC<IndexProps> = (props) => {
|
||||
const { global } = props
|
||||
|
||||
const {
|
||||
admin: { components: { views: { Edit: CustomEdit } = {} } = {} } = {},
|
||||
admin: { components: { views: { Edit: Edit } = {} } = {} } = {},
|
||||
fields,
|
||||
label,
|
||||
slug,
|
||||
} = global
|
||||
|
||||
// The component definition could come from multiple places in the config
|
||||
// we need to cascade into the proper component from the top-down
|
||||
// 1. "components.Edit"
|
||||
// 2. "components.Edit.Default"
|
||||
// 3. "components.Edit.Default.Component"
|
||||
const CustomEditView =
|
||||
typeof Edit === 'function'
|
||||
? Edit
|
||||
: typeof Edit === 'object' && typeof Edit.Default === 'function'
|
||||
? Edit.Default
|
||||
: typeof Edit?.Default === 'object' && typeof Edit.Default.Component === 'function'
|
||||
? Edit.Default.Component
|
||||
: undefined
|
||||
|
||||
const onSave = useCallback(
|
||||
async (json) => {
|
||||
getVersions()
|
||||
@@ -102,13 +116,14 @@ const GlobalView: React.FC<IndexProps> = (props) => {
|
||||
|
||||
return (
|
||||
<RenderCustomComponent
|
||||
CustomComponent={CustomEdit}
|
||||
CustomComponent={CustomEditView}
|
||||
DefaultComponent={DefaultGlobal}
|
||||
componentProps={{
|
||||
action: `${serverURL}${api}/globals/${slug}?locale=${locale}&fallback-locale=null`,
|
||||
apiURL: `${serverURL}${api}/globals/${slug}?locale=${locale}${
|
||||
global.versions?.drafts ? '&draft=true' : ''
|
||||
}`,
|
||||
canAccessAdmin: permissions?.canAccessAdmin,
|
||||
data: dataToRender,
|
||||
global,
|
||||
initialState,
|
||||
@@ -116,6 +131,7 @@ const GlobalView: React.FC<IndexProps> = (props) => {
|
||||
onSave,
|
||||
permissions: docPermissions,
|
||||
updatedAt: updatedAt || dataToRender?.updatedAt,
|
||||
user,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
79
packages/payload/src/admin/components/views/Routes/child.tsx
Normal file
79
packages/payload/src/admin/components/views/Routes/child.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { match } from 'react-router-dom'
|
||||
|
||||
import React from 'react'
|
||||
import { Route } from 'react-router-dom'
|
||||
|
||||
import type { Permissions, User } from '../../../../auth'
|
||||
import type { SanitizedCollectionConfig, SanitizedGlobalConfig } from '../../../../exports/types'
|
||||
|
||||
import Unauthorized from '../Unauthorized'
|
||||
|
||||
export const childRoutes = (props: {
|
||||
collection?: SanitizedCollectionConfig
|
||||
global?: SanitizedGlobalConfig
|
||||
match: match<{
|
||||
[key: string]: string | undefined
|
||||
}>
|
||||
permissions: Permissions
|
||||
user: User
|
||||
}): React.ReactElement[] => {
|
||||
const { collection, global, match, permissions, user } = props
|
||||
|
||||
let customViews = []
|
||||
const internalViews = ['Default', 'Versions']
|
||||
|
||||
const BaseEdit =
|
||||
collection?.admin?.components?.views?.Edit || global?.admin?.components?.views?.Edit
|
||||
|
||||
if (typeof BaseEdit !== 'function' && typeof BaseEdit === 'object') {
|
||||
customViews = Object.entries(BaseEdit)
|
||||
.filter(([viewKey, view]) => {
|
||||
// Remove internal views from the list of custom views
|
||||
// This way we can easily iterate over the remaining views
|
||||
return Boolean(
|
||||
!internalViews.includes(viewKey) &&
|
||||
typeof view !== 'function' &&
|
||||
typeof view === 'object',
|
||||
)
|
||||
})
|
||||
?.map(([, view]) => view)
|
||||
}
|
||||
|
||||
return customViews?.reduce((acc, { Component, path }) => {
|
||||
const routesToReturn = [...acc]
|
||||
|
||||
if (collection) {
|
||||
routesToReturn.push(
|
||||
<Route
|
||||
exact
|
||||
key={`${collection.slug}-${path}`}
|
||||
path={`${match.url}/collections/${collection.slug}/:id${path}`}
|
||||
>
|
||||
{permissions?.collections?.[collection.slug]?.read?.permission ? (
|
||||
<Component collection={collection} user={user} />
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
)
|
||||
}
|
||||
|
||||
if (global) {
|
||||
routesToReturn.push(
|
||||
<Route
|
||||
exact
|
||||
key={`${global.slug}-${path}`}
|
||||
path={`${match.url}/globals/${global.slug}${path}`}
|
||||
>
|
||||
{permissions?.globals?.[global.slug]?.read?.permission ? (
|
||||
<Component global={global} />
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
)
|
||||
}
|
||||
|
||||
return routesToReturn
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { match } from 'react-router-dom'
|
||||
|
||||
import { lazy } from 'react'
|
||||
import React from 'react'
|
||||
import { Route } from 'react-router-dom'
|
||||
|
||||
import type { Permissions, User } from '../../../../auth'
|
||||
import type { SanitizedCollectionConfig } from '../../../../collections/config/types'
|
||||
|
||||
import { DocumentInfoProvider } from '../../utilities/DocumentInfo'
|
||||
import Version from '../Version'
|
||||
import Versions from '../Versions'
|
||||
import List from '../collections/List'
|
||||
import { childRoutes } from './child'
|
||||
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Edit = lazy(() => import('../collections/Edit'))
|
||||
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Unauthorized = lazy(() => import('../Unauthorized'))
|
||||
|
||||
export const collectionRoutes = (props: {
|
||||
collections: SanitizedCollectionConfig[]
|
||||
match: match<{
|
||||
[key: string]: string | undefined
|
||||
}>
|
||||
permissions: Permissions
|
||||
user: User
|
||||
}): React.ReactElement[] => {
|
||||
const { collections, match, permissions, user } = props
|
||||
|
||||
// Note: `Route` must be directly nested within `Switch` for `useRouteMatch` to work
|
||||
// This means that we cannot use `Fragment` here with a simple map function to return an array of routes
|
||||
// Instead, we need to use `reduce` to return an array of routes directly within `Switch`
|
||||
return collections
|
||||
?.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden))
|
||||
.reduce((acc, collection) => {
|
||||
// Default routes
|
||||
const routesToReturn = [
|
||||
...acc,
|
||||
<Route
|
||||
exact
|
||||
key={`${collection.slug}-list`}
|
||||
path={`${match.url}/collections/${collection.slug}`}
|
||||
>
|
||||
{permissions?.collections?.[collection.slug]?.read?.permission ? (
|
||||
<List collection={collection} />
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
<Route
|
||||
exact
|
||||
key={`${collection.slug}-create`}
|
||||
path={`${match.url}/collections/${collection.slug}/create`}
|
||||
>
|
||||
{permissions?.collections?.[collection.slug]?.create?.permission ? (
|
||||
<DocumentInfoProvider collection={collection} idFromParams>
|
||||
<Edit collection={collection} />
|
||||
</DocumentInfoProvider>
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
<Route
|
||||
exact
|
||||
key={`${collection.slug}-edit`}
|
||||
path={`${match.url}/collections/${collection.slug}/:id`}
|
||||
>
|
||||
{permissions?.collections?.[collection.slug]?.read?.permission ? (
|
||||
<DocumentInfoProvider collection={collection} idFromParams>
|
||||
<Edit collection={collection} isEditing />
|
||||
</DocumentInfoProvider>
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
childRoutes({
|
||||
collection,
|
||||
match,
|
||||
permissions,
|
||||
user,
|
||||
}),
|
||||
]
|
||||
|
||||
// Version routes
|
||||
if (collection.versions) {
|
||||
routesToReturn.push(
|
||||
<Route
|
||||
exact
|
||||
key={`${collection.slug}-versions`}
|
||||
path={`${match.url}/collections/${collection.slug}/:id/versions`}
|
||||
>
|
||||
{permissions?.collections?.[collection.slug]?.readVersions?.permission ? (
|
||||
<Versions collection={collection} />
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
)
|
||||
|
||||
routesToReturn.push(
|
||||
<Route
|
||||
exact
|
||||
key={`${collection.slug}-view-version`}
|
||||
path={`${match.url}/collections/${collection.slug}/:id/versions/:versionID`}
|
||||
>
|
||||
{permissions?.collections?.[collection.slug]?.readVersions?.permission ? (
|
||||
<DocumentInfoProvider collection={collection} idFromParams>
|
||||
<Version collection={collection} />
|
||||
</DocumentInfoProvider>
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
)
|
||||
}
|
||||
|
||||
return routesToReturn
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
import { Route } from 'react-router-dom'
|
||||
|
||||
import type { User } from '../../../../auth'
|
||||
import type { SanitizedConfig } from '../../../../exports/config'
|
||||
|
||||
export const customRoutes = (props: {
|
||||
canAccessAdmin: boolean
|
||||
customRoutes: SanitizedConfig['admin']['components']['routes']
|
||||
match: { url: string }
|
||||
user: User
|
||||
}) => {
|
||||
const { canAccessAdmin, customRoutes, match, user } = props
|
||||
|
||||
if (Array.isArray(customRoutes)) {
|
||||
return customRoutes.map(({ Component, exact, path, sensitive, strict }) => (
|
||||
// You are responsible for ensuring that your own custom route is secure
|
||||
// i.e. return `Unauthorized` in your own component if the user does not have permission
|
||||
<Route
|
||||
exact={exact}
|
||||
key={`${match.url}${path}`}
|
||||
path={`${match.url}${path}`}
|
||||
sensitive={sensitive}
|
||||
strict={strict}
|
||||
>
|
||||
<Component canAccessAdmin={canAccessAdmin} user={user} />
|
||||
</Route>
|
||||
))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { match } from 'react-router-dom'
|
||||
|
||||
import { lazy } from 'react'
|
||||
import React from 'react'
|
||||
import { Route } from 'react-router-dom'
|
||||
|
||||
import type { Permissions, User } from '../../../../auth'
|
||||
import type { SanitizedGlobalConfig } from '../../../../exports/types'
|
||||
|
||||
import { DocumentInfoProvider } from '../../utilities/DocumentInfo'
|
||||
import Version from '../Version'
|
||||
import Versions from '../Versions'
|
||||
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const EditGlobal = lazy(() => import('../Global'))
|
||||
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Unauthorized = lazy(() => import('../Unauthorized'))
|
||||
|
||||
export const globalRoutes = (props: {
|
||||
globals: SanitizedGlobalConfig[]
|
||||
locale: string
|
||||
match: match<{
|
||||
[key: string]: string | undefined
|
||||
}>
|
||||
permissions: Permissions
|
||||
user: User
|
||||
}): React.ReactElement[] => {
|
||||
const { globals, locale, match, permissions, user } = props
|
||||
|
||||
// Note: `Route` must be directly nested within `Switch` for `useRouteMatch` to work
|
||||
// This means that we cannot use `Fragment` here with a simple map function to return an array of routes
|
||||
// Instead, we need to use `reduce` to return an array of routes directly within `Switch`
|
||||
return globals
|
||||
?.filter(({ admin: { hidden } }) => !(typeof hidden === 'function' ? hidden({ user }) : hidden))
|
||||
.reduce((acc, global) => {
|
||||
const canReadGlobal = permissions?.globals?.[global.slug]?.read?.permission
|
||||
const canReadVersions = permissions?.globals?.[global.slug]?.readVersions?.permission
|
||||
|
||||
// Default routes
|
||||
const routesToReturn = [
|
||||
...acc,
|
||||
<Route exact key={global.slug} path={`${match.url}/globals/${global.slug}`}>
|
||||
{canReadGlobal ? (
|
||||
<DocumentInfoProvider global={global} idFromParams key={`${global.slug}-${locale}`}>
|
||||
<EditGlobal global={global} />
|
||||
</DocumentInfoProvider>
|
||||
) : (
|
||||
<Unauthorized />
|
||||
)}
|
||||
</Route>,
|
||||
]
|
||||
|
||||
// Version routes
|
||||
if (global.versions) {
|
||||
routesToReturn.push(
|
||||
<Route
|
||||
exact
|
||||
key={`${global.slug}-versions`}
|
||||
path={`${match.url}/globals/${global.slug}/versions`}
|
||||
>
|
||||
{canReadVersions ? <Versions global={global} /> : <Unauthorized />}
|
||||
</Route>,
|
||||
)
|
||||
|
||||
routesToReturn.push(
|
||||
<Route
|
||||
exact
|
||||
key={`${global.slug}-view-version`}
|
||||
path={`${match.url}/globals/${global.slug}/versions/:versionID`}
|
||||
>
|
||||
{canReadVersions ? <Version global={global} /> : <Unauthorized />}
|
||||
</Route>,
|
||||
)
|
||||
}
|
||||
|
||||
return routesToReturn
|
||||
}, [])
|
||||
}
|
||||
212
packages/payload/src/admin/components/views/Routes/index.tsx
Normal file
212
packages/payload/src/admin/components/views/Routes/index.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { Fragment, Suspense, lazy, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Redirect, Route, Switch } from 'react-router-dom'
|
||||
|
||||
import { requests } from '../../../api'
|
||||
import { LoadingOverlayToggle } from '../../elements/Loading'
|
||||
import StayLoggedIn from '../../modals/StayLoggedIn'
|
||||
import DefaultTemplate from '../../templates/Default'
|
||||
import { useAuth } from '../../utilities/Auth'
|
||||
import { useConfig } from '../../utilities/Config'
|
||||
import { DocumentInfoProvider } from '../../utilities/DocumentInfo'
|
||||
import { useLocale } from '../../utilities/Locale'
|
||||
import { collectionRoutes } from './collections'
|
||||
import { customRoutes } from './custom'
|
||||
import { globalRoutes } from './globals'
|
||||
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Dashboard = lazy(() => import('../Dashboard'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const ForgotPassword = lazy(() => import('../ForgotPassword'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Login = lazy(() => import('../Login'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Logout = lazy(() => import('../Logout'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const NotFound = lazy(() => import('../NotFound'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Verify = lazy(() => import('../Verify'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const CreateFirstUser = lazy(() => import('../CreateFirstUser'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const ResetPassword = lazy(() => import('../ResetPassword'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Unauthorized = lazy(() => import('../Unauthorized'))
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const Account = lazy(() => import('../Account'))
|
||||
|
||||
export const Routes: React.FC = () => {
|
||||
const [initialized, setInitialized] = useState(null)
|
||||
const { permissions, refreshCookie, user } = useAuth()
|
||||
const { i18n } = useTranslation()
|
||||
const { code: locale } = useLocale()
|
||||
|
||||
const canAccessAdmin = permissions?.canAccessAdmin
|
||||
|
||||
const config = useConfig()
|
||||
|
||||
const {
|
||||
admin: {
|
||||
components: { routes: customRoutesConfig } = {},
|
||||
inactivityRoute: logoutInactivityRoute,
|
||||
logoutRoute,
|
||||
user: userSlug,
|
||||
},
|
||||
collections,
|
||||
globals,
|
||||
routes,
|
||||
} = config
|
||||
|
||||
const isLoadingUser = Boolean(
|
||||
typeof user === 'undefined' || (user && typeof canAccessAdmin === 'undefined'),
|
||||
)
|
||||
|
||||
const userCollection = collections.find(({ slug }) => slug === userSlug)
|
||||
|
||||
useEffect(() => {
|
||||
const { slug } = userCollection
|
||||
|
||||
if (!userCollection.auth.disableLocalStrategy) {
|
||||
requests
|
||||
.get(`${routes.api}/${slug}/init`, {
|
||||
headers: {
|
||||
'Accept-Language': i18n.language,
|
||||
},
|
||||
})
|
||||
.then((res) =>
|
||||
res.json().then((data) => {
|
||||
if (data && 'initialized' in data) {
|
||||
setInitialized(data.initialized)
|
||||
}
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
setInitialized(true)
|
||||
}
|
||||
}, [i18n.language, routes, userCollection])
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LoadingOverlayToggle name="route-suspense" show />}>
|
||||
<LoadingOverlayToggle name="route-loader" show={isLoadingUser} />
|
||||
<Route
|
||||
path={routes.admin}
|
||||
render={({ match }) => {
|
||||
if (initialized === false) {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path={`${match.url}/create-first-user`}>
|
||||
<CreateFirstUser setInitialized={setInitialized} />
|
||||
</Route>
|
||||
<Route>
|
||||
<Redirect to={`${match.url}/create-first-user`} />
|
||||
</Route>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
if (initialized === true && !isLoadingUser) {
|
||||
return (
|
||||
<Switch>
|
||||
{customRoutes({
|
||||
canAccessAdmin,
|
||||
customRoutes: customRoutesConfig,
|
||||
match,
|
||||
user,
|
||||
})}
|
||||
<Route path={`${match.url}/login`}>
|
||||
<Login />
|
||||
</Route>
|
||||
<Route path={`${match.url}${logoutRoute}`}>
|
||||
<Logout />
|
||||
</Route>
|
||||
<Route path={`${match.url}${logoutInactivityRoute}`}>
|
||||
<Logout inactivity />
|
||||
</Route>
|
||||
{!userCollection.auth.disableLocalStrategy && (
|
||||
<Route path={`${match.url}/forgot`}>
|
||||
<ForgotPassword />
|
||||
</Route>
|
||||
)}
|
||||
{!userCollection.auth.disableLocalStrategy && (
|
||||
<Route path={`${match.url}/reset/:token`}>
|
||||
<ResetPassword />
|
||||
</Route>
|
||||
)}
|
||||
{collections.map((collection) => {
|
||||
if (collection?.auth?.verify && !collection.auth.disableLocalStrategy) {
|
||||
return (
|
||||
<Route
|
||||
exact
|
||||
key={`${collection.slug}-verify`}
|
||||
path={`${match.url}/${collection.slug}/verify/:token`}
|
||||
>
|
||||
<Verify collection={collection} />
|
||||
</Route>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
<Route>
|
||||
{user ? (
|
||||
<Fragment>
|
||||
{canAccessAdmin && (
|
||||
<DefaultTemplate>
|
||||
<Switch>
|
||||
<Route exact path={`${match.url}/`}>
|
||||
<Dashboard />
|
||||
</Route>
|
||||
<Route path={`${match.url}/account`}>
|
||||
<DocumentInfoProvider
|
||||
collection={collections.find(({ slug }) => slug === userSlug)}
|
||||
id={user.id}
|
||||
>
|
||||
<Account />
|
||||
</DocumentInfoProvider>
|
||||
</Route>
|
||||
{collectionRoutes({
|
||||
collections,
|
||||
match,
|
||||
permissions,
|
||||
user,
|
||||
})}
|
||||
{globalRoutes({
|
||||
globals,
|
||||
locale,
|
||||
match,
|
||||
permissions,
|
||||
user,
|
||||
})}
|
||||
<Route path={`${match.url}*`}>
|
||||
<NotFound />
|
||||
</Route>
|
||||
</Switch>
|
||||
</DefaultTemplate>
|
||||
)}
|
||||
{canAccessAdmin === false && <Unauthorized />}
|
||||
</Fragment>
|
||||
) : (
|
||||
<Redirect
|
||||
to={`${match.url}/login${
|
||||
window.location.pathname.startsWith(routes.admin)
|
||||
? `?redirect=${encodeURIComponent(
|
||||
window.location.pathname.replace(routes.admin, ''),
|
||||
)}`
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</Route>
|
||||
<Route path={`${match.url}*`}>
|
||||
<NotFound />
|
||||
</Route>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
<StayLoggedIn refreshCookie={refreshCookie} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -37,9 +37,11 @@ const VersionView: React.FC<Props> = ({ collection, global }) => {
|
||||
serverURL,
|
||||
} = useConfig()
|
||||
const { setStepNav } = useStepNav()
|
||||
|
||||
const {
|
||||
params: { id, versionID },
|
||||
} = useRouteMatch<{ id?: string; versionID: string }>()
|
||||
|
||||
const [compareValue, setCompareValue] = useState<CompareOption>(mostRecentVersionOption)
|
||||
const [localeOptions] = useState<LocaleOption[]>(() => (localization ? localization.locales : []))
|
||||
const [locales, setLocales] = useState<LocaleOption[]>(localeOptions)
|
||||
|
||||
160
packages/payload/src/admin/components/views/Versions/Default.tsx
Normal file
160
packages/payload/src/admin/components/views/Versions/Default.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { StepNavItem } from '../../elements/StepNav/types'
|
||||
import type { Props } from './types'
|
||||
|
||||
import { getTranslation } from '../../../../utilities/getTranslation'
|
||||
import Eyebrow from '../../elements/Eyebrow'
|
||||
import { Gutter } from '../../elements/Gutter'
|
||||
import IDLabel from '../../elements/IDLabel'
|
||||
import { LoadingOverlayToggle } from '../../elements/Loading'
|
||||
import Paginator from '../../elements/Paginator'
|
||||
import PerPage from '../../elements/PerPage'
|
||||
import { useStepNav } from '../../elements/StepNav'
|
||||
import { Table } from '../../elements/Table'
|
||||
import { useConfig } from '../../utilities/Config'
|
||||
import Meta from '../../utilities/Meta'
|
||||
import { useSearchParams } from '../../utilities/SearchParams'
|
||||
import { buildVersionColumns } from './columns'
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'versions'
|
||||
|
||||
export const DefaultVersionsView: React.FC<Props> = (props) => {
|
||||
const { collection, data, editURL, entityLabel, global, id, isLoadingVersions, versionsData } =
|
||||
props
|
||||
|
||||
const {
|
||||
routes: { admin },
|
||||
} = useConfig()
|
||||
|
||||
const { setStepNav } = useStepNav()
|
||||
|
||||
const { i18n, t } = useTranslation('version')
|
||||
|
||||
const { limit } = useSearchParams()
|
||||
|
||||
const useAsTitle = collection?.admin?.useAsTitle || 'id'
|
||||
|
||||
useEffect(() => {
|
||||
let nav: StepNavItem[] = []
|
||||
|
||||
if (collection) {
|
||||
let docLabel = ''
|
||||
|
||||
if (data) {
|
||||
if (useAsTitle) {
|
||||
if (data[useAsTitle]) {
|
||||
docLabel = data[useAsTitle]
|
||||
} else {
|
||||
docLabel = `[${t('general:untitled')}]`
|
||||
}
|
||||
} else {
|
||||
docLabel = data.id
|
||||
}
|
||||
}
|
||||
|
||||
nav = [
|
||||
{
|
||||
label: getTranslation(collection.labels.plural, i18n),
|
||||
url: `${admin}/collections/${collection.slug}`,
|
||||
},
|
||||
{
|
||||
label: docLabel,
|
||||
url: editURL,
|
||||
},
|
||||
{
|
||||
label: t('versions'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (global) {
|
||||
nav = [
|
||||
{
|
||||
label: getTranslation(global.label, i18n),
|
||||
url: editURL,
|
||||
},
|
||||
{
|
||||
label: t('versions'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
setStepNav(nav)
|
||||
}, [setStepNav, collection, global, useAsTitle, data, admin, id, editURL, t, i18n])
|
||||
|
||||
let useIDLabel = data[useAsTitle] === data?.id
|
||||
let heading: string
|
||||
let metaDesc: string
|
||||
let metaTitle: string
|
||||
|
||||
if (collection) {
|
||||
metaTitle = `${t('versions')} - ${data[useAsTitle]} - ${entityLabel}`
|
||||
metaDesc = t('viewingVersions', { documentTitle: data[useAsTitle], entityLabel })
|
||||
heading = data?.[useAsTitle] || `[${t('general:untitled')}]`
|
||||
}
|
||||
|
||||
if (global) {
|
||||
metaTitle = `${t('versions')} - ${entityLabel}`
|
||||
metaDesc = t('viewingVersionsGlobal', { entityLabel })
|
||||
heading = entityLabel
|
||||
useIDLabel = false
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<LoadingOverlayToggle name="versions" show={isLoadingVersions} />
|
||||
<div className={baseClass}>
|
||||
<Meta description={metaDesc} title={metaTitle} />
|
||||
<Eyebrow />
|
||||
<Gutter className={`${baseClass}__wrap`}>
|
||||
<header className={`${baseClass}__header`}>
|
||||
<div className={`${baseClass}__intro`}>{t('showingVersionsFor')}</div>
|
||||
{useIDLabel && <IDLabel id={data?.id} />}
|
||||
{!useIDLabel && <h1>{heading}</h1>}
|
||||
</header>
|
||||
{versionsData?.totalDocs > 0 && (
|
||||
<React.Fragment>
|
||||
<Table
|
||||
columns={buildVersionColumns(collection, global, t)}
|
||||
data={versionsData?.docs}
|
||||
/>
|
||||
<div className={`${baseClass}__page-controls`}>
|
||||
<Paginator
|
||||
hasNextPage={versionsData.hasNextPage}
|
||||
hasPrevPage={versionsData.hasPrevPage}
|
||||
limit={versionsData.limit}
|
||||
nextPage={versionsData.nextPage}
|
||||
numberOfNeighbors={1}
|
||||
page={versionsData.page}
|
||||
prevPage={versionsData.prevPage}
|
||||
totalPages={versionsData.totalPages}
|
||||
/>
|
||||
{versionsData?.totalDocs > 0 && (
|
||||
<React.Fragment>
|
||||
<div className={`${baseClass}__page-info`}>
|
||||
{versionsData.page * versionsData.limit - (versionsData.limit - 1)}-
|
||||
{versionsData.totalPages > 1 && versionsData.totalPages !== versionsData.page
|
||||
? versionsData.limit * versionsData.page
|
||||
: versionsData.totalDocs}{' '}
|
||||
{t('of')} {versionsData.totalDocs}
|
||||
</div>
|
||||
<PerPage
|
||||
limit={limit ? Number(limit) : 10}
|
||||
limits={collection?.admin?.pagination?.limits}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{versionsData?.totalDocs === 0 && (
|
||||
<div className={`${baseClass}__no-versions`}>{t('noFurtherVersionsFound')}</div>
|
||||
)}
|
||||
</Gutter>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -2,40 +2,38 @@ import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouteMatch } from 'react-router-dom'
|
||||
|
||||
import type { StepNavItem } from '../../elements/StepNav/types'
|
||||
import type { Props } from './types'
|
||||
import type { IndexProps } from './types'
|
||||
|
||||
import { getTranslation } from '../../../../utilities/getTranslation'
|
||||
import usePayloadAPI from '../../../hooks/usePayloadAPI'
|
||||
import Eyebrow from '../../elements/Eyebrow'
|
||||
import { Gutter } from '../../elements/Gutter'
|
||||
import IDLabel from '../../elements/IDLabel'
|
||||
import { LoadingOverlayToggle } from '../../elements/Loading'
|
||||
import Paginator from '../../elements/Paginator'
|
||||
import PerPage from '../../elements/PerPage'
|
||||
import { useStepNav } from '../../elements/StepNav'
|
||||
import { Table } from '../../elements/Table'
|
||||
import { useAuth } from '../../utilities/Auth'
|
||||
import { useConfig } from '../../utilities/Config'
|
||||
import Meta from '../../utilities/Meta'
|
||||
import { EditDepthContext } from '../../utilities/EditDepth'
|
||||
import RenderCustomComponent from '../../utilities/RenderCustomComponent'
|
||||
import { useSearchParams } from '../../utilities/SearchParams'
|
||||
import { buildVersionColumns } from './columns'
|
||||
import './index.scss'
|
||||
import { DefaultVersionsView } from './Default'
|
||||
|
||||
const baseClass = 'versions'
|
||||
const VersionsView: React.FC<IndexProps> = (props) => {
|
||||
const { collection, global } = props
|
||||
|
||||
const { permissions, user } = useAuth()
|
||||
|
||||
const [fetchURL, setFetchURL] = useState('')
|
||||
|
||||
const Versions: React.FC<Props> = ({ collection, global }) => {
|
||||
const {
|
||||
routes: { admin, api },
|
||||
serverURL,
|
||||
} = useConfig()
|
||||
const { setStepNav } = useStepNav()
|
||||
|
||||
const { i18n } = useTranslation('version')
|
||||
|
||||
const { limit, page, sort } = useSearchParams()
|
||||
|
||||
const {
|
||||
params: { id },
|
||||
} = useRouteMatch<{ id: string }>()
|
||||
const { i18n, t } = useTranslation('version')
|
||||
const [fetchURL, setFetchURL] = useState('')
|
||||
const { limit, page, sort } = useSearchParams()
|
||||
|
||||
let CustomVersionsView: React.ComponentType | null = null
|
||||
let docURL: string
|
||||
let entityLabel: string
|
||||
let slug: string
|
||||
@@ -46,6 +44,21 @@ const Versions: React.FC<Props> = ({ collection, global }) => {
|
||||
docURL = `${serverURL}${api}/${slug}/${id}`
|
||||
entityLabel = getTranslation(collection.labels.singular, i18n)
|
||||
editURL = `${admin}/collections/${collection.slug}/${id}`
|
||||
|
||||
// The component definition could come from multiple places in the config
|
||||
// we need to cascade into the proper component from the top-down
|
||||
// 1. "components.Edit"
|
||||
// 2. "components.Edit.Versions"
|
||||
// 3. "components.Edit.Versions.Component"
|
||||
const Edit = collection?.admin?.components?.views?.Edit
|
||||
CustomVersionsView =
|
||||
typeof Edit === 'function'
|
||||
? Edit
|
||||
: typeof Edit === 'object' && typeof Edit.Versions === 'function'
|
||||
? Edit.Versions
|
||||
: typeof Edit?.Versions === 'object' && typeof Edit.Versions.Component === 'function'
|
||||
? Edit.Versions.Component
|
||||
: undefined
|
||||
}
|
||||
|
||||
if (global) {
|
||||
@@ -53,61 +66,23 @@ const Versions: React.FC<Props> = ({ collection, global }) => {
|
||||
docURL = `${serverURL}${api}/globals/${slug}`
|
||||
entityLabel = getTranslation(global.label, i18n)
|
||||
editURL = `${admin}/globals/${global.slug}`
|
||||
|
||||
// See note above about cascading component definitions
|
||||
const Edit = global?.admin?.components?.views?.Edit
|
||||
CustomVersionsView =
|
||||
typeof Edit === 'function'
|
||||
? Edit
|
||||
: typeof Edit === 'object' && typeof Edit.Versions === 'function'
|
||||
? Edit.Versions
|
||||
: typeof Edit?.Versions === 'object' && typeof Edit.Versions.Component === 'function'
|
||||
? Edit.Versions.Component
|
||||
: undefined
|
||||
}
|
||||
|
||||
const useAsTitle = collection?.admin?.useAsTitle || 'id'
|
||||
const [{ data: doc }] = usePayloadAPI(docURL, { initialParams: { draft: 'true' } })
|
||||
const [{ data, isLoading }] = usePayloadAPI(docURL, { initialParams: { draft: 'true' } })
|
||||
const [{ data: versionsData, isLoading: isLoadingVersions }, { setParams }] =
|
||||
usePayloadAPI(fetchURL)
|
||||
|
||||
useEffect(() => {
|
||||
let nav: StepNavItem[] = []
|
||||
|
||||
if (collection) {
|
||||
let docLabel = ''
|
||||
|
||||
if (doc) {
|
||||
if (useAsTitle) {
|
||||
if (doc[useAsTitle]) {
|
||||
docLabel = doc[useAsTitle]
|
||||
} else {
|
||||
docLabel = `[${t('general:untitled')}]`
|
||||
}
|
||||
} else {
|
||||
docLabel = doc.id
|
||||
}
|
||||
}
|
||||
|
||||
nav = [
|
||||
{
|
||||
label: getTranslation(collection.labels.plural, i18n),
|
||||
url: `${admin}/collections/${collection.slug}`,
|
||||
},
|
||||
{
|
||||
label: docLabel,
|
||||
url: editURL,
|
||||
},
|
||||
{
|
||||
label: t('versions'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (global) {
|
||||
nav = [
|
||||
{
|
||||
label: getTranslation(global.label, i18n),
|
||||
url: editURL,
|
||||
},
|
||||
{
|
||||
label: t('versions'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
setStepNav(nav)
|
||||
}, [setStepNav, collection, global, useAsTitle, doc, admin, id, editURL, t, i18n])
|
||||
|
||||
useEffect(() => {
|
||||
const params = {
|
||||
depth: 1,
|
||||
@@ -144,79 +119,27 @@ const Versions: React.FC<Props> = ({ collection, global }) => {
|
||||
setParams(params)
|
||||
}, [setParams, page, sort, limit, serverURL, api, id, global, collection])
|
||||
|
||||
let useIDLabel = doc[useAsTitle] === doc?.id
|
||||
let heading: string
|
||||
let metaDesc: string
|
||||
let metaTitle: string
|
||||
|
||||
if (collection) {
|
||||
metaTitle = `${t('versions')} - ${doc[useAsTitle]} - ${entityLabel}`
|
||||
metaDesc = t('viewingVersions', { documentTitle: doc[useAsTitle], entityLabel })
|
||||
heading = doc?.[useAsTitle] || `[${t('general:untitled')}]`
|
||||
}
|
||||
|
||||
if (global) {
|
||||
metaTitle = `${t('versions')} - ${entityLabel}`
|
||||
metaDesc = t('viewingVersionsGlobal', { entityLabel })
|
||||
heading = entityLabel
|
||||
useIDLabel = false
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<LoadingOverlayToggle name="versions" show={isLoadingVersions} />
|
||||
<div className={baseClass}>
|
||||
<Meta description={metaDesc} title={metaTitle} />
|
||||
<Eyebrow />
|
||||
<Gutter className={`${baseClass}__wrap`}>
|
||||
<header className={`${baseClass}__header`}>
|
||||
<div className={`${baseClass}__intro`}>{t('showingVersionsFor')}</div>
|
||||
{useIDLabel && <IDLabel id={doc?.id} />}
|
||||
{!useIDLabel && <h1>{heading}</h1>}
|
||||
</header>
|
||||
|
||||
{versionsData?.totalDocs > 0 && (
|
||||
<React.Fragment>
|
||||
<Table
|
||||
columns={buildVersionColumns(collection, global, t)}
|
||||
data={versionsData?.docs}
|
||||
/>
|
||||
<div className={`${baseClass}__page-controls`}>
|
||||
<Paginator
|
||||
hasNextPage={versionsData.hasNextPage}
|
||||
hasPrevPage={versionsData.hasPrevPage}
|
||||
limit={versionsData.limit}
|
||||
nextPage={versionsData.nextPage}
|
||||
numberOfNeighbors={1}
|
||||
page={versionsData.page}
|
||||
prevPage={versionsData.prevPage}
|
||||
totalPages={versionsData.totalPages}
|
||||
/>
|
||||
{versionsData?.totalDocs > 0 && (
|
||||
<React.Fragment>
|
||||
<div className={`${baseClass}__page-info`}>
|
||||
{versionsData.page * versionsData.limit - (versionsData.limit - 1)}-
|
||||
{versionsData.totalPages > 1 && versionsData.totalPages !== versionsData.page
|
||||
? versionsData.limit * versionsData.page
|
||||
: versionsData.totalDocs}{' '}
|
||||
{t('of')} {versionsData.totalDocs}
|
||||
</div>
|
||||
<PerPage
|
||||
limit={limit ? Number(limit) : 10}
|
||||
limits={collection?.admin?.pagination?.limits}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
{versionsData?.totalDocs === 0 && (
|
||||
<div className={`${baseClass}__no-versions`}>{t('noFurtherVersionsFound')}</div>
|
||||
)}
|
||||
</Gutter>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
<EditDepthContext.Provider value={1}>
|
||||
<RenderCustomComponent
|
||||
CustomComponent={CustomVersionsView}
|
||||
DefaultComponent={DefaultVersionsView}
|
||||
componentProps={{
|
||||
canAccessAdmin: permissions?.canAccessAdmin,
|
||||
collection,
|
||||
data,
|
||||
editURL,
|
||||
entityLabel,
|
||||
fetchURL,
|
||||
global,
|
||||
id,
|
||||
isLoading,
|
||||
isLoadingVersions,
|
||||
user,
|
||||
versionsData,
|
||||
}}
|
||||
/>
|
||||
</EditDepthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default Versions
|
||||
export default VersionsView
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import type { SanitizedCollectionConfig } from '../../../../collections/config/types'
|
||||
import type { PaginatedDocs } from '../../../../database/types'
|
||||
import type { SanitizedGlobalConfig } from '../../../../globals/config/types'
|
||||
import type { Version } from '../../utilities/DocumentInfo/types'
|
||||
|
||||
export type Props = {
|
||||
export type IndexProps = {
|
||||
collection?: SanitizedCollectionConfig
|
||||
global?: SanitizedGlobalConfig
|
||||
}
|
||||
|
||||
export type Props = IndexProps & {
|
||||
data: Version
|
||||
editURL: string
|
||||
entityLabel: string
|
||||
fetchURL: string
|
||||
id: string
|
||||
isLoading: boolean
|
||||
isLoadingVersions: boolean
|
||||
versionsData: PaginatedDocs<Version>
|
||||
}
|
||||
|
||||
@@ -20,29 +20,45 @@ import formatFields from './formatFields'
|
||||
const EditView: React.FC<IndexProps> = (props) => {
|
||||
const { collection: incomingCollection, isEditing } = props
|
||||
|
||||
const { admin: { components: { views: { Edit: CustomEdit } = {} } = {} } = {}, slug } =
|
||||
const { admin: { components: { views: { Edit } = {} } = {} } = {}, slug: collectionSlug } =
|
||||
incomingCollection
|
||||
|
||||
// The component definition could come from multiple places in the config
|
||||
// we need to cascade into the proper component from the top-down
|
||||
// 1. "components.Edit"
|
||||
// 2. "components.Edit.Default"
|
||||
// 3. "components.Edit.Default.Component"
|
||||
const CustomEditView =
|
||||
typeof Edit === 'function'
|
||||
? Edit
|
||||
: typeof Edit === 'object' && typeof Edit.Default === 'function'
|
||||
? Edit.Default
|
||||
: typeof Edit?.Default === 'object' && typeof Edit.Default.Component === 'function'
|
||||
? Edit.Default.Component
|
||||
: undefined
|
||||
|
||||
const [fields] = useState(() => formatFields(incomingCollection, isEditing))
|
||||
const [collection] = useState(() => ({ ...incomingCollection, fields }))
|
||||
const [redirect, setRedirect] = useState<string>()
|
||||
|
||||
const { code: locale } = useLocale()
|
||||
|
||||
const {
|
||||
routes: { admin, api },
|
||||
serverURL,
|
||||
} = useConfig()
|
||||
|
||||
const { params: { id } = {} } = useRouteMatch<Record<string, string>>()
|
||||
const history = useHistory()
|
||||
const [internalState, setInternalState] = useState<Fields>()
|
||||
const [updatedAt, setUpdatedAt] = useState<string>()
|
||||
const { user } = useAuth()
|
||||
const { permissions, user } = useAuth()
|
||||
const userRef = useRef(user)
|
||||
const { docPermissions, getDocPermissions, getDocPreferences, getVersions } = useDocumentInfo()
|
||||
const { t } = useTranslation('general')
|
||||
|
||||
const [{ data, isError, isLoading: isLoadingData }] = usePayloadAPI(
|
||||
isEditing ? `${serverURL}${api}/${slug}/${id}` : null,
|
||||
isEditing ? `${serverURL}${api}/${collectionSlug}/${id}` : null,
|
||||
{ initialData: null, initialParams: { depth: 0, draft: 'true', 'fallback-locale': 'null' } },
|
||||
)
|
||||
|
||||
@@ -107,25 +123,29 @@ const EditView: React.FC<IndexProps> = (props) => {
|
||||
return <Redirect to={`${admin}/not-found`} />
|
||||
}
|
||||
|
||||
const apiURL = `${serverURL}${api}/${slug}/${id}?locale=${locale}${
|
||||
const apiURL = `${serverURL}${api}/${collectionSlug}/${id}?locale=${locale}${
|
||||
collection.versions.drafts ? '&draft=true' : ''
|
||||
}`
|
||||
const action = `${serverURL}${api}/${slug}${
|
||||
|
||||
const action = `${serverURL}${api}/${collectionSlug}${
|
||||
isEditing ? `/${id}` : ''
|
||||
}?locale=${locale}&fallback-locale=null`
|
||||
|
||||
const hasSavePermission =
|
||||
(isEditing && docPermissions?.update?.permission) ||
|
||||
(!isEditing && (docPermissions as CollectionPermission)?.create?.permission)
|
||||
|
||||
const isLoading = !internalState || !docPermissions || isLoadingData
|
||||
|
||||
return (
|
||||
<EditDepthContext.Provider value={1}>
|
||||
<RenderCustomComponent
|
||||
CustomComponent={CustomEdit}
|
||||
CustomComponent={CustomEditView}
|
||||
DefaultComponent={DefaultEdit}
|
||||
componentProps={{
|
||||
action,
|
||||
apiURL,
|
||||
canAccessAdmin: permissions?.canAccessAdmin,
|
||||
collection,
|
||||
data,
|
||||
hasSavePermission,
|
||||
@@ -136,6 +156,7 @@ const EditView: React.FC<IndexProps> = (props) => {
|
||||
onSave,
|
||||
permissions: docPermissions,
|
||||
updatedAt: updatedAt || data?.updatedAt,
|
||||
user,
|
||||
}}
|
||||
/>
|
||||
</EditDepthContext.Provider>
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
--breakpoint-m-width: #{$breakpoint-m-width};
|
||||
--breakpoint-l-width: #{$breakpoint-l-width};
|
||||
--scrollbar-width: 17px;
|
||||
--nav-width: #{base(9)};
|
||||
|
||||
--theme-bg: var(--theme-elevation-0);
|
||||
--theme-input-bg: var(--theme-elevation-0);
|
||||
@@ -36,16 +35,10 @@
|
||||
--accessibility-outline: 2px solid var(--theme-text);
|
||||
--accessibility-outline-offset: 2px;
|
||||
|
||||
--gutter-h: #{base(5)};
|
||||
|
||||
@include large-break {
|
||||
--gutter-h: #{base(3)};
|
||||
--nav-width: #{base(8)};
|
||||
}
|
||||
--gutter-h: #{base(3)};
|
||||
|
||||
@include mid-break {
|
||||
--gutter-h: #{base(2)};
|
||||
--nav-width: 0px;
|
||||
}
|
||||
|
||||
@include small-break {
|
||||
|
||||
@@ -45,23 +45,23 @@ const sanitizeCollection = (
|
||||
})
|
||||
if (!hasUpdatedAt) {
|
||||
sanitized.fields.push({
|
||||
name: 'updatedAt',
|
||||
admin: {
|
||||
disableBulkEdit: true,
|
||||
hidden: true,
|
||||
},
|
||||
label: translations['general:updatedAt'],
|
||||
name: 'updatedAt',
|
||||
type: 'date',
|
||||
})
|
||||
}
|
||||
if (!hasCreatedAt) {
|
||||
sanitized.fields.push({
|
||||
name: 'createdAt',
|
||||
admin: {
|
||||
disableBulkEdit: true,
|
||||
hidden: true,
|
||||
},
|
||||
label: translations['general:createdAt'],
|
||||
name: 'createdAt',
|
||||
type: 'date',
|
||||
})
|
||||
}
|
||||
@@ -143,7 +143,11 @@ const sanitizeCollection = (
|
||||
// /////////////////////////////////
|
||||
|
||||
const validRelationships = config.collections.map((c) => c.slug)
|
||||
sanitized.fields = sanitizeFields(sanitized.fields, validRelationships)
|
||||
sanitized.fields = sanitizeFields({
|
||||
config,
|
||||
fields: sanitized.fields,
|
||||
validRelationships,
|
||||
})
|
||||
|
||||
return sanitized as SanitizedCollectionConfig
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import joi from 'joi'
|
||||
|
||||
import { endpointsSchema } from '../../config/schema'
|
||||
import { componentSchema } from '../../utilities/componentSchema'
|
||||
import { componentSchema, customViewSchema } from '../../config/shared/componentSchema'
|
||||
|
||||
const strategyBaseSchema = joi.object().keys({
|
||||
logout: joi.boolean(),
|
||||
@@ -31,7 +31,19 @@ const collectionSchema = joi.object().keys({
|
||||
SaveDraftButton: componentSchema,
|
||||
}),
|
||||
views: joi.object({
|
||||
Edit: componentSchema,
|
||||
Edit: joi.alternatives().try(
|
||||
componentSchema,
|
||||
joi.object({
|
||||
Default: joi.alternatives().try(componentSchema, customViewSchema),
|
||||
Versions: joi.alternatives().try(componentSchema, customViewSchema),
|
||||
// Version
|
||||
// Preview
|
||||
// Relationships
|
||||
// References
|
||||
// API
|
||||
// :path
|
||||
}),
|
||||
),
|
||||
List: componentSchema,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -164,6 +164,28 @@ type BeforeDuplicateArgs<T> = {
|
||||
|
||||
export type BeforeDuplicate<T = any> = (args: BeforeDuplicateArgs<T>) => Promise<T> | T
|
||||
|
||||
export type CollectionEditView =
|
||||
| {
|
||||
/**
|
||||
* The component to render for this view
|
||||
* + Replaces the default component
|
||||
*/
|
||||
Component: React.ComponentType<EditProps>
|
||||
/**
|
||||
* The label rendered in the admin UI for this view
|
||||
* + Example: `default` is `Edit`
|
||||
*/
|
||||
label: string
|
||||
/**
|
||||
* The URL path to the nested collection edit views
|
||||
* + Example: `/admin/collections/:collection/:id/:path`
|
||||
* + The `:path` is the value of this property
|
||||
* + Note: the default collection view uses no path
|
||||
*/
|
||||
path?: string
|
||||
}
|
||||
| React.ComponentType<EditProps>
|
||||
|
||||
export type CollectionAdminOptions = {
|
||||
/**
|
||||
* Custom admin components
|
||||
@@ -199,7 +221,33 @@ export type CollectionAdminOptions = {
|
||||
SaveDraftButton?: CustomSaveDraftButtonProps
|
||||
}
|
||||
views?: {
|
||||
Edit?: React.ComponentType<EditProps>
|
||||
/**
|
||||
* Replaces the "Edit" view entirely
|
||||
*/
|
||||
Edit?:
|
||||
| {
|
||||
/**
|
||||
* Replaces or adds nested views within the "Edit" view
|
||||
* + `Default` - `/admin/collections/:collection/:id`
|
||||
* + `API` - `/admin/collections/:collection/:id/api`
|
||||
* + `Preview` - `/admin/collections/:collection/:id/preview`
|
||||
* + `References` - `/admin/collections/:collection/:id/references`
|
||||
* + `Relationships` - `/admin/collections/:collection/:id/relationships`
|
||||
* + `Versions` - `/admin/collections/:collection/:id/versions`
|
||||
* + `Version` - `/admin/collections/:collection/:id/versions/:version`
|
||||
* + `:path` - `/admin/collections/:collection/:id/:path`
|
||||
*/
|
||||
Default: CollectionEditView
|
||||
Versions?: CollectionEditView
|
||||
// TODO: uncomment these as they are built
|
||||
// [key: string]: CollectionEditView
|
||||
// API?: CollectionEditView
|
||||
// Preview?: CollectionEditView
|
||||
// References?: CollectionEditView
|
||||
// Relationships?: CollectionEditView
|
||||
// Version: CollectionEditView
|
||||
}
|
||||
| React.ComponentType<EditProps>
|
||||
List?: React.ComponentType<ListProps>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
LocalizationConfigWithLabels,
|
||||
LocalizationConfigWithNoLabels,
|
||||
SanitizedConfig,
|
||||
SanitizedLocalizationConfig,
|
||||
} from './types'
|
||||
|
||||
import { defaultUserCollection } from '../auth/defaultUser'
|
||||
@@ -96,7 +95,7 @@ export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => {
|
||||
checkDuplicateCollections(config.collections)
|
||||
|
||||
if (config.globals.length > 0) {
|
||||
config.globals = sanitizeGlobals(config.collections, config.globals)
|
||||
config.globals = sanitizeGlobals(config as SanitizedConfig)
|
||||
}
|
||||
|
||||
if (typeof config.serverURL === 'undefined') {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import joi from 'joi'
|
||||
|
||||
import { routeSchema } from './shared/routeSchema'
|
||||
|
||||
const component = joi.alternatives().try(joi.object().unknown(), joi.func())
|
||||
|
||||
export const endpointsSchema = joi.alternatives().try(
|
||||
@@ -50,15 +52,7 @@ export default joi.object({
|
||||
Button: component,
|
||||
}),
|
||||
providers: joi.array().items(component),
|
||||
routes: joi.array().items(
|
||||
joi.object().keys({
|
||||
Component: component.required(),
|
||||
exact: joi.bool(),
|
||||
path: joi.string().required(),
|
||||
sensitive: joi.bool(),
|
||||
strict: joi.bool(),
|
||||
}),
|
||||
),
|
||||
routes: routeSchema,
|
||||
views: joi.object({
|
||||
Account: component,
|
||||
Dashboard: component,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import joi from 'joi'
|
||||
|
||||
export const componentSchema = joi.alternatives().try(joi.object().unknown(), joi.func())
|
||||
|
||||
export const customViewSchema = {
|
||||
Component: componentSchema,
|
||||
label: joi.string(),
|
||||
path: joi.string(),
|
||||
}
|
||||
13
packages/payload/src/config/shared/routeSchema.ts
Normal file
13
packages/payload/src/config/shared/routeSchema.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import joi from 'joi'
|
||||
|
||||
import { componentSchema } from './componentSchema'
|
||||
|
||||
export const routeSchema = joi.array().items(
|
||||
joi.object().keys({
|
||||
Component: componentSchema,
|
||||
exact: joi.bool(),
|
||||
path: joi.string().required(),
|
||||
sensitive: joi.bool(),
|
||||
strict: joi.bool(),
|
||||
}),
|
||||
)
|
||||
@@ -22,8 +22,6 @@ import type { GlobalConfig, SanitizedGlobalConfig } from '../globals/config/type
|
||||
import type { Payload } from '../payload'
|
||||
import type { Where } from '../types'
|
||||
|
||||
import { Validate } from '../fields/config/types'
|
||||
|
||||
type Prettify<T> = {
|
||||
[K in keyof T]: T[K]
|
||||
} & NonNullable<unknown>
|
||||
@@ -195,13 +193,15 @@ export type Endpoint = {
|
||||
root?: boolean
|
||||
}
|
||||
|
||||
export type AdminView = React.ComponentType<{
|
||||
export type CustomAdminView = React.ComponentType<{
|
||||
canAccessAdmin: boolean
|
||||
collection?: SanitizedCollectionConfig
|
||||
global?: SanitizedGlobalConfig
|
||||
user: User
|
||||
}>
|
||||
|
||||
export type AdminRoute = {
|
||||
Component: AdminView
|
||||
Component: CustomAdminView
|
||||
/** Whether the path should be matched exactly or as a prefix */
|
||||
exact?: boolean
|
||||
path: string
|
||||
|
||||
@@ -7,9 +7,15 @@ import type {
|
||||
NumberField,
|
||||
TextField,
|
||||
} from './types'
|
||||
|
||||
import { Config } from '../../config/types'
|
||||
import { InvalidFieldName, InvalidFieldRelationship, MissingFieldType } from '../../errors'
|
||||
import sanitizeFields from './sanitize'
|
||||
import { DatabaseAdapter } from '../..'
|
||||
|
||||
const dummyConfig: Config = {
|
||||
collections: [],
|
||||
db: () => ({}) as DatabaseAdapter,
|
||||
}
|
||||
|
||||
describe('sanitizeFields', () => {
|
||||
it('should throw on missing type field', () => {
|
||||
@@ -24,7 +30,11 @@ describe('sanitizeFields', () => {
|
||||
expect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
sanitizeFields(fields, [])
|
||||
sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})
|
||||
}).toThrow(MissingFieldType)
|
||||
})
|
||||
it('should throw on invalid field name', () => {
|
||||
@@ -36,7 +46,11 @@ describe('sanitizeFields', () => {
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields(fields, [])
|
||||
sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})
|
||||
}).toThrow(InvalidFieldName)
|
||||
})
|
||||
|
||||
@@ -48,7 +62,11 @@ describe('sanitizeFields', () => {
|
||||
type: 'text',
|
||||
},
|
||||
]
|
||||
const sanitizedField = sanitizeFields(fields, [])[0] as TextField
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as TextField
|
||||
expect(sanitizedField.name).toStrictEqual('someField')
|
||||
expect(sanitizedField.label).toStrictEqual('Some Field')
|
||||
expect(sanitizedField.type).toStrictEqual('text')
|
||||
@@ -61,7 +79,11 @@ describe('sanitizeFields', () => {
|
||||
type: 'text',
|
||||
},
|
||||
]
|
||||
const sanitizedField = sanitizeFields(fields, [])[0] as TextField
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as TextField
|
||||
expect(sanitizedField.name).toStrictEqual('someField')
|
||||
expect(sanitizedField.label).toStrictEqual('Do not label')
|
||||
expect(sanitizedField.type).toStrictEqual('text')
|
||||
@@ -76,7 +98,11 @@ describe('sanitizeFields', () => {
|
||||
type: 'text',
|
||||
},
|
||||
]
|
||||
const sanitizedField = sanitizeFields(fields, [])[0] as TextField
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as TextField
|
||||
expect(sanitizedField.name).toStrictEqual('someField')
|
||||
expect(sanitizedField.label).toStrictEqual(false)
|
||||
expect(sanitizedField.type).toStrictEqual('text')
|
||||
@@ -94,7 +120,11 @@ describe('sanitizeFields', () => {
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
}
|
||||
const sanitizedField = sanitizeFields([arrayField], [])[0] as ArrayField
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields: [arrayField],
|
||||
validRelationships: [],
|
||||
})[0] as ArrayField
|
||||
expect(sanitizedField.name).toStrictEqual('items')
|
||||
expect(sanitizedField.label).toStrictEqual(false)
|
||||
expect(sanitizedField.type).toStrictEqual('array')
|
||||
@@ -119,7 +149,11 @@ describe('sanitizeFields', () => {
|
||||
type: 'blocks',
|
||||
},
|
||||
]
|
||||
const sanitizedField = sanitizeFields(fields, [])[0] as BlockField
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as BlockField
|
||||
expect(sanitizedField.name).toStrictEqual('noLabelBlock')
|
||||
expect(sanitizedField.label).toStrictEqual(false)
|
||||
expect(sanitizedField.type).toStrictEqual('blocks')
|
||||
@@ -140,7 +174,11 @@ describe('sanitizeFields', () => {
|
||||
type: 'array',
|
||||
},
|
||||
]
|
||||
const sanitizedField = sanitizeFields(fields, [])[0] as ArrayField
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as ArrayField
|
||||
expect(sanitizedField.name).toStrictEqual('items')
|
||||
expect(sanitizedField.label).toStrictEqual('Items')
|
||||
expect(sanitizedField.type).toStrictEqual('array')
|
||||
@@ -160,7 +198,11 @@ describe('sanitizeFields', () => {
|
||||
type: 'blocks',
|
||||
},
|
||||
]
|
||||
const sanitizedField = sanitizeFields(fields, [])[0] as BlockField
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as BlockField
|
||||
expect(sanitizedField.name).toStrictEqual('specialBlock')
|
||||
expect(sanitizedField.label).toStrictEqual('Special Block')
|
||||
expect(sanitizedField.type).toStrictEqual('blocks')
|
||||
@@ -184,7 +226,7 @@ describe('sanitizeFields', () => {
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields(fields, validRelationships)
|
||||
sanitizeFields({ config: dummyConfig, fields, validRelationships })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -199,7 +241,7 @@ describe('sanitizeFields', () => {
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields(fields, validRelationships)
|
||||
sanitizeFields({ config: dummyConfig, fields, validRelationships })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -225,7 +267,7 @@ describe('sanitizeFields', () => {
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields(fields, validRelationships)
|
||||
sanitizeFields({ config: dummyConfig, fields, validRelationships })
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
@@ -240,7 +282,7 @@ describe('sanitizeFields', () => {
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields(fields, validRelationships)
|
||||
sanitizeFields({ config: dummyConfig, fields, validRelationships })
|
||||
}).toThrow(InvalidFieldRelationship)
|
||||
})
|
||||
|
||||
@@ -255,7 +297,7 @@ describe('sanitizeFields', () => {
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields(fields, validRelationships)
|
||||
sanitizeFields({ config: dummyConfig, fields, validRelationships })
|
||||
}).toThrow(InvalidFieldRelationship)
|
||||
})
|
||||
|
||||
@@ -281,7 +323,7 @@ describe('sanitizeFields', () => {
|
||||
},
|
||||
]
|
||||
expect(() => {
|
||||
sanitizeFields(fields, validRelationships)
|
||||
sanitizeFields({ config: dummyConfig, fields, validRelationships })
|
||||
}).toThrow(InvalidFieldRelationship)
|
||||
})
|
||||
|
||||
@@ -294,12 +336,20 @@ describe('sanitizeFields', () => {
|
||||
},
|
||||
]
|
||||
|
||||
const sanitizedField = sanitizeFields(fields, [])[0] as CheckboxField
|
||||
const sanitizedField = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields,
|
||||
validRelationships: [],
|
||||
})[0] as CheckboxField
|
||||
expect(sanitizedField.defaultValue).toStrictEqual(false)
|
||||
})
|
||||
|
||||
it('should return empty field array if no fields', () => {
|
||||
const sanitizedFields = sanitizeFields([], [])
|
||||
const sanitizedFields = sanitizeFields({
|
||||
config: dummyConfig,
|
||||
fields: [],
|
||||
validRelationships: [],
|
||||
})
|
||||
expect(sanitizedFields).toStrictEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Config } from '../../config/types'
|
||||
import type { Field } from './types'
|
||||
|
||||
import withCondition from '../../admin/components/forms/withCondition'
|
||||
@@ -8,7 +9,13 @@ import { baseIDField } from '../baseFields/baseIDField'
|
||||
import validations from '../validations'
|
||||
import { fieldAffectsData, tabHasName } from './types'
|
||||
|
||||
const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[] => {
|
||||
type Args = {
|
||||
config: Config
|
||||
fields: Field[]
|
||||
validRelationships: string[]
|
||||
}
|
||||
|
||||
const sanitizeFields = ({ config, fields, validRelationships }: Args): Field[] => {
|
||||
if (!fields) return []
|
||||
|
||||
return fields.map((unsanitizedField) => {
|
||||
@@ -80,6 +87,8 @@ const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[]
|
||||
}
|
||||
|
||||
if (fieldAffectsData(field)) {
|
||||
if (field.localized && !config.localization) delete field.localized
|
||||
|
||||
if (typeof field.validate === 'undefined') {
|
||||
const defaultValidate = validations[field.type]
|
||||
if (defaultValidate) {
|
||||
@@ -101,8 +110,13 @@ const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[]
|
||||
field.admin = {}
|
||||
}
|
||||
|
||||
if ('fields' in field && field.fields)
|
||||
field.fields = sanitizeFields(field.fields, validRelationships)
|
||||
if ('fields' in field && field.fields) {
|
||||
field.fields = sanitizeFields({
|
||||
config,
|
||||
fields: field.fields,
|
||||
validRelationships,
|
||||
})
|
||||
}
|
||||
|
||||
if (field.type === 'tabs') {
|
||||
field.tabs = field.tabs.map((tab) => {
|
||||
@@ -110,7 +124,13 @@ const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[]
|
||||
if (tabHasName(tab) && typeof tab.label === 'undefined') {
|
||||
unsanitizedTab.label = toWords(tab.name)
|
||||
}
|
||||
unsanitizedTab.fields = sanitizeFields(tab.fields, validRelationships)
|
||||
|
||||
unsanitizedTab.fields = sanitizeFields({
|
||||
config,
|
||||
fields: tab.fields,
|
||||
validRelationships,
|
||||
})
|
||||
|
||||
return unsanitizedTab
|
||||
})
|
||||
}
|
||||
@@ -121,7 +141,13 @@ const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[]
|
||||
unsanitizedBlock.labels = !unsanitizedBlock.labels
|
||||
? formatLabels(unsanitizedBlock.slug)
|
||||
: unsanitizedBlock.labels
|
||||
unsanitizedBlock.fields = sanitizeFields(block.fields, validRelationships)
|
||||
|
||||
unsanitizedBlock.fields = sanitizeFields({
|
||||
config,
|
||||
fields: block.fields,
|
||||
validRelationships,
|
||||
})
|
||||
|
||||
return unsanitizedBlock
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import joi from 'joi'
|
||||
|
||||
import { componentSchema } from '../../utilities/componentSchema'
|
||||
import { componentSchema } from '../../config/shared/componentSchema'
|
||||
|
||||
export const baseAdminComponentFields = joi
|
||||
.object()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CollectionConfig } from '../../collections/config/types'
|
||||
import type { GlobalConfig, SanitizedGlobalConfig } from './types'
|
||||
import type { Config } from '../../config/types'
|
||||
import type { SanitizedGlobalConfig } from './types'
|
||||
|
||||
import defaultAccess from '../../auth/defaultAccess'
|
||||
import sanitizeFields from '../../fields/config/sanitize'
|
||||
@@ -9,10 +9,9 @@ import translations from '../../translations'
|
||||
import { toWords } from '../../utilities/formatLabels'
|
||||
import baseVersionFields from '../../versions/baseFields'
|
||||
|
||||
const sanitizeGlobals = (
|
||||
collections: CollectionConfig[],
|
||||
globals: GlobalConfig[],
|
||||
): SanitizedGlobalConfig[] => {
|
||||
const sanitizeGlobals = (config: Config): SanitizedGlobalConfig[] => {
|
||||
const { collections, globals } = config
|
||||
|
||||
const sanitizedGlobals = globals.map((global) => {
|
||||
const sanitizedGlobal = { ...global }
|
||||
|
||||
@@ -72,29 +71,33 @@ const sanitizeGlobals = (
|
||||
})
|
||||
if (!hasUpdatedAt) {
|
||||
sanitizedGlobal.fields.push({
|
||||
name: 'updatedAt',
|
||||
admin: {
|
||||
disableBulkEdit: true,
|
||||
hidden: true,
|
||||
},
|
||||
label: translations['general:updatedAt'],
|
||||
name: 'updatedAt',
|
||||
type: 'date',
|
||||
})
|
||||
}
|
||||
if (!hasCreatedAt) {
|
||||
sanitizedGlobal.fields.push({
|
||||
name: 'createdAt',
|
||||
admin: {
|
||||
disableBulkEdit: true,
|
||||
hidden: true,
|
||||
},
|
||||
label: translations['general:createdAt'],
|
||||
name: 'createdAt',
|
||||
type: 'date',
|
||||
})
|
||||
}
|
||||
|
||||
const validRelationships = collections.map((c) => c.slug)
|
||||
sanitizedGlobal.fields = sanitizeFields(sanitizedGlobal.fields, validRelationships)
|
||||
sanitizedGlobal.fields = sanitizeFields({
|
||||
config,
|
||||
fields: sanitizedGlobal.fields,
|
||||
validRelationships,
|
||||
})
|
||||
|
||||
return sanitizedGlobal as SanitizedGlobalConfig
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import joi from 'joi'
|
||||
|
||||
import { endpointsSchema } from '../../config/schema'
|
||||
import { componentSchema } from '../../utilities/componentSchema'
|
||||
import { componentSchema, customViewSchema } from '../../config/shared/componentSchema'
|
||||
|
||||
const globalSchema = joi
|
||||
.object()
|
||||
@@ -20,7 +20,19 @@ const globalSchema = joi
|
||||
SaveDraftButton: componentSchema,
|
||||
}),
|
||||
views: joi.object({
|
||||
Edit: componentSchema,
|
||||
Edit: joi.alternatives().try(
|
||||
componentSchema,
|
||||
joi.object({
|
||||
Default: joi.alternatives().try(componentSchema, customViewSchema),
|
||||
Versions: joi.alternatives().try(componentSchema, customViewSchema),
|
||||
// Version
|
||||
// Preview
|
||||
// Relationships
|
||||
// References
|
||||
// API
|
||||
// :path
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
description: joi.alternatives().try(joi.string(), componentSchema),
|
||||
|
||||
@@ -38,6 +38,28 @@ export type AfterReadHook = (args: {
|
||||
req: PayloadRequest
|
||||
}) => any
|
||||
|
||||
export type GlobalEditView =
|
||||
| {
|
||||
/**
|
||||
* The component to render for this view
|
||||
* + Replaces the default component
|
||||
*/
|
||||
Component: React.ComponentType<any>
|
||||
/**
|
||||
* The label rendered in the admin UI for this view
|
||||
* + Example: `default` is `Edit`
|
||||
*/
|
||||
label: string
|
||||
/**
|
||||
* The URL path to the nested global edit views
|
||||
* + Example: `/admin/globals/:slug/:path`
|
||||
* + The `:path` is the value of this property
|
||||
* + Note: the default global view uses no path
|
||||
*/
|
||||
path?: string
|
||||
}
|
||||
| React.ComponentType<any>
|
||||
|
||||
export type GlobalAdminOptions = {
|
||||
/**
|
||||
* Custom admin components
|
||||
@@ -66,7 +88,33 @@ export type GlobalAdminOptions = {
|
||||
SaveDraftButton?: CustomSaveDraftButtonProps
|
||||
}
|
||||
views?: {
|
||||
Edit?: React.ComponentType<any>
|
||||
/**
|
||||
* Replaces the "Edit" view
|
||||
*/
|
||||
Edit?:
|
||||
| {
|
||||
/**
|
||||
* Replaces or adds nested routes within the "Edit" view
|
||||
* + `Default` - `/admin/globals/:slug`
|
||||
* + `API` - `/admin/globals/:id/api`
|
||||
* + `Preview` - `/admin/globals/:id/preview`
|
||||
* + `References` - `/admin/globals/:id/references`
|
||||
* + `Relationships` - `/admin/globals/:id/relationships`
|
||||
* + `Versions` - `/admin/globals/:id/versions`
|
||||
* + `Version` - `/admin/globals/:id/versions/:version`
|
||||
* + `:path` - `/admin/globals/:id/:path`
|
||||
*/
|
||||
Default: GlobalEditView
|
||||
Versions?: GlobalEditView
|
||||
// TODO: uncomment these as they are built
|
||||
// [name: string]: GlobalEditView
|
||||
// API?: GlobalEditView
|
||||
// Preview?: GlobalEditView
|
||||
// References?: GlobalEditView
|
||||
// Relationships?: GlobalEditView
|
||||
// Version?: GlobalEditView
|
||||
}
|
||||
| React.ComponentType<any>
|
||||
}
|
||||
}
|
||||
/**
|
||||
|
||||
50
pnpm-lock.yaml
generated
50
pnpm-lock.yaml
generated
@@ -53,15 +53,42 @@ importers:
|
||||
get-port:
|
||||
specifier: 5.1.1
|
||||
version: 5.1.1
|
||||
graphql-request:
|
||||
specifier: 3.7.0
|
||||
version: 3.7.0(graphql@16.7.1)
|
||||
isomorphic-fetch:
|
||||
specifier: 3.0.0
|
||||
version: 3.0.0
|
||||
jest:
|
||||
specifier: 29.6.4
|
||||
version: 29.6.4(@types/node@20.5.7)(ts-node@10.9.1)
|
||||
jest-environment-jsdom:
|
||||
specifier: 29.6.4
|
||||
version: 29.6.4
|
||||
jwt-decode:
|
||||
specifier: 3.1.2
|
||||
version: 3.1.2
|
||||
mongodb-memory-server:
|
||||
specifier: 8.13.0
|
||||
version: 8.13.0
|
||||
node-fetch:
|
||||
specifier: 2.6.12
|
||||
version: 2.6.12
|
||||
prettier:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
qs:
|
||||
specifier: 6.11.2
|
||||
version: 6.11.2
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
react-i18next:
|
||||
specifier: 11.18.6
|
||||
version: 11.18.6(i18next@22.5.1)(react-dom@18.2.0)(react@18.2.0)
|
||||
react-router-dom:
|
||||
specifier: 5.3.4
|
||||
version: 5.3.4(react@18.2.0)
|
||||
rimraf:
|
||||
specifier: 3.0.2
|
||||
version: 3.0.2
|
||||
@@ -701,9 +728,6 @@ importers:
|
||||
graphql-request:
|
||||
specifier: 3.7.0
|
||||
version: 3.7.0(graphql@16.7.1)
|
||||
mongodb-memory-server:
|
||||
specifier: 8.13.0
|
||||
version: 8.13.0
|
||||
node-fetch:
|
||||
specifier: 2.6.12
|
||||
version: 2.6.12
|
||||
@@ -8497,13 +8521,11 @@ packages:
|
||||
tiny-invariant: 1.3.1
|
||||
tiny-warning: 1.0.3
|
||||
value-equal: 1.0.1
|
||||
dev: false
|
||||
|
||||
/hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
dev: false
|
||||
|
||||
/hosted-git-info@2.8.9:
|
||||
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
|
||||
@@ -8548,7 +8570,6 @@ packages:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
dev: false
|
||||
|
||||
/html-webpack-plugin@5.5.3(webpack@5.88.2):
|
||||
resolution: {integrity: sha512-6YrDKTuqaP/TquFH7h4srYWsZx+x6k6+FbsTm0ziCwGHDP78Unr1r9F/H4+sGmMbX08GQcJ+K64x55b+7VM/jg==}
|
||||
@@ -8672,7 +8693,6 @@ packages:
|
||||
resolution: {integrity: sha512-8TGPgM3pAD+VRsMtUMNknRz3kzqwp/gPALrWMsDnmC1mKqJwpWyooQRLMcbTwq8z8YwSmuj+ZYvc+xCuEpkssA==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.22.11
|
||||
dev: false
|
||||
|
||||
/iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
@@ -9136,7 +9156,6 @@ packages:
|
||||
whatwg-fetch: 3.6.17
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
dev: false
|
||||
|
||||
/issue-parser@6.0.0:
|
||||
resolution: {integrity: sha512-zKa/Dxq2lGsBIXQ7CUZWTHfvxPC2ej0KfO7fIPqLlHB9J2hJ7rGhZ5rilhuufylr4RXYPzJUeFjKxz305OsNlA==}
|
||||
@@ -9858,7 +9877,6 @@ packages:
|
||||
|
||||
/jwt-decode@3.1.2:
|
||||
resolution: {integrity: sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==}
|
||||
dev: false
|
||||
|
||||
/kareem@2.5.1:
|
||||
resolution: {integrity: sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==}
|
||||
@@ -10699,7 +10717,6 @@ packages:
|
||||
/object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: false
|
||||
|
||||
/object-inspect@1.12.3:
|
||||
resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==}
|
||||
@@ -11131,7 +11148,6 @@ packages:
|
||||
resolution: {integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==}
|
||||
dependencies:
|
||||
isarray: 0.0.1
|
||||
dev: false
|
||||
|
||||
/path-type@3.0.0:
|
||||
resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==}
|
||||
@@ -12171,7 +12187,6 @@ packages:
|
||||
loose-envify: 1.4.0
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
dev: false
|
||||
|
||||
/proto-list@1.2.4:
|
||||
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
|
||||
@@ -12258,7 +12273,6 @@ packages:
|
||||
engines: {node: '>=0.6'}
|
||||
dependencies:
|
||||
side-channel: 1.0.4
|
||||
dev: false
|
||||
|
||||
/qs@6.4.1:
|
||||
resolution: {integrity: sha512-LQy1Q1fcva/UsnP/6Iaa4lVeM49WiOitu2T4hZCyA/elLKu37L99qcBJk4VCCk+rdLvnMzfKyiN3SZTqdAZGSQ==}
|
||||
@@ -12410,11 +12424,9 @@ packages:
|
||||
i18next: 22.5.1
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
dev: false
|
||||
|
||||
/react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
@@ -12459,7 +12471,6 @@ packages:
|
||||
react-router: 5.3.4(react@18.2.0)
|
||||
tiny-invariant: 1.3.1
|
||||
tiny-warning: 1.0.3
|
||||
dev: false
|
||||
|
||||
/react-router-navigation-prompt@1.9.6(react-router-dom@5.3.4)(react@18.2.0):
|
||||
resolution: {integrity: sha512-l0sAtbroHK8i1/Eyy29XcrMpBEt0R08BaScgMUt8r5vWWbLz7G0ChOikayTCQm7QgDFsHw8gVnxDJb7TBZCAKg==}
|
||||
@@ -12486,7 +12497,6 @@ packages:
|
||||
react-is: 16.13.1
|
||||
tiny-invariant: 1.3.1
|
||||
tiny-warning: 1.0.3
|
||||
dev: false
|
||||
|
||||
/react-select@5.7.4(@types/react@18.2.15)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-NhuE56X+p9QDFh4BgeygHFIvJJszO1i1KSkg/JPcIJrbovyRtI+GuOEa4XzFCEpZRAEoEI8u/cAHK+jG/PgUzQ==}
|
||||
@@ -12809,7 +12819,6 @@ packages:
|
||||
|
||||
/resolve-pathname@3.0.0:
|
||||
resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==}
|
||||
dev: false
|
||||
|
||||
/resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
@@ -13801,11 +13810,9 @@ packages:
|
||||
|
||||
/tiny-invariant@1.3.1:
|
||||
resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==}
|
||||
dev: false
|
||||
|
||||
/tiny-warning@1.0.3:
|
||||
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
|
||||
dev: false
|
||||
|
||||
/titleize@3.0.0:
|
||||
resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==}
|
||||
@@ -14356,7 +14363,6 @@ packages:
|
||||
|
||||
/value-equal@1.0.1:
|
||||
resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==}
|
||||
dev: false
|
||||
|
||||
/vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
@@ -14365,7 +14371,6 @@ packages:
|
||||
/void-elements@3.1.0:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: false
|
||||
|
||||
/w3c-xmlserializer@4.0.0:
|
||||
resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==}
|
||||
@@ -14552,7 +14557,6 @@ packages:
|
||||
|
||||
/whatwg-fetch@3.6.17:
|
||||
resolution: {integrity: sha512-c4ghIvG6th0eudYwKZY5keb81wtFz9/WeAHAoy8+r18kcWlitUIrmGFQ2rWEl4UCKUilD3zCLHOIPheHx5ypRQ==}
|
||||
dev: false
|
||||
|
||||
/whatwg-mimetype@3.0.0:
|
||||
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||
|
||||
@@ -32,6 +32,7 @@ module.exports = {
|
||||
{
|
||||
files: ['**/int.spec.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-use-before-define': 'off',
|
||||
'jest/prefer-strict-equal': 'off',
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ReadOnlyCollection, RestrictedVersion } from './payload-types'
|
||||
|
||||
import payload from '../../packages/payload/src'
|
||||
import wait from '../../packages/payload/src/utilities/wait'
|
||||
import { openMainMenu } from '../helpers'
|
||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
|
||||
import { initPayloadE2E } from '../helpers/configHelpers'
|
||||
import {
|
||||
@@ -99,6 +100,7 @@ describe('access control', () => {
|
||||
|
||||
test('should not show in nav', async () => {
|
||||
await page.goto(url.admin)
|
||||
await openMainMenu(page)
|
||||
await expect(page.locator('.nav >> a:has-text("Restricteds")')).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -137,7 +139,9 @@ describe('access control', () => {
|
||||
|
||||
test('should show in nav', async () => {
|
||||
await page.goto(url.admin)
|
||||
await expect(page.locator(`.nav a[href="/admin/collections/${readOnlySlug}"]`)).toHaveCount(1)
|
||||
await expect(
|
||||
page.locator(`.main-menu a[href="/admin/collections/${readOnlySlug}"]`),
|
||||
).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('should have collection url', async () => {
|
||||
|
||||
@@ -4,17 +4,18 @@ import type { Post, RelyOnRequestHeader, Restricted } from './payload-types'
|
||||
import payload from '../../packages/payload/src'
|
||||
import { Forbidden } from '../../packages/payload/src/errors'
|
||||
import { initPayloadTest } from '../helpers/configHelpers'
|
||||
import { requestHeaders } from './config'
|
||||
import {
|
||||
firstArrayText,
|
||||
hiddenAccessSlug,
|
||||
hiddenFieldsSlug,
|
||||
relyOnRequestHeadersSlug,
|
||||
requestHeaders,
|
||||
restrictedSlug,
|
||||
restrictedVersionsSlug,
|
||||
secondArrayText,
|
||||
siblingDataSlug,
|
||||
slug,
|
||||
} from './config'
|
||||
import { firstArrayText, secondArrayText } from './shared'
|
||||
} from './shared'
|
||||
|
||||
describe('Access Control', () => {
|
||||
let post1: Post
|
||||
|
||||
@@ -2,11 +2,9 @@ import React from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
|
||||
// As this is the demo project, we import our dependencies from the `src` directory.
|
||||
import Chevron from '../../../../packages/payload/src/admin/components/icons/Chevron'
|
||||
import { useConfig } from '../../../../packages/payload/src/admin/components/utilities/Config'
|
||||
|
||||
// In your projects, you can import as follows:
|
||||
// import { Chevron } from 'payload/components';
|
||||
// import { useConfig } from 'payload/components/utilities';
|
||||
|
||||
const baseClass = 'after-nav-links'
|
||||
@@ -17,26 +15,35 @@ const AfterNavLinks: React.FC = () => {
|
||||
} = useConfig()
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<span className="nav__label">Custom Routes</span>
|
||||
<nav>
|
||||
<div
|
||||
className={baseClass}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'calc(var(--base) / 4)',
|
||||
}}
|
||||
>
|
||||
<h4 className="nav__label" style={{ color: 'var(--theme-elevation-400)', margin: 0 }}>
|
||||
Custom Routes
|
||||
</h4>
|
||||
<h4 className="nav__link" style={{ margin: 0 }}>
|
||||
<NavLink
|
||||
activeClassName="active"
|
||||
className="nav__link"
|
||||
style={{ textDecoration: 'none' }}
|
||||
to={`${adminRoute}/custom-default-route`}
|
||||
>
|
||||
<Chevron />
|
||||
Default Template
|
||||
</NavLink>
|
||||
</h4>
|
||||
<h4 className="nav__link" style={{ margin: 0 }}>
|
||||
<NavLink
|
||||
activeClassName="active"
|
||||
className="nav__link"
|
||||
style={{ textDecoration: 'none' }}
|
||||
to={`${adminRoute}/custom-minimal-route`}
|
||||
>
|
||||
<Chevron />
|
||||
Minimal Template
|
||||
</NavLink>
|
||||
</nav>
|
||||
</h4>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,14 +49,21 @@ const CustomDefaultRoute: AdminView = ({ canAccessAdmin, user }) => {
|
||||
title="Custom Route with Default Template"
|
||||
/>
|
||||
<Eyebrow />
|
||||
<h1>Custom Route</h1>
|
||||
<p>
|
||||
Here is a custom route that was added in the Payload config. It uses the Default Template,
|
||||
so the sidebar is rendered.
|
||||
</p>
|
||||
<Button buttonStyle="secondary" el="link" to={`${adminRoute}`}>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
<div
|
||||
style={{
|
||||
paddingRight: 'var(--gutter-h)',
|
||||
paddingLeft: 'var(--gutter-h)',
|
||||
}}
|
||||
>
|
||||
<h1>Custom Route</h1>
|
||||
<p>
|
||||
Here is a custom route that was added in the Payload config. It uses the Default Template,
|
||||
so the sidebar is rendered.
|
||||
</p>
|
||||
<Button buttonStyle="secondary" el="link" to={`${adminRoute}`}>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</DefaultTemplate>
|
||||
)
|
||||
}
|
||||
98
test/admin/components/views/CustomEdit/index.tsx
Normal file
98
test/admin/components/views/CustomEdit/index.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { Fragment, useEffect } from 'react'
|
||||
import { Redirect, useParams } from 'react-router-dom'
|
||||
|
||||
import Button from '../../../../../packages/payload/src/admin/components/elements/Button'
|
||||
import Eyebrow from '../../../../../packages/payload/src/admin/components/elements/Eyebrow'
|
||||
import { useStepNav } from '../../../../../packages/payload/src/admin/components/elements/StepNav'
|
||||
import { useConfig } from '../../../../../packages/payload/src/admin/components/utilities/Config'
|
||||
import { type CustomAdminView } from '../../../../../packages/payload/src/config/types'
|
||||
|
||||
const CustomEditView: CustomAdminView = ({ canAccessAdmin, collection, global, user }) => {
|
||||
const {
|
||||
routes: { admin: adminRoute },
|
||||
} = useConfig()
|
||||
|
||||
const params = useParams()
|
||||
|
||||
const { setStepNav } = useStepNav()
|
||||
|
||||
// This effect will only run one time and will allow us
|
||||
// to set the step nav to display our custom route name
|
||||
|
||||
useEffect(() => {
|
||||
setStepNav([
|
||||
{
|
||||
label: 'Custom Edit View',
|
||||
},
|
||||
])
|
||||
}, [setStepNav])
|
||||
|
||||
// If an unauthorized user tries to navigate straight to this page,
|
||||
// Boot 'em out
|
||||
if (!user || (user && !canAccessAdmin)) {
|
||||
return <Redirect to={`${adminRoute}/unauthorized`} />
|
||||
}
|
||||
|
||||
let versionsRoute = ''
|
||||
let customRoute = ''
|
||||
|
||||
if (collection) {
|
||||
versionsRoute = `${adminRoute}/collections/${collection?.slug}/${params.id}/versions`
|
||||
customRoute = `${adminRoute}/collections/${collection?.slug}/${params.id}/custom`
|
||||
}
|
||||
|
||||
if (global) {
|
||||
versionsRoute = `${adminRoute}/globals/${global?.slug}/versions`
|
||||
customRoute = `${adminRoute}/globals/${global?.slug}/custom`
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Eyebrow />
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: 'var(--gutter-h)',
|
||||
paddingRight: 'var(--gutter-h)',
|
||||
}}
|
||||
>
|
||||
<h1>Custom Edit View</h1>
|
||||
<p>This custom edit view was added through one of the following Payload configs:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>components.views.Edit</code>
|
||||
<p>
|
||||
{'This takes precedence over the default edit view, '}
|
||||
<b>as well as all nested views like versions.</b>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<code>components.views.Edit.Default</code>
|
||||
<p>
|
||||
{'This allows you to override only the default edit view, but '}
|
||||
<b>
|
||||
<em>not</em>
|
||||
</b>
|
||||
{' any nested views like versions, etc.'}
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<code>components.views.Edit.Default.Component</code>
|
||||
<p>
|
||||
This is the most granular override, allowing you to override only the default edit
|
||||
view's Component, and its other properties like path and label.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<Button buttonStyle="primary" el="link" to={versionsRoute}>
|
||||
Custom Versions
|
||||
</Button>
|
||||
|
||||
<Button buttonStyle="secondary" el="link" to={customRoute}>
|
||||
Custom View
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomEditView
|
||||
85
test/admin/components/views/CustomVersions/index.tsx
Normal file
85
test/admin/components/views/CustomVersions/index.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { Fragment, useEffect } from 'react'
|
||||
import { Redirect, useParams } from 'react-router-dom'
|
||||
|
||||
import Button from '../../../../../packages/payload/src/admin/components/elements/Button'
|
||||
import Eyebrow from '../../../../../packages/payload/src/admin/components/elements/Eyebrow'
|
||||
import { useStepNav } from '../../../../../packages/payload/src/admin/components/elements/StepNav'
|
||||
import { useConfig } from '../../../../../packages/payload/src/admin/components/utilities/Config'
|
||||
import { type CustomAdminView } from '../../../../../packages/payload/src/config/types'
|
||||
|
||||
const CustomVersionsView: CustomAdminView = ({ canAccessAdmin, collection, global, user }) => {
|
||||
const {
|
||||
routes: { admin: adminRoute },
|
||||
} = useConfig()
|
||||
|
||||
const params = useParams()
|
||||
|
||||
const { setStepNav } = useStepNav()
|
||||
|
||||
// This effect will only run one time and will allow us
|
||||
// to set the step nav to display our custom route name
|
||||
|
||||
useEffect(() => {
|
||||
setStepNav([
|
||||
{
|
||||
label: 'Custom Versions View',
|
||||
},
|
||||
])
|
||||
}, [setStepNav])
|
||||
|
||||
// If an unauthorized user tries to navigate straight to this page,
|
||||
// Boot 'em out
|
||||
if (!user || (user && !canAccessAdmin)) {
|
||||
return <Redirect to={`${adminRoute}/unauthorized`} />
|
||||
}
|
||||
|
||||
let backURL = adminRoute
|
||||
|
||||
if (collection) {
|
||||
backURL = `${adminRoute}/collections/${collection?.slug}/${params.id}`
|
||||
}
|
||||
|
||||
if (global) {
|
||||
backURL = `${adminRoute}/globals/${global?.slug}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Eyebrow />
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: 'var(--gutter-h)',
|
||||
paddingRight: 'var(--gutter-h)',
|
||||
}}
|
||||
>
|
||||
<h1>Custom Versions View</h1>
|
||||
<p>This custom versions view was added through one of the following Payload configs:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>components.views.Versions</code>
|
||||
<p>
|
||||
{'This takes precedence over the default versions view, '}
|
||||
<b>as well as all nested views like /versions/:id.</b>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<code>components.views.Edit.versions</code>
|
||||
<p>Same as above.</p>
|
||||
</li>
|
||||
<li>
|
||||
<code>components.views.Edit.versions.Component</code>
|
||||
</li>
|
||||
<p>
|
||||
This is the most granular override, allowing you to override only the default versions
|
||||
view's Component, and its other properties like path and label.
|
||||
</p>
|
||||
</ul>
|
||||
<Button buttonStyle="secondary" el="link" to={backURL}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomVersionsView
|
||||
67
test/admin/components/views/CustomView/index.tsx
Normal file
67
test/admin/components/views/CustomView/index.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { Fragment, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import Button from '../../../../../packages/payload/src/admin/components/elements/Button'
|
||||
import Eyebrow from '../../../../../packages/payload/src/admin/components/elements/Eyebrow'
|
||||
import { useStepNav } from '../../../../../packages/payload/src/admin/components/elements/StepNav'
|
||||
import { useConfig } from '../../../../../packages/payload/src/admin/components/utilities/Config'
|
||||
import { type CustomAdminView } from '../../../../../packages/payload/src/config/types'
|
||||
|
||||
const CustomView: CustomAdminView = ({ collection, global }) => {
|
||||
const {
|
||||
routes: { admin: adminRoute },
|
||||
} = useConfig()
|
||||
|
||||
const params = useParams()
|
||||
|
||||
const { setStepNav } = useStepNav()
|
||||
|
||||
// This effect will only run one time and will allow us
|
||||
// to set the step nav to display our custom route name
|
||||
|
||||
useEffect(() => {
|
||||
setStepNav([
|
||||
{
|
||||
label: 'Custom View',
|
||||
},
|
||||
])
|
||||
}, [setStepNav])
|
||||
|
||||
let backURL = ''
|
||||
let versionsRoute = ''
|
||||
|
||||
if (collection) {
|
||||
backURL = `${adminRoute}/collections/${collection?.slug}/${params.id}`
|
||||
versionsRoute = `${adminRoute}/collections/${collection?.slug}/${params.id}/versions`
|
||||
}
|
||||
|
||||
if (global) {
|
||||
backURL = `${adminRoute}/globals/${global?.slug}`
|
||||
versionsRoute = `${adminRoute}/globals/${global?.slug}/versions`
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Eyebrow />
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: 'var(--gutter-h)',
|
||||
paddingRight: 'var(--gutter-h)',
|
||||
}}
|
||||
>
|
||||
<h1>Custom View</h1>
|
||||
<p>This custom view was added through the Payload config:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>components.views[key].Component</code>
|
||||
</li>
|
||||
</ul>
|
||||
<Button buttonStyle="secondary" el="link" to={backURL}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomView
|
||||
@@ -9,8 +9,11 @@ import BeforeLogin from './components/BeforeLogin'
|
||||
import DemoUIFieldCell from './components/DemoUIField/Cell'
|
||||
import DemoUIFieldField from './components/DemoUIField/Field'
|
||||
import Logout from './components/Logout'
|
||||
import CustomDefaultRoute from './components/views/CustomDefault'
|
||||
import CustomMinimalRoute from './components/views/CustomMinimal'
|
||||
import CustomDefaultRoute from './components/routes/CustomDefault'
|
||||
import CustomMinimalRoute from './components/routes/CustomMinimal'
|
||||
import CustomEditView from './components/views/CustomEdit'
|
||||
import CustomVersionsView from './components/views/CustomVersions'
|
||||
import CustomView from './components/views/CustomView'
|
||||
import { globalSlug, slug } from './shared'
|
||||
|
||||
export interface Post {
|
||||
@@ -131,6 +134,48 @@ export default buildConfigWithDefaults({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'custom-views-one',
|
||||
versions: true,
|
||||
admin: {
|
||||
components: {
|
||||
views: {
|
||||
Edit: CustomEditView,
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'custom-views-two',
|
||||
versions: true,
|
||||
admin: {
|
||||
components: {
|
||||
views: {
|
||||
Edit: {
|
||||
Default: CustomEditView,
|
||||
Versions: CustomVersionsView,
|
||||
MyCustomView: {
|
||||
path: '/custom',
|
||||
Component: CustomView,
|
||||
label: 'Custom',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'group-one-collection-ones',
|
||||
admin: {
|
||||
@@ -218,6 +263,49 @@ export default buildConfigWithDefaults({
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
slug: 'custom-global-views-one',
|
||||
versions: true,
|
||||
admin: {
|
||||
components: {
|
||||
views: {
|
||||
Edit: CustomEditView,
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'custom-global-views-two',
|
||||
versions: true,
|
||||
admin: {
|
||||
components: {
|
||||
views: {
|
||||
Edit: {
|
||||
Default: CustomEditView,
|
||||
Versions: CustomVersionsView,
|
||||
MyCustomView: {
|
||||
path: '/custom',
|
||||
Component: CustomView,
|
||||
label: 'Custom',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'group-globals-one',
|
||||
admin: {
|
||||
@@ -262,6 +350,20 @@ export default buildConfigWithDefaults({
|
||||
})
|
||||
})
|
||||
|
||||
await payload.create({
|
||||
collection: 'custom-views-one',
|
||||
data: {
|
||||
title: 'title',
|
||||
},
|
||||
})
|
||||
|
||||
await payload.create({
|
||||
collection: 'custom-views-two',
|
||||
data: {
|
||||
title: 'title',
|
||||
},
|
||||
})
|
||||
|
||||
await payload.create({
|
||||
collection: 'geo',
|
||||
data: {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Post } from './config'
|
||||
import payload from '../../packages/payload/src'
|
||||
import { mapAsync } from '../../packages/payload/src/utilities/mapAsync'
|
||||
import wait from '../../packages/payload/src/utilities/wait'
|
||||
import { saveDocAndAssert, saveDocHotkeyAndAssert } from '../helpers'
|
||||
import { openMainMenu, saveDocAndAssert, saveDocHotkeyAndAssert } from '../helpers'
|
||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
|
||||
import { initPayloadE2E } from '../helpers/configHelpers'
|
||||
import { globalSlug, slug } from './shared'
|
||||
@@ -45,27 +45,29 @@ describe('admin', () => {
|
||||
describe('Nav', () => {
|
||||
test('should nav to collection - sidebar', async () => {
|
||||
await page.goto(url.admin)
|
||||
const collectionLink = page.locator(`#nav-${slug}`)
|
||||
await collectionLink.click()
|
||||
|
||||
await openMainMenu(page)
|
||||
await page.locator(`#nav-${slug}`).click()
|
||||
expect(page.url()).toContain(url.list)
|
||||
})
|
||||
|
||||
test('should nav to a global - sidebar', async () => {
|
||||
await page.goto(url.admin)
|
||||
await openMainMenu(page)
|
||||
await page.locator(`#nav-global-${globalSlug}`).click()
|
||||
|
||||
expect(page.url()).toContain(url.global(globalSlug))
|
||||
})
|
||||
|
||||
test('should navigate to collection - card', async () => {
|
||||
await page.goto(url.admin)
|
||||
await wait(200)
|
||||
await page.locator(`#card-${slug}`).click()
|
||||
expect(page.url()).toContain(url.list)
|
||||
})
|
||||
|
||||
test('should collapse and expand collection groups', async () => {
|
||||
await page.goto(url.admin)
|
||||
await openMainMenu(page)
|
||||
|
||||
const navGroup = page.locator('#nav-group-One .nav-group__toggle')
|
||||
const link = page.locator('#nav-group-one-collection-ones')
|
||||
|
||||
@@ -81,6 +83,8 @@ describe('admin', () => {
|
||||
|
||||
test('should collapse and expand globals groups', async () => {
|
||||
await page.goto(url.admin)
|
||||
await openMainMenu(page)
|
||||
|
||||
const navGroup = page.locator('#nav-group-Group .nav-group__toggle')
|
||||
const link = page.locator('#nav-global-group-globals-one')
|
||||
|
||||
@@ -96,12 +100,9 @@ describe('admin', () => {
|
||||
|
||||
test('should save nav group collapse preferences', async () => {
|
||||
await page.goto(url.admin)
|
||||
|
||||
const navGroup = page.locator('#nav-group-One .nav-group__toggle')
|
||||
await navGroup.click()
|
||||
|
||||
await openMainMenu(page)
|
||||
await page.locator('#nav-group-One .nav-group__toggle').click()
|
||||
await page.goto(url.admin)
|
||||
|
||||
const link = page.locator('#nav-group-one-collection-ones')
|
||||
await expect(link).toBeHidden()
|
||||
})
|
||||
@@ -189,13 +190,11 @@ describe('admin', () => {
|
||||
})
|
||||
|
||||
test('should delete existing', async () => {
|
||||
const { id, ...post } = await createPost()
|
||||
|
||||
const { id, title } = await createPost()
|
||||
await page.goto(url.edit(id))
|
||||
await page.locator('#action-delete').click()
|
||||
await page.locator('#confirm-delete').click()
|
||||
|
||||
await expect(page.locator(`text=Post en "${post.title}" successfully deleted.`)).toBeVisible()
|
||||
await expect(page.locator(`text=Post en "${title}" successfully deleted.`)).toBeVisible()
|
||||
expect(page.url()).toContain(url.list)
|
||||
})
|
||||
|
||||
@@ -724,7 +723,8 @@ describe('admin', () => {
|
||||
describe('custom css', () => {
|
||||
test('should see custom css in admin UI', async () => {
|
||||
await page.goto(url.admin)
|
||||
const navControls = page.locator('.nav__controls')
|
||||
await openMainMenu(page)
|
||||
const navControls = page.locator('.main-menu__controls')
|
||||
await expect(navControls).toHaveCSS('font-family', 'monospace')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
.nav__controls {
|
||||
.main-menu__controls {
|
||||
font-family: monospace;
|
||||
background-image: url('/placeholder.png');
|
||||
}
|
||||
.nav__controls:before {
|
||||
|
||||
.main-menu__controls:before {
|
||||
content: 'custom-css';
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import jwtDecode from 'jwt-decode';
|
||||
import { GraphQLClient } from 'graphql-request';
|
||||
import { GraphQLClient } from 'graphql-request'
|
||||
import jwtDecode from 'jwt-decode'
|
||||
|
||||
import type { User } from '../../packages/payload/src/auth'
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('globals', () => {
|
||||
|
||||
describe('local', () => {
|
||||
it('should save empty json objects', async () => {
|
||||
const createdJSON = await payload.updateGlobal({
|
||||
const createdJSON: any = await payload.updateGlobal({
|
||||
slug,
|
||||
data: {
|
||||
json: {
|
||||
|
||||
@@ -55,3 +55,22 @@ export async function saveDocAndAssert(page: Page, selector = '#action-save'): P
|
||||
await expect(page.locator('.Toastify')).toContainText('successfully')
|
||||
expect(page.url()).not.toContain('create')
|
||||
}
|
||||
|
||||
export async function openMainMenu(page: Page): Promise<void> {
|
||||
await page.locator('.payload__modal-toggler--slug-main-menu').click()
|
||||
const mainMenuModal = page.locator('#main-menu')
|
||||
await expect(mainMenuModal).toBeVisible()
|
||||
}
|
||||
|
||||
export async function closeMainMenu(page: Page): Promise<void> {
|
||||
await page.locator('.payload__modal-toggler--slug-main-menu--is-open').click()
|
||||
const mainMenuModal = page.locator('#main-menu')
|
||||
await expect(mainMenuModal).toBeHidden()
|
||||
}
|
||||
|
||||
export async function changeLocale(page: Page, newLocale: string) {
|
||||
await openMainMenu(page)
|
||||
await page.locator('.localizer >> button').first().click()
|
||||
await page.locator(`.localizer >> a:has-text("${newLocale}")`).click()
|
||||
expect(page.url()).toContain(`locale=${newLocale}`)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function initPayloadTest(options: Options): Promise<{ serverURL: st
|
||||
...(options.init || {}),
|
||||
}
|
||||
|
||||
process.env.PAYLOAD_DROP_DATABASE = 'true'
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.PAYLOAD_CONFIG_PATH = path.resolve(options.__dirname, './config.ts')
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { NestedAfterReadHook } from './payload-types'
|
||||
|
||||
import payload from '../../packages/payload/src'
|
||||
import { AuthenticationError } from '../../packages/payload/src/errors'
|
||||
import { devUser, regularUser } from '../credentials'
|
||||
@@ -77,7 +79,7 @@ describe('Hooks', () => {
|
||||
})
|
||||
|
||||
it('should save data generated with afterRead hooks in nested field structures', async () => {
|
||||
const document = await payload.create({
|
||||
const document: NestedAfterReadHook = await payload.create({
|
||||
collection: nestedAfterReadHooksSlug,
|
||||
data: {
|
||||
text: 'ok',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { expect, test } from '@playwright/test'
|
||||
import type { LocalizedPost } from './payload-types'
|
||||
|
||||
import payload from '../../packages/payload/src'
|
||||
import { saveDocAndAssert } from '../helpers'
|
||||
import { changeLocale, saveDocAndAssert } from '../helpers'
|
||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
|
||||
import { initPayloadTest } from '../helpers/configHelpers'
|
||||
import { englishTitle, localizedPostsSlug, spanishLocale } from './shared'
|
||||
@@ -29,6 +29,7 @@ const arabicTitle = 'arabic title'
|
||||
const description = 'description'
|
||||
|
||||
let page: Page
|
||||
|
||||
describe('Localization', () => {
|
||||
beforeAll(async ({ browser }) => {
|
||||
const { serverURL } = await initPayloadTest({
|
||||
@@ -52,7 +53,7 @@ describe('Localization', () => {
|
||||
await saveDocAndAssert(page)
|
||||
|
||||
// Change back to English
|
||||
await changeLocale('es')
|
||||
await changeLocale(page, 'es')
|
||||
|
||||
// Localized field should not be populated
|
||||
await expect(page.locator('#field-title')).toBeEmpty()
|
||||
@@ -60,7 +61,7 @@ describe('Localization', () => {
|
||||
|
||||
await fillValues({ title: spanishTitle, description })
|
||||
await saveDocAndAssert(page)
|
||||
await changeLocale(defaultLocale)
|
||||
await changeLocale(page, defaultLocale)
|
||||
|
||||
// Expect english title
|
||||
await expect(page.locator('#field-title')).toHaveValue(title)
|
||||
@@ -73,13 +74,13 @@ describe('Localization', () => {
|
||||
const newLocale = 'es'
|
||||
|
||||
// Change to Spanish
|
||||
await changeLocale(newLocale)
|
||||
await changeLocale(page, newLocale)
|
||||
|
||||
await fillValues({ title: spanishTitle, description })
|
||||
await saveDocAndAssert(page)
|
||||
|
||||
// Change back to English
|
||||
await changeLocale(defaultLocale)
|
||||
await changeLocale(page, defaultLocale)
|
||||
|
||||
// Localized field should not be populated
|
||||
await expect(page.locator('#field-title')).toBeEmpty()
|
||||
@@ -101,13 +102,13 @@ describe('Localization', () => {
|
||||
const newLocale = 'ar'
|
||||
|
||||
// Change to Arabic
|
||||
await changeLocale(newLocale)
|
||||
await changeLocale(page, newLocale)
|
||||
|
||||
await fillValues({ title: arabicTitle, description })
|
||||
await saveDocAndAssert(page)
|
||||
|
||||
// Change back to English
|
||||
await changeLocale(defaultLocale)
|
||||
await changeLocale(page, defaultLocale)
|
||||
|
||||
// Localized field should not be populated
|
||||
await expect(page.locator('#field-title')).toBeEmpty()
|
||||
@@ -125,16 +126,16 @@ describe('Localization', () => {
|
||||
})
|
||||
|
||||
describe('localized duplicate', () => {
|
||||
let id
|
||||
|
||||
beforeAll(async () => {
|
||||
test('should duplicate data for all locales', async () => {
|
||||
const localizedPost = await payload.create({
|
||||
collection: localizedPostsSlug,
|
||||
data: {
|
||||
title: englishTitle,
|
||||
},
|
||||
})
|
||||
id = localizedPost.id
|
||||
|
||||
const id = localizedPost.id.toString()
|
||||
|
||||
await payload.update({
|
||||
collection: localizedPostsSlug,
|
||||
id,
|
||||
@@ -143,17 +144,12 @@ describe('Localization', () => {
|
||||
title: spanishTitle,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('should duplicate data for all locales', async () => {
|
||||
await page.goto(url.edit(id))
|
||||
|
||||
await page.locator('.btn.duplicate').first().click()
|
||||
await expect(page.locator('.Toastify')).toContainText('successfully')
|
||||
|
||||
await expect(page.locator('#field-title')).toHaveValue(englishTitle)
|
||||
|
||||
await changeLocale(spanishLocale)
|
||||
await changeLocale(page, spanishLocale)
|
||||
await expect(page.locator('#field-title')).toHaveValue(spanishTitle)
|
||||
})
|
||||
})
|
||||
@@ -165,9 +161,3 @@ async function fillValues(data: Partial<LocalizedPost>) {
|
||||
if (titleVal) await page.locator('#field-title').fill(titleVal)
|
||||
if (descVal) await page.locator('#field-description').fill(descVal)
|
||||
}
|
||||
|
||||
async function changeLocale(newLocale: string) {
|
||||
await page.locator('.localizer >> button').first().click()
|
||||
await page.locator(`.localizer >> a:has-text("${newLocale}")`).click()
|
||||
expect(page.url()).toContain(`locale=${newLocale}`)
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ import {
|
||||
relationSpanishTitle,
|
||||
relationSpanishTitle2,
|
||||
relationshipLocalizedSlug,
|
||||
withLocalizedRelSlug,
|
||||
withRequiredLocalizedFields,
|
||||
spanishLocale,
|
||||
spanishTitle,
|
||||
withLocalizedRelSlug,
|
||||
withRequiredLocalizedFields,
|
||||
} from './shared'
|
||||
|
||||
const collection = localizedPostsSlug
|
||||
@@ -36,6 +36,7 @@ describe('Localization', () => {
|
||||
;({ serverURL } = await initPayloadTest({ __dirname, init: { local: false } }))
|
||||
config = await configPromise
|
||||
|
||||
// @ts-expect-error Force typing
|
||||
post1 = await payload.create({
|
||||
collection,
|
||||
data: {
|
||||
@@ -43,6 +44,7 @@ describe('Localization', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// @ts-expect-error Force typing
|
||||
postWithLocalizedData = await payload.create({
|
||||
collection,
|
||||
data: {
|
||||
@@ -89,7 +91,7 @@ describe('Localization', () => {
|
||||
|
||||
expect(updated.title).toEqual(spanishTitle)
|
||||
|
||||
const localized = await payload.findByID({
|
||||
const localized: any = await payload.findByID({
|
||||
collection,
|
||||
id: post1.id,
|
||||
locale: 'all',
|
||||
@@ -111,7 +113,7 @@ describe('Localization', () => {
|
||||
|
||||
expect(updated.title).toEqual(englishTitle)
|
||||
|
||||
const localizedFallback = await payload.findByID({
|
||||
const localizedFallback: any = await payload.findByID({
|
||||
collection,
|
||||
id: post1.id,
|
||||
locale: 'all',
|
||||
@@ -131,6 +133,7 @@ describe('Localization', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// @ts-expect-error Force typing
|
||||
localizedPost = await payload.update({
|
||||
collection,
|
||||
id,
|
||||
@@ -171,7 +174,7 @@ describe('Localization', () => {
|
||||
})
|
||||
|
||||
it('all locales', async () => {
|
||||
const localized = await payload.findByID({
|
||||
const localized: any = await payload.findByID({
|
||||
collection,
|
||||
locale: 'all',
|
||||
id: localizedPost.id,
|
||||
@@ -191,8 +194,8 @@ describe('Localization', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id);
|
||||
});
|
||||
expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id)
|
||||
})
|
||||
|
||||
it('by localized field value - alternate locale', async () => {
|
||||
const result = await payload.find({
|
||||
@@ -205,8 +208,8 @@ describe('Localization', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id);
|
||||
});
|
||||
expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id)
|
||||
})
|
||||
|
||||
it('by localized field value - opposite locale???', async () => {
|
||||
const result = await payload.find({
|
||||
@@ -219,10 +222,10 @@ describe('Localization', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(result.docs.map(({ id }) => id)).toContain(localizedPost.id)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Localized Relationship', () => {
|
||||
let localizedRelation: LocalizedPost
|
||||
@@ -243,6 +246,7 @@ describe('Localization', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// @ts-expect-error Force typing
|
||||
withRelationship = await payload.create({
|
||||
collection: withLocalizedRelSlug,
|
||||
data: {
|
||||
@@ -304,15 +308,16 @@ describe('Localization', () => {
|
||||
|
||||
it('populates relationships with all locales', async () => {
|
||||
// the relationship fields themselves are localized on this collection
|
||||
const result = await payload.find({
|
||||
const result: any = await payload.find({
|
||||
collection: relationshipLocalizedSlug,
|
||||
locale: 'all',
|
||||
depth: 1,
|
||||
})
|
||||
expect((result.docs[0].relationship as any).en.id).toBeDefined()
|
||||
expect((result.docs[0].relationshipHasMany as any).en[0].id).toBeDefined()
|
||||
expect((result.docs[0].relationMultiRelationTo as any).en.value.id).toBeDefined()
|
||||
expect((result.docs[0].relationMultiRelationToHasMany as any).en[0].value.id).toBeDefined()
|
||||
|
||||
expect(result.docs[0].relationship.en.id).toBeDefined()
|
||||
expect(result.docs[0].relationshipHasMany.en[0].id).toBeDefined()
|
||||
expect(result.docs[0].relationMultiRelationTo.en.value.id).toBeDefined()
|
||||
expect(result.docs[0].relationMultiRelationToHasMany.en[0].value.id).toBeDefined()
|
||||
expect(result.docs[0].arrayField.en[0].nestedRelation.id).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -328,7 +333,7 @@ describe('Localization', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id);
|
||||
expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id)
|
||||
|
||||
// Second relationship
|
||||
const result2 = await payload.find({
|
||||
@@ -340,8 +345,8 @@ describe('Localization', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id);
|
||||
});
|
||||
expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id)
|
||||
})
|
||||
|
||||
it('specific locale', async () => {
|
||||
const result = await payload.find({
|
||||
@@ -395,7 +400,7 @@ describe('Localization', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id);
|
||||
expect(result.docs.map(({ id }) => id)).toContain(withRelationship.id)
|
||||
|
||||
// First relationship - spanish
|
||||
const result2 = await queryRelation({
|
||||
@@ -404,7 +409,7 @@ describe('Localization', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id);
|
||||
expect(result2.docs.map(({ id }) => id)).toContain(withRelationship.id)
|
||||
|
||||
// Second relationship - english
|
||||
const result3 = await queryRelation({
|
||||
@@ -413,7 +418,7 @@ describe('Localization', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result3.docs.map(({ id }) => id)).toContain(withRelationship.id);
|
||||
expect(result3.docs.map(({ id }) => id)).toContain(withRelationship.id)
|
||||
|
||||
// Second relationship - spanish
|
||||
const result4 = await queryRelation({
|
||||
@@ -509,7 +514,7 @@ describe('Localization', () => {
|
||||
|
||||
describe('Localized - arrays with nested localized fields', () => {
|
||||
it('should allow moving rows and retain existing row locale data', async () => {
|
||||
const globalArray = await payload.findGlobal({
|
||||
const globalArray: any = await payload.findGlobal({
|
||||
slug: 'global-array',
|
||||
})
|
||||
|
||||
@@ -753,7 +758,7 @@ async function createLocalizedPost(data: {
|
||||
[spanishLocale]: string
|
||||
}
|
||||
}): Promise<LocalizedPost> {
|
||||
const localizedRelation = await payload.create({
|
||||
const localizedRelation: any = await payload.create({
|
||||
collection,
|
||||
data: {
|
||||
title: data.title.en,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import { closeMainMenu, openMainMenu } from '../helpers'
|
||||
import { initPayloadE2E } from '../helpers/configHelpers'
|
||||
|
||||
const { beforeAll, describe } = test
|
||||
@@ -19,6 +20,8 @@ describe('refresh-permissions', () => {
|
||||
test('should show test global immediately after allowing access', async () => {
|
||||
await page.goto(`${serverURL}/admin/globals/settings`)
|
||||
|
||||
await openMainMenu(page)
|
||||
|
||||
// Ensure that we have loaded accesses by checking that settings collection
|
||||
// at least is visible in the menu.
|
||||
await expect(page.locator('#nav-global-settings')).toBeVisible()
|
||||
@@ -26,10 +29,14 @@ describe('refresh-permissions', () => {
|
||||
// Test collection should be hidden at first.
|
||||
await expect(page.locator('#nav-global-test')).toBeHidden()
|
||||
|
||||
await closeMainMenu(page)
|
||||
|
||||
// Allow access to test global.
|
||||
await page.locator('.custom-checkbox:has(#field-test) input').check()
|
||||
await page.locator('#action-save').click()
|
||||
|
||||
await openMainMenu(page)
|
||||
|
||||
// Now test collection should appear in the menu.
|
||||
await expect(page.locator('#nav-global-test')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { Page } from '@playwright/test'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import wait from '../../packages/payload/src/utilities/wait'
|
||||
import { changeLocale } from '../helpers'
|
||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
|
||||
import { initPayloadE2E } from '../helpers/configHelpers'
|
||||
import { autosaveSlug, draftSlug } from './shared'
|
||||
@@ -130,25 +131,19 @@ describe('versions', () => {
|
||||
await page.locator('#field-description').fill(description)
|
||||
await wait(500)
|
||||
|
||||
await changeLocale(spanishLocale)
|
||||
await changeLocale(page, spanishLocale)
|
||||
await page.locator('#field-title').fill(spanishTitle)
|
||||
await wait(500)
|
||||
|
||||
await changeLocale(locale)
|
||||
await changeLocale(page, locale)
|
||||
await page.locator('#field-description').fill(newDescription)
|
||||
await wait(500)
|
||||
|
||||
await changeLocale(spanishLocale)
|
||||
await changeLocale(page, spanishLocale)
|
||||
await wait(500)
|
||||
await page.reload()
|
||||
await expect(page.locator('#field-title')).toHaveValue(spanishTitle)
|
||||
await expect(page.locator('#field-description')).toHaveValue(newDescription)
|
||||
})
|
||||
})
|
||||
|
||||
async function changeLocale(newLocale: string) {
|
||||
await page.locator('.localizer >> button').first().click()
|
||||
await page.locator(`.localizer >> a:has-text("${newLocale}")`).click()
|
||||
expect(page.url()).toContain(`locale=${newLocale}`)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user