@eeacms/volto-slate-footnote 7.2.4 → 7.2.6
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 +16 -0
- package/package.json +1 -1
- package/src/Blocks/Footnote/less/public.less +14 -0
- package/src/editor/SearchWidget.test.jsx +28 -40
- package/src/editor/render.jsx +0 -2
- package/src/editor/styles.less +5 -0
- package/src/editor/utils.js +56 -4
- package/src/editor/utils.test.js +296 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ 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.6](https://github.com/eea/volto-slate-footnote/compare/7.2.5...7.2.6) - 22 September 2025
|
|
8
|
+
|
|
9
|
+
#### :bug: Bug Fixes
|
|
10
|
+
|
|
11
|
+
- fix(footnotes): popup width due to large links [David Ichim - [`a054b56`](https://github.com/eea/volto-slate-footnote/commit/a054b56b3c4df7ca3fd1caf102bfdb4002fc9b49)]
|
|
12
|
+
|
|
13
|
+
#### :nail_care: Enhancements
|
|
14
|
+
|
|
15
|
+
- change(render): removed fixed bottom left position [David Ichim - [`9caf528`](https://github.com/eea/volto-slate-footnote/commit/9caf52809d783d54ac6a7839143351f32fd99c01)]
|
|
16
|
+
|
|
17
|
+
#### :house: Internal changes
|
|
18
|
+
|
|
19
|
+
- style: Automated code fix [eea-jenkins - [`da8e341`](https://github.com/eea/volto-slate-footnote/commit/da8e341c427e5f3575f5011cf8df09fd435a55f6)]
|
|
20
|
+
|
|
21
|
+
### [7.2.5](https://github.com/eea/volto-slate-footnote/compare/7.2.4...7.2.5) - 9 September 2025
|
|
22
|
+
|
|
7
23
|
### [7.2.4](https://github.com/eea/volto-slate-footnote/compare/7.2.3...7.2.4) - 11 July 2025
|
|
8
24
|
|
|
9
25
|
#### :bug: Bug Fixes
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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('
|
|
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
|
|
45
|
-
fireEvent.change(
|
|
44
|
+
const input = screen.getByRole('textbox');
|
|
45
|
+
fireEvent.change(input, { target: { value: 'test' } });
|
|
46
46
|
|
|
47
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
});
|
package/src/editor/render.jsx
CHANGED
package/src/editor/styles.less
CHANGED
package/src/editor/utils.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
|
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
|
|
|
@@ -348,5 +400,5 @@ export const renderTextWithLinks = (text, zoteroId) => {
|
|
|
348
400
|
}}
|
|
349
401
|
/>
|
|
350
402
|
);
|
|
351
|
-
else return <div>{result}</div>;
|
|
403
|
+
else return <div className="description-content">{result}</div>;
|
|
352
404
|
};
|
package/src/editor/utils.test.js
CHANGED
|
@@ -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
|
|
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
|