Skip to content

Commit ee2f5f6

Browse files
priethorjasmussenMamaduka
authored andcommitted
Visual revisions: Label autosaves in the revisions timeline (#79950)
Co-authored-by: priethor <priethor@git.wordpress.org> Co-authored-by: jasmussen <joen@git.wordpress.org> Co-authored-by: Mamaduka <mamaduka@git.wordpress.org>
1 parent e9ccd57 commit ee2f5f6

3 files changed

Lines changed: 228 additions & 20 deletions

File tree

‎packages/editor/src/components/post-revisions-timeline/index.js‎

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import { store as coreStore } from '@wordpress/core-data';
66
import { DataViewsPicker, filterSortAndPaginate } from '@wordpress/dataviews';
77
import { dateI18n, getDate, humanTimeDiff, getSettings } from '@wordpress/date';
88
import { useCallback, useEffect, useMemo, useState } from '@wordpress/element';
9-
import { __ } from '@wordpress/i18n';
9+
import { __, sprintf } from '@wordpress/i18n';
1010
import { authorField } from '@wordpress/fields';
11-
import { Text } from '@wordpress/ui';
11+
import { Badge, Stack, Text } from '@wordpress/ui';
1212

1313
/**
1414
* Internal dependencies
@@ -40,16 +40,28 @@ function getDisplayDate( value ) {
4040
: humanTimeDiff( date );
4141
}
4242

43+
function isAutosaveRevision( item ) {
44+
// Autosaves use the `{parent_id}-autosave-v1` slug, like Core's `wp_is_post_autosave()`.
45+
return item.slug?.endsWith( '-autosave-v1' ) ?? false;
46+
}
47+
48+
function RevisionBadges( { item } ) {
49+
if ( ! isAutosaveRevision( item ) ) {
50+
return null;
51+
}
52+
53+
return <Badge intent="none">{ __( 'Autosave' ) }</Badge>;
54+
}
55+
4356
export default function PostRevisionsTimeline() {
4457
const { setCurrentRevisionId } = unlock( useDispatch( editorStore ) );
4558
const [ view, setView ] = useState( baseView );
4659

47-
const { revisions, revisionKey, currentRevisionId, currentRevision } =
48-
useSelect( ( select ) => {
60+
const { revisions, revisionKey, currentRevisionId } = useSelect(
61+
( select ) => {
4962
const { getCurrentPostType } = select( editorStore );
5063
const {
5164
getCurrentRevisionId: _getCurrentRevisionId,
52-
getCurrentRevision,
5365
getRevisionPage,
5466
getPageRevisions,
5567
} = unlock( select( editorStore ) );
@@ -65,13 +77,10 @@ export default function PostRevisionsTimeline() {
6577
revisions: getPageRevisions( getRevisionPage() ),
6678
revisionKey: _revisionKey,
6779
currentRevisionId: _currentRevisionId,
68-
currentRevision: _currentRevisionId
69-
? getCurrentRevision()
70-
: undefined,
7180
};
72-
}, [] );
73-
74-
const postContent = currentRevision?.content?.raw;
81+
},
82+
[]
83+
);
7584

7685
const isLoading = ! revisions;
7786

@@ -83,14 +92,30 @@ export default function PostRevisionsTimeline() {
8392
// Return the humanized label the row renders so the picker
8493
// option's accessible name announces e.g. "5 minutes ago"
8594
// instead of the raw ISO timestamp.
86-
getValue: ( { item } ) => getDisplayDate( item.date ),
95+
getValue: ( { item } ) => {
96+
const displayDate = getDisplayDate( item.date );
97+
98+
if ( ! isAutosaveRevision( item ) ) {
99+
return displayDate;
100+
}
101+
102+
return sprintf(
103+
/* translators: 1: revision date, 2: revision type. */
104+
__( '%1$s, %2$s' ),
105+
displayDate,
106+
__( 'Autosave' )
107+
);
108+
},
87109
render: ( { item } ) => (
88-
<Text
89-
variant="heading-sm"
90-
render={ <time dateTime={ item.date } /> }
91-
>
92-
{ getDisplayDate( item.date ) }
93-
</Text>
110+
<Stack direction="row" align="center" gap="sm">
111+
<Text
112+
variant="heading-sm"
113+
render={ <time dateTime={ item.date } /> }
114+
>
115+
{ getDisplayDate( item.date ) }
116+
</Text>
117+
<RevisionBadges item={ item } />
118+
</Stack>
94119
),
95120
enableSorting: false,
96121
enableHiding: false,
@@ -107,14 +132,16 @@ export default function PostRevisionsTimeline() {
107132
return null;
108133
}
109134
return (
110-
<PostContentInformationUI postContent={ postContent } />
135+
<PostContentInformationUI
136+
postContent={ item.content?.raw }
137+
/>
111138
);
112139
},
113140
enableSorting: false,
114141
enableHiding: false,
115142
},
116143
],
117-
[ revisionKey, currentRevisionId, postContent ]
144+
[ revisionKey, currentRevisionId ]
118145
);
119146

120147
const { data: shownRevisions, paginationInfo } = useMemo(
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import { render, screen } from '@testing-library/react';
5+
6+
/**
7+
* WordPress dependencies
8+
*/
9+
import { useSelect, useDispatch } from '@wordpress/data';
10+
11+
/**
12+
* Internal dependencies
13+
*/
14+
import PostRevisionsTimeline from '../';
15+
16+
jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() );
17+
jest.mock( '@wordpress/data/src/components/use-dispatch', () => ( {
18+
useDispatch: jest.fn(),
19+
} ) );
20+
21+
jest.mock( '@wordpress/fields', () => ( {
22+
authorField: {
23+
id: 'author',
24+
label: 'Author',
25+
getValue: ( { item } ) => item?._embedded?.author?.[ 0 ]?.name,
26+
render: ( { item } ) => {
27+
const { createElement } = require( '@wordpress/element' );
28+
const authorName = item?._embedded?.author?.[ 0 ]?.name;
29+
30+
return createElement(
31+
'span',
32+
null,
33+
createElement(
34+
'span',
35+
{ 'aria-hidden': 'true' },
36+
'Author icon'
37+
),
38+
createElement( 'span', null, authorName )
39+
);
40+
},
41+
},
42+
} ) );
43+
44+
jest.mock( '@wordpress/dataviews', () => {
45+
const DataViewsPicker = ( { data, fields, getItemId, selection } ) => {
46+
const titleField = fields.find( ( field ) => field.id === 'date' );
47+
48+
return (
49+
<div>
50+
{ data.map( ( item ) => (
51+
<div
52+
key={ getItemId( item ) }
53+
role="option"
54+
aria-label={ titleField.getValue( { item } ) }
55+
aria-selected={ selection.includes(
56+
getItemId( item )
57+
) }
58+
>
59+
{ fields.map( ( field ) => {
60+
const normalizedField = {
61+
id: field.id,
62+
label: field.label,
63+
render: field.render,
64+
};
65+
66+
return normalizedField.render ? (
67+
<div key={ normalizedField.id }>
68+
<normalizedField.render
69+
item={ item }
70+
field={ normalizedField }
71+
/>
72+
</div>
73+
) : null;
74+
} ) }
75+
</div>
76+
) ) }
77+
</div>
78+
);
79+
};
80+
DataViewsPicker.Layout = () => null;
81+
DataViewsPicker.Footer = () => null;
82+
83+
return {
84+
DataViewsPicker,
85+
filterSortAndPaginate: ( data ) => ( {
86+
data,
87+
paginationInfo: { totalItems: data.length },
88+
} ),
89+
};
90+
} );
91+
92+
jest.mock( '../../../lock-unlock', () => ( {
93+
unlock: ( object ) => {
94+
return {
95+
...object,
96+
registerPrivateActions: jest.fn(),
97+
registerPrivateSelectors: jest.fn(),
98+
};
99+
},
100+
} ) );
101+
102+
jest.mock( '../../post-content-information', () => ( {
103+
PostContentInformationUI: () => null,
104+
} ) );
105+
106+
describe( 'PostRevisionsTimeline', () => {
107+
let getCurrentRevision;
108+
109+
beforeEach( () => {
110+
const revisions = [
111+
{
112+
id: 3,
113+
date: '2026-07-07T12:00:00',
114+
author: 1,
115+
slug: '10-autosave-v1',
116+
content: { raw: 'Autosaved content' },
117+
_embedded: { author: [ { id: 1, name: 'Alice' } ] },
118+
},
119+
{
120+
id: 2,
121+
date: '2026-07-07T11:00:00',
122+
author: 2,
123+
slug: '10-revision-v1',
124+
content: { raw: 'Current revision content' },
125+
_embedded: { author: [ { id: 2, name: 'Bob' } ] },
126+
},
127+
{
128+
id: 1,
129+
date: '2026-07-07T10:00:00',
130+
author: 1,
131+
slug: '10-revision-v1',
132+
content: { raw: 'Older revision content' },
133+
_embedded: { author: [ { id: 1, name: 'Alice' } ] },
134+
},
135+
];
136+
const users = {
137+
1: { name: 'Alice' },
138+
2: { name: 'Bob' },
139+
};
140+
141+
getCurrentRevision = jest.fn( () => revisions[ 0 ] );
142+
143+
useSelect.mockImplementation( ( mapSelect ) =>
144+
mapSelect( () => ( {
145+
getCurrentPostType: () => 'post',
146+
getCurrentRevisionId: () => 3,
147+
getCurrentRevision,
148+
getRevisionPage: () => 1,
149+
getPageRevisions: () => revisions,
150+
getCurrentPostLastRevisionId: () => 2,
151+
getEntityConfig: () => ( { revisionKey: 'id' } ),
152+
getEntityRecord: ( _kind, _name, id ) => users[ id ],
153+
} ) )
154+
);
155+
useDispatch.mockReturnValue( { setCurrentRevisionId: jest.fn() } );
156+
} );
157+
158+
it( 'keeps the author field intact and labels autosaves', () => {
159+
render( <PostRevisionsTimeline /> );
160+
161+
expect( screen.getAllByText( 'Author icon' ) ).toHaveLength( 3 );
162+
expect( screen.getAllByText( 'Alice' ) ).toHaveLength( 2 );
163+
expect( screen.getByText( 'Bob' ) ).toBeVisible();
164+
expect( screen.getByText( 'Autosave' ) ).toBeVisible();
165+
expect( screen.getAllByRole( 'option' )[ 0 ] ).toHaveAccessibleName(
166+
/Autosave/
167+
);
168+
expect( screen.getAllByRole( 'option' )[ 1 ] ).not.toHaveAccessibleName(
169+
/Autosave/
170+
);
171+
expect( screen.queryByText( 'Current' ) ).not.toBeInTheDocument();
172+
expect(
173+
screen.queryByText( 'Revision by Alice' )
174+
).not.toBeInTheDocument();
175+
expect(
176+
screen.queryByText( 'Current Revision by Bob' )
177+
).not.toBeInTheDocument();
178+
expect( getCurrentRevision ).not.toHaveBeenCalled();
179+
} );
180+
} );

‎packages/editor/src/store/private-selectors.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ export function buildRevisionsPageQuery( revisionKey, page ) {
393393
'date',
394394
'modified',
395395
'author',
396+
'slug',
396397
'meta',
397398
'title.raw',
398399
'excerpt.raw',

0 commit comments

Comments
 (0)