Skip to content

Commit 3f375cc

Browse files
authored
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.
1 parent 3847428 commit 3f375cc

File tree

15 files changed

+207
-25
lines changed

15 files changed

+207
-25
lines changed

docs/fields/join.mdx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ desc: The Join field provides the ability to work on related documents. Learn ho
66
keywords: join, relationship, junction, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
77
---
88

9-
The Join Field is used to make Relationship fields in the opposite direction. It is used to show the relationship from
10-
the other side. The field itself acts as a virtual field, in that no new data is stored on the collection with a Join
9+
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
10+
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
1111
field. Instead, the Admin UI surfaces the related documents for a better editing experience and is surfaced by Payload's
1212
APIs.
1313

@@ -16,6 +16,7 @@ The Join field is useful in scenarios including:
1616
- To surface `Order`s for a given `Product`
1717
- To view and edit `Posts` belonging to a `Category`
1818
- To work with any bi-directional relationship data
19+
- Displaying where a document or upload is used in other documents
1920

2021
<LightDarkImage
2122
srcLight="https://payloadcms.com/images/docs/fields/join.png"
@@ -24,8 +25,8 @@ The Join field is useful in scenarios including:
2425
caption="Admin Panel screenshot of Join field"
2526
/>
2627

27-
For the Join field to work, you must have an existing [relationship](./relationship) field in the collection you are
28-
joining. This will reference the collection and path of the field of the related documents.
28+
For the Join field to work, you must have an existing [relationship](./relationship) or [upload](./upload) field in the
29+
collection you are joining. This will reference the collection and path of the field of the related documents.
2930
To add a Relationship Field, set the `type` to `join` in your [Field Config](./overview):
3031

3132
```ts
@@ -122,7 +123,7 @@ complete control over any type of relational architecture in Payload, all wrappe
122123
|------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
123124
| **`name`** \* | To be used as the property name when retrieved from the database. [More](/docs/fields/overview#field-names) |
124125
| **`collection`** \* | The `slug`s having the relationship field. |
125-
| **`on`** \* | The relationship field name of the field that relates to collection document. Use dot notation for nested paths, like 'myGroup.relationName'. |
126+
| **`on`** \* | The name of the relationship or upload field that relates to the collection document. Use dot notation for nested paths, like 'myGroup.relationName'. |
126127
| **`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) |
127128
| **`label`** | Text used as a field label in the Admin Panel or an object with keys for each language. |
128129
| **`hooks`** | Provide Field Hooks to control logic for this field. [More details](../hooks/fields). |

docs/fields/upload.mdx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ desc: Upload fields will allow a file to be uploaded, only from a collection sup
66
keywords: upload, images media, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, nextjs
77
---
88

9-
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.
9+
The Upload Field allows for the selection of a Document from a Collection supporting [Uploads](../upload/overview), and
10+
formats the selection as a thumbnail in the Admin Panel.
1011

1112
Upload fields are useful for a variety of use cases, such as:
1213

@@ -15,10 +16,10 @@ Upload fields are useful for a variety of use cases, such as:
1516
- To give a layout building block the ability to feature a background image
1617

1718
<LightDarkImage
18-
srcLight="https://payloadcms.com/images/docs/fields/upload.png"
19-
srcDark="https://payloadcms.com/images/docs/fields/upload-dark.png"
20-
alt="Shows an upload field in the Payload Admin Panel"
21-
caption="Admin Panel screenshot of an Upload field"
19+
srcLight="https://payloadcms.com/images/docs/fields/upload.png"
20+
srcDark="https://payloadcms.com/images/docs/fields/upload-dark.png"
21+
alt="Shows an upload field in the Payload Admin Panel"
22+
caption="Admin Panel screenshot of an Upload field"
2223
/>
2324

2425
To create an Upload Field, set the `type` to `upload` in your [Field Config](./overview):
@@ -43,7 +44,7 @@ export const MyUploadField: Field = {
4344
## Config Options
4445

4546
| Option | Description |
46-
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
47+
|------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
4748
| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
4849
| **`*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> |
4950
| **`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
9798
called with an argument object with the following properties:
9899

99100
| Property | Description |
100-
| ------------- | ----------------------------------------------------------------------------------------------------- |
101+
|---------------|-------------------------------------------------------------------------------------------------------|
101102
| `relationTo` | The collection `slug` to filter against, limited to this field's `relationTo` property |
102103
| `data` | An object containing the full collection or global document currently being edited |
103104
| `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).
127128
unless you call the default upload field validation function imported from{' '}
128129
<strong>payload/shared</strong> in your validate function.
129130
</Banner>
131+
132+
## Bi-directional relationships
133+
134+
The `upload` field on its own is used to reference documents in an upload collection. This can be considered a "one-way"
135+
relationship. If you wish to allow an editor to visit the upload document and see where it is being used, you may use
136+
the `join` field in the upload enabled collection. Read more about bi-directional relationships using
137+
the [Join field](./join)

packages/payload/src/fields/config/sanitizeJoinField.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { SanitizedJoins } from '../../collections/config/types.js'
22
import type { Config } from '../../config/types.js'
3-
import type { JoinField, RelationshipField } from './types.js'
3+
import type { JoinField, RelationshipField, UploadField } from './types.js'
44

55
import { APIError } from '../../errors/index.js'
66
import { InvalidFieldJoin } from '../../errors/InvalidFieldJoin.js'
@@ -33,7 +33,7 @@ export const sanitizeJoinField = ({
3333
if (!joinCollection) {
3434
throw new InvalidFieldJoin(field)
3535
}
36-
let joinRelationship: RelationshipField | undefined
36+
let joinRelationship: RelationshipField | UploadField
3737

3838
const pathSegments = field.on.split('.') // Split the schema path into segments
3939
let currentSegmentIndex = 0
@@ -49,9 +49,10 @@ export const sanitizeJoinField = ({
4949
if ('name' in field && field.name === currentSegment) {
5050
// Check if this is the last segment in the path
5151
if (
52-
currentSegmentIndex === pathSegments.length - 1 &&
53-
'type' in field &&
54-
field.type === 'relationship'
52+
(currentSegmentIndex === pathSegments.length - 1 &&
53+
'type' in field &&
54+
field.type === 'relationship') ||
55+
field.type === 'upload'
5556
) {
5657
joinRelationship = field // Return the matched field
5758
next()

packages/ui/src/elements/Table/TableCellProvider/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,12 @@ export const TableCellProvider: React.FC<{
2828
return (
2929
<TableCellContext.Provider
3030
value={{
31+
...contextToInherit,
3132
cellData,
3233
cellProps,
3334
columnIndex,
3435
customCellContext,
3536
rowData,
36-
...contextToInherit,
3737
}}
3838
>
3939
{children}

packages/ui/src/fields/Upload/Input.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ export function UploadInput(props: UploadInputProps) {
153153
collectionSlug: activeRelationTo,
154154
})
155155

156+
/**
157+
* Prevent initial retrieval of documents from running more than once
158+
*/
156159
const loadedValueDocsRef = React.useRef<boolean>(false)
157160

158161
const canCreate = useMemo(() => {
@@ -388,6 +391,7 @@ export function UploadInput(props: UploadInputProps) {
388391
useEffect(() => {
389392
async function loadInitialDocs() {
390393
if (value) {
394+
loadedValueDocsRef.current = true
391395
const loadedDocs = await populateDocs(
392396
Array.isArray(value) ? value : [value],
393397
activeRelationTo,
@@ -398,8 +402,6 @@ export function UploadInput(props: UploadInputProps) {
398402
)
399403
}
400404
}
401-
402-
loadedValueDocsRef.current = true
403405
}
404406

405407
if (!loadedValueDocsRef.current) {

test/joins/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/uploads/

test/joins/collections/Posts.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { CollectionConfig } from 'payload'
22

3-
import { categoriesSlug, postsSlug } from '../shared.js'
3+
import { categoriesSlug, postsSlug, uploadsSlug } from '../shared.js'
44

55
export const Posts: CollectionConfig = {
66
slug: postsSlug,
@@ -13,6 +13,11 @@ export const Posts: CollectionConfig = {
1313
name: 'title',
1414
type: 'text',
1515
},
16+
{
17+
name: 'upload',
18+
type: 'upload',
19+
relationTo: uploadsSlug,
20+
},
1621
{
1722
name: 'category',
1823
type: 'relationship',

test/joins/collections/Uploads.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { CollectionConfig } from 'payload'
2+
3+
import path from 'path'
4+
import { fileURLToPath } from 'url'
5+
6+
import { uploadsSlug } from '../shared.js'
7+
const filename = fileURLToPath(import.meta.url)
8+
const dirname = path.dirname(filename)
9+
10+
export const Uploads: CollectionConfig = {
11+
slug: uploadsSlug,
12+
fields: [
13+
{
14+
name: 'relatedPosts',
15+
type: 'join',
16+
collection: 'posts',
17+
on: 'upload',
18+
},
19+
],
20+
upload: {
21+
staticDir: path.resolve(dirname, '../uploads'),
22+
},
23+
}

test/joins/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from 'path'
44
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
55
import { Categories } from './collections/Categories.js'
66
import { Posts } from './collections/Posts.js'
7+
import { Uploads } from './collections/Uploads.js'
78
import { seed } from './seed.js'
89
import { localizedCategoriesSlug, localizedPostsSlug } from './shared.js'
910

@@ -14,6 +15,7 @@ export default buildConfigWithDefaults({
1415
collections: [
1516
Posts,
1617
Categories,
18+
Uploads,
1719
{
1820
slug: localizedPostsSlug,
1921
admin: {

test/joins/e2e.spec.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
1010
import { navigateToDoc } from '../helpers/e2e/navigateToDoc.js'
1111
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
1212
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
13-
import { categoriesSlug, postsSlug } from './shared.js'
13+
import { categoriesSlug, postsSlug, uploadsSlug } from './shared.js'
1414

1515
const filename = fileURLToPath(import.meta.url)
1616
const dirname = path.dirname(filename)
1717

1818
test.describe('Admin Panel', () => {
1919
let page: Page
2020
let categoriesURL: AdminUrlUtil
21+
let uploadsURL: AdminUrlUtil
2122
let postsURL: AdminUrlUtil
2223

2324
test.beforeAll(async ({ browser }, testInfo) => {
@@ -26,6 +27,7 @@ test.describe('Admin Panel', () => {
2627
const { payload, serverURL } = await initPayloadE2ENoConfig({ dirname })
2728
postsURL = new AdminUrlUtil(serverURL, postsSlug)
2829
categoriesURL = new AdminUrlUtil(serverURL, categoriesSlug)
30+
uploadsURL = new AdminUrlUtil(serverURL, uploadsSlug)
2931

3032
const context = await browser.newContext()
3133
page = await context.newPage()
@@ -183,4 +185,65 @@ test.describe('Admin Panel', () => {
183185
await expect(joinField).toBeVisible()
184186
await expect(joinField.locator('.relationship-table tbody tr')).toBeHidden()
185187
})
188+
189+
test('should update relationship table when new upload is created', async () => {
190+
await navigateToDoc(page, uploadsURL)
191+
const joinField = page.locator('.field-type.join').first()
192+
await expect(joinField).toBeVisible()
193+
194+
const addButton = joinField.locator('.relationship-table__actions button.doc-drawer__toggler', {
195+
hasText: exactText('Add new'),
196+
})
197+
198+
await expect(addButton).toBeVisible()
199+
200+
await addButton.click()
201+
const drawer = page.locator('[id^=doc-drawer_posts_1_]')
202+
await expect(drawer).toBeVisible()
203+
const uploadField = drawer.locator('#field-upload')
204+
await expect(uploadField).toBeVisible()
205+
const uploadValue = uploadField.locator('.upload-relationship-details img')
206+
await expect(uploadValue).toBeVisible()
207+
const titleField = drawer.locator('#field-title')
208+
await expect(titleField).toBeVisible()
209+
await titleField.fill('Test post with upload')
210+
await drawer.locator('button[id="action-save"]').click()
211+
await expect(drawer).toBeHidden()
212+
await expect(
213+
joinField.locator('tbody tr td:nth-child(2)', {
214+
hasText: exactText('Test post with upload'),
215+
}),
216+
).toBeVisible()
217+
})
218+
219+
test('should update relationship table when new upload is created', async () => {
220+
await navigateToDoc(page, uploadsURL)
221+
const joinField = page.locator('.field-type.join').first()
222+
await expect(joinField).toBeVisible()
223+
224+
// TODO: change this to edit the first row in the join table
225+
const addButton = joinField.locator('.relationship-table__actions button.doc-drawer__toggler', {
226+
hasText: exactText('Add new'),
227+
})
228+
229+
await expect(addButton).toBeVisible()
230+
231+
await addButton.click()
232+
const drawer = page.locator('[id^=doc-drawer_posts_1_]')
233+
await expect(drawer).toBeVisible()
234+
const uploadField = drawer.locator('#field-upload')
235+
await expect(uploadField).toBeVisible()
236+
const uploadValue = uploadField.locator('.upload-relationship-details img')
237+
await expect(uploadValue).toBeVisible()
238+
const titleField = drawer.locator('#field-title')
239+
await expect(titleField).toBeVisible()
240+
await titleField.fill('Edited title for upload')
241+
await drawer.locator('button[id="action-save"]').click()
242+
await expect(drawer).toBeHidden()
243+
await expect(
244+
joinField.locator('tbody tr td:nth-child(2)', {
245+
hasText: exactText('Edited title for upload'),
246+
}),
247+
).toBeVisible()
248+
})
186249
})

0 commit comments

Comments
 (0)