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'