diff --git a/src/Blocks/Footnote/FootnotesBlockView.jsx b/src/Blocks/Footnote/FootnotesBlockView.jsx index dd28841..758bbc6 100644 --- a/src/Blocks/Footnote/FootnotesBlockView.jsx +++ b/src/Blocks/Footnote/FootnotesBlockView.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { escapeRegExp } from 'lodash'; + import { openAccordionOrTabIfContainsFootnoteReference, getAllBlocksAndSlateFields, @@ -8,10 +8,9 @@ import { import './less/public.less'; import { UniversalLink } from '@plone/volto/components'; +import { renderTextWithLinks } from '../../editor/utils'; const alphabet = 'abcdefghijklmnopqrstuvwxyz'; -const urlRegex = - /\b((http|https|ftp):\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(\/[^\s]*)?\b/g; /** * @summary The React component that displays the list of footnotes inserted @@ -78,47 +77,6 @@ const FootnotesBlockView = (props) => { startList = citationIndice; } - const renderTextWithLinks = (text) => { - if (!text) return null; - const links = text.match(urlRegex); - if (!links) { - return ( -
- ); - } - let result = []; - const parts = text.split( - new RegExp(`(${links.map((link) => escapeRegExp(link)).join('|')})`), - ); - parts.forEach((part, index) => { - if (links.includes(part)) { - result.push( - - {part} - , - ); - return; - } - - result.push( - , - ); - }); - - return
{result}
; - }; return (

{title}

@@ -141,7 +99,7 @@ const FootnotesBlockView = (props) => { key={`footnote-${zoteroId || uid}`} id={`footnote-${zoteroId || uid}`} > -
{renderTextWithLinks(footnoteText)}
+
{renderTextWithLinks(footnoteText, zoteroId)}
{refsList ? ( <> {/** some footnotes are never parent so we need the parent to reference */} diff --git a/src/Blocks/Footnote/FootnotesBlockView.test.jsx b/src/Blocks/Footnote/FootnotesBlockView.test.jsx new file mode 100644 index 0000000..7b65609 --- /dev/null +++ b/src/Blocks/Footnote/FootnotesBlockView.test.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import FootnotesBlockView from './FootnotesBlockView'; + +jest.mock('@plone/volto/components', () => ({ + UniversalLink: ({ children, href }) => {children}, +})); + +jest.mock('@eeacms/volto-slate-footnote/editor/utils', () => ({ + openAccordionOrTabIfContainsFootnoteReference: jest.fn(), + renderTextWithLinks: jest.fn(), + getAllBlocksAndSlateFields: jest.fn(() => [ + { id: 'block1', footnote: 'Footnote with no link' }, + { id: 'block2', footnote: 'Footnote with link http://example.com' }, + { id: 'block3', footnote: 'Footnote with HTML' }, + ]), + makeFootnoteListOfUniqueItems: jest.fn((blocks) => ({ + note1: { + uid: '1', + footnote: 'First note with a reference', + zoteroId: 'zotero1', + refs: { ref1: 'ref1' }, + }, + note2: { + uid: '2', + footnote: 'Second note with multiple references', + zoteroId: null, + refs: { ref2: 'ref2', ref3: 'ref3' }, + }, + note3: { + uid: '3', + footnote: 'Note with HTML', + zoteroId: 'zotero3', + refs: {}, + }, + })), +})); + +describe('FootnotesBlockView', () => { + const propsVariations = [ + { + description: 'renders with global metadata', + props: { + data: { title: 'Global Metadata', global: true, placeholder: 'Global' }, + properties: { test: 'metadata' }, + tabData: null, + content: null, + metadata: { test: 'metadata' }, + }, + }, + { + description: 'renders with tabData', + props: { + data: { title: 'Tab Data', global: false, placeholder: 'Tab' }, + properties: { test: 'tabProperties' }, + tabData: { test: 'tabData' }, + content: null, + }, + }, + { + description: 'renders with content', + props: { + data: { title: 'Content Data', global: false, placeholder: 'Content' }, + properties: { test: 'contentProperties' }, + tabData: null, + content: { test: 'contentData' }, + }, + }, + { + description: 'renders with no metadata', + props: { + data: { title: 'No Metadata', global: false, placeholder: 'Default' }, + properties: { test: 'defaultProperties' }, + tabData: null, + content: null, + }, + }, + ]; + + test.each(propsVariations)('$description', ({ props }) => { + render(); + }); +}); diff --git a/src/editor/render.jsx b/src/editor/render.jsx index 6cdd7ff..79b1e33 100644 --- a/src/editor/render.jsx +++ b/src/editor/render.jsx @@ -1,6 +1,7 @@ import React from 'react'; import { Popup, List } from 'semantic-ui-react'; import { useEditorContext } from '@plone/volto-slate/hooks'; + import { getAllBlocksAndSlateFields } from '@eeacms/volto-slate-footnote/editor/utils'; import { makeFootnoteListOfUniqueItems, @@ -8,7 +9,9 @@ import { } from './utils'; import { isEmpty } from 'lodash'; import { useSelector } from 'react-redux'; -import { UniversalLink } from '@plone/volto/components'; + +import { renderTextWithLinks } from './utils'; +import { useHistory } from 'react-router-dom'; /** * Removes '' from footnote @@ -16,14 +19,13 @@ import { UniversalLink } from '@plone/volto/components'; * @returns {string} formatted footnote */ -const urlRegex = /https?:\/\/[^\s]+/g; - export const FootnoteElement = (props) => { const { attributes, children, element, mode, extras } = props; const { data = {} } = element; const { uid, zoteroId } = data; const editor = useEditorContext(); const ref = React.useRef(); + const history = useHistory(); const initialFormData = useSelector((state) => state?.content?.data || {}); const blockProps = editor?.getBlockProps ? editor.getBlockProps() : null; @@ -51,35 +53,6 @@ export const FootnoteElement = (props) => { : // no extra citations (no multiples) `[${Object.keys(notesObjResult).indexOf(zoteroId) + 1}]`; - const renderTextWithLinks = (text) => { - if (!text) return null; - const parts = text.split(urlRegex); - const links = text.match(urlRegex); - let result = []; - - parts.forEach((part, index) => { - result.push( -
, - ); - - if (links && links[index]) { - result.push( - - {links[index]} - , - ); - } - }); - return result; - }; const citationIndice = zoteroId // ZOTERO ? indiceIfZoteroId : // FOOTNOTES @@ -142,16 +115,20 @@ export const FootnoteElement = (props) => { + onClick={(e) => { openAccordionOrTabIfContainsFootnoteReference( `#footnote-${citationRefId}`, - ) - } + ); + if (e.target.tagName !== 'A') { + e.preventDefault(); + history.push(`#footnote-${citationRefId}`); + } + }} key={`#footnote-${citationRefId}`} > - {renderTextWithLinks(footnoteText)} + {renderTextWithLinks(footnoteText, zoteroId)} @@ -164,16 +141,22 @@ export const FootnoteElement = (props) => { return ( + onClick={(e) => { openAccordionOrTabIfContainsFootnoteReference( `#footnote-${item.zoteroId || item.uid}`, - ) - } + ); + if (e.target.tagName !== 'A') { + e.preventDefault(); + history.push( + `#footnote-${item.zoteroId || item.uid}`, + ); + } + }} key={`#footnote-${item.zoteroId || item.uid}`} > - {renderTextWithLinks(footnoteText)} + {renderTextWithLinks(footnoteText, item.zoteroId)} @@ -202,11 +185,15 @@ export const FootnoteElement = (props) => { + onClick={(e) => { openAccordionOrTabIfContainsFootnoteReference( `#footnote-${citationRefId}`, - ) - } + ); + if (e.target.tagName !== 'A') { + e.preventDefault(); + history.push(`#footnote-${citationRefId}`); + } + }} key={`#footnote-${citationRefId}`} > @@ -219,11 +206,15 @@ export const FootnoteElement = (props) => { data.extra.map((item) => ( + onClick={(e) => { openAccordionOrTabIfContainsFootnoteReference( `#footnote-${item.zoteroId || item.uid}`, - ) - } + ); + if (e.target.tagName !== 'A') { + e.preventDefault(); + history.push(`#footnote-${citationRefId}`); + } + }} key={`#footnote-${item.zoteroId || item.uid}`} > diff --git a/src/editor/utils.js b/src/editor/utils.js index 5a6dedc..f938333 100644 --- a/src/editor/utils.js +++ b/src/editor/utils.js @@ -1,6 +1,11 @@ import config from '@plone/volto/registry'; import { Node } from 'slate'; import { getAllBlocks } from '@plone/volto-slate/utils'; +import { escapeRegExp } from 'lodash'; +import { UniversalLink } from '@plone/volto/components'; +const urlRegex = + /\b((http|https|ftp):\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(\/[^\s<>)]*)?(?=\s|$|<|>|\))/g; + /** * retrive all slate children of nested objects * @param {object} path - the keys that we want to extract the slate children from @@ -9,7 +14,7 @@ import { getAllBlocks } from '@plone/volto-slate/utils'; * path:{items:'value'} * @returns string */ -const retriveValuesOfSlateFromNestedPath = (path, value) => { +export const retriveValuesOfSlateFromNestedPath = (path, value) => { if (Array.isArray(value)) { let allSlateValue = []; value.forEach((element) => { @@ -276,3 +281,72 @@ const iterateFootnoteObj = (notesObjResultTemp, node, parentUid) => { }; } }; + +export function isValidHTML(htmlString) { + if ( + __CLIENT__ && + typeof window !== 'undefined' && + typeof DOMParser !== 'undefined' + ) { + // The environment is client-side, and DOMParser is available + const parser = new DOMParser(); + const parsedDocument = parser.parseFromString(htmlString, 'text/html'); + const errors = parsedDocument.querySelectorAll('parsererror'); + return errors.length === 0; + } + return false; +} + +export const renderTextWithLinks = (text, zoteroId) => { + if (!text) return null; + + const links = text.match(urlRegex); + let isValid = false; + if (zoteroId && isValidHTML(text)) isValid = true; + + if (!links) { + if (isValid) + return ( + + ); + else return text; + } + let result = []; + const parts = text.split( + new RegExp(`(${links.map((link) => escapeRegExp(link)).join('|')})`), + ); + parts.forEach((part, index) => { + if (links.includes(part) && zoteroId) { + result.push(` + + ${part} + `); + return; + } else if (links.includes(part)) { + result.push( + + {part} + , + ); + return; + } else result.push(part); + }); + + if (isValid) + return ( + acc + c, ''), + }} + /> + ); + else return
{result}
; +}; diff --git a/src/editor/utils.test.js b/src/editor/utils.test.js index 02970d0..7ccf6b5 100644 --- a/src/editor/utils.test.js +++ b/src/editor/utils.test.js @@ -1,6 +1,8 @@ import { openAccordionOrTabIfContainsFootnoteReference, getAllBlocksAndSlateFields, + isValidHTML, + retriveValuesOfSlateFromNestedPath, } from './utils'; import { getAllBlocks } from '@plone/volto-slate/utils'; @@ -8,6 +10,47 @@ jest.mock('@plone/volto-slate/utils', () => ({ getAllBlocks: jest.fn(), })); +describe('retriveValuesOfSlateFromNestedPath', () => { + test('should return values for a given string path in an object', () => { + const obj = { key: ['value1', 'value2'] }; + expect(retriveValuesOfSlateFromNestedPath('key', obj)).toEqual([ + 'value1', + 'value2', + ]); + }); + + test('should return an empty array when the path is not found', () => { + const obj = { key: [] }; + expect(retriveValuesOfSlateFromNestedPath('key', obj)).toEqual([]); + }); + + test('should return values from an array of objects', () => { + const objArray = [{ key: ['value1'] }, { key: ['value2'] }]; + expect(retriveValuesOfSlateFromNestedPath('key', objArray)).toEqual([ + 'value1', + 'value2', + ]); + }); + + test('should handle nested object paths', () => { + const obj = { level1: { level2: ['value'] } }; + expect( + retriveValuesOfSlateFromNestedPath({ level1: 'level2' }, obj), + ).toEqual(['value']); + }); + + test('should return an empty array for invalid inputs', () => { + expect(retriveValuesOfSlateFromNestedPath('key', null)).toEqual([]); + expect(retriveValuesOfSlateFromNestedPath('key', undefined)).toEqual([]); + expect(retriveValuesOfSlateFromNestedPath({}, {})).toEqual([]); + }); + + test('should return empty array if given an empty path object', () => { + expect(retriveValuesOfSlateFromNestedPath({}, { key: 'value' })).toEqual( + [], + ); + }); +}); describe('openAccordionOrTabIfContainsFootnoteReference', () => { it('should open accordion if it contains footnote reference', () => { document.body.innerHTML = ` @@ -197,3 +240,29 @@ describe('getAllBlocksAndSlateFields', () => { expect(result).toEqual(expected); }); }); + +describe('isValidHTML', () => { + beforeAll(() => { + global.DOMParser = class { + parseFromString(str, type) { + const doc = { + querySelectorAll: (selector) => { + if (selector === 'parsererror' && str.includes('')) { + return [{}]; // Simulate an error + } + return []; + }, + }; + return doc; + } + }; + }); + + test('returns true for valid HTML', () => { + expect(isValidHTML('
Hello
')).toBe(true); + }); + + test('returns false for invalid HTML', () => { + expect(isValidHTML('Invalid HTML')).toBe(false); + }); +});