@plone/volto 18.18.0 → 18.19.0
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/.eslintignore +1 -1
- package/CHANGELOG.md +18 -0
- package/package.json +4 -4
- package/src/components/manage/ConditionalLink/ConditionalLink.test.jsx +71 -12
- package/src/components/manage/ConditionalLink/ConditionalLink.tsx +34 -0
- package/src/components/manage/UniversalLink/UniversalLink.test.jsx +195 -13
- package/src/components/manage/UniversalLink/UniversalLink.tsx +214 -0
- package/tsconfig.json +7 -24
- package/types/components/manage/ConditionalLink/ConditionalLink.d.ts +7 -14
- package/types/components/manage/UniversalLink/UniversalLink.d.ts +54 -20
- package/types/react-router-hash-link.d.ts +12 -0
- package/src/components/manage/ConditionalLink/ConditionalLink.jsx +0 -27
- package/src/components/manage/UniversalLink/UniversalLink.jsx +0 -154
package/.eslintignore
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/types/
|
|
2
|
-
|
|
2
|
+
!types/react-router-hash-link.d.ts
|
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,24 @@ myst:
|
|
|
17
17
|
|
|
18
18
|
<!-- towncrier release notes start -->
|
|
19
19
|
|
|
20
|
+
## 18.19.0 (2025-05-08)
|
|
21
|
+
|
|
22
|
+
### Feature
|
|
23
|
+
|
|
24
|
+
- Refactor the UniversalLink component using typescript.
|
|
25
|
+
Use union types for deciding between href or item.
|
|
26
|
+
Modify tsconfig, include types located in `types` folder, otherwise d.ts files will noch be recognized in .tsx files, e.g. inside components. I need this for the `react-router-hash-link` package, that we use in the UniversalLink component. The specific file is `/types/react-router-hash-link.d.ts`.
|
|
27
|
+
Modify lint-staged.config.js to exclude d.ts files.
|
|
28
|
+
Use newest version of classnames (with types).
|
|
29
|
+
Create tests and negative tests for optimization with React.memo (add render counter for testing this behavior)
|
|
30
|
+
|
|
31
|
+
@tomschall [#6826](https://github.com/plone/volto/issues/6826)
|
|
32
|
+
- `ConditionalLink` in TypeScript. @sneridagh [#6959](https://github.com/plone/volto/issues/6959)
|
|
33
|
+
|
|
34
|
+
### Internal
|
|
35
|
+
|
|
36
|
+
- Fixed types of #6826 in build:types. @sneridagh
|
|
37
|
+
|
|
20
38
|
## 18.18.0 (2025-05-08)
|
|
21
39
|
|
|
22
40
|
### Feature
|
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
}
|
|
10
10
|
],
|
|
11
11
|
"license": "MIT",
|
|
12
|
-
"version": "18.
|
|
12
|
+
"version": "18.19.0",
|
|
13
13
|
"repository": {
|
|
14
14
|
"type": "git",
|
|
15
15
|
"url": "git@github.com:plone/volto.git"
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
"@loadable/component": "5.14.1",
|
|
151
151
|
"@loadable/server": "5.14.0",
|
|
152
152
|
"@redux-devtools/extension": "^3.3.0",
|
|
153
|
-
"classnames": "2.
|
|
153
|
+
"classnames": "2.5.1",
|
|
154
154
|
"connected-react-router": "6.8.0",
|
|
155
155
|
"debug": "4.3.2",
|
|
156
156
|
"decorate-component-with-props": "1.2.1",
|
|
@@ -236,9 +236,9 @@
|
|
|
236
236
|
"url": "^0.11.3",
|
|
237
237
|
"use-deep-compare-effect": "1.8.1",
|
|
238
238
|
"uuid": "^8.3.2",
|
|
239
|
-
"@plone/scripts": "3.9.0",
|
|
240
239
|
"@plone/registry": "2.5.3",
|
|
241
|
-
"@plone/
|
|
240
|
+
"@plone/scripts": "3.9.0",
|
|
241
|
+
"@plone/volto-slate": "18.3.1"
|
|
242
242
|
},
|
|
243
243
|
"devDependencies": {
|
|
244
244
|
"@babel/core": "^7.0.0",
|
|
@@ -1,28 +1,87 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import renderer from 'react-test-renderer';
|
|
3
3
|
import { MemoryRouter } from 'react-router-dom';
|
|
4
|
+
import { Provider } from 'react-intl-redux';
|
|
5
|
+
import configureStore from 'redux-mock-store';
|
|
4
6
|
|
|
5
7
|
import ConditionalLink from './ConditionalLink';
|
|
6
8
|
|
|
9
|
+
const mockStore = configureStore();
|
|
10
|
+
const store = mockStore({
|
|
11
|
+
userSession: {
|
|
12
|
+
token: null,
|
|
13
|
+
},
|
|
14
|
+
intl: {
|
|
15
|
+
locale: 'en',
|
|
16
|
+
messages: {},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
7
20
|
describe('ConditionalLink', () => {
|
|
8
|
-
it('renders a
|
|
21
|
+
it('renders a link when condition is true', () => {
|
|
22
|
+
const component = renderer.create(
|
|
23
|
+
<Provider store={store}>
|
|
24
|
+
<MemoryRouter>
|
|
25
|
+
<ConditionalLink to="/test" condition={true}>
|
|
26
|
+
Link Text
|
|
27
|
+
</ConditionalLink>
|
|
28
|
+
</MemoryRouter>
|
|
29
|
+
</Provider>,
|
|
30
|
+
);
|
|
31
|
+
const json = component.toJSON();
|
|
32
|
+
expect(json.type).toBe('a');
|
|
33
|
+
expect(json.props.href).toBe('/test');
|
|
34
|
+
expect(json.children[0]).toBe('Link Text');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders a span when condition is false', () => {
|
|
9
38
|
const component = renderer.create(
|
|
10
|
-
<
|
|
11
|
-
<
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
39
|
+
<Provider store={store}>
|
|
40
|
+
<MemoryRouter>
|
|
41
|
+
<ConditionalLink to="/test" condition={false}>
|
|
42
|
+
Link Text
|
|
43
|
+
</ConditionalLink>
|
|
44
|
+
</MemoryRouter>
|
|
45
|
+
</Provider>,
|
|
15
46
|
);
|
|
16
47
|
const json = component.toJSON();
|
|
17
48
|
expect(json).toMatchSnapshot();
|
|
18
49
|
});
|
|
19
|
-
|
|
50
|
+
|
|
51
|
+
it('passes additional props when rendering a link', () => {
|
|
52
|
+
const component = renderer.create(
|
|
53
|
+
<Provider store={store}>
|
|
54
|
+
<MemoryRouter>
|
|
55
|
+
<ConditionalLink
|
|
56
|
+
to="/test"
|
|
57
|
+
condition={true}
|
|
58
|
+
className="custom-class"
|
|
59
|
+
data-test="test-id"
|
|
60
|
+
>
|
|
61
|
+
Link Text
|
|
62
|
+
</ConditionalLink>
|
|
63
|
+
</MemoryRouter>
|
|
64
|
+
</Provider>,
|
|
65
|
+
);
|
|
66
|
+
const json = component.toJSON();
|
|
67
|
+
expect(json.props.className).toBe('custom-class');
|
|
68
|
+
expect(json.props['data-test']).toBe('test-id');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('renders a component if no external(href) link passed', () => {
|
|
20
72
|
const component = renderer.create(
|
|
21
|
-
<
|
|
22
|
-
<
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
73
|
+
<Provider store={store}>
|
|
74
|
+
<MemoryRouter>
|
|
75
|
+
<ConditionalLink
|
|
76
|
+
condition={true}
|
|
77
|
+
item={{
|
|
78
|
+
'@id': 'http://localhost:3000/en/welcome-to-volto',
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<h1>Title</h1>
|
|
82
|
+
</ConditionalLink>
|
|
83
|
+
</MemoryRouter>
|
|
84
|
+
</Provider>,
|
|
26
85
|
);
|
|
27
86
|
const json = component.toJSON();
|
|
28
87
|
expect(json).toMatchSnapshot();
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import UniversalLink from '@plone/volto/components/manage/UniversalLink/UniversalLink';
|
|
3
|
+
import type { UniversalLinkProps } from '@plone/volto/components/manage/UniversalLink/UniversalLink';
|
|
4
|
+
|
|
5
|
+
type ConditionalLinkProps = UniversalLinkProps & {
|
|
6
|
+
condition: boolean;
|
|
7
|
+
to: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const ConditionalLink = React.forwardRef<
|
|
11
|
+
HTMLAnchorElement,
|
|
12
|
+
ConditionalLinkProps
|
|
13
|
+
>(({ condition, to, ...props }, ref) => {
|
|
14
|
+
if (condition) {
|
|
15
|
+
if (!props.item && to) {
|
|
16
|
+
return (
|
|
17
|
+
// @ts-ignore If not here, the build:types fails
|
|
18
|
+
<UniversalLink {...props} href={to} ref={ref}>
|
|
19
|
+
{props.children}
|
|
20
|
+
</UniversalLink>
|
|
21
|
+
);
|
|
22
|
+
} else {
|
|
23
|
+
return (
|
|
24
|
+
<UniversalLink {...props} ref={ref}>
|
|
25
|
+
{props.children}
|
|
26
|
+
</UniversalLink>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
return <>{props.children}</>;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export default ConditionalLink;
|
|
@@ -4,7 +4,7 @@ import { Provider } from 'react-intl-redux';
|
|
|
4
4
|
import configureStore from 'redux-mock-store';
|
|
5
5
|
import { render } from '@testing-library/react';
|
|
6
6
|
import { MemoryRouter } from 'react-router-dom';
|
|
7
|
-
import UniversalLink from './UniversalLink';
|
|
7
|
+
import UniversalLink, { __test } from './UniversalLink';
|
|
8
8
|
import config from '@plone/volto/registry';
|
|
9
9
|
|
|
10
10
|
const mockStore = configureStore();
|
|
@@ -39,7 +39,10 @@ describe('UniversalLink', () => {
|
|
|
39
39
|
const component = renderer.create(
|
|
40
40
|
<Provider store={store}>
|
|
41
41
|
<MemoryRouter>
|
|
42
|
-
<UniversalLink
|
|
42
|
+
<UniversalLink
|
|
43
|
+
href="https://github.com/plone/volto"
|
|
44
|
+
className="custom-link"
|
|
45
|
+
>
|
|
43
46
|
<h1>Title</h1>
|
|
44
47
|
</UniversalLink>
|
|
45
48
|
</MemoryRouter>
|
|
@@ -213,18 +216,197 @@ describe('UniversalLink', () => {
|
|
|
213
216
|
expect(json).toMatchSnapshot();
|
|
214
217
|
expect(global.console.error).toHaveBeenCalled();
|
|
215
218
|
});
|
|
216
|
-
});
|
|
217
219
|
|
|
218
|
-
it('renders a UniversalLink component when url ends with @@display-file', () => {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
220
|
+
it('renders a UniversalLink component when url ends with @@display-file', () => {
|
|
221
|
+
const component = renderer.create(
|
|
222
|
+
<Provider store={store}>
|
|
223
|
+
<MemoryRouter>
|
|
224
|
+
<UniversalLink href="http://localhost:3000/en/welcome-to-volto/@@display-file">
|
|
225
|
+
<h1>Title</h1>
|
|
226
|
+
</UniversalLink>
|
|
227
|
+
</MemoryRouter>
|
|
228
|
+
</Provider>,
|
|
229
|
+
);
|
|
230
|
+
const json = component.toJSON();
|
|
231
|
+
expect(json).toMatchSnapshot();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('only one UniversalLink re-renders when prop changes (stable references)', () => {
|
|
235
|
+
const renderCounter = vi.fn();
|
|
236
|
+
__test.renderCounter = renderCounter;
|
|
237
|
+
|
|
238
|
+
const itemA = { '@id': '/en/a' };
|
|
239
|
+
const itemB = { '@id': '/en/b' };
|
|
240
|
+
const itemC = { '@id': '/en/c' };
|
|
241
|
+
|
|
242
|
+
const Wrapper = ({ children }) => (
|
|
243
|
+
<Provider store={store}>
|
|
244
|
+
<MemoryRouter>{children}</MemoryRouter>
|
|
245
|
+
</Provider>
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const { rerender } = render(
|
|
249
|
+
<>
|
|
250
|
+
<UniversalLink item={itemA} />
|
|
251
|
+
<UniversalLink item={itemB} />
|
|
252
|
+
<UniversalLink item={itemC} />
|
|
253
|
+
</>,
|
|
254
|
+
{ wrapper: Wrapper },
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
// expect 3 renders
|
|
258
|
+
expect(renderCounter).toHaveBeenCalledTimes(3);
|
|
259
|
+
|
|
260
|
+
const updatedItemB = { '@id': '/en/b-updated' };
|
|
261
|
+
|
|
262
|
+
rerender(
|
|
263
|
+
<>
|
|
264
|
+
<UniversalLink item={itemA} />
|
|
265
|
+
<UniversalLink item={updatedItemB} />
|
|
266
|
+
<UniversalLink item={itemC} />
|
|
267
|
+
</>,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// expect 4 renders (only one UniversalLink re-renders)
|
|
271
|
+
expect(renderCounter).toHaveBeenCalledTimes(4);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('only one UniversalLink re-renders when prop changes (with children - stable references)', () => {
|
|
275
|
+
const renderCounter = vi.fn();
|
|
276
|
+
__test.renderCounter = renderCounter;
|
|
277
|
+
|
|
278
|
+
const itemA = { '@id': '/en/a' };
|
|
279
|
+
const itemB = { '@id': '/en/b' };
|
|
280
|
+
const itemC = { '@id': '/en/c' };
|
|
281
|
+
const title = 'Title';
|
|
282
|
+
|
|
283
|
+
const Wrapper = ({ children }) => (
|
|
284
|
+
<Provider store={store}>
|
|
285
|
+
<MemoryRouter>{children}</MemoryRouter>
|
|
286
|
+
</Provider>
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const { rerender } = render(
|
|
290
|
+
<>
|
|
291
|
+
<UniversalLink item={itemA}>{title}</UniversalLink>
|
|
292
|
+
<UniversalLink item={itemB}>{title}</UniversalLink>
|
|
293
|
+
<UniversalLink item={itemC}>{title}</UniversalLink>
|
|
294
|
+
</>,
|
|
295
|
+
{ wrapper: Wrapper },
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// expect 3 renders
|
|
299
|
+
expect(renderCounter).toHaveBeenCalledTimes(3);
|
|
300
|
+
|
|
301
|
+
const updatedItemB = { '@id': '/en/b-updated' };
|
|
302
|
+
|
|
303
|
+
rerender(
|
|
304
|
+
<>
|
|
305
|
+
<UniversalLink item={itemA}>{title}</UniversalLink>
|
|
306
|
+
<UniversalLink item={updatedItemB}>{title}</UniversalLink>
|
|
307
|
+
<UniversalLink item={itemC}>{title}</UniversalLink>
|
|
308
|
+
</>,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// expect 4 renders (only one UniversalLink re-renders)
|
|
312
|
+
expect(renderCounter).toHaveBeenCalledTimes(4);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('[NEGATIVE TEST] UniversalLink re-renders all instances when children are inline JSX (React.memo ineffective)', () => {
|
|
316
|
+
// NEGATIVE TEST:
|
|
317
|
+
// This test demonstrates that React.memo does NOT prevent re-renders
|
|
318
|
+
// when props like `children` are passed as inline JSX.
|
|
319
|
+
// This is expected behavior due to unstable object references.
|
|
320
|
+
// Do NOT use inline props if render optimization is required.
|
|
321
|
+
const renderCounter = vi.fn();
|
|
322
|
+
__test.renderCounter = renderCounter;
|
|
323
|
+
|
|
324
|
+
const itemA = { '@id': '/en/a' };
|
|
325
|
+
const itemB = { '@id': '/en/b' };
|
|
326
|
+
const itemC = { '@id': '/en/c' };
|
|
327
|
+
|
|
328
|
+
const Wrapper = ({ children }) => (
|
|
329
|
+
<Provider store={store}>
|
|
330
|
+
<MemoryRouter>{children}</MemoryRouter>
|
|
331
|
+
</Provider>
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const { rerender } = render(
|
|
335
|
+
<>
|
|
336
|
+
<UniversalLink item={itemA}>
|
|
337
|
+
<h1>Title</h1>
|
|
338
|
+
</UniversalLink>
|
|
339
|
+
<UniversalLink item={itemB}>
|
|
340
|
+
<h1>Title</h1>
|
|
341
|
+
</UniversalLink>
|
|
342
|
+
<UniversalLink item={itemC}>
|
|
343
|
+
<h1>Title</h1>
|
|
344
|
+
</UniversalLink>
|
|
345
|
+
</>,
|
|
346
|
+
{ wrapper: Wrapper },
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// expect 3 renders
|
|
350
|
+
expect(renderCounter).toHaveBeenCalledTimes(3);
|
|
351
|
+
|
|
352
|
+
const updatedItemB = { '@id': '/en/b-updated' };
|
|
353
|
+
|
|
354
|
+
rerender(
|
|
355
|
+
<>
|
|
356
|
+
<UniversalLink item={itemA}>
|
|
223
357
|
<h1>Title</h1>
|
|
224
358
|
</UniversalLink>
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
359
|
+
<UniversalLink item={updatedItemB}>
|
|
360
|
+
<h1>Title</h1>
|
|
361
|
+
</UniversalLink>
|
|
362
|
+
<UniversalLink item={itemC}>
|
|
363
|
+
<h1>Title</h1>
|
|
364
|
+
</UniversalLink>
|
|
365
|
+
</>,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
// expect 6 renders (React.memo does NOT prevent re-renders when props like `children` are passed as inline JSX.)
|
|
369
|
+
expect(renderCounter).toHaveBeenCalledTimes(6);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('[NEGATIVE TEST] UniversalLink re-renders all instances when props are inline JSX (React.memo ineffective)', () => {
|
|
373
|
+
// NEGATIVE TEST:
|
|
374
|
+
// This test demonstrates that React.memo does NOT prevent re-renders
|
|
375
|
+
// when props like `item` are passed as inline object.
|
|
376
|
+
// This is expected behavior due to unstable object references.
|
|
377
|
+
// Do NOT use inline props if render optimization is required.
|
|
378
|
+
const renderCounter = vi.fn();
|
|
379
|
+
__test.renderCounter = renderCounter;
|
|
380
|
+
|
|
381
|
+
const title = 'Title';
|
|
382
|
+
|
|
383
|
+
const Wrapper = ({ children }) => (
|
|
384
|
+
<Provider store={store}>
|
|
385
|
+
<MemoryRouter>{children}</MemoryRouter>
|
|
386
|
+
</Provider>
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const { rerender } = render(
|
|
390
|
+
<>
|
|
391
|
+
<UniversalLink item={{ '@id': '/en/a' }}>{title}</UniversalLink>
|
|
392
|
+
<UniversalLink item={{ '@id': '/en/b' }}>{title}</UniversalLink>
|
|
393
|
+
<UniversalLink item={{ '@id': '/en/c' }}>{title}</UniversalLink>
|
|
394
|
+
</>,
|
|
395
|
+
{ wrapper: Wrapper },
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
// expect 3 renders
|
|
399
|
+
expect(renderCounter).toHaveBeenCalledTimes(3);
|
|
400
|
+
|
|
401
|
+
rerender(
|
|
402
|
+
<>
|
|
403
|
+
<UniversalLink item={{ '@id': '/en/a' }}>{title}</UniversalLink>
|
|
404
|
+
<UniversalLink item={{ '@id': '/en/b' }}>{title}</UniversalLink>
|
|
405
|
+
<UniversalLink item={{ '@id': '/en/c' }}>{title}</UniversalLink>
|
|
406
|
+
</>,
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
// expect 6 renders (React.memo does NOT prevent re-renders when props like `children` are passed as inline JSX.)
|
|
410
|
+
expect(renderCounter).toHaveBeenCalledTimes(6);
|
|
411
|
+
});
|
|
230
412
|
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { HashLink as Link } from 'react-router-hash-link';
|
|
3
|
+
import { useSelector } from 'react-redux';
|
|
4
|
+
import {
|
|
5
|
+
flattenToAppURL,
|
|
6
|
+
isInternalURL,
|
|
7
|
+
URLUtils,
|
|
8
|
+
} from '@plone/volto/helpers/Url/Url';
|
|
9
|
+
import config from '@plone/volto/registry';
|
|
10
|
+
import cx from 'classnames';
|
|
11
|
+
import type { ObjectBrowserItem } from '@plone/types';
|
|
12
|
+
|
|
13
|
+
type BaseProps = {
|
|
14
|
+
openLinkInNewTab?: boolean;
|
|
15
|
+
download?: boolean;
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
className?: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
smooth?: boolean;
|
|
20
|
+
onClick?: (e: React.MouseEvent) => void;
|
|
21
|
+
onKeyDown?: (e: React.KeyboardEvent) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type HrefOnly = {
|
|
25
|
+
href: string;
|
|
26
|
+
item?: never;
|
|
27
|
+
} & BaseProps;
|
|
28
|
+
|
|
29
|
+
type ItemOnly = {
|
|
30
|
+
href?: never;
|
|
31
|
+
item: Partial<ObjectBrowserItem> & { remoteUrl?: string };
|
|
32
|
+
} & BaseProps;
|
|
33
|
+
|
|
34
|
+
export type UniversalLinkProps = HrefOnly | ItemOnly;
|
|
35
|
+
export interface AppState {
|
|
36
|
+
content: {
|
|
37
|
+
data?: {
|
|
38
|
+
'@components'?: {
|
|
39
|
+
translations?: {
|
|
40
|
+
items?: { language: string; '@id': string }[];
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
navroot: {
|
|
46
|
+
data: {
|
|
47
|
+
navroot: {
|
|
48
|
+
id: string;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
intl: {
|
|
53
|
+
locale: string;
|
|
54
|
+
};
|
|
55
|
+
userSession: {
|
|
56
|
+
token: string | null;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const __test = {
|
|
61
|
+
renderCounter: () => {},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function getUrl(
|
|
65
|
+
props: UniversalLinkProps,
|
|
66
|
+
token: string | null,
|
|
67
|
+
item: UniversalLinkProps['item'],
|
|
68
|
+
children: React.ReactNode,
|
|
69
|
+
): string {
|
|
70
|
+
if ('href' in props && typeof props.href === 'string') {
|
|
71
|
+
return props.href;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!item || item['@id'] === '') return config.settings.publicURL;
|
|
75
|
+
if (!item['@id']) {
|
|
76
|
+
// eslint-disable-next-line no-console
|
|
77
|
+
console.error(
|
|
78
|
+
'Invalid item passed to UniversalLink',
|
|
79
|
+
item,
|
|
80
|
+
props,
|
|
81
|
+
children,
|
|
82
|
+
);
|
|
83
|
+
return '#';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let url = flattenToAppURL(item['@id']);
|
|
87
|
+
const remoteUrl = item.remoteUrl || item.getRemoteUrl;
|
|
88
|
+
|
|
89
|
+
if (!token && remoteUrl) {
|
|
90
|
+
url = remoteUrl;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (
|
|
94
|
+
!token &&
|
|
95
|
+
item['@type'] &&
|
|
96
|
+
config.settings.downloadableObjects.includes(item['@type'])
|
|
97
|
+
) {
|
|
98
|
+
url = `${url}/@@download/file`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
!token &&
|
|
103
|
+
item['@type'] &&
|
|
104
|
+
config.settings.viewableInBrowserObjects.includes(item['@type'])
|
|
105
|
+
) {
|
|
106
|
+
url = `${url}/@@display-file/file`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return url;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const UniversalLink = React.memo(
|
|
113
|
+
React.forwardRef<HTMLAnchorElement | HTMLDivElement, UniversalLinkProps>(
|
|
114
|
+
function UniversalLink(props, ref) {
|
|
115
|
+
const {
|
|
116
|
+
openLinkInNewTab,
|
|
117
|
+
download,
|
|
118
|
+
children,
|
|
119
|
+
className,
|
|
120
|
+
title,
|
|
121
|
+
smooth,
|
|
122
|
+
onClick,
|
|
123
|
+
onKeyDown,
|
|
124
|
+
item,
|
|
125
|
+
...rest
|
|
126
|
+
} = props;
|
|
127
|
+
__test.renderCounter();
|
|
128
|
+
|
|
129
|
+
const token = useSelector<AppState, string | null>(
|
|
130
|
+
(state) => state.userSession?.token,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
let url = getUrl(props, token, item, children);
|
|
134
|
+
|
|
135
|
+
const isExternal = !isInternalURL(url);
|
|
136
|
+
|
|
137
|
+
const isDownload =
|
|
138
|
+
(!isExternal && url.includes('@@download')) || download;
|
|
139
|
+
const isDisplayFile =
|
|
140
|
+
(!isExternal && url.includes('@@display-file')) || false;
|
|
141
|
+
|
|
142
|
+
const checkedURL = URLUtils.checkAndNormalizeUrl(url);
|
|
143
|
+
|
|
144
|
+
url = checkedURL.url;
|
|
145
|
+
let tag = (
|
|
146
|
+
<Link
|
|
147
|
+
to={flattenToAppURL(url)}
|
|
148
|
+
target={openLinkInNewTab ?? false ? '_blank' : undefined}
|
|
149
|
+
title={title}
|
|
150
|
+
className={className}
|
|
151
|
+
smooth={smooth ?? config.settings.hashLinkSmoothScroll}
|
|
152
|
+
// @ts-ignore
|
|
153
|
+
ref={ref}
|
|
154
|
+
{...rest}
|
|
155
|
+
>
|
|
156
|
+
{children}
|
|
157
|
+
</Link>
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
if (isExternal) {
|
|
161
|
+
const isTelephoneOrMail = checkedURL.isMail || checkedURL.isTelephone;
|
|
162
|
+
const getClassName = cx({ external: !isTelephoneOrMail }, className);
|
|
163
|
+
|
|
164
|
+
tag = (
|
|
165
|
+
<a
|
|
166
|
+
href={url}
|
|
167
|
+
title={title}
|
|
168
|
+
target={
|
|
169
|
+
!isTelephoneOrMail && !(openLinkInNewTab === false)
|
|
170
|
+
? '_blank'
|
|
171
|
+
: undefined
|
|
172
|
+
}
|
|
173
|
+
rel="noopener noreferrer"
|
|
174
|
+
{...rest}
|
|
175
|
+
className={getClassName}
|
|
176
|
+
ref={ref as React.Ref<HTMLAnchorElement>}
|
|
177
|
+
>
|
|
178
|
+
{children}
|
|
179
|
+
</a>
|
|
180
|
+
);
|
|
181
|
+
} else if (isDownload) {
|
|
182
|
+
tag = (
|
|
183
|
+
<a
|
|
184
|
+
href={flattenToAppURL(url)}
|
|
185
|
+
download
|
|
186
|
+
title={title}
|
|
187
|
+
{...rest}
|
|
188
|
+
className={className}
|
|
189
|
+
ref={ref as React.Ref<HTMLAnchorElement>}
|
|
190
|
+
>
|
|
191
|
+
{children}
|
|
192
|
+
</a>
|
|
193
|
+
);
|
|
194
|
+
} else if (isDisplayFile) {
|
|
195
|
+
tag = (
|
|
196
|
+
<a
|
|
197
|
+
title={title}
|
|
198
|
+
target="_blank"
|
|
199
|
+
rel="noopener noreferrer"
|
|
200
|
+
{...rest}
|
|
201
|
+
href={flattenToAppURL(url)}
|
|
202
|
+
className={className}
|
|
203
|
+
ref={ref as React.Ref<HTMLAnchorElement>}
|
|
204
|
+
>
|
|
205
|
+
{children}
|
|
206
|
+
</a>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
return tag;
|
|
210
|
+
},
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
export default UniversalLink;
|
package/tsconfig.json
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
3
|
"target": "ESNext",
|
|
4
|
-
"lib": [
|
|
5
|
-
|
|
6
|
-
"DOM.Iterable",
|
|
7
|
-
"ESNext"
|
|
8
|
-
],
|
|
9
|
-
"types": [
|
|
10
|
-
"vitest/globals",
|
|
11
|
-
"jest"
|
|
12
|
-
],
|
|
4
|
+
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
|
5
|
+
"types": ["vitest/globals", "jest"],
|
|
13
6
|
"module": "commonjs",
|
|
14
7
|
"allowJs": true,
|
|
15
8
|
"skipLibCheck": true,
|
|
@@ -24,22 +17,12 @@
|
|
|
24
17
|
"noEmit": true,
|
|
25
18
|
"jsx": "react-jsx",
|
|
26
19
|
"paths": {
|
|
27
|
-
"@plone/volto/*": [
|
|
28
|
-
|
|
29
|
-
]
|
|
30
|
-
"@plone/volto-slate/*": [
|
|
31
|
-
"../volto-slate/src/*"
|
|
32
|
-
],
|
|
33
|
-
"@root/*": [
|
|
34
|
-
"./src/*"
|
|
35
|
-
]
|
|
20
|
+
"@plone/volto/*": ["./src/*"],
|
|
21
|
+
"@plone/volto-slate/*": ["../volto-slate/src/*"],
|
|
22
|
+
"@root/*": ["./src/*"]
|
|
36
23
|
}
|
|
37
24
|
},
|
|
38
|
-
"include": [
|
|
39
|
-
"src",
|
|
40
|
-
"packages",
|
|
41
|
-
"vitest.config.ts"
|
|
42
|
-
],
|
|
25
|
+
"include": ["src", "packages", "types", "vitest.config.ts"],
|
|
43
26
|
"exclude": [
|
|
44
27
|
"node_modules",
|
|
45
28
|
"build",
|
|
@@ -49,4 +32,4 @@
|
|
|
49
32
|
"src/**/*.spec.{js,jsx,ts,tsx}",
|
|
50
33
|
"src/**/*.stories.{js,jsx,ts,tsx}"
|
|
51
34
|
]
|
|
52
|
-
}
|
|
35
|
+
}
|
|
@@ -1,15 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { UniversalLinkProps } from '@plone/volto/components/manage/UniversalLink/UniversalLink';
|
|
3
|
+
type ConditionalLinkProps = UniversalLinkProps & {
|
|
4
|
+
condition: boolean;
|
|
5
|
+
to: string;
|
|
6
|
+
};
|
|
7
|
+
declare const ConditionalLink: React.ForwardRefExoticComponent<ConditionalLinkProps & React.RefAttributes<HTMLAnchorElement>>;
|
|
1
8
|
export default ConditionalLink;
|
|
2
|
-
declare function ConditionalLink({ condition, to, item, ...props }: {
|
|
3
|
-
[x: string]: any;
|
|
4
|
-
condition: any;
|
|
5
|
-
to: any;
|
|
6
|
-
item: any;
|
|
7
|
-
}): import("react/jsx-runtime").JSX.Element;
|
|
8
|
-
declare namespace ConditionalLink {
|
|
9
|
-
namespace propTypes {
|
|
10
|
-
let condition: any;
|
|
11
|
-
let to: any;
|
|
12
|
-
let item: any;
|
|
13
|
-
let children: any;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
@@ -1,22 +1,56 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
item?: any;
|
|
6
|
-
openLinkInNewTab: any;
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ObjectBrowserItem } from '@plone/types';
|
|
3
|
+
type BaseProps = {
|
|
4
|
+
openLinkInNewTab?: boolean;
|
|
7
5
|
download?: boolean;
|
|
8
|
-
children:
|
|
9
|
-
className?:
|
|
10
|
-
title?:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
className?: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
smooth?: boolean;
|
|
10
|
+
onClick?: (e: React.MouseEvent) => void;
|
|
11
|
+
onKeyDown?: (e: React.KeyboardEvent) => void;
|
|
12
|
+
};
|
|
13
|
+
type HrefOnly = {
|
|
14
|
+
href: string;
|
|
15
|
+
item?: never;
|
|
16
|
+
} & BaseProps;
|
|
17
|
+
type ItemOnly = {
|
|
18
|
+
href?: never;
|
|
19
|
+
item: Partial<ObjectBrowserItem> & {
|
|
20
|
+
remoteUrl?: string;
|
|
21
|
+
};
|
|
22
|
+
} & BaseProps;
|
|
23
|
+
export type UniversalLinkProps = HrefOnly | ItemOnly;
|
|
24
|
+
export interface AppState {
|
|
25
|
+
content: {
|
|
26
|
+
data?: {
|
|
27
|
+
'@components'?: {
|
|
28
|
+
translations?: {
|
|
29
|
+
items?: {
|
|
30
|
+
language: string;
|
|
31
|
+
'@id': string;
|
|
32
|
+
}[];
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
navroot: {
|
|
38
|
+
data: {
|
|
39
|
+
navroot: {
|
|
40
|
+
id: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
intl: {
|
|
45
|
+
locale: string;
|
|
46
|
+
};
|
|
47
|
+
userSession: {
|
|
48
|
+
token: string | null;
|
|
49
|
+
};
|
|
22
50
|
}
|
|
51
|
+
export declare const __test: {
|
|
52
|
+
renderCounter: () => void;
|
|
53
|
+
};
|
|
54
|
+
export declare function getUrl(props: UniversalLinkProps, token: string | null, item: UniversalLinkProps['item'], children: React.ReactNode): string;
|
|
55
|
+
declare const UniversalLink: React.MemoExoticComponent<React.ForwardRefExoticComponent<UniversalLinkProps & React.RefAttributes<HTMLAnchorElement | HTMLDivElement>>>;
|
|
56
|
+
export default UniversalLink;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
declare module 'react-router-hash-link' {
|
|
2
|
+
import { ComponentType } from 'react';
|
|
3
|
+
import { NavLinkProps } from 'react-router-dom';
|
|
4
|
+
|
|
5
|
+
interface HashLinkProps extends NavLinkProps {
|
|
6
|
+
smooth?: boolean;
|
|
7
|
+
scroll?: (element: HTMLElement) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const HashLink: ComponentType<HashLinkProps>;
|
|
11
|
+
export const NavHashLink: ComponentType<HashLinkProps>;
|
|
12
|
+
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import UniversalLink from '@plone/volto/components/manage/UniversalLink/UniversalLink';
|
|
3
|
-
import PropTypes from 'prop-types';
|
|
4
|
-
|
|
5
|
-
const ConditionalLink = ({ condition, to, item, ...props }) => {
|
|
6
|
-
if (condition) {
|
|
7
|
-
return (
|
|
8
|
-
<UniversalLink href={to} item={item} {...props}>
|
|
9
|
-
{props.children}
|
|
10
|
-
</UniversalLink>
|
|
11
|
-
);
|
|
12
|
-
} else {
|
|
13
|
-
return <>{props.children}</>;
|
|
14
|
-
}
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
ConditionalLink.propTypes = {
|
|
18
|
-
condition: PropTypes.bool,
|
|
19
|
-
to: PropTypes.string,
|
|
20
|
-
item: PropTypes.shape({
|
|
21
|
-
'@id': PropTypes.string,
|
|
22
|
-
remoteUrl: PropTypes.string, //of plone @type 'Link'
|
|
23
|
-
}),
|
|
24
|
-
children: PropTypes.node,
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export default ConditionalLink;
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* UniversalLink
|
|
3
|
-
* @module components/UniversalLink
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import React from 'react';
|
|
7
|
-
import PropTypes from 'prop-types';
|
|
8
|
-
import { HashLink as Link } from 'react-router-hash-link';
|
|
9
|
-
import { useSelector } from 'react-redux';
|
|
10
|
-
import {
|
|
11
|
-
flattenToAppURL,
|
|
12
|
-
isInternalURL,
|
|
13
|
-
URLUtils,
|
|
14
|
-
} from '@plone/volto/helpers/Url/Url';
|
|
15
|
-
|
|
16
|
-
import config from '@plone/volto/registry';
|
|
17
|
-
import cx from 'classnames';
|
|
18
|
-
|
|
19
|
-
const UniversalLink = ({
|
|
20
|
-
href,
|
|
21
|
-
item = null,
|
|
22
|
-
openLinkInNewTab,
|
|
23
|
-
download = false,
|
|
24
|
-
children,
|
|
25
|
-
className = null,
|
|
26
|
-
title = null,
|
|
27
|
-
...props
|
|
28
|
-
}) => {
|
|
29
|
-
const token = useSelector((state) => state.userSession?.token);
|
|
30
|
-
|
|
31
|
-
let url = href;
|
|
32
|
-
if (!href && item) {
|
|
33
|
-
if (item['@id'] === '') {
|
|
34
|
-
url = config.settings.publicURL;
|
|
35
|
-
} else if (!item['@id']) {
|
|
36
|
-
// eslint-disable-next-line no-console
|
|
37
|
-
console.error(
|
|
38
|
-
'Invalid item passed to UniversalLink',
|
|
39
|
-
item,
|
|
40
|
-
props,
|
|
41
|
-
children,
|
|
42
|
-
);
|
|
43
|
-
url = '#';
|
|
44
|
-
} else {
|
|
45
|
-
//case: generic item
|
|
46
|
-
url = flattenToAppURL(item['@id']);
|
|
47
|
-
|
|
48
|
-
//case: item like a Link
|
|
49
|
-
let remoteUrl = item.remoteUrl || item.getRemoteUrl;
|
|
50
|
-
if (!token && remoteUrl) {
|
|
51
|
-
url = remoteUrl;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
//case: item of type 'File'
|
|
55
|
-
if (
|
|
56
|
-
!token &&
|
|
57
|
-
config.settings.downloadableObjects.includes(item['@type'])
|
|
58
|
-
) {
|
|
59
|
-
url = `${url}/@@download/file`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
!token &&
|
|
64
|
-
config.settings.viewableInBrowserObjects.includes(item['@type'])
|
|
65
|
-
) {
|
|
66
|
-
url = `${url}/@@display-file/file`;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const isExternal = !isInternalURL(url);
|
|
72
|
-
|
|
73
|
-
const isDownload = (!isExternal && url.includes('@@download')) || download;
|
|
74
|
-
const isDisplayFile =
|
|
75
|
-
(!isExternal && url.includes('@@display-file')) || false;
|
|
76
|
-
|
|
77
|
-
const checkedURL = URLUtils.checkAndNormalizeUrl(url);
|
|
78
|
-
|
|
79
|
-
url = checkedURL.url;
|
|
80
|
-
let tag = (
|
|
81
|
-
<Link
|
|
82
|
-
to={flattenToAppURL(url)}
|
|
83
|
-
target={openLinkInNewTab ?? false ? '_blank' : null}
|
|
84
|
-
title={title}
|
|
85
|
-
className={className}
|
|
86
|
-
smooth={config.settings.hashLinkSmoothScroll}
|
|
87
|
-
{...props}
|
|
88
|
-
>
|
|
89
|
-
{children}
|
|
90
|
-
</Link>
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
if (isExternal) {
|
|
94
|
-
const isTelephoneOrMail = checkedURL.isMail || checkedURL.isTelephone;
|
|
95
|
-
tag = (
|
|
96
|
-
<a
|
|
97
|
-
href={url}
|
|
98
|
-
title={title}
|
|
99
|
-
target={
|
|
100
|
-
!isTelephoneOrMail && !(openLinkInNewTab === false) ? '_blank' : null
|
|
101
|
-
}
|
|
102
|
-
rel="noopener noreferrer"
|
|
103
|
-
className={cx({ external: !isTelephoneOrMail }, className)}
|
|
104
|
-
{...props}
|
|
105
|
-
>
|
|
106
|
-
{children}
|
|
107
|
-
</a>
|
|
108
|
-
);
|
|
109
|
-
} else if (isDownload) {
|
|
110
|
-
tag = (
|
|
111
|
-
<a
|
|
112
|
-
href={flattenToAppURL(url)}
|
|
113
|
-
download
|
|
114
|
-
title={title}
|
|
115
|
-
className={className}
|
|
116
|
-
{...props}
|
|
117
|
-
>
|
|
118
|
-
{children}
|
|
119
|
-
</a>
|
|
120
|
-
);
|
|
121
|
-
} else if (isDisplayFile) {
|
|
122
|
-
tag = (
|
|
123
|
-
<a
|
|
124
|
-
href={flattenToAppURL(url)}
|
|
125
|
-
title={title}
|
|
126
|
-
target="_blank"
|
|
127
|
-
rel="noopener noreferrer"
|
|
128
|
-
className={className}
|
|
129
|
-
{...props}
|
|
130
|
-
>
|
|
131
|
-
{children}
|
|
132
|
-
</a>
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
return tag;
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
UniversalLink.propTypes = {
|
|
139
|
-
href: PropTypes.string,
|
|
140
|
-
openLinkInNewTab: PropTypes.bool,
|
|
141
|
-
download: PropTypes.bool,
|
|
142
|
-
className: PropTypes.string,
|
|
143
|
-
title: PropTypes.string,
|
|
144
|
-
item: PropTypes.shape({
|
|
145
|
-
'@id': PropTypes.string.isRequired,
|
|
146
|
-
remoteUrl: PropTypes.string, //of plone @type 'Link'
|
|
147
|
-
}),
|
|
148
|
-
children: PropTypes.oneOfType([
|
|
149
|
-
PropTypes.arrayOf(PropTypes.node),
|
|
150
|
-
PropTypes.node,
|
|
151
|
-
]),
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
export default UniversalLink;
|