diff --git a/CHANGELOG.md b/CHANGELOG.md index 8825bfed..88c375fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,17 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). -### [2.3.0](https://github.com/eea/volto-eea-website-theme/compare/2.2.2...2.3.0) - 9 September 2024 +### [2.4.0](https://github.com/eea/volto-eea-website-theme/compare/2.3.0...2.4.0) - 11 October 2024 + +#### :bug: Bug Fixes + +- fix: add subsite_css class when not undefined [nileshgulia1 - [`db3f80f`](https://github.com/eea/volto-eea-website-theme/commit/db3f80f9dac34f528030d1d2a2f858555059879e)] + +#### :hammer_and_wrench: Others + +- Increase test coverage [dobri1408 - [`e2d46a9`](https://github.com/eea/volto-eea-website-theme/commit/e2d46a981c6f50980f0b0bf1f35b2d03121f3c88)] +- Update package.json [Ichim David - [`24ea8f0`](https://github.com/eea/volto-eea-website-theme/commit/24ea8f0ef7c474bcf171f4720465e12a0d600b46)] +### [2.3.0](https://github.com/eea/volto-eea-website-theme/compare/2.2.2...2.3.0) - 13 September 2024 #### :house: Internal changes diff --git a/cypress/e2e/01-block-basics.cy.js b/cypress/e2e/01-block-basics.cy.js index 6cf3240f..4a9ce0a7 100644 --- a/cypress/e2e/01-block-basics.cy.js +++ b/cypress/e2e/01-block-basics.cy.js @@ -25,7 +25,9 @@ describe('Blocks Tests', () => { cy.get('.ui.basic.icon.button.block-add-button').first().click(); cy.get('.blocks-chooser .title').contains('Media').click(); cy.get('.content.active.media .button.image').contains('Image').click(); - cy.get('.block.image .ui.input input[type="text"]').type("https://eea.github.io/volto-eea-design-system/img/eea_icon.png{enter}"); + cy.get('.block.image .ui.input input[type="text"]').type( + 'https://eea.github.io/volto-eea-design-system/img/eea_icon.png{enter}', + ); cy.get('.align-buttons .ui.basic.icon.button').first().click(); cy.get('#blockform-fieldset-styling').click(); @@ -36,11 +38,73 @@ describe('Blocks Tests', () => { cy.url().should('eq', Cypress.config().baseUrl + '/cypress/my-page'); // check banner rss link - cy.get('.button.rssfeed').should('have.attr', 'href', '/cypress/my-page/rss'); + cy.get('.button.rssfeed').should( + 'have.attr', + 'href', + '/cypress/my-page/rss', + ); cy.get('.button.rssfeed').contains('RSS'); // then the page view should contain our changes cy.contains('My Add-on Page'); - cy.get('.block.image.align.left img.top').should('have.attr', 'src', 'https://eea.github.io/volto-eea-design-system/img/eea_icon.png'); + cy.get('.block.image.align.left img.top').should( + 'have.attr', + 'src', + 'https://eea.github.io/volto-eea-design-system/img/eea_icon.png', + ); + }); + + //Fails because we don't add navigation block by default + + it('Add Navigation Block', () => { + // Change page title + cy.clearSlateTitle(); + cy.getSlateTitle().type('My Add-on Page'); + + cy.get('.documentFirstHeading').contains('My Add-on Page'); + + cy.getSlate().click(); + + // Add Navigation block + cy.get('.ui.basic.icon.button.block-add-button').first().click(); + cy.get('.blocks-chooser input').type('Navigation'); + cy.get('.blocks-chooser .contextNavigation').click(); + cy.get('#field-name').type('Nav title'); + cy.get( + '.field-wrapper-includeTop > .grid > :nth-child(1) > .twelve > .wrapper > .ui > label', + ).click(); + // cy.get( + // '.field-wrapper-currentFolderOnly > .grid > :nth-child(1) > .twelve > .wrapper > .ui > label', + // ).click(); + cy.get( + '.field-wrapper-no_icons > .grid > :nth-child(1) > .twelve > .wrapper > .ui > label', + ).click(); + cy.get( + '.field-wrapper-no_thumbs > .grid > :nth-child(1) > .twelve > .wrapper > .ui > label', + ).click(); + + // Save + cy.get('#toolbar-save').click(); + cy.url().should('eq', Cypress.config().baseUrl + '/cypress/my-page'); + + // // then the page view should contain our changes + cy.get('.context-navigation-header').contains('Nav title'); + + // Edit to select Accordion variation + cy.get('.toolbar-actions .edit').click(); + cy.get('.block-editor-contextNavigation').click(); + cy.get( + '#sidebar-properties .field-wrapper-variation .react-select__value-container', + ).click(); + cy.get('.field-wrapper-variation .react-select__option') + .contains('Accordion') + .click(); + + cy.get('#toolbar-save').click(); + cy.url().should('eq', Cypress.config().baseUrl + '/cypress/my-page'); + + // then the page view should contain our changes + cy.get('.accordion-header').contains('Nav title'); + cy.get('.accordion-header').click(); }); }); diff --git a/package.json b/package.json index 7fb66570..5ebd1220 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eeacms/volto-eea-website-theme", - "version": "2.3.0", + "version": "2.4.0", "description": "@eeacms/volto-eea-website-theme: Volto add-on", "main": "src/index.js", "author": "European Environment Agency: IDM2 A-Team", @@ -14,6 +14,7 @@ "react" ], "addons": [ + "@eeacms/volto-block-toc", "@eeacms/volto-group-block", "@eeacms/volto-eea-design-system", "volto-subsites" @@ -23,6 +24,7 @@ "url": "git@github.com:eea/volto-eea-website-theme.git" }, "dependencies": { + "@eeacms/volto-block-toc": "*", "@eeacms/volto-block-style": "*", "@eeacms/volto-eea-design-system": "*", "@eeacms/volto-group-block": "*", diff --git a/src/components/manage/Blocks/ContextNavigation/ContextNavigationEdit.jsx b/src/components/manage/Blocks/ContextNavigation/ContextNavigationEdit.jsx new file mode 100644 index 00000000..95795d95 --- /dev/null +++ b/src/components/manage/Blocks/ContextNavigation/ContextNavigationEdit.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { EditSchema } from './schema'; +import { SidebarPortal } from '@plone/volto/components'; +import BlockDataForm from '@plone/volto/components/manage/Form/BlockDataForm'; + +import ContextNavigationView from './ContextNavigationView'; + +const ContextNavigationFillEdit = (props) => { + const contentTypes = props.properties?.['@components']?.types; + const availableTypes = React.useMemo( + () => contentTypes?.map((type) => [type.id, type.title || type.name]), + [contentTypes], + ); + + const schema = React.useMemo( + () => EditSchema({ availableTypes }), + [availableTypes], + ); + + return ( + <> +

Context navigation

+ {' '} + + { + props.onChangeBlock(props.block, { + ...props.data, + [id]: value, + }); + }} + onChangeBlock={props.onChangeBlock} + formData={props.data} + block={props.block} + navRoot={props.navRoot} + contentType={props.contentType} + /> + + + ); +}; + +export default ContextNavigationFillEdit; diff --git a/src/components/manage/Blocks/ContextNavigation/ContextNavigationEdit.test.jsx b/src/components/manage/Blocks/ContextNavigation/ContextNavigationEdit.test.jsx new file mode 100644 index 00000000..b184f87e --- /dev/null +++ b/src/components/manage/Blocks/ContextNavigation/ContextNavigationEdit.test.jsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import ContextNavigationEdit from './ContextNavigationEdit'; +import { Router } from 'react-router-dom'; +import { Provider } from 'react-intl-redux'; +import configureStore from 'redux-mock-store'; +import { createMemoryHistory } from 'history'; +import '@testing-library/jest-dom/extend-expect'; + +jest.mock('@plone/volto/components', () => ({ + InlineForm: ({ onChangeField }) => ( +
+

InlineForm

+ +
+ ), + SidebarPortal: ({ children, selected }) => + selected ? ( +
+
SidebarPortal
+ {children} +
+ ) : null, +})); + +jest.mock('@plone/volto/components/theme/Navigation/ContextNavigation', () => { + return { + __esModule: true, + default: ({ params }) => { + return
ConnectedContextNavigation {params.root_path}
; + }, + }; +}); + +jest.mock('@plone/volto/helpers', () => ({ + withBlockExtensions: jest.fn((Component) => Component), + emptyBlocksForm: jest.fn(), + getBlocksLayoutFieldname: () => 'blocks_layout', + withVariationSchemaEnhancer: jest.fn((Component) => Component), +})); + +const mockStore = configureStore(); +const store = mockStore({ + intl: { + locale: 'en', + messages: {}, + }, +}); + +describe('ContextNavigationEdit', () => { + it('renders corectly', () => { + const history = createMemoryHistory(); + const { getByText, queryByText } = render( + + + + + , + , + ); + + expect(getByText('Context navigation')).toBeInTheDocument(); + expect(getByText('ConnectedContextNavigation')).toBeInTheDocument(); + expect(queryByText('InlineForm')).toBeNull(); + expect(queryByText('SidebarPortal')).toBeNull(); + }); + + it('renders corectly', () => { + const history = createMemoryHistory(); + const { container, getByText } = render( + + + {}} /> + + , + , + ); + + expect(getByText('Context navigation')).toBeInTheDocument(); + expect(getByText('ConnectedContextNavigation')).toBeInTheDocument(); + expect(getByText('InlineForm')).toBeInTheDocument(); + expect(getByText('SidebarPortal')).toBeInTheDocument(); + + fireEvent.change(container.querySelector('#test'), { + target: { value: 'test' }, + }); + }); +}); diff --git a/src/components/manage/Blocks/ContextNavigation/ContextNavigationView.jsx b/src/components/manage/Blocks/ContextNavigation/ContextNavigationView.jsx new file mode 100644 index 00000000..ebaea7ca --- /dev/null +++ b/src/components/manage/Blocks/ContextNavigation/ContextNavigationView.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { flattenToAppURL, withBlockExtensions } from '@plone/volto/helpers'; +import DefaultTemplate from './variations/Default'; + +const ContextNavigationView = (props = {}) => { + const { variation, data = {} } = props; + const navProps = { ...data }; + const root_path = data?.root_node?.[0]?.['@id']; + if (root_path) navProps['root_path'] = flattenToAppURL(root_path); + const Renderer = variation?.view ?? DefaultTemplate; + return ; +}; + +export default withBlockExtensions(ContextNavigationView); diff --git a/src/components/manage/Blocks/ContextNavigation/ContextNavigationView.test.jsx b/src/components/manage/Blocks/ContextNavigation/ContextNavigationView.test.jsx new file mode 100644 index 00000000..ed0e8b80 --- /dev/null +++ b/src/components/manage/Blocks/ContextNavigation/ContextNavigationView.test.jsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import ContextNavigationView from './ContextNavigationView'; +import { Router } from 'react-router-dom'; +import { Provider } from 'react-intl-redux'; +import configureStore from 'redux-mock-store'; +import { createMemoryHistory } from 'history'; +import '@testing-library/jest-dom/extend-expect'; + +jest.mock('@plone/volto/components/theme/Navigation/ContextNavigation', () => { + return { + __esModule: true, + default: ({ params }) => { + return
ConnectedContextNavigation {params.root_path}
; + }, + }; +}); + +jest.mock('@plone/volto/helpers', () => ({ + withBlockExtensions: jest.fn((Component) => Component), + emptyBlocksForm: jest.fn(), + getBlocksLayoutFieldname: () => 'blocks_layout', + flattenToAppURL: () => '', +})); + +const mockStore = configureStore(); +const store = mockStore({ + intl: { + locale: 'en', + messages: {}, + }, +}); + +describe('ContextNavigationView', () => { + let history; + beforeEach(() => { + history = createMemoryHistory(); + }); + + it('renders corectly', () => { + const { container } = render( + + + + + , + ); + + expect(container.firstChild).toHaveTextContent( + 'ConnectedContextNavigation', + ); + }); + + it('renders corectly', () => { + const { container } = render( + + + + + , + ); + expect(container.firstChild).toHaveTextContent( + 'ConnectedContextNavigation', + ); + }); +}); diff --git a/src/components/manage/Blocks/ContextNavigation/index.js b/src/components/manage/Blocks/ContextNavigation/index.js new file mode 100644 index 00000000..b2634603 --- /dev/null +++ b/src/components/manage/Blocks/ContextNavigation/index.js @@ -0,0 +1,30 @@ +import codeSVG from '@plone/volto/icons/code.svg'; +import ContextNavigationEdit from './ContextNavigationEdit'; +import ContextNavigationView from './ContextNavigationView'; +import BlockSettingsSchema from '@plone/volto/components/manage/Blocks/Block/Schema'; +import variations from './variations'; + +const applyConfig = (config) => { + config.blocks.blocksConfig.contextNavigation = { + id: 'contextNavigation', + title: 'Navigation', + icon: codeSVG, + group: 'common', + view: ContextNavigationView, + edit: ContextNavigationEdit, + schema: BlockSettingsSchema, + restricted: false, + variations, + mostUsed: false, + blockHasOwnFocusManagement: true, + sidebarTab: 1, + security: { + addPermission: [], + view: [], + }, + }; + + return config; +}; + +export default applyConfig; diff --git a/src/components/manage/Blocks/ContextNavigation/schema.js b/src/components/manage/Blocks/ContextNavigation/schema.js new file mode 100644 index 00000000..6e67b930 --- /dev/null +++ b/src/components/manage/Blocks/ContextNavigation/schema.js @@ -0,0 +1,88 @@ +export const EditSchema = ({ availableTypes }) => { + return { + title: 'Navigation', + fieldsets: [ + { + id: 'default', + title: 'Default', + fields: [ + 'name', + 'root_node', + 'portal_type', + 'includeTop', + 'currentFolderOnly', + 'topLevel', + 'bottomLevel', + 'no_icons', + 'thumb_scale', + 'no_thumbs', + ], + }, + ], + required: [], + properties: { + name: { + title: 'Title', + description: 'The title of the navigation tree', + }, + root_node: { + title: 'Root node', + description: + 'You may search for and choose a folder to act as the root of the navigation tree. Leave blank to use the Plone site root.', + widget: 'object_browser', + // TODO: these don't work. Why? + mode: 'link', + selectedItemAttrs: ['Title', 'Description'], + }, + portal_type: { + title: 'Filter children', + description: 'Only show child items of this content type', + choices: availableTypes, + isMulti: true, + }, + includeTop: { + title: 'Include top node', + description: + "Whether or not to show the top, or 'root', node in the navigation tree. This is affected by the 'Start level' setting.", + type: 'boolean', + }, + currentFolderOnly: { + title: 'Only show the contents of the current folder', + description: + 'If selected, the navigation tree will only show the current folder and its children at all times.', + type: 'boolean', + }, + + topLevel: { + title: 'Start level', + description: + 'An integer value that specifies the number of folder levels below the site root that must be exceeded before the navigation tree will display. 0 means that the navigation tree should be displayed everywhere including pages in the root of the site. 1 means the tree only shows up inside folders located in the root and downwards, never showing at the top level.', + type: 'number', + default: 1, + }, + bottomLevel: { + title: 'Navigation tree depth', + description: + 'How many folders should be included before the navigation tree stops. 0 means no limit. 1 only includes the root folder.', + type: 'number', + default: 0, + }, + no_icons: { + title: 'Suppress icons', + description: + 'If enabled, the portlet will not show document type icons.', + type: 'boolean', + }, + thumb_scale: { + title: 'Override thumb scale', + description: + "Enter a valid scale name (see 'Image Handling' control panel) to override (e.g. icon, tile, thumb, mini, preview, ... ). Leave empty to use default (see 'Site' control panel).", + }, + no_thumbs: { + title: 'Suppress thumbs', + type: 'boolean', + description: 'If enabled, the portlet will not show thumbs.', + }, + }, + }; +}; diff --git a/src/components/manage/Blocks/ContextNavigation/variations/Accordion.jsx b/src/components/manage/Blocks/ContextNavigation/variations/Accordion.jsx new file mode 100644 index 00000000..82eaeac0 --- /dev/null +++ b/src/components/manage/Blocks/ContextNavigation/variations/Accordion.jsx @@ -0,0 +1,179 @@ +import cx from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { withRouter } from 'react-router'; +import { compose } from 'redux'; +import { Accordion } from 'semantic-ui-react'; + +import Slugger from 'github-slugger'; + +import { Icon, UniversalLink } from '@plone/volto/components'; +import { withContentNavigation } from '@plone/volto/components/theme/Navigation/withContentNavigation'; +import withEEASideMenu from '@eeacms/volto-block-toc/hocs/withEEASideMenu'; +import { flattenToAppURL } from '@plone/volto/helpers'; + +import downIcon from '@plone/volto/icons/down-key.svg'; +import upIcon from '@plone/volto/icons/up-key.svg'; + +const messages = defineMessages({ + navigation: { + id: 'Navigation', + defaultMessage: 'Navigation', + }, +}); + +const AccordionNavigation = ({ + navigation = {}, + device, + isMenuOpenOnOutsideClick, +}) => { + const { items = [], title, has_custom_name } = navigation; + const intl = useIntl(); + const navOpen = ['mobile', 'tablet'].includes(device) ? false : true; + const [isNavOpen, setIsNavOpen] = React.useState(navOpen); + const [activeItems, setActiveItems] = React.useState({}); + + const onClickSummary = React.useCallback((e) => { + e.preventDefault(); + setIsNavOpen((prev) => !prev); + }, []); + + React.useEffect(() => { + if (isMenuOpenOnOutsideClick === false) setIsNavOpen(false); + }, [isMenuOpenOnOutsideClick]); + + const onKeyDownSummary = React.useCallback( + (e) => { + if (e.keyCode === 13 || e.keyCode === 32) { + e.preventDefault(); + onClickSummary(e); + } + }, + [onClickSummary], + ); + + const renderItems = ({ item, level = 0 }) => { + const { + title, + href, + is_current, + is_in_path, + items: childItems, + type, + } = item; + const hasChildItems = childItems && childItems.length > 0; + const normalizedTitle = Slugger.slug(title); + + const checkIfActive = () => { + return activeItems[href] !== undefined ? activeItems[href] : is_in_path; + }; + + const isActive = checkIfActive(); + + const handleTitleClick = () => { + setActiveItems((prev) => ({ ...prev, [href]: !isActive })); + }; + + return ( +
  • + {hasChildItems ? ( + + + {title} + + + +
      + {childItems.map((child) => + renderItems({ item: child, level: level + 1 }), + )} +
    +
    +
    + ) : ( + + {title} + + )} +
  • + ); + }; + + return items.length ? ( + <> + + + ) : null; +}; + +AccordionNavigation.propTypes = { + /** + * Navigation tree returned from @contextnavigation restapi endpoint + */ + navigation: PropTypes.shape({ + items: PropTypes.arrayOf( + PropTypes.shape({ + title: PropTypes.string, + url: PropTypes.string, + href: PropTypes.string, + is_current: PropTypes.bool, + is_in_path: PropTypes.bool, + items: PropTypes.array, + type: PropTypes.string, + }), + ), + has_custom_name: PropTypes.bool, + title: PropTypes.string, + }), +}; + +export default compose( + withRouter, + withContentNavigation, + (WrappedComponent) => (props) => + withEEASideMenu(WrappedComponent)({ + ...props, + shouldRender: props.navigation?.items?.length > 0, + }), +)(AccordionNavigation); diff --git a/src/components/manage/Blocks/ContextNavigation/variations/Default.jsx b/src/components/manage/Blocks/ContextNavigation/variations/Default.jsx new file mode 100644 index 00000000..c5420bf6 --- /dev/null +++ b/src/components/manage/Blocks/ContextNavigation/variations/Default.jsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ConnectedContextNavigation from '@plone/volto/components/theme/Navigation/ContextNavigation'; + +const Default = (props) => { + const { params } = props; + return ; +}; + +export default Default; diff --git a/src/components/manage/Blocks/ContextNavigation/variations/index.js b/src/components/manage/Blocks/ContextNavigation/variations/index.js new file mode 100644 index 00000000..f85f69f6 --- /dev/null +++ b/src/components/manage/Blocks/ContextNavigation/variations/index.js @@ -0,0 +1,18 @@ +import Accordion from './Accordion'; +import Default from './Default'; + +const contextBlockVariations = [ + { + id: 'default', + title: 'Listing (default)', + view: Default, + isDefault: true, + }, + { + id: 'accordion', + title: 'Accordion', + view: Accordion, + }, +]; + +export default contextBlockVariations; diff --git a/src/components/manage/Blocks/Title/Edit.jsx b/src/components/manage/Blocks/Title/Edit.jsx index ba31ca2f..1c49fc1f 100644 --- a/src/components/manage/Blocks/Title/Edit.jsx +++ b/src/components/manage/Blocks/Title/Edit.jsx @@ -11,8 +11,8 @@ import { ReactEditor, Editable, Slate, withReact } from 'slate-react'; import config from '@plone/volto/registry'; import { SidebarPortal } from '@plone/volto/components'; import { BodyClass } from '@plone/volto/helpers'; -import InlineForm from '@plone/volto/components/manage/Form/InlineForm'; -import BannerView from '@eeacms/volto-eea-website-theme/components/theme/Banner/View'; +import View from '@eeacms/volto-eea-website-theme/components/manage/Blocks/Title/View'; +import BlockDataForm from '@plone/volto/components/manage/Form/BlockDataForm'; import schema from './schema'; const messages = defineMessages({ @@ -165,8 +165,9 @@ export const TitleBlockEdit = (props) => { return ( - { fluid /> - { @@ -194,7 +195,9 @@ export const TitleBlockEdit = (props) => { [id]: value, }); }} + onChangeBlock={props.onChangeBlock} formData={props.data} + block={props.block} /> diff --git a/src/components/manage/Blocks/Title/View.jsx b/src/components/manage/Blocks/Title/View.jsx index ca89833f..89a10706 100644 --- a/src/components/manage/Blocks/Title/View.jsx +++ b/src/components/manage/Blocks/Title/View.jsx @@ -4,36 +4,26 @@ */ import React from 'react'; -import { Portal } from 'react-portal'; -import PropTypes from 'prop-types'; -import { BodyClass } from '@plone/volto/helpers'; - -import BannerView from '@eeacms/volto-eea-website-theme/components/theme/Banner/View'; -function IsomorphicPortal({ children }) { - const [isClient, setIsClient] = React.useState(); - React.useEffect(() => setIsClient(true), []); - - return isClient ? ( - {children} - ) : ( - children - ); -} +import PropTypes from 'prop-types'; +import { withBlockExtensions, BodyClass } from '@plone/volto/helpers'; +import DefaultTemplate from './variations/Default'; /** * View title block class. * @class View * @extends Component */ -const View = (props) => ( - - - - - - -); +const View = (props = {}) => { + const { variation } = props; + const Renderer = variation?.view ?? DefaultTemplate; + return ( + <> + + + + ); +}; /** * Property types. @@ -44,4 +34,4 @@ View.propTypes = { properties: PropTypes.objectOf(PropTypes.any).isRequired, }; -export default View; +export default withBlockExtensions(View); diff --git a/src/components/manage/Blocks/Title/index.js b/src/components/manage/Blocks/Title/index.js index 614172d6..5af73d27 100644 --- a/src/components/manage/Blocks/Title/index.js +++ b/src/components/manage/Blocks/Title/index.js @@ -1,11 +1,63 @@ import Edit from './Edit'; import View from './View'; +import DefaultTemplate from './variations/Default'; +import WebReport from './variations/WebReport'; +import WebReportPage from './variations/WebReportPage'; +import './variations/styles.less'; const applyConfig = (config) => { config.blocks.blocksConfig.title = { ...config.blocks.blocksConfig.title, edit: Edit, view: View, + variations: [ + { + id: 'default', + title: 'Default', + view: DefaultTemplate, + isDefault: true, + }, + { + id: 'web_report', + title: 'Web Report', + view: WebReport, + schemaEnhancer: ({ schema }) => { + const fields = schema.fieldsets[0].fields; + schema.fieldsets[0].fields = [ + ...fields, + 'content_type', + 'hero_header', + ]; + + schema.properties.content_type = { + title: 'Content type name', + description: + 'Add a custom content-type name, leave empty for default', + }; + schema.properties.hero_header = { + title: 'Hero header size', + type: 'boolean', + }; + return schema; + }, + }, + { + id: 'web_report_page', + title: 'Web Report Page', + view: WebReportPage, + schemaEnhancer: ({ schema }) => { + const fields = schema.fieldsets[0].fields; + schema.fieldsets[0].fields = [...fields, 'content_type']; + + schema.properties.content_type = { + title: 'Content type name', + description: + 'Add a custom content-type name, leave empty for default', + }; + return schema; + }, + }, + ], copyrightPrefix: 'Image', sidebarTab: 1, }; diff --git a/src/components/manage/Blocks/Title/variations/Default.jsx b/src/components/manage/Blocks/Title/variations/Default.jsx new file mode 100644 index 00000000..038201ee --- /dev/null +++ b/src/components/manage/Blocks/Title/variations/Default.jsx @@ -0,0 +1,43 @@ +/** + * View title block. + * @module components/manage/Blocks/Title/View + */ + +import React from 'react'; +import { Portal } from 'react-portal'; +import PropTypes from 'prop-types'; + +import BannerView from '@eeacms/volto-eea-website-theme/components/theme/Banner/View'; + +function IsomorphicPortal({ children }) { + const [isClient, setIsClient] = React.useState(); + React.useEffect(() => setIsClient(true), []); + + return isClient ? ( + {children} + ) : ( + children + ); +} + +const DefaultTemplate = (props) => + props.isEditMode ? ( + + ) : ( + + + + + + ); + +/** + * Property types. + * @property {Object} propTypes Property types. + * @static + */ +DefaultTemplate.propTypes = { + properties: PropTypes.objectOf(PropTypes.any).isRequired, +}; + +export default DefaultTemplate; diff --git a/src/components/manage/Blocks/Title/variations/WebReport.jsx b/src/components/manage/Blocks/Title/variations/WebReport.jsx new file mode 100644 index 00000000..fc408f22 --- /dev/null +++ b/src/components/manage/Blocks/Title/variations/WebReport.jsx @@ -0,0 +1,69 @@ +/** + * Web Report title block variation. + * @module components/manage/Blocks/Title/variations/WebReport + */ + +import React from 'react'; +import { Portal } from 'react-portal'; +import PropTypes from 'prop-types'; + +import { MaybeWrap } from '@plone/volto/components'; +import BannerView from '@eeacms/volto-eea-website-theme/components/theme/Banner/View'; +import Banner from '@eeacms/volto-eea-design-system/ui/Banner/Banner'; +import clsx from 'clsx'; + +import { BodyClass } from '@plone/volto/helpers'; + +function IsomorphicPortal({ children }) { + const [isClient, setIsClient] = React.useState(); + React.useEffect(() => setIsClient(true), []); + + return isClient ? ( + {children} + ) : ( + children + ); +} + +const WebReport = (props) => { + return ( + + + + {props.data.content_type || props.properties.type_title} + + ), + belowTitle: ( + <> + + {props.data.subtitle} + + + ), + }} + /> + + ); +}; + +/** + * Property types. + * @property {Object} propTypes Property types. + * @static + */ +WebReport.propTypes = { + properties: PropTypes.objectOf(PropTypes.any).isRequired, +}; + +export default WebReport; diff --git a/src/components/manage/Blocks/Title/variations/WebReportPage.jsx b/src/components/manage/Blocks/Title/variations/WebReportPage.jsx new file mode 100644 index 00000000..da539e78 --- /dev/null +++ b/src/components/manage/Blocks/Title/variations/WebReportPage.jsx @@ -0,0 +1,59 @@ +/** + * Web Report Page title block variation. + * @module components/manage/Title/variations/WebReport + */ + +import React from 'react'; +import { Portal } from 'react-portal'; +import PropTypes from 'prop-types'; + +import { MaybeWrap } from '@plone/volto/components'; +import BannerView from '@eeacms/volto-eea-website-theme/components/theme/Banner/View'; +import clsx from 'clsx'; + +import { BodyClass } from '@plone/volto/helpers'; + +function IsomorphicPortal({ children }) { + const [isClient, setIsClient] = React.useState(); + React.useEffect(() => setIsClient(true), []); + + return isClient ? ( + {children} + ) : ( + children + ); +} + +const WebReportPage = (props) => { + return ( + + + +
    + {props.data.content_type || props.properties.type_title} +
    +
    {props.data.subtitle}
    + + ), + }} + /> +
    + ); +}; + +/** + * Property types. + * @property {Object} propTypes Property types. + * @static + */ +WebReportPage.propTypes = { + properties: PropTypes.objectOf(PropTypes.any).isRequired, +}; + +export default WebReportPage; diff --git a/src/components/manage/Blocks/Title/variations/styles.less b/src/components/manage/Blocks/Title/variations/styles.less new file mode 100644 index 00000000..7fd9ad1f --- /dev/null +++ b/src/components/manage/Blocks/Title/variations/styles.less @@ -0,0 +1,28 @@ +.view-viewview.light-header .main.bar { + position: relative; + z-index: 1; + width: 100%; + margin-bottom: -160px; +} +//Gradient styles for web report +.light-header .gradient { + background: linear-gradient( + 0deg, + #ffffff, + rgba(255, 255, 255, 0.9) 30%, + rgba(46, 82, 114, 0.7) 70%, + rgba(14, 21, 26, 0.8) 100% + ) !important; +} + +.ui.block.title .eea.banner .content { + padding-right: 1rem; + padding-left: 1rem; +} + +.share-popup { + .actions { + display: flex; + flex-flow: row; + } +} diff --git a/src/components/theme/Banner/View.jsx b/src/components/theme/Banner/View.jsx index e721fc93..78423afe 100644 --- a/src/components/theme/Banner/View.jsx +++ b/src/components/theme/Banner/View.jsx @@ -291,8 +291,12 @@ const View = (props) => { } > - {subtitle && {subtitle}} + {!props.data.aboveTitle && subtitle && ( + {subtitle} + )} + {props.data.aboveTitle} + {props.data.belowTitle} <Banner.Metadata> <Banner.MetadataField type="type" diff --git a/src/components/theme/SubsiteClass.jsx b/src/components/theme/SubsiteClass.jsx index 188875b8..bd5210d4 100644 --- a/src/components/theme/SubsiteClass.jsx +++ b/src/components/theme/SubsiteClass.jsx @@ -14,8 +14,10 @@ const SubsiteClass = () => { return ( <BodyClass - className={cx('subsite', `subsite-${subsite.subsite_css_class?.token}`, { + className={cx('subsite', { 'subsite-root': isSubsiteRoot(location.pathname, subsite), + [`subsite-${subsite.subsite_css_class?.token}`]: + subsite.subsite_css_class?.token, })} /> ); diff --git a/src/components/theme/WebReport/WebReportSectionView.jsx b/src/components/theme/WebReport/WebReportSectionView.jsx new file mode 100644 index 00000000..523e772c --- /dev/null +++ b/src/components/theme/WebReport/WebReportSectionView.jsx @@ -0,0 +1,49 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useHistory } from 'react-router-dom'; +import { isInternalURL, flattenToAppURL } from '@plone/volto/helpers'; +import { DefaultView } from '@plone/volto/components/'; +import { Redirect } from 'react-router-dom'; + +const WebReportSectionView = (props) => { + const { content, token } = props; + const history = useHistory(); + const redirectUrl = React.useMemo(() => { + if (content) { + const items = content.items; + const firstItem = items?.[0]; + return firstItem?.['@id']; + } + }, [content]); + + useEffect(() => { + if (!token) { + if (isInternalURL(redirectUrl)) { + history.replace(flattenToAppURL(redirectUrl)); + } else if (!__SERVER__ && redirectUrl) { + window.location.href = flattenToAppURL(redirectUrl); + } + } + }, [history, content, redirectUrl, token]); + + if (__SERVER__ && redirectUrl && !token) { + return <Redirect to={redirectUrl} />; + } + return <DefaultView {...props} />; +}; + +WebReportSectionView.propTypes = { + content: PropTypes.shape({ + items: PropTypes.arrayOf( + PropTypes.shape({ + '@id': PropTypes.string, + }), + ), + }), +}; + +WebReportSectionView.defaultProps = { + content: null, +}; + +export default WebReportSectionView; diff --git a/src/customizations/volto/components/theme/Breadcrumbs/Breadcrumbs.jsx b/src/customizations/volto/components/theme/Breadcrumbs/Breadcrumbs.jsx index 1fbb1f42..97e7e41a 100644 --- a/src/customizations/volto/components/theme/Breadcrumbs/Breadcrumbs.jsx +++ b/src/customizations/volto/components/theme/Breadcrumbs/Breadcrumbs.jsx @@ -3,7 +3,7 @@ * @module components/theme/Breadcrumbs/Breadcrumbs */ -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useLocation } from 'react-router'; @@ -32,10 +32,23 @@ const isContentRoute = (pathname) => { const Breadcrumbs = (props) => { const dispatch = useDispatch(); const { items = [], root = '/' } = useSelector((state) => state?.breadcrumbs); + const content = useSelector((state) => state?.content?.data); + // const pathname = useSelector((state) => state.location.pathname); const location = useLocation(); const { pathname } = location; + const linkLevels = useMemo(() => { + if (content) { + const type = content['@type']; + const isContentTypesToAvoid = + config.settings.contentTypeToAvoidAsLinks || {}; + if (isContentTypesToAvoid[type]) { + return isContentTypesToAvoid[type]; + } + } + }, [content]); + const sections = items.map((item) => ({ title: item.title, href: item.url, @@ -54,7 +67,12 @@ const Breadcrumbs = (props) => { return ( <React.Fragment> <div id="page-header" /> - <EEABreadcrumbs pathname={pathname} sections={sections} root={root} /> + <EEABreadcrumbs + pathname={pathname} + sections={sections} + root={root} + linkLevels={linkLevels} + /> </React.Fragment> ); }; diff --git a/src/customizations/volto/components/theme/Header/Header.jsx b/src/customizations/volto/components/theme/Header/Header.jsx index 3b9d4c44..1c224d9a 100644 --- a/src/customizations/volto/components/theme/Header/Header.jsx +++ b/src/customizations/volto/components/theme/Header/Header.jsx @@ -17,7 +17,6 @@ import eeaFlag from '@eeacms/volto-eea-design-system/../theme/themes/eea/assets/ import config from '@plone/volto/registry'; import { compose } from 'recompose'; -import { BodyClass } from '@plone/volto/helpers'; import cx from 'classnames'; import loadable from '@loadable/component'; @@ -43,10 +42,12 @@ const EEAHeader = ({ pathname, token, items, history, subsite }) => { const has_home_layout = layout === 'homepage_inverse_view' || (__CLIENT__ && document.body.classList.contains('homepage-inverse')); + return ( has_home_layout && (removeTrailingSlash(pathname) === router_pathname || - router_pathname.endsWith('/edit')) + router_pathname.endsWith('/edit') || + router_pathname.endsWith('/add')) ); }); @@ -75,7 +76,6 @@ const EEAHeader = ({ pathname, token, items, history, subsite }) => { return ( <Header menuItems={items}> - {isHomePageInverse && <BodyClass className="homepage" />} <Header.TopHeader> <Header.TopItem className="official-union"> <Image src={eeaFlag} alt="European Union flag"></Image> diff --git a/src/customizations/volto/components/theme/View/DefaultView.jsx b/src/customizations/volto/components/theme/View/DefaultView.jsx new file mode 100644 index 00000000..be84e1b3 --- /dev/null +++ b/src/customizations/volto/components/theme/View/DefaultView.jsx @@ -0,0 +1,190 @@ +/** + * Document view component. + * @module components/theme/View/DefaultView + */ + +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + Container as SemanticContainer, + Segment, + Grid, + Label, +} from 'semantic-ui-react'; +import config from '@plone/volto/registry'; +import { getSchema } from '@plone/volto/actions'; +import { getWidget } from '@plone/volto/helpers/Widget/utils'; +import { RenderBlocks } from '@plone/volto/components'; + +import { hasBlocksData, getBaseUrl } from '@plone/volto/helpers'; +import { useDispatch, shallowEqual, useSelector } from 'react-redux'; + +import isEqual from 'lodash/isEqual'; +import AccordionContextNavigation from '@eeacms/volto-eea-website-theme/components/manage/Blocks/ContextNavigation/variations/Accordion'; + +/** + * Component to display the default view. + * @function DefaultView + * @param {Object} content Content object. + * @returns {string} Markup of the component. + */ +const DefaultView = (props) => { + const { content, location } = props; + const [hasLightLayout, setHasLightLayout] = React.useState(false); + + React.useEffect(() => { + const updateLightLayout = () => { + if (__CLIENT__) { + setHasLightLayout(document.body.classList.contains('light-header')); + } + }; + + updateLightLayout(); + + if (__CLIENT__) { + const observer = new MutationObserver(updateLightLayout); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class'], + }); + + return () => observer.disconnect(); + } + }, []); + + const { contextNavigationActions } = useSelector( + (state) => ({ + contextNavigationActions: state.actions?.actions?.context_navigation, + }), + shallowEqual, + ); + + const navigation_paths = contextNavigationActions || []; + const path = getBaseUrl(location?.pathname || ''); + const dispatch = useDispatch(); + const { views } = config.widgets; + const contentSchema = useSelector((state) => state.schema?.schema); + const fieldsetsToExclude = [ + 'categorization', + 'dates', + 'ownership', + 'settings', + ]; + const fieldsets = contentSchema?.fieldsets.filter( + (fs) => !fieldsetsToExclude.includes(fs.id), + ); + + // TL;DR: There is a flash of the non block-based view because of the reset + // of the content on route change. Subscribing to the content change at this + // level has nasty implications, so we can't watch the Redux state for loaded + // content flag here (because it forces an additional component update) + // Instead, we can watch if the content is "empty", but this has a drawback + // since the locking mechanism inserts a `lock` key before the content is there. + // So "empty" means `content` is present, but only with a `lock` key, thus the next + // ugly condition comes to life + const contentLoaded = content && !isEqual(Object.keys(content), ['lock']); + + React.useEffect(() => { + content?.['@type'] && + !hasBlocksData(content) && + dispatch(getSchema(content['@type'], location.pathname)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const Container = + config.getComponent({ name: 'Container' }).component || SemanticContainer; + const matchingNavigationPath = navigation_paths.find((navPath) => + path.includes(navPath.url), + ); + + // If the content is not yet loaded, then do not show anything + return contentLoaded ? ( + hasBlocksData(content) ? ( + <> + <Container id="page-document"> + <RenderBlocks {...props} path={path} /> + </Container> + {hasLightLayout && matchingNavigationPath && ( + <AccordionContextNavigation + params={{ + name: matchingNavigationPath.title, + no_thumbs: matchingNavigationPath.no_thumbs || true, + no_icons: matchingNavigationPath.no_icons || true, + root_path: matchingNavigationPath.url, + includeTop: matchingNavigationPath.includeTop || true, + bottomLevel: matchingNavigationPath.bottomLevel || 3, + topLevel: matchingNavigationPath.topLevel || 1, + currentFolderOnly: + matchingNavigationPath.currentFolderOnly || false, + }} + /> + )} + </> + ) : ( + <Container id="page-document"> + {fieldsets?.map((fs) => { + return ( + <div className="fieldset" key={fs.id}> + {fs.id !== 'default' && <h2>{fs.title}</h2>} + {fs.fields?.map((f, key) => { + let field = { + ...contentSchema?.properties[f], + id: f, + widget: getWidget(f, contentSchema?.properties[f]), + }; + let Widget = views?.getWidget(field); + return f !== 'title' ? ( + <Grid celled="internally" key={key}> + <Grid.Row> + <Label title={field.id}>{field.title}:</Label> + </Grid.Row> + <Grid.Row> + <Segment basic> + <Widget value={content[f]} /> + </Segment> + </Grid.Row> + </Grid> + ) : ( + <Widget key={key} value={content[f]} /> + ); + })} + </div> + ); + })} + </Container> + ) + ) : null; +}; + +/** + * Property types. + * @property {Object} propTypes Property types. + * @static + */ +DefaultView.propTypes = { + /** + * Content of the object + */ + content: PropTypes.shape({ + /** + * Title of the object + */ + title: PropTypes.string, + /** + * Description of the object + */ + description: PropTypes.string, + /** + * Text of the object + */ + text: PropTypes.shape({ + /** + * Data of the text of the object + */ + data: PropTypes.string, + }), + }).isRequired, +}; + +export default DefaultView; diff --git a/src/hocs/withDeviceSize.test.jsx b/src/hocs/withDeviceSize.test.jsx new file mode 100644 index 00000000..39df8832 --- /dev/null +++ b/src/hocs/withDeviceSize.test.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import withDeviceSize from './withDeviceSize.jsx'; + +describe('withDeviceSize HOC', () => { + // Mock the WrappedComponent + const WrappedComponent = ({ device }) => ( + <div data-testid="device">{device}</div> + ); + + const mockResize = (width) => { + Object.defineProperty(document.documentElement, 'clientWidth', { + writable: true, + configurable: true, + value: width, + }); + window.dispatchEvent(new Event('resize')); + }; + + it('should return mobile for screen width less than 768px', () => { + const ComponentWithDeviceSize = withDeviceSize(WrappedComponent); + + const { getByTestId } = render(<ComponentWithDeviceSize />); + + act(() => { + mockResize(500); // Simulating a mobile screen + }); + + expect(getByTestId('device').textContent).toBe('mobile'); + }); + + it('should return tablet for screen width between 768px and 992px', () => { + const ComponentWithDeviceSize = withDeviceSize(WrappedComponent); + + const { getByTestId } = render(<ComponentWithDeviceSize />); + + act(() => { + mockResize(800); // Simulating a tablet screen + }); + + expect(getByTestId('device').textContent).toBe('tablet'); + }); + + it('should return computer for screen width between 992px and 1200px', () => { + const ComponentWithDeviceSize = withDeviceSize(WrappedComponent); + + const { getByTestId } = render(<ComponentWithDeviceSize />); + + act(() => { + mockResize(1000); // Simulating a computer screen + }); + + expect(getByTestId('device').textContent).toBe('computer'); + }); + + it('should return large for screen width between 1200px and 1920px', () => { + const ComponentWithDeviceSize = withDeviceSize(WrappedComponent); + + const { getByTestId } = render(<ComponentWithDeviceSize />); + + act(() => { + mockResize(1500); // Simulating a large screen + }); + + expect(getByTestId('device').textContent).toBe('large'); + }); + + it('should return widescreen for screen width above 1920px', () => { + const ComponentWithDeviceSize = withDeviceSize(WrappedComponent); + + const { getByTestId } = render(<ComponentWithDeviceSize />); + + act(() => { + mockResize(2000); // Simulating a widescreen display + }); + + expect(getByTestId('device').textContent).toBe('widescreen'); + }); +}); diff --git a/src/index.js b/src/index.js index 0fc2c879..76de3028 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ import CustomCSS from '@eeacms/volto-eea-website-theme/components/theme/CustomCS import DraftBackground from '@eeacms/volto-eea-website-theme/components/theme/DraftBackground/DraftBackground'; import HomePageInverseView from '@eeacms/volto-eea-website-theme/components/theme/Homepage/HomePageInverseView'; import HomePageView from '@eeacms/volto-eea-website-theme/components/theme/Homepage/HomePageView'; +import WebReportSectionView from '@eeacms/volto-eea-website-theme/components/theme/WebReport/WebReportSectionView'; import NotFound from '@eeacms/volto-eea-website-theme/components/theme/NotFound/NotFound'; import { TokenWidget } from '@eeacms/volto-eea-website-theme/components/theme/Widgets/TokenWidget'; import { TopicsWidget } from '@eeacms/volto-eea-website-theme/components/theme/Widgets/TopicsWidget'; @@ -26,6 +27,7 @@ import { } from '@eeacms/volto-eea-website-theme/helpers/schema-utils'; import installLayoutSettingsBlock from '@eeacms/volto-eea-website-theme/components/manage/Blocks/LayoutSettings'; +import installContextNavigationBlock from '@eeacms/volto-eea-website-theme/components/manage/Blocks/ContextNavigation'; import installCustomTitle from '@eeacms/volto-eea-website-theme/components/manage/Blocks/Title'; import FlexGroup from '@eeacms/volto-eea-website-theme/components/manage/Blocks/GroupBlockTemplate/FlexGroup/FlexGroup'; @@ -241,7 +243,14 @@ const applyConfig = (config) => { ...(config.views.layoutViewsNamesMapping || {}), homepage_view: 'Homepage view', homepage_inverse_view: 'Homepage white view', + web_report_section: 'Web report section', }; + + config.views.contentTypesViews = { + ...(config.views.contentTypesViews || {}), + web_report_section: WebReportSectionView, + }; + config.views.errorViews = { ...config.views.errorViews, 404: NotFound, @@ -486,11 +495,11 @@ const applyConfig = (config) => { // }, }; - // layout settings - config = [installLayoutSettingsBlock].reduce( - (acc, apply) => apply(acc), - config, - ); + //If you don't want to show the content type as a link in the breadcrumbs, you can set it to a number + // where 1 is the last item in the breadcrumbs, 2 is the second last, etc. + config.settings.contentTypeToAvoidAsLinks = { + web_report_section: 2, + }; // Group if (config.blocks.blocksConfig.group) { @@ -559,8 +568,12 @@ const applyConfig = (config) => { GET_CONTENT: ['breadcrumbs'], // 'navigation', 'actions', 'types'], }); - // Custom blocks: Title - return [installCustomTitle].reduce((acc, apply) => apply(acc), config); + // Custom blocks: Title,Layout settings, Context navigation + return [ + installCustomTitle, + installLayoutSettingsBlock, + installContextNavigationBlock, + ].reduce((acc, apply) => apply(acc), config); }; export default applyConfig;