feat: allows fields to be collapsed in the version view diff (#8054)

## Description

Allows some fields to be collapsed in the version diff view. The fields
that can be collapsed are the ones which can also be collapsed in the
edit view, or that have visual grouping:
- `collapsible` 
- `group`
- `array`  (and their rows)
- `blocks` (and their rows)
- `tabs`

It also 
- Fixes incorrect indentation of some fields
- Fixes the rendering of localized tabs in the diff view
- Fixes locale labels for the group field
- Adds a field change count to each collapsible diff (could imagine this
being used in other places)
- Brings the indentation gutter back to help visualize multiple nesting
levels


## Future improvements
- Persist collapsed state across page reloads (sessionStorage vs
preferences)

## Screenshots

### Without locales

![comparison](https://github.com/user-attachments/assets/754be708-be6d-43b4-bbe3-5d64ab6a0f76)


### With locales
![comparison with
locales](https://github.com/user-attachments/assets/02fb47fb-fa38-4195-8376-67bfda7f282d)

-------------- 

- [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

<!-- Please delete options that are not relevant. -->

- [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
- [ ] ~I have made corresponding changes to the documentation~
This commit is contained in:
Francisco Lourenço
2025-01-25 02:32:55 +08:00
committed by GitHub
parent 92e6beb050
commit 828b3b71c0
65 changed files with 1585 additions and 241 deletions

View File

@@ -8,7 +8,7 @@ import React, { useState } from 'react'
import type { CompareOption, DefaultVersionsViewProps } from './types.js'
import { diffComponents } from '../RenderFieldsToDiff/fields/index.js'
import RenderFieldsToDiff from '../RenderFieldsToDiff/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'

View File

@@ -0,0 +1,46 @@
@layer payload-default {
.diff-collapser {
&__toggle-button {
all: unset;
cursor: pointer;
// Align the chevron visually with the label text
vertical-align: 1px;
}
&__label {
// Add space between label, chevron, and change count
margin: 0 calc(var(--base) * 0.25);
}
&__field-change-count {
// Reset the font weight of the change count to normal
font-weight: normal;
}
&__content {
[dir='ltr'] & {
// Vertical gutter
border-left: 3px solid var(--theme-elevation-50);
// Center-align the gutter with the chevron
margin-left: 3px;
// Content indentation
padding-left: calc(var(--base) * 0.5);
}
[dir='rtl'] & {
// Vertical gutter
border-right: 3px solid var(--theme-elevation-50);
// Center-align the gutter with the chevron
margin-right: 3px;
// Content indentation
padding-right: calc(var(--base) * 0.5);
}
}
&__content--is-collapsed {
// Hide the content when collapsed. We use display: none instead of
// conditional rendering to avoid loosing children's collapsed state when
// remounting.
display: none;
}
}
}

View File

@@ -0,0 +1,113 @@
import type { ClientField } from 'payload'
import { ChevronIcon, Pill, useTranslation } from '@payloadcms/ui'
import { fieldIsArrayType, fieldIsBlockType } from 'payload/shared'
import React, { useState } from 'react'
import Label from '../Label/index.js'
import './index.scss'
import { countChangedFields, countChangedFieldsInRows } from '../utilities/countChangedFields.js'
const baseClass = 'diff-collapser'
type Props =
| {
// fields collapser
children: React.ReactNode
comparison: unknown
field?: never
fields: ClientField[]
initCollapsed?: boolean
isIterable?: false
label: React.ReactNode
locales: string[] | undefined
version: unknown
}
| {
// iterable collapser
children: React.ReactNode
comparison?: unknown
field: ClientField
fields?: never
initCollapsed?: boolean
isIterable: true
label: React.ReactNode
locales: string[] | undefined
version: unknown
}
export const DiffCollapser: React.FC<Props> = ({
children,
comparison,
field,
fields,
initCollapsed = false,
isIterable = false,
label,
locales,
version,
}) => {
const { t } = useTranslation()
const [isCollapsed, setIsCollapsed] = useState(initCollapsed)
let changeCount = 0
if (isIterable) {
if (!fieldIsArrayType(field) && !fieldIsBlockType(field)) {
throw new Error(
'DiffCollapser: field must be an array or blocks field when isIterable is true',
)
}
const comparisonRows = comparison ?? []
const versionRows = version ?? []
if (!Array.isArray(comparisonRows) || !Array.isArray(versionRows)) {
throw new Error(
'DiffCollapser: comparison and version must be arrays when isIterable is true',
)
}
changeCount = countChangedFieldsInRows({
comparisonRows,
field,
locales,
versionRows,
})
} else {
changeCount = countChangedFields({
comparison,
fields,
locales,
version,
})
}
const contentClassNames = [
`${baseClass}__content`,
isCollapsed && `${baseClass}__content--is-collapsed`,
]
.filter(Boolean)
.join(' ')
return (
<div className={baseClass}>
<Label>
<button
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
className={`${baseClass}__toggle-button`}
onClick={() => setIsCollapsed(!isCollapsed)}
type="button"
>
<ChevronIcon direction={isCollapsed ? 'right' : 'down'} />
</button>
<span className={`${baseClass}__label`}>{label}</span>
{changeCount > 0 && (
<Pill className={`${baseClass}__field-change-count`} pillStyle="light-gray" size="small">
{t('version:changedFieldsCount', { count: changeCount })}
</Pill>
)}
</Label>
<div className={contentClassNames}>{children}</div>
</div>
)
}

View File

@@ -0,0 +1,47 @@
'use client'
import { getTranslation } from '@payloadcms/translations'
import React from 'react'
import type { DiffComponentProps } from '../types.js'
import { DiffCollapser } from '../../DiffCollapser/index.js'
import { RenderFieldsToDiff } from '../../index.js'
const baseClass = 'collapsible-diff'
export const Collapsible: React.FC<DiffComponentProps> = ({
comparison,
diffComponents,
field,
fieldPermissions,
fields,
i18n,
locales,
version,
}) => {
return (
<div className={baseClass}>
<DiffCollapser
comparison={comparison}
fields={fields}
label={
'label' in field &&
field.label &&
typeof field.label !== 'function' && <span>{getTranslation(field.label, i18n)}</span>
}
locales={locales}
version={version}
>
<RenderFieldsToDiff
comparison={comparison}
diffComponents={diffComponents}
fieldPermissions={fieldPermissions}
fields={fields}
i18n={i18n}
locales={locales}
version={version}
/>
</DiffCollapser>
</div>
)
}

View File

@@ -0,0 +1,14 @@
@layer payload-default {
.group-diff {
&__locale-label {
background: var(--theme-elevation-100);
padding: calc(var(--base) * 0.25);
[dir='ltr'] & {
margin-right: calc(var(--base) * 0.25);
}
[dir='rtl'] & {
margin-left: calc(var(--base) * 0.25);
}
}
}
}

View File

@@ -0,0 +1,55 @@
'use client'
import { getTranslation } from '@payloadcms/translations'
import React from 'react'
import './index.scss'
import type { DiffComponentProps } from '../types.js'
import { DiffCollapser } from '../../DiffCollapser/index.js'
import { RenderFieldsToDiff } from '../../index.js'
const baseClass = 'group-diff'
export const Group: React.FC<DiffComponentProps> = ({
comparison,
diffComponents,
field,
fieldPermissions,
fields,
i18n,
locale,
locales,
version,
}) => {
return (
<div className={baseClass}>
<DiffCollapser
comparison={comparison}
fields={fields}
label={
'label' in field &&
field.label &&
typeof field.label !== 'function' && (
<span>
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
{getTranslation(field.label, i18n)}
</span>
)
}
locales={locales}
version={version}
>
<RenderFieldsToDiff
comparison={comparison}
diffComponents={diffComponents}
fieldPermissions={fieldPermissions}
fields={fields}
i18n={i18n}
locales={locales}
version={version}
/>
</DiffCollapser>
</div>
)
}

View File

@@ -1,7 +1,5 @@
@layer payload-default {
.iterable-diff {
margin-bottom: calc(var(--base) * 2);
&__locale-label {
background: var(--theme-elevation-100);
padding: calc(var(--base) * 0.25);
@@ -14,16 +12,9 @@
}
}
&__wrap {
margin: calc(var(--base) * 0.5);
[dir='ltr'] & {
padding-left: calc(var(--base) * 0.5);
// border-left: $style-stroke-width-s solid var(--theme-elevation-150);
}
[dir='rtl'] & {
padding-right: calc(var(--base) * 0.5);
// border-right: $style-stroke-width-s solid var(--theme-elevation-150);
}
// Space between each row
&__row:not(:first-of-type) {
margin-top: calc(var(--base) * 0.5);
}
&__no-rows {

View File

@@ -2,18 +2,19 @@
import type { ClientField } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { getUniqueListBy } from 'payload/shared'
import { fieldIsArrayType, fieldIsBlockType } from 'payload/shared'
import React from 'react'
import type { DiffComponentProps } from '../types.js'
import RenderFieldsToDiff from '../../index.js'
import Label from '../../Label/index.js'
import { DiffCollapser } from '../../DiffCollapser/index.js'
import './index.scss'
import { RenderFieldsToDiff } from '../../index.js'
import { getFieldsForRowComparison } from '../../utilities/getFieldsForRowComparison.js'
const baseClass = 'iterable-diff'
const Iterable: React.FC<DiffComponentProps> = ({
export const Iterable: React.FC<DiffComponentProps> = ({
comparison,
diffComponents,
field,
@@ -27,88 +28,79 @@ const Iterable: React.FC<DiffComponentProps> = ({
const comparisonRowCount = Array.isArray(comparison) ? comparison.length : 0
const maxRows = Math.max(versionRowCount, comparisonRowCount)
if (!fieldIsArrayType(field) && !fieldIsBlockType(field)) {
throw new Error(`Expected field to be an array or blocks type but got: ${field.type}`)
}
return (
<div className={baseClass}>
{'label' in field && field.label && typeof field.label !== 'function' && (
<Label>
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
{getTranslation(field.label, i18n)}
</Label>
)}
{maxRows > 0 && (
<React.Fragment>
{Array.from(Array(maxRows).keys()).map((row, i) => {
const versionRow = version?.[i] || {}
const comparisonRow = comparison?.[i] || {}
<DiffCollapser
comparison={comparison}
field={field}
isIterable
label={
'label' in field &&
field.label &&
typeof field.label !== 'function' && (
<span>
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
{getTranslation(field.label, i18n)}
</span>
)
}
locales={locales}
version={version}
>
{maxRows > 0 && (
<div className={`${baseClass}__rows`}>
{Array.from(Array(maxRows).keys()).map((row, i) => {
const versionRow = version?.[i] || {}
const comparisonRow = comparison?.[i] || {}
let fields: ClientField[] = []
const fields: ClientField[] = getFieldsForRowComparison({
comparisonRow,
field,
versionRow,
})
if (field.type === 'array' && 'fields' in field) {
fields = field.fields
}
const rowNumber = String(i + 1).padStart(2, '0')
const rowLabel = fieldIsArrayType(field) ? `Item ${rowNumber}` : `Block ${rowNumber}`
if (field.type === 'blocks') {
fields = [
// {
// name: 'blockType',
// label: i18n.t('fields:blockType'),
// type: 'text',
// },
]
if (versionRow?.blockType === comparisonRow?.blockType) {
const matchedBlock = ('blocks' in field &&
field.blocks?.find((block) => block.slug === versionRow?.blockType)) || {
fields: [],
}
fields = [...fields, ...matchedBlock.fields]
} else {
const matchedVersionBlock = ('blocks' in field &&
field.blocks?.find((block) => block.slug === versionRow?.blockType)) || {
fields: [],
}
const matchedComparisonBlock = ('blocks' in field &&
field.blocks?.find((block) => block.slug === comparisonRow?.blockType)) || {
fields: [],
}
fields = getUniqueListBy<ClientField>(
[...fields, ...matchedVersionBlock.fields, ...matchedComparisonBlock.fields],
'name',
)
}
}
return (
<div className={`${baseClass}__wrap`} key={i}>
<RenderFieldsToDiff
comparison={comparisonRow}
diffComponents={diffComponents}
fieldPermissions={fieldPermissions}
fields={fields}
i18n={i18n}
locales={locales}
version={versionRow}
/>
</div>
)
})}
</React.Fragment>
)}
{maxRows === 0 && (
<div className={`${baseClass}__no-rows`}>
{i18n.t('version:noRowsFound', {
label:
'labels' in field && field.labels?.plural
? getTranslation(field.labels.plural, i18n)
: i18n.t('general:rows'),
})}
</div>
)}
return (
<div className={`${baseClass}__row`} key={i}>
<DiffCollapser
comparison={comparisonRow}
fields={fields}
label={rowLabel}
locales={locales}
version={versionRow}
>
<RenderFieldsToDiff
comparison={comparisonRow}
diffComponents={diffComponents}
fieldPermissions={fieldPermissions}
fields={fields}
i18n={i18n}
locales={locales}
version={versionRow}
/>
</DiffCollapser>
</div>
)
})}
</div>
)}
{maxRows === 0 && (
<div className={`${baseClass}__no-rows`}>
{i18n.t('version:noRowsFound', {
label:
'labels' in field && field.labels?.plural
? getTranslation(field.labels.plural, i18n)
: i18n.t('general:rows'),
})}
</div>
)}
</DiffCollapser>
</div>
)
}
export default Iterable

View File

@@ -1,14 +0,0 @@
@layer payload-default {
.nested-diff {
&__wrap--gutter {
[dir='ltr'] & {
padding-left: calc(var(--base) * 0.25);
// border-left: $style-stroke-width-s solid var(--theme-elevation-150);
}
[dir='rtl'] & {
padding-right: calc(var(--base) * 0.25);
// border-right: $style-stroke-width-s solid var(--theme-elevation-150);
}
}
}
}

View File

@@ -1,52 +0,0 @@
'use client'
import { getTranslation } from '@payloadcms/translations'
import React from 'react'
import type { DiffComponentProps } from '../types.js'
import RenderFieldsToDiff from '../../index.js'
import Label from '../../Label/index.js'
import './index.scss'
const baseClass = 'nested-diff'
const Nested: React.FC<DiffComponentProps> = ({
comparison,
diffComponents,
disableGutter = false,
field,
fieldPermissions,
fields,
i18n,
locale,
locales,
version,
}) => {
return (
<div className={baseClass}>
{'label' in field && field.label && typeof field.label !== 'function' && (
<Label>
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
{getTranslation(field.label, i18n)}
</Label>
)}
<div
className={[`${baseClass}__wrap`, !disableGutter && `${baseClass}__wrap--gutter`]
.filter(Boolean)
.join(' ')}
>
<RenderFieldsToDiff
comparison={comparison}
diffComponents={diffComponents}
fieldPermissions={fieldPermissions}
fields={fields}
i18n={i18n}
locales={locales}
version={version}
/>
</div>
</div>
)
}
export default Nested

View File

@@ -99,7 +99,7 @@ const generateLabelFromValue = (
return valueToReturn
}
const Relationship: React.FC<DiffComponentProps<RelationshipFieldClient>> = ({
export const Relationship: React.FC<DiffComponentProps<RelationshipFieldClient>> = ({
comparison,
field,
i18n,
@@ -159,5 +159,3 @@ const Relationship: React.FC<DiffComponentProps<RelationshipFieldClient>> = ({
</div>
)
}
export default Relationship

View File

@@ -0,0 +1,38 @@
'use client'
import { getTranslation } from '@payloadcms/translations'
import React from 'react'
import type { DiffComponentProps } from '../types.js'
import { RenderFieldsToDiff } from '../../index.js'
import Label from '../../Label/index.js'
const baseClass = 'row-diff'
export const Row: React.FC<DiffComponentProps> = ({
comparison,
diffComponents,
field,
fieldPermissions,
fields,
i18n,
locales,
version,
}) => {
return (
<div className={baseClass}>
{'label' in field && field.label && typeof field.label !== 'function' && (
<Label>{getTranslation(field.label, i18n)}</Label>
)}
<RenderFieldsToDiff
comparison={comparison}
diffComponents={diffComponents}
fieldPermissions={fieldPermissions}
fields={fields}
i18n={i18n}
locales={locales}
version={version}
/>
</div>
)
}

View File

@@ -45,7 +45,7 @@ const getTranslatedOptions = (
return typeof options === 'string' ? options : getTranslation(options.label, i18n)
}
const Select: React.FC<DiffComponentProps<SelectFieldClient>> = ({
export const Select: React.FC<DiffComponentProps<SelectFieldClient>> = ({
comparison,
diffMethod,
field,
@@ -87,5 +87,3 @@ const Select: React.FC<DiffComponentProps<SelectFieldClient>> = ({
</div>
)
}
export default Select

View File

@@ -0,0 +1,20 @@
@layer payload-default {
.tabs-diff {
// Space between each tab or tab locale
&__tab:not(:first-of-type),
&__tab-locale:not(:first-of-type) {
margin-top: var(--base);
}
&__locale-label {
background: var(--theme-elevation-100);
padding: calc(var(--base) * 0.25);
[dir='ltr'] & {
margin-right: calc(var(--base) * 0.25);
}
[dir='rtl'] & {
margin-left: calc(var(--base) * 0.25);
}
}
}
}

View File

@@ -1,62 +1,101 @@
'use client'
import type { TabsFieldClient } from 'payload'
import type { ClientTab, TabsFieldClient } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import React from 'react'
import type { DiffComponentProps } from '../types.js'
import RenderFieldsToDiff from '../../index.js'
import Nested from '../Nested/index.js'
import { DiffCollapser } from '../../DiffCollapser/index.js'
import { RenderFieldsToDiff } from '../../index.js'
import './index.scss'
const baseClass = 'tabs-diff'
const Tabs: React.FC<DiffComponentProps<TabsFieldClient>> = ({
comparison,
diffComponents,
field,
fieldPermissions,
i18n,
locale,
locales,
version,
}) => {
export const Tabs: React.FC<DiffComponentProps<TabsFieldClient>> = (props) => {
const { comparison, field, locales, version } = props
return (
<div className={baseClass}>
<div className={`${baseClass}__wrap`}>
{field.tabs.map((tab, i) => {
if ('name' in tab) {
return (
<Nested
comparison={comparison?.[tab.name]}
diffComponents={diffComponents}
field={field}
fieldPermissions={fieldPermissions}
fields={tab.fields}
i18n={i18n}
key={i}
locale={locale}
locales={locales}
version={version?.[tab.name]}
/>
)
}
return (
<RenderFieldsToDiff
comparison={comparison}
diffComponents={diffComponents}
fieldPermissions={fieldPermissions}
fields={tab.fields}
i18n={i18n}
key={i}
locales={locales}
version={version}
/>
)
})}
</div>
{field.tabs.map((tab, i) => {
return (
<div className={`${baseClass}__tab`} key={i}>
{(() => {
if ('name' in tab && locales && tab.localized) {
// Named localized tab
return locales.map((locale, index) => {
const localizedTabProps = {
...props,
comparison: comparison?.[tab.name]?.[locale],
version: version?.[tab.name]?.[locale],
}
return (
<div className={`${baseClass}__tab-locale`} key={[locale, index].join('-')}>
<div className={`${baseClass}__tab-locale-value`}>
<Tab key={locale} {...localizedTabProps} locale={locale} tab={tab} />
</div>
</div>
)
})
} else if ('name' in tab && tab.name) {
// Named tab
const namedTabProps = {
...props,
comparison: comparison?.[tab.name],
version: version?.[tab.name],
}
return <Tab key={i} {...namedTabProps} tab={tab} />
} else {
// Unnamed tab
return <Tab key={i} {...props} tab={tab} />
}
})()}
</div>
)
})}
</div>
)
}
export default Tabs
type TabProps = {
tab: ClientTab
} & DiffComponentProps<TabsFieldClient>
const Tab: React.FC<TabProps> = ({
comparison,
diffComponents,
fieldPermissions,
i18n,
locale,
locales,
tab,
version,
}) => {
return (
<DiffCollapser
comparison={comparison}
fields={tab.fields}
label={
'label' in tab &&
tab.label &&
typeof tab.label !== 'function' && (
<span>
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
{getTranslation(tab.label, i18n)}
</span>
)
}
locales={locales}
version={version}
>
<RenderFieldsToDiff
comparison={comparison}
diffComponents={diffComponents}
fieldPermissions={fieldPermissions}
fields={tab.fields}
i18n={i18n}
locales={locales}
version={version}
/>
</DiffCollapser>
)
}

View File

@@ -13,7 +13,7 @@ import './index.scss'
const baseClass = 'text-diff'
const Text: React.FC<DiffComponentProps<TextFieldClient>> = ({
export const Text: React.FC<DiffComponentProps<TextFieldClient>> = ({
comparison,
diffMethod,
field,
@@ -58,5 +58,3 @@ const Text: React.FC<DiffComponentProps<TextFieldClient>> = ({
</div>
)
}
export default Text

View File

@@ -1,26 +1,28 @@
import Iterable from './Iterable/index.js'
import Nested from './Nested/index.js'
import Relationship from './Relationship/index.js'
import Select from './Select/index.js'
import Tabs from './Tabs/index.js'
import Text from './Text/index.js'
import { Collapsible } from './Collapsible/index.js'
import { Group } from './Group/index.js'
import { Iterable } from './Iterable/index.js'
import { Relationship } from './Relationship/index.js'
import { Row } from './Row/index.js'
import { Select } from './Select/index.js'
import { Tabs } from './Tabs/index.js'
import { Text } from './Text/index.js'
export const diffComponents = {
array: Iterable,
blocks: Iterable,
checkbox: Text,
code: Text,
collapsible: Nested,
collapsible: Collapsible,
date: Text,
email: Text,
group: Nested,
group: Group,
json: Text,
number: Text,
point: Text,
radio: Select,
relationship: Relationship,
richText: Text,
row: Nested,
row: Row,
select: Select,
tabs: Tabs,
text: Text,

View File

@@ -9,7 +9,6 @@ export type DiffComponentProps<TField extends ClientField = ClientField> = {
readonly comparison: any
readonly diffComponents: DiffComponents
readonly diffMethod?: DiffMethod
readonly disableGutter?: boolean
readonly field: TField
readonly fieldPermissions?:
| {

View File

@@ -8,12 +8,11 @@ import type { diffComponents as _diffComponents } from './fields/index.js'
import type { FieldDiffProps, Props } from './types.js'
import { diffMethods } from './fields/diffMethods.js'
import Nested from './fields/Nested/index.js'
import './index.scss'
const baseClass = 'render-field-diffs'
const RenderFieldsToDiff: React.FC<Props> = ({
export const RenderFieldsToDiff: React.FC<Props> = ({
comparison,
diffComponents: __diffComponents,
fieldPermissions,
@@ -128,13 +127,13 @@ const RenderFieldsToDiff: React.FC<Props> = ({
)
}
// At this point, we are dealing with a `row`, etc
// At this point, we are dealing with a field with subfields but no
// nested data, eg. row, collapsible, etc.
if ('fields' in field) {
return (
<Nested
<Component
comparison={comparison}
diffComponents={diffComponents}
disableGutter
field={field}
fieldPermissions={fieldPermissions}
fields={field.fields}
@@ -152,5 +151,3 @@ const RenderFieldsToDiff: React.FC<Props> = ({
</div>
)
}
export default RenderFieldsToDiff

View File

@@ -0,0 +1,538 @@
import { countChangedFields, countChangedFieldsInRows } from './countChangedFields.js'
import type { ClientField } from 'payload'
describe('countChangedFields', () => {
// locales can be undefined when not configured in payload.config.js
const locales = undefined
it('should return 0 when no fields have changed', () => {
const fields: ClientField[] = [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'number' },
]
const comparison = { a: 'original', b: 123 }
const version = { a: 'original', b: 123 }
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(0)
})
it('should count simple changed fields', () => {
const fields: ClientField[] = [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'number' },
]
const comparison = { a: 'original', b: 123 }
const version = { a: 'changed', b: 123 }
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(1)
})
it('should count previously undefined fields', () => {
const fields: ClientField[] = [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'number' },
]
const comparison = {}
const version = { a: 'new', b: 123 }
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(2)
})
it('should not count the id field because it is not displayed in the version view', () => {
const fields: ClientField[] = [
{ name: 'id', type: 'text' },
{ name: 'a', type: 'text' },
]
const comparison = { id: 'original', a: 'original' }
const version = { id: 'changed', a: 'original' }
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(0)
})
it('should count changed fields inside collapsible fields', () => {
const fields: ClientField[] = [
{
type: 'collapsible',
label: 'A collapsible field',
fields: [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
],
},
]
const comparison = { a: 'original', b: 'original', c: 'original' }
const version = { a: 'changed', b: 'changed', c: 'original' }
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(2)
})
it('should count changed fields inside row fields', () => {
const fields: ClientField[] = [
{
type: 'row',
fields: [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
],
},
]
const comparison = { a: 'original', b: 'original', c: 'original' }
const version = { a: 'changed', b: 'changed', c: 'original' }
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(2)
})
it('should count changed fields inside group fields', () => {
const fields: ClientField[] = [
{
type: 'group',
name: 'group',
fields: [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
],
},
]
const comparison = { group: { a: 'original', b: 'original', c: 'original' } }
const version = { group: { a: 'changed', b: 'changed', c: 'original' } }
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(2)
})
it('should count changed fields inside unnamed tabs ', () => {
const fields: ClientField[] = [
{
type: 'tabs',
tabs: [
{
label: 'Unnamed tab',
fields: [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
],
},
],
},
]
const comparison = { a: 'original', b: 'original', c: 'original' }
const version = { a: 'changed', b: 'changed', c: 'original' }
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(2)
})
it('should count changed fields inside named tabs ', () => {
const fields: ClientField[] = [
{
type: 'tabs',
tabs: [
{
name: 'namedTab',
fields: [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
],
},
],
},
]
const comparison = { namedTab: { a: 'original', b: 'original', c: 'original' } }
const version = { namedTab: { a: 'changed', b: 'changed', c: 'original' } }
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(2)
})
it('should ignore UI fields', () => {
const fields: ClientField[] = [
{ name: 'a', type: 'text' },
{
name: 'b',
type: 'ui',
admin: {},
},
]
const comparison = { a: 'original', b: 'original' }
const version = { a: 'original', b: 'changed' }
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(0)
})
it('should count changed fields inside array fields', () => {
const fields: ClientField[] = [
{
name: 'arrayField',
type: 'array',
fields: [
{
name: 'a',
type: 'text',
},
{
name: 'b',
type: 'text',
},
{
name: 'c',
type: 'text',
},
],
},
]
const comparison = {
arrayField: [
{ a: 'original', b: 'original', c: 'original' },
{ a: 'original', b: 'original' },
],
}
const version = {
arrayField: [
{ a: 'changed', b: 'changed', c: 'original' },
{ a: 'changed', b: 'changed', c: 'changed' },
],
}
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(5)
})
it('should count changed fields inside arrays nested inside of collapsibles', () => {
const fields: ClientField[] = [
{
type: 'collapsible',
label: 'A collapsible field',
fields: [
{
name: 'arrayField',
type: 'array',
fields: [
{
name: 'a',
type: 'text',
},
{
name: 'b',
type: 'text',
},
{
name: 'c',
type: 'text',
},
],
},
],
},
]
const comparison = { arrayField: [{ a: 'original', b: 'original', c: 'original' }] }
const version = { arrayField: [{ a: 'changed', b: 'changed', c: 'original' }] }
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(2)
})
it('should count changed fields inside blocks fields', () => {
const fields: ClientField[] = [
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'blockA',
fields: [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
],
},
],
},
]
const comparison = {
blocks: [
{ blockType: 'blockA', a: 'original', b: 'original', c: 'original' },
{ blockType: 'blockA', a: 'original', b: 'original' },
],
}
const version = {
blocks: [
{ blockType: 'blockA', a: 'changed', b: 'changed', c: 'original' },
{ blockType: 'blockA', a: 'changed', b: 'changed', c: 'changed' },
],
}
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(5)
})
it('should count changed fields between blocks with different slugs', () => {
const fields: ClientField[] = [
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'blockA',
fields: [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
],
},
{
slug: 'blockB',
fields: [
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
{ name: 'd', type: 'text' },
],
},
],
},
]
const comparison = {
blocks: [{ blockType: 'blockA', a: 'removed', b: 'original', c: 'original' }],
}
const version = {
blocks: [{ blockType: 'blockB', b: 'original', c: 'changed', d: 'new' }],
}
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(3)
})
describe('localized fields', () => {
const locales = ['en', 'de']
it('should count simple localized fields', () => {
const fields: ClientField[] = [
{ name: 'a', type: 'text', localized: true },
{ name: 'b', type: 'text', localized: true },
]
const comparison = {
a: { en: 'original', de: 'original' },
b: { en: 'original', de: 'original' },
}
const version = {
a: { en: 'changed', de: 'original' },
b: { en: 'original', de: 'original' },
}
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(1)
})
it('should count multiple locales of the same localized field', () => {
const locales = ['en', 'de']
const fields: ClientField[] = [
{ name: 'a', type: 'text', localized: true },
{ name: 'b', type: 'text', localized: true },
]
const comparison = {
a: { en: 'original', de: 'original' },
b: { en: 'original', de: 'original' },
}
const version = {
a: { en: 'changed', de: 'changed' },
b: { en: 'original', de: 'original' },
}
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(2)
})
it('should count changed fields inside localized groups fields', () => {
const fields: ClientField[] = [
{
type: 'group',
name: 'group',
localized: true,
fields: [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
],
},
]
const comparison = {
group: {
en: { a: 'original', b: 'original', c: 'original' },
de: { a: 'original', b: 'original', c: 'original' },
},
}
const version = {
group: {
en: { a: 'changed', b: 'changed', c: 'original' },
de: { a: 'original', b: 'changed', c: 'original' },
},
}
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(3)
})
it('should count changed fields inside localized tabs', () => {
const fields: ClientField[] = [
{
type: 'tabs',
tabs: [
{
name: 'tab',
localized: true,
fields: [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
],
},
],
},
]
const comparison = {
tab: {
en: { a: 'original', b: 'original', c: 'original' },
de: { a: 'original', b: 'original', c: 'original' },
},
}
const version = {
tab: {
en: { a: 'changed', b: 'changed', c: 'original' },
de: { a: 'original', b: 'changed', c: 'original' },
},
}
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(3)
})
it('should count changed fields inside localized array fields', () => {
const fields: ClientField[] = [
{
name: 'arrayField',
type: 'array',
localized: true,
fields: [
{
name: 'a',
type: 'text',
},
{
name: 'b',
type: 'text',
},
{
name: 'c',
type: 'text',
},
],
},
]
const comparison = {
arrayField: {
en: [{ a: 'original', b: 'original', c: 'original' }],
de: [{ a: 'original', b: 'original', c: 'original' }],
},
}
const version = {
arrayField: {
en: [{ a: 'changed', b: 'changed', c: 'original' }],
de: [{ a: 'original', b: 'changed', c: 'original' }],
},
}
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(3)
})
it('should count changed fields inside localized blocks fields', () => {
const fields: ClientField[] = [
{
name: 'blocks',
type: 'blocks',
localized: true,
blocks: [
{
slug: 'blockA',
fields: [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
],
},
],
},
]
const comparison = {
blocks: {
en: [{ blockType: 'blockA', a: 'original', b: 'original', c: 'original' }],
de: [{ blockType: 'blockA', a: 'original', b: 'original', c: 'original' }],
},
}
const version = {
blocks: {
en: [{ blockType: 'blockA', a: 'changed', b: 'changed', c: 'original' }],
de: [{ blockType: 'blockA', a: 'original', b: 'changed', c: 'original' }],
},
}
const result = countChangedFields({ comparison, fields, version, locales })
expect(result).toBe(3)
})
})
})
describe('countChangedFieldsInRows', () => {
it('should count fields in array rows', () => {
const field: ClientField = {
name: 'myArray',
type: 'array',
fields: [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
],
}
const comparisonRows = [{ a: 'original', b: 'original', c: 'original' }]
const versionRows = [{ a: 'changed', b: 'changed', c: 'original' }]
const result = countChangedFieldsInRows({
comparisonRows,
field,
locales: undefined,
versionRows,
})
expect(result).toBe(2)
})
it('should count fields in blocks', () => {
const field: ClientField = {
name: 'myBlocks',
type: 'blocks',
blocks: [
{
slug: 'blockA',
fields: [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
],
},
],
}
const comparisonRows = [{ blockType: 'blockA', a: 'original', b: 'original', c: 'original' }]
const versionRows = [{ blockType: 'blockA', a: 'changed', b: 'changed', c: 'original' }]
const result = countChangedFieldsInRows({
comparisonRows,
field,
locales: undefined,
versionRows,
})
expect(result).toBe(2)
})
})

View File

@@ -0,0 +1,197 @@
import type { ArrayFieldClient, BlocksFieldClient, ClientField } from 'payload'
import { fieldHasChanges } from './fieldHasChanges.js'
import { getFieldsForRowComparison } from './getFieldsForRowComparison.js'
type Args = {
comparison: unknown
fields: ClientField[]
locales: string[] | undefined
version: unknown
}
/**
* Recursively counts the number of changed fields between comparison and
* version data for a given set of fields.
*/
export function countChangedFields({ comparison, fields, locales, version }: Args) {
let count = 0
fields.forEach((field) => {
// Don't count the id field since it is not displayed in the UI
if ('name' in field && field.name === 'id') {
return
}
const fieldType = field.type
switch (fieldType) {
// Iterable fields are arrays and blocks fields. We iterate over each row and
// count the number of changed fields in each.
case 'array':
case 'blocks': {
if (locales && field.localized) {
locales.forEach((locale) => {
const comparisonRows = comparison?.[field.name]?.[locale] ?? []
const versionRows = version?.[field.name]?.[locale] ?? []
count += countChangedFieldsInRows({ comparisonRows, field, locales, versionRows })
})
} else {
const comparisonRows = comparison?.[field.name] ?? []
const versionRows = version?.[field.name] ?? []
count += countChangedFieldsInRows({ comparisonRows, field, locales, versionRows })
}
break
}
// Regular fields without nested fields.
case 'checkbox':
case 'code':
case 'date':
case 'email':
case 'join':
case 'json':
case 'number':
case 'point':
case 'radio':
case 'relationship':
case 'richText':
case 'select':
case 'text':
case 'textarea':
case 'upload': {
// Fields that have a name and contain data. We can just check if the data has changed.
if (locales && field.localized) {
locales.forEach((locale) => {
if (
fieldHasChanges(version?.[field.name]?.[locale], comparison?.[field.name]?.[locale])
) {
count++
}
})
} else if (fieldHasChanges(version?.[field.name], comparison?.[field.name])) {
count++
}
break
}
// Fields that have nested fields, but don't nest their fields' data.
case 'collapsible':
case 'row': {
count += countChangedFields({
comparison,
fields: field.fields,
locales,
version,
})
break
}
// Fields that have nested fields and nest their fields' data.
case 'group': {
if (locales && field.localized) {
locales.forEach((locale) => {
count += countChangedFields({
comparison: comparison?.[field.name]?.[locale],
fields: field.fields,
locales,
version: version?.[field.name]?.[locale],
})
})
} else {
count += countChangedFields({
comparison: comparison?.[field.name],
fields: field.fields,
locales,
version: version?.[field.name],
})
}
break
}
// Each tab in a tabs field has nested fields. The fields data may be
// nested or not depending on the existence of a name property.
case 'tabs': {
field.tabs.forEach((tab) => {
if ('name' in tab && locales && tab.localized) {
// Named localized tab
locales.forEach((locale) => {
count += countChangedFields({
comparison: comparison?.[tab.name]?.[locale],
fields: tab.fields,
locales,
version: version?.[tab.name]?.[locale],
})
})
} else if ('name' in tab) {
// Named tab
count += countChangedFields({
comparison: comparison?.[tab.name],
fields: tab.fields,
locales,
version: version?.[tab.name],
})
} else {
// Unnamed tab
count += countChangedFields({
comparison,
fields: tab.fields,
locales,
version,
})
}
})
break
}
// UI fields don't have data and are not displayed in the version view
// so we can ignore them.
case 'ui': {
break
}
default: {
const _exhaustiveCheck: never = fieldType
throw new Error(`Unexpected field.type in countChangedFields : ${String(fieldType)}`)
}
}
})
return count
}
type countChangedFieldsInRowsArgs = {
comparisonRows: unknown[]
field: ArrayFieldClient | BlocksFieldClient
locales: string[] | undefined
versionRows: unknown[]
}
export function countChangedFieldsInRows({
comparisonRows = [],
field,
locales,
versionRows = [],
}: countChangedFieldsInRowsArgs) {
let count = 0
let i = 0
while (comparisonRows[i] || versionRows[i]) {
const comparisonRow = comparisonRows?.[i] || {}
const versionRow = versionRows?.[i] || {}
const rowFields = getFieldsForRowComparison({
comparisonRow,
field,
versionRow,
})
count += countChangedFields({
comparison: comparisonRow,
fields: rowFields,
locales,
version: versionRow,
})
i++
}
return count
}

View File

@@ -0,0 +1,38 @@
import { fieldHasChanges } from './fieldHasChanges.js'
describe('hasChanges', () => {
it('should return false for identical values', () => {
const a = 'value'
const b = 'value'
expect(fieldHasChanges(a, b)).toBe(false)
})
it('should return true for different values', () => {
const a = 1
const b = 2
expect(fieldHasChanges(a, b)).toBe(true)
})
it('should return false for identical objects', () => {
const a = { key: 'value' }
const b = { key: 'value' }
expect(fieldHasChanges(a, b)).toBe(false)
})
it('should return true for different objects', () => {
const a = { key: 'value' }
const b = { key: 'differentValue' }
expect(fieldHasChanges(a, b)).toBe(true)
})
it('should handle undefined values', () => {
const a = { key: 'value' }
const b = undefined
expect(fieldHasChanges(a, b)).toBe(true)
})
it('should handle null values', () => {
const a = { key: 'value' }
const b = null
expect(fieldHasChanges(a, b)).toBe(true)
})
})

View File

@@ -0,0 +1,3 @@
export function fieldHasChanges(a: unknown, b: unknown) {
return JSON.stringify(a) !== JSON.stringify(b)
}

View File

@@ -0,0 +1,97 @@
import { getFieldsForRowComparison } from './getFieldsForRowComparison'
import type { ArrayFieldClient, BlocksFieldClient, ClientField } from 'payload'
describe('getFieldsForRowComparison', () => {
describe('array fields', () => {
it('should return fields from array field', () => {
const arrayFields: ClientField[] = [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
]
const field: ArrayFieldClient = {
type: 'array',
name: 'items',
fields: arrayFields,
}
const result = getFieldsForRowComparison({
field,
versionRow: {},
comparisonRow: {},
})
expect(result).toEqual(arrayFields)
})
})
describe('blocks fields', () => {
it('should return combined fields when block types match', () => {
const blockAFields: ClientField[] = [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
]
const field: BlocksFieldClient = {
type: 'blocks',
name: 'myBlocks',
blocks: [
{
slug: 'blockA',
fields: blockAFields,
},
],
}
const versionRow = { blockType: 'blockA' }
const comparisonRow = { blockType: 'blockA' }
const result = getFieldsForRowComparison({
field,
versionRow,
comparisonRow,
})
expect(result).toEqual(blockAFields)
})
it('should return unique combined fields when block types differ', () => {
const field: BlocksFieldClient = {
type: 'blocks',
name: 'myBlocks',
blocks: [
{
slug: 'blockA',
fields: [
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
],
},
{
slug: 'blockB',
fields: [
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
],
},
],
}
const versionRow = { blockType: 'blockA' }
const comparisonRow = { blockType: 'blockB' }
const result = getFieldsForRowComparison({
field,
versionRow,
comparisonRow,
})
// Should contain all unique fields from both blocks
expect(result).toEqual([
{ name: 'a', type: 'text' },
{ name: 'b', type: 'text' },
{ name: 'c', type: 'text' },
])
})
})
})

View File

@@ -0,0 +1,52 @@
import type { ArrayFieldClient, BlocksFieldClient, ClientField } from 'payload'
import { getUniqueListBy } from 'payload/shared'
/**
* Get the fields for a row in an iterable field for comparison.
* - Array fields: the fields of the array field, because the fields are the same for each row.
* - Blocks fields: the union of fields from the comparison and version row,
* because the fields from the version and comparison rows may differ.
*/
export function getFieldsForRowComparison({
comparisonRow,
field,
versionRow,
}: {
comparisonRow: any
field: ArrayFieldClient | BlocksFieldClient
versionRow: any
}) {
let fields: ClientField[] = []
if (field.type === 'array' && 'fields' in field) {
fields = field.fields
}
if (field.type === 'blocks') {
if (versionRow?.blockType === comparisonRow?.blockType) {
const matchedBlock = ('blocks' in field &&
field.blocks?.find((block) => block.slug === versionRow?.blockType)) || {
fields: [],
}
fields = matchedBlock.fields
} else {
const matchedVersionBlock = ('blocks' in field &&
field.blocks?.find((block) => block.slug === versionRow?.blockType)) || {
fields: [],
}
const matchedComparisonBlock = ('blocks' in field &&
field.blocks?.find((block) => block.slug === comparisonRow?.blockType)) || {
fields: [],
}
fields = getUniqueListBy<ClientField>(
[...matchedVersionBlock.fields, ...matchedComparisonBlock.fields],
'name',
)
}
}
return fields
}

View File

@@ -352,6 +352,7 @@ export const clientTranslationKeys = createClientTranslationKeys([
'version:autosavedSuccessfully',
'version:autosavedVersion',
'version:changed',
'version:changedFieldsCount',
'version:confirmRevertToSaved',
'version:compareVersion',
'version:confirmPublish',

View File

@@ -435,6 +435,8 @@ export const arTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'تمّ الحفظ التّلقائي بنجاح.',
autosavedVersion: 'النّسخة المحفوظة تلقائياً',
changed: 'تمّ التّغيير',
changedFieldsCount_one: '{{count}} قام بتغيير الحقل',
changedFieldsCount_other: '{{count}} حقول تم تغييرها',
compareVersion: 'مقارنة النّسخة مع:',
confirmPublish: 'تأكيد النّشر',
confirmRevertToSaved: 'تأكيد الرّجوع للنسخة المنشورة',

View File

@@ -444,6 +444,8 @@ export const azTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Uğurla avtomatik olaraq yadda saxlandı.',
autosavedVersion: 'Avtomatik yadda saxlanmış versiya',
changed: 'Dəyişdirildi',
changedFieldsCount_one: '{{count}} sahə dəyişdi',
changedFieldsCount_other: '{{count}} dəyişdirilmiş sahələr',
compareVersion: 'Versiyanı müqayisə et:',
confirmPublish: 'Dərci təsdiq edin',
confirmRevertToSaved: 'Yadda saxlanana qayıtmağı təsdiq edin',

View File

@@ -443,6 +443,8 @@ export const bgTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Успешно автоматично запазване.',
autosavedVersion: 'Автоматично запазена версия',
changed: 'Променен',
changedFieldsCount_one: '{{count}} променено поле',
changedFieldsCount_other: '{{count}} променени полета',
compareVersion: 'Сравни версия с:',
confirmPublish: 'Потвърди публикуване',
confirmRevertToSaved: 'Потвърди възстановяване до запазен',

View File

@@ -446,6 +446,8 @@ export const caTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Desat automàticament amb èxit.',
autosavedVersion: 'Versió desada automàticament',
changed: 'Canviat',
changedFieldsCount_one: '{{count}} camp canviat',
changedFieldsCount_other: '{{count}} camps modificats',
compareVersion: 'Comparar versió amb:',
confirmPublish: 'Confirmar publicació',
confirmRevertToSaved: 'Confirmar revertir a desat',

View File

@@ -440,6 +440,8 @@ export const csTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Úspěšně uloženo automaticky.',
autosavedVersion: 'Verze automatického uložení',
changed: 'Změněno',
changedFieldsCount_one: '{{count}} změněné pole',
changedFieldsCount_other: '{{count}} změněná pole',
compareVersion: 'Porovnat verzi s:',
confirmPublish: 'Potvrďte publikování',
confirmRevertToSaved: 'Potvrdit vrácení k uloženému',

View File

@@ -442,6 +442,8 @@ export const daTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Autosaved gennemført.',
autosavedVersion: 'Autosaved version',
changed: 'Ændret',
changedFieldsCount_one: '{{count}} ændret felt',
changedFieldsCount_other: '{{count}} ændrede felter',
compareVersion: 'Sammenlign version med:',
confirmPublish: 'Bekræft offentliggørelse',
confirmRevertToSaved: 'Bekræft tilbagerulning til gemt',

View File

@@ -448,6 +448,8 @@ export const deTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Erfolgreich automatisch gespeichert.',
autosavedVersion: 'Automatisch gespeicherte Version',
changed: 'Geändert',
changedFieldsCount_one: '{{count}} geändertes Feld',
changedFieldsCount_other: '{{count}} geänderte Felder',
compareVersion: 'Vergleiche Version zu:',
confirmPublish: 'Veröffentlichung bestätigen',
confirmRevertToSaved: 'Zurücksetzen auf die letzte Speicherung bestätigen',

View File

@@ -443,6 +443,8 @@ export const enTranslations = {
autosave: 'Autosave',
autosavedSuccessfully: 'Autosaved successfully.',
autosavedVersion: 'Autosaved version',
changedFieldsCount_one: '{{count}} changed field',
changedFieldsCount_other: '{{count}} changed fields',
changed: 'Changed',
compareVersion: 'Compare version against:',
confirmPublish: 'Confirm publish',

View File

@@ -448,6 +448,8 @@ export const esTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Guardado automáticamente con éxito.',
autosavedVersion: 'Versión Autoguardada',
changed: 'Modificado',
changedFieldsCount_one: '{{count}} campo modificado',
changedFieldsCount_other: '{{count}} campos modificados',
compareVersion: 'Comparar versión con:',
confirmPublish: 'Confirmar publicación',
confirmRevertToSaved: 'Confirmar revertir a guardado',

View File

@@ -437,6 +437,8 @@ export const etTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Automaatselt salvestatud.',
autosavedVersion: 'Automaatselt salvestatud versioon',
changed: 'Muudetud',
changedFieldsCount_one: '{{count}} muudetud väli',
changedFieldsCount_other: '{{count}} muudetud välja',
compareVersion: 'Võrdle versiooni:',
confirmPublish: 'Kinnita avaldamine',
confirmRevertToSaved: 'Kinnita taastamine salvestatud seisundisse',

View File

@@ -439,6 +439,8 @@ export const faTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'با موفقیت ذخیره خودکار شد.',
autosavedVersion: 'نگارش ذخیره شده خودکار',
changed: 'تغییر کرد',
changedFieldsCount_one: '{{count}} فیلد تغییر کرد',
changedFieldsCount_other: '{{count}} فیلدهای تغییر یافته',
compareVersion: 'مقایسه نگارش با:',
confirmPublish: 'تأیید انتشار',
confirmRevertToSaved: 'تأیید بازگردانی نگارش ذخیره شده',

View File

@@ -456,6 +456,8 @@ export const frTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Enregistrement automatique réussi.',
autosavedVersion: 'Version enregistrée automatiquement',
changed: 'Modifié',
changedFieldsCount_one: '{{count}} champ modifié',
changedFieldsCount_other: '{{count}} champs modifiés',
compareVersion: 'Comparez cette version à :',
confirmPublish: 'Confirmer la publication',
confirmRevertToSaved: 'Confirmer la restauration',

View File

@@ -429,6 +429,8 @@ export const heTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'נשמר בהצלחה.',
autosavedVersion: 'גרסת שמירה אוטומטית',
changed: 'שונה',
changedFieldsCount_one: '{{count}} שינה שדה',
changedFieldsCount_other: '{{count}} שדות ששונו',
compareVersion: 'השווה לגרסה:',
confirmPublish: 'אישור פרסום',
confirmRevertToSaved: 'אישור שחזור לגרסה שנשמרה',

View File

@@ -440,6 +440,8 @@ export const hrTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Automatsko spremanje uspješno.',
autosavedVersion: 'Verzija automatski spremljenog dokumenta',
changed: 'Promijenjeno',
changedFieldsCount_one: '{{count}} promijenjeno polje',
changedFieldsCount_other: '{{count}} promijenjena polja',
compareVersion: 'Usporedi verziju sa:',
confirmPublish: 'Potvrdi objavu',
confirmRevertToSaved: 'Potvrdite vraćanje na spremljeno',

View File

@@ -448,6 +448,8 @@ export const huTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Automatikus mentés sikeres.',
autosavedVersion: 'Automatikusan mentett verzió',
changed: 'Megváltozott',
changedFieldsCount_one: '{{count}} megváltozott mező',
changedFieldsCount_other: '{{count}} módosított mező',
compareVersion: 'Hasonlítsa össze a verziót a következőkkel:',
confirmPublish: 'A közzététel megerősítése',
confirmRevertToSaved: 'Erősítse meg a mentett verzióra való visszatérést',

View File

@@ -448,6 +448,8 @@ export const itTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Salvataggio automatico riuscito.',
autosavedVersion: 'Versione salvata automaticamente',
changed: 'Modificato',
changedFieldsCount_one: '{{count}} campo modificato',
changedFieldsCount_other: '{{count}} campi modificati',
compareVersion: 'Confronta versione con:',
confirmPublish: 'Conferma la pubblicazione',
confirmRevertToSaved: 'Conferma il ripristino dei salvataggi',

View File

@@ -441,6 +441,8 @@ export const jaTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: '自動保存に成功しました。',
autosavedVersion: '自動保存されたバージョン',
changed: '変更済み',
changedFieldsCount_one: '{{count}} 変更されたフィールド',
changedFieldsCount_other: '{{count}}つの変更されたフィールド',
compareVersion: 'バージョンを比較:',
confirmPublish: '公開を確認する',
confirmRevertToSaved: '保存された状態に戻す確認',

View File

@@ -436,6 +436,8 @@ export const koTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: '자동 저장이 완료되었습니다.',
autosavedVersion: '자동 저장된 버전',
changed: '변경됨',
changedFieldsCount_one: '{{count}} 변경된 필드',
changedFieldsCount_other: '{{count}}개의 변경된 필드',
compareVersion: '비교할 버전 선택:',
confirmPublish: '게시하기',
confirmRevertToSaved: '저장된 상태로 되돌리기',

View File

@@ -451,6 +451,8 @@ export const myTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'အလိုအလျောက် သိမ်းဆည်းပြီးပါပြီ။',
autosavedVersion: 'အော်တို ဗားရှင်း',
changed: 'ပြောင်းခဲ့သည်။',
changedFieldsCount_one: '{{count}} field telah diubah',
changedFieldsCount_other: '{{count}}ကယ်လက်ရှိအရာများပြောင်းလဲလိုက်သည်',
compareVersion: 'ဗားရှင်းနှင့် နှိုင်းယှဉ်ချက်:',
confirmPublish: 'ထုတ်ဝေအတည်ပြုပါ။',
confirmRevertToSaved: 'သိမ်းဆည်းပြီးကြောင်း အတည်ပြုပါ။',

View File

@@ -444,6 +444,8 @@ export const nbTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Lagret automatisk.',
autosavedVersion: 'Automatisk lagret versjon',
changed: 'Endret',
changedFieldsCount_one: '{{count}} endret felt',
changedFieldsCount_other: '{{count}} endrede felt',
compareVersion: 'Sammenlign versjon mot:',
confirmPublish: 'Bekreft publisering',
confirmRevertToSaved: 'Bekreft tilbakestilling til lagret',

View File

@@ -448,6 +448,8 @@ export const nlTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Succesvol automatisch bewaard.',
autosavedVersion: 'Automatisch bewaarde versie',
changed: 'Gewijzigd',
changedFieldsCount_one: '{{count}} gewijzigd veld',
changedFieldsCount_other: '{{count}} gewijzigde velden',
compareVersion: 'Vergelijk versie met:',
confirmPublish: 'Bevestig publiceren',
confirmRevertToSaved: 'Bevestig terugdraaien naar bewaarde versie',

View File

@@ -443,6 +443,8 @@ export const plTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Pomyślnie zapisano automatycznie.',
autosavedVersion: 'Wersja zapisana automatycznie',
changed: 'Zmieniono',
changedFieldsCount_one: '{{count}} zmienione pole',
changedFieldsCount_other: '{{count}} zmienione pola',
compareVersion: 'Porównaj wersję z:',
confirmPublish: 'Potwierdź publikację',
confirmRevertToSaved: 'Potwierdź powrót do zapisanego',

View File

@@ -444,6 +444,8 @@ export const ptTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Salvamento automático com sucesso.',
autosavedVersion: 'Versão de salvamento automático',
changed: 'Alterado',
changedFieldsCount_one: '{{count}} campo alterado',
changedFieldsCount_other: '{{count}} campos alterados',
compareVersion: 'Comparar versão com:',
confirmPublish: 'Confirmar publicação',
confirmRevertToSaved: 'Confirmar a reversão para o salvo',

View File

@@ -451,6 +451,8 @@ export const roTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Autosalvare cu succes.',
autosavedVersion: 'Versiunea salvată automat.',
changed: 'Schimbat',
changedFieldsCount_one: '{{count}} a modificat câmpul',
changedFieldsCount_other: '{{count}} câmpuri modificate',
compareVersion: 'Comparați versiunea cu:',
confirmPublish: 'Confirmați publicarea',
confirmRevertToSaved: 'Confirmați revenirea la starea salvată',

View File

@@ -439,6 +439,8 @@ export const rsTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Аутоматско чување успешно.',
autosavedVersion: 'Верзија аутоматски сачуваног документа',
changed: 'Промењено',
changedFieldsCount_one: '{{count}} promenjeno polje',
changedFieldsCount_other: '{{count}} promenjena polja',
compareVersion: 'Упореди верзију са:',
confirmPublish: 'Потврди објаву',
confirmRevertToSaved: 'Потврдите враћање на сачувано',

View File

@@ -441,6 +441,8 @@ export const rsLatinTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Automatsko čuvanje uspešno.',
autosavedVersion: 'Verzija automatski sačuvanog dokumenta',
changed: 'Promenjeno',
changedFieldsCount_one: '{{count}} promenjeno polje',
changedFieldsCount_other: '{{count}} promenjenih polja',
compareVersion: 'Uporedi verziju sa:',
confirmPublish: 'Potvrdi objavu',
confirmRevertToSaved: 'Potvrdite vraćanje na sačuvano',

View File

@@ -446,6 +446,8 @@ export const ruTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Автосохранение успешно.',
autosavedVersion: 'Автоматически сохраненная версия',
changed: 'Изменено',
changedFieldsCount_one: '{{count}} изменил поле',
changedFieldsCount_other: '{{count}} измененных полей',
compareVersion: 'Сравнить версию с:',
confirmPublish: 'Подтвердить публикацию',
confirmRevertToSaved: 'Подтвердить возврат к сохраненному',

View File

@@ -443,6 +443,8 @@ export const skTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Úspešne uložené automaticky.',
autosavedVersion: 'Verzia automatického uloženia',
changed: 'Zmenené',
changedFieldsCount_one: '{{count}} zmenené pole',
changedFieldsCount_other: '{{count}} zmenených polí',
compareVersion: 'Porovnať verziu s:',
confirmPublish: 'Potvrdiť publikovanie',
confirmRevertToSaved: 'Potvrdiť vrátenie k uloženému',

View File

@@ -440,6 +440,8 @@ export const slTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Samodejno shranjeno uspešno.',
autosavedVersion: 'Samodejno shranjena različica',
changed: 'Spremenjeno',
changedFieldsCount_one: '{{count}} spremenjeno polje',
changedFieldsCount_other: '{{count}} spremenjena polja',
compareVersion: 'Primerjaj različico z:',
confirmPublish: 'Potrdi objavo',
confirmRevertToSaved: 'Potrdi vrnitev na shranjeno',

View File

@@ -443,6 +443,8 @@ export const svTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Autosparades framgångsrikt.',
autosavedVersion: 'Autosparad version',
changed: 'Ändrad',
changedFieldsCount_one: '{{count}} ändrat fält',
changedFieldsCount_other: '{{count}} ändrade fält',
compareVersion: 'Jämför version med:',
confirmPublish: 'Bekräfta publicering',
confirmRevertToSaved: 'Bekräfta återgång till sparad',

View File

@@ -434,6 +434,8 @@ export const thTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'บันทึกอัตโนมัติสำเร็จ',
autosavedVersion: 'เวอร์ชันบันทึกอัตโนมัติ',
changed: 'มีการแก้ไข',
changedFieldsCount_one: '{{count}} เปลี่ยนฟิลด์',
changedFieldsCount_other: '{{count}} ฟิลด์ที่มีการเปลี่ยนแปลง',
compareVersion: 'เปรียบเทียบเวอร์ชันกับ:',
confirmPublish: 'ยืนยันการเผยแพร่',
confirmRevertToSaved: 'ยืนยันย้อนการแก้ไข',

View File

@@ -445,6 +445,8 @@ export const trTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Otomatik kaydetme başarılı',
autosavedVersion: 'Otomatik kayıtlı sürüm',
changed: 'Değişiklik yapıldı',
changedFieldsCount_one: '{{count}} alanı değişti',
changedFieldsCount_other: '{{count}} değişen alan',
compareVersion: 'Sürümü şununla karşılaştır:',
confirmPublish: 'Yayınlamayı onayla',
confirmRevertToSaved: 'Confirm revert to saved',

View File

@@ -441,6 +441,8 @@ export const ukTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Автозбереження успішно виконано.',
autosavedVersion: 'Автозбереження',
changed: 'Змінено',
changedFieldsCount_one: '{{count}} змінене поле',
changedFieldsCount_other: '{{count}} змінених полів',
compareVersion: 'Порівняти версію з:',
confirmPublish: 'Підтвердити публікацію',
confirmRevertToSaved: 'Підтвердити повернення до збереженого стану',

View File

@@ -438,6 +438,8 @@ export const viTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: 'Đã tự động lưu thành công.',
autosavedVersion: 'Các phiên bản từ việc tự động lưu dữ liệu',
changed: 'Đã thay đổi',
changedFieldsCount_one: '{{count}} đã thay đổi trường',
changedFieldsCount_other: '{{count}} trường đã thay đổi',
compareVersion: 'So sánh phiên bản này với:',
confirmPublish: 'Xác nhận xuất bản',
confirmRevertToSaved: 'Xác nhận, quay về trạng thái đã lưu',

View File

@@ -424,6 +424,8 @@ export const zhTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: '自动保存成功。',
autosavedVersion: '自动保存的版本',
changed: '已更改',
changedFieldsCount_one: '{{count}}已更改的字段',
changedFieldsCount_other: '{{count}}已更改的字段',
compareVersion: '对比版本:',
confirmPublish: '确认发布',
confirmRevertToSaved: '确认恢复到保存状态',

View File

@@ -424,6 +424,8 @@ export const zhTwTranslations: DefaultTranslationsObject = {
autosavedSuccessfully: '自動儲存成功。',
autosavedVersion: '自動儲存的版本',
changed: '已更改',
changedFieldsCount_one: '{{count}} 更改了字段',
changedFieldsCount_other: '{{count}}個已更改的欄位',
compareVersion: '對比版本:',
confirmPublish: '確認發佈',
confirmRevertToSaved: '確認回復到儲存狀態',

View File

@@ -133,5 +133,10 @@
}
}
}
&--size-small {
padding: 0 base(0.2);
line-height: 18px;
}
}
}

View File

@@ -22,6 +22,7 @@ export type PillProps = {
onClick?: () => void
pillStyle?: 'dark' | 'error' | 'light' | 'light-gray' | 'success' | 'warning' | 'white'
rounded?: boolean
size?: 'medium' | 'small'
to?: string
}
@@ -76,12 +77,14 @@ const StaticPill: React.FC<PillProps> = (props) => {
onClick,
pillStyle = 'light',
rounded,
size = 'medium',
to,
} = props
const classes = [
baseClass,
`${baseClass}--style-${pillStyle}`,
`${baseClass}--size-${size}`,
className && className,
to && `${baseClass}--has-link`,
(to || onClick) && `${baseClass}--has-action`,
@@ -115,7 +118,7 @@ const StaticPill: React.FC<PillProps> = (props) => {
type={Element === 'button' ? 'button' : undefined}
>
<span className={`${baseClass}__label`}>{children}</span>
{icon && <span className={`${baseClass}__icon`}>{icon}</span>}
{Boolean(icon) && <span className={`${baseClass}__icon`}>{icon}</span>}
</Element>
)
}

View File

@@ -841,15 +841,72 @@ describe('Versions', () => {
await page.waitForURL(versionURL)
await expect(page.locator('.render-field-diffs').first()).toBeVisible()
const blocksDiffLabel = page.locator('.field-diff-label', {
hasText: exactText('Blocks Field'),
})
const blocksDiffLabel = page.getByText('Blocks Field', { exact: true })
await expect(blocksDiffLabel).toBeVisible()
const blocksDiff = blocksDiffLabel.locator('+ .iterable-diff__wrap > .render-field-diffs')
const blocksDiff = page.locator('.iterable-diff', { has: blocksDiffLabel })
await expect(blocksDiff).toBeVisible()
const blockTypeDiffLabel = blocksDiff.locator('.render-field-diffs__field').first()
await expect(blockTypeDiffLabel).toBeVisible()
const blocksDiffRows = blocksDiff.locator('.iterable-diff__rows')
await expect(blocksDiffRows).toBeVisible()
const firstBlocksDiffRow = blocksDiffRows.locator('.iterable-diff__row').first()
await expect(firstBlocksDiffRow).toBeVisible()
const firstBlockDiffLabel = firstBlocksDiffRow.getByText('Block 01', { exact: true })
await expect(firstBlockDiffLabel).toBeVisible()
})
test('should render diff collapser for nested fields', async () => {
const versionURL = `${serverURL}/admin/collections/${draftCollectionSlug}/${postID}/versions/${versionID}`
await page.goto(versionURL)
await page.waitForURL(versionURL)
await expect(page.locator('.render-field-diffs').first()).toBeVisible()
const blocksDiffLabel = page.getByText('Blocks Field', { exact: true })
await expect(blocksDiffLabel).toBeVisible()
// Expect iterable rows diff to be visible
const blocksDiff = page.locator('.iterable-diff', { has: blocksDiffLabel })
await expect(blocksDiff).toBeVisible()
// Expect iterable change count to be visible
const iterableChangeCount = blocksDiff.locator('.diff-collapser__field-change-count').first()
await expect(iterableChangeCount).toHaveText('2 changed fields')
// Expect iterable rows to be visible
const blocksDiffRows = blocksDiff.locator('.iterable-diff__rows')
await expect(blocksDiffRows).toBeVisible()
// Expect first iterable row to be visible
const firstBlocksDiffRow = blocksDiffRows.locator('.iterable-diff__row').first()
await expect(firstBlocksDiffRow).toBeVisible()
// Expect first row change count to be visible
const firstBlocksDiffRowChangeCount = firstBlocksDiffRow
.locator('.diff-collapser__field-change-count')
.first()
await expect(firstBlocksDiffRowChangeCount).toHaveText('2 changed fields')
// Expect collapser content to be visible
const diffCollapserContent = blocksDiffRows.locator('.diff-collapser__content')
await expect(diffCollapserContent).toBeVisible()
// Expect toggle button to be visible
const toggleButton = firstBlocksDiffRow.locator('.diff-collapser__toggle-button').first()
await expect(toggleButton).toBeVisible()
// Collapse content
await toggleButton.click()
// Expect collapser content to be hidden
await expect(diffCollapserContent).toBeHidden()
// Uncollapse content
await toggleButton.click()
// Expect collapser content to be visible
await expect(diffCollapserContent).toBeVisible()
})
})
})