Files
payloadcms/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx
aptt abbe38fbaf fix(plugin-seo): allow number ids & resolved relations for image auto-generation (#13906)
### What?

- The SEO plugin's `generateImage` type now also allows returning
`number` in addition to `string` and objects with an `id` property of
the same.

### Why?

`generateImage` can't be used as specified in [the
docs](https://payloadcms.com/docs/plugins/seo#generateimage) if it's
fully typed using Payload-generated collection types. Typechecking only
works because of the fallback `any` type.

This function seems to assume that the setup works with string-based
IDs. This is not necessarily the case for SQL-like databases like
Postgres.

If I fully type the `args` and consequently the return type using the
types Payload generates for me in my setup (which has `number` ids),
then the type signature of my `generateImage` doesn't conform to what
the plugin expects and typechecking fails.

Additionally, Payload's generated types for relation fields are an
ID-object union because it can't know how relations are resolved in
every context. I believe this to be the correct choice.

But it means that `generateImage` might possibly return a media object
(based on the types alone) which would break the functionality. It's
therefore safest to allow this and handle it in the UI, like [it is
already being done in the upload field's `onChange`
handler](39143c9d12/packages/plugin-seo/src/fields/MetaImage/MetaImageComponent.tsx (L183)).

### How?

By
- [widening `GenerateImage`'s return type to allow for a more diverse
set of
configurations](a0ea58d81d (diff-bad1e1b58992c48178ea7d0dfa546f66bfa6e10eed2dd3db5c74e092824fa7ffL58))
- [handling objects in the UI
component](a0ea58d81d)

Fixes #13905
2025-09-24 19:51:00 +00:00

231 lines
5.9 KiB
TypeScript

'use client'
import type { FieldType } from '@payloadcms/ui'
import type { UploadFieldClientProps } from 'payload'
import {
FieldLabel,
RenderCustomComponent,
UploadInput,
useConfig,
useDocumentInfo,
useDocumentTitle,
useField,
useForm,
useLocale,
useTranslation,
} from '@payloadcms/ui'
import { reduceToSerializableFields } from '@payloadcms/ui/shared'
import React, { useCallback } from 'react'
import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../../translations/index.js'
import type { GenerateImage } from '../../types.js'
import { Pill } from '../../ui/Pill.js'
type MetaImageProps = {
readonly hasGenerateImageFn: boolean
} & UploadFieldClientProps
export const MetaImageComponent: React.FC<MetaImageProps> = (props) => {
const {
field: { admin: { allowCreate } = {}, label, localized, relationTo, required },
hasGenerateImageFn,
readOnly,
} = props
const {
config: {
routes: { api },
serverURL,
},
getEntityConfig,
} = useConfig()
const {
customComponents: { Error, Label } = {},
filterOptions,
path,
setValue,
showError,
value,
}: FieldType<number | string> = useField()
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()
const locale = useLocale()
const { getData } = useForm()
const docInfo = useDocumentInfo()
const { title } = useDocumentTitle()
const regenerateImage = useCallback(async () => {
if (!hasGenerateImageFn) {
return
}
const endpoint = `${serverURL}${api}/plugin-seo/generate-image`
const genImageResponse = await fetch(endpoint, {
body: JSON.stringify({
id: docInfo.id,
collectionSlug: docInfo.collectionSlug,
doc: getData(),
docPermissions: docInfo.docPermissions,
globalSlug: docInfo.globalSlug,
hasPublishPermission: docInfo.hasPublishPermission,
hasSavePermission: docInfo.hasSavePermission,
initialData: docInfo.initialData,
initialState: reduceToSerializableFields(docInfo.initialState ?? {}),
locale: typeof locale === 'object' ? locale?.code : locale,
title,
} satisfies Omit<
Parameters<GenerateImage>[0],
'collectionConfig' | 'globalConfig' | 'hasPublishedDoc' | 'req' | 'versionCount'
>),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
})
const { result: generatedImage } = await genImageResponse.json()
// string ids, number ids or nullish values
let newValue: null | number | string | undefined = generatedImage
// non-nullish resolved relations
if (typeof generatedImage === 'object' && generatedImage && 'id' in generatedImage) {
newValue = generatedImage.id
}
// coerce to an empty string for falsy (=empty) values
setValue(newValue || '')
}, [
hasGenerateImageFn,
serverURL,
api,
docInfo.id,
docInfo.collectionSlug,
docInfo.docPermissions,
docInfo.globalSlug,
docInfo.hasPublishPermission,
docInfo.hasSavePermission,
docInfo.initialData,
docInfo.initialState,
getData,
locale,
setValue,
title,
])
const hasImage = Boolean(value)
const collection = getEntityConfig({ collectionSlug: relationTo })
return (
<div
style={{
marginBottom: '20px',
}}
>
<div
style={{
marginBottom: '5px',
position: 'relative',
}}
>
<div className="plugin-seo__field">
<RenderCustomComponent
CustomComponent={Label}
Fallback={
<FieldLabel label={label} localized={localized} path={path} required={required} />
}
/>
{hasGenerateImageFn && (
<React.Fragment>
&nbsp; &mdash; &nbsp;
<button
disabled={readOnly}
onClick={() => {
void regenerateImage()
}}
style={{
background: 'none',
backgroundColor: 'transparent',
border: 'none',
color: 'currentcolor',
cursor: 'pointer',
padding: 0,
textDecoration: 'underline',
}}
type="button"
>
{t('plugin-seo:autoGenerate')}
</button>
</React.Fragment>
)}
</div>
{hasGenerateImageFn && (
<div
style={{
color: '#9A9A9A',
}}
>
{t('plugin-seo:imageAutoGenerationTip')}
</div>
)}
</div>
<div
style={{
marginBottom: '10px',
position: 'relative',
}}
>
<UploadInput
allowCreate={allowCreate !== false}
api={api}
collection={collection}
Error={Error}
filterOptions={filterOptions}
onChange={(incomingImage) => {
if (incomingImage !== null) {
if (typeof incomingImage === 'object') {
const { id: incomingID } = incomingImage
setValue(incomingID)
} else {
setValue(incomingImage)
}
} else {
setValue(null)
}
}}
path={path}
readOnly={readOnly}
relationTo={relationTo}
required={required}
serverURL={serverURL}
showError={showError}
style={{
marginBottom: 0,
}}
value={value}
/>
</div>
<div
style={{
alignItems: 'center',
display: 'flex',
width: '100%',
}}
>
<Pill
backgroundColor={hasImage ? 'green' : 'red'}
color="white"
label={hasImage ? t('plugin-seo:good') : t('plugin-seo:noImage')}
/>
</div>
</div>
)
}