diff --git a/docs/fields/join.mdx b/docs/fields/join.mdx
index 320e4756b0..43ff750ea2 100644
--- a/docs/fields/join.mdx
+++ b/docs/fields/join.mdx
@@ -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
-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). |
diff --git a/docs/fields/upload.mdx b/docs/fields/upload.mdx
index a36d6ff8d5..57fa729268 100644
--- a/docs/fields/upload.mdx
+++ b/docs/fields/upload.mdx
@@ -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
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. Note: the related collection must be configured to support Uploads. |
| **`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{' '}
payload/shared in your validate function.
+
+## 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)
diff --git a/packages/payload/src/fields/config/sanitizeJoinField.ts b/packages/payload/src/fields/config/sanitizeJoinField.ts
index f10f877d53..632fbd09a5 100644
--- a/packages/payload/src/fields/config/sanitizeJoinField.ts
+++ b/packages/payload/src/fields/config/sanitizeJoinField.ts
@@ -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()
diff --git a/packages/ui/src/elements/Table/TableCellProvider/index.tsx b/packages/ui/src/elements/Table/TableCellProvider/index.tsx
index 1df2b1eb23..6b3b4ae445 100644
--- a/packages/ui/src/elements/Table/TableCellProvider/index.tsx
+++ b/packages/ui/src/elements/Table/TableCellProvider/index.tsx
@@ -28,12 +28,12 @@ export const TableCellProvider: React.FC<{
return (
{children}
diff --git a/packages/ui/src/fields/Upload/Input.tsx b/packages/ui/src/fields/Upload/Input.tsx
index cb9bee34ea..a68e0178dc 100644
--- a/packages/ui/src/fields/Upload/Input.tsx
+++ b/packages/ui/src/fields/Upload/Input.tsx
@@ -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(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) {
diff --git a/test/joins/.gitignore b/test/joins/.gitignore
new file mode 100644
index 0000000000..9a9546b344
--- /dev/null
+++ b/test/joins/.gitignore
@@ -0,0 +1 @@
+/uploads/
diff --git a/test/joins/collections/Posts.ts b/test/joins/collections/Posts.ts
index 01d3e3fd12..77c6e35f82 100644
--- a/test/joins/collections/Posts.ts
+++ b/test/joins/collections/Posts.ts
@@ -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',
diff --git a/test/joins/collections/Uploads.ts b/test/joins/collections/Uploads.ts
new file mode 100644
index 0000000000..0262fc0d1c
--- /dev/null
+++ b/test/joins/collections/Uploads.ts
@@ -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'),
+ },
+}
diff --git a/test/joins/config.ts b/test/joins/config.ts
index c1815cdccd..4a083adda3 100644
--- a/test/joins/config.ts
+++ b/test/joins/config.ts
@@ -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: {
diff --git a/test/joins/e2e.spec.ts b/test/joins/e2e.spec.ts
index 6e127743c7..e6e078e4bd 100644
--- a/test/joins/e2e.spec.ts
+++ b/test/joins/e2e.spec.ts
@@ -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()
+ })
})
diff --git a/test/joins/image.png b/test/joins/image.png
new file mode 100644
index 0000000000..23787ee3d7
Binary files /dev/null and b/test/joins/image.png differ
diff --git a/test/joins/int.spec.ts b/test/joins/int.spec.ts
index c10084cf74..3d306ac4ed 100644
--- a/test/joins/int.spec.ts
+++ b/test/joins/int.spec.ts
@@ -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,
diff --git a/test/joins/payload-types.ts b/test/joins/payload-types.ts
index ec9171323c..2d46776e26 100644
--- a/test/joins/payload-types.ts
+++ b/test/joins/payload-types.ts
@@ -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';
diff --git a/test/joins/seed.ts b/test/joins/seed.ts
index 2665695868..d82f0b631d 100644
--- a/test/joins/seed.ts
+++ b/test/joins/seed.ts
@@ -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) {
diff --git a/test/joins/shared.ts b/test/joins/shared.ts
index cf4af79035..0cce98b0ed 100644
--- a/test/joins/shared.ts
+++ b/test/joins/shared.ts
@@ -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'