@eeacms/volto-slate-footnote 7.2.4 → 7.2.5

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 CHANGED
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
+ ### [7.2.5](https://github.com/eea/volto-slate-footnote/compare/7.2.4...7.2.5) - 9 September 2025
8
+
7
9
  ### [7.2.4](https://github.com/eea/volto-slate-footnote/compare/7.2.3...7.2.4) - 11 July 2025
8
10
 
9
11
  #### :bug: Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-slate-footnote",
3
- "version": "7.2.4",
3
+ "version": "7.2.5",
4
4
  "description": "volto-slate-footnote: Volto add-on",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -2,6 +2,20 @@
2
2
  & :target {
3
3
  background: yellow;
4
4
  }
5
+
6
+ ol {
7
+ overflow-wrap: break-word;
8
+ word-wrap: break-word;
9
+
10
+ li {
11
+ overflow-wrap: break-word;
12
+
13
+ a {
14
+ overflow-wrap: break-word;
15
+ word-break: break-all;
16
+ }
17
+ }
18
+ }
5
19
  }
6
20
 
7
21
  .slateFootnotes {
@@ -1,8 +1,27 @@
1
1
  import React from 'react';
2
- import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2
+ import { render, screen, fireEvent } from '@testing-library/react';
3
3
  import SearchWidget from './SearchWidget';
4
4
  import '@testing-library/jest-dom/extend-expect';
5
5
 
6
+ jest.mock('semantic-ui-react', () => {
7
+ const Card = ({ children }) => <div>{children}</div>;
8
+ Card.Content = ({ children }) => <div>{children}</div>;
9
+ Card.Header = ({ children }) => <div>{children}</div>;
10
+ Card.Description = ({ children }) => <div>{children}</div>;
11
+
12
+ return {
13
+ Search: ({ onSearchChange, value }) => (
14
+ <input
15
+ type="text"
16
+ value={value || ''}
17
+ onChange={(e) => onSearchChange(e, { value: e.target.value })}
18
+ />
19
+ ),
20
+ Card,
21
+ Segment: ({ children }) => <div>{children}</div>,
22
+ };
23
+ });
24
+
6
25
  describe('SearchWidget', () => {
7
26
  const choices = [
8
27
  { footnote: 'Citation 1' },
@@ -10,61 +29,30 @@ describe('SearchWidget', () => {
10
29
  { footnote: 'Citation 3' },
11
30
  ];
12
31
 
13
- it('renders the search input and displays the choices', () => {
32
+ it('renders the search widget', () => {
14
33
  const onChange = jest.fn();
15
34
  render(<SearchWidget choices={choices} onChange={onChange} value="" />);
16
35
 
17
36
  expect(screen.getByRole('textbox')).toBeInTheDocument();
18
37
  expect(screen.getByText('Citation')).toBeInTheDocument();
19
- expect(screen.queryAllByRole('option')).toHaveLength(0);
20
- expect(screen.getByText('No results found.')).toBeInTheDocument();
21
38
  });
22
39
 
23
- it('filters the choices based on the search input', async () => {
24
- const onChange = jest.fn();
25
- const { container } = render(
26
- <SearchWidget choices={choices} onChange={onChange} value="" />,
27
- );
28
-
29
- const searchInput = screen.getByRole('textbox');
30
- fireEvent.change(searchInput, { target: { value: 'Citation 2' } });
31
-
32
- await waitFor(() => {
33
- expect(container.querySelectorAll('.result')).toHaveLength(1);
34
- expect(
35
- container.querySelector('div[footnote="Citation 2"]'),
36
- ).toBeInTheDocument();
37
- });
38
- });
39
-
40
- it('calls the onChange callback when a choice is selected', () => {
40
+ it('calls onChange when input changes', () => {
41
41
  const onChange = jest.fn();
42
42
  render(<SearchWidget choices={choices} onChange={onChange} value="" />);
43
43
 
44
- const searchInput = screen.getByRole('textbox');
45
- fireEvent.change(searchInput, { target: { value: 'Citation 2' } });
44
+ const input = screen.getByRole('textbox');
45
+ fireEvent.change(input, { target: { value: 'test' } });
46
46
 
47
- waitFor(() => {
48
- const option = screen.getByText('Citation 2');
49
- fireEvent.click(option);
50
- expect(onChange).toHaveBeenCalledWith({ footnote: 'Citation 2' });
51
- });
47
+ expect(onChange).toHaveBeenCalledWith({ footnote: 'test' });
52
48
  });
53
49
 
54
- it('updates the value prop when the search input changes', () => {
50
+ it('displays initial value', () => {
55
51
  const onChange = jest.fn();
56
52
  render(
57
- <SearchWidget
58
- choices={choices}
59
- onChange={onChange}
60
- value="Initial value"
61
- />,
53
+ <SearchWidget choices={choices} onChange={onChange} value="Initial" />,
62
54
  );
63
55
 
64
- const searchInput = screen.getByRole('textbox');
65
- expect(searchInput).toHaveValue('Initial value');
66
-
67
- fireEvent.change(searchInput, { target: { value: 'New value' } });
68
- expect(onChange).toHaveBeenCalledWith({ footnote: 'New value' });
56
+ expect(screen.getByRole('textbox')).toHaveValue('Initial');
69
57
  });
70
58
  });
@@ -3,8 +3,20 @@ import { Node } from 'slate';
3
3
  import { getAllBlocks } from '@plone/volto-slate/utils';
4
4
  import { escapeRegExp } from 'lodash';
5
5
  import { UniversalLink } from '@plone/volto/components';
6
- const urlRegex =
7
- /\b((http|https|ftp):\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(\/[^\s<>)]*)?(?=\s|$|<|>|\))/g;
6
+
7
+ const protocol = '((http|https|ftp):\\/\\/)?';
8
+ const domain = '([a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}';
9
+ const port = '(:\\d+)?';
10
+ const fileExtensions =
11
+ 'pdf|doc|docx|xls|xlsx|png|jpg|jpeg|gif|htm|html|xml|txt|csv|zip|ppt|pptx';
12
+ const pathWithFile = `(\\/[^<>]*\\.(${fileExtensions})(?=[,;.!?\\s)]|$)|\\/[^\\s<>]*)?`;
13
+ const queryString = '(\\?[^\\s<>]*)?';
14
+ const trailingPunctuation = '(?=[\\s,;.!?]|$|\\)[\\s,;.!?]|\\)$)';
15
+
16
+ const urlRegex = new RegExp(
17
+ `\\b${protocol}${domain}${port}${pathWithFile}${queryString}${trailingPunctuation}`,
18
+ 'gi',
19
+ );
8
20
 
9
21
  /**
10
22
  * retrive all slate children of nested objects
@@ -297,10 +309,50 @@ export function isValidHTML(htmlString) {
297
309
  return false;
298
310
  }
299
311
 
312
+ const cleanUrls = (urls, text) => {
313
+ if (!urls) return urls;
314
+
315
+ return urls.map((url) => {
316
+ // Handle URLs ending with punctuation that should not be part of the URL
317
+ // Remove trailing punctuation if it's followed by whitespace or end of string
318
+ const trailingPunctuationMatch = url.match(/^(.+?)([.!?;,]+)$/);
319
+ if (trailingPunctuationMatch) {
320
+ const [, urlPart] = trailingPunctuationMatch;
321
+ const urlIndex = text.indexOf(url);
322
+ const afterUrl = text.substring(urlIndex + url.length);
323
+
324
+ // If punctuation is followed by whitespace or end of string, remove it
325
+ if (afterUrl.match(/^\s/) || afterUrl === '') {
326
+ return urlPart;
327
+ }
328
+ }
329
+
330
+ // Handle URLs that end with unmatched closing parenthesis
331
+ // This happens when a URL is wrapped in parentheses like "(https://example.com)"
332
+ if (url.endsWith(')')) {
333
+ const urlIndex = text.indexOf(url);
334
+ const beforeUrl = text.substring(0, urlIndex);
335
+
336
+ // Check if this closing parenthesis is unmatched (URL is wrapped in parentheses)
337
+ const openParensInUrl = (url.match(/\(/g) || []).length;
338
+ const closeParensInUrl = (url.match(/\)/g) || []).length;
339
+
340
+ // If there's an extra closing parenthesis and the URL is preceded by an opening parenthesis
341
+ if (closeParensInUrl > openParensInUrl && beforeUrl.endsWith('(')) {
342
+ return url.slice(0, -1); // Remove the trailing closing parenthesis
343
+ }
344
+ }
345
+
346
+ return url;
347
+ });
348
+ };
349
+
300
350
  export const renderTextWithLinks = (text, zoteroId) => {
301
351
  if (!text) return null;
302
352
 
303
- const links = text.match(urlRegex);
353
+ const rawLinks = text.match(urlRegex);
354
+ const links = cleanUrls(rawLinks, text);
355
+
304
356
  let isValid = false;
305
357
  if (zoteroId && isValidHTML(text)) isValid = true;
306
358
 
@@ -1,15 +1,52 @@
1
+ import React from 'react';
1
2
  import {
2
3
  openAccordionOrTabIfContainsFootnoteReference,
3
4
  getAllBlocksAndSlateFields,
4
5
  isValidHTML,
5
6
  retriveValuesOfSlateFromNestedPath,
7
+ renderTextWithLinks,
8
+ makeFootnoteListOfUniqueItems,
6
9
  } from './utils';
7
10
  import { getAllBlocks } from '@plone/volto-slate/utils';
11
+ import { UniversalLink } from '@plone/volto/components';
8
12
 
9
13
  jest.mock('@plone/volto-slate/utils', () => ({
10
14
  getAllBlocks: jest.fn(),
11
15
  }));
12
16
 
17
+ jest.mock('@plone/volto/components', () => ({
18
+ UniversalLink: jest.fn(({ href, children }) => <a href={href}>{children}</a>),
19
+ }));
20
+
21
+ jest.mock('@plone/volto/registry', () => ({
22
+ __esModule: true,
23
+ default: {
24
+ settings: {
25
+ footnotes: ['citation', 'footnote'],
26
+ blocksWithFootnotesSupport: {
27
+ slate: ['value'],
28
+ slateTable: ['table'],
29
+ },
30
+ },
31
+ },
32
+ }));
33
+
34
+ // Mock Slate's Node module
35
+ jest.mock('slate', () => ({
36
+ Node: {
37
+ elements: function* (node) {
38
+ // Simple implementation for testing
39
+ if (node && node.children) {
40
+ for (const child of node.children) {
41
+ if (child.type) {
42
+ yield [child, []];
43
+ }
44
+ }
45
+ }
46
+ },
47
+ },
48
+ }));
49
+
13
50
  describe('retriveValuesOfSlateFromNestedPath', () => {
14
51
  test('should return values for a given string path in an object', () => {
15
52
  const obj = { key: ['value1', 'value2'] };
@@ -244,7 +281,7 @@ describe('getAllBlocksAndSlateFields', () => {
244
281
  describe('isValidHTML', () => {
245
282
  beforeAll(() => {
246
283
  global.DOMParser = class {
247
- parseFromString(str, type) {
284
+ parseFromString(str) {
248
285
  const doc = {
249
286
  querySelectorAll: (selector) => {
250
287
  if (selector === 'parsererror' && str.includes('<error>')) {
@@ -266,3 +303,261 @@ describe('isValidHTML', () => {
266
303
  expect(isValidHTML('<error>Invalid HTML</error>')).toBe(false);
267
304
  });
268
305
  });
306
+
307
+ describe('renderTextWithLinks', () => {
308
+ beforeEach(() => {
309
+ jest.clearAllMocks();
310
+ });
311
+
312
+ it('should return null for empty text', () => {
313
+ expect(renderTextWithLinks(null)).toBeNull();
314
+ expect(renderTextWithLinks('')).toBeNull();
315
+ });
316
+
317
+ it('should return plain text when no links are found', () => {
318
+ const text = 'This is plain text without links';
319
+ expect(renderTextWithLinks(text)).toBe(text);
320
+ });
321
+
322
+ it('should detect and render simple HTTP URL', () => {
323
+ const text = 'Visit http://example.com for info';
324
+ const result = renderTextWithLinks(text);
325
+ expect(result).toBeDefined();
326
+ expect(result.type).toBe('div');
327
+ expect(result.props.children).toBeDefined();
328
+ expect(result.props.children.length).toBeGreaterThan(0);
329
+ });
330
+
331
+ it('should detect and render simple HTTPS URL', () => {
332
+ const text = 'Visit https://example.com for info';
333
+ const result = renderTextWithLinks(text);
334
+ expect(result).toBeDefined();
335
+ expect(result.type).toBe('div');
336
+ expect(result.props.children).toBeDefined();
337
+ });
338
+
339
+ it('should handle URL with file extension', () => {
340
+ const text = 'Download https://example.com/file.pdf';
341
+ const result = renderTextWithLinks(text);
342
+ expect(result).toBeDefined();
343
+ expect(result.type).toBe('div');
344
+ });
345
+
346
+ it('should handle URL wrapped in parentheses', () => {
347
+ const text = '(https://example.com/page) for details';
348
+ const result = renderTextWithLinks(text);
349
+ expect(result).toBeDefined();
350
+ expect(result.type).toBe('div');
351
+ });
352
+
353
+ it('should handle multiple URLs', () => {
354
+ const text = 'Visit https://example.com and http://test.org';
355
+ const result = renderTextWithLinks(text);
356
+ expect(result).toBeDefined();
357
+ expect(result.type).toBe('div');
358
+ expect(
359
+ result.props.children.filter((c) => c && c.type === UniversalLink).length,
360
+ ).toBe(2);
361
+ });
362
+
363
+ it('should render HTML when zoteroId is provided', () => {
364
+ global.__CLIENT__ = true;
365
+ global.DOMParser = class {
366
+ parseFromString() {
367
+ return { querySelectorAll: () => [] };
368
+ }
369
+ };
370
+ const text = '<em>Test</em> content';
371
+ const result = renderTextWithLinks(text, 'zotero123');
372
+ expect(result).toBeDefined();
373
+ expect(result.type).toBe('span');
374
+ });
375
+
376
+ it('should handle URL without protocol', () => {
377
+ const text = 'Visit example.com';
378
+ const result = renderTextWithLinks(text);
379
+ expect(result).toBeDefined();
380
+ expect(result.type).toBe('div');
381
+ });
382
+ });
383
+
384
+ describe('makeFootnoteListOfUniqueItems', () => {
385
+ it('should return empty object for empty blocks', () => {
386
+ const result = makeFootnoteListOfUniqueItems([]);
387
+ expect(result).toEqual({});
388
+ });
389
+
390
+ it('should handle blocks without footnote support', () => {
391
+ const blocks = [{ '@type': 'unsupported', value: 'test' }];
392
+ const result = makeFootnoteListOfUniqueItems(blocks);
393
+ expect(result).toEqual({});
394
+ });
395
+
396
+ it('should process slate blocks with citations', () => {
397
+ const blocks = [
398
+ {
399
+ '@type': 'slate',
400
+ value: [
401
+ {
402
+ children: [
403
+ {
404
+ type: 'citation',
405
+ data: {
406
+ zoteroId: 'zot123',
407
+ uid: 'uid1',
408
+ footnote: 'Citation text',
409
+ },
410
+ },
411
+ ],
412
+ },
413
+ ],
414
+ },
415
+ ];
416
+ const result = makeFootnoteListOfUniqueItems(blocks);
417
+ expect(result).toHaveProperty('zot123');
418
+ expect(result.zot123.uid).toBe('uid1');
419
+ });
420
+
421
+ it('should handle multiple references to same zoteroId', () => {
422
+ const blocks = [
423
+ {
424
+ '@type': 'slate',
425
+ value: [
426
+ {
427
+ children: [
428
+ {
429
+ type: 'citation',
430
+ data: {
431
+ zoteroId: 'zot123',
432
+ uid: 'uid1',
433
+ footnote: 'Citation text',
434
+ },
435
+ },
436
+ ],
437
+ },
438
+ ],
439
+ },
440
+ {
441
+ '@type': 'slate',
442
+ value: [
443
+ {
444
+ children: [
445
+ {
446
+ type: 'citation',
447
+ data: {
448
+ zoteroId: 'zot123',
449
+ uid: 'uid2',
450
+ footnote: 'Citation text',
451
+ },
452
+ },
453
+ ],
454
+ },
455
+ ],
456
+ },
457
+ ];
458
+ const result = makeFootnoteListOfUniqueItems(blocks);
459
+ expect(result.zot123.refs).toBeDefined();
460
+ expect(result.zot123.refs).toHaveProperty('uid1');
461
+ expect(result.zot123.refs).toHaveProperty('uid2');
462
+ });
463
+
464
+ it('should handle footnotes with extra citations', () => {
465
+ const blocks = [
466
+ {
467
+ '@type': 'slate',
468
+ value: [
469
+ {
470
+ children: [
471
+ {
472
+ type: 'citation',
473
+ data: {
474
+ zoteroId: 'zot123',
475
+ uid: 'uid1',
476
+ footnote: 'Main citation',
477
+ extra: [
478
+ {
479
+ zoteroId: 'zot456',
480
+ uid: 'uid2',
481
+ footnote: 'Extra citation',
482
+ },
483
+ ],
484
+ },
485
+ },
486
+ ],
487
+ },
488
+ ],
489
+ },
490
+ ];
491
+ const result = makeFootnoteListOfUniqueItems(blocks);
492
+ expect(result).toHaveProperty('zot123');
493
+ expect(result).toHaveProperty('zot456');
494
+ expect(result.zot456.uid).toBe('uid1');
495
+ });
496
+
497
+ it('should handle regular footnotes without zoteroId', () => {
498
+ const blocks = [
499
+ {
500
+ '@type': 'slate',
501
+ value: [
502
+ {
503
+ children: [
504
+ {
505
+ type: 'footnote',
506
+ data: {
507
+ uid: 'uid1',
508
+ footnote: 'Footnote text',
509
+ },
510
+ },
511
+ ],
512
+ },
513
+ ],
514
+ },
515
+ ];
516
+ const result = makeFootnoteListOfUniqueItems(blocks);
517
+ expect(result).toHaveProperty('uid1');
518
+ expect(result.uid1.footnote).toBe('Footnote text');
519
+ });
520
+
521
+ it('should handle identical footnote texts', () => {
522
+ const blocks = [
523
+ {
524
+ '@type': 'slate',
525
+ value: [
526
+ {
527
+ children: [
528
+ {
529
+ type: 'footnote',
530
+ data: {
531
+ uid: 'uid1',
532
+ footnote: 'Same text',
533
+ },
534
+ },
535
+ ],
536
+ },
537
+ ],
538
+ },
539
+ {
540
+ '@type': 'slate',
541
+ value: [
542
+ {
543
+ children: [
544
+ {
545
+ type: 'footnote',
546
+ data: {
547
+ uid: 'uid2',
548
+ footnote: 'Same text',
549
+ },
550
+ },
551
+ ],
552
+ },
553
+ ],
554
+ },
555
+ ];
556
+ const result = makeFootnoteListOfUniqueItems(blocks);
557
+ const keys = Object.keys(result);
558
+ expect(keys.length).toBe(1);
559
+ expect(result[keys[0]].refs).toBeDefined();
560
+ });
561
+ });
562
+
563
+ // Removed test for deprecated openAccordionIfContainsFootnoteReference alias