Skip to content

Commit 828b3b7

Browse files
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~
1 parent 92e6beb commit 828b3b7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+1579
-235
lines changed

packages/next/src/views/Version/Default/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import React, { useState } from 'react'
88
import type { CompareOption, DefaultVersionsViewProps } from './types.js'
99

1010
import { diffComponents } from '../RenderFieldsToDiff/fields/index.js'
11-
import RenderFieldsToDiff from '../RenderFieldsToDiff/index.js'
11+
import { RenderFieldsToDiff } from '../RenderFieldsToDiff/index.js'
1212
import Restore from '../Restore/index.js'
1313
import { SelectComparison } from '../SelectComparison/index.js'
1414
import { SelectLocales } from '../SelectLocales/index.js'
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
@layer payload-default {
2+
.diff-collapser {
3+
&__toggle-button {
4+
all: unset;
5+
cursor: pointer;
6+
// Align the chevron visually with the label text
7+
vertical-align: 1px;
8+
}
9+
10+
&__label {
11+
// Add space between label, chevron, and change count
12+
margin: 0 calc(var(--base) * 0.25);
13+
}
14+
15+
&__field-change-count {
16+
// Reset the font weight of the change count to normal
17+
font-weight: normal;
18+
}
19+
20+
&__content {
21+
[dir='ltr'] & {
22+
// Vertical gutter
23+
border-left: 3px solid var(--theme-elevation-50);
24+
// Center-align the gutter with the chevron
25+
margin-left: 3px;
26+
// Content indentation
27+
padding-left: calc(var(--base) * 0.5);
28+
}
29+
[dir='rtl'] & {
30+
// Vertical gutter
31+
border-right: 3px solid var(--theme-elevation-50);
32+
// Center-align the gutter with the chevron
33+
margin-right: 3px;
34+
// Content indentation
35+
padding-right: calc(var(--base) * 0.5);
36+
}
37+
}
38+
39+
&__content--is-collapsed {
40+
// Hide the content when collapsed. We use display: none instead of
41+
// conditional rendering to avoid loosing children's collapsed state when
42+
// remounting.
43+
display: none;
44+
}
45+
}
46+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import type { ClientField } from 'payload'
2+
3+
import { ChevronIcon, Pill, useTranslation } from '@payloadcms/ui'
4+
import { fieldIsArrayType, fieldIsBlockType } from 'payload/shared'
5+
import React, { useState } from 'react'
6+
7+
import Label from '../Label/index.js'
8+
import './index.scss'
9+
import { countChangedFields, countChangedFieldsInRows } from '../utilities/countChangedFields.js'
10+
11+
const baseClass = 'diff-collapser'
12+
13+
type Props =
14+
| {
15+
// fields collapser
16+
children: React.ReactNode
17+
comparison: unknown
18+
field?: never
19+
fields: ClientField[]
20+
initCollapsed?: boolean
21+
isIterable?: false
22+
label: React.ReactNode
23+
locales: string[] | undefined
24+
version: unknown
25+
}
26+
| {
27+
// iterable collapser
28+
children: React.ReactNode
29+
comparison?: unknown
30+
field: ClientField
31+
fields?: never
32+
initCollapsed?: boolean
33+
isIterable: true
34+
label: React.ReactNode
35+
locales: string[] | undefined
36+
version: unknown
37+
}
38+
39+
export const DiffCollapser: React.FC<Props> = ({
40+
children,
41+
comparison,
42+
field,
43+
fields,
44+
initCollapsed = false,
45+
isIterable = false,
46+
label,
47+
locales,
48+
version,
49+
}) => {
50+
const { t } = useTranslation()
51+
const [isCollapsed, setIsCollapsed] = useState(initCollapsed)
52+
53+
let changeCount = 0
54+
55+
if (isIterable) {
56+
if (!fieldIsArrayType(field) && !fieldIsBlockType(field)) {
57+
throw new Error(
58+
'DiffCollapser: field must be an array or blocks field when isIterable is true',
59+
)
60+
}
61+
const comparisonRows = comparison ?? []
62+
const versionRows = version ?? []
63+
64+
if (!Array.isArray(comparisonRows) || !Array.isArray(versionRows)) {
65+
throw new Error(
66+
'DiffCollapser: comparison and version must be arrays when isIterable is true',
67+
)
68+
}
69+
70+
changeCount = countChangedFieldsInRows({
71+
comparisonRows,
72+
field,
73+
locales,
74+
versionRows,
75+
})
76+
} else {
77+
changeCount = countChangedFields({
78+
comparison,
79+
fields,
80+
locales,
81+
version,
82+
})
83+
}
84+
85+
const contentClassNames = [
86+
`${baseClass}__content`,
87+
isCollapsed && `${baseClass}__content--is-collapsed`,
88+
]
89+
.filter(Boolean)
90+
.join(' ')
91+
92+
return (
93+
<div className={baseClass}>
94+
<Label>
95+
<button
96+
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
97+
className={`${baseClass}__toggle-button`}
98+
onClick={() => setIsCollapsed(!isCollapsed)}
99+
type="button"
100+
>
101+
<ChevronIcon direction={isCollapsed ? 'right' : 'down'} />
102+
</button>
103+
<span className={`${baseClass}__label`}>{label}</span>
104+
{changeCount > 0 && (
105+
<Pill className={`${baseClass}__field-change-count`} pillStyle="light-gray" size="small">
106+
{t('version:changedFieldsCount', { count: changeCount })}
107+
</Pill>
108+
)}
109+
</Label>
110+
<div className={contentClassNames}>{children}</div>
111+
</div>
112+
)
113+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use client'
2+
import { getTranslation } from '@payloadcms/translations'
3+
import React from 'react'
4+
5+
import type { DiffComponentProps } from '../types.js'
6+
7+
import { DiffCollapser } from '../../DiffCollapser/index.js'
8+
import { RenderFieldsToDiff } from '../../index.js'
9+
10+
const baseClass = 'collapsible-diff'
11+
12+
export const Collapsible: React.FC<DiffComponentProps> = ({
13+
comparison,
14+
diffComponents,
15+
field,
16+
fieldPermissions,
17+
fields,
18+
i18n,
19+
locales,
20+
version,
21+
}) => {
22+
return (
23+
<div className={baseClass}>
24+
<DiffCollapser
25+
comparison={comparison}
26+
fields={fields}
27+
label={
28+
'label' in field &&
29+
field.label &&
30+
typeof field.label !== 'function' && <span>{getTranslation(field.label, i18n)}</span>
31+
}
32+
locales={locales}
33+
version={version}
34+
>
35+
<RenderFieldsToDiff
36+
comparison={comparison}
37+
diffComponents={diffComponents}
38+
fieldPermissions={fieldPermissions}
39+
fields={fields}
40+
i18n={i18n}
41+
locales={locales}
42+
version={version}
43+
/>
44+
</DiffCollapser>
45+
</div>
46+
)
47+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
@layer payload-default {
2+
.group-diff {
3+
&__locale-label {
4+
background: var(--theme-elevation-100);
5+
padding: calc(var(--base) * 0.25);
6+
[dir='ltr'] & {
7+
margin-right: calc(var(--base) * 0.25);
8+
}
9+
[dir='rtl'] & {
10+
margin-left: calc(var(--base) * 0.25);
11+
}
12+
}
13+
}
14+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
'use client'
2+
import { getTranslation } from '@payloadcms/translations'
3+
import React from 'react'
4+
5+
import './index.scss'
6+
7+
import type { DiffComponentProps } from '../types.js'
8+
9+
import { DiffCollapser } from '../../DiffCollapser/index.js'
10+
import { RenderFieldsToDiff } from '../../index.js'
11+
12+
const baseClass = 'group-diff'
13+
14+
export const Group: React.FC<DiffComponentProps> = ({
15+
comparison,
16+
diffComponents,
17+
field,
18+
fieldPermissions,
19+
fields,
20+
i18n,
21+
locale,
22+
locales,
23+
version,
24+
}) => {
25+
return (
26+
<div className={baseClass}>
27+
<DiffCollapser
28+
comparison={comparison}
29+
fields={fields}
30+
label={
31+
'label' in field &&
32+
field.label &&
33+
typeof field.label !== 'function' && (
34+
<span>
35+
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
36+
{getTranslation(field.label, i18n)}
37+
</span>
38+
)
39+
}
40+
locales={locales}
41+
version={version}
42+
>
43+
<RenderFieldsToDiff
44+
comparison={comparison}
45+
diffComponents={diffComponents}
46+
fieldPermissions={fieldPermissions}
47+
fields={fields}
48+
i18n={i18n}
49+
locales={locales}
50+
version={version}
51+
/>
52+
</DiffCollapser>
53+
</div>
54+
)
55+
}

packages/next/src/views/Version/RenderFieldsToDiff/fields/Iterable/index.scss

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
@layer payload-default {
22
.iterable-diff {
3-
margin-bottom: calc(var(--base) * 2);
4-
53
&__locale-label {
64
background: var(--theme-elevation-100);
75
padding: calc(var(--base) * 0.25);
@@ -14,16 +12,9 @@
1412
}
1513
}
1614

17-
&__wrap {
18-
margin: calc(var(--base) * 0.5);
19-
[dir='ltr'] & {
20-
padding-left: calc(var(--base) * 0.5);
21-
// border-left: $style-stroke-width-s solid var(--theme-elevation-150);
22-
}
23-
[dir='rtl'] & {
24-
padding-right: calc(var(--base) * 0.5);
25-
// border-right: $style-stroke-width-s solid var(--theme-elevation-150);
26-
}
15+
// Space between each row
16+
&__row:not(:first-of-type) {
17+
margin-top: calc(var(--base) * 0.5);
2718
}
2819

2920
&__no-rows {

0 commit comments

Comments
 (0)