@plone/volto 19.3.1 → 19.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/package.json +5 -5
- package/src/components/manage/Toolbar/Toolbar.jsx +2 -3
- package/src/components/manage/Toolbar/UserAvatar.test.tsx +72 -0
- package/src/components/manage/Toolbar/UserAvatar.tsx +27 -0
- package/src/components/manage/Widgets/SelectAutoComplete.jsx +27 -8
- package/src/components/manage/Widgets/SelectAutoComplete.test.jsx +64 -1
- package/src/components/theme/Search/Search.jsx +9 -1
- package/types/components/manage/Toolbar/UserAvatar.d.ts +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,23 @@ myst:
|
|
|
17
17
|
|
|
18
18
|
<!-- towncrier release notes start -->
|
|
19
19
|
|
|
20
|
+
## 19.4.1 (2026-09-10)
|
|
21
|
+
|
|
22
|
+
### Bugfix
|
|
23
|
+
|
|
24
|
+
- Fix `SelectAutoComplete` rendering the raw token instead of its title when the vocabulary subrequest still held a stale token/title pair from a previously edited value (for example after changing a value, saving, and editing again via client-side navigation). The widget now keeps its local token/title cache in sync with the incoming choices instead of seeding it only once. @sneridagh [#8423](https://github.com/plone/volto/issues/8423)
|
|
25
|
+
|
|
26
|
+
## 19.4.0 (2026-09-03)
|
|
27
|
+
|
|
28
|
+
### Feature
|
|
29
|
+
|
|
30
|
+
- Added a `UserAvatar` component, used by the toolbar to render the signed-in user's avatar. Add-ons can now change that avatar by shadowing this one small component, instead of shadowing the whole `Toolbar`. @ericof [#8404](https://github.com/plone/volto/issues/8404)
|
|
31
|
+
- Adding quanta support with semanticUI fallback for seach container. @TimoBroeskamp [#8411](https://github.com/plone/volto/issues/8411)
|
|
32
|
+
|
|
33
|
+
### Bugfix
|
|
34
|
+
|
|
35
|
+
- Fixed cursor position lost when clicking on a Slate block in edit, causing the cursor to jump to the start of the text. @wesleybl [#8402](https://github.com/plone/volto/issues/8402)
|
|
36
|
+
|
|
20
37
|
## 19.3.1 (2026-08-26)
|
|
21
38
|
|
|
22
39
|
### Bugfix
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
}
|
|
10
10
|
],
|
|
11
11
|
"license": "MIT",
|
|
12
|
-
"version": "19.
|
|
12
|
+
"version": "19.4.1",
|
|
13
13
|
"repository": {
|
|
14
14
|
"type": "git",
|
|
15
15
|
"url": "git@github.com:plone/volto.git"
|
|
@@ -176,9 +176,9 @@
|
|
|
176
176
|
"use-deep-compare-effect": "1.8.1",
|
|
177
177
|
"uuid": "^14.0.0",
|
|
178
178
|
"@plone/components": "4.2.1",
|
|
179
|
-
"@plone/
|
|
179
|
+
"@plone/registry": "3.0.2",
|
|
180
180
|
"@plone/scripts": "4.0.1",
|
|
181
|
-
"@plone/
|
|
181
|
+
"@plone/volto-slate": "19.0.5"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
184
|
"@babel/core": "^7.28.5",
|
|
@@ -286,10 +286,10 @@
|
|
|
286
286
|
"webpack-bundle-analyzer": "4.10.1",
|
|
287
287
|
"webpack-dev-server": "^5.2.4",
|
|
288
288
|
"webpack-node-externals": "3.0.0",
|
|
289
|
+
"@plone/razzle": "1.0.1",
|
|
289
290
|
"@plone/babel-preset-razzle": "^1.0.1",
|
|
290
|
-
"@plone/razzle-dev-utils": "1.0.0",
|
|
291
291
|
"@plone/volto-coresandbox": "1.0.0",
|
|
292
|
-
"@plone/razzle": "1.0.
|
|
292
|
+
"@plone/razzle-dev-utils": "1.0.0",
|
|
293
293
|
"@plone/types": "2.0.1"
|
|
294
294
|
},
|
|
295
295
|
"scripts": {
|
|
@@ -39,9 +39,9 @@ import unlockSVG from '@plone/volto/icons/unlock.svg';
|
|
|
39
39
|
import folderSVG from '@plone/volto/icons/folder.svg';
|
|
40
40
|
import addSVG from '@plone/volto/icons/add-document.svg';
|
|
41
41
|
import moreSVG from '@plone/volto/icons/more.svg';
|
|
42
|
-
import userSVG from '@plone/volto/icons/user.svg';
|
|
43
42
|
import backSVG from '@plone/volto/icons/back.svg';
|
|
44
43
|
import clearSVG from '@plone/volto/icons/clear.svg';
|
|
44
|
+
import UserAvatar from './UserAvatar';
|
|
45
45
|
|
|
46
46
|
const messages = defineMessages({
|
|
47
47
|
edit: {
|
|
@@ -695,8 +695,7 @@ class Toolbar extends Component {
|
|
|
695
695
|
tabIndex={0}
|
|
696
696
|
id="toolbar-personal"
|
|
697
697
|
>
|
|
698
|
-
<
|
|
699
|
-
name={userSVG}
|
|
698
|
+
<UserAvatar
|
|
700
699
|
size="30px"
|
|
701
700
|
title={this.props.intl.formatMessage(
|
|
702
701
|
messages.personalTools,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import cameraSVG from '@plone/volto/icons/camera.svg';
|
|
4
|
+
|
|
5
|
+
import UserAvatar from './UserAvatar';
|
|
6
|
+
|
|
7
|
+
const renderUserAvatar = (
|
|
8
|
+
props: React.ComponentProps<typeof UserAvatar> = {},
|
|
9
|
+
) => render(<UserAvatar {...props} />);
|
|
10
|
+
|
|
11
|
+
describe('UserAvatar', () => {
|
|
12
|
+
it('renders the default user icon', () => {
|
|
13
|
+
const { asFragment } = renderUserAvatar();
|
|
14
|
+
expect(asFragment()).toMatchSnapshot();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('defaults to the toolbar button size', () => {
|
|
18
|
+
const { container } = renderUserAvatar();
|
|
19
|
+
const svg = container.querySelector('svg');
|
|
20
|
+
|
|
21
|
+
expect(svg).not.toBeNull();
|
|
22
|
+
expect(svg).toHaveClass('icon');
|
|
23
|
+
expect(svg).toHaveStyle({ height: '30px' });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('renders at the given size', () => {
|
|
27
|
+
const { container } = renderUserAvatar({ size: '96px' });
|
|
28
|
+
|
|
29
|
+
expect(container.querySelector('svg')).toHaveStyle({ height: '96px' });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('applies the given size to a custom icon too', () => {
|
|
33
|
+
const { container } = renderUserAvatar({ icon: cameraSVG, size: '96px' });
|
|
34
|
+
|
|
35
|
+
expect(container.querySelector('svg')).toHaveStyle({ height: '96px' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('exposes the title to assistive technology', () => {
|
|
39
|
+
const { container } = renderUserAvatar({ title: 'Personal tools' });
|
|
40
|
+
|
|
41
|
+
expect(container.querySelector('svg > title')?.textContent).toBe(
|
|
42
|
+
'Personal tools',
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('renders no title element when no title is given', () => {
|
|
47
|
+
const { container } = renderUserAvatar();
|
|
48
|
+
|
|
49
|
+
expect(container.querySelector('svg > title')).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('renders the given icon instead of the default one', () => {
|
|
53
|
+
const { container: withDefault } = renderUserAvatar();
|
|
54
|
+
const { container: withCamera } = renderUserAvatar({ icon: cameraSVG });
|
|
55
|
+
|
|
56
|
+
expect(withCamera.querySelector('svg')?.innerHTML).not.toBe(
|
|
57
|
+
withDefault.querySelector('svg')?.innerHTML,
|
|
58
|
+
);
|
|
59
|
+
expect(withCamera.innerHTML).toMatchSnapshot();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('keeps the title when a custom icon is given', () => {
|
|
63
|
+
const { container } = renderUserAvatar({
|
|
64
|
+
icon: cameraSVG,
|
|
65
|
+
title: 'Personal tools',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(container.querySelector('svg > title')?.textContent).toBe(
|
|
69
|
+
'Personal tools',
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UserAvatar component.
|
|
3
|
+
* @module components/manage/Toolbar/UserAvatar
|
|
4
|
+
*/
|
|
5
|
+
import Icon from '@plone/volto/components/theme/Icon/Icon';
|
|
6
|
+
import userSVG from '@plone/volto/icons/user.svg';
|
|
7
|
+
|
|
8
|
+
export type UserAvatarProps = {
|
|
9
|
+
/** Title of the rendered icon (a11y). */
|
|
10
|
+
title?: string;
|
|
11
|
+
/** Icon to render in place of the default user glyph. */
|
|
12
|
+
icon?: typeof userSVG;
|
|
13
|
+
/** Size of the avatar (in px). Defaults to the toolbar button size. */
|
|
14
|
+
size?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Avatar of the currently signed-in user, as shown in the toolbar.
|
|
19
|
+
*
|
|
20
|
+
* Extracted so that add-ons can shadow this component alone — rendering a
|
|
21
|
+
* portrait, initials or a gravatar — instead of shadowing the whole toolbar.
|
|
22
|
+
*/
|
|
23
|
+
const UserAvatar = ({ title, icon, size = '30px' }: UserAvatarProps) => {
|
|
24
|
+
return <Icon name={icon || userSVG} size={size} title={title} />;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default UserAvatar;
|
|
@@ -148,14 +148,33 @@ class SelectAutoComplete extends Component {
|
|
|
148
148
|
|
|
149
149
|
componentDidUpdate(prevProps, prevState) {
|
|
150
150
|
const { value, choices } = this.props;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
151
|
+
// Keep the local token/title cache in sync with the incoming vocabulary
|
|
152
|
+
// choices. Seeding the cache only once (when it was still empty) locked it
|
|
153
|
+
// to whatever choices happened to be present first. When the vocabulary
|
|
154
|
+
// subrequest still held a stale token/title pair from a previously edited
|
|
155
|
+
// value (its cache persists across client-side navigation), the widget kept
|
|
156
|
+
// rendering the raw token instead of its title after the value was changed
|
|
157
|
+
// and saved. Merging fresh choices as they arrive (de-duplicated, and
|
|
158
|
+
// preserving options the user picked) resolves the current value's label as
|
|
159
|
+
// soon as it is fetched.
|
|
160
|
+
const choicesChanged = choices !== prevProps.choices;
|
|
161
|
+
const cacheEmptyWithValue =
|
|
162
|
+
this.state.termsPairsCache.length === 0 && value?.length > 0;
|
|
163
|
+
if (choices?.length > 0 && (choicesChanged || cacheEmptyWithValue)) {
|
|
164
|
+
this.setState((state) => {
|
|
165
|
+
const normalizedIncoming = normalizeChoices(choices, this.props.intl);
|
|
166
|
+
const knownValues = new Set(
|
|
167
|
+
normalizeChoices(state.termsPairsCache, this.props.intl).map(
|
|
168
|
+
(pair) => pair.value,
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
const additions = choices.filter(
|
|
172
|
+
(choice, index) => !knownValues.has(normalizedIncoming[index].value),
|
|
173
|
+
);
|
|
174
|
+
return additions.length > 0
|
|
175
|
+
? { termsPairsCache: [...state.termsPairsCache, ...additions] }
|
|
176
|
+
: null;
|
|
177
|
+
});
|
|
159
178
|
}
|
|
160
179
|
}
|
|
161
180
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import configureStore from 'redux-mock-store';
|
|
3
|
+
import { createStore, combineReducers } from 'redux';
|
|
3
4
|
import { Provider } from 'react-intl-redux';
|
|
4
|
-
import { waitFor, render, screen } from '@testing-library/react';
|
|
5
|
+
import { act, waitFor, render, screen } from '@testing-library/react';
|
|
6
|
+
import vocabularies from '@plone/volto/reducers/vocabularies/vocabularies';
|
|
5
7
|
import SelectAutoComplete from './SelectAutoComplete';
|
|
6
8
|
|
|
7
9
|
const mockStore = configureStore();
|
|
@@ -60,3 +62,64 @@ test('renders a select widget component', async () => {
|
|
|
60
62
|
await waitFor(() => screen.getByText('My field'));
|
|
61
63
|
expect(container).toMatchSnapshot();
|
|
62
64
|
});
|
|
65
|
+
|
|
66
|
+
const VOCAB = 'plone.app.vocabularies.Keywords';
|
|
67
|
+
|
|
68
|
+
function tokenTitleSuccess(token, title) {
|
|
69
|
+
return {
|
|
70
|
+
type: 'GET_VOCABULARY_TOKEN_TITLE_SUCCESS',
|
|
71
|
+
vocabulary: VOCAB,
|
|
72
|
+
tokens: [token],
|
|
73
|
+
subrequest: 'widget-responsible-en',
|
|
74
|
+
result: { items: [{ token, title }] },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test('resolves the current value label from fresh vocabulary choices even when a stale token/title pair was cached first', async () => {
|
|
79
|
+
// A real store (not a mock) so that dispatching keeps the same widget
|
|
80
|
+
// instance mounted while its `choices` change — this is what reproduces the
|
|
81
|
+
// stale vocabulary cache surviving across a client-side navigation.
|
|
82
|
+
const intl = (state = { locale: 'en', messages: {} }) => state;
|
|
83
|
+
const store = createStore(combineReducers({ intl, vocabularies }));
|
|
84
|
+
|
|
85
|
+
// The vocabulary subrequest still holds a stale pair left by a previously
|
|
86
|
+
// edited value; it does not contain the current value ("anna").
|
|
87
|
+
store.dispatch(tokenTitleSuccess('max', 'Max Berger'));
|
|
88
|
+
|
|
89
|
+
render(
|
|
90
|
+
<Provider store={store}>
|
|
91
|
+
<SelectAutoComplete
|
|
92
|
+
widgetOptions={{ vocabulary: { '@id': VOCAB } }}
|
|
93
|
+
id="responsible"
|
|
94
|
+
title="Responsible"
|
|
95
|
+
fieldSet="default"
|
|
96
|
+
isMulti={false}
|
|
97
|
+
value="anna"
|
|
98
|
+
onChange={() => {}}
|
|
99
|
+
onBlur={() => {}}
|
|
100
|
+
onClick={() => {}}
|
|
101
|
+
/>
|
|
102
|
+
</Provider>,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// With only the stale pair cached, the raw token is shown at first.
|
|
106
|
+
await waitFor(() => screen.getByText('anna'));
|
|
107
|
+
|
|
108
|
+
// A refresh of the vocabulary subrequest that still resolves the stale pair
|
|
109
|
+
// makes the widget seed its local cache from those stale choices — this is
|
|
110
|
+
// the state that used to lock the widget to the wrong label.
|
|
111
|
+
act(() => {
|
|
112
|
+
store.dispatch(tokenTitleSuccess('max', 'Max Berger'));
|
|
113
|
+
});
|
|
114
|
+
await waitFor(() => screen.getByText('anna'));
|
|
115
|
+
|
|
116
|
+
// The choices for the current value finally arrive (same mounted instance).
|
|
117
|
+
// The widget must pick up the fresh pair and render its title instead of
|
|
118
|
+
// staying locked to the stale cache it seeded earlier.
|
|
119
|
+
act(() => {
|
|
120
|
+
store.dispatch(tokenTitleSuccess('anna', 'Anna Becker'));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await waitFor(() => screen.getByText('Anna Becker'));
|
|
124
|
+
expect(screen.queryByText('anna')).not.toBeInTheDocument();
|
|
125
|
+
});
|
|
@@ -5,7 +5,12 @@ import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
|
|
5
5
|
import UniversalLink from '@plone/volto/components/manage/UniversalLink/UniversalLink';
|
|
6
6
|
import { asyncConnect } from '@plone/volto/helpers/AsyncConnect';
|
|
7
7
|
import { createPortal } from 'react-dom';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
Container as SemanticContainer,
|
|
10
|
+
Pagination,
|
|
11
|
+
Button,
|
|
12
|
+
Header,
|
|
13
|
+
} from 'semantic-ui-react';
|
|
9
14
|
import qs from 'query-string';
|
|
10
15
|
import classNames from 'classnames';
|
|
11
16
|
import config from '@plone/volto/registry';
|
|
@@ -85,6 +90,9 @@ const Search = (props) => {
|
|
|
85
90
|
|
|
86
91
|
const options = qs.parse(location.search);
|
|
87
92
|
|
|
93
|
+
const Container =
|
|
94
|
+
config.getComponent({ name: 'Container' }).component || SemanticContainer;
|
|
95
|
+
|
|
88
96
|
return (
|
|
89
97
|
<Container id="page-search">
|
|
90
98
|
<Helmet title={intl.formatMessage(messages.Search)} />
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import userSVG from '@plone/volto/icons/user.svg';
|
|
2
|
+
export type UserAvatarProps = {
|
|
3
|
+
/** Title of the rendered icon (a11y). */
|
|
4
|
+
title?: string;
|
|
5
|
+
/** Icon to render in place of the default user glyph. */
|
|
6
|
+
icon?: typeof userSVG;
|
|
7
|
+
/** Size of the avatar (in px). Defaults to the toolbar button size. */
|
|
8
|
+
size?: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Avatar of the currently signed-in user, as shown in the toolbar.
|
|
12
|
+
*
|
|
13
|
+
* Extracted so that add-ons can shadow this component alone — rendering a
|
|
14
|
+
* portrait, initials or a gravatar — instead of shadowing the whole toolbar.
|
|
15
|
+
*/
|
|
16
|
+
declare const UserAvatar: ({ title, icon, size }: UserAvatarProps) => import("react/jsx-runtime").JSX.Element;
|
|
17
|
+
export default UserAvatar;
|