chore: merge

This commit is contained in:
James
2023-09-15 17:12:32 -04:00
59 changed files with 1968 additions and 1023 deletions

View File

@@ -47,6 +47,7 @@
"copyfiles": "2.4.1",
"cross-env": "7.0.3",
"express": "4.18.2",
"form-data": "3.0.1",
"get-port": "5.1.1",
"graphql-request": "3.7.0",
"isomorphic-fetch": "3.0.0",
@@ -58,6 +59,8 @@
"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",

View File

@@ -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 = () => (

View File

@@ -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

View File

@@ -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"
/>

View File

@@ -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>
)

View File

@@ -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;
}
}
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -16,6 +16,7 @@ type Props = {
overlayType?: string
show?: boolean
}
export const LoadingOverlay: React.FC<Props> = ({
animationDuration,
loadingText,

View File

@@ -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;

View File

@@ -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'}>

View File

@@ -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;
}
}
}
}
}

View File

@@ -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>
)
}

View File

@@ -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;
}
}
}
}

View File

@@ -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>
)
}

View File

@@ -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;

View File

@@ -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,
}}
/>
)

View 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
}, [])
}

View File

@@ -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
}, [])
}

View File

@@ -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
}

View File

@@ -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
}, [])
}

View 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>
)
}

View File

@@ -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)

View 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>
)
}

View File

@@ -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

View File

@@ -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>
}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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,
}),
}),

View File

@@ -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>
}
}

View File

@@ -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,

View File

@@ -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(),
}

View 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(),
}),
)

View File

@@ -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

View File

@@ -1,6 +1,6 @@
import joi from 'joi'
import { componentSchema } from '../../utilities/componentSchema'
import { componentSchema } from '../../config/shared/componentSchema'
export const baseAdminComponentFields = joi
.object()

View File

@@ -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),

View File

@@ -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>
}
}
/**

25
pnpm-lock.yaml generated
View File

@@ -47,6 +47,9 @@ importers:
express:
specifier: 4.18.2
version: 4.18.2
form-data:
specifier: 3.0.1
version: 3.0.1
get-port:
specifier: 5.1.1
version: 5.1.1
@@ -80,6 +83,12 @@ importers:
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
@@ -8512,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==}
@@ -8563,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==}
@@ -8687,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==}
@@ -10747,7 +10752,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==}
@@ -11179,7 +11183,6 @@ packages:
resolution: {integrity: sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==}
dependencies:
isarray: 0.0.1
dev: false
/path-type@3.0.0:
resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==}
@@ -12219,7 +12222,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==}
@@ -12457,11 +12459,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==}
@@ -12506,7 +12506,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==}
@@ -12533,7 +12532,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==}
@@ -12856,7 +12854,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==}
@@ -13848,11 +13845,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==}
@@ -14403,7 +14398,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==}
@@ -14412,7 +14406,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==}

View File

@@ -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',

View File

@@ -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 () => {

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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&apos;s Component, and its other properties like path and label.
</p>
</li>
</ul>
<Button buttonStyle="primary" el="link" to={versionsRoute}>
Custom Versions
</Button>
&nbsp; &nbsp; &nbsp;
<Button buttonStyle="secondary" el="link" to={customRoute}>
Custom View
</Button>
</div>
</Fragment>
)
}
export default CustomEditView

View 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&apos;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

View 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

View File

@@ -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: {

View File

@@ -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')
})
})

View File

@@ -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';
}

View File

@@ -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: {

View File

@@ -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}`)
}

View File

@@ -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',

View File

@@ -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}`)
}

View File

@@ -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,

View File

@@ -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()
})

View File

@@ -1,5 +1,4 @@
import { randomBytes } from 'crypto'
import mongoose from 'mongoose'
import { randomBytes } from 'crypto';
import type {
ChainedRelation,
@@ -8,12 +7,12 @@ import type {
Director,
Post,
Relation,
} from './payload-types'
} from './payload-types';
import payload from '../../packages/payload/src'
import { mapAsync } from '../../packages/payload/src/utilities/mapAsync'
import { initPayloadTest } from '../helpers/configHelpers'
import { RESTClient } from '../helpers/rest'
import payload from '../../packages/payload/src';
import { mapAsync } from '../../packages/payload/src/utilities/mapAsync';
import { initPayloadTest } from '../helpers/configHelpers';
import { RESTClient } from '../helpers/rest';
import config, {
chainedRelSlug,
customIdNumberSlug,
@@ -21,7 +20,7 @@ import config, {
defaultAccessRelSlug,
relationSlug,
slug,
} from './config'
} from './config';
let client: RESTClient

View File

@@ -1,13 +1,14 @@
import FormData from 'form-data'
import fs from 'fs'
import path from 'path'
import { promisify } from 'util'
import FormData from 'form-data';
import fs from 'fs';
import path from 'path';
import { promisify } from 'util';
import payload from '../../packages/payload/src'
import getFileByPath from '../../packages/payload/src/uploads/getFileByPath'
import { initPayloadTest } from '../helpers/configHelpers'
import { RESTClient } from '../helpers/rest'
import configPromise, { enlargeSlug, mediaSlug, reduceSlug, relationSlug } from './config'
import payload from '../../packages/payload/src';
import getFileByPath from '../../packages/payload/src/uploads/getFileByPath';
import { initPayloadTest } from '../helpers/configHelpers';
import { RESTClient } from '../helpers/rest';
import configPromise from './config';
import { enlargeSlug, mediaSlug, reduceSlug, relationSlug } from './shared';
const stat = promisify(fs.stat)

View File

@@ -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}`)
}
})