Skip to content

Commit

Permalink
feat(ui): allows customizing version diff components, render versions…
Browse files Browse the repository at this point in the history
… ui on the server (payloadcms#10815)

This PR moves the logic for rendering diff field components in the
version comparison view from the client to the server.

This allows us to expose more customization options to the server-side
Payload Config. For example, users can now pass their own diff
components for fields - even including RSCs.

This PR also cleans up the version view types

Implements the following from
payloadcms#4197:
- allow for customization of diff components
- more control over versions screens in general

TODO:
- [x] Bring getFieldPaths fixes into core
- [x] Cleanup and test with scrutiny. Ensure all field types display
their diffs correctly
- [x] Review public API for overriding field types, add docs
- [x] Add e2e test for new public API
  • Loading branch information
AlessioGr authored Jan 28, 2025
1 parent 33ac13d commit c562fbf
Show file tree
Hide file tree
Showing 70 changed files with 11,005 additions and 3,701 deletions.
226 changes: 129 additions & 97 deletions docs/fields/overview.mdx

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions packages/next/src/views/Version/Default/SelectedLocalesContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'

import React, { createContext } from 'react'

type SelectedLocalesContextType = {
selectedLocales: string[]
}

export const SelectedLocalesContext = createContext<SelectedLocalesContextType>({
selectedLocales: [],
})

export const useSelectedLocales = () => React.useContext(SelectedLocalesContext)
110 changes: 63 additions & 47 deletions packages/next/src/views/Version/Default/index.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,90 @@
'use client'
import type { OptionObject } from 'payload'

import {
CheckboxInput,
Gutter,
useConfig, useDocumentInfo, usePayloadAPI, useTranslation } from '@payloadcms/ui'
import { CheckboxInput, Gutter, useConfig, useDocumentInfo, useTranslation } from '@payloadcms/ui'
import { formatDate } from '@payloadcms/ui/shared'
import React, { useState } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation.js'
import React, { useEffect, useMemo, useState } from 'react'

import type { CompareOption, DefaultVersionsViewProps } from './types.js'

import { diffComponents } from '../RenderFieldsToDiff/fields/index.js'
import { RenderFieldsToDiff } from '../RenderFieldsToDiff/index.js'
import Restore from '../Restore/index.js'
import { SelectComparison } from '../SelectComparison/index.js'
import { SelectLocales } from '../SelectLocales/index.js'
import './index.scss'
import { SelectLocales } from '../SelectLocales/index.js'
import { SelectedLocalesContext } from './SelectedLocalesContext.js'
import { SetStepNav } from './SetStepNav.js'

const baseClass = 'view-version'

export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
canUpdate,
doc,
docPermissions,
initialComparisonDoc,
latestDraftVersion,
latestPublishedVersion,
localeOptions,
modifiedOnly: modifiedOnlyProp,
RenderedDiff,
selectedLocales: selectedLocalesProp,
versionID,
}) => {
const { config, getEntityConfig } = useConfig()

const availableLocales = useMemo(
() =>
config.localization
? config.localization.locales.map((locale) => ({
label: locale.label,
value: locale.code,
}))
: [],
[config.localization],
)

const { i18n } = useTranslation()
const { id, collectionSlug, globalSlug } = useDocumentInfo()

const [collectionConfig] = useState(() => getEntityConfig({ collectionSlug }))

const [globalConfig] = useState(() => getEntityConfig({ globalSlug }))

const [locales, setLocales] = useState<OptionObject[]>(localeOptions)
const [selectedLocales, setSelectedLocales] = useState<OptionObject[]>(selectedLocalesProp)

const [compareValue, setCompareValue] = useState<CompareOption>()
const [modifiedOnly, setModifiedOnly] = useState(false)
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [modifiedOnly, setModifiedOnly] = useState(modifiedOnlyProp)
function onToggleModifiedOnly() {
setModifiedOnly(!modifiedOnly)
}

useEffect(() => {
// If the selected comparison doc or locales change, update URL params so that version page RSC
// can update the version comparison state
const current = new URLSearchParams(Array.from(searchParams.entries()))

if (!compareValue) {
current.delete('compareValue')
} else {
current.set('compareValue', compareValue?.value)
}
if (!selectedLocales) {
current.delete('localeCodes')
} else {
current.set('localeCodes', JSON.stringify(selectedLocales.map((locale) => locale.value)))
}

if (!modifiedOnly) {
current.delete('modifiedOnly')
} else {
current.set('modifiedOnly', 'true')
}

const search = current.toString()
const query = search ? `?${search}` : ''
router.push(`${pathname}${query}`)
}, [compareValue, pathname, router, searchParams, selectedLocales, modifiedOnly])

const {
admin: { dateFormat },
localization,
Expand All @@ -60,19 +100,6 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
collectionSlug || globalSlug
}/versions`

const compareFetchURL = compareValue?.value && `${compareBaseURL}/${compareValue.value}`

const [{ data: currentComparisonDoc }] = usePayloadAPI(compareFetchURL, {
initialData: initialComparisonDoc,
initialParams: { depth: 1, draft: 'true', locale: 'all' },
})

const comparison = compareValue?.value && currentComparisonDoc?.version // the `version` key is only present on `versions` documents

const canUpdate = docPermissions?.update

const localeValues = locales && locales.map((locale) => locale.value)

const draftsEnabled = Boolean((collectionConfig || globalConfig)?.versions.drafts)

return (
Expand Down Expand Up @@ -129,29 +156,18 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
versionID={versionID}
/>
{localization && (
<SelectLocales onChange={setLocales} options={localeOptions} value={locales} />
<SelectLocales
onChange={setSelectedLocales}
options={availableLocales}
value={selectedLocales}
/>
)}
</div>
{doc?.version && (
<RenderFieldsToDiff
comparison={comparison}
diffComponents={diffComponents}
fieldPermissions={docPermissions?.fields}
fields={(collectionConfig || globalConfig)?.fields}
i18n={i18n}
locales={localeValues}
modifiedOnly={modifiedOnly}
version={
globalConfig
? {
...doc?.version,
createdAt: doc?.version?.createdAt || doc.createdAt,
updatedAt: doc?.version?.updatedAt || doc.updatedAt,
}
: doc?.version
}
/>
)}
<SelectedLocalesContext.Provider
value={{ selectedLocales: selectedLocales.map((locale) => locale.value) }}
>
{doc?.version && RenderedDiff}
</SelectedLocalesContext.Provider>
</Gutter>
</main>
)
Expand Down
14 changes: 5 additions & 9 deletions packages/next/src/views/Version/Default/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import type {
Document,
OptionObject,
SanitizedCollectionPermission,
SanitizedGlobalPermission,
} from 'payload'
import type { Document, OptionObject } from 'payload'

export type CompareOption = {
label: React.ReactNode | string
Expand All @@ -13,11 +8,12 @@ export type CompareOption = {
}

export type DefaultVersionsViewProps = {
readonly canUpdate: boolean
readonly doc: Document
readonly docPermissions: SanitizedCollectionPermission | SanitizedGlobalPermission
readonly initialComparisonDoc: Document
readonly latestDraftVersion?: string
readonly latestPublishedVersion?: string
readonly localeOptions: OptionObject[]
modifiedOnly: boolean
readonly RenderedDiff: React.ReactNode
readonly selectedLocales: OptionObject[]
readonly versionID?: string
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use client'
const baseClass = 'render-field-diffs'
import type { VersionField } from 'payload'

import './index.scss'

import { ShimmerEffect } from '@payloadcms/ui'
import React, { Fragment, useEffect } from 'react'

export const RenderVersionFieldsToDiff = ({
versionFields,
}: {
versionFields: VersionField[]
}): React.ReactNode => {
const [hasMounted, setHasMounted] = React.useState(false)

// defer rendering until after the first mount as the CSS is loaded with Emotion
// this will ensure that the CSS is loaded before rendering the diffs and prevent CLS
useEffect(() => {
setHasMounted(true)
}, [])

return (
<div className={baseClass}>
{!hasMounted ? (
<Fragment>
<ShimmerEffect height="8rem" width="100%" />
</Fragment>
) : (
versionFields?.map((field, fieldIndex) => {
if (field.fieldByLocale) {
const LocaleComponents: React.ReactNode[] = []
for (const [locale, baseField] of Object.entries(field.fieldByLocale)) {
LocaleComponents.push(
<div className={`${baseClass}__locale`} key={[locale, fieldIndex].join('-')}>
<div className={`${baseClass}__locale-value`}>{baseField.CustomComponent}</div>
</div>,
)
}
return (
<div className={`${baseClass}__field`} key={fieldIndex}>
{LocaleComponents}
</div>
)
} else if (field.field) {
return (
<div
className={`${baseClass}__field field__${field.field.type}`}
data-field-path={field.field.path}
key={fieldIndex}
>
{field.field.CustomComponent}
</div>
)
}

return null
})
)}
</div>
)
}
Loading

0 comments on commit c562fbf

Please sign in to comment.