Compare commits

..

23 Commits

Author SHA1 Message Date
Sasha
ccb4da475f temp 2025-04-04 20:39:41 +03:00
Alessio Gravili
f7ed8e90e1 docs: fix invalid markdown (#11996) 2025-04-04 12:41:54 -04:00
Tony Tkachenko
e6aad5adfc docs: add missing comma (#11976)
Add missing comma
2025-04-04 00:04:32 +00:00
Sasha
4ebd3ce668 fix(db-postgres): deleteOne fails when the where query does not resolve to any document (#11632)
Previously, if you called `payload.db.deleteOne` with a `where` query
that does not resolve to anything, an error would be occurred.
2025-04-04 00:46:31 +03:00
James
fae113b799 chore: fix flake 2025-04-03 17:06:35 -04:00
Jacob Fletcher
e87521a376 perf(ui): significantly optimize form state component rendering, up to 96% smaller and 75% faster (#11946)
Significantly optimizes the component rendering strategy within the form
state endpoint by precisely rendering only the fields that require it.
This cuts down on server processing and network response sizes when
invoking form state requests **that manipulate array and block rows
which contain server components**, such as rich text fields, custom row
labels, etc. (results listed below).

Here's a breakdown of the issue:

Previously, when manipulating array and block fields, _all_ rows would
render any server components that might exist within them, including
rich text fields. This means that subsequent changes to these fields
would potentially _re-render_ those same components even if they don't
require it.

For example, if you have an array field with a rich text field within
it, adding the first row would cause the rich text field to render,
which is expected. However, when you add a second row, the rich text
field within the first row would render again unnecessarily along with
the new row.

This is especially noticeable for fields with many rows, where every
single row processes its server components and returns RSC data. And
this does not only affect nested rich text fields, but any custom
component defined on the field level, as these are handled in the same
way.

The reason this was necessary in the first place was to ensure that the
server components receive the proper data when they are rendered, such
as the row index and the row's data. Changing one of these rows could
cause the server component to receive the wrong data if it was not
freshly rendered.

While this is still a requirement that rows receive up-to-date props, it
is no longer necessary to render everything.

Here's a breakdown of the actual fix:

This change ensures that only the fields that are actually being
manipulated will be rendered, rather than all rows. The existing rows
will remain in memory on the client, while the newly rendered components
will return from the server. For example, if you add a new row to an
array field, only the new row will render its server components.

To do this, we send the path of the field that is being manipulated to
the server. The server can then use this path to determine for itself
which fields have already been rendered and which ones need required
rendering.

## Results

The following results were gathered by booting up the `form-state` test
suite and seeding 100 array rows, each containing a rich text field. To
invoke a form state request, we navigate to a document within the
"posts" collection, then add a new array row to the list. The result is
then saved to the file system for comparison.

| Test Suite | Collection | Number of Rows | Before | After | Percentage
Change |
|------|------|---------|--------|--------|--------|
| `form-state` | `posts` | 101 | 1.9MB / 266ms | 80KB / 70ms | ~96%
smaller / ~75% faster |

---------

Co-authored-by: James <james@trbl.design>
Co-authored-by: Alessio Gravili <alessio@gravili.de>
2025-04-03 12:27:14 -04:00
Jacob Fletcher
8880d705e3 fix(ui): optimistic rows disappear while form state requests are pending (#11961)
When manipulating array and blocks rows on slow networks, rows can
sometimes disappear and then reappear as requests in the queue arrive.

Consider this scenario:

1. You add a row to form state: this pushes the row in local state
optimistically then triggers a long-running form state request
containing a single row
2. You add another row to form state: this pushes a second row into
local state optimistically then triggers another long-running form state
request containing two rows
3. The first form state request returns with a single row in the
response and replaces local state (which contained two rows)
4. AT THIS MOMENT IN TIME, THE SECOND ROW DISAPPEARS
5. The second form state request returns with two rows in the response
and replaces local state
6. THE UI IS NO LONGER STALE AND BOTH ROWS APPEAR AS EXPECTED

The same issue applies when deleting, moving, and duplicating rows.
Local state becomes out of sync with the form state response and is
ultimately overridden.

The issue is that when we merge the result from form state, we do not
traverse the rows themselves, and instead take the rows in their
entirety. This means that we lose local row state. Instead, we need to
compare the results with what is saved to local state and intelligently
merge them.
2025-04-03 12:23:14 -04:00
reiv
018bdad247 feat(graphql): improve non-nullability in query result types (#11952)
### What?
Makes several fields and list item types in query results (e.g. `docs`)
non-nullable.

### Why?
When dealing with code generated from a Payload GraphQL schema, it is
often necessary to use type guards and optional chaining.

For example:

```graphql
type Posts {
  docs: [Post]
  ...
}
```

This implies that the `docs` field itself is nullable and that the array
can contain nulls. In reality, neither of these is true. But because of
the types generated by tools like `graphql-code-generator`, the way to
access `posts` ends up something like this:

```ts
const posts = (query.data.docs ?? []).filter(doc => doc != null);
```

Instead, we would like the schema to be:

```graphql
type Posts {
  docs: [Post!]!
  ...
}
```


### How?
The proposed change involves adding `GraphQLNonNull` where appropriate.

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
2025-04-03 15:17:23 +00:00
Said Akhrarov
816fb28f55 feat(ui): use drag overlay in orderable table (#11959)
<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->
### What?
This PR introduces a new `DragOverlay` to the existing `OrderableTable`
component along with a few new utility components. This enables a more
fluid and seamless drag-and-drop experience for end-users who have
enabled `orderable: true` on their collections.

### Why?
Previously, the rows in the `OrderableTable` component were confined
within the table element that renders them. This is troublesome for a
few reasons:
- It clips rows when dragging even slightly outside of the bounds of the
table.
- It creates unnecessary scrollbars within the containing element as the
container is not geared for comprehensive drag-and-drop interactions.

### How?
Introducing a `DragOverlay` component gives the draggable rows an area
to render freely without clipping. This PR also introduces a new
`OrderableRow` (for rendering orderable rows in the table as well as in
a drag preview), and an `OrderableRowDragPreview` component to render a
drag-preview of the active row 1:1 as you would see in the table without
violating HTML rules.

This PR also adds an `onDragStart` event handler to the
`DraggableDroppable` component to allow for listening for the start of a
drag event, necessary for interactions with a `DragOverlay` to
communicate which row initiated the event.

Before:


[orderable-before.webm](https://github.com/user-attachments/assets/ccf32bb0-91db-44f3-8c2a-4f81bb762529)


After:


[orderable-after.webm](https://github.com/user-attachments/assets/d320e7e6-fab8-4ea4-9cb1-38b581cbc50e)


After (With overflow on page):


[orderable-overflow-y.webm](https://github.com/user-attachments/assets/418b9018-901d-4217-980c-8d04d58d19c8)
2025-04-03 10:17:19 -03:00
Sasha
857e984fbb fix(db-mongodb): querying relationships with where clause as an object with several conditions (#11953)
Fixes https://github.com/payloadcms/payload/issues/11927

When trying to use the following notation:
```ts
const { docs } = await payload.find({
  collection: 'movies',
  depth: 0,
  where: {
    'director.name': { equals: 'Director1' },
    'director.localized': { equals: 'Director1_Localized' },
  },
})
```
Currently, it respects only the latest condition and the first is
ignored.

However, this works fine:
```ts
const { docs } = await payload.find({
  collection: 'movies',
  depth: 0,
  where: {
    and: [
      {
        'director.name': { equals: 'Director1' },
      },
      {
        'director.localized': { equals: 'Director1_Localized' },
      },
    ],
  },
})
```

But this should be an equivalent to
```
 where: {
    'director.name': { equals: 'Director1' },
    'director.localized': { equals: 'Director1_Localized' },
  },
```
2025-04-03 09:07:10 -04:00
Germán Jabloñski
d47b753898 chore(plugin-cloud-storage): enable TypeScript strict (#11850) 2025-04-03 10:06:25 -03:00
Germán Jabloñski
308cb64b9c chore(richtext-lexical): add DebugJsxConverterFeature (#10856)
Display the editor content below using the JSX converter
Added for debugging reasons, similar to TreeViewFeature

usage:

```ts
    {
      name: 'content',
      type: 'richText',
      editor: lexicalEditor({
        features: ({ defaultFeatures }) => [...defaultFeatures, DebugJsxConverterFeature()],
      }),
    },
```
2025-04-03 09:06:07 -04:00
Germán Jabloñski
6c735effff chore(plugin-redirects): enable TypeScript strict (#11931) 2025-04-03 09:04:21 -04:00
Germán Jabloñski
fd42ad5f52 chore(plugin-nested-docs): enable TypeScript strict (#11930) 2025-04-03 09:04:04 -04:00
Germán Jabloñski
a58ff57e4f chore(plugin-form-builder): enable TypeScript strict (#11929) 2025-04-03 09:01:13 -04:00
Alessio Gravili
06d937e903 docs: fix variable names for lexical markdown conversion (#11963) 2025-04-03 09:21:27 +03:00
Sasha
8e93ad8f5f fix(storage-uploadthing): pass clientUploads.routerInputConfig to the handler (#11962)
PR https://github.com/payloadcms/payload/pull/11954 added this property
but didn't actually pass it through to the handler.
2025-04-02 23:51:30 +00:00
Sasha
f310c90211 fix(db-postgres): down migration fails because migrationTableExists doesn't check in the current transaction (#11910)
Fixes https://github.com/payloadcms/payload/issues/11882

Previously, down migration that dropped the `payload_migrations` table
was failing because `migrationTableExists` doesn't check the current
transaction, only in which you can get a `false` value result.
2025-04-03 02:33:34 +03:00
Sasha
dc793d1d14 fix: ValidationError error message when label is a function (#11904)
Fixes https://github.com/payloadcms/payload/issues/11901

Previously, when `ValidationError` `errors.path` was referring to a
field with `label` defined as a function, the error message was
generated with `[object Object]`. Now, we call that function instead.
Since the `i18n` argument is required for `StaticLabel`, this PR
introduces so you can pass a partial `req` to `ValidationError` from
which we thread `req.i18n` to the label args.
2025-04-03 00:38:54 +03:00
Sasha
f9c73ad5f2 feat(storage-uploadthing): configurable upload router input config (#11954)
Fixes https://github.com/payloadcms/payload/issues/11949 by setting the
default limit to `512MB`.
Additionally, makes this configurable via
`clientUploads.routerInputConfig`. Details are here
https://docs.uploadthing.com/file-routes#route-config
2025-04-03 00:14:08 +03:00
Sasha
760cfadaad fix: do not append doc input for scheduled publish job if it's enabled only for globals (#11892)
Fixes https://github.com/payloadcms/payload/issues/11891


Previously, if you had scheduled publish enabled only for globals, not
collections - you'd get an error on `payload generate:types`:
<img width="886" alt="image"
src="https://github.com/user-attachments/assets/78125ce8-bd89-4269-bc56-966d8e0c3968"
/>

This was caused by appending the `doc` field to the scheduled publish
job input schema with empty `collections` array. Now we skip this field
if we don't have any collections.
2025-04-03 00:12:35 +03:00
Alessio Gravili
d29bdfc10f feat(next): improved lexical richText diffing in version view (#11760)
This replaces our JSON-based richtext diffing with HTML-based richtext
diffing for lexical. It uses [this HTML diff
library](https://github.com/Arman19941113/html-diff) that I then
modified to handle diffing more complex elements like links, uploads and
relationships.

This makes it way easier to spot changes, replacing the lengthy Lexical
JSON with a clean visual diff that shows exactly what's different.

## Before

![CleanShot 2025-03-18 at 13 54
51@2x](https://github.com/user-attachments/assets/811a7c14-d592-4fdc-a1f4-07eeb78255fe)


## After


![CleanShot 2025-03-31 at 18 14
10@2x](https://github.com/user-attachments/assets/efb64da0-4ff8-4965-a458-558a18375c46)
![CleanShot 2025-03-31 at 18 14
26@2x](https://github.com/user-attachments/assets/133652ce-503b-4b86-9c4c-e5c7706d8ea6)
2025-04-02 20:10:20 +00:00
Alessio Gravili
f34eb228c4 feat(drizzle): export buildQuery and parseParams (#11935)
This exports `buildQuery` and `parseParams` from @payloadcms/drizzle
2025-04-02 18:17:39 +00:00
131 changed files with 7057 additions and 992 deletions

View File

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

View File

@@ -239,7 +239,7 @@ export default buildConfig({
// ...
// highlight-start
cors: {
origins: ['http://localhost:3000']
origins: ['http://localhost:3000'],
headers: ['x-custom-header']
}
// highlight-end

View File

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

View File

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

View File

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

View File

@@ -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 ?? {}, {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ type Args = {
aliasTable?: Table
fields: FlattenedField[]
joins: BuildQueryJoinAliases
locale: string
locale?: string
parentIsLocalized: boolean
selectFields: Record<string, GenericColumn>
selectLocale?: boolean

View File

@@ -423,6 +423,7 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
path: fieldName,
},
],
req,
},
req?.t,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,6 +77,7 @@ export const beforeChange = async <T extends JsonObject>({
collection: collection?.slug,
errors,
global: global?.slug,
req,
},
req.t,
)

View File

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

View File

@@ -88,11 +88,11 @@ export const getFields = ({ collection, prefix }: Args): Field[] => {
type: 'group',
fields: [
{
...(existingSizeURLField || ({} as any)),
...(existingSizeURLField || {}),
...baseURLField,
},
],
}
} as Field
}),
}

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ export const getAfterReadHook =
let url = value
if (disablePayloadAccessControl && filename) {
url = await adapter.generateURL({
url = await adapter.generateURL?.({
collection,
data,
filename,

View File

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

View File

@@ -67,9 +67,6 @@ export const cloudStoragePlugin =
if ('clientUploadContext' in args.params) {
return adapter.staticHandler(req, args)
}
// Otherwise still skip staticHandler
return null
})
}

View File

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

View File

@@ -1,9 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
/* TODO: remove the following lines */
"strict": false,
"noUncheckedIndexedAccess": false,
},
"references": [{ "path": "../payload" }, { "path": "../ui" }]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
/* TODO: remove the following lines */
"strict": false,
},
"references": [{ "path": "../payload" }, { "path": "../ui" }]
}

View File

@@ -10,5 +10,5 @@ export const parentFilterOptions: (breadcrumbsFieldSlug?: string) => FilterOptio
}
}
return null
return true
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
/* TODO: remove the following lines */
"strict": false,
},
"references": [{ "path": "../payload" }]
}

View File

@@ -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[] = [

View File

@@ -1,8 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
/* TODO: remove the following lines */
"strict": false,
},
"references": [{ "path": "../payload" }]
}

View File

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

View File

@@ -1,2 +1,3 @@
export { RscEntryLexicalCell } from '../../cell/rscEntry.js'
export { LexicalDiffComponent } from '../../field/Diff/index.js'
export { RscEntryLexicalField } from '../../field/rscEntry.js'

View File

@@ -83,6 +83,7 @@ export type HTMLConvertersAsync<
: SerializedInlineBlockNode
>
}
unknown?: HTMLConverterAsync<SerializedLexicalNode>
}
export type HTMLConvertersFunctionAsync<

View File

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

View File

@@ -71,6 +71,7 @@ export type HTMLConverters<
: SerializedInlineBlockNode
>
}
unknown?: HTMLConverter<SerializedLexicalNode>
}
export type HTMLConvertersFunction<

View File

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

View File

@@ -66,6 +66,7 @@ export type JSXConverters<
: SerializedInlineBlockNode
>
}
unknown?: JSXConverter<SerializedLexicalNode>
}
export type SerializedLexicalNodeWithParent = {
parent?: SerializedLexicalNode

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
import { createServerFeature } from '../../../../utilities/createServerFeature.js'
export const DebugJsxConverterFeature = createServerFeature({
feature: {
ClientFeature: '@payloadcms/richtext-lexical/client#DebugJsxConverterFeatureClient',
},
key: 'jsxConverter',
})

View File

@@ -46,7 +46,6 @@ export const uploadValidation = (
const result = await fieldSchemasToFormState({
id,
collectionSlug: node.relationTo,
data: node?.fields ?? {},
documentData: data,
fields: collection.fields,

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}&nbsp;</span>
)}
<span>{nodeType}</span>
<div className={`${baseClass}__meta`}>
<br />
</div>
</div>
)
// Render to HTML
const html = ReactDOMServer.renderToString(JSX)
return html
},
}
}

View File

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

View File

@@ -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>
&nbsp;-&nbsp;
{uploadDoc?.width}x{uploadDoc?.height}
</React.Fragment>
)}
{uploadDoc?.mimeType && (
<React.Fragment>
&nbsp;-&nbsp;
{uploadDoc?.mimeType}
</React.Fragment>
)}
</div>
</div>
</div>
</div>
)
// Render to HTML
const html = ReactDOMServer.renderToString(JSX)
return html
},
}
}

View 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.

View 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: '';
}
}
}
}
}

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -96,6 +96,11 @@
}
}
&--drag-preview {
cursor: grabbing;
z-index: var(--z-popup);
}
@include mid-break {
th,
td {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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