Compare commits

..

1 Commits

Author SHA1 Message Date
Alessio Gravili
8fe8edc014 ci: use node 23.6.1 2025-01-30 12:27:04 -07:00
51 changed files with 9042 additions and 3710 deletions

View File

@@ -16,7 +16,7 @@ concurrency:
cancel-in-progress: true
env:
NODE_VERSION: 22.6.0
NODE_VERSION: 23.6.1
PNPM_VERSION: 9.7.1
DO_NOT_TRACK: 1 # Disable Turbopack telemetry
NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry

View File

@@ -25,18 +25,6 @@ This plugin sets up multi-tenancy for your application from within your [Admin P
- Adds a `tenant` field to each specified collection
- Adds a tenant selector to the admin panel, allowing you to switch between tenants
- Filters list view results by selected tenant
- Filters relationship fields by selected tenant
- Ability to create "global" like collections, 1 doc per tenant
- Automatically assign a tenant to new documents
<Banner type="error">
**Warning**
By default this plugin cleans up documents when a tenant is deleted. You should ensure you have
strong access control on your tenants collection to prevent deletions by unauthorized users.
You can disabled this behavior by setting `cleanupAfterTenantDelete` to `false` in the plugin options.
</Banner>
## Installation
@@ -52,7 +40,7 @@ The plugin accepts an object with the following properties:
```ts
type MultiTenantPluginConfig<ConfigTypes = unknown> = {
/**
/**
* After a tenant is deleted, the plugin will attempt to clean up related documents
* - removing documents with the tenant ID
* - removing the tenant from users
@@ -156,12 +144,8 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
* Useful for super-admin type users
*/
userHasAccessToAllTenants?: (
user: ConfigTypes extends { user: unknown } ? ConfigTypes['user'] : User,
user: ConfigTypes extends { user } ? ConfigTypes['user'] : User,
) => boolean
/**
* Opt out of adding access constraints to the tenants collection
*/
useTenantsCollectionAccess?: boolean
}
```

View File

@@ -28,7 +28,7 @@ To securely allow headless operation you will need to configure the allowed orig
## Limiting GraphQL Complexity
Because GraphQL gives the power of query writing outside a server's control, someone with bad intentions might write a maliciously complex query and bog down your server. To prevent resource-intensive GraphQL requests, Payload provides a way to specify complexity limits. These limits are based on a complexity score calculated for each request.
Because GraphQL gives the power of query writing outside a server's control, someone with bad intentions might write a maliciously complex query and bog down your server. To prevent resource-intensive GraphQL requests, Payload provides a way specify complexity limits which are based on a complexity score that is calculated for each request.
Any GraphQL request that is calculated to be too expensive is rejected. On the Payload Config, in `graphQL` you can set the `maxComplexity` value as an integer. For reference, the default complexity value for each added field is 1, and all `relationship` and `upload` fields are assigned a value of 10.

View File

@@ -9,8 +9,8 @@
h4,
h5,
h6 {
font-size: unset;
font-weight: unset;
font-size: auto;
font-weight: auto;
}
:root {

View File

@@ -18,8 +18,7 @@
"payload": "latest",
"payload-app": "workspace:*",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sharp": "0.32.6"
"react-dom": "^18.2.0"
},
"devDependencies": {
"@remix-run/dev": "^2.15.2",

View File

@@ -88,10 +88,6 @@ export const traverseFields = ({
texts,
withinArrayOrBlockLocale,
}: Args) => {
if (row._uuid) {
data._uuid = row._uuid
}
fields.forEach((field) => {
let columnName = ''
let fieldName = ''

View File

@@ -20,10 +20,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
db,
fields,
ignoreResult,
// TODO:
// When we support joins for write operations (create/update) - pass collectionSlug to the buildFindManyArgs
// Make a new argument in upsertRow.ts and pass the slug from every operation.
joinQuery: _joinQuery,
joinQuery,
operation,
path = '',
req,
@@ -266,9 +263,6 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
}
}
// When versions are enabled, this is used to track mapping between blocks/arrays ObjectID to their numeric generated representation, then we use it for nested to arrays/blocks select hasMany in versions.
const arraysBlocksUUIDMap: Record<string, number | string> = {}
for (const [blockName, blockRows] of Object.entries(blocksToInsert)) {
const blockTableName = adapter.tableNameMap.get(`${tableName}_blocks_${blockName}`)
insertedBlockRows[blockName] = await adapter.insert({
@@ -279,12 +273,6 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
insertedBlockRows[blockName].forEach((row, i) => {
blockRows[i].row = row
if (
typeof row._uuid === 'string' &&
(typeof row.id === 'string' || typeof row.id === 'number')
) {
arraysBlocksUUIDMap[row._uuid] = row.id
}
})
const blockLocaleIndexMap: number[] = []
@@ -317,7 +305,6 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
arrays: blockRows.map(({ arrays }) => arrays),
db,
parentRows: insertedBlockRows[blockName],
uuidMap: arraysBlocksUUIDMap,
})
}
@@ -341,7 +328,6 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
arrays: [rowToInsert.arrays],
db,
parentRows: [insertedRow],
uuidMap: arraysBlocksUUIDMap,
})
// //////////////////////////////////
@@ -358,14 +344,6 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
})
}
if (Object.keys(arraysBlocksUUIDMap).length > 0) {
tableRows.forEach((row: any) => {
if (row.parent in arraysBlocksUUIDMap) {
row.parent = arraysBlocksUUIDMap[row.parent]
}
})
}
if (tableRows.length) {
await adapter.insert({
db,
@@ -436,11 +414,13 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
// RETRIEVE NEWLY UPDATED ROW
// //////////////////////////////////
joinQuery = operation === 'create' ? false : joinQuery
const findManyArgs = buildFindManyArgs({
adapter,
depth: 0,
fields,
joinQuery: false,
joinQuery,
select,
tableName,
})
@@ -458,7 +438,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
config: adapter.payload.config,
data: doc,
fields,
joinQuery: false,
joinQuery,
})
return result

View File

@@ -8,7 +8,6 @@ type Args = {
}[]
db: DrizzleAdapter['drizzle'] | DrizzleTransaction
parentRows: Record<string, unknown>[]
uuidMap?: Record<string, number | string>
}
type RowsByTable = {
@@ -21,13 +20,7 @@ type RowsByTable = {
}
}
export const insertArrays = async ({
adapter,
arrays,
db,
parentRows,
uuidMap = {},
}: Args): Promise<void> => {
export const insertArrays = async ({ adapter, arrays, db, parentRows }: Args): Promise<void> => {
// Maintain a map of flattened rows by table
const rowsByTable: RowsByTable = {}
@@ -81,15 +74,6 @@ export const insertArrays = async ({
tableName,
values: row.rows,
})
insertedRows.forEach((row) => {
if (
typeof row._uuid === 'string' &&
(typeof row.id === 'string' || typeof row.id === 'number')
) {
uuidMap[row._uuid] = row.id
}
})
}
// Insert locale rows

View File

@@ -32,12 +32,7 @@ export const RenderVersionFieldsToDiff = ({
const LocaleComponents: React.ReactNode[] = []
for (const [locale, baseField] of Object.entries(field.fieldByLocale)) {
LocaleComponents.push(
<div
className={`${baseClass}__locale`}
data-field-path={baseField.path}
data-locale={locale}
key={[locale, fieldIndex].join('-')}
>
<div className={`${baseClass}__locale`} key={[locale, fieldIndex].join('-')}>
<div className={`${baseClass}__locale-value`}>{baseField.CustomComponent}</div>
</div>,
)

View File

@@ -282,13 +282,15 @@ const buildVersionField = ({
}
} // At this point, we are dealing with a `row`, etc
else if ('fields' in field) {
if (field.type === 'array' && versionValue) {
const arrayValue = Array.isArray(versionValue) ? versionValue : []
if (field.type === 'array') {
if (!Array.isArray(versionValue)) {
throw new Error('Expected versionValue to be an array')
}
baseVersionField.rows = []
for (let i = 0; i < arrayValue.length; i++) {
for (let i = 0; i < versionValue.length; i++) {
const comparisonRow = comparisonValue?.[i] || {}
const versionRow = arrayValue?.[i] || {}
const versionRow = versionValue?.[i] || {}
baseVersionField.rows[i] = buildVersionFields({
clientSchemaMap,
comparisonSiblingData: comparisonRow,
@@ -327,11 +329,13 @@ const buildVersionField = ({
} else if (field.type === 'blocks') {
baseVersionField.rows = []
const blocksValue = Array.isArray(versionValue) ? versionValue : []
if (!Array.isArray(versionValue)) {
throw new Error('Expected versionValue to be an array')
}
for (let i = 0; i < blocksValue.length; i++) {
for (let i = 0; i < versionValue.length; i++) {
const comparisonRow = comparisonValue?.[i] || {}
const versionRow = blocksValue[i] || {}
const versionRow = versionValue[i] || {}
const versionBlock = field.blocks.find((block) => block.slug === versionRow.blockType)
let fields = []

View File

@@ -128,17 +128,15 @@ export const multiTenantPlugin =
if (collection.slug === tenantsCollectionSlug) {
tenantCollection = collection
if (pluginConfig.useTenantsCollectionAccess !== false) {
/**
* Add access control constraint to tenants collection
* - constrains access a users assigned tenants
*/
addCollectionAccess({
collection,
fieldName: 'id',
userHasAccessToAllTenants,
})
}
/**
* Add access control constraint to tenants collection
* - constrains access a users assigned tenants
*/
addCollectionAccess({
collection,
fieldName: 'id',
userHasAccessToAllTenants,
})
if (pluginConfig.cleanupAfterTenantDelete !== false) {
/**

View File

@@ -121,10 +121,6 @@ export type MultiTenantPluginConfig<ConfigTypes = unknown> = {
userHasAccessToAllTenants?: (
user: ConfigTypes extends { user: unknown } ? ConfigTypes['user'] : User,
) => boolean
/**
* Opt out of adding access constraints to the tenants collection
*/
useTenantsCollectionAccess?: boolean
}
export type Tenant<IDType = number | string> = {

View File

@@ -394,7 +394,7 @@
},
"peerDependencies": {
"@faceless-ui/modal": "3.0.0-beta.2",
"@faceless-ui/scroll-info": "2.0.0",
"@faceless-ui/scroll-info": "2.0.0-beta.0",
"@lexical/headless": "0.21.0",
"@lexical/html": "0.21.0",
"@lexical/link": "0.21.0",

View File

@@ -79,10 +79,6 @@ const RichTextComponent: React.FC<
const disabled = readOnlyFromProps || formProcessing || formInitializing
const [isSmallWidthViewport, setIsSmallWidthViewport] = useState<boolean>(false)
const [rerenderProviderKey, setRerenderProviderKey] = useState<Date>()
const prevInitialValueRef = React.useRef<SerializedEditorState | undefined>(initialValue)
const prevValueRef = React.useRef<SerializedEditorState | undefined>(value)
useEffect(() => {
const updateViewPortWidth = () => {
@@ -117,24 +113,13 @@ const RichTextComponent: React.FC<
const handleChange = useCallback(
(editorState: EditorState) => {
const newState = editorState.toJSON()
prevValueRef.current = newState
setValue(newState)
setValue(editorState.toJSON())
},
[setValue],
)
const styles = useMemo(() => mergeFieldStyles(field), [field])
useEffect(() => {
if (JSON.stringify(initialValue) !== JSON.stringify(prevInitialValueRef.current)) {
prevInitialValueRef.current = initialValue
if (JSON.stringify(prevValueRef.current) !== JSON.stringify(value)) {
setRerenderProviderKey(new Date())
}
}
}, [initialValue, value])
return (
<div className={classes} key={pathWithEditDepth} style={styles}>
<RenderCustomComponent
@@ -150,7 +135,7 @@ const RichTextComponent: React.FC<
editorConfig={editorConfig}
fieldProps={props}
isSmallWidthViewport={isSmallWidthViewport}
key={JSON.stringify({ path, rerenderProviderKey })} // makes sure lexical is completely re-rendered when initialValue changes, bypassing the lexical-internal value memoization. That way, external changes to the form will update the editor. More infos in PR description (https://github.com/payloadcms/payload/pull/5010)
key={JSON.stringify({ initialValue, path })} // makes sure lexical is completely re-rendered when initialValue changes, bypassing the lexical-internal value memoization. That way, external changes to the form will update the editor. More infos in PR description (https://github.com/payloadcms/payload/pull/5010)
onChange={handleChange}
readOnly={disabled}
value={value}

View File

@@ -119,10 +119,11 @@
"@dnd-kit/core": "6.0.8",
"@dnd-kit/sortable": "7.0.2",
"@faceless-ui/modal": "3.0.0-beta.2",
"@faceless-ui/scroll-info": "2.0.0",
"@faceless-ui/window-info": "3.0.1",
"@faceless-ui/scroll-info": "2.0.0-beta.0",
"@faceless-ui/window-info": "3.0.0-beta.0",
"@monaco-editor/react": "4.6.0",
"@payloadcms/translations": "workspace:*",
"body-scroll-lock": "4.0.0-beta.0",
"bson-objectid": "2.0.4",
"date-fns": "4.1.0",
"dequal": "2.0.3",
@@ -146,6 +147,7 @@
"@babel/preset-typescript": "7.26.0",
"@hyrious/esbuild-plugin-commonjs": "^0.2.4",
"@payloadcms/eslint-config": "workspace:*",
"@types/body-scroll-lock": "^3.1.0",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"@types/uuid": "10.0.0",

View File

@@ -1,5 +1,6 @@
'use client'
import { useWindowInfo } from '@faceless-ui/window-info'
import { clearAllBodyScrollLocks, disableBodyScroll, enableBodyScroll } from 'body-scroll-lock'
import React, { useEffect, useRef } from 'react'
import { usePreferences } from '../../providers/Preferences/index.js'
@@ -72,9 +73,9 @@ export const NavProvider: React.FC<{
useEffect(() => {
if (navRef.current) {
if (navOpen && midBreak) {
navRef.current.style.overscrollBehavior = 'contain'
disableBodyScroll(navRef.current)
} else {
navRef.current.style.overscrollBehavior = 'auto'
enableBodyScroll(navRef.current)
}
}
}, [navOpen, midBreak])
@@ -96,7 +97,7 @@ export const NavProvider: React.FC<{
// when the component unmounts, clear all body scroll locks
useEffect(() => {
return () => {
navRef.current.style.overscrollBehavior = 'auto'
clearAllBodyScrollLocks()
}
}, [])

View File

@@ -23,7 +23,6 @@ export const RelationshipField: React.FC<Props> = (props) => {
disabled,
field: { admin: { isSortable } = {}, hasMany, relationTo },
onChange,
operator,
value,
} = props
@@ -97,40 +96,38 @@ export const RelationshipField: React.FC<Props> = (props) => {
})
}
if (operator) {
try {
const response = await fetch(
`${serverURL}${api}/${relationSlug}${qs.stringify(query, { addQueryPrefix: true })}`,
{
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
},
signal: abortController.signal,
try {
const response = await fetch(
`${serverURL}${api}/${relationSlug}${qs.stringify(query, { addQueryPrefix: true })}`,
{
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
},
)
signal: abortController.signal,
},
)
if (response.ok) {
const data: PaginatedDocs = await response.json()
if (data.docs.length > 0) {
addOptions(data, relationSlug)
if (response.ok) {
const data: PaginatedDocs = await response.json()
if (data.docs.length > 0) {
addOptions(data, relationSlug)
if (data.nextPage) {
nextPageByRelationshipRef.current.set(relationSlug, data.nextPage)
} else {
partiallyLoadedRelationshipSlugs.current =
partiallyLoadedRelationshipSlugs.current.filter(
(partiallyLoadedRelation) => partiallyLoadedRelation !== relationSlug,
)
}
if (data.nextPage) {
nextPageByRelationshipRef.current.set(relationSlug, data.nextPage)
} else {
partiallyLoadedRelationshipSlugs.current =
partiallyLoadedRelationshipSlugs.current.filter(
(partiallyLoadedRelation) => partiallyLoadedRelation !== relationSlug,
)
}
} else {
setErrorLoading(t('error:unspecific'))
}
} catch (e) {
if (!abortController.signal.aborted) {
console.error(e)
}
} else {
setErrorLoading(t('error:unspecific'))
}
} catch (e) {
if (!abortController.signal.aborted) {
console.error(e)
}
}
}

View File

@@ -145,7 +145,6 @@ export const Condition: React.FC<Props> = (props) => {
isClearable={false}
onChange={(operator: Option<Operator>) => {
setInternalOperatorOption(operator.value)
setInternalQueryValue(undefined)
}}
options={fieldOption?.operators}
value={

View File

@@ -284,39 +284,20 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
// ..
// This is a performance enhancement for saving
// large documents with hundreds of fields
const newState: FormState = {}
const newState = {}
for (const [path, newField] of Object.entries(action.state)) {
Object.entries(action.state).forEach(([path, field]) => {
const oldField = state[path]
if (newField.valid !== false) {
newField.valid = true
}
if (newField.passesCondition !== false) {
newField.passesCondition = true
}
const newField = field
if (!dequal(oldField, newField)) {
newState[path] = newField
} else if (oldField) {
newState[path] = oldField
}
}
})
return newState
}
//TODO: Remove this in 4.0 - this is a temporary fix to prevent a breaking change
if (action.sanitize) {
for (const field of Object.values(action.state)) {
if (field.valid !== false) {
field.valid = true
}
if (field.passesCondition !== false) {
field.passesCondition = true
}
}
}
// If we're not optimizing, just set the state to the new state
return action.state
}

View File

@@ -637,12 +637,7 @@ export const Form: React.FC<FormProps> = (props) => {
useEffect(() => {
if (initialState) {
contextRef.current = { ...initContextState } as FormContextType
dispatchFields({
type: 'REPLACE_STATE',
optimize: false,
sanitize: true,
state: initialState,
})
dispatchFields({ type: 'REPLACE_STATE', optimize: false, state: initialState })
}
}, [initialState, dispatchFields])

View File

@@ -22,29 +22,28 @@ export const mergeServerFormState = ({
existingState,
incomingState,
}: Args): { changed: boolean; newState: FormState } => {
const serverPropsToAccept = [
'passesCondition',
'valid',
'errorMessage',
'rows',
'customComponents',
'requiresRender',
]
if (acceptValues) {
serverPropsToAccept.push('value')
}
let changed = false
const newState = {}
if (existingState) {
const serverPropsToAccept = [
'passesCondition',
'valid',
'errorMessage',
'rows',
'customComponents',
'requiresRender',
]
if (acceptValues) {
serverPropsToAccept.push('value')
}
for (const [path, newFieldState] of Object.entries(existingState)) {
Object.entries(existingState).forEach(([path, newFieldState]) => {
if (!incomingState[path]) {
continue
return
}
let fieldChanged = false
/**
* Handle error paths
@@ -66,7 +65,6 @@ export const mergeServerFormState = ({
if (incomingState[path]?.filterOptions || newFieldState.filterOptions) {
if (!dequal(incomingState[path]?.filterOptions, newFieldState.filterOptions)) {
changed = true
fieldChanged = true
newFieldState.filterOptions = incomingState[path].filterOptions
}
}
@@ -77,7 +75,6 @@ export const mergeServerFormState = ({
serverPropsToAccept.forEach((prop) => {
if (!dequal(incomingState[path]?.[prop], newFieldState[prop])) {
changed = true
fieldChanged = true
if (!(prop in incomingState[path])) {
// Regarding excluding the customComponents prop from being deleted: the incoming state might not have been rendered, as rendering components for every form onchange is expensive.
// Thus, we simply re-use the initial render state
@@ -90,25 +87,18 @@ export const mergeServerFormState = ({
}
})
if (newFieldState.valid !== false) {
newFieldState.valid = true
}
if (newFieldState.passesCondition !== false) {
newFieldState.passesCondition = true
}
// Conditions don't work if we don't memcopy the new state, as the object references would otherwise be the same
newState[path] = fieldChanged ? { ...newFieldState } : newFieldState
}
newState[path] = { ...newFieldState }
})
// Now loop over values that are part of incoming state but not part of existing state, and add them to the new state.
// This can happen if a new array row was added. In our local state, we simply add out stubbed `array` and `array.[index].id` entries to the local form state.
// However, all other array sub-fields are not added to the local state - those will be added by the server and may be incoming here.
for (const [path, field] of Object.entries(incomingState)) {
for (const [path, newFieldState] of Object.entries(incomingState)) {
if (!existingState[path]) {
changed = true
newState[path] = field
newState[path] = newFieldState
}
}
}

View File

@@ -91,11 +91,6 @@ export type Reset = (data: unknown) => Promise<void>
export type REPLACE_STATE = {
optimize?: boolean
/**
* If `sanitize` is true, default values will be set for form field properties that are not present in the incoming state.
* For example, `valid` will be set to true if it is not present in the incoming state.
*/
sanitize?: boolean
state: FormState
type: 'REPLACE_STATE'
}

11836
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,3 @@ NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# Secret used to authenticate cron jobs
CRON_SECRET=YOUR_CRON_SECRET_HERE
# Used to validate preview requests
PREVIEW_SECRET=YOUR_SECRET_HERE

View File

@@ -9,8 +9,8 @@
h4,
h5,
h6 {
font-size: unset;
font-weight: unset;
font-size: auto;
font-weight: auto;
}
:root {

View File

@@ -0,0 +1,7 @@
import { draftMode } from 'next/headers'
export async function GET(): Promise<Response> {
const draft = await draftMode()
draft.disable()
return new Response('Draft mode is disabled')
}

View File

@@ -1,63 +1,91 @@
import type { CollectionSlug, PayloadRequest } from 'payload'
import { getPayload } from 'payload'
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPayload, type PayloadRequest } from 'payload'
import configPromise from '@payload-config'
import { CollectionSlug } from 'payload'
export async function GET(
req: {
req: Request & {
cookies: {
get: (name: string) => {
value: string
}
}
} & Request,
},
): Promise<Response> {
const payload = await getPayload({ config: configPromise })
const { searchParams } = new URL(req.url)
const path = searchParams.get('path')
const collection = searchParams.get('collection') as CollectionSlug
const slug = searchParams.get('slug')
const previewSecret = searchParams.get('previewSecret')
if (previewSecret !== process.env.PREVIEW_SECRET) {
if (previewSecret) {
return new Response('You are not allowed to preview this page', { status: 403 })
} else {
if (!path) {
return new Response('No path provided', { status: 404 })
}
if (!collection) {
return new Response('No path provided', { status: 404 })
}
if (!slug) {
return new Response('No path provided', { status: 404 })
}
if (!path.startsWith('/')) {
return new Response('This endpoint can only be used for internal previews', { status: 500 })
}
let user
try {
user = await payload.auth({
req: req as unknown as PayloadRequest,
headers: req.headers,
})
} catch (error) {
payload.logger.error({ err: error }, 'Error verifying token for live preview')
return new Response('You are not allowed to preview this page', { status: 403 })
}
const draft = await draftMode()
// You can add additional checks here to see if the user is allowed to preview this page
if (!user) {
draft.disable()
return new Response('You are not allowed to preview this page', { status: 403 })
}
// Verify the given slug exists
try {
const docs = await payload.find({
collection,
draft: true,
limit: 1,
// pagination: false reduces overhead if you don't need totalDocs
pagination: false,
depth: 0,
select: {},
where: {
slug: {
equals: slug,
},
},
})
if (!docs.docs.length) {
return new Response('Document not found', { status: 404 })
}
} catch (error) {
payload.logger.error({ err: error }, 'Error verifying token for live preview')
}
draft.enable()
redirect(path)
}
if (!path || !collection || !slug) {
return new Response('Insufficient search params', { status: 404 })
}
if (!path.startsWith('/')) {
return new Response('This endpoint can only be used for relative previews', { status: 500 })
}
let user
try {
user = await payload.auth({
req: req as unknown as PayloadRequest,
headers: req.headers,
})
} catch (error) {
payload.logger.error({ err: error }, 'Error verifying token for live preview')
return new Response('You are not allowed to preview this page', { status: 403 })
}
const draft = await draftMode()
if (!user) {
draft.disable()
return new Response('You are not allowed to preview this page', { status: 403 })
}
// You can add additional checks here to see if the user is allowed to preview this page
draft.enable()
redirect(path)
}

View File

@@ -12,16 +12,22 @@ type Props = {
}
export const generatePreviewPath = ({ collection, slug, req }: Props) => {
const encodedParams = new URLSearchParams({
const path = `${collectionPrefixMap[collection]}/${slug}`
const params = {
slug,
collection,
path: `${collectionPrefixMap[collection]}/${slug}`,
previewSecret: process.env.PREVIEW_SECRET || '',
path,
}
const encodedParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
encodedParams.append(key, value)
})
const isProduction =
process.env.NODE_ENV === 'production' || Boolean(process.env.VERCEL_PROJECT_PRODUCTION_URL)
const protocol = isProduction ? 'https:' : req.protocol
const url = `${protocol}//${req.host}/next/preview?${encodedParams.toString()}`

View File

@@ -1,14 +1,8 @@
# Database connection string
POSTGRES_URL=postgresql://127.0.0.1:5432/your-database-name
# Used to encrypt JWT tokens
PAYLOAD_SECRET=YOUR_SECRET_HERE
# Used to configure CORS, format links and more. No trailing slash
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# Secret used to authenticate cron jobs
CRON_SECRET=YOUR_CRON_SECRET_HERE
# Used to validate preview requests
PREVIEW_SECRET=YOUR_SECRET_HERE
CRON_SECRET=YOUR_CRON_SECRET_HERE

View File

@@ -4,7 +4,7 @@ This is the official [Payload Website Template](https://github.com/payloadcms/pa
You can deploy to Vercel, using Neon and Vercel Blob Storage with one click:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/payloadcms/payload/tree/main/templates/with-vercel-website&project-name=payload-project&env=PAYLOAD_SECRET%2CCRON_SECRET%2CPREVIEW_SECRET&build-command=pnpm%20run%20ci&stores=%5B%7B%22type%22:%22postgres%22%7D,%7B%22type%22:%22blob%22%7D%5D)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/payloadcms/payload/tree/main/templates/with-vercel-website&project-name=payload-project&env=PAYLOAD_SECRET%2CCRON_SECRET&build-command=pnpm%20run%20ci&stores=%5B%7B%22type%22:%22postgres%22%7D,%7B%22type%22:%22blob%22%7D%5D)
This template is right for you if you are working on:

View File

@@ -9,8 +9,8 @@
h4,
h5,
h6 {
font-size: unset;
font-weight: unset;
font-size: auto;
font-weight: auto;
}
:root {

View File

@@ -0,0 +1,7 @@
import { draftMode } from 'next/headers'
export async function GET(): Promise<Response> {
const draft = await draftMode()
draft.disable()
return new Response('Draft mode is disabled')
}

View File

@@ -1,63 +1,91 @@
import type { CollectionSlug, PayloadRequest } from 'payload'
import { getPayload } from 'payload'
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPayload, type PayloadRequest } from 'payload'
import configPromise from '@payload-config'
import { CollectionSlug } from 'payload'
export async function GET(
req: {
req: Request & {
cookies: {
get: (name: string) => {
value: string
}
}
} & Request,
},
): Promise<Response> {
const payload = await getPayload({ config: configPromise })
const { searchParams } = new URL(req.url)
const path = searchParams.get('path')
const collection = searchParams.get('collection') as CollectionSlug
const slug = searchParams.get('slug')
const previewSecret = searchParams.get('previewSecret')
if (previewSecret !== process.env.PREVIEW_SECRET) {
if (previewSecret) {
return new Response('You are not allowed to preview this page', { status: 403 })
} else {
if (!path) {
return new Response('No path provided', { status: 404 })
}
if (!collection) {
return new Response('No path provided', { status: 404 })
}
if (!slug) {
return new Response('No path provided', { status: 404 })
}
if (!path.startsWith('/')) {
return new Response('This endpoint can only be used for internal previews', { status: 500 })
}
let user
try {
user = await payload.auth({
req: req as unknown as PayloadRequest,
headers: req.headers,
})
} catch (error) {
payload.logger.error({ err: error }, 'Error verifying token for live preview')
return new Response('You are not allowed to preview this page', { status: 403 })
}
const draft = await draftMode()
// You can add additional checks here to see if the user is allowed to preview this page
if (!user) {
draft.disable()
return new Response('You are not allowed to preview this page', { status: 403 })
}
// Verify the given slug exists
try {
const docs = await payload.find({
collection,
draft: true,
limit: 1,
// pagination: false reduces overhead if you don't need totalDocs
pagination: false,
depth: 0,
select: {},
where: {
slug: {
equals: slug,
},
},
})
if (!docs.docs.length) {
return new Response('Document not found', { status: 404 })
}
} catch (error) {
payload.logger.error({ err: error }, 'Error verifying token for live preview')
}
draft.enable()
redirect(path)
}
if (!path || !collection || !slug) {
return new Response('Insufficient search params', { status: 404 })
}
if (!path.startsWith('/')) {
return new Response('This endpoint can only be used for relative previews', { status: 500 })
}
let user
try {
user = await payload.auth({
req: req as unknown as PayloadRequest,
headers: req.headers,
})
} catch (error) {
payload.logger.error({ err: error }, 'Error verifying token for live preview')
return new Response('You are not allowed to preview this page', { status: 403 })
}
const draft = await draftMode()
if (!user) {
draft.disable()
return new Response('You are not allowed to preview this page', { status: 403 })
}
// You can add additional checks here to see if the user is allowed to preview this page
draft.enable()
redirect(path)
}

View File

@@ -10,7 +10,6 @@ export const contact: Partial<Page> = {
{
blockType: 'formBlock',
enableIntro: true,
// @ts-ignore
form: '{{CONTACT_FORM_ID}}',
introContent: {
root: {

View File

@@ -84,7 +84,6 @@ export const homeStatic: Page = {
title: 'Payload Website Template',
},
title: 'Home',
// @ts-ignore
id: '',
layout: [],
updatedAt: '',

View File

@@ -23,7 +23,6 @@ export const home: RequiredDataFromCollectionSlug<'pages'> = {
},
},
],
// @ts-ignore
media: '{{IMAGE_1}}',
richText: {
root: {
@@ -502,7 +501,6 @@ export const home: RequiredDataFromCollectionSlug<'pages'> = {
{
blockName: 'Media Block',
blockType: 'mediaBlock',
// @ts-ignore
media: '{{IMAGE_2}}',
},
{
@@ -659,7 +657,6 @@ export const home: RequiredDataFromCollectionSlug<'pages'> = {
],
meta: {
description: 'An open-source website built with Payload and Next.js.',
// @ts-ignore
image: '{{IMAGE_1}}',
title: 'Payload Website Template',
},

View File

@@ -12,16 +12,22 @@ type Props = {
}
export const generatePreviewPath = ({ collection, slug, req }: Props) => {
const encodedParams = new URLSearchParams({
const path = `${collectionPrefixMap[collection]}/${slug}`
const params = {
slug,
collection,
path: `${collectionPrefixMap[collection]}/${slug}`,
previewSecret: process.env.PREVIEW_SECRET || '',
path,
}
const encodedParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
encodedParams.append(key, value)
})
const isProduction =
process.env.NODE_ENV === 'production' || Boolean(process.env.VERCEL_PROJECT_PRODUCTION_URL)
const protocol = isProduction ? 'https:' : req.protocol
const url = `${protocol}//${req.host}/next/preview?${encodedParams.toString()}`

View File

@@ -327,29 +327,6 @@ describe('List View', () => {
await expect(page.locator('.condition__value input')).toHaveValue('')
})
test('should reset query value when operator is changed', async () => {
const id = (await page.locator('.cell-id').first().innerText()).replace('ID: ', '')
await page.locator('.list-controls__toggle-columns').click()
await openListFilters(page, {})
await page.locator('.where-builder__add-first-filter').click()
const operatorField = page.locator('.condition__operator')
await operatorField.click()
const dropdownOperatorOptions = operatorField.locator('.rs__option')
await dropdownOperatorOptions.locator('text=equals').click()
const valueField = page.locator('.condition__value > input')
await valueField.fill(id)
// change operator to "not equals"
await operatorField.click()
await dropdownOperatorOptions.locator('text=not equals').click()
// expect value field to reset (be empty)
await expect(valueField.locator('.rs__placeholder')).toContainText('Select a value')
await expect(page.locator('.condition__value input')).toHaveValue('')
})
test('should accept where query from valid URL where parameter', async () => {
// delete all posts created by the seed
await deleteAllPosts()

View File

@@ -1,47 +0,0 @@
import type { CollectionConfig } from 'payload'
import { selectVersionsFieldsSlug } from '../../slugs.js'
const SelectVersionsFields: CollectionConfig = {
slug: selectVersionsFieldsSlug,
versions: true,
fields: [
{
type: 'select',
hasMany: true,
options: ['a', 'b', 'c'],
name: 'hasMany',
},
{
type: 'array',
name: 'array',
fields: [
{
type: 'select',
hasMany: true,
options: ['a', 'b', 'c'],
name: 'hasManyArr',
},
],
},
{
type: 'blocks',
name: 'blocks',
blocks: [
{
slug: 'block',
fields: [
{
type: 'select',
hasMany: true,
options: ['a', 'b', 'c'],
name: 'hasManyBlocks',
},
],
},
],
},
],
}
export default SelectVersionsFields

View File

@@ -33,7 +33,6 @@ import RelationshipFields from './collections/Relationship/index.js'
import RichTextFields from './collections/RichText/index.js'
import RowFields from './collections/Row/index.js'
import SelectFields from './collections/Select/index.js'
import SelectVersionsFields from './collections/SelectVersions/index.js'
import TabsFields from './collections/Tabs/index.js'
import { TabsFields2 } from './collections/Tabs2/index.js'
import TextFields from './collections/Text/index.js'
@@ -68,7 +67,7 @@ export const collectionSlugs: CollectionConfig[] = [
],
},
LexicalInBlock,
SelectVersionsFields,
ArrayFields,
BlockFields,
CheckboxFields,

View File

@@ -678,30 +678,6 @@ describe('Fields', () => {
expect(upd.array[0].group.selectHasMany).toStrictEqual(['six'])
})
it('should work with versions', async () => {
const base = await payload.create({
collection: 'select-versions-fields',
data: { hasMany: ['a', 'b'] },
})
expect(base.hasMany).toStrictEqual(['a', 'b'])
const array = await payload.create({
collection: 'select-versions-fields',
data: { array: [{ hasManyArr: ['a', 'b'] }] },
draft: true,
})
expect(array.array[0]?.hasManyArr).toStrictEqual(['a', 'b'])
const block = await payload.create({
collection: 'select-versions-fields',
data: { blocks: [{ blockType: 'block', hasManyBlocks: ['a', 'b'] }] },
})
expect(block.blocks[0]?.hasManyBlocks).toStrictEqual(['a', 'b'])
})
})
describe('number', () => {

View File

@@ -34,7 +34,6 @@ export interface Config {
lexicalObjectReferenceBug: LexicalObjectReferenceBug;
users: User;
LexicalInBlock: LexicalInBlock;
'select-versions-fields': SelectVersionsField;
'array-fields': ArrayField;
'block-fields': BlockField;
'checkbox-fields': CheckboxField;
@@ -80,7 +79,6 @@ export interface Config {
lexicalObjectReferenceBug: LexicalObjectReferenceBugSelect<false> | LexicalObjectReferenceBugSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
LexicalInBlock: LexicalInBlockSelect<false> | LexicalInBlockSelect<true>;
'select-versions-fields': SelectVersionsFieldsSelect<false> | SelectVersionsFieldsSelect<true>;
'array-fields': ArrayFieldsSelect<false> | ArrayFieldsSelect<true>;
'block-fields': BlockFieldsSelect<false> | BlockFieldsSelect<true>;
'checkbox-fields': CheckboxFieldsSelect<false> | CheckboxFieldsSelect<true>;
@@ -454,30 +452,6 @@ export interface LexicalInBlock {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "select-versions-fields".
*/
export interface SelectVersionsField {
id: string;
hasMany?: ('a' | 'b' | 'c')[] | null;
array?:
| {
hasManyArr?: ('a' | 'b' | 'c')[] | null;
id?: string | null;
}[]
| null;
blocks?:
| {
hasManyArr?: ('a' | 'b' | 'c')[] | null;
id?: string | null;
blockName?: string | null;
blockType: 'block';
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "array-fields".
@@ -1830,10 +1804,6 @@ export interface PayloadLockedDocument {
relationTo: 'LexicalInBlock';
value: string | LexicalInBlock;
} | null)
| ({
relationTo: 'select-versions-fields';
value: string | SelectVersionsField;
} | null)
| ({
relationTo: 'array-fields';
value: string | ArrayField;
@@ -2104,32 +2074,6 @@ export interface LexicalInBlockSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "select-versions-fields_select".
*/
export interface SelectVersionsFieldsSelect<T extends boolean = true> {
hasMany?: T;
array?:
| T
| {
hasManyArr?: T;
id?: T;
};
blocks?:
| T
| {
block?:
| T
| {
hasManyArr?: T;
id?: T;
blockName?: T;
};
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "array-fields_select".

View File

@@ -24,7 +24,6 @@ export const relationshipFieldsSlug = 'relationship-fields'
export const richTextFieldsSlug = 'rich-text-fields'
export const rowFieldsSlug = 'row-fields'
export const selectFieldsSlug = 'select-fields'
export const selectVersionsFieldsSlug = 'select-versions-fields'
export const tabsFieldsSlug = 'tabs-fields'
export const tabsFields2Slug = 'tabs-fields-2'
export const textFieldsSlug = 'text-fields'

View File

@@ -428,32 +428,6 @@ export default buildConfigWithDefaults({
},
],
},
{
slug: 'relations',
fields: [
{
name: 'item',
type: 'relationship',
relationTo: ['items'],
},
],
},
{
slug: 'items',
fields: [
{
type: 'select',
options: ['completed', 'failed', 'pending'],
name: 'status',
},
{
type: 'join',
on: 'item',
collection: 'relations',
name: 'relation',
},
],
},
],
onInit: async (payload) => {
await payload.create({

View File

@@ -1577,23 +1577,6 @@ describe('Relationships', () => {
expect(res.docs).toHaveLength(1)
expect(res.docs[0].id).toBe(id)
})
it('should update document that polymorphicaly joined to another collection', async () => {
const item = await payload.create({ collection: 'items', data: { status: 'pending' } })
await payload.create({
collection: 'relations',
data: { item: { relationTo: 'items', value: item } },
})
const updated = await payload.update({
collection: 'items',
data: { status: 'completed' },
id: item.id,
})
expect(updated.status).toBe('completed')
})
})
})

View File

@@ -29,18 +29,12 @@ export interface Config {
'rels-to-pages-and-custom-text-ids': RelsToPagesAndCustomTextId;
'object-writes': ObjectWrite;
'deep-nested': DeepNested;
relations: Relation1;
items: Item;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {
items: {
relation: 'relations';
};
};
collectionsJoins: {};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>;
postsLocalized: PostsLocalizedSelect<false> | PostsLocalizedSelect<true>;
@@ -60,8 +54,6 @@ export interface Config {
'rels-to-pages-and-custom-text-ids': RelsToPagesAndCustomTextIdsSelect<false> | RelsToPagesAndCustomTextIdsSelect<true>;
'object-writes': ObjectWritesSelect<false> | ObjectWritesSelect<true>;
'deep-nested': DeepNestedSelect<false> | DeepNestedSelect<true>;
relations: RelationsSelect<false> | RelationsSelect<true>;
items: ItemsSelect<false> | ItemsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -381,33 +373,6 @@ export interface DeepNested {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relations".
*/
export interface Relation1 {
id: string;
item?: {
relationTo: 'items';
value: string | Item;
} | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "items".
*/
export interface Item {
id: string;
status?: ('completed' | 'failed' | 'pending') | null;
relation?: {
docs?: (string | Relation1)[] | null;
hasNextPage?: boolean | null;
} | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -487,14 +452,6 @@ export interface PayloadLockedDocument {
relationTo: 'deep-nested';
value: string | DeepNested;
} | null)
| ({
relationTo: 'relations';
value: string | Relation1;
} | null)
| ({
relationTo: 'items';
value: string | Item;
} | null)
| ({
relationTo: 'users';
value: string | User;
@@ -764,25 +721,6 @@ export interface DeepNestedSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "relations_select".
*/
export interface RelationsSelect<T extends boolean = true> {
item?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "items_select".
*/
export interface ItemsSelect<T extends boolean = true> {
status?: T;
relation?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".

View File

@@ -15,17 +15,6 @@ export const Diff: CollectionConfig = {
},
],
},
{
name: 'arrayLocalized',
type: 'array',
localized: true,
fields: [
{
name: 'textInArrayLocalized',
type: 'text',
},
],
},
{
name: 'blocks',
type: 'blocks',

View File

@@ -945,21 +945,6 @@ describe('Versions', () => {
await expect(textInArray.locator('tr').nth(1).locator('td').nth(3)).toHaveText('textInArray2')
})
test('correctly renders diff for localized array fields', async () => {
await navigateToVersionFieldsDiff()
const textInArray = page
.locator('[data-field-path="arrayLocalized"][data-locale="en"]')
.locator('[data-field-path="arrayLocalized.0.textInArrayLocalized"]')
await expect(textInArray.locator('tr').nth(1).locator('td').nth(1)).toHaveText(
'textInArrayLocalized',
)
await expect(textInArray.locator('tr').nth(1).locator('td').nth(3)).toHaveText(
'textInArrayLocalized2',
)
})
test('correctly renders diff for block fields', async () => {
await navigateToVersionFieldsDiff()

View File

@@ -224,12 +224,6 @@ export interface Diff {
id?: string | null;
}[]
| null;
arrayLocalized?:
| {
textInArrayLocalized?: string | null;
id?: string | null;
}[]
| null;
blocks?:
| {
textInBlock?: string | null;
@@ -647,12 +641,6 @@ export interface DiffSelect<T extends boolean = true> {
textInArray?: T;
id?: T;
};
arrayLocalized?:
| T
| {
textInArrayLocalized?: T;
id?: T;
};
blocks?:
| T
| {

View File

@@ -122,18 +122,12 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
const diffDoc = await _payload.create({
collection: diffCollectionSlug,
locale: 'en',
data: {
array: [
{
textInArray: 'textInArray',
},
],
arrayLocalized: [
{
textInArrayLocalized: 'textInArrayLocalized',
},
],
blocks: [
{
blockType: 'TextBlock',
@@ -170,18 +164,12 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
const updatedDiffDoc = await _payload.update({
id: diffDoc.id,
collection: diffCollectionSlug,
locale: 'en',
data: {
array: [
{
textInArray: 'textInArray2',
},
],
arrayLocalized: [
{
textInArrayLocalized: 'textInArrayLocalized2',
},
],
blocks: [
{
blockType: 'TextBlock',

View File

@@ -31,7 +31,7 @@
}
],
"paths": {
"@payload-config": ["./test/fields/config.ts"],
"@payload-config": ["./test/_community/config.ts"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],