### 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
231 lines
5.9 KiB
TypeScript
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>
|
|
—
|
|
<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>
|
|
)
|
|
}
|