feat: join field on upload fields (#8379)

This PR makes it possible to use the new `join` field in connection with
an `upload` field. Previously `join` was reserved only for
relationships.
This commit is contained in:
Dan Ribbens
2024-09-30 13:12:30 -04:00
committed by GitHub
parent 3847428f0a
commit 3f375cc6ee
15 changed files with 207 additions and 25 deletions

View File

@@ -6,8 +6,8 @@ desc: The Join field provides the ability to work on related documents. Learn ho
keywords: join, relationship, junction, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
The Join Field is used to make Relationship fields in the opposite direction. It is used to show the relationship from
the other side. The field itself acts as a virtual field, in that no new data is stored on the collection with a Join
The Join Field is used to make Relationship and Upload fields available in the opposite direction. With a Join you can edit and view collections
having reference to a specific collection document. The field itself acts as a virtual field, in that no new data is stored on the collection with a Join
field. Instead, the Admin UI surfaces the related documents for a better editing experience and is surfaced by Payload's
APIs.
@@ -16,6 +16,7 @@ The Join field is useful in scenarios including:
- To surface `Order`s for a given `Product`
- To view and edit `Posts` belonging to a `Category`
- To work with any bi-directional relationship data
- Displaying where a document or upload is used in other documents
<LightDarkImage
srcLight="https://payloadcms.com/images/docs/fields/join.png"
@@ -24,8 +25,8 @@ The Join field is useful in scenarios including:
caption="Admin Panel screenshot of Join field"
/>
For the Join field to work, you must have an existing [relationship](./relationship) field in the collection you are
joining. This will reference the collection and path of the field of the related documents.
For the Join field to work, you must have an existing [relationship](./relationship) or [upload](./upload) field in the
collection you are joining. This will reference the collection and path of the field of the related documents.
To add a Relationship Field, set the `type` to `join` in your [Field Config](./overview):
```ts
@@ -122,7 +123,7 @@ complete control over any type of relational architecture in Payload, all wrappe
|------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`name`** \* | To be used as the property name when retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`collection`** \* | The `slug`s having the relationship field. |
| **`on`** \* | The relationship field name of the field that relates to collection document. Use dot notation for nested paths, like 'myGroup.relationName'. |
| **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. |
| **`maxDepth`** | Default is 1, Sets a maximum population depth for this field, regardless of the remaining depth when this field is reached. [Max Depth](/docs/getting-started/concepts#field-level-max-depth) |
| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. |
| **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). |

View File

@@ -6,7 +6,8 @@ desc: Upload fields will allow a file to be uploaded, only from a collection sup
keywords: upload, images media, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
---
The Upload Field allows for the selection of a Document from a Collection supporting [Uploads](../upload/overview), and formats the selection as a thumbnail in the Admin Panel.
The Upload Field allows for the selection of a Document from a Collection supporting [Uploads](../upload/overview), and
formats the selection as a thumbnail in the Admin Panel.
Upload fields are useful for a variety of use cases, such as:
@@ -15,10 +16,10 @@ Upload fields are useful for a variety of use cases, such as:
- To give a layout building block the ability to feature a background image
<LightDarkImage
srcLight="https://payloadcms.com/images/docs/fields/upload.png"
srcDark="https://payloadcms.com/images/docs/fields/upload-dark.png"
alt="Shows an upload field in the Payload Admin Panel"
caption="Admin Panel screenshot of an Upload field"
srcLight="https://payloadcms.com/images/docs/fields/upload.png"
srcDark="https://payloadcms.com/images/docs/fields/upload-dark.png"
alt="Shows an upload field in the Payload Admin Panel"
caption="Admin Panel screenshot of an Upload field"
/>
To create an Upload Field, set the `type` to `upload` in your [Field Config](./overview):
@@ -43,7 +44,7 @@ export const MyUploadField: Field = {
## Config Options
| Option | Description |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`*relationTo`** \* | Provide a single collection `slug` to allow this field to accept a relation to. <strong>Note: the related collection must be configured to support Uploads.</strong> |
| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-upload-options). |
@@ -97,7 +98,7 @@ prevent all, or a `Where` query. When using a function, it will be
called with an argument object with the following properties:
| Property | Description |
| ------------- | ----------------------------------------------------------------------------------------------------- |
|---------------|-------------------------------------------------------------------------------------------------------|
| `relationTo` | The collection `slug` to filter against, limited to this field's `relationTo` property |
| `data` | An object containing the full collection or global document currently being edited |
| `siblingData` | An object containing document data that is scoped to only fields within the same parent of this field |
@@ -127,3 +128,10 @@ You can learn more about writing queries [here](/docs/queries/overview).
unless you call the default upload field validation function imported from{' '}
<strong>payload/shared</strong> in your validate function.
</Banner>
## Bi-directional relationships
The `upload` field on its own is used to reference documents in an upload collection. This can be considered a "one-way"
relationship. If you wish to allow an editor to visit the upload document and see where it is being used, you may use
the `join` field in the upload enabled collection. Read more about bi-directional relationships using
the [Join field](./join)

View File

@@ -1,6 +1,6 @@
import type { SanitizedJoins } from '../../collections/config/types.js'
import type { Config } from '../../config/types.js'
import type { JoinField, RelationshipField } from './types.js'
import type { JoinField, RelationshipField, UploadField } from './types.js'
import { APIError } from '../../errors/index.js'
import { InvalidFieldJoin } from '../../errors/InvalidFieldJoin.js'
@@ -33,7 +33,7 @@ export const sanitizeJoinField = ({
if (!joinCollection) {
throw new InvalidFieldJoin(field)
}
let joinRelationship: RelationshipField | undefined
let joinRelationship: RelationshipField | UploadField
const pathSegments = field.on.split('.') // Split the schema path into segments
let currentSegmentIndex = 0
@@ -49,9 +49,10 @@ export const sanitizeJoinField = ({
if ('name' in field && field.name === currentSegment) {
// Check if this is the last segment in the path
if (
currentSegmentIndex === pathSegments.length - 1 &&
'type' in field &&
field.type === 'relationship'
(currentSegmentIndex === pathSegments.length - 1 &&
'type' in field &&
field.type === 'relationship') ||
field.type === 'upload'
) {
joinRelationship = field // Return the matched field
next()

View File

@@ -28,12 +28,12 @@ export const TableCellProvider: React.FC<{
return (
<TableCellContext.Provider
value={{
...contextToInherit,
cellData,
cellProps,
columnIndex,
customCellContext,
rowData,
...contextToInherit,
}}
>
{children}

View File

@@ -153,6 +153,9 @@ export function UploadInput(props: UploadInputProps) {
collectionSlug: activeRelationTo,
})
/**
* Prevent initial retrieval of documents from running more than once
*/
const loadedValueDocsRef = React.useRef<boolean>(false)
const canCreate = useMemo(() => {
@@ -388,6 +391,7 @@ export function UploadInput(props: UploadInputProps) {
useEffect(() => {
async function loadInitialDocs() {
if (value) {
loadedValueDocsRef.current = true
const loadedDocs = await populateDocs(
Array.isArray(value) ? value : [value],
activeRelationTo,
@@ -398,8 +402,6 @@ export function UploadInput(props: UploadInputProps) {
)
}
}
loadedValueDocsRef.current = true
}
if (!loadedValueDocsRef.current) {

1
test/joins/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/uploads/

View File

@@ -1,6 +1,6 @@
import type { CollectionConfig } from 'payload'
import { categoriesSlug, postsSlug } from '../shared.js'
import { categoriesSlug, postsSlug, uploadsSlug } from '../shared.js'
export const Posts: CollectionConfig = {
slug: postsSlug,
@@ -13,6 +13,11 @@ export const Posts: CollectionConfig = {
name: 'title',
type: 'text',
},
{
name: 'upload',
type: 'upload',
relationTo: uploadsSlug,
},
{
name: 'category',
type: 'relationship',

View File

@@ -0,0 +1,23 @@
import type { CollectionConfig } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import { uploadsSlug } from '../shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const Uploads: CollectionConfig = {
slug: uploadsSlug,
fields: [
{
name: 'relatedPosts',
type: 'join',
collection: 'posts',
on: 'upload',
},
],
upload: {
staticDir: path.resolve(dirname, '../uploads'),
},
}

View File

@@ -4,6 +4,7 @@ import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { Categories } from './collections/Categories.js'
import { Posts } from './collections/Posts.js'
import { Uploads } from './collections/Uploads.js'
import { seed } from './seed.js'
import { localizedCategoriesSlug, localizedPostsSlug } from './shared.js'
@@ -14,6 +15,7 @@ export default buildConfigWithDefaults({
collections: [
Posts,
Categories,
Uploads,
{
slug: localizedPostsSlug,
admin: {

View File

@@ -10,7 +10,7 @@ import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { navigateToDoc } from '../helpers/e2e/navigateToDoc.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { categoriesSlug, postsSlug } from './shared.js'
import { categoriesSlug, postsSlug, uploadsSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -18,6 +18,7 @@ const dirname = path.dirname(filename)
test.describe('Admin Panel', () => {
let page: Page
let categoriesURL: AdminUrlUtil
let uploadsURL: AdminUrlUtil
let postsURL: AdminUrlUtil
test.beforeAll(async ({ browser }, testInfo) => {
@@ -26,6 +27,7 @@ test.describe('Admin Panel', () => {
const { payload, serverURL } = await initPayloadE2ENoConfig({ dirname })
postsURL = new AdminUrlUtil(serverURL, postsSlug)
categoriesURL = new AdminUrlUtil(serverURL, categoriesSlug)
uploadsURL = new AdminUrlUtil(serverURL, uploadsSlug)
const context = await browser.newContext()
page = await context.newPage()
@@ -183,4 +185,65 @@ test.describe('Admin Panel', () => {
await expect(joinField).toBeVisible()
await expect(joinField.locator('.relationship-table tbody tr')).toBeHidden()
})
test('should update relationship table when new upload is created', async () => {
await navigateToDoc(page, uploadsURL)
const joinField = page.locator('.field-type.join').first()
await expect(joinField).toBeVisible()
const addButton = joinField.locator('.relationship-table__actions button.doc-drawer__toggler', {
hasText: exactText('Add new'),
})
await expect(addButton).toBeVisible()
await addButton.click()
const drawer = page.locator('[id^=doc-drawer_posts_1_]')
await expect(drawer).toBeVisible()
const uploadField = drawer.locator('#field-upload')
await expect(uploadField).toBeVisible()
const uploadValue = uploadField.locator('.upload-relationship-details img')
await expect(uploadValue).toBeVisible()
const titleField = drawer.locator('#field-title')
await expect(titleField).toBeVisible()
await titleField.fill('Test post with upload')
await drawer.locator('button[id="action-save"]').click()
await expect(drawer).toBeHidden()
await expect(
joinField.locator('tbody tr td:nth-child(2)', {
hasText: exactText('Test post with upload'),
}),
).toBeVisible()
})
test('should update relationship table when new upload is created', async () => {
await navigateToDoc(page, uploadsURL)
const joinField = page.locator('.field-type.join').first()
await expect(joinField).toBeVisible()
// TODO: change this to edit the first row in the join table
const addButton = joinField.locator('.relationship-table__actions button.doc-drawer__toggler', {
hasText: exactText('Add new'),
})
await expect(addButton).toBeVisible()
await addButton.click()
const drawer = page.locator('[id^=doc-drawer_posts_1_]')
await expect(drawer).toBeVisible()
const uploadField = drawer.locator('#field-upload')
await expect(uploadField).toBeVisible()
const uploadValue = uploadField.locator('.upload-relationship-details img')
await expect(uploadValue).toBeVisible()
const titleField = drawer.locator('#field-title')
await expect(titleField).toBeVisible()
await titleField.fill('Edited title for upload')
await drawer.locator('button[id="action-save"]').click()
await expect(drawer).toBeHidden()
await expect(
joinField.locator('tbody tr td:nth-child(2)', {
hasText: exactText('Edited title for upload'),
}),
).toBeVisible()
})
})

BIN
test/joins/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -1,6 +1,7 @@
import type { Payload } from 'payload'
import path from 'path'
import { getFileByPath } from 'payload'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
@@ -9,6 +10,7 @@ import type { Category, Post } from './payload-types.js'
import { devUser } from '../credentials.js'
import { idToString } from '../helpers/idToString.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { categoriesSlug, uploadsSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -40,19 +42,30 @@ describe('Joins Field', () => {
token = data.token
category = await payload.create({
collection: 'categories',
collection: categoriesSlug,
data: {
name: 'paginate example',
group: {},
},
})
// create an upload
const imageFilePath = path.resolve(dirname, './image.png')
const imageFile = await getFileByPath(imageFilePath)
const { id: uploadedImage } = await payload.create({
collection: uploadsSlug,
data: {},
file: imageFile,
})
categoryID = idToString(category.id, payload)
for (let i = 0; i < 15; i++) {
await createPost({
title: `test ${i}`,
category: category.id,
upload: uploadedImage,
group: {
category: category.id,
},
@@ -94,6 +107,16 @@ describe('Joins Field', () => {
expect(docs[0].category.relatedPosts.docs).toHaveLength(10)
})
it('should populate uploads in joins', async () => {
const { docs } = await payload.find({
limit: 1,
collection: 'posts',
})
expect(docs[0].upload.id).toBeDefined()
expect(docs[0].upload.relatedPosts.docs).toHaveLength(10)
})
it('should filter joins using where query', async () => {
const categoryWithPosts = await payload.findByID({
id: category.id,

View File

@@ -13,6 +13,7 @@ export interface Config {
collections: {
posts: Post;
categories: Category;
uploads: Upload;
'localized-posts': LocalizedPost;
'localized-categories': LocalizedCategory;
users: User;
@@ -54,6 +55,7 @@ export interface UserAuthOperations {
export interface Post {
id: string;
title?: string | null;
upload?: (string | null) | Upload;
category?: (string | null) | Category;
group?: {
category?: (string | null) | Category;
@@ -61,6 +63,28 @@ export interface Post {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "uploads".
*/
export interface Upload {
id: string;
relatedPosts?: {
docs?: (string | Post)[] | null;
hasNextPage?: boolean | null;
} | null;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories".
@@ -138,6 +162,10 @@ export interface PayloadLockedDocument {
relationTo: 'categories';
value: string | Category;
} | null)
| ({
relationTo: 'uploads';
value: string | Upload;
} | null)
| ({
relationTo: 'localized-posts';
value: string | LocalizedPost;
@@ -150,7 +178,6 @@ export interface PayloadLockedDocument {
relationTo: 'users';
value: string | User;
} | null);
editedAt?: string | null;
globalSlug?: string | null;
user: {
relationTo: 'users';

View File

@@ -1,8 +1,15 @@
import type { Payload } from 'payload'
import path from 'path'
import { getFileByPath } from 'payload'
import { fileURLToPath } from 'url'
import { devUser } from '../credentials.js'
import { seedDB } from '../helpers/seed.js'
import { categoriesSlug, collectionSlugs, postsSlug } from './shared.js'
import { categoriesSlug, collectionSlugs, postsSlug, uploadsSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export const seed = async (_payload) => {
await _payload.create({
@@ -53,6 +60,23 @@ export const seed = async (_payload) => {
title: 'Test Post 3',
},
})
// create an upload with image.png
const imageFilePath = path.resolve(dirname, './image.png')
const imageFile = await getFileByPath(imageFilePath)
const { id: uploadedImage } = await _payload.create({
collection: uploadsSlug,
data: {},
file: imageFile,
})
// create a post that uses the upload
await _payload.create({
collection: postsSlug,
data: {
upload: uploadedImage.id,
},
})
}
export async function clearAndSeedEverything(_payload: Payload) {

View File

@@ -2,6 +2,8 @@ export const categoriesSlug = 'categories'
export const postsSlug = 'posts'
export const uploadsSlug = 'uploads'
export const localizedPostsSlug = 'localized-posts'
export const localizedCategoriesSlug = 'localized-categories'