diff --git a/CHANGELOG.md b/CHANGELOG.md index da7315cf..bd72e35e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ 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). +### [1.32.0](https://github.com/eea/volto-eea-website-theme/compare/1.31.0...1.32.0) - 21 March 2024 + +#### :rocket: New Features + +- feat(slate): add new h3 slate element [Miu Razvan - [`20a7e93`](https://github.com/eea/volto-eea-website-theme/commit/20a7e9316d5c1de279712c38bc05eb775f8bd326)] +- feat(slate): add h4 in slate toolbar [Miu Razvan - [`2c523ac`](https://github.com/eea/volto-eea-website-theme/commit/2c523acdeb78ed224c4a37285c48dbf4555604e0)] + +#### :nail_care: Enhancements + +- change(slate): Use F as icon and renamed title of new heading Slate toolbar button to Figure title [David Ichim - [`7355bb3`](https://github.com/eea/volto-eea-website-theme/commit/7355bb3f3cf527c24fc41a10f72cc854773ab84b)] +- change(slate): renamed addFormat to addSlateToolbarButton [ichim-david - [`0809c04`](https://github.com/eea/volto-eea-website-theme/commit/0809c040ea9e096c60cbce36e679f9bab23a52a2)] + +#### :house: Internal changes + +- style: Automated code fix [eea-jenkins - [`6971349`](https://github.com/eea/volto-eea-website-theme/commit/69713496d601e4e0b2fe3469a4ffcd8d36105040)] +- style: Automated code fix [eea-jenkins - [`a292700`](https://github.com/eea/volto-eea-website-theme/commit/a292700cd047bad925add1fde4d834f7fd03ec62)] + +#### :hammer_and_wrench: Others + +- Update package.json [ichim-david - [`9db96ff`](https://github.com/eea/volto-eea-website-theme/commit/9db96ff3f8e8eedce92c53befe6a1d6e965d8699)] +- Revert "(fix): In search block on edit, the sort on and sort order are not working (#205)" [David Ichim - [`2045d50`](https://github.com/eea/volto-eea-website-theme/commit/2045d50b3d2f18fab0d6c6794d8030975b1a3f21)] ### [1.31.0](https://github.com/eea/volto-eea-website-theme/compare/1.30.0...1.31.0) - 14 March 2024 #### :rocket: New Features diff --git a/package.json b/package.json index d0a52238..553a8d61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@eeacms/volto-eea-website-theme", - "version": "1.31.0", + "version": "1.32.0", "description": "@eeacms/volto-eea-website-theme: Volto add-on", "main": "src/index.js", "author": "European Environment Agency: IDM2 A-Team", diff --git a/src/customizations/volto/components/manage/Blocks/Search/SearchBlockEdit.jsx b/src/customizations/volto/components/manage/Blocks/Search/SearchBlockEdit.jsx deleted file mode 100644 index 4ce1f10a..00000000 --- a/src/customizations/volto/components/manage/Blocks/Search/SearchBlockEdit.jsx +++ /dev/null @@ -1,106 +0,0 @@ -//this customization is used for fixing: in the search block, on edit sort on and reversed order doesn't work. -//See here volto pr: https://github.com/plone/volto/pull/5262 -import React, { useEffect } from 'react'; -import { defineMessages } from 'react-intl'; -import { compose } from 'redux'; - -import { SidebarPortal, BlockDataForm } from '@plone/volto/components'; -import { addExtensionFieldToSchema } from '@plone/volto/helpers/Extensions/withBlockSchemaEnhancer'; -import { getBaseUrl } from '@plone/volto/helpers'; -import config from '@plone/volto/registry'; - -import { SearchBlockViewComponent } from '@plone/volto/components/manage/Blocks/Search/SearchBlockView'; -import Schema from '@plone/volto/components/manage/Blocks/Search/schema'; -import { - withSearch, - withQueryString, -} from '@plone/volto/components/manage/Blocks/Search/hocs'; -import { cloneDeep } from 'lodash'; - -const messages = defineMessages({ - template: { - id: 'Results template', - defaultMessage: 'Results template', - }, -}); - -const SearchBlockEdit = (props) => { - const { - block, - onChangeBlock, - data, - selected, - intl, - navRoot, - contentType, - onTriggerSearch, - querystring = {}, - } = props; - const { sortable_indexes = {} } = querystring; - - let schema = Schema({ data, intl }); - - schema = addExtensionFieldToSchema({ - schema, - name: 'listingBodyTemplate', - items: config.blocks.blocksConfig.listing.variations, - intl, - title: { id: intl.formatMessage(messages.template) }, - }); - const listingVariations = config.blocks.blocksConfig?.listing?.variations; - let activeItem = listingVariations.find( - (item) => item.id === data.listingBodyTemplate, - ); - const listingSchemaEnhancer = activeItem?.schemaEnhancer; - if (listingSchemaEnhancer) - schema = listingSchemaEnhancer({ - schema: cloneDeep(schema), - data, - intl, - }); - schema.properties.sortOnOptions.items = { - choices: Object.keys(sortable_indexes).map((k) => [ - k, - sortable_indexes[k].title, - ]), - }; - - const { query = {} } = data || {}; - // We don't need deep compare here, as this is just json serializable data. - const deepQuery = JSON.stringify(query); - useEffect(() => { - onTriggerSearch( - '', - data?.facets, - data?.query?.sort_on, - data?.query?.sort_order, - ); - }, [deepQuery, onTriggerSearch, data]); - - return ( - <> - - - { - onChangeBlock(block, { - ...data, - [id]: value, - }); - }} - onChangeBlock={onChangeBlock} - formData={data} - navRoot={navRoot} - contentType={contentType} - /> - - - ); -}; - -export default compose(withQueryString, withSearch())(SearchBlockEdit); diff --git a/src/customizations/volto/components/manage/Blocks/Search/hocs/withSearch.jsx b/src/customizations/volto/components/manage/Blocks/Search/hocs/withSearch.jsx deleted file mode 100644 index 6bf2b191..00000000 --- a/src/customizations/volto/components/manage/Blocks/Search/hocs/withSearch.jsx +++ /dev/null @@ -1,479 +0,0 @@ -//this customization is used for fixing: in the search block, on edit sort on and reversed order doesn't work. -//See here volto pr: https://github.com/plone/volto/pull/5262 -import React from 'react'; -import { useSelector } from 'react-redux'; -import qs from 'query-string'; -import { useLocation, useHistory } from 'react-router-dom'; - -import { resolveExtension } from '@plone/volto/helpers/Extensions/withBlockExtensions'; -import config from '@plone/volto/registry'; -import { usePrevious } from '@plone/volto/helpers'; -import { isEqual } from 'lodash'; - -function getDisplayName(WrappedComponent) { - return WrappedComponent.displayName || WrappedComponent.name || 'Component'; -} - -const SEARCH_ENDPOINT_FIELDS = [ - 'SearchableText', - 'b_size', - 'limit', - 'sort_on', - 'sort_order', -]; - -const PAQO = 'plone.app.querystring.operation'; - -/** - * Based on URL state, gets an initial internal state for the search - * - * @function getInitialState - * - */ -function getInitialState( - data, - facets, - urlSearchText, - id, - sortOnParam, - sortOrderParam, -) { - const { - types: facetWidgetTypes, - } = config.blocks.blocksConfig.search.extensions.facetWidgets; - const facetSettings = data?.facets || []; - - return { - query: [ - ...(data.query?.query || []), - ...(facetSettings || []) - .map((facet) => { - if (!facet?.field) return null; - - const { valueToQuery } = resolveExtension( - 'type', - facetWidgetTypes, - facet, - ); - - const name = facet.field.value; - const value = facets[name]; - - return valueToQuery({ value, facet }); - }) - .filter((f) => !!f), - ...(urlSearchText - ? [ - { - i: 'SearchableText', - o: 'plone.app.querystring.operation.string.contains', - v: urlSearchText, - }, - ] - : []), - ], - sort_on: sortOnParam || data.query?.sort_on, - sort_order: sortOrderParam || data.query?.sort_order, - b_size: data.query?.b_size, - limit: data.query?.limit, - block: id, - }; -} - -/** - * "Normalizes" the search state to something that's serializable - * (for querying) and used to compute data for the ListingBody - * - * @function normalizeState - * - */ -function normalizeState({ - query, // base query - facets, // facet values - id, // block id - searchText, // SearchableText - sortOn, - sortOrder, - facetSettings, // data.facets extracted from block data -}) { - const { - types: facetWidgetTypes, - } = config.blocks.blocksConfig.search.extensions.facetWidgets; - - // Here, we are removing the QueryString of the Listing ones, which is present in the Facet - // because we already initialize the facet with those values. - const configuredFacets = facetSettings - ? facetSettings.map((facet) => facet?.field?.value) - : []; - - let copyOfQuery = query.query ? [...query.query] : []; - - const queryWithoutFacet = copyOfQuery.filter((query) => { - return !configuredFacets.includes(query.i); - }); - - const params = { - query: [ - ...(queryWithoutFacet || []), - ...(facetSettings || []).map((facet) => { - if (!facet?.field) return null; - - const { valueToQuery } = resolveExtension( - 'type', - facetWidgetTypes, - facet, - ); - - const name = facet.field.value; - const value = facets[name]; - - return valueToQuery({ value, facet }); - }), - ].filter((o) => !!o), - sort_on: sortOn || query.sort_on, - sort_order: sortOrder || query.sort_order, - b_size: query.b_size, - limit: query.limit, - block: id, - }; - - // Note Ideally the searchtext functionality should be restructured as being just - // another facet. But right now it's the same. This means that if a searchText - // is provided, it will override the SearchableText facet. - // If there is no searchText, the SearchableText in the query remains in effect. - // TODO eventually the searchText should be a distinct facet from SearchableText, and - // the two conditions could be combined, in comparison to the current state, when - // one overrides the other. - if (searchText) { - params.query = params.query.reduce( - // Remove SearchableText from query - (acc, kvp) => (kvp.i === 'SearchableText' ? acc : [...acc, kvp]), - [], - ); - params.query.push({ - i: 'SearchableText', - o: 'plone.app.querystring.operation.string.contains', - v: searchText, - }); - } - - return params; -} - -const getSearchFields = (searchData) => { - return Object.assign( - {}, - ...SEARCH_ENDPOINT_FIELDS.map((k) => { - return searchData[k] ? { [k]: searchData[k] } : {}; - }), - searchData.query ? { query: serializeQuery(searchData['query']) } : {}, - ); -}; - -/** - * A hook that will mirror the search block state to a hash location - */ -const useHashState = () => { - const location = useLocation(); - const history = useHistory(); - - /** - * Required to maintain parameter compatibility. - With this we will maintain support for receiving hash (#) and search (?) type parameters. - */ - const oldState = React.useMemo(() => { - return { - ...qs.parse(location.search), - ...qs.parse(location.hash), - }; - }, [location.hash, location.search]); - - // This creates a shallow copy. Why is this needed? - const current = Object.assign( - {}, - ...Array.from(Object.keys(oldState)).map((k) => ({ [k]: oldState[k] })), - ); - - const setSearchData = React.useCallback( - (searchData) => { - const newParams = qs.parse(location.search); - - let changed = false; - - Object.keys(searchData) - .sort() - .forEach((k) => { - if (searchData[k]) { - newParams[k] = searchData[k]; - if (oldState[k] !== searchData[k]) { - changed = true; - } - } - }); - - if (changed) { - history.push({ - search: qs.stringify(newParams), - }); - } - }, - [history, oldState, location.search], - ); - - return [current, setSearchData]; -}; - -/** - * A hook to make it possible to switch disable mirroring the search block - * state to the window location. When using the internal state we "start from - * scratch", as it's intended to be used in the edit page. - */ -const useSearchBlockState = (uniqueId, isEditMode) => { - const [hashState, setHashState] = useHashState(); - const [internalState, setInternalState] = React.useState({}); - - return isEditMode - ? [internalState, setInternalState] - : [hashState, setHashState]; -}; - -// Simple compress/decompress the state in URL by replacing the lengthy string -const deserializeQuery = (q) => { - return JSON.parse(q)?.map((kvp) => ({ - ...kvp, - o: kvp.o.replace(/^paqo/, PAQO), - })); -}; -const serializeQuery = (q) => { - return JSON.stringify( - q?.map((kvp) => ({ ...kvp, o: kvp.o.replace(PAQO, 'paqo') })), - ); -}; - -const withSearch = (options) => (WrappedComponent) => { - const { inputDelay = 1000 } = options || {}; - - function WithSearch(props) { - const { data, id, editable = false } = props; - - const [locationSearchData, setLocationSearchData] = useSearchBlockState( - id, - editable, - ); - - // TODO: Improve the hook dependencies out of the scope of https://github.com/plone/volto/pull/4662 - // eslint-disable-next-line react-hooks/exhaustive-deps - const urlQuery = locationSearchData.query - ? deserializeQuery(locationSearchData.query) - : []; - const urlSearchText = - locationSearchData.SearchableText || - urlQuery.find(({ i }) => i === 'SearchableText')?.v || - ''; - - // TODO: refactor, should use only useLocationStateManager()!!! - const [searchText, setSearchText] = React.useState(urlSearchText); - // TODO: Improve the hook dependencies out of the scope of https://github.com/plone/volto/pull/4662 - // eslint-disable-next-line react-hooks/exhaustive-deps - const configuredFacets = - data.facets?.map((facet) => facet?.field?.value) || []; - - // Here we are getting the initial value of the facet if Listing Query contains the same criteria as - // facet. - const queryData = data?.query?.query - ? deserializeQuery(JSON.stringify(data?.query?.query)) - : []; - - let intializeFacetWithQueryValue = []; - - for (let value of configuredFacets) { - const queryString = queryData.find((item) => item.i === value); - if (queryString) { - intializeFacetWithQueryValue = [ - ...intializeFacetWithQueryValue, - { [queryString.i]: queryString.v }, - ]; - } - } - - const multiFacets = data.facets - ?.filter((facet) => facet?.multiple) - .map((facet) => facet?.field?.value); - const [facets, setFacets] = React.useState( - Object.assign( - {}, - ...urlQuery.map(({ i, v }) => ({ [i]: v })), - // TODO: the 'o' should be kept. This would be a major refactoring of the facets - ...intializeFacetWithQueryValue, - // support for simple filters like ?Subject=something - // TODO: since the move to hash params this is no longer working. - // We'd have to treat the location.search and manage it just like the - // hash, to support it. We can read it, but we'd have to reset it as - // well, so at that point what's the difference to the hash? - ...configuredFacets.map((f) => - locationSearchData[f] - ? { - [f]: - multiFacets.indexOf(f) > -1 - ? [locationSearchData[f]] - : locationSearchData[f], - } - : {}, - ), - ), - ); - const previousUrlQuery = usePrevious(urlQuery); - - // During first render the previousUrlQuery is undefined and urlQuery - // is empty so it ressetting the facet when you are navigating but during reload we have urlQuery and we need - // to set the facet at first render. - const preventOverrideOfFacetState = - previousUrlQuery === undefined && urlQuery.length === 0; - - React.useEffect(() => { - if ( - !isEqual(urlQuery, previousUrlQuery) && - !preventOverrideOfFacetState - ) { - setFacets( - Object.assign( - {}, - ...urlQuery.map(({ i, v }) => ({ [i]: v })), // TODO: the 'o' should be kept. This would be a major refactoring of the facets - - // support for simple filters like ?Subject=something - // TODO: since the move to hash params this is no longer working. - // We'd have to treat the location.search and manage it just like the - // hash, to support it. We can read it, but we'd have to reset it as - // well, so at that point what's the difference to the hash? - ...configuredFacets.map((f) => - locationSearchData[f] - ? { - [f]: - multiFacets.indexOf(f) > -1 - ? [locationSearchData[f]] - : locationSearchData[f], - } - : {}, - ), - ), - ); - } - }, [ - urlQuery, - configuredFacets, - locationSearchData, - multiFacets, - previousUrlQuery, - preventOverrideOfFacetState, - ]); - - const [sortOn, setSortOn] = React.useState(data?.query?.sort_on); - const [sortOrder, setSortOrder] = React.useState(data?.query?.sort_order); - - const [searchData, setSearchData] = React.useState( - getInitialState(data, facets, urlSearchText, id), - ); - - const deepFacets = JSON.stringify(facets); - const deepData = JSON.stringify(data); - React.useEffect(() => { - setSearchData( - getInitialState( - JSON.parse(deepData), - JSON.parse(deepFacets), - urlSearchText, - id, - sortOn, - sortOrder, - ), - ); - }, [deepData, deepFacets, urlSearchText, id, sortOn, sortOrder]); - - const timeoutRef = React.useRef(); - const facetSettings = data?.facets; - - const deepQuery = JSON.stringify(data.query); - const onTriggerSearch = React.useCallback( - ( - toSearchText = undefined, - toSearchFacets = undefined, - toSortOn = undefined, - toSortOrder = undefined, - ) => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout( - () => { - const newSearchData = normalizeState({ - id, - query: data.query || {}, - facets: toSearchFacets || facets, - searchText: toSearchText ? toSearchText.trim() : '', - sortOn: toSortOn || undefined, - sortOrder: toSortOrder || sortOrder, - facetSettings, - }); - if (toSearchFacets) setFacets(toSearchFacets); - if (toSortOn) setSortOn(toSortOn || undefined); - if (toSortOrder) setSortOrder(toSortOrder); - setSearchData(newSearchData); - setLocationSearchData(getSearchFields(newSearchData)); - }, - toSearchFacets ? inputDelay / 3 : inputDelay, - ); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - // Use deep comparison of data.query - deepQuery, - facets, - id, - setLocationSearchData, - searchText, - sortOn, - sortOrder, - facetSettings, - ], - ); - - const removeSearchQuery = () => { - let newSearchData = { ...searchData }; - newSearchData.query = searchData.query.reduce( - // Remove SearchableText from query - (acc, kvp) => (kvp.i === 'SearchableText' ? acc : [...acc, kvp]), - [], - ); - setSearchData(newSearchData); - setLocationSearchData(getSearchFields(newSearchData)); - }; - - const querystringResults = useSelector( - (state) => state.querystringsearch.subrequests, - ); - const totalItems = - querystringResults[id]?.total || querystringResults[id]?.items?.length; - - return ( - - ); - } - WithSearch.displayName = `WithSearch(${getDisplayName(WrappedComponent)})`; - - return WithSearch; -}; - -export default withSearch; diff --git a/src/icons/font-mono.svg b/src/icons/font-mono.svg new file mode 100644 index 00000000..86fa362e --- /dev/null +++ b/src/icons/font-mono.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/index.js b/src/index.js index dab7f3ac..a49cbd01 100644 --- a/src/index.js +++ b/src/index.js @@ -373,6 +373,7 @@ const applyConfig = (config) => { }, ]; + // Install slate config = installSlate(config); // Custom block-style colors diff --git a/src/slate.js b/src/slate.js index c4f0c40d..0a0559f6 100644 --- a/src/slate.js +++ b/src/slate.js @@ -1,6 +1,11 @@ import React from 'react'; +import cx from 'classnames'; import { List } from 'semantic-ui-react'; -import { MarkElementButton, ToolbarButton } from '@plone/volto-slate/editor/ui'; +import { + MarkElementButton, + ToolbarButton, + BlockButton, +} from '@plone/volto-slate/editor/ui'; import installCallout from '@plone/volto-slate/editor/plugins/Callout'; import { Icon } from '@plone/volto/components'; import { Editor, Transforms, Text } from 'slate'; @@ -14,6 +19,39 @@ import alignJustifyIcon from '@plone/volto/icons/align-justify.svg'; import lightIcon from './icons/light.svg'; import smallIcon from './icons/small.svg'; import clearIcon from './icons/eraser.svg'; +import fontMono from './icons/font-mono.svg'; + +const installSlateToolbarButton = ({ + config, + key, + before, + button, + element, +}) => { + const toolbarButtons = config.settings.slate.toolbarButtons; + const index = toolbarButtons.indexOf(key); + const beforeIndex = toolbarButtons.indexOf(before); + if (index === -1) { + if (beforeIndex > -1) { + toolbarButtons.splice(beforeIndex + 1, 0, key); + } else { + toolbarButtons.push(key); + } + } else if (index > -1 && beforeIndex > -1 && index > beforeIndex + 1) { + toolbarButtons.splice(index, 1); + toolbarButtons.splice(beforeIndex + 1, 0, key); + } else if (index > -1 && index < beforeIndex) { + toolbarButtons.splice(index, 1); + toolbarButtons.splice(beforeIndex, 0, key); + } + if (button) { + config.settings.slate.buttons[key] = button; + } + if (element) { + config.settings.slate.elements[key] = element; + } + return config; +}; const toggleBlockClassFormat = (editor, format) => { const levels = Array.from(Editor.levels(editor, editor.selection)); @@ -67,20 +105,16 @@ const clearFormatting = (editor) => { Editor.nodes(editor, { mode: 'lowest', match: (n, p) => { - // console.log('node', n, p); return Text.isText(n); }, //at: [0], // uncomment if you want everything to be cleared }), ); - // console.log('sn', sn); - sn.forEach(([n, at]) => { const toRemove = Object.keys(n).filter((k) => k.startsWith('style-')); if (toRemove.length) { Transforms.unsetNodes(editor, toRemove, { at }); - // console.log('unset', n, at, toRemove); } }); @@ -111,9 +145,46 @@ const ClearFormattingButton = ({ icon, ...props }) => { export default function installSlate(config) { if (config.settings.slate) { + let renderLinkElement; // Callout slate button config = installCallout(config); + try { + renderLinkElement = require('@eeacms/volto-anchors/helpers') + .renderLinkElement; + } catch {} + + installSlateToolbarButton({ + config, + key: 'h3-light', + before: 'heading-three', + button: (props) => ( + + ), + element: renderLinkElement + ? (opts) => { + return renderLinkElement('h3')({ + ...opts, + className: 'subtitle-light', + }); + } + : ({ attributes, children }) => ( +

+ {children} +

+ ), + }); + config.settings.slate.topLevelTargetElements.push('h3-light'); + config.settings.slate.buttons.clearformatting = (props) => ( );