Compare commits
23 Commits
docs/draft
...
fix/array-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccb4da475f | ||
|
|
f7ed8e90e1 | ||
|
|
e6aad5adfc | ||
|
|
4ebd3ce668 | ||
|
|
fae113b799 | ||
|
|
e87521a376 | ||
|
|
8880d705e3 | ||
|
|
018bdad247 | ||
|
|
816fb28f55 | ||
|
|
857e984fbb | ||
|
|
d47b753898 | ||
|
|
308cb64b9c | ||
|
|
6c735effff | ||
|
|
fd42ad5f52 | ||
|
|
a58ff57e4f | ||
|
|
06d937e903 | ||
|
|
8e93ad8f5f | ||
|
|
f310c90211 | ||
|
|
dc793d1d14 | ||
|
|
f9c73ad5f2 | ||
|
|
760cfadaad | ||
|
|
d29bdfc10f | ||
|
|
f34eb228c4 |
@@ -60,31 +60,31 @@ export const Posts: CollectionConfig = {
|
||||
|
||||
The following options are available:
|
||||
|
||||
| Option | Description |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). |
|
||||
| `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). |
|
||||
| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |
|
||||
| `custom` | Extension point for adding custom data (e.g. for plugins) |
|
||||
| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. |
|
||||
| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. |
|
||||
| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. |
|
||||
| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). |
|
||||
| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
|
||||
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
|
||||
| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
|
||||
| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. |
|
||||
| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
|
||||
| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). |
|
||||
| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
|
||||
| `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. |
|
||||
| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
|
||||
| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
|
||||
| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. |
|
||||
| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). |
|
||||
| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). |
|
||||
| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. |
|
||||
| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks |
|
||||
| Option | Description |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). |
|
||||
| `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). |
|
||||
| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |
|
||||
| `custom` | Extension point for adding custom data (e.g. for plugins) |
|
||||
| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. |
|
||||
| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. |
|
||||
| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. |
|
||||
| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). |
|
||||
| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
|
||||
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
|
||||
| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
|
||||
| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. |
|
||||
| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
|
||||
| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). |
|
||||
| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
|
||||
| `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. |
|
||||
| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
|
||||
| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
|
||||
| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. |
|
||||
| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). |
|
||||
| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). |
|
||||
| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. |
|
||||
| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks |
|
||||
|
||||
_\* An asterisk denotes that a property is required._
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ export default buildConfig({
|
||||
// ...
|
||||
// highlight-start
|
||||
cors: {
|
||||
origins: ['http://localhost:3000']
|
||||
origins: ['http://localhost:3000'],
|
||||
headers: ['x-custom-header']
|
||||
}
|
||||
// highlight-end
|
||||
|
||||
@@ -55,18 +55,9 @@ Because _**you**_ are in complete control of who can do what with your data, you
|
||||
wield that power responsibly before deploying to Production.
|
||||
|
||||
<Banner type="error">
|
||||
**
|
||||
By default, all Access Control functions require that a user is successfully logged in to
|
||||
Payload to create, read, update, or delete data.
|
||||
**
|
||||
But, if you allow public user registration, for example, you will want to make sure that your
|
||||
access control functions are more strict - permitting
|
||||
**By default, all Access Control functions require that a user is successfully logged in to Payload to create, read, update, or delete data.**
|
||||
|
||||
**
|
||||
only appropriate users
|
||||
**
|
||||
|
||||
to perform appropriate actions.
|
||||
But, if you allow public user registration, for example, you will want to make sure that your access control functions are more strict - permitting **only appropriate users** to perform appropriate actions.
|
||||
|
||||
</Banner>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
// Your richtext data here
|
||||
const data: SerializedEditorState = {}
|
||||
|
||||
const html = convertLexicalToMarkdown({
|
||||
const markdown = convertLexicalToMarkdown({
|
||||
data,
|
||||
editorConfig: await editorConfigFactory.default({
|
||||
config, // <= make sure you have access to your Payload Config
|
||||
@@ -101,7 +101,7 @@ import {
|
||||
editorConfigFactory,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
const html = convertMarkdownToLexical({
|
||||
const lexicalJSON = convertMarkdownToLexical({
|
||||
editorConfig: await editorConfigFactory.default({
|
||||
config, // <= make sure you have access to your Payload Config
|
||||
}),
|
||||
|
||||
@@ -49,21 +49,17 @@ Within the Admin UI, if drafts are enabled, a document can be shown with one of
|
||||
specify if you are interacting with drafts or with live documents.
|
||||
</Banner>
|
||||
|
||||
#### Updating drafts
|
||||
#### Updating or creating drafts
|
||||
|
||||
If you enable drafts on a collection or global, the `update` operation for REST, GraphQL, and Local APIs exposes a new option called `draft` which allows you to specify if you are updating a **draft**, or if you're just sending your changes straight to the published document. For example, if you pass the query parameter `?draft=true` to a REST `update` operation, your action will be treated as if you are updating a `draft` and not a published document. By default, the `draft` argument is set to `false`.
|
||||
If you enable drafts on a collection or global, the `create` and `update` operations for REST, GraphQL, and Local APIs expose a new option called `draft` which allows you to specify if you are creating or updating a **draft**, or if you're just sending your changes straight to the published document. For example, if you pass the query parameter `?draft=true` to a REST `create` or `update` operation, your action will be treated as if you are creating a `draft` and not a published document. By default, the `draft` argument is set to `false`.
|
||||
|
||||
**Required fields**
|
||||
|
||||
If `draft` is enabled while updating a document, all fields are considered as not required, so that you can save drafts that are incomplete.
|
||||
|
||||
#### Creating drafts
|
||||
|
||||
By default, draft-enabled collections will create draft documents when you create a new document. In order to create a published document, you need to pass `_status: 'published'` to the document data.
|
||||
If `draft` is enabled while creating or updating a document, all fields are considered as not required, so that you can save drafts that are incomplete.
|
||||
|
||||
#### Reading drafts vs. published documents
|
||||
|
||||
In addition to the `draft` argument within `update` operations, a `draft` argument is also exposed for `find` and `findByID` operations.
|
||||
In addition to the `draft` argument within `create` and `update` operations, a `draft` argument is also exposed for `find` and `findByID` operations.
|
||||
|
||||
If `draft` is set to `true` while reading a document, **Payload will automatically replace returned document(s) with their newest drafts** if any newer drafts are available.
|
||||
|
||||
|
||||
@@ -81,7 +81,19 @@ export async function parseParams({
|
||||
[searchParam.path]: searchParam.value,
|
||||
})
|
||||
} else {
|
||||
result[searchParam.path] = searchParam.value
|
||||
if (result[searchParam.path]) {
|
||||
if (!result.$and) {
|
||||
result.$and = []
|
||||
}
|
||||
|
||||
result.$and.push({ [searchParam.path]: result[searchParam.path] })
|
||||
result.$and.push({
|
||||
[searchParam.path]: searchParam.value,
|
||||
})
|
||||
delete result[searchParam.path]
|
||||
} else {
|
||||
result[searchParam.path] = searchParam.value
|
||||
}
|
||||
}
|
||||
} else if (typeof searchParam?.value === 'object') {
|
||||
result = deepMergeWithCombinedArrays(result, searchParam.value ?? {}, {
|
||||
|
||||
@@ -59,6 +59,10 @@ export const deleteOne: DeleteOne = async function deleteOne(
|
||||
docToDelete = await db.query[tableName].findFirst(findManyArgs)
|
||||
}
|
||||
|
||||
if (!docToDelete) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result =
|
||||
returning === false
|
||||
? null
|
||||
|
||||
@@ -23,8 +23,10 @@ export { migrateFresh } from './migrateFresh.js'
|
||||
export { migrateRefresh } from './migrateRefresh.js'
|
||||
export { migrateReset } from './migrateReset.js'
|
||||
export { migrateStatus } from './migrateStatus.js'
|
||||
export { default as buildQuery } from './queries/buildQuery.js'
|
||||
export { operatorMap } from './queries/operatorMap.js'
|
||||
export type { Operators } from './queries/operatorMap.js'
|
||||
export { parseParams } from './queries/parseParams.js'
|
||||
export { queryDrafts } from './queryDrafts.js'
|
||||
export { buildDrizzleRelations } from './schema/buildDrizzleRelations.js'
|
||||
export { buildRawSchema } from './schema/buildRawSchema.js'
|
||||
|
||||
@@ -50,7 +50,8 @@ export async function migrateDown(this: DrizzleAdapter): Promise<void> {
|
||||
msg: `Migrated down: ${migrationFile.name} (${Date.now() - start}ms)`,
|
||||
})
|
||||
|
||||
const tableExists = await migrationTableExists(this)
|
||||
const tableExists = await migrationTableExists(this, db)
|
||||
|
||||
if (tableExists) {
|
||||
await payload.delete({
|
||||
id: migration.id,
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function migrateRefresh(this: DrizzleAdapter) {
|
||||
msg: `Migrated down: ${migration.name} (${Date.now() - start}ms)`,
|
||||
})
|
||||
|
||||
const tableExists = await migrationTableExists(this)
|
||||
const tableExists = await migrationTableExists(this, db)
|
||||
if (tableExists) {
|
||||
await payload.delete({
|
||||
collection: 'payload-migrations',
|
||||
|
||||
@@ -45,7 +45,7 @@ export async function migrateReset(this: DrizzleAdapter): Promise<void> {
|
||||
msg: `Migrated down: ${migrationFile.name} (${Date.now() - start}ms)`,
|
||||
})
|
||||
|
||||
const tableExists = await migrationTableExists(this)
|
||||
const tableExists = await migrationTableExists(this, db)
|
||||
if (tableExists) {
|
||||
await payload.delete({
|
||||
id: migration.id,
|
||||
|
||||
@@ -19,7 +19,7 @@ type Args = {
|
||||
aliasTable?: Table
|
||||
fields: FlattenedField[]
|
||||
joins: BuildQueryJoinAliases
|
||||
locale: string
|
||||
locale?: string
|
||||
parentIsLocalized: boolean
|
||||
selectFields: Record<string, GenericColumn>
|
||||
selectLocale?: boolean
|
||||
|
||||
@@ -423,6 +423,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
|
||||
path: fieldName,
|
||||
},
|
||||
],
|
||||
req,
|
||||
},
|
||||
req?.t,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { DrizzleAdapter } from '../types.js'
|
||||
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
||||
|
||||
export const migrationTableExists = async (adapter: DrizzleAdapter): Promise<boolean> => {
|
||||
import type { DrizzleAdapter, PostgresDB } from '../types.js'
|
||||
|
||||
export const migrationTableExists = async (
|
||||
adapter: DrizzleAdapter,
|
||||
db?: LibSQLDatabase | PostgresDB,
|
||||
): Promise<boolean> => {
|
||||
let statement
|
||||
|
||||
if (adapter.name === 'postgres') {
|
||||
@@ -20,7 +25,7 @@ export const migrationTableExists = async (adapter: DrizzleAdapter): Promise<boo
|
||||
}
|
||||
|
||||
const result = await adapter.execute({
|
||||
drizzle: adapter.drizzle,
|
||||
drizzle: db ?? adapter.drizzle,
|
||||
raw: statement,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { GraphQLBoolean, GraphQLInt, GraphQLList, GraphQLObjectType } from 'graphql'
|
||||
import { GraphQLBoolean, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLObjectType } from 'graphql'
|
||||
|
||||
export const buildPaginatedListType = (name, docType) =>
|
||||
new GraphQLObjectType({
|
||||
name,
|
||||
fields: {
|
||||
docs: {
|
||||
type: new GraphQLList(docType),
|
||||
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(docType))),
|
||||
},
|
||||
hasNextPage: { type: GraphQLBoolean },
|
||||
hasPrevPage: { type: GraphQLBoolean },
|
||||
limit: { type: GraphQLInt },
|
||||
nextPage: { type: GraphQLInt },
|
||||
hasNextPage: { type: new GraphQLNonNull(GraphQLBoolean) },
|
||||
hasPrevPage: { type: new GraphQLNonNull(GraphQLBoolean) },
|
||||
limit: { type: new GraphQLNonNull(GraphQLInt) },
|
||||
nextPage: { type: new GraphQLNonNull(GraphQLInt) },
|
||||
offset: { type: GraphQLInt },
|
||||
page: { type: GraphQLInt },
|
||||
pagingCounter: { type: GraphQLInt },
|
||||
prevPage: { type: GraphQLInt },
|
||||
totalDocs: { type: GraphQLInt },
|
||||
totalPages: { type: GraphQLInt },
|
||||
page: { type: new GraphQLNonNull(GraphQLInt) },
|
||||
pagingCounter: { type: new GraphQLNonNull(GraphQLInt) },
|
||||
prevPage: { type: new GraphQLNonNull(GraphQLInt) },
|
||||
totalDocs: { type: new GraphQLNonNull(GraphQLInt) },
|
||||
totalPages: { type: new GraphQLNonNull(GraphQLInt) },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -348,11 +348,15 @@ export const fieldToSchemaMap: FieldToSchemaMap = {
|
||||
name: joinName,
|
||||
fields: {
|
||||
docs: {
|
||||
type: Array.isArray(field.collection)
|
||||
? GraphQLJSON
|
||||
: new GraphQLList(graphqlResult.collections[field.collection].graphQL.type),
|
||||
type: new GraphQLNonNull(
|
||||
Array.isArray(field.collection)
|
||||
? GraphQLJSON
|
||||
: new GraphQLList(
|
||||
new GraphQLNonNull(graphqlResult.collections[field.collection].graphQL.type),
|
||||
),
|
||||
),
|
||||
},
|
||||
hasNextPage: { type: GraphQLBoolean },
|
||||
hasNextPage: { type: new GraphQLNonNull(GraphQLBoolean) },
|
||||
},
|
||||
}),
|
||||
args: {
|
||||
@@ -428,7 +432,7 @@ export const fieldToSchemaMap: FieldToSchemaMap = {
|
||||
...objectTypeConfig,
|
||||
[formatName(field.name)]: formattedNameResolver({
|
||||
type: withNullableType({
|
||||
type: field?.hasMany === true ? new GraphQLList(type) : type,
|
||||
type: field?.hasMany === true ? new GraphQLList(new GraphQLNonNull(type)) : type,
|
||||
field,
|
||||
forceNullable,
|
||||
parentIsLocalized,
|
||||
@@ -856,7 +860,10 @@ export const fieldToSchemaMap: FieldToSchemaMap = {
|
||||
...objectTypeConfig,
|
||||
[formatName(field.name)]: formattedNameResolver({
|
||||
type: withNullableType({
|
||||
type: field.hasMany === true ? new GraphQLList(GraphQLString) : GraphQLString,
|
||||
type:
|
||||
field.hasMany === true
|
||||
? new GraphQLList(new GraphQLNonNull(GraphQLString))
|
||||
: GraphQLString,
|
||||
field,
|
||||
forceNullable,
|
||||
parentIsLocalized,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
'use client'
|
||||
import type { ClientField } from 'payload'
|
||||
|
||||
import { ChevronIcon, Pill, useConfig, useTranslation } from '@payloadcms/ui'
|
||||
import { ChevronIcon, FieldDiffLabel, Pill, useConfig, useTranslation } from '@payloadcms/ui'
|
||||
import { fieldIsArrayType, fieldIsBlockType } from 'payload/shared'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import Label from '../Label/index.js'
|
||||
import './index.scss'
|
||||
import { countChangedFields, countChangedFieldsInRows } from '../utilities/countChangedFields.js'
|
||||
|
||||
@@ -100,7 +99,7 @@ export const DiffCollapser: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<Label>
|
||||
<FieldDiffLabel>
|
||||
<button
|
||||
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
className={`${baseClass}__toggle-button`}
|
||||
@@ -115,7 +114,7 @@ export const DiffCollapser: React.FC<Props> = ({
|
||||
{t('version:changedFieldsCount', { count: changeCount })}
|
||||
</Pill>
|
||||
)}
|
||||
</Label>
|
||||
</FieldDiffLabel>
|
||||
<div className={contentClassNames}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import type { I18nClient } from '@payloadcms/translations'
|
||||
import type {
|
||||
BaseVersionField,
|
||||
ClientField,
|
||||
ClientFieldSchemaMap,
|
||||
Field,
|
||||
FieldDiffClientProps,
|
||||
FieldDiffServerProps,
|
||||
FieldTypes,
|
||||
FlattenedBlock,
|
||||
PayloadComponent,
|
||||
PayloadRequest,
|
||||
SanitizedFieldPermissions,
|
||||
VersionField,
|
||||
} from 'payload'
|
||||
import type { DiffMethod } from 'react-diff-viewer-continued'
|
||||
|
||||
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
|
||||
import { dequal } from 'dequal/lite'
|
||||
import {
|
||||
type BaseVersionField,
|
||||
type ClientField,
|
||||
type ClientFieldSchemaMap,
|
||||
type Field,
|
||||
type FieldDiffClientProps,
|
||||
type FieldDiffServerProps,
|
||||
type FieldTypes,
|
||||
type FlattenedBlock,
|
||||
MissingEditorProp,
|
||||
type PayloadComponent,
|
||||
type PayloadRequest,
|
||||
type SanitizedFieldPermissions,
|
||||
type VersionField,
|
||||
} from 'payload'
|
||||
import { fieldIsID, fieldShouldBeLocalized, getUniqueListBy, tabHasName } from 'payload/shared'
|
||||
|
||||
import { diffMethods } from './fields/diffMethods.js'
|
||||
@@ -238,7 +239,24 @@ const buildVersionField = ({
|
||||
return null
|
||||
}
|
||||
|
||||
const CustomComponent = field?.admin?.components?.Diff ?? customDiffComponents?.[field.type]
|
||||
let CustomComponent = customDiffComponents?.[field.type]
|
||||
if (field?.type === 'richText') {
|
||||
if (!field?.editor) {
|
||||
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
|
||||
}
|
||||
|
||||
if (typeof field?.editor === 'function') {
|
||||
throw new Error('Attempted to access unsanitized rich text editor.')
|
||||
}
|
||||
|
||||
if (field.editor.CellComponent) {
|
||||
CustomComponent = field.editor.DiffComponent
|
||||
}
|
||||
}
|
||||
if (field?.admin?.components?.Diff) {
|
||||
CustomComponent = field.admin.components.Diff
|
||||
}
|
||||
|
||||
const DefaultComponent = diffComponents?.[field.type]
|
||||
|
||||
const baseVersionField: BaseVersionField = {
|
||||
|
||||
@@ -7,12 +7,11 @@ import type {
|
||||
} from 'payload'
|
||||
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import { useConfig, useTranslation } from '@payloadcms/ui'
|
||||
import { FieldDiffLabel, useConfig, useTranslation } from '@payloadcms/ui'
|
||||
import { fieldAffectsData, fieldIsPresentationalOnly, fieldShouldBeLocalized } from 'payload/shared'
|
||||
import React from 'react'
|
||||
import ReactDiffViewer from 'react-diff-viewer-continued'
|
||||
|
||||
import Label from '../../Label/index.js'
|
||||
import './index.scss'
|
||||
import { diffStyles } from '../styles.js'
|
||||
|
||||
@@ -169,10 +168,10 @@ export const Relationship: RelationshipFieldDiffClientComponent = ({
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<Label>
|
||||
<FieldDiffLabel>
|
||||
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
|
||||
{getTranslation(label, i18n)}
|
||||
</Label>
|
||||
</FieldDiffLabel>
|
||||
<ReactDiffViewer
|
||||
hideLineNumbers
|
||||
newValue={versionToRender}
|
||||
|
||||
@@ -3,10 +3,9 @@ import type { I18nClient } from '@payloadcms/translations'
|
||||
import type { Option, SelectField, SelectFieldDiffClientComponent } from 'payload'
|
||||
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import { useTranslation } from '@payloadcms/ui'
|
||||
import { FieldDiffLabel, useTranslation } from '@payloadcms/ui'
|
||||
import React from 'react'
|
||||
|
||||
import Label from '../../Label/index.js'
|
||||
import './index.scss'
|
||||
import { diffStyles } from '../styles.js'
|
||||
import { DiffViewer } from './DiffViewer/index.js'
|
||||
@@ -103,10 +102,10 @@ export const Select: SelectFieldDiffClientComponent = ({
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<Label>
|
||||
<FieldDiffLabel>
|
||||
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
|
||||
{'label' in field && getTranslation(field.label || '', i18n)}
|
||||
</Label>
|
||||
</FieldDiffLabel>
|
||||
<DiffViewer
|
||||
comparisonToRender={comparisonToRender}
|
||||
diffMethod={diffMethod}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
import type { TextFieldDiffClientComponent } from 'payload'
|
||||
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import { useTranslation } from '@payloadcms/ui'
|
||||
import { FieldDiffLabel, useTranslation } from '@payloadcms/ui'
|
||||
import React from 'react'
|
||||
|
||||
import Label from '../../Label/index.js'
|
||||
import './index.scss'
|
||||
import { diffStyles } from '../styles.js'
|
||||
import { DiffViewer } from './DiffViewer/index.js'
|
||||
@@ -34,12 +33,12 @@ export const Text: TextFieldDiffClientComponent = ({
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<Label>
|
||||
<FieldDiffLabel>
|
||||
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
|
||||
{'label' in field &&
|
||||
typeof field.label !== 'function' &&
|
||||
getTranslation(field.label || '', i18n)}
|
||||
</Label>
|
||||
</FieldDiffLabel>
|
||||
<DiffViewer
|
||||
comparisonToRender={comparisonToRender}
|
||||
diffMethod={diffMethod}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const diffStyles = {
|
||||
import type { ReactDiffViewerStylesOverride } from 'react-diff-viewer-continued'
|
||||
|
||||
export const diffStyles: ReactDiffViewerStylesOverride = {
|
||||
diffContainer: {
|
||||
minWidth: 'unset',
|
||||
},
|
||||
@@ -26,4 +28,11 @@ export const diffStyles = {
|
||||
wordRemovedBackground: 'var(--theme-error-200)',
|
||||
},
|
||||
},
|
||||
wordAdded: {
|
||||
color: 'var(--theme-success-600)',
|
||||
},
|
||||
wordRemoved: {
|
||||
color: 'var(--theme-error-600)',
|
||||
textDecorationLine: 'line-through',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,12 +5,17 @@ import type { JSONSchema4 } from 'json-schema'
|
||||
import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js'
|
||||
import type { Config, PayloadComponent, SanitizedConfig } from '../config/types.js'
|
||||
import type { ValidationFieldError } from '../errors/ValidationError.js'
|
||||
import type { FieldAffectingData, RichTextField, Validate } from '../fields/config/types.js'
|
||||
import type {
|
||||
FieldAffectingData,
|
||||
RichTextField,
|
||||
RichTextFieldClient,
|
||||
Validate,
|
||||
} from '../fields/config/types.js'
|
||||
import type { SanitizedGlobalConfig } from '../globals/config/types.js'
|
||||
import type { RequestContext } from '../index.js'
|
||||
import type { JsonObject, PayloadRequest, PopulateType } from '../types/index.js'
|
||||
import type { RichTextFieldClientProps } from './fields/RichText.js'
|
||||
import type { FieldSchemaMap } from './types.js'
|
||||
import type { RichTextFieldClientProps, RichTextFieldServerProps } from './fields/RichText.js'
|
||||
import type { FieldDiffClientProps, FieldDiffServerProps, FieldSchemaMap } from './types.js'
|
||||
|
||||
export type AfterReadRichTextHookArgs<
|
||||
TData extends TypeWithID = any,
|
||||
@@ -248,7 +253,15 @@ export type RichTextAdapter<
|
||||
ExtraFieldProperties = any,
|
||||
> = {
|
||||
CellComponent: PayloadComponent<never>
|
||||
FieldComponent: PayloadComponent<never, RichTextFieldClientProps>
|
||||
/**
|
||||
* Component that will be displayed in the version diff view.
|
||||
* If not provided, richtext content will be diffed as JSON.
|
||||
*/
|
||||
DiffComponent?: PayloadComponent<
|
||||
FieldDiffServerProps<RichTextField, RichTextFieldClient>,
|
||||
FieldDiffClientProps<RichTextFieldClient>
|
||||
>
|
||||
FieldComponent: PayloadComponent<RichTextFieldServerProps, RichTextFieldClientProps>
|
||||
} & RichTextAdapterBase<Value, AdapterProps, ExtraFieldProperties>
|
||||
|
||||
export type RichTextAdapterProvider<
|
||||
|
||||
@@ -13,8 +13,12 @@ export type Data = {
|
||||
export type Row = {
|
||||
blockType?: string
|
||||
collapsed?: boolean
|
||||
customComponents?: {
|
||||
RowLabel?: React.ReactNode
|
||||
}
|
||||
id: string
|
||||
isLoading?: boolean
|
||||
lastRenderedPath?: string
|
||||
}
|
||||
|
||||
export type FilterOptionsResult = {
|
||||
@@ -34,7 +38,6 @@ export type FieldState = {
|
||||
Error?: React.ReactNode
|
||||
Field?: React.ReactNode
|
||||
Label?: React.ReactNode
|
||||
RowLabels?: React.ReactNode[]
|
||||
}
|
||||
disableFormData?: boolean
|
||||
errorMessage?: string
|
||||
@@ -46,8 +49,16 @@ export type FieldState = {
|
||||
fieldSchema?: Field
|
||||
filterOptions?: FilterOptionsResult
|
||||
initialValue?: unknown
|
||||
/**
|
||||
* The path of the field when its custom components were last rendered.
|
||||
* This is used to denote if a field has been rendered, and if so,
|
||||
* what path it was rendered under last.
|
||||
*
|
||||
* If this path is undefined, or, if it is different
|
||||
* from the current path of a given field, the field's components will be re-rendered.
|
||||
*/
|
||||
lastRenderedPath?: string
|
||||
passesCondition?: boolean
|
||||
requiresRender?: boolean
|
||||
rows?: Row[]
|
||||
/**
|
||||
* The `serverPropsToIgnore` obj is used to prevent the various properties from being overridden across form state requests.
|
||||
@@ -95,6 +106,13 @@ export type BuildFormStateArgs = {
|
||||
*/
|
||||
language?: keyof SupportedLanguages
|
||||
locale?: string
|
||||
/**
|
||||
* If true, will not render RSCs and instead return a simple string in their place.
|
||||
* This is useful for environments that lack RSC support, such as Jest.
|
||||
* Form state can still be built, but any server components will be omitted.
|
||||
* @default false
|
||||
*/
|
||||
mockRSCs?: boolean
|
||||
operation?: 'create' | 'update'
|
||||
/*
|
||||
If true, will render field components within their state object
|
||||
|
||||
@@ -56,8 +56,7 @@ export type Options<TSlug extends CollectionSlug, TSelect extends SelectType> =
|
||||
*/
|
||||
disableVerificationEmail?: boolean
|
||||
/**
|
||||
* @deprecated this property has no effect on the published status of the created document. It will only control whether validation runs or not. In order to control the draft status of the document, you can pass _status: 'draft' or _status: 'published' in the data object.
|
||||
* By default, draft-enabled collections will create documents with _status: 'draft'.
|
||||
* Create a **draft** document. [More](https://payloadcms.com/docs/versions/drafts#draft-api)
|
||||
*/
|
||||
draft?: boolean
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { en } from '@payloadcms/translations/languages/en'
|
||||
import { status as httpStatus } from 'http-status'
|
||||
|
||||
import type { LabelFunction, StaticLabel } from '../config/types.js'
|
||||
import type { PayloadRequest } from '../types/index.js'
|
||||
|
||||
import { APIError } from './APIError.js'
|
||||
|
||||
@@ -28,6 +29,10 @@ export class ValidationError extends APIError<{
|
||||
errors: ValidationFieldError[]
|
||||
global?: string
|
||||
id?: number | string
|
||||
/**
|
||||
* req needs to be passed through (if you have one) in order to resolve label functions that may be part of the errors array
|
||||
*/
|
||||
req?: Partial<PayloadRequest>
|
||||
},
|
||||
t?: TFunction,
|
||||
) {
|
||||
@@ -37,8 +42,36 @@ export class ValidationError extends APIError<{
|
||||
? en.translations.error.followingFieldsInvalid_one
|
||||
: en.translations.error.followingFieldsInvalid_other
|
||||
|
||||
const req = results.req
|
||||
// delete to avoid logging the whole req
|
||||
delete results['req']
|
||||
|
||||
super(
|
||||
`${message} ${results.errors.map((f) => f.label || f.path).join(', ')}`,
|
||||
`${message} ${results.errors
|
||||
.map((f) => {
|
||||
if (f.label) {
|
||||
if (typeof f.label === 'function') {
|
||||
if (!req || !req.i18n || !req.t) {
|
||||
return f.path
|
||||
}
|
||||
|
||||
return f.label({ i18n: req.i18n, t: req.t })
|
||||
}
|
||||
|
||||
if (typeof f.label === 'object') {
|
||||
if (req?.i18n?.language) {
|
||||
return f.label[req.i18n.language]
|
||||
}
|
||||
|
||||
return f.label[Object.keys(f.label)[0]]
|
||||
}
|
||||
|
||||
return f.label
|
||||
}
|
||||
|
||||
return f.path
|
||||
})
|
||||
.join(', ')}`,
|
||||
httpStatus.BAD_REQUEST,
|
||||
results,
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ import type {
|
||||
EmailFieldLabelServerComponent,
|
||||
FieldDescriptionClientProps,
|
||||
FieldDescriptionServerProps,
|
||||
FieldDiffClientComponent,
|
||||
FieldDiffClientProps,
|
||||
FieldDiffServerProps,
|
||||
GroupFieldClientProps,
|
||||
GroupFieldLabelClientComponent,
|
||||
@@ -326,7 +326,7 @@ type Admin = {
|
||||
components?: {
|
||||
Cell?: PayloadComponent<DefaultServerCellComponentProps, DefaultCellComponentProps>
|
||||
Description?: PayloadComponent<FieldDescriptionServerProps, FieldDescriptionClientProps>
|
||||
Diff?: PayloadComponent<FieldDiffServerProps, FieldDiffClientComponent>
|
||||
Diff?: PayloadComponent<FieldDiffServerProps, FieldDiffClientProps>
|
||||
Field?: PayloadComponent<FieldClientComponent | FieldServerComponent>
|
||||
/**
|
||||
* The Filter component has to be a client component
|
||||
|
||||
@@ -77,6 +77,7 @@ export const beforeChange = async <T extends JsonObject>({
|
||||
collection: collection?.slug,
|
||||
errors,
|
||||
global: global?.slug,
|
||||
req,
|
||||
},
|
||||
req.t,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import type { User } from '../../auth/types.js'
|
||||
import type { Field } from '../../fields/config/types.js'
|
||||
import type { TaskConfig } from '../../queues/config/types/taskTypes.js'
|
||||
import type { SchedulePublishTaskInput } from './types.js'
|
||||
|
||||
@@ -87,11 +88,15 @@ export const getSchedulePublishTask = ({
|
||||
name: 'locale',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'doc',
|
||||
type: 'relationship',
|
||||
relationTo: collections,
|
||||
},
|
||||
...(collections.length > 0
|
||||
? [
|
||||
{
|
||||
name: 'doc',
|
||||
type: 'relationship',
|
||||
relationTo: collections,
|
||||
} satisfies Field,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'global',
|
||||
type: 'select',
|
||||
|
||||
@@ -88,11 +88,11 @@ export const getFields = ({ collection, prefix }: Args): Field[] => {
|
||||
type: 'group',
|
||||
fields: [
|
||||
{
|
||||
...(existingSizeURLField || ({} as any)),
|
||||
...(existingSizeURLField || {}),
|
||||
...baseURLField,
|
||||
},
|
||||
],
|
||||
}
|
||||
} as Field
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ export const getFields = ({
|
||||
fields: [
|
||||
...(adapter.fields || []),
|
||||
{
|
||||
...(existingSizeURLField || ({} as any)),
|
||||
...(existingSizeURLField || {}),
|
||||
...baseURLField,
|
||||
hooks: {
|
||||
afterRead: [
|
||||
@@ -124,7 +124,7 @@ export const getFields = ({
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
} as Field
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ export const getAfterDeleteHook = ({
|
||||
try {
|
||||
const filesToDelete: string[] = [
|
||||
doc.filename,
|
||||
...Object.values(doc?.sizes || []).map((resizedFileData) => resizedFileData?.filename),
|
||||
...Object.values(doc?.sizes || []).map(
|
||||
(resizedFileData) => resizedFileData?.filename as string,
|
||||
),
|
||||
]
|
||||
|
||||
const promises = filesToDelete.map(async (filename) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ export const getAfterReadHook =
|
||||
let url = value
|
||||
|
||||
if (disablePayloadAccessControl && filename) {
|
||||
url = await adapter.generateURL({
|
||||
url = await adapter.generateURL?.({
|
||||
collection,
|
||||
data,
|
||||
filename,
|
||||
|
||||
@@ -29,7 +29,7 @@ export const getBeforeChangeHook =
|
||||
if (typeof originalDoc.sizes === 'object') {
|
||||
filesToDelete = filesToDelete.concat(
|
||||
Object.values(originalDoc?.sizes || []).map(
|
||||
(resizedFileData) => resizedFileData?.filename,
|
||||
(resizedFileData) => resizedFileData?.filename as string,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,9 +67,6 @@ export const cloudStoragePlugin =
|
||||
if ('clientUploadContext' in args.params) {
|
||||
return adapter.staticHandler(req, args)
|
||||
}
|
||||
|
||||
// Otherwise still skip staticHandler
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export const initClientUploads = <ExtraProps extends Record<string, unknown>, T>
|
||||
clientProps: {
|
||||
collectionSlug,
|
||||
enabled,
|
||||
extra: extraClientHandlerProps ? extraClientHandlerProps(collection) : undefined,
|
||||
extra: extraClientHandlerProps ? extraClientHandlerProps(collection!) : undefined,
|
||||
prefix,
|
||||
serverHandlerPath,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
/* TODO: remove the following lines */
|
||||
"strict": false,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
},
|
||||
"references": [{ "path": "../payload" }, { "path": "../ui" }]
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export const sendEmail = async (
|
||||
|
||||
if (emails && emails.length) {
|
||||
const formattedEmails: FormattedEmail[] = await Promise.all(
|
||||
emails.map(async (email: Email): Promise<FormattedEmail | null> => {
|
||||
emails.map(async (email: Email): Promise<FormattedEmail> => {
|
||||
const {
|
||||
bcc: emailBCC,
|
||||
cc: emailCC,
|
||||
|
||||
@@ -23,6 +23,7 @@ export const generateSubmissionCollection = (
|
||||
},
|
||||
relationTo: formSlug,
|
||||
required: true,
|
||||
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
|
||||
validate: async (value, { req: { payload }, req }) => {
|
||||
/* Don't run in the client side */
|
||||
if (!payload) {
|
||||
@@ -40,7 +41,7 @@ export const generateSubmissionCollection = (
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return 'Cannot create this submission because this form does not exist.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const DynamicFieldSelector: React.FC<
|
||||
|
||||
return null
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter((field) => field !== null)
|
||||
setOptions(allNonPaymentFields)
|
||||
}
|
||||
}, [fields, getDataByPath])
|
||||
@@ -40,9 +40,8 @@ export const DynamicFieldSelector: React.FC<
|
||||
<SelectField
|
||||
{...props}
|
||||
field={{
|
||||
name: props?.field?.name,
|
||||
options,
|
||||
...(props.field || {}),
|
||||
options,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -57,11 +57,11 @@ export const generateFormCollection = (formConfig: FormBuilderPluginConfig): Col
|
||||
],
|
||||
})
|
||||
|
||||
if (redirect.fields[2].type !== 'row') {
|
||||
redirect.fields[2].label = 'Custom URL'
|
||||
if (redirect.fields[2]!.type !== 'row') {
|
||||
redirect.fields[2]!.label = 'Custom URL'
|
||||
}
|
||||
|
||||
redirect.fields[2].admin = {
|
||||
redirect.fields[2]!.admin = {
|
||||
condition: (_, siblingData) => siblingData?.type === 'custom',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const replaceDoubleCurlys = (str: string, variables?: EmailVariables): st
|
||||
return variables.map(({ field, value }) => `${field} : ${value}`).join(' <br /> ')
|
||||
} else if (variable === '*:table') {
|
||||
return keyValuePairToHtmlTable(
|
||||
variables.reduce((acc, { field, value }) => {
|
||||
variables.reduce<Record<string, string>>((acc, { field, value }) => {
|
||||
acc[field] = value
|
||||
return acc
|
||||
}, {}),
|
||||
|
||||
@@ -106,7 +106,7 @@ export const serializeSlate = (children?: Node[], submissionData?: any): string
|
||||
`
|
||||
case 'link':
|
||||
return `
|
||||
<a href={${escapeHTML(replaceDoubleCurlys(node.url, submissionData))}}>
|
||||
<a href={${escapeHTML(replaceDoubleCurlys(node.url!, submissionData))}}>
|
||||
${serializeSlate(node.children, submissionData)}
|
||||
</a>
|
||||
`
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
/* TODO: remove the following lines */
|
||||
"strict": false,
|
||||
},
|
||||
"references": [{ "path": "../payload" }, { "path": "../ui" }]
|
||||
}
|
||||
|
||||
@@ -10,5 +10,5 @@ export const parentFilterOptions: (breadcrumbsFieldSlug?: string) => FilterOptio
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -58,9 +58,11 @@ const resave = async ({ collection, doc, draft, pluginConfig, req }: ResaveArgs)
|
||||
},
|
||||
})
|
||||
|
||||
const childrenById = [...draftChildren, ...publishedChildren.docs].reduce((acc, child) => {
|
||||
const childrenById = [...draftChildren, ...publishedChildren.docs].reduce<
|
||||
Record<string, JsonObject[]>
|
||||
>((acc, child) => {
|
||||
acc[child.id] = acc[child.id] || []
|
||||
acc[child.id].push(child)
|
||||
acc[child.id]!.push(child)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CollectionAfterChangeHook, CollectionConfig } from 'payload'
|
||||
|
||||
import type { NestedDocsPluginConfig } from '../types.js'
|
||||
import type { Breadcrumb, NestedDocsPluginConfig } from '../types.js'
|
||||
|
||||
// This hook automatically re-saves a document after it is created
|
||||
// so that we can build its breadcrumbs with the newly created document's ID.
|
||||
@@ -10,7 +10,7 @@ export const resaveSelfAfterCreate =
|
||||
async ({ doc, operation, req }) => {
|
||||
const { locale, payload } = req
|
||||
const breadcrumbSlug = pluginConfig.breadcrumbsFieldSlug || 'breadcrumbs'
|
||||
const breadcrumbs = doc[breadcrumbSlug]
|
||||
const breadcrumbs = doc[breadcrumbSlug] as unknown as Breadcrumb[]
|
||||
|
||||
if (operation === 'create') {
|
||||
const originalDocWithDepth0 = await payload.findByID({
|
||||
|
||||
@@ -10,7 +10,7 @@ export const formatBreadcrumb = (
|
||||
let url: string | undefined = undefined
|
||||
let label: string
|
||||
|
||||
const lastDoc = docs[docs.length - 1]
|
||||
const lastDoc = docs[docs.length - 1]!
|
||||
|
||||
if (typeof pluginConfig?.generateURL === 'function') {
|
||||
url = pluginConfig.generateURL(docs, lastDoc)
|
||||
@@ -19,7 +19,7 @@ export const formatBreadcrumb = (
|
||||
if (typeof pluginConfig?.generateLabel === 'function') {
|
||||
label = pluginConfig.generateLabel(docs, lastDoc)
|
||||
} else {
|
||||
const title = lastDoc[collection.admin.useAsTitle]
|
||||
const title = collection.admin?.useAsTitle ? lastDoc[collection.admin.useAsTitle] : ''
|
||||
|
||||
label = typeof title === 'string' || typeof title === 'number' ? String(title) : ''
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const getParents = async (
|
||||
): Promise<Array<Record<string, unknown>>> => {
|
||||
const parentSlug = pluginConfig?.parentFieldSlug || 'parent'
|
||||
const parent = doc[parentSlug]
|
||||
let retrievedParent
|
||||
let retrievedParent: null | Record<string, unknown> = null
|
||||
|
||||
if (parent) {
|
||||
// If not auto-populated, and we have an ID
|
||||
@@ -27,7 +27,7 @@ export const getParents = async (
|
||||
|
||||
// If auto-populated
|
||||
if (typeof parent === 'object') {
|
||||
retrievedParent = parent
|
||||
retrievedParent = parent as Record<string, unknown>
|
||||
}
|
||||
|
||||
if (retrievedParent) {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
/* TODO: remove the following lines */
|
||||
"strict": false,
|
||||
},
|
||||
"references": [{ "path": "../payload" }]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ export const redirectsPlugin =
|
||||
pluginConfig?.redirectTypes?.includes(option.value),
|
||||
),
|
||||
required: true,
|
||||
...((pluginConfig?.redirectTypeFieldOverride || {}) as SelectField),
|
||||
...((pluginConfig?.redirectTypeFieldOverride || {}) as {
|
||||
hasMany: boolean
|
||||
} & Partial<SelectField>),
|
||||
}
|
||||
|
||||
const defaultFields: Field[] = [
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
/* TODO: remove the following lines */
|
||||
"strict": false,
|
||||
},
|
||||
"references": [{ "path": "../payload" }]
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export { UnorderedListFeatureClient } from '../../features/lists/unorderedList/c
|
||||
export { LexicalPluginToLexicalFeatureClient } from '../../features/migrations/lexicalPluginToLexical/feature.client.js'
|
||||
export { SlateToLexicalFeatureClient } from '../../features/migrations/slateToLexical/feature.client.js'
|
||||
export { ParagraphFeatureClient } from '../../features/paragraph/client/index.js'
|
||||
export { DebugJsxConverterFeatureClient } from '../../features/debug/jsxConverter/client/index.js'
|
||||
|
||||
export { RelationshipFeatureClient } from '../../features/relationship/client/index.js'
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { RscEntryLexicalCell } from '../../cell/rscEntry.js'
|
||||
export { LexicalDiffComponent } from '../../field/Diff/index.js'
|
||||
export { RscEntryLexicalField } from '../../field/rscEntry.js'
|
||||
|
||||
@@ -83,6 +83,7 @@ export type HTMLConvertersAsync<
|
||||
: SerializedInlineBlockNode
|
||||
>
|
||||
}
|
||||
unknown?: HTMLConverterAsync<SerializedLexicalNode>
|
||||
}
|
||||
|
||||
export type HTMLConvertersFunctionAsync<
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-console */
|
||||
import type { SerializedLexicalNode } from 'lexical'
|
||||
|
||||
import type { SerializedBlockNode, SerializedInlineBlockNode } from '../../../../nodeTypes.js'
|
||||
@@ -30,7 +31,7 @@ export function findConverterForNode<
|
||||
converterForNode = converters?.blocks?.[
|
||||
(node as SerializedBlockNode)?.fields?.blockType
|
||||
] as TConverter
|
||||
if (!converterForNode) {
|
||||
if (!converterForNode && !unknownConverter) {
|
||||
console.error(
|
||||
`Lexical => HTML converter: Blocks converter: found ${(node as SerializedBlockNode)?.fields?.blockType} block, but no converter is provided`,
|
||||
)
|
||||
@@ -39,7 +40,7 @@ export function findConverterForNode<
|
||||
converterForNode = converters?.inlineBlocks?.[
|
||||
(node as SerializedInlineBlockNode)?.fields?.blockType
|
||||
] as TConverter
|
||||
if (!converterForNode) {
|
||||
if (!converterForNode && !unknownConverter) {
|
||||
console.error(
|
||||
`Lexical => HTML converter: Inline Blocks converter: found ${(node as SerializedInlineBlockNode)?.fields?.blockType} inline block, but no converter is provided`,
|
||||
)
|
||||
|
||||
@@ -71,6 +71,7 @@ export type HTMLConverters<
|
||||
: SerializedInlineBlockNode
|
||||
>
|
||||
}
|
||||
unknown?: HTMLConverter<SerializedLexicalNode>
|
||||
}
|
||||
|
||||
export type HTMLConvertersFunction<
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-console */
|
||||
import type { SerializedEditorState, SerializedLexicalNode } from 'lexical'
|
||||
|
||||
import React from 'react'
|
||||
@@ -51,7 +52,7 @@ export function convertLexicalNodesToJSX({
|
||||
let converterForNode: JSXConverter<any> | undefined
|
||||
if (node.type === 'block') {
|
||||
converterForNode = converters?.blocks?.[(node as SerializedBlockNode)?.fields?.blockType]
|
||||
if (!converterForNode) {
|
||||
if (!converterForNode && !unknownConverter) {
|
||||
console.error(
|
||||
`Lexical => JSX converter: Blocks converter: found ${(node as SerializedBlockNode)?.fields?.blockType} block, but no converter is provided`,
|
||||
)
|
||||
@@ -59,7 +60,7 @@ export function convertLexicalNodesToJSX({
|
||||
} else if (node.type === 'inlineBlock') {
|
||||
converterForNode =
|
||||
converters?.inlineBlocks?.[(node as SerializedInlineBlockNode)?.fields?.blockType]
|
||||
if (!converterForNode) {
|
||||
if (!converterForNode && !unknownConverter) {
|
||||
console.error(
|
||||
`Lexical => JSX converter: Inline Blocks converter: found ${(node as SerializedInlineBlockNode)?.fields?.blockType} inline block, but no converter is provided`,
|
||||
)
|
||||
|
||||
@@ -66,6 +66,7 @@ export type JSXConverters<
|
||||
: SerializedInlineBlockNode
|
||||
>
|
||||
}
|
||||
unknown?: JSXConverter<SerializedLexicalNode>
|
||||
}
|
||||
export type SerializedLexicalNodeWithParent = {
|
||||
parent?: SerializedLexicalNode
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { createClientFeature } from '../../../../utilities/createClientFeature.js'
|
||||
import { RichTextPlugin } from './plugin/index.js'
|
||||
|
||||
export const DebugJsxConverterFeatureClient = createClientFeature({
|
||||
plugins: [
|
||||
{
|
||||
Component: RichTextPlugin,
|
||||
position: 'bottom',
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// eslint-disable-next-line payload/no-imports-from-exports-dir
|
||||
import { defaultJSXConverters, RichText } from '../../../../../exports/react/index.js'
|
||||
|
||||
export function RichTextPlugin() {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [editorState, setEditorState] = useState(editor.getEditorState().toJSON())
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
setEditorState(editorState.toJSON())
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
return <RichText converters={defaultJSXConverters} data={editorState} />
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createServerFeature } from '../../../../utilities/createServerFeature.js'
|
||||
|
||||
export const DebugJsxConverterFeature = createServerFeature({
|
||||
feature: {
|
||||
ClientFeature: '@payloadcms/richtext-lexical/client#DebugJsxConverterFeatureClient',
|
||||
},
|
||||
key: 'jsxConverter',
|
||||
})
|
||||
@@ -46,7 +46,6 @@ export const uploadValidation = (
|
||||
const result = await fieldSchemasToFormState({
|
||||
id,
|
||||
collectionSlug: node.relationTo,
|
||||
|
||||
data: node?.fields ?? {},
|
||||
documentData: data,
|
||||
fields: collection.fields,
|
||||
|
||||
35
packages/richtext-lexical/src/field/Diff/colors.scss
Normal file
35
packages/richtext-lexical/src/field/Diff/colors.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
@import '../../scss/styles.scss';
|
||||
|
||||
@layer payload-default {
|
||||
:root {
|
||||
--diff-delete-pill-bg: var(--theme-error-200);
|
||||
--diff-delete-pill-color: var(--theme-error-600);
|
||||
--diff-delete-pill-border: var(--theme-error-400);
|
||||
--diff-delete-parent-bg: var(--theme-error-100);
|
||||
--diff-delete-parent-color: var(--theme-error-800);
|
||||
--diff-delete-link-color: var(--theme-error-600);
|
||||
|
||||
--diff-create-pill-bg: var(--theme-success-200);
|
||||
--diff-create-pill-color: var(--theme-success-600);
|
||||
--diff-create-pill-border: var(--theme-success-400);
|
||||
--diff-create-parent-bg: var(--theme-success-100);
|
||||
--diff-create-parent-color: var(--theme-success-800);
|
||||
--diff-create-link-color: var(--theme-success-600);
|
||||
}
|
||||
|
||||
html[data-theme='dark'] {
|
||||
--diff-delete-pill-bg: var(--theme-error-200);
|
||||
--diff-delete-pill-color: var(--theme-error-650);
|
||||
--diff-delete-pill-border: var(--theme-error-400);
|
||||
--diff-delete-parent-bg: var(--theme-error-100);
|
||||
--diff-delete-parent-color: var(--theme-error-900);
|
||||
--diff-delete-link-color: var(--theme-error-750);
|
||||
|
||||
--diff-create-pill-bg: var(--theme-success-200);
|
||||
--diff-create-pill-color: var(--theme-success-650);
|
||||
--diff-create-pill-border: var(--theme-success-400);
|
||||
--diff-create-parent-bg: var(--theme-success-100);
|
||||
--diff-create-parent-color: var(--theme-success-900);
|
||||
--diff-create-link-color: var(--theme-success-750);
|
||||
}
|
||||
}
|
||||
59
packages/richtext-lexical/src/field/Diff/converters/link.ts
Normal file
59
packages/richtext-lexical/src/field/Diff/converters/link.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
import type {
|
||||
HTMLConvertersAsync,
|
||||
HTMLPopulateFn,
|
||||
} from '../../../features/converters/lexicalToHtml/async/types.js'
|
||||
import type { SerializedAutoLinkNode, SerializedLinkNode } from '../../../nodeTypes.js'
|
||||
|
||||
export const LinkDiffHTMLConverterAsync: (args: {
|
||||
internalDocToHref?: (args: {
|
||||
linkNode: SerializedLinkNode
|
||||
populate?: HTMLPopulateFn
|
||||
}) => Promise<string> | string
|
||||
}) => HTMLConvertersAsync<SerializedAutoLinkNode | SerializedLinkNode> = ({
|
||||
internalDocToHref,
|
||||
}) => ({
|
||||
autolink: async ({ node, nodesToHTML, providedStyleTag }) => {
|
||||
const children = (
|
||||
await nodesToHTML({
|
||||
nodes: node.children,
|
||||
})
|
||||
).join('')
|
||||
|
||||
// hash fields to ensure they are diffed if they change
|
||||
const nodeFieldsHash = createHash('sha256').update(JSON.stringify(node.fields)).digest('hex')
|
||||
|
||||
return `<a${providedStyleTag} data-fields-hash="${nodeFieldsHash}" data-enable-match="true" href="${node.fields.url}"${node.fields.newTab ? ' rel="noopener noreferrer" target="_blank"' : ''}>
|
||||
${children}
|
||||
</a>`
|
||||
},
|
||||
link: async ({ node, nodesToHTML, populate, providedStyleTag }) => {
|
||||
const children = (
|
||||
await nodesToHTML({
|
||||
nodes: node.children,
|
||||
})
|
||||
).join('')
|
||||
|
||||
let href: string = node.fields.url ?? ''
|
||||
if (node.fields.linkType === 'internal') {
|
||||
if (internalDocToHref) {
|
||||
href = await internalDocToHref({ linkNode: node, populate })
|
||||
} else {
|
||||
console.error(
|
||||
'Lexical => HTML converter: Link converter: found internal link, but internalDocToHref is not provided',
|
||||
)
|
||||
href = '#' // fallback
|
||||
}
|
||||
}
|
||||
|
||||
// hash fields to ensure they are diffed if they change
|
||||
const nodeFieldsHash = createHash('sha256')
|
||||
.update(JSON.stringify(node.fields ?? {}))
|
||||
.digest('hex')
|
||||
|
||||
return `<a${providedStyleTag} data-fields-hash="${nodeFieldsHash}" data-enable-match="true" href="${href}"${node.fields.newTab ? ' rel="noopener noreferrer" target="_blank"' : ''}>
|
||||
${children}
|
||||
</a>`
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
@import '../../colors.scss';
|
||||
|
||||
@layer payload-default {
|
||||
.lexical-diff {
|
||||
ul.list-check {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.checkboxItem {
|
||||
list-style-type: none;
|
||||
|
||||
&__wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px; // Spacing before label text
|
||||
border: 1px solid var(--theme-text);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// Because the checkbox is non-interactive:
|
||||
pointer-events: none;
|
||||
|
||||
.icon--check {
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
&[data-match-type='create'] {
|
||||
border-color: var(--diff-create-pill-color);
|
||||
}
|
||||
|
||||
&[data-match-type='delete'] {
|
||||
border-color: var(--diff-delete-pill-color);
|
||||
}
|
||||
}
|
||||
|
||||
&--nested {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { CheckIcon } from '@payloadcms/ui/rsc'
|
||||
|
||||
import type { HTMLConvertersAsync } from '../../../../features/converters/lexicalToHtml/async/types.js'
|
||||
import type { SerializedListItemNode } from '../../../../nodeTypes.js'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
export const ListItemDiffHTMLConverterAsync: HTMLConvertersAsync<SerializedListItemNode> = {
|
||||
listitem: async ({ node, nodesToHTML, parent, providedCSSString }) => {
|
||||
const hasSubLists = node.children.some((child) => child.type === 'list')
|
||||
|
||||
const children = (
|
||||
await nodesToHTML({
|
||||
nodes: node.children,
|
||||
})
|
||||
).join('')
|
||||
|
||||
if ('listType' in parent && parent?.listType === 'check') {
|
||||
const ReactDOMServer = (await import('react-dom/server')).default
|
||||
|
||||
const JSX = (
|
||||
<li
|
||||
aria-checked={node.checked ? true : false}
|
||||
className={`checkboxItem ${node.checked ? 'checkboxItem--checked' : 'checkboxItem--unchecked'}${
|
||||
hasSubLists ? ' checkboxItem--nested' : ''
|
||||
}`}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
|
||||
role="checkbox"
|
||||
tabIndex={-1}
|
||||
value={node.value}
|
||||
>
|
||||
{hasSubLists ? (
|
||||
// When sublists exist, just render them safely as HTML
|
||||
<div dangerouslySetInnerHTML={{ __html: children }} />
|
||||
) : (
|
||||
// Otherwise, show our custom styled checkbox
|
||||
<div className="checkboxItem__wrapper">
|
||||
<div
|
||||
className="checkboxItem__icon"
|
||||
data-checked={node.checked}
|
||||
data-enable-match="true"
|
||||
>
|
||||
{node.checked && <CheckIcon />}
|
||||
</div>
|
||||
<span className="checkboxItem__label">{children}</span>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
|
||||
const html = ReactDOMServer.renderToString(JSX)
|
||||
|
||||
// Add style="list-style-type: none;${providedCSSString}" to html
|
||||
const styleIndex = html.indexOf('class="list-item-checkbox')
|
||||
const classIndex = html.indexOf('class="list-item-checkbox', styleIndex)
|
||||
const classEndIndex = html.indexOf('"', classIndex + 6)
|
||||
const className = html.substring(classIndex, classEndIndex)
|
||||
const classNameWithStyle = `${className} style="list-style-type: none;${providedCSSString}"`
|
||||
const htmlWithStyle = html.replace(className, classNameWithStyle)
|
||||
|
||||
return htmlWithStyle
|
||||
} else {
|
||||
return `<li
|
||||
class="${hasSubLists ? 'nestedListItem' : ''}"
|
||||
style="${hasSubLists ? `list-style-type: none;${providedCSSString}` : providedCSSString}"
|
||||
value="${node.value}"
|
||||
data-enable-match="true"
|
||||
>${children}</li>`
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
@import '../../colors.scss';
|
||||
|
||||
@layer payload-default {
|
||||
.lexical-diff__diff-container {
|
||||
.lexical-relationship-diff {
|
||||
@extend %body;
|
||||
@include shadow-sm;
|
||||
min-width: calc(var(--base) * 8);
|
||||
max-width: fit-content;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--theme-input-bg);
|
||||
border-radius: $style-radius-s;
|
||||
border: 1px solid var(--theme-elevation-100);
|
||||
position: relative;
|
||||
font-family: var(--font-body);
|
||||
margin-block: base(0.5);
|
||||
max-height: calc(var(--base) * 4);
|
||||
padding: base(0.6);
|
||||
|
||||
&[data-match-type='create'] {
|
||||
border-color: var(--diff-create-pill-border);
|
||||
color: var(--diff-create-parent-color);
|
||||
|
||||
.lexical-relationship-diff__collectionLabel {
|
||||
color: var(--diff-create-link-color);
|
||||
}
|
||||
|
||||
[data-match-type='create'] {
|
||||
color: var(--diff-create-parent-color);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-match-type='delete'] {
|
||||
border-color: var(--diff-delete-pill-border);
|
||||
color: var(--diff-delete-parent-color);
|
||||
text-decoration-line: none;
|
||||
background-color: var(--diff-delete-pill-bg);
|
||||
|
||||
.lexical-relationship-diff__collectionLabel {
|
||||
color: var(--diff-delete-link-color);
|
||||
}
|
||||
|
||||
[data-match-type='delete'] {
|
||||
text-decoration-line: none;
|
||||
}
|
||||
|
||||
* {
|
||||
color: var(--diff-delete-parent-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__collectionLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { FileData, PayloadRequest, TypeWithID } from 'payload'
|
||||
|
||||
import { getTranslation, type I18nClient } from '@payloadcms/translations'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
import type { HTMLConvertersAsync } from '../../../../features/converters/lexicalToHtml/async/types.js'
|
||||
import type { SerializedRelationshipNode } from '../../../../nodeTypes.js'
|
||||
|
||||
const baseClass = 'lexical-relationship-diff'
|
||||
|
||||
export const RelationshipDiffHTMLConverterAsync: (args: {
|
||||
i18n: I18nClient
|
||||
req: PayloadRequest
|
||||
}) => HTMLConvertersAsync<SerializedRelationshipNode> = ({ i18n, req }) => {
|
||||
return {
|
||||
relationship: async ({ node, populate, providedCSSString }) => {
|
||||
let data: (Record<string, any> & TypeWithID) | undefined = undefined
|
||||
|
||||
// If there's no valid upload data, populate return an empty string
|
||||
if (typeof node.value !== 'object') {
|
||||
if (!populate) {
|
||||
return ''
|
||||
}
|
||||
data = await populate<FileData & TypeWithID>({
|
||||
id: node.value,
|
||||
collectionSlug: node.relationTo,
|
||||
})
|
||||
} else {
|
||||
data = node.value as unknown as FileData & TypeWithID
|
||||
}
|
||||
|
||||
const relatedCollection = req.payload.collections[node.relationTo]?.config
|
||||
|
||||
const ReactDOMServer = (await import('react-dom/server')).default
|
||||
|
||||
const JSX = (
|
||||
<div
|
||||
className={`${baseClass}${providedCSSString}`}
|
||||
data-enable-match="true"
|
||||
data-id={node.value}
|
||||
data-slug={node.relationTo}
|
||||
>
|
||||
<div className={`${baseClass}__card`}>
|
||||
<div className={`${baseClass}__collectionLabel`}>
|
||||
{i18n.t('fields:labelRelationship', {
|
||||
label: relatedCollection?.labels?.singular
|
||||
? getTranslation(relatedCollection?.labels?.singular, i18n)
|
||||
: relatedCollection?.slug,
|
||||
})}
|
||||
</div>
|
||||
{data &&
|
||||
relatedCollection?.admin?.useAsTitle &&
|
||||
data[relatedCollection.admin.useAsTitle] ? (
|
||||
<strong className={`${baseClass}__title`} data-enable-match="false">
|
||||
<a
|
||||
className={`${baseClass}__link`}
|
||||
data-enable-match="false"
|
||||
href={`/${relatedCollection.slug}/${data.id}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{data[relatedCollection.admin.useAsTitle]}
|
||||
</a>
|
||||
</strong>
|
||||
) : (
|
||||
<strong>{node.value as string}</strong>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render to HTML
|
||||
const html = ReactDOMServer.renderToString(JSX)
|
||||
|
||||
return html
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
@import '../../colors.scss';
|
||||
|
||||
@layer payload-default {
|
||||
.lexical-diff__diff-container {
|
||||
.lexical-unknown-diff {
|
||||
@extend %body;
|
||||
@include shadow-sm;
|
||||
max-width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--theme-input-bg);
|
||||
border-radius: $style-radius-s;
|
||||
border: 1px solid var(--theme-elevation-100);
|
||||
position: relative;
|
||||
font-family: var(--font-body);
|
||||
margin-block: base(0.5);
|
||||
max-height: calc(var(--base) * 4);
|
||||
padding: base(0.25);
|
||||
|
||||
&__specifier {
|
||||
font-family: 'SF Mono', Menlo, Consolas, Monaco, monospace;
|
||||
}
|
||||
|
||||
&[data-match-type='create'] {
|
||||
border-color: var(--diff-create-pill-border);
|
||||
color: var(--diff-create-parent-color);
|
||||
}
|
||||
|
||||
&[data-match-type='delete'] {
|
||||
border-color: var(--diff-delete-pill-border);
|
||||
color: var(--diff-delete-parent-color);
|
||||
text-decoration-line: none;
|
||||
background-color: var(--diff-delete-pill-bg);
|
||||
|
||||
* {
|
||||
text-decoration-line: none;
|
||||
color: var(--diff-delete-parent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { LexicalNode } from 'lexical'
|
||||
import type { PayloadRequest } from 'payload'
|
||||
|
||||
import { type I18nClient } from '@payloadcms/translations'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
import type { HTMLConvertersAsync } from '../../../../features/converters/lexicalToHtml/async/types.js'
|
||||
import type { SerializedBlockNode } from '../../../../nodeTypes.js'
|
||||
|
||||
const baseClass = 'lexical-unknown-diff'
|
||||
|
||||
export const UnknownDiffHTMLConverterAsync: (args: {
|
||||
i18n: I18nClient
|
||||
req: PayloadRequest
|
||||
}) => HTMLConvertersAsync<LexicalNode> = ({ i18n, req }) => {
|
||||
return {
|
||||
unknown: async ({ node, providedCSSString }) => {
|
||||
const ReactDOMServer = (await import('react-dom/server')).default
|
||||
|
||||
// hash fields to ensure they are diffed if they change
|
||||
const nodeFieldsHash = createHash('sha256')
|
||||
.update(JSON.stringify(node ?? {}))
|
||||
.digest('hex')
|
||||
|
||||
let nodeType = node.type
|
||||
|
||||
let nodeTypeSpecifier: null | string = null
|
||||
|
||||
if (node.type === 'block') {
|
||||
nodeTypeSpecifier = (node as SerializedBlockNode).fields.blockType
|
||||
nodeType = 'Block'
|
||||
} else if (node.type === 'inlineBlock') {
|
||||
nodeTypeSpecifier = (node as SerializedBlockNode).fields.blockType
|
||||
nodeType = 'InlineBlock'
|
||||
}
|
||||
|
||||
const JSX = (
|
||||
<div
|
||||
className={`${baseClass}${providedCSSString}`}
|
||||
data-enable-match="true"
|
||||
data-fields-hash={`${nodeFieldsHash}`}
|
||||
>
|
||||
{nodeTypeSpecifier && (
|
||||
<span className={`${baseClass}__specifier`}>{nodeTypeSpecifier} </span>
|
||||
)}
|
||||
<span>{nodeType}</span>
|
||||
<div className={`${baseClass}__meta`}>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render to HTML
|
||||
const html = ReactDOMServer.renderToString(JSX)
|
||||
|
||||
return html
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
@import '../../../../scss/styles.scss';
|
||||
@import '../../colors.scss';
|
||||
|
||||
@layer payload-default {
|
||||
.lexical-diff__diff-container {
|
||||
.lexical-upload-diff {
|
||||
@extend %body;
|
||||
@include shadow-sm;
|
||||
min-width: calc(var(--base) * 10);
|
||||
max-width: fit-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--theme-input-bg);
|
||||
border-radius: $style-radius-s;
|
||||
border: 1px solid var(--theme-elevation-100);
|
||||
position: relative;
|
||||
font-family: var(--font-body);
|
||||
margin-block: base(0.5);
|
||||
max-height: calc(var(--base) * 3);
|
||||
padding: base(0.6);
|
||||
|
||||
&[data-match-type='create'] {
|
||||
border-color: var(--diff-create-pill-border);
|
||||
color: var(--diff-create-parent-color);
|
||||
|
||||
* {
|
||||
color: var(--diff-create-parent-color);
|
||||
}
|
||||
|
||||
.lexical-upload-diff__meta {
|
||||
color: var(--diff-create-link-color);
|
||||
* {
|
||||
color: var(--diff-create-link-color);
|
||||
}
|
||||
}
|
||||
|
||||
.lexical-upload-diff__thumbnail {
|
||||
border-radius: 0px;
|
||||
border-color: var(--diff-create-pill-border);
|
||||
background-color: none;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-match-type='delete'] {
|
||||
border-color: var(--diff-delete-pill-border);
|
||||
text-decoration-line: none;
|
||||
color: var(--diff-delete-parent-color);
|
||||
background-color: var(--diff-delete-pill-bg);
|
||||
|
||||
.lexical-upload-diff__meta {
|
||||
color: var(--diff-delete-link-color);
|
||||
* {
|
||||
color: var(--diff-delete-link-color);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
text-decoration-line: none;
|
||||
color: var(--diff-delete-parent-color);
|
||||
}
|
||||
|
||||
.lexical-upload-diff__thumbnail {
|
||||
border-radius: 0px;
|
||||
border-color: var(--diff-delete-pill-border);
|
||||
background-color: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__thumbnail {
|
||||
width: calc(var(--base) * 3 - base(0.6) * 2);
|
||||
height: calc(var(--base) * 3 - base(0.6) * 2);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0px;
|
||||
border: 1px solid var(--theme-elevation-100);
|
||||
|
||||
img,
|
||||
svg {
|
||||
position: absolute;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: calc(var(--base) * 0.25) calc(var(--base) * 0.75);
|
||||
justify-content: space-between;
|
||||
font-weight: 400;
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { FileData, PayloadRequest, TypeWithID } from 'payload'
|
||||
|
||||
import { type I18nClient } from '@payloadcms/translations'
|
||||
import { File } from '@payloadcms/ui/rsc'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
import { formatFilesize } from 'payload/shared'
|
||||
import React from 'react'
|
||||
|
||||
import type { HTMLConvertersAsync } from '../../../../features/converters/lexicalToHtml/async/types.js'
|
||||
import type { UploadDataImproved } from '../../../../features/upload/server/nodes/UploadNode.js'
|
||||
import type { SerializedUploadNode } from '../../../../nodeTypes.js'
|
||||
|
||||
const baseClass = 'lexical-upload-diff'
|
||||
|
||||
export const UploadDiffHTMLConverterAsync: (args: {
|
||||
i18n: I18nClient
|
||||
req: PayloadRequest
|
||||
}) => HTMLConvertersAsync<SerializedUploadNode> = ({ i18n, req }) => {
|
||||
return {
|
||||
upload: async ({ node, populate, providedCSSString }) => {
|
||||
const uploadNode = node as UploadDataImproved
|
||||
|
||||
let uploadDoc: (FileData & TypeWithID) | undefined = undefined
|
||||
|
||||
// If there's no valid upload data, populate return an empty string
|
||||
if (typeof uploadNode.value !== 'object') {
|
||||
if (!populate) {
|
||||
return ''
|
||||
}
|
||||
uploadDoc = await populate<FileData & TypeWithID>({
|
||||
id: uploadNode.value,
|
||||
collectionSlug: uploadNode.relationTo,
|
||||
})
|
||||
} else {
|
||||
uploadDoc = uploadNode.value as unknown as FileData & TypeWithID
|
||||
}
|
||||
|
||||
if (!uploadDoc) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const relatedCollection = req.payload.collections[uploadNode.relationTo]?.config
|
||||
|
||||
const thumbnailSRC: string =
|
||||
('thumbnailURL' in uploadDoc && (uploadDoc?.thumbnailURL as string)) || uploadDoc?.url || ''
|
||||
|
||||
const ReactDOMServer = (await import('react-dom/server')).default
|
||||
|
||||
// hash fields to ensure they are diffed if they change
|
||||
const nodeFieldsHash = createHash('sha256')
|
||||
.update(JSON.stringify(node.fields ?? {}))
|
||||
.digest('hex')
|
||||
|
||||
const JSX = (
|
||||
<div
|
||||
className={`${baseClass}${providedCSSString}`}
|
||||
data-enable-match="true"
|
||||
data-fields-hash={`${nodeFieldsHash}`}
|
||||
data-filename={uploadDoc?.filename}
|
||||
data-lexical-upload-id={uploadNode.value}
|
||||
data-lexical-upload-relation-to={uploadNode.relationTo}
|
||||
data-src={thumbnailSRC}
|
||||
>
|
||||
<div className={`${baseClass}__card`}>
|
||||
<div className={`${baseClass}__thumbnail`}>
|
||||
{thumbnailSRC?.length ? (
|
||||
<img alt={uploadDoc?.filename} src={thumbnailSRC} />
|
||||
) : (
|
||||
<File />
|
||||
)}
|
||||
</div>
|
||||
<div className={`${baseClass}__info`}>
|
||||
<strong>{uploadDoc?.filename}</strong>
|
||||
<div className={`${baseClass}__meta`}>
|
||||
{formatFilesize(uploadDoc?.filesize)}
|
||||
{typeof uploadDoc?.width === 'number' && typeof uploadDoc?.height === 'number' && (
|
||||
<React.Fragment>
|
||||
-
|
||||
{uploadDoc?.width}x{uploadDoc?.height}
|
||||
</React.Fragment>
|
||||
)}
|
||||
{uploadDoc?.mimeType && (
|
||||
<React.Fragment>
|
||||
-
|
||||
{uploadDoc?.mimeType}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render to HTML
|
||||
const html = ReactDOMServer.renderToString(JSX)
|
||||
|
||||
return html
|
||||
},
|
||||
}
|
||||
}
|
||||
21
packages/richtext-lexical/src/field/Diff/htmlDiff/LICENSE.MD
Normal file
21
packages/richtext-lexical/src/field/Diff/htmlDiff/LICENSE.MD
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Arman Tang
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
90
packages/richtext-lexical/src/field/Diff/htmlDiff/index.scss
Normal file
90
packages/richtext-lexical/src/field/Diff/htmlDiff/index.scss
Normal file
@@ -0,0 +1,90 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
@import '../colors.scss';
|
||||
|
||||
@layer payload-default {
|
||||
.lexical-diff__diff-container {
|
||||
font-family: var(--font-serif);
|
||||
font-size: base(0.8);
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
// Apply background color to parents that have children with diffs
|
||||
p,
|
||||
li,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
blockquote,
|
||||
h6 {
|
||||
&:has([data-match-type='create']) {
|
||||
background-color: var(--diff-create-parent-bg);
|
||||
color: var(--diff-create-parent-color);
|
||||
}
|
||||
|
||||
&:has([data-match-type='delete']) {
|
||||
background-color: var(--diff-delete-parent-bg);
|
||||
color: var(--diff-delete-parent-color);
|
||||
}
|
||||
}
|
||||
|
||||
li::marker {
|
||||
color: var(--theme-text);
|
||||
}
|
||||
|
||||
[data-match-type='delete'] {
|
||||
color: var(--diff-delete-pill-color);
|
||||
text-decoration-color: var(--diff-delete-pill-color);
|
||||
text-decoration-line: line-through;
|
||||
background-color: var(--diff-delete-pill-bg);
|
||||
border-radius: 4px;
|
||||
text-decoration-thickness: 1px;
|
||||
}
|
||||
|
||||
a[data-match-type='delete'] {
|
||||
color: var(--diff-delete-link-color);
|
||||
}
|
||||
|
||||
a[data-match-type='create']:not(img) {
|
||||
// :not(img) required to increase specificity
|
||||
color: var(--diff-create-link-color);
|
||||
}
|
||||
|
||||
[data-match-type='create']:not(img) {
|
||||
background-color: var(--diff-create-pill-bg);
|
||||
color: var(--diff-create-pill-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.html-diff {
|
||||
&-create-inline-wrapper,
|
||||
&-delete-inline-wrapper {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
&-create-block-wrapper,
|
||||
&-delete-block-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&-create-inline-wrapper,
|
||||
&-delete-inline-wrapper,
|
||||
&-create-block-wrapper,
|
||||
&-delete-block-wrapper {
|
||||
position: relative;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
659
packages/richtext-lexical/src/field/Diff/htmlDiff/index.ts
Normal file
659
packages/richtext-lexical/src/field/Diff/htmlDiff/index.ts
Normal file
@@ -0,0 +1,659 @@
|
||||
// Taken and modified from https://github.com/Arman19941113/html-diff/blob/master/packages/html-diff/src/index.ts
|
||||
|
||||
interface MatchedBlock {
|
||||
newEnd: number
|
||||
newStart: number
|
||||
oldEnd: number
|
||||
oldStart: number
|
||||
size: number
|
||||
}
|
||||
|
||||
interface Operation {
|
||||
/**
|
||||
* Index of entry in tokenized token list
|
||||
*/
|
||||
newEnd: number
|
||||
newStart: number
|
||||
oldEnd: number
|
||||
oldStart: number
|
||||
type: 'create' | 'delete' | 'equal' | 'replace'
|
||||
}
|
||||
|
||||
type BaseOpType = 'create' | 'delete'
|
||||
|
||||
interface HtmlDiffConfig {
|
||||
classNames: {
|
||||
createBlock: string
|
||||
createInline: string
|
||||
deleteBlock: string
|
||||
deleteInline: string
|
||||
}
|
||||
greedyBoundary: number
|
||||
greedyMatch: boolean
|
||||
minMatchedSize: number
|
||||
}
|
||||
|
||||
export interface HtmlDiffOptions {
|
||||
/**
|
||||
* The classNames for wrapper DOM.
|
||||
* Use this to configure your own styles without importing the built-in CSS file
|
||||
*/
|
||||
classNames?: Partial<{
|
||||
createBlock?: string
|
||||
createInline?: string
|
||||
deleteBlock?: string
|
||||
deleteInline?: string
|
||||
}>
|
||||
/**
|
||||
* @defaultValue 1000
|
||||
*/
|
||||
greedyBoundary?: number
|
||||
/**
|
||||
* When greedyMatch is enabled, if the length of the sub-tokens exceeds greedyBoundary,
|
||||
* we will use the matched sub-tokens that are sufficiently good, even if they are not optimal, to enhance performance.
|
||||
* @defaultValue true
|
||||
*/
|
||||
greedyMatch?: boolean
|
||||
/**
|
||||
* Determine the minimum threshold for calculating common sub-tokens.
|
||||
* You may adjust it to a value larger than 2, but not lower, due to the potential inclusion of HTML tags in the count.
|
||||
* @defaultValue 2
|
||||
*/
|
||||
minMatchedSize?: number
|
||||
}
|
||||
|
||||
// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/optimal-quantifier-concatenation
|
||||
const htmlStartTagReg = /^<(?<name>[^\s/>]+)[^>]*>$/
|
||||
// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/optimal-quantifier-concatenation
|
||||
const htmlTagWithNameReg = /^<(?<isEnd>\/)?(?<name>[^\s>]+)[^>]*>$/
|
||||
|
||||
const htmlTagReg = /^<[^>]+>/
|
||||
const htmlImgTagReg = /^<img[^>]*>$/
|
||||
const htmlVideoTagReg = /^<video[^>]*>.*?<\/video>$/ms
|
||||
|
||||
export class HtmlDiff {
|
||||
private readonly config: HtmlDiffConfig
|
||||
private leastCommonLength: number = Infinity
|
||||
private readonly matchedBlockList: MatchedBlock[] = []
|
||||
private readonly newTokens: string[] = []
|
||||
private readonly oldTokens: string[] = []
|
||||
private readonly operationList: Operation[] = []
|
||||
private sideBySideContents?: [string, string]
|
||||
private unifiedContent?: string
|
||||
|
||||
constructor(
|
||||
oldHtml: string,
|
||||
newHtml: string,
|
||||
{
|
||||
classNames = {
|
||||
createBlock: 'html-diff-create-block-wrapper',
|
||||
createInline: 'html-diff-create-inline-wrapper',
|
||||
deleteBlock: 'html-diff-delete-block-wrapper',
|
||||
deleteInline: 'html-diff-delete-inline-wrapper',
|
||||
},
|
||||
greedyBoundary = 1000,
|
||||
greedyMatch = true,
|
||||
minMatchedSize = 2,
|
||||
}: HtmlDiffOptions = {},
|
||||
) {
|
||||
// init config
|
||||
this.config = {
|
||||
classNames: {
|
||||
createBlock: 'html-diff-create-block-wrapper',
|
||||
createInline: 'html-diff-create-inline-wrapper',
|
||||
deleteBlock: 'html-diff-delete-block-wrapper',
|
||||
deleteInline: 'html-diff-delete-inline-wrapper',
|
||||
...classNames,
|
||||
},
|
||||
greedyBoundary,
|
||||
greedyMatch,
|
||||
minMatchedSize,
|
||||
}
|
||||
// white space is junk
|
||||
oldHtml = oldHtml.trim()
|
||||
newHtml = newHtml.trim()
|
||||
|
||||
// no need to diff
|
||||
if (oldHtml === newHtml) {
|
||||
this.unifiedContent = oldHtml
|
||||
let equalSequence = 0
|
||||
// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/optimal-quantifier-concatenation
|
||||
const content = oldHtml.replace(/<([^\s/>]+)[^>]*>/g, (match: string, name: string) => {
|
||||
const tagNameLength = name.length + 1
|
||||
return `${match.slice(0, tagNameLength)} data-seq="${++equalSequence}"${match.slice(tagNameLength)}`
|
||||
})
|
||||
this.sideBySideContents = [content, content]
|
||||
return
|
||||
}
|
||||
|
||||
// step1: split HTML to tokens(atomic tokens)
|
||||
this.oldTokens = this.tokenize(oldHtml)
|
||||
this.newTokens = this.tokenize(newHtml)
|
||||
// step2: find matched blocks
|
||||
this.matchedBlockList = this.getMatchedBlockList()
|
||||
|
||||
// step3: generate operation list
|
||||
this.operationList = this.getOperationList()
|
||||
}
|
||||
|
||||
// Find the longest matched block between tokens
|
||||
private computeBestMatchedBlock(
|
||||
oldStart: number,
|
||||
oldEnd: number,
|
||||
newStart: number,
|
||||
newEnd: number,
|
||||
): MatchedBlock | null {
|
||||
let bestMatchedBlock = null
|
||||
for (let i = oldStart; i < oldEnd; i++) {
|
||||
const len = Math.min(oldEnd - i, newEnd - newStart)
|
||||
const ret = this.slideBestMatchedBlock(i, newStart, len)
|
||||
if (ret && (!bestMatchedBlock || ret.size > bestMatchedBlock.size)) {
|
||||
bestMatchedBlock = ret
|
||||
if (ret.size > this.leastCommonLength) {
|
||||
return bestMatchedBlock
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let j = newStart; j < newEnd; j++) {
|
||||
const len = Math.min(oldEnd - oldStart, newEnd - j)
|
||||
const ret = this.slideBestMatchedBlock(oldStart, j, len)
|
||||
if (ret && (!bestMatchedBlock || ret.size > bestMatchedBlock.size)) {
|
||||
bestMatchedBlock = ret
|
||||
if (ret.size > this.leastCommonLength) {
|
||||
return bestMatchedBlock
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestMatchedBlock
|
||||
}
|
||||
|
||||
private computeMatchedBlockList(
|
||||
oldStart: number,
|
||||
oldEnd: number,
|
||||
newStart: number,
|
||||
newEnd: number,
|
||||
matchedBlockList: MatchedBlock[] = [],
|
||||
): MatchedBlock[] {
|
||||
const matchBlock = this.computeBestMatchedBlock(oldStart, oldEnd, newStart, newEnd)
|
||||
|
||||
if (!matchBlock) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (oldStart < matchBlock.oldStart && newStart < matchBlock.newStart) {
|
||||
this.computeMatchedBlockList(
|
||||
oldStart,
|
||||
matchBlock.oldStart,
|
||||
newStart,
|
||||
matchBlock.newStart,
|
||||
matchedBlockList,
|
||||
)
|
||||
}
|
||||
matchedBlockList.push(matchBlock)
|
||||
if (oldEnd > matchBlock.oldEnd && newEnd > matchBlock.newEnd) {
|
||||
this.computeMatchedBlockList(
|
||||
matchBlock.oldEnd,
|
||||
oldEnd,
|
||||
matchBlock.newEnd,
|
||||
newEnd,
|
||||
matchedBlockList,
|
||||
)
|
||||
}
|
||||
return matchedBlockList
|
||||
}
|
||||
|
||||
private dressUpBlockTag(type: BaseOpType, token: string): string {
|
||||
if (type === 'create') {
|
||||
return `<div class="${this.config.classNames.createBlock}">${token}</div>`
|
||||
}
|
||||
if (type === 'delete') {
|
||||
return `<div class="${this.config.classNames.deleteBlock}">${token}</div>`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private dressUpDiffContent(type: BaseOpType, tokens: string[]): string {
|
||||
const tokensLength = tokens.length
|
||||
if (!tokensLength) {
|
||||
return ''
|
||||
}
|
||||
|
||||
let result = ''
|
||||
let textStartIndex = 0
|
||||
let i = -1
|
||||
for (const token of tokens) {
|
||||
i++
|
||||
|
||||
// If this is true, this HTML should be diffed as well - not just its children
|
||||
const isMatchElement = token.includes('data-enable-match="true"')
|
||||
const isMatchExplicitlyDisabled = token.includes('data-enable-match="false"')
|
||||
const isHtmlTag = !!token.match(htmlTagReg)?.length
|
||||
|
||||
if (isMatchExplicitlyDisabled) {
|
||||
textStartIndex = i + 1
|
||||
result += token
|
||||
}
|
||||
// this token is html tag
|
||||
else if (!isMatchElement && isHtmlTag) {
|
||||
// handle text tokens before
|
||||
if (i > textStartIndex) {
|
||||
result += this.dressUpText(type, tokens.slice(textStartIndex, i))
|
||||
}
|
||||
// handle this tag
|
||||
textStartIndex = i + 1
|
||||
if (token.match(htmlVideoTagReg)) {
|
||||
result += this.dressUpBlockTag(type, token)
|
||||
} /* else if ([htmlImgTagReg].some((item) => token.match(item))) {
|
||||
result += this.dressUpInlineTag(type, token)
|
||||
}*/ else {
|
||||
result += token
|
||||
}
|
||||
} else if (isMatchElement && isHtmlTag) {
|
||||
// handle text tokens before
|
||||
if (i > textStartIndex) {
|
||||
result += this.dressUpText(type, tokens.slice(textStartIndex, i))
|
||||
}
|
||||
|
||||
// handle this tag
|
||||
textStartIndex = i + 1
|
||||
// Add data-match-type to the tag that can be styled
|
||||
const newToken = this.dressupMatchEnabledHtmlTag(type, token)
|
||||
|
||||
result += newToken
|
||||
}
|
||||
}
|
||||
if (textStartIndex < tokensLength) {
|
||||
result += this.dressUpText(type, tokens.slice(textStartIndex))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private dressUpInlineTag(type: BaseOpType, token: string): string {
|
||||
if (type === 'create') {
|
||||
return `<span class="${this.config.classNames.createInline}">${token}</span>`
|
||||
}
|
||||
if (type === 'delete') {
|
||||
return `<span class="${this.config.classNames.deleteInline}">${token}</span>`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private dressupMatchEnabledHtmlTag(type: BaseOpType, token: string): string {
|
||||
// token is a single html tag, e.g. <a data-enable-match="true" href="https://2" rel=undefined target=undefined>
|
||||
// add data-match-type to the tag
|
||||
const tagName = token.match(htmlStartTagReg)?.groups?.name
|
||||
if (!tagName) {
|
||||
return token
|
||||
}
|
||||
const tagNameLength = tagName.length + 1
|
||||
const matchType = type === 'create' ? 'create' : 'delete'
|
||||
return `${token.slice(0, tagNameLength)} data-match-type="${matchType}"${token.slice(
|
||||
tagNameLength,
|
||||
token.length,
|
||||
)}`
|
||||
}
|
||||
|
||||
private dressUpText(type: BaseOpType, tokens: string[]): string {
|
||||
const text = tokens.join('')
|
||||
if (!text.trim()) {
|
||||
return ''
|
||||
}
|
||||
if (type === 'create') {
|
||||
return `<span data-match-type="create">${text}</span>`
|
||||
}
|
||||
if (type === 'delete') {
|
||||
return `<span data-match-type="delete">${text}</span>`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a list of token entries that are matched between the old and new HTML. This list will not
|
||||
* include token ranges that differ.
|
||||
*/
|
||||
private getMatchedBlockList(): MatchedBlock[] {
|
||||
const n1 = this.oldTokens.length
|
||||
const n2 = this.newTokens.length
|
||||
|
||||
// 1. sync from start
|
||||
let start: MatchedBlock | null = null
|
||||
let i = 0
|
||||
while (i < n1 && i < n2 && this.oldTokens[i] === this.newTokens[i]) {
|
||||
i++
|
||||
}
|
||||
if (i >= this.config.minMatchedSize) {
|
||||
start = {
|
||||
newEnd: i,
|
||||
newStart: 0,
|
||||
oldEnd: i,
|
||||
oldStart: 0,
|
||||
size: i,
|
||||
}
|
||||
}
|
||||
|
||||
// 2. sync from end
|
||||
let end: MatchedBlock | null = null
|
||||
let e1 = n1 - 1
|
||||
let e2 = n2 - 1
|
||||
while (i <= e1 && i <= e2 && this.oldTokens[e1] === this.newTokens[e2]) {
|
||||
e1--
|
||||
e2--
|
||||
}
|
||||
const size = n1 - 1 - e1
|
||||
if (size >= this.config.minMatchedSize) {
|
||||
end = {
|
||||
newEnd: n2,
|
||||
newStart: e2 + 1,
|
||||
oldEnd: n1,
|
||||
oldStart: e1 + 1,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. handle rest
|
||||
const oldStart = start ? i : 0
|
||||
const oldEnd = end ? e1 + 1 : n1
|
||||
const newStart = start ? i : 0
|
||||
const newEnd = end ? e2 + 1 : n2
|
||||
// optimize for large tokens
|
||||
if (this.config.greedyMatch) {
|
||||
const commonLength = Math.min(oldEnd - oldStart, newEnd - newStart)
|
||||
if (commonLength > this.config.greedyBoundary) {
|
||||
this.leastCommonLength = Math.floor(commonLength / 3)
|
||||
}
|
||||
}
|
||||
const ret = this.computeMatchedBlockList(oldStart, oldEnd, newStart, newEnd)
|
||||
if (start) {
|
||||
ret.unshift(start)
|
||||
}
|
||||
if (end) {
|
||||
ret.push(end)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// Generate operation list by matchedBlockList
|
||||
private getOperationList(): Operation[] {
|
||||
const operationList: Operation[] = []
|
||||
let walkIndexOld = 0
|
||||
let walkIndexNew = 0
|
||||
for (const matchedBlock of this.matchedBlockList) {
|
||||
const isOldStartIndexMatched = walkIndexOld === matchedBlock.oldStart
|
||||
const isNewStartIndexMatched = walkIndexNew === matchedBlock.newStart
|
||||
const operationBase = {
|
||||
newEnd: matchedBlock.newStart,
|
||||
newStart: walkIndexNew,
|
||||
oldEnd: matchedBlock.oldStart,
|
||||
oldStart: walkIndexOld,
|
||||
}
|
||||
if (!isOldStartIndexMatched && !isNewStartIndexMatched) {
|
||||
operationList.push(Object.assign(operationBase, { type: 'replace' as const }))
|
||||
} else if (isOldStartIndexMatched && !isNewStartIndexMatched) {
|
||||
operationList.push(Object.assign(operationBase, { type: 'create' as const }))
|
||||
} else if (!isOldStartIndexMatched && isNewStartIndexMatched) {
|
||||
operationList.push(Object.assign(operationBase, { type: 'delete' as const }))
|
||||
}
|
||||
|
||||
operationList.push({
|
||||
type: 'equal',
|
||||
newEnd: matchedBlock.newEnd,
|
||||
newStart: matchedBlock.newStart,
|
||||
oldEnd: matchedBlock.oldEnd,
|
||||
oldStart: matchedBlock.oldStart,
|
||||
})
|
||||
walkIndexOld = matchedBlock.oldEnd
|
||||
walkIndexNew = matchedBlock.newEnd
|
||||
}
|
||||
// handle the tail content
|
||||
const maxIndexOld = this.oldTokens.length
|
||||
const maxIndexNew = this.newTokens.length
|
||||
const tailOperationBase = {
|
||||
newEnd: maxIndexNew,
|
||||
newStart: walkIndexNew,
|
||||
oldEnd: maxIndexOld,
|
||||
oldStart: walkIndexOld,
|
||||
}
|
||||
const isOldFinished = walkIndexOld === maxIndexOld
|
||||
const isNewFinished = walkIndexNew === maxIndexNew
|
||||
if (!isOldFinished && !isNewFinished) {
|
||||
operationList.push(Object.assign(tailOperationBase, { type: 'replace' as const }))
|
||||
} else if (isOldFinished && !isNewFinished) {
|
||||
operationList.push(Object.assign(tailOperationBase, { type: 'create' as const }))
|
||||
} else if (!isOldFinished && isNewFinished) {
|
||||
operationList.push(Object.assign(tailOperationBase, { type: 'delete' as const }))
|
||||
}
|
||||
return operationList
|
||||
}
|
||||
|
||||
private slideBestMatchedBlock(addA: number, addB: number, len: number): MatchedBlock | null {
|
||||
let maxSize = 0
|
||||
let bestMatchedBlock: MatchedBlock | null = null
|
||||
|
||||
let continuousSize = 0
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (this.oldTokens[addA + i] === this.newTokens[addB + i]) {
|
||||
continuousSize++
|
||||
} else {
|
||||
continuousSize = 0
|
||||
}
|
||||
if (continuousSize > maxSize) {
|
||||
maxSize = continuousSize
|
||||
bestMatchedBlock = {
|
||||
newEnd: addB + i + 1,
|
||||
newStart: addB + i - continuousSize + 1,
|
||||
oldEnd: addA + i + 1,
|
||||
oldStart: addA + i - continuousSize + 1,
|
||||
size: continuousSize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxSize >= this.config.minMatchedSize ? bestMatchedBlock : null
|
||||
}
|
||||
|
||||
/**
|
||||
* convert HTML to tokens
|
||||
* @example
|
||||
* tokenize("<a> Hello World </a>")
|
||||
* ["<a>"," ", "Hello", " ", "World", " ", "</a>"]
|
||||
*/
|
||||
private tokenize(html: string): string[] {
|
||||
// atomic token: html tag、continuous numbers or letters、blank spaces、other symbol
|
||||
return (
|
||||
html.match(
|
||||
/<picture[^>]*>.*?<\/picture>|<video[^>]*>.*?<\/video>|<[^>]+>|\w+\b|\s+|[^<>\w]/gs,
|
||||
) || []
|
||||
)
|
||||
}
|
||||
|
||||
public getSideBySideContents(): string[] {
|
||||
if (this.sideBySideContents !== undefined) {
|
||||
return this.sideBySideContents
|
||||
}
|
||||
|
||||
let oldHtml = ''
|
||||
let newHtml = ''
|
||||
let equalSequence = 0
|
||||
this.operationList.forEach((operation) => {
|
||||
switch (operation.type) {
|
||||
case 'create': {
|
||||
newHtml += this.dressUpDiffContent(
|
||||
'create',
|
||||
this.newTokens.slice(operation.newStart, operation.newEnd),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
const deletedTokens = this.oldTokens.slice(operation.oldStart, operation.oldEnd)
|
||||
oldHtml += this.dressUpDiffContent('delete', deletedTokens)
|
||||
break
|
||||
}
|
||||
case 'equal': {
|
||||
const equalTokens = this.newTokens.slice(operation.newStart, operation.newEnd)
|
||||
let equalString = ''
|
||||
for (const token of equalTokens) {
|
||||
// find start tags and add data-seq to enable sync scroll
|
||||
const startTagMatch = token.match(htmlStartTagReg)
|
||||
if (startTagMatch) {
|
||||
equalSequence += 1
|
||||
const tagNameLength = (startTagMatch?.groups?.name?.length ?? 0) + 1
|
||||
equalString += `${token.slice(0, tagNameLength)} data-seq="${equalSequence}"${token.slice(tagNameLength)}`
|
||||
} else {
|
||||
equalString += token
|
||||
}
|
||||
}
|
||||
oldHtml += equalString
|
||||
newHtml += equalString
|
||||
break
|
||||
}
|
||||
|
||||
case 'replace': {
|
||||
oldHtml += this.dressUpDiffContent(
|
||||
'delete',
|
||||
this.oldTokens.slice(operation.oldStart, operation.oldEnd),
|
||||
)
|
||||
newHtml += this.dressUpDiffContent(
|
||||
'create',
|
||||
this.newTokens.slice(operation.newStart, operation.newEnd),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
console.error('Richtext diff error - invalid operation: ' + String(operation.type))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result: [string, string] = [oldHtml, newHtml]
|
||||
this.sideBySideContents = result
|
||||
return result
|
||||
}
|
||||
|
||||
public getUnifiedContent(): string {
|
||||
if (this.unifiedContent !== undefined) {
|
||||
return this.unifiedContent
|
||||
}
|
||||
|
||||
let result = ''
|
||||
this.operationList.forEach((operation) => {
|
||||
switch (operation.type) {
|
||||
case 'create': {
|
||||
result += this.dressUpDiffContent(
|
||||
'create',
|
||||
this.newTokens.slice(operation.newStart, operation.newEnd),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
result += this.dressUpDiffContent(
|
||||
'delete',
|
||||
this.oldTokens.slice(operation.oldStart, operation.oldEnd),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case 'equal': {
|
||||
for (const token of this.newTokens.slice(operation.newStart, operation.newEnd)) {
|
||||
result += token
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'replace': {
|
||||
// handle specially tag replace
|
||||
const olds = this.oldTokens.slice(operation.oldStart, operation.oldEnd)
|
||||
const news = this.newTokens.slice(operation.newStart, operation.newEnd)
|
||||
if (
|
||||
olds.length === 1 &&
|
||||
news.length === 1 &&
|
||||
olds[0]?.match(htmlTagReg) &&
|
||||
news[0]?.match(htmlTagReg)
|
||||
) {
|
||||
result += news[0]
|
||||
break
|
||||
}
|
||||
|
||||
const deletedTokens: string[] = []
|
||||
const createdTokens: string[] = []
|
||||
let createIndex = operation.newStart
|
||||
for (
|
||||
let deleteIndex = operation.oldStart;
|
||||
deleteIndex < operation.oldEnd;
|
||||
deleteIndex++
|
||||
) {
|
||||
const deletedToken = this.oldTokens[deleteIndex]
|
||||
|
||||
if (!deletedToken) {
|
||||
continue
|
||||
}
|
||||
|
||||
const matchTagResultD = deletedToken?.match(htmlTagWithNameReg)
|
||||
if (matchTagResultD) {
|
||||
// handle replaced tag token
|
||||
|
||||
// skip special tag
|
||||
if ([htmlImgTagReg, htmlVideoTagReg].some((item) => deletedToken?.match(item))) {
|
||||
deletedTokens.push(deletedToken)
|
||||
continue
|
||||
}
|
||||
|
||||
// handle normal tag
|
||||
result += this.dressUpDiffContent('delete', deletedTokens)
|
||||
deletedTokens.splice(0)
|
||||
let isTagInNewFind = false
|
||||
for (
|
||||
let tempCreateIndex = createIndex;
|
||||
tempCreateIndex < operation.newEnd;
|
||||
tempCreateIndex++
|
||||
) {
|
||||
const createdToken = this.newTokens[tempCreateIndex]
|
||||
if (!createdToken) {
|
||||
continue
|
||||
}
|
||||
const matchTagResultC = createdToken?.match(htmlTagWithNameReg)
|
||||
if (
|
||||
matchTagResultC &&
|
||||
matchTagResultC.groups?.name === matchTagResultD.groups?.name &&
|
||||
matchTagResultC.groups?.isEnd === matchTagResultD.groups?.isEnd
|
||||
) {
|
||||
// find first matched tag, but not maybe the expected tag(to optimize)
|
||||
isTagInNewFind = true
|
||||
result += this.dressUpDiffContent('create', createdTokens)
|
||||
result += createdToken
|
||||
createdTokens.splice(0)
|
||||
createIndex = tempCreateIndex + 1
|
||||
break
|
||||
} else {
|
||||
createdTokens.push(createdToken)
|
||||
}
|
||||
}
|
||||
if (!isTagInNewFind) {
|
||||
result += deletedToken
|
||||
createdTokens.splice(0)
|
||||
}
|
||||
} else {
|
||||
// token is not a tag
|
||||
deletedTokens.push(deletedToken)
|
||||
}
|
||||
}
|
||||
if (createIndex < operation.newEnd) {
|
||||
createdTokens.push(...this.newTokens.slice(createIndex, operation.newEnd))
|
||||
}
|
||||
result += this.dressUpDiffContent('delete', deletedTokens)
|
||||
result += this.dressUpDiffContent('create', createdTokens)
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
console.error('Richtext diff error - invalid operation: ' + String(operation.type))
|
||||
}
|
||||
}
|
||||
})
|
||||
this.unifiedContent = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
95
packages/richtext-lexical/src/field/Diff/index.scss
Normal file
95
packages/richtext-lexical/src/field/Diff/index.scss
Normal file
@@ -0,0 +1,95 @@
|
||||
@import '../../scss/styles.scss';
|
||||
@import './colors.scss';
|
||||
|
||||
@layer payload-default {
|
||||
.lexical-diff {
|
||||
&__diff-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
font-size: base(0.8);
|
||||
margin-block: base(0.8);
|
||||
margin-inline: base(0.2);
|
||||
border-inline-start-color: var(--theme-elevation-150);
|
||||
border-inline-start-width: base(0.2);
|
||||
border-inline-start-style: solid;
|
||||
padding-inline-start: base(0.6);
|
||||
padding-block: base(0.2);
|
||||
|
||||
&:has([data-match-type='create']) {
|
||||
border-inline-start-color: var(--theme-success-150);
|
||||
}
|
||||
|
||||
&:has([data-match-type='delete']) {
|
||||
border-inline-start-color: var(--theme-error-150);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
border-bottom: 1px dotted;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
padding: base(0.7) 0px base(0.55);
|
||||
line-height: base(1.2);
|
||||
font-weight: 600;
|
||||
font-size: base(1.4);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
h2 {
|
||||
padding: base(0.7) 0px base(0.5);
|
||||
line-height: base(1);
|
||||
font-weight: 600;
|
||||
font-size: base(1.25);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
h3 {
|
||||
padding: base(0.65) 0px base(0.45);
|
||||
line-height: base(0.9);
|
||||
font-weight: 600;
|
||||
font-size: base(1.1);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
h4 {
|
||||
padding: base(0.65) 0px base(0.4);
|
||||
line-height: base(0.7);
|
||||
font-weight: 600;
|
||||
font-size: base(1);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
h5 {
|
||||
padding: base(0.65) 0px base(0.35);
|
||||
line-height: base(0.5);
|
||||
font-weight: 600;
|
||||
font-size: base(0.9);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
h6 {
|
||||
padding: base(0.65) 0px base(0.35);
|
||||
line-height: base(0.5);
|
||||
font-weight: 600;
|
||||
font-size: base(0.8);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
p {
|
||||
padding: base(0.4) 0 base(0.4);
|
||||
|
||||
// First paraagraph has no top padding
|
||||
&:first-child {
|
||||
padding: 0 0 base(0.4);
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-top: base(0.4);
|
||||
padding-bottom: base(0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
packages/richtext-lexical/src/field/Diff/index.tsx
Normal file
74
packages/richtext-lexical/src/field/Diff/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { SerializedEditorState } from 'lexical'
|
||||
import type { RichTextFieldDiffServerComponent } from 'payload'
|
||||
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import { FieldDiffLabel } from '@payloadcms/ui/rsc'
|
||||
import React from 'react'
|
||||
|
||||
import './htmlDiff/index.scss'
|
||||
import './index.scss'
|
||||
|
||||
import type { HTMLConvertersFunctionAsync } from '../../features/converters/lexicalToHtml/async/types.js'
|
||||
|
||||
import { convertLexicalToHTMLAsync } from '../../features/converters/lexicalToHtml/async/index.js'
|
||||
import { getPayloadPopulateFn } from '../../features/converters/utilities/payloadPopulateFn.js'
|
||||
import { LinkDiffHTMLConverterAsync } from './converters/link.js'
|
||||
import { ListItemDiffHTMLConverterAsync } from './converters/listitem/index.js'
|
||||
import { RelationshipDiffHTMLConverterAsync } from './converters/relationship/index.js'
|
||||
import { UnknownDiffHTMLConverterAsync } from './converters/unknown/index.js'
|
||||
import { UploadDiffHTMLConverterAsync } from './converters/upload/index.js'
|
||||
import { HtmlDiff } from './htmlDiff/index.js'
|
||||
const baseClass = 'lexical-diff'
|
||||
|
||||
export const LexicalDiffComponent: RichTextFieldDiffServerComponent = async (args) => {
|
||||
const { comparisonValue, field, i18n, locale, versionValue } = args
|
||||
|
||||
const converters: HTMLConvertersFunctionAsync = ({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
...LinkDiffHTMLConverterAsync({}),
|
||||
...ListItemDiffHTMLConverterAsync,
|
||||
...UploadDiffHTMLConverterAsync({ i18n: args.i18n, req: args.req }),
|
||||
...RelationshipDiffHTMLConverterAsync({ i18n: args.i18n, req: args.req }),
|
||||
...UnknownDiffHTMLConverterAsync({ i18n: args.i18n, req: args.req }),
|
||||
})
|
||||
|
||||
const payloadPopulateFn = await getPayloadPopulateFn({
|
||||
currentDepth: 0,
|
||||
depth: 1,
|
||||
req: args.req,
|
||||
})
|
||||
const comparisonHTML = await convertLexicalToHTMLAsync({
|
||||
converters,
|
||||
data: comparisonValue as SerializedEditorState,
|
||||
populate: payloadPopulateFn,
|
||||
})
|
||||
|
||||
const versionHTML = await convertLexicalToHTMLAsync({
|
||||
converters,
|
||||
data: versionValue as SerializedEditorState,
|
||||
populate: payloadPopulateFn,
|
||||
})
|
||||
|
||||
const diffHTML = new HtmlDiff(comparisonHTML, versionHTML)
|
||||
|
||||
const [oldHTML, newHTML] = diffHTML.getSideBySideContents()
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<FieldDiffLabel>
|
||||
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
|
||||
{'label' in field &&
|
||||
typeof field.label !== 'function' &&
|
||||
getTranslation(field.label || '', i18n)}
|
||||
</FieldDiffLabel>
|
||||
<div className={`${baseClass}__diff-container`}>
|
||||
{oldHTML && (
|
||||
<div className={`${baseClass}__diff-old`} dangerouslySetInnerHTML={{ __html: oldHTML }} />
|
||||
)}
|
||||
{newHTML && (
|
||||
<div className={`${baseClass}__diff-new`} dangerouslySetInnerHTML={{ __html: newHTML }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -101,6 +101,13 @@ export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapter
|
||||
sanitizedEditorConfig: finalSanitizedEditorConfig,
|
||||
},
|
||||
},
|
||||
DiffComponent: {
|
||||
path: '@payloadcms/richtext-lexical/rsc#LexicalDiffComponent',
|
||||
serverProps: {
|
||||
admin: args?.admin,
|
||||
sanitizedEditorConfig: finalSanitizedEditorConfig,
|
||||
},
|
||||
},
|
||||
editorConfig: finalSanitizedEditorConfig,
|
||||
features,
|
||||
FieldComponent: {
|
||||
@@ -896,9 +903,10 @@ export {
|
||||
} from './features/converters/lexicalToHtml_deprecated/index.js'
|
||||
export { convertLexicalToMarkdown } from './features/converters/lexicalToMarkdown/index.js'
|
||||
export { convertMarkdownToLexical } from './features/converters/markdownToLexical/index.js'
|
||||
|
||||
export { getPayloadPopulateFn } from './features/converters/utilities/payloadPopulateFn.js'
|
||||
|
||||
export { getRestPopulateFn } from './features/converters/utilities/restPopulateFn.js'
|
||||
export { DebugJsxConverterFeature } from './features/debug/jsxConverter/server/index.js'
|
||||
export { TestRecorderFeature } from './features/debug/testRecorder/server/index.js'
|
||||
export { TreeViewFeature } from './features/debug/treeView/server/index.js'
|
||||
export { EXPERIMENTAL_TableFeature } from './features/experimental_table/server/index.js'
|
||||
|
||||
@@ -10,6 +10,7 @@ export const getGenerateImportMap =
|
||||
({ addToImportMap, baseDir, config, importMap, imports }) => {
|
||||
addToImportMap('@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell')
|
||||
addToImportMap('@payloadcms/richtext-lexical/rsc#RscEntryLexicalField')
|
||||
addToImportMap('@payloadcms/richtext-lexical/rsc#LexicalDiffComponent')
|
||||
|
||||
// iterate just through args.resolvedFeatureMap.values()
|
||||
for (const resolvedFeature of args.resolvedFeatureMap.values()) {
|
||||
|
||||
@@ -12,6 +12,7 @@ type Args = {
|
||||
req: PayloadRequest
|
||||
}) => boolean | Promise<boolean>
|
||||
acl: 'private' | 'public-read'
|
||||
routerInputConfig?: FileRouterInputConfig
|
||||
token?: string
|
||||
}
|
||||
|
||||
@@ -22,18 +23,24 @@ import type { FileRouter } from 'uploadthing/server'
|
||||
import { createRouteHandler } from 'uploadthing/next'
|
||||
import { createUploadthing } from 'uploadthing/server'
|
||||
|
||||
import type { FileRouterInputConfig } from './index.js'
|
||||
|
||||
export const getClientUploadRoute = ({
|
||||
access = defaultAccess,
|
||||
acl,
|
||||
routerInputConfig = {},
|
||||
token,
|
||||
}: Args): PayloadHandler => {
|
||||
const f = createUploadthing()
|
||||
|
||||
const uploadRouter = {
|
||||
uploader: f({
|
||||
...routerInputConfig,
|
||||
blob: {
|
||||
acl,
|
||||
maxFileCount: 1,
|
||||
maxFileSize: '512MB',
|
||||
...('blob' in routerInputConfig ? routerInputConfig.blob : {}),
|
||||
},
|
||||
})
|
||||
.middleware(async ({ req: rawReq }) => {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type {
|
||||
Adapter,
|
||||
ClientUploadsConfig,
|
||||
ClientUploadsAccess,
|
||||
PluginOptions as CloudStoragePluginOptions,
|
||||
CollectionOptions,
|
||||
GeneratedAdapter,
|
||||
} from '@payloadcms/plugin-cloud-storage/types'
|
||||
import type { Config, Field, Plugin, UploadCollectionSlug } from 'payload'
|
||||
import type { createUploadthing } from 'uploadthing/server'
|
||||
import type { UTApiOptions } from 'uploadthing/types'
|
||||
|
||||
import { cloudStoragePlugin } from '@payloadcms/plugin-cloud-storage'
|
||||
import { initClientUploads } from '@payloadcms/plugin-cloud-storage/utilities'
|
||||
import { createRouteHandler } from 'uploadthing/next'
|
||||
import { createUploadthing, UTApi } from 'uploadthing/server'
|
||||
import { UTApi } from 'uploadthing/server'
|
||||
|
||||
import { generateURL } from './generateURL.js'
|
||||
import { getClientUploadRoute } from './getClientUploadRoute.js'
|
||||
@@ -19,11 +19,18 @@ import { getHandleDelete } from './handleDelete.js'
|
||||
import { getHandleUpload } from './handleUpload.js'
|
||||
import { getHandler } from './staticHandler.js'
|
||||
|
||||
export type FileRouterInputConfig = Parameters<ReturnType<typeof createUploadthing>>[0]
|
||||
|
||||
export type UploadthingStorageOptions = {
|
||||
/**
|
||||
* Do uploads directly on the client, to bypass limits on Vercel.
|
||||
*/
|
||||
clientUploads?: ClientUploadsConfig
|
||||
clientUploads?:
|
||||
| {
|
||||
access?: ClientUploadsAccess
|
||||
routerInputConfig?: FileRouterInputConfig
|
||||
}
|
||||
| boolean
|
||||
|
||||
/**
|
||||
* Collection options to apply the adapter to.
|
||||
@@ -69,6 +76,10 @@ export const uploadthingStorage: UploadthingPlugin =
|
||||
? uploadthingStorageOptions.clientUploads.access
|
||||
: undefined,
|
||||
acl: uploadthingStorageOptions.options.acl || 'public-read',
|
||||
routerInputConfig:
|
||||
typeof uploadthingStorageOptions.clientUploads === 'object'
|
||||
? uploadthingStorageOptions.clientUploads.routerInputConfig
|
||||
: undefined,
|
||||
token: uploadthingStorageOptions.options.token,
|
||||
}),
|
||||
serverHandlerPath: '/storage-uploadthing-client-upload-route',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
||||
|
||||
import {
|
||||
closestCenter,
|
||||
@@ -18,7 +18,7 @@ import type { Props } from './types.js'
|
||||
export { Props }
|
||||
|
||||
export const DraggableSortable: React.FC<Props> = (props) => {
|
||||
const { children, className, ids, onDragEnd } = props
|
||||
const { children, className, ids, onDragEnd, onDragStart } = props
|
||||
|
||||
const id = useId()
|
||||
|
||||
@@ -58,11 +58,27 @@ export const DraggableSortable: React.FC<Props> = (props) => {
|
||||
[onDragEnd, ids],
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const { active } = event
|
||||
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof onDragStart === 'function') {
|
||||
onDragStart({ id: active.id, event })
|
||||
}
|
||||
},
|
||||
[onDragStart],
|
||||
)
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
id={id}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext items={ids}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DragEndEvent } from '@dnd-kit/core'
|
||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
||||
import type { Ref } from 'react'
|
||||
|
||||
export type Props = {
|
||||
@@ -7,4 +7,5 @@ export type Props = {
|
||||
droppableRef?: Ref<HTMLElement>
|
||||
ids: string[]
|
||||
onDragEnd: (e: { event: DragEndEvent; moveFromIndex: number; moveToIndex: number }) => void
|
||||
onDragStart?: (e: { event: DragStartEvent; id: number | string }) => void
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import './index.scss'
|
||||
|
||||
const baseClass = 'field-diff-label'
|
||||
|
||||
const Label: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||
export const FieldDiffLabel: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
|
||||
<div className={baseClass}>{children}</div>
|
||||
)
|
||||
|
||||
export default Label
|
||||
47
packages/ui/src/elements/Table/OrderableRow.tsx
Normal file
47
packages/ui/src/elements/Table/OrderableRow.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { DraggableSyntheticListeners } from '@dnd-kit/core'
|
||||
import type { Column } from 'payload'
|
||||
import type { HTMLAttributes, Ref } from 'react'
|
||||
|
||||
export type Props = {
|
||||
readonly cellMap: Record<string, number>
|
||||
readonly columns: Column[]
|
||||
readonly dragAttributes?: HTMLAttributes<unknown>
|
||||
readonly dragListeners?: DraggableSyntheticListeners
|
||||
readonly ref?: Ref<HTMLTableRowElement>
|
||||
readonly rowId: number | string
|
||||
} & HTMLAttributes<HTMLTableRowElement>
|
||||
|
||||
export const OrderableRow = ({
|
||||
cellMap,
|
||||
columns,
|
||||
dragAttributes = {},
|
||||
dragListeners = {},
|
||||
rowId,
|
||||
...rest
|
||||
}: Props) => (
|
||||
<tr {...rest}>
|
||||
{columns.map((col, colIndex) => {
|
||||
const { accessor } = col
|
||||
|
||||
// Use the cellMap to find which index in the renderedCells to use
|
||||
const cell = col.renderedCells[cellMap[rowId]]
|
||||
|
||||
// For drag handles, wrap in div with drag attributes
|
||||
if (accessor === '_dragHandle') {
|
||||
return (
|
||||
<td className={`cell-${accessor}`} key={colIndex}>
|
||||
<div {...dragAttributes} {...dragListeners}>
|
||||
{cell}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<td className={`cell-${accessor}`} key={colIndex}>
|
||||
{cell}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
16
packages/ui/src/elements/Table/OrderableRowDragPreview.tsx
Normal file
16
packages/ui/src/elements/Table/OrderableRowDragPreview.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type Props = {
|
||||
readonly children: ReactNode
|
||||
readonly className?: string
|
||||
readonly rowId?: number | string
|
||||
}
|
||||
|
||||
export const OrderableRowDragPreview = ({ children, className, rowId }: Props) =>
|
||||
typeof rowId === 'undefined' ? null : (
|
||||
<div className={className}>
|
||||
<table cellPadding={0} cellSpacing={0}>
|
||||
<tbody>{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
@@ -4,12 +4,15 @@ import type { ClientCollectionConfig, Column, OrderableEndpointBody } from 'payl
|
||||
|
||||
import './index.scss'
|
||||
|
||||
import { DragOverlay } from '@dnd-kit/core'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useListQuery } from '../../providers/ListQuery/index.js'
|
||||
import { DraggableSortableItem } from '../DraggableSortable/DraggableSortableItem/index.js'
|
||||
import { DraggableSortable } from '../DraggableSortable/index.js'
|
||||
import { OrderableRow } from './OrderableRow.js'
|
||||
import { OrderableRowDragPreview } from './OrderableRowDragPreview.js'
|
||||
|
||||
const baseClass = 'table'
|
||||
|
||||
@@ -36,6 +39,8 @@ export const OrderableTable: React.FC<Props> = ({
|
||||
// id -> index for each column
|
||||
const [cellMap, setCellMap] = useState<Record<string, number>>({})
|
||||
|
||||
const [dragActiveRowId, setDragActiveRowId] = useState<number | string | undefined>()
|
||||
|
||||
// Update local data when server data changes
|
||||
useEffect(() => {
|
||||
setLocalData(serverData)
|
||||
@@ -56,10 +61,12 @@ export const OrderableTable: React.FC<Props> = ({
|
||||
const handleDragEnd = async ({ moveFromIndex, moveToIndex }) => {
|
||||
if (query.sort !== orderableFieldName && query.sort !== `-${orderableFieldName}`) {
|
||||
toast.warning('To reorder the rows you must first sort them by the "Order" column')
|
||||
setDragActiveRowId(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (moveFromIndex === moveToIndex) {
|
||||
setDragActiveRowId(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -129,9 +136,15 @@ export const OrderableTable: React.FC<Props> = ({
|
||||
// Rollback to previous state if the request fails
|
||||
setLocalData(previousData)
|
||||
toast.error(error)
|
||||
} finally {
|
||||
setDragActiveRowId(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragStart = ({ id }) => {
|
||||
setDragActiveRowId(id)
|
||||
}
|
||||
|
||||
const rowIds = localData.map((row) => row.id ?? row._id)
|
||||
|
||||
return (
|
||||
@@ -140,7 +153,7 @@ export const OrderableTable: React.FC<Props> = ({
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<DraggableSortable ids={rowIds} onDragEnd={handleDragEnd}>
|
||||
<DraggableSortable ids={rowIds} onDragEnd={handleDragEnd} onDragStart={handleDragStart}>
|
||||
<table cellPadding="0" cellSpacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -154,44 +167,35 @@ export const OrderableTable: React.FC<Props> = ({
|
||||
<tbody>
|
||||
{localData.map((row, rowIndex) => (
|
||||
<DraggableSortableItem id={rowIds[rowIndex]} key={rowIds[rowIndex]}>
|
||||
{({ attributes, listeners, setNodeRef, transform, transition }) => (
|
||||
<tr
|
||||
{({ attributes, isDragging, listeners, setNodeRef, transform, transition }) => (
|
||||
<OrderableRow
|
||||
cellMap={cellMap}
|
||||
className={`row-${rowIndex + 1}`}
|
||||
columns={activeColumns}
|
||||
dragAttributes={attributes}
|
||||
dragListeners={listeners}
|
||||
ref={setNodeRef}
|
||||
rowId={row.id ?? row._id}
|
||||
style={{
|
||||
opacity: isDragging ? 0 : 1,
|
||||
transform,
|
||||
transition,
|
||||
}}
|
||||
>
|
||||
{activeColumns.map((col, colIndex) => {
|
||||
const { accessor } = col
|
||||
|
||||
// Use the cellMap to find which index in the renderedCells to use
|
||||
const cell = col.renderedCells[cellMap[row.id ?? row._id]]
|
||||
|
||||
// For drag handles, wrap in div with drag attributes
|
||||
if (accessor === '_dragHandle') {
|
||||
return (
|
||||
<td className={`cell-${accessor}`} key={colIndex}>
|
||||
<div {...attributes} {...listeners}>
|
||||
{cell}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<td className={`cell-${accessor}`} key={colIndex}>
|
||||
{cell}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
/>
|
||||
)}
|
||||
</DraggableSortableItem>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<DragOverlay>
|
||||
<OrderableRowDragPreview
|
||||
className={[baseClass, `${baseClass}--drag-preview`].join(' ')}
|
||||
rowId={dragActiveRowId}
|
||||
>
|
||||
<OrderableRow cellMap={cellMap} columns={activeColumns} rowId={dragActiveRowId} />
|
||||
</OrderableRowDragPreview>
|
||||
</DragOverlay>
|
||||
</DraggableSortable>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -96,6 +96,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--drag-preview {
|
||||
cursor: grabbing;
|
||||
z-index: var(--z-popup);
|
||||
}
|
||||
|
||||
@include mid-break {
|
||||
th,
|
||||
td {
|
||||
|
||||
@@ -367,3 +367,4 @@ export { SetDocumentStepNav } from '../../views/Edit/SetDocumentStepNav/index.js
|
||||
export { SetDocumentTitle } from '../../views/Edit/SetDocumentTitle/index.js'
|
||||
|
||||
export { parseSearchParams } from '../../utilities/parseSearchParams.js'
|
||||
export { FieldDiffLabel } from '../../elements/FieldDiffLabel/index.js'
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export { FieldDiffLabel } from '../../elements/FieldDiffLabel/index.js'
|
||||
export { File } from '../../graphics/File/index.js'
|
||||
export { CheckIcon } from '../../icons/Check/index.js'
|
||||
export { copyDataFromLocaleHandler } from '../../utilities/copyDataFromLocale.js'
|
||||
export { renderFilters, renderTable } from '../../utilities/renderTable.js'
|
||||
export { resolveFilterOptions } from '../../utilities/resolveFilterOptions.js'
|
||||
|
||||
@@ -110,10 +110,10 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
|
||||
)
|
||||
|
||||
const {
|
||||
customComponents: { AfterInput, BeforeInput, Description, Error, Label, RowLabels } = {},
|
||||
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
|
||||
disabled,
|
||||
errorPaths,
|
||||
rows: rowsData = [],
|
||||
rows = [],
|
||||
showError,
|
||||
valid,
|
||||
value,
|
||||
@@ -173,12 +173,12 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
|
||||
(collapsed: boolean) => {
|
||||
const { collapsedIDs, updatedRows } = toggleAllRows({
|
||||
collapsed,
|
||||
rows: rowsData,
|
||||
rows,
|
||||
})
|
||||
setDocFieldPreferences(path, { collapsed: collapsedIDs })
|
||||
dispatchFields({ type: 'SET_ALL_ROWS_COLLAPSED', path, updatedRows })
|
||||
},
|
||||
[dispatchFields, path, rowsData, setDocFieldPreferences],
|
||||
[dispatchFields, path, rows, setDocFieldPreferences],
|
||||
)
|
||||
|
||||
const setCollapse = useCallback(
|
||||
@@ -186,22 +186,22 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
|
||||
const { collapsedIDs, updatedRows } = extractRowsAndCollapsedIDs({
|
||||
collapsed,
|
||||
rowID,
|
||||
rows: rowsData,
|
||||
rows,
|
||||
})
|
||||
|
||||
dispatchFields({ type: 'SET_ROW_COLLAPSED', path, updatedRows })
|
||||
setDocFieldPreferences(path, { collapsed: collapsedIDs })
|
||||
},
|
||||
[dispatchFields, path, rowsData, setDocFieldPreferences],
|
||||
[dispatchFields, path, rows, setDocFieldPreferences],
|
||||
)
|
||||
|
||||
const hasMaxRows = maxRows && rowsData.length >= maxRows
|
||||
const hasMaxRows = maxRows && rows.length >= maxRows
|
||||
|
||||
const fieldErrorCount = errorPaths.length
|
||||
const fieldHasErrors = submitted && errorPaths.length > 0
|
||||
|
||||
const showRequired = (readOnly || disabled) && rowsData.length === 0
|
||||
const showMinRows = rowsData.length < minRows || (required && rowsData.length === 0)
|
||||
const showRequired = (readOnly || disabled) && rows.length === 0
|
||||
const showMinRows = rows.length < minRows || (required && rows.length === 0)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -242,7 +242,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
|
||||
<ErrorPill count={fieldErrorCount} i18n={i18n} withMessage />
|
||||
)}
|
||||
</div>
|
||||
{rowsData?.length > 0 && (
|
||||
{rows?.length > 0 && (
|
||||
<ul className={`${baseClass}__header-actions`}>
|
||||
<li>
|
||||
<button
|
||||
@@ -272,13 +272,13 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
|
||||
</header>
|
||||
<NullifyLocaleField fieldValue={value} localized={localized} path={path} />
|
||||
{BeforeInput}
|
||||
{(rowsData?.length > 0 || (!valid && (showRequired || showMinRows))) && (
|
||||
{(rows?.length > 0 || (!valid && (showRequired || showMinRows))) && (
|
||||
<DraggableSortable
|
||||
className={`${baseClass}__draggable-rows`}
|
||||
ids={rowsData.map((row) => row.id)}
|
||||
ids={rows.map((row) => row.id)}
|
||||
onDragEnd={({ moveFromIndex, moveToIndex }) => moveRow(moveFromIndex, moveToIndex)}
|
||||
>
|
||||
{rowsData.map((rowData, i) => {
|
||||
{rows.map((rowData, i) => {
|
||||
const { id: rowID, isLoading } = rowData
|
||||
|
||||
const rowPath = `${path}.${i}`
|
||||
@@ -297,7 +297,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
|
||||
<ArrayRow
|
||||
{...draggableSortableItemProps}
|
||||
addRow={addRow}
|
||||
CustomRowLabel={RowLabels?.[i]}
|
||||
CustomRowLabel={rows?.[i]?.customComponents?.RowLabel}
|
||||
duplicateRow={duplicateRow}
|
||||
errorCount={rowErrorCount}
|
||||
fields={fields}
|
||||
@@ -313,7 +313,7 @@ export const ArrayFieldComponent: ArrayFieldClientComponent = (props) => {
|
||||
readOnly={readOnly || disabled}
|
||||
removeRow={removeRow}
|
||||
row={rowData}
|
||||
rowCount={rowsData?.length}
|
||||
rowCount={rows?.length}
|
||||
rowIndex={i}
|
||||
schemaPath={schemaPath}
|
||||
setCollapse={setCollapse}
|
||||
|
||||
@@ -98,7 +98,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
|
||||
)
|
||||
|
||||
const {
|
||||
customComponents: { AfterInput, BeforeInput, Description, Error, Label, RowLabels } = {},
|
||||
customComponents: { AfterInput, BeforeInput, Description, Error, Label } = {},
|
||||
disabled,
|
||||
errorPaths,
|
||||
rows = [],
|
||||
@@ -293,7 +293,7 @@ const BlocksFieldComponent: BlocksFieldClientComponent = (props) => {
|
||||
hasMaxRows={hasMaxRows}
|
||||
isLoading={isLoading}
|
||||
isSortable={isSortable}
|
||||
Label={RowLabels?.[i]}
|
||||
Label={rows?.[i]?.customComponents?.RowLabel}
|
||||
labels={labels}
|
||||
moveRow={moveRow}
|
||||
parentPath={path}
|
||||
|
||||
@@ -53,24 +53,14 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
|
||||
[`${path}.${rowIndex}.id`]: {
|
||||
initialValue: newRow.id,
|
||||
passesCondition: true,
|
||||
requiresRender: true,
|
||||
valid: true,
|
||||
value: newRow.id,
|
||||
},
|
||||
[path]: {
|
||||
...state[path],
|
||||
disableFormData: true,
|
||||
requiresRender: true,
|
||||
rows: withNewRow,
|
||||
value: siblingRows.length,
|
||||
...(state[path]?.requiresRender === true
|
||||
? {
|
||||
serverPropsToIgnore: [
|
||||
...(state[path]?.serverPropsToIgnore || []),
|
||||
'requiresRender',
|
||||
],
|
||||
}
|
||||
: state[path]?.serverPropsToIgnore || []),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -144,12 +134,16 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
|
||||
const { remainingFields, rows } = separateRows(path, state)
|
||||
const rowsMetadata = [...(state[path].rows || [])]
|
||||
|
||||
const duplicateRowMetadata = deepCopyObjectSimple(rowsMetadata[rowIndex])
|
||||
const duplicateRowMetadata = deepCopyObjectSimpleWithoutReactComponents(
|
||||
rowsMetadata[rowIndex],
|
||||
)
|
||||
|
||||
if (duplicateRowMetadata.id) {
|
||||
duplicateRowMetadata.id = new ObjectId().toHexString()
|
||||
}
|
||||
|
||||
const duplicateRowState = deepCopyObjectSimpleWithoutReactComponents(rows[rowIndex])
|
||||
|
||||
if (duplicateRowState.id) {
|
||||
duplicateRowState.id.value = new ObjectId().toHexString()
|
||||
duplicateRowState.id.initialValue = new ObjectId().toHexString()
|
||||
@@ -177,17 +171,8 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
|
||||
[path]: {
|
||||
...state[path],
|
||||
disableFormData: true,
|
||||
requiresRender: true,
|
||||
rows: rowsMetadata,
|
||||
value: rows.length,
|
||||
...(state[path]?.requiresRender === true
|
||||
? {
|
||||
serverPropsToIgnore: [
|
||||
...(state[path]?.serverPropsToIgnore || []),
|
||||
'requiresRender',
|
||||
],
|
||||
}
|
||||
: state[path]?.serverPropsToIgnore || ([] as any)),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -214,41 +199,10 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
|
||||
...flattenRows(path, topLevelRows),
|
||||
[path]: {
|
||||
...state[path],
|
||||
requiresRender: true,
|
||||
rows: rowsWithinField,
|
||||
...(state[path]?.requiresRender === true
|
||||
? {
|
||||
serverPropsToIgnore: [
|
||||
...(state[path]?.serverPropsToIgnore || []),
|
||||
'requiresRender',
|
||||
],
|
||||
}
|
||||
: state[path]?.serverPropsToIgnore || ([] as any)),
|
||||
},
|
||||
}
|
||||
|
||||
// Do the same for custom components, i.e. `array.customComponents.RowLabels[0]` -> `array.customComponents.RowLabels[1]`
|
||||
// Do this _after_ initializing `newState` to avoid adding the `customComponents` key to the state if it doesn't exist
|
||||
if (newState[path]?.customComponents?.RowLabels) {
|
||||
const customComponents = {
|
||||
...newState[path].customComponents,
|
||||
RowLabels: [...newState[path].customComponents.RowLabels],
|
||||
}
|
||||
|
||||
// Ensure the array grows if necessary
|
||||
if (moveToIndex >= customComponents.RowLabels.length) {
|
||||
customComponents.RowLabels.length = moveToIndex + 1
|
||||
}
|
||||
|
||||
const copyOfMovingLabel = customComponents.RowLabels[moveFromIndex]
|
||||
|
||||
customComponents.RowLabels.splice(moveFromIndex, 1)
|
||||
|
||||
customComponents.RowLabels.splice(moveToIndex, 0, copyOfMovingLabel)
|
||||
|
||||
newState[path].customComponents = customComponents
|
||||
}
|
||||
|
||||
return newState
|
||||
}
|
||||
|
||||
@@ -273,17 +227,8 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
|
||||
[path]: {
|
||||
...state[path],
|
||||
disableFormData: rows.length > 0,
|
||||
requiresRender: true,
|
||||
rows: rowsMetadata,
|
||||
value: rows.length,
|
||||
...(state[path]?.requiresRender === true
|
||||
? {
|
||||
serverPropsToIgnore: [
|
||||
...(state[path]?.serverPropsToIgnore || []),
|
||||
'requiresRender',
|
||||
],
|
||||
}
|
||||
: state[path]?.serverPropsToIgnore || []),
|
||||
},
|
||||
...flattenRows(path, rows),
|
||||
}
|
||||
@@ -323,14 +268,6 @@ export function fieldReducer(state: FormState, action: FieldAction): FormState {
|
||||
disableFormData: true,
|
||||
rows: rowsMetadata,
|
||||
value: siblingRows.length,
|
||||
...(state[path]?.requiresRender === true
|
||||
? {
|
||||
serverPropsToIgnore: [
|
||||
...(state[path]?.serverPropsToIgnore || []),
|
||||
'requiresRender',
|
||||
],
|
||||
}
|
||||
: state[path]?.serverPropsToIgnore || []),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -596,7 +596,7 @@ export const Form: React.FC<FormProps> = (props) => {
|
||||
const newRows: unknown[] = getDataByPath(path) || []
|
||||
const rowIndex = rowIndexArg === undefined ? newRows.length : rowIndexArg
|
||||
|
||||
// dispatch ADD_ROW that sets requiresRender: true and adds a blank row to local form state.
|
||||
// dispatch ADD_ROW adds a blank row to local form state.
|
||||
// This performs no form state request, as the debounced onChange effect will do that for us.
|
||||
dispatchFields({
|
||||
type: 'ADD_ROW',
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
import type { FieldState } from 'payload'
|
||||
import type { FieldState, FormState } from 'payload'
|
||||
|
||||
import { dequal } from 'dequal/lite' // lite: no need for Map and Set support
|
||||
import { type FormState } from 'payload'
|
||||
|
||||
import { mergeErrorPaths } from './mergeErrorPaths.js'
|
||||
|
||||
@@ -34,9 +33,7 @@ export const mergeServerFormState = ({
|
||||
'valid',
|
||||
'errorMessage',
|
||||
'errorPaths',
|
||||
'rows',
|
||||
'customComponents',
|
||||
'requiresRender',
|
||||
]
|
||||
|
||||
if (acceptValues) {
|
||||
@@ -77,6 +74,26 @@ export const mergeServerFormState = ({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Need to intelligently merge the rows array to ensure changes to local state are not lost while the request was pending
|
||||
* For example, the server response could come back with a row which has been deleted on the client
|
||||
* Loop over the incoming rows, if it exists in client side form state, merge in any new properties from the server
|
||||
*/
|
||||
if (Array.isArray(incomingState[path].rows)) {
|
||||
incomingState[path].rows.forEach((row) => {
|
||||
const matchedExistingRowIndex = newFieldState.rows.findIndex(
|
||||
(existingRow) => existingRow.id === row.id,
|
||||
)
|
||||
|
||||
if (matchedExistingRowIndex > -1) {
|
||||
newFieldState.rows[matchedExistingRowIndex] = {
|
||||
...newFieldState.rows[matchedExistingRowIndex],
|
||||
...row,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle adding all the remaining props that should be updated in the local form state from the server form state
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
BuildFormStateArgs,
|
||||
ClientFieldSchemaMap,
|
||||
Data,
|
||||
DocumentPreferences,
|
||||
@@ -9,6 +10,7 @@ import type {
|
||||
FormState,
|
||||
FormStateWithoutComponents,
|
||||
PayloadRequest,
|
||||
Row,
|
||||
SanitizedFieldPermissions,
|
||||
SanitizedFieldsPermissions,
|
||||
SelectMode,
|
||||
@@ -68,6 +70,7 @@ export type AddFieldStatePromiseArgs = {
|
||||
*/
|
||||
includeSchema?: boolean
|
||||
indexPath: string
|
||||
mockRSCs?: BuildFormStateArgs['mockRSCs']
|
||||
/**
|
||||
* Whether to omit parent fields in the state. @default false
|
||||
*/
|
||||
@@ -122,6 +125,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
fullData,
|
||||
includeSchema = false,
|
||||
indexPath,
|
||||
mockRSCs,
|
||||
omitParents = false,
|
||||
operation,
|
||||
parentPath,
|
||||
@@ -148,12 +152,16 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
)
|
||||
}
|
||||
|
||||
const requiresRender = renderAllFields || previousFormState?.[path]?.requiresRender
|
||||
const lastRenderedPath = previousFormState?.[path]?.lastRenderedPath
|
||||
|
||||
let fieldPermissions: SanitizedFieldPermissions = true
|
||||
|
||||
const fieldState: FieldState = {}
|
||||
|
||||
if (lastRenderedPath) {
|
||||
fieldState.lastRenderedPath = lastRenderedPath
|
||||
}
|
||||
|
||||
if (passesCondition === false) {
|
||||
fieldState.passesCondition = false
|
||||
}
|
||||
@@ -289,6 +297,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
forceFullValue,
|
||||
fullData,
|
||||
includeSchema,
|
||||
mockRSCs,
|
||||
omitParents,
|
||||
operation,
|
||||
parentIndexPath: '',
|
||||
@@ -299,7 +308,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
fieldPermissions === true ? fieldPermissions : fieldPermissions?.fields || {},
|
||||
preferences,
|
||||
previousFormState,
|
||||
renderAllFields: requiresRender,
|
||||
renderAllFields,
|
||||
renderFieldFn,
|
||||
req,
|
||||
select: typeof arraySelect === 'object' ? arraySelect : undefined,
|
||||
@@ -314,16 +323,25 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
acc.rows = []
|
||||
}
|
||||
|
||||
acc.rows.push({
|
||||
id: row.id,
|
||||
})
|
||||
|
||||
const previousRows = previousFormState?.[path]?.rows || []
|
||||
|
||||
// First, check if `previousFormState` has a matching row
|
||||
const previousRow: Row = previousRows.find((prevRow) => prevRow.id === row.id)
|
||||
|
||||
const newRow: Row = {
|
||||
id: row.id,
|
||||
isLoading: false,
|
||||
}
|
||||
|
||||
if (previousRow?.lastRenderedPath) {
|
||||
newRow.lastRenderedPath = previousRow.lastRenderedPath
|
||||
}
|
||||
|
||||
acc.rows.push(newRow)
|
||||
|
||||
const collapsedRowIDsFromPrefs = preferences?.fields?.[path]?.collapsed
|
||||
|
||||
const collapsed = (() => {
|
||||
// First, check if `previousFormState` has a matching row
|
||||
const previousRow = previousRows.find((prevRow) => prevRow.id === row.id)
|
||||
if (previousRow) {
|
||||
return previousRow.collapsed ?? false
|
||||
}
|
||||
@@ -356,8 +374,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
fieldState.rows = rows
|
||||
}
|
||||
|
||||
fieldState.requiresRender = false
|
||||
|
||||
// Add values to field state
|
||||
if (data[field.name] !== null) {
|
||||
fieldState.value = forceFullValue ? arrayValue : arrayValue.length
|
||||
@@ -467,6 +483,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
forceFullValue,
|
||||
fullData,
|
||||
includeSchema,
|
||||
mockRSCs,
|
||||
omitParents,
|
||||
operation,
|
||||
parentIndexPath: '',
|
||||
@@ -481,7 +498,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
: parentPermissions?.[field.name]?.blocks?.[block.slug]?.fields || {},
|
||||
preferences,
|
||||
previousFormState,
|
||||
renderAllFields: requiresRender,
|
||||
renderAllFields,
|
||||
renderFieldFn,
|
||||
req,
|
||||
select: typeof blockSelect === 'object' ? blockSelect : undefined,
|
||||
@@ -492,10 +509,22 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
}),
|
||||
)
|
||||
|
||||
acc.rowMetadata.push({
|
||||
const previousRows = previousFormState?.[path]?.rows || []
|
||||
|
||||
// First, check if `previousFormState` has a matching row
|
||||
const previousRow: Row = previousRows.find((prevRow) => prevRow.id === row.id)
|
||||
|
||||
const newRow: Row = {
|
||||
id: row.id,
|
||||
blockType: row.blockType,
|
||||
})
|
||||
isLoading: false,
|
||||
}
|
||||
|
||||
if (previousRow?.lastRenderedPath) {
|
||||
newRow.lastRenderedPath = previousRow.lastRenderedPath
|
||||
}
|
||||
|
||||
acc.rowMetadata.push(newRow)
|
||||
|
||||
const collapsedRowIDs = preferences?.fields?.[path]?.collapsed
|
||||
|
||||
@@ -534,10 +563,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
|
||||
fieldState.rows = rowMetadata
|
||||
|
||||
// Unset requiresRender
|
||||
// so it will be removed from form state
|
||||
fieldState.requiresRender = false
|
||||
|
||||
// Add field to state
|
||||
if (!omitParents && (!filter || filter(args))) {
|
||||
state[path] = fieldState
|
||||
@@ -568,6 +593,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
forceFullValue,
|
||||
fullData,
|
||||
includeSchema,
|
||||
mockRSCs,
|
||||
omitParents,
|
||||
operation,
|
||||
parentIndexPath: '',
|
||||
@@ -707,6 +733,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
|
||||
await iterateFields({
|
||||
id,
|
||||
mockRSCs,
|
||||
select,
|
||||
selectMode,
|
||||
// passthrough parent functionality
|
||||
@@ -814,6 +841,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
forceFullValue,
|
||||
fullData,
|
||||
includeSchema,
|
||||
mockRSCs,
|
||||
omitParents,
|
||||
operation,
|
||||
parentIndexPath: isNamedTab ? '' : tabIndexPath,
|
||||
@@ -842,12 +870,12 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresRender && renderFieldFn && !fieldIsHiddenOrDisabled(field)) {
|
||||
if (renderFieldFn && !fieldIsHiddenOrDisabled(field)) {
|
||||
const fieldState = state[path]
|
||||
|
||||
const fieldConfig = fieldSchemaMap.get(schemaPath)
|
||||
|
||||
if (!fieldConfig) {
|
||||
if (!fieldConfig && !mockRSCs) {
|
||||
if (schemaPath.endsWith('.blockType')) {
|
||||
return
|
||||
} else {
|
||||
@@ -871,6 +899,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
fieldState,
|
||||
formState: state,
|
||||
indexPath,
|
||||
lastRenderedPath,
|
||||
mockRSCs,
|
||||
operation,
|
||||
parentPath,
|
||||
parentSchemaPath,
|
||||
@@ -878,6 +908,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
|
||||
permissions: fieldPermissions,
|
||||
preferences,
|
||||
previousFieldState: previousFormState?.[path],
|
||||
renderAllFields,
|
||||
req,
|
||||
schemaPath,
|
||||
siblingData: data,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
BuildFormStateArgs,
|
||||
ClientFieldSchemaMap,
|
||||
Data,
|
||||
DocumentPreferences,
|
||||
@@ -56,6 +57,7 @@ type Args = {
|
||||
* the initial block data here, which will be used as `blockData` for the top-level fields, until the first block is encountered.
|
||||
*/
|
||||
initialBlockData?: Data
|
||||
mockRSCs?: BuildFormStateArgs['mockRSCs']
|
||||
operation?: 'create' | 'update'
|
||||
permissions: SanitizedFieldsPermissions
|
||||
preferences: DocumentPreferences
|
||||
@@ -86,6 +88,7 @@ export const fieldSchemasToFormState = async ({
|
||||
fields,
|
||||
fieldSchemaMap,
|
||||
initialBlockData,
|
||||
mockRSCs,
|
||||
operation,
|
||||
permissions,
|
||||
preferences,
|
||||
@@ -139,6 +142,7 @@ export const fieldSchemasToFormState = async ({
|
||||
fields,
|
||||
fieldSchemaMap,
|
||||
fullData,
|
||||
mockRSCs,
|
||||
operation,
|
||||
parentIndexPath: '',
|
||||
parentPassesCondition: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
BuildFormStateArgs,
|
||||
ClientFieldSchemaMap,
|
||||
Data,
|
||||
DocumentPreferences,
|
||||
@@ -46,6 +47,7 @@ type Args = {
|
||||
* Whether the field schema should be included in the state. @default false
|
||||
*/
|
||||
includeSchema?: boolean
|
||||
mockRSCs?: BuildFormStateArgs['mockRSCs']
|
||||
/**
|
||||
* Whether to omit parent fields in the state. @default false
|
||||
*/
|
||||
@@ -94,6 +96,7 @@ export const iterateFields = async ({
|
||||
forceFullValue = false,
|
||||
fullData,
|
||||
includeSchema = false,
|
||||
mockRSCs,
|
||||
omitParents = false,
|
||||
operation,
|
||||
parentIndexPath,
|
||||
@@ -180,6 +183,7 @@ export const iterateFields = async ({
|
||||
fullData,
|
||||
includeSchema,
|
||||
indexPath,
|
||||
mockRSCs,
|
||||
omitParents,
|
||||
operation,
|
||||
parentIndexPath,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user