From 39e110e6331efff0ca8ca7174780076243a016de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20K=C5=82os?= Date: Thu, 1 Aug 2024 16:09:59 +0200 Subject: [PATCH] feat: adds upload's relationship thumbnail (#5015) ## Description I've made an implementation of the feature requested here: https://github.com/payloadcms/payload/discussions/3407 Before: ![CleanShot 2024-02-07 at 00 39 47](https://github.com/payloadcms/payload/assets/34719093/4b182118-41bd-47f7-af03-a0b739f7e407) After: ![CleanShot 2024-02-07 at 00 40 17](https://github.com/payloadcms/payload/assets/34719093/d813de81-bab5-40b2-b31c-5a7ee107dabd) - [x] I have read and understand the [CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md) document in this repository. ## Type of change - [x] New feature (non-breaking change which adds functionality) ## Checklist: - [x] I have added tests that prove my fix is effective or that my feature works - [x] Existing test suite passes locally with my changes --- docs/fields/upload.mdx | 1 + docs/upload/overview.mdx | 1 + .../Cell/field-types/Relationship/index.tsx | 20 ++++- .../payload/src/collections/config/schema.ts | 1 + packages/payload/src/fields/config/schema.ts | 1 + packages/payload/src/fields/config/types.ts | 1 + packages/payload/src/uploads/types.ts | 2 + test/uploads/config.ts | 89 +++++++++++++++++++ test/uploads/e2e.spec.ts | 49 +++++++++- test/uploads/shared.ts | 3 + 10 files changed, 164 insertions(+), 4 deletions(-) diff --git a/docs/fields/upload.mdx b/docs/fields/upload.mdx index 8149ae159d..03b2d6bfc7 100644 --- a/docs/fields/upload.mdx +++ b/docs/fields/upload.mdx @@ -49,6 +49,7 @@ caption="Admin panel screenshot of an Upload field" | **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) | | **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. | | **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`displayPreview`** | Enable displaying preview of the uploaded file. Overrides related Collection's `displayPreview` option. [More](/docs/upload/overview#collection-upload-options). | | **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | | **`required`** | Require this field to have a value. | | **`admin`** | Admin-specific configuration. See the [default field admin config](/docs/fields/overview#admin-config) for more details. | diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 99a5219be0..f4e929e7d8 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -47,6 +47,7 @@ Every Payload Collection can opt-in to supporting Uploads by specifying the `upl | **`adminThumbnail`** | Set the way that the Admin panel will display thumbnails for this Collection. [More](#admin-thumbnails) | | **`crop`** | Set to `false` to disable the cropping tool in the Admin panel. Crop is enabled by default. [More](#crop-and-focal-point-selector) | | **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) | +| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config). | | **`externalFileHeaderFilter`** | Accepts existing headers and can filter/modify them. | | **`focalPoint`** | Set to `false` to disable the focal point selection tool in the Admin panel. The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) | | **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) | diff --git a/packages/payload/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx b/packages/payload/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx index a2c2702381..446058decd 100644 --- a/packages/payload/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx +++ b/packages/payload/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import type { RelationshipField } from '../../../../../../../../exports/types' +import type { RelationshipField, UploadField } from '../../../../../../../../exports/types' import type { CellComponentProps } from '../../types' import { getTranslation } from '../../../../../../../../utilities/getTranslation' @@ -9,13 +9,14 @@ import useIntersect from '../../../../../../../hooks/useIntersect' import { formatUseAsTitle } from '../../../../../../../hooks/useTitle' import { useConfig } from '../../../../../../utilities/Config' import { useListRelationships } from '../../../RelationshipProvider' +import File from '../File' import './index.scss' type Value = { relationTo: string; value: number | string } const baseClass = 'relationship-cell' const totalToShow = 3 -const RelationshipCell: React.FC> = (props) => { +const RelationshipCell: React.FC> = (props) => { const { data: cellData, field } = props const config = useConfig() const { collections, routes } = config @@ -68,11 +69,24 @@ const RelationshipCell: React.FC> = (props i18n, }) + let fileField = null + if (field.type === 'upload') { + const relatedCollectionPreview = !!relatedCollection.upload.displayPreview + const fieldPreview = field.displayPreview + const previewAllowed = + fieldPreview || (relatedCollectionPreview && fieldPreview !== false) + if (previewAllowed && document) { + fileField = ( + + ) + } + } + return ( {document === false && `${t('untitled')} - ID: ${value}`} {document === null && `${t('loading')}...`} - {document && (label || `${t('untitled')} - ID: ${value}`)} + {document && (fileField || label || `${t('untitled')} - ID: ${value}`)} {values.length > i + 1 && ', '} ) diff --git a/packages/payload/src/collections/config/schema.ts b/packages/payload/src/collections/config/schema.ts index 23e28df780..dccd688038 100644 --- a/packages/payload/src/collections/config/schema.ts +++ b/packages/payload/src/collections/config/schema.ts @@ -171,6 +171,7 @@ const collectionSchema = joi.object().keys({ adminThumbnail: joi.alternatives().try(joi.string(), joi.func()), crop: joi.bool(), disableLocalStorage: joi.bool(), + displayPreview: joi.bool().default(false), externalFileHeaderFilter: joi.func(), filesRequiredOnCreate: joi.bool(), focalPoint: joi.bool(), diff --git a/packages/payload/src/fields/config/schema.ts b/packages/payload/src/fields/config/schema.ts index 514fd490be..6c86a59436 100644 --- a/packages/payload/src/fields/config/schema.ts +++ b/packages/payload/src/fields/config/schema.ts @@ -342,6 +342,7 @@ export const upload = baseField.keys({ }), }), defaultValue: joi.alternatives().try(joi.object(), joi.func()), + displayPreview: joi.boolean().default(false), filterOptions: joi.alternatives().try(joi.object(), joi.func()), maxDepth: joi.number(), relationTo: joi.string().required(), diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index f97d2b97f5..da1cfe0ebb 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -408,6 +408,7 @@ export type UploadField = FieldBase & { Label?: React.ComponentType } } + displayPreview?: boolean filterOptions?: FilterOptions maxDepth?: number relationTo: string diff --git a/packages/payload/src/uploads/types.ts b/packages/payload/src/uploads/types.ts index e01401ce8a..cd35526620 100644 --- a/packages/payload/src/uploads/types.ts +++ b/packages/payload/src/uploads/types.ts @@ -77,6 +77,7 @@ export type IncomingUploadType = { adminThumbnail?: GetAdminThumbnail | string crop?: boolean disableLocalStorage?: boolean + displayPreview?: boolean /** * Accepts existing headers and can filter/modify them. * @@ -102,6 +103,7 @@ export type Upload = { adminThumbnail?: GetAdminThumbnail | string crop?: boolean disableLocalStorage?: boolean + displayPreview?: boolean filesRequiredOnCreate?: boolean focalPoint?: boolean formatOptions?: ImageUploadFormatOptions diff --git a/test/uploads/config.ts b/test/uploads/config.ts index 3f90a1cb84..92a9c8eafd 100644 --- a/test/uploads/config.ts +++ b/test/uploads/config.ts @@ -15,7 +15,10 @@ import { focalOnlySlug, globalWithMedia, mediaSlug, + mediaWithRelationPreviewSlug, + mediaWithoutRelationPreviewSlug, reduceSlug, + relationPreviewSlug, relationSlug, versionSlug, } from './shared' @@ -583,6 +586,67 @@ export default buildConfigWithDefaults({ drafts: true, }, }, + { + slug: mediaWithRelationPreviewSlug, + fields: [ + { + name: 'title', + type: 'text', + }, + ], + upload: { + displayPreview: true, + }, + }, + { + slug: mediaWithoutRelationPreviewSlug, + fields: [ + { + name: 'title', + type: 'text', + }, + ], + upload: true, + }, + { + slug: relationPreviewSlug, + fields: [ + { + name: 'imageWithPreview1', + type: 'upload', + relationTo: mediaWithRelationPreviewSlug, + }, + { + name: 'imageWithPreview2', + type: 'upload', + relationTo: mediaWithRelationPreviewSlug, + displayPreview: true, + }, + { + name: 'imageWithoutPreview1', + type: 'upload', + relationTo: mediaWithRelationPreviewSlug, + displayPreview: false, + }, + { + name: 'imageWithoutPreview2', + type: 'upload', + relationTo: mediaWithoutRelationPreviewSlug, + }, + { + name: 'imageWithPreview3', + type: 'upload', + relationTo: mediaWithoutRelationPreviewSlug, + displayPreview: true, + }, + { + name: 'imageWithoutPreview3', + type: 'upload', + relationTo: mediaWithoutRelationPreviewSlug, + displayPreview: false, + }, + ], + }, ], globals: [ { @@ -707,6 +771,31 @@ export default buildConfigWithDefaults({ name: `thumb-${imageFile.name}`, }, }) + + // Create media with and without relation preview + const { id: uploadedImageWithPreview } = await payload.create({ + collection: mediaWithRelationPreviewSlug, + data: {}, + file: imageFile, + }) + + const { id: uploadedImageWithoutPreview } = await payload.create({ + collection: mediaWithoutRelationPreviewSlug, + data: {}, + file: imageFile, + }) + + await payload.create({ + collection: relationPreviewSlug, + data: { + imageWithPreview1: uploadedImageWithPreview, + imageWithPreview2: uploadedImageWithPreview, + imageWithoutPreview1: uploadedImageWithPreview, + imageWithoutPreview2: uploadedImageWithoutPreview, + imageWithPreview3: uploadedImageWithoutPreview, + imageWithoutPreview3: uploadedImageWithoutPreview, + }, + }) }, serverURL: undefined, }) diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index 487fbb3258..2b6b15981d 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -7,7 +7,7 @@ import type { Media } from './payload-types' import payload from '../../packages/payload/src' import wait from '../../packages/payload/src/utilities/wait' -import { initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers' +import { exactText, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers' import { AdminUrlUtil } from '../helpers/adminUrlUtil' import { initPayloadE2E } from '../helpers/configHelpers' import { RESTClient } from '../helpers/rest' @@ -19,6 +19,7 @@ import { focalOnlySlug, globalWithMedia, mediaSlug, + relationPreviewSlug, relationSlug, withMetadataSlug, withOnlyJPEGMetadataSlug, @@ -38,6 +39,7 @@ let focalOnlyURL: AdminUrlUtil let withMetadataURL: AdminUrlUtil let withoutMetadataURL: AdminUrlUtil let withOnlyJPEGMetadataURL: AdminUrlUtil +let relationPreviewURL: AdminUrlUtil describe('uploads', () => { let page: Page @@ -59,6 +61,7 @@ describe('uploads', () => { withMetadataURL = new AdminUrlUtil(serverURL, withMetadataSlug) withoutMetadataURL = new AdminUrlUtil(serverURL, withoutMetadataSlug) withOnlyJPEGMetadataURL = new AdminUrlUtil(serverURL, withOnlyJPEGMetadataSlug) + relationPreviewURL = new AdminUrlUtil(serverURL, relationPreviewSlug) const context = await browser.newContext() page = await context.newPage() @@ -536,6 +539,50 @@ describe('uploads', () => { }) }) + test('should see upload previews in relation list if allowed in config', async () => { + await page.goto(relationPreviewURL.list) + + await wait(110) + + // Show all columns with relations + await page.locator('.list-controls__toggle-columns').click() + await expect(page.locator('.column-selector')).toBeVisible() + const imageWithoutPreview2Button = page.locator(`.column-selector .column-selector__column`, { + hasText: exactText('Image Without Preview2'), + }) + const imageWithPreview3Button = page.locator(`.column-selector .column-selector__column`, { + hasText: exactText('Image With Preview3'), + }) + const imageWithoutPreview3Button = page.locator(`.column-selector .column-selector__column`, { + hasText: exactText('Image Without Preview3'), + }) + await imageWithoutPreview2Button.click() + await imageWithPreview3Button.click() + await imageWithoutPreview3Button.click() + + // Wait for the columns to be displayed + await expect(page.locator('.cell-imageWithoutPreview3')).toBeVisible() + + // collection's displayPreview: true, field's displayPreview: unset + const relationPreview1 = page.locator('.cell-imageWithPreview1 img') + await expect(relationPreview1).toBeVisible() + // collection's displayPreview: true, field's displayPreview: true + const relationPreview2 = page.locator('.cell-imageWithPreview2 img') + await expect(relationPreview2).toBeVisible() + // collection's displayPreview: true, field's displayPreview: false + const relationPreview3 = page.locator('.cell-imageWithoutPreview1 img') + await expect(relationPreview3).toBeHidden() + // collection's displayPreview: false, field's displayPreview: unset + const relationPreview4 = page.locator('.cell-imageWithoutPreview2 img') + await expect(relationPreview4).toBeHidden() + // collection's displayPreview: false, field's displayPreview: true + const relationPreview5 = page.locator('.cell-imageWithPreview3 img') + await expect(relationPreview5).toBeVisible() + // collection's displayPreview: false, field's displayPreview: false + const relationPreview6 = page.locator('.cell-imageWithoutPreview3 img') + await expect(relationPreview6).toBeHidden() + }) + describe('globals', () => { test('should be able to crop media from a global', async () => { await page.goto(globalURL) diff --git a/test/uploads/shared.ts b/test/uploads/shared.ts index 56ace6b708..2af7e65013 100644 --- a/test/uploads/shared.ts +++ b/test/uploads/shared.ts @@ -6,6 +6,9 @@ export const focalOnlySlug = 'focal-only' export const mediaSlug = 'media' export const reduceSlug = 'reduce' export const relationSlug = 'relation' +export const relationPreviewSlug = 'relation-preview' +export const mediaWithRelationPreviewSlug = 'media-with-relation-preview' +export const mediaWithoutRelationPreviewSlug = 'media-without-relation-preview' export const versionSlug = 'versions' export const globalWithMedia = 'global-with-media' export const animatedTypeMedia = 'animated-type-media'