From 4603af9d3d4d071ccd38c52fa3357a7a8db11dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Re=C3=A9?= <95078727+reekitconcept@users.noreply.github.com> Date: Mon, 4 Mar 2024 09:40:29 +0100 Subject: [PATCH] Add support for sidebar facet conditions (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add support for sidebar facet conditions - add person result type - display phone, email - add missing icons - thumbnail image - fallback avatar - layout selector and grid layout - ResultItemPreviewImage helper - query parameters improvements - refactor conversion between state and query parameters - support search conditions as a query parameter - facet fields UI - optimized rendering - field results - cached selection - sorted values by count, alphabetic - show remaining, already selected values at the end of the list - prefix filter for each field - support Show more for each field - fix semantic-ui dimmer to cover the entire viewport * Add required news doodah * fix linting * fix linting * fix linting * fix linting * fix linting * fix linting * - pass layout to result type templates as a prop * - enable translations on facet search condition labels * - implement base64 conversion of utf, needed for accented characters * rename prefix - contains * fix tests * - fix css for image result type, tile footer * fix linting * Use the branched version of kitconcept.solr for acceptance * - fix margins of preview image in result types * fix counter layout, casued problem with missing (0) counters * Fix dimmer to not block the entire page, center instead * - hide page selector if there is only one page * Show more indicator displayed only if there are more elements * - fix - never provide a * as a fallback for empty SearchableText * - add doEmptySearch option that causes to allow search with empty term This is useful to start search with the facet conditions. * improve scrollbar placement on more list of values * Fix linting Fix linting Fix linting * Update acceptance to use kitconcept.solr==1.0.0a5 * Update solr configuration for acceptance tests * Fix local acceptance testing, HOST env var is needed for Volto 17 setup * Fix results to be shown * Fix updating search on paging or sort order change * Fix sort order * Fix unit tests --------- Co-authored-by: Balázs Reé --- acceptance/cypress/support/commands.js | 6 + acceptance/cypress/tests/search-ui.cy.js | 3 + acceptance/docker-compose.yml | 3 +- acceptance/solr/etc/conf/schema.xml | 2 +- acceptance/solr/etc/conf/solrconfig.xml | 73 ++-- news/15.feature | 1 + src/actions/solrsearch/solrsearch.js | 6 +- src/actions/solrsearch/solrsearch.test.js | 20 +- .../theme/SolrSearch/SearchConditions.jsx | 127 +++++++ .../theme/SolrSearch/SearchConditions.test.js | 128 +++++++ .../SolrSearch/SearchConditionsField.jsx | 118 +++++++ .../SearchConditionsFieldSearch.jsx | 24 ++ .../SolrSearch/SearchConditionsValue.jsx | 46 +++ .../theme/SolrSearch/SearchQuery.jsx | 29 ++ .../theme/SolrSearch/SearchQuery.test.jsx | 115 ++++++ .../theme/SolrSearch/SelectLayout.jsx | 67 ++++ .../theme/SolrSearch/ShowMoreIndicator.jsx | 44 +++ .../theme/SolrSearch/SolrSearch.jsx | 286 ++++++++------- .../theme/SolrSearch/base64Helpers.js | 17 + .../theme/SolrSearch/base64Helpers.test.js | 18 + .../SolrSearch/icons/fallback-avatar.svg | 25 ++ .../theme/SolrSearch/icons/grid.svg | 8 + .../theme/SolrSearch/icons/location.svg | 22 +- .../theme/SolrSearch/icons/phone.svg | 9 + .../resultItems/ImageResultItem.jsx | 2 +- .../resultItems/PersonResultItem.jsx | 81 +++++ .../resultItems/helpers/ImageType.jsx | 4 +- .../helpers/ResultItemPreviewImage.jsx | 19 +- .../helpers/ResultItemPreviewImage.test.jsx | 46 +++ .../ResultItemPreviewImage.test.jsx.snap | 328 ++++++++++++++++++ .../theme/SolrSearch/resultItems/index.js | 8 +- src/index.js | 4 + src/reducers/solrsearch/solrsearch.js | 10 + src/reducers/solrsearch/solrsearch.test.js | 72 ++++ src/theme/solrsearch.less | 183 +++++++++- 35 files changed, 1738 insertions(+), 216 deletions(-) create mode 100644 news/15.feature create mode 100644 src/components/theme/SolrSearch/SearchConditions.jsx create mode 100644 src/components/theme/SolrSearch/SearchConditions.test.js create mode 100644 src/components/theme/SolrSearch/SearchConditionsField.jsx create mode 100644 src/components/theme/SolrSearch/SearchConditionsFieldSearch.jsx create mode 100644 src/components/theme/SolrSearch/SearchConditionsValue.jsx create mode 100644 src/components/theme/SolrSearch/SearchQuery.jsx create mode 100644 src/components/theme/SolrSearch/SearchQuery.test.jsx create mode 100644 src/components/theme/SolrSearch/SelectLayout.jsx create mode 100644 src/components/theme/SolrSearch/ShowMoreIndicator.jsx create mode 100644 src/components/theme/SolrSearch/base64Helpers.js create mode 100644 src/components/theme/SolrSearch/base64Helpers.test.js create mode 100644 src/components/theme/SolrSearch/icons/fallback-avatar.svg create mode 100644 src/components/theme/SolrSearch/icons/grid.svg create mode 100644 src/components/theme/SolrSearch/icons/phone.svg create mode 100644 src/components/theme/SolrSearch/resultItems/PersonResultItem.jsx diff --git a/acceptance/cypress/support/commands.js b/acceptance/cypress/support/commands.js index 7a59b22..04a1001 100644 --- a/acceptance/cypress/support/commands.js +++ b/acceptance/cypress/support/commands.js @@ -134,6 +134,7 @@ Cypress.Commands.add( contentContactName, contentWebsite, contentContactMail, + contentEffective, reviewState = 'published', withImage, path = '', @@ -189,6 +190,7 @@ Cypress.Commands.add( description: contentDescription, subjects: contentSubjects, topic: contentTopics, + effective: contentEffective, file: { data: 'dGVzdGZpbGUK', encoding: 'base64', @@ -215,6 +217,7 @@ Cypress.Commands.add( title: contentTitle, description: contentDescription, subjects: contentSubjects, + effective: contentEffective, image: { data: 'iVBORw0KGgoAAAANSUhEUgAAANcAAAA4CAMAAABZsZ3QAAAAM1BMVEX29fK42OU+oMvn7u9drtIPisHI4OhstdWZyt4fkcXX5+sAg74umMhNp86p0eJ7vNiKw9v/UV4wAAAAAXRSTlMAQObYZgAABBxJREFUeF7tmuty4yAMhZG4X2zn/Z92J5tsBJwWXG/i3XR6frW2Y/SBLIRAfaQUDNt8E5tLUt9BycfcKfq3R6Mlfyimtx4rzp+K3dtibXkor99zsEqLYZltblTecciogoh+TXfY1Ve4dn07rCDGG9dHSEEOg/GmXl0U1XDxTKxNK5De7BxsyyBr6gGm2/vPxKJ8F6f7BXKfRMp1xIWK9A+5ks25alSb353dWnDJN1k35EL5f8dVGifTf/4tjUuuFq7u4srmXC60yAmldLXIWbg65RKU87lcGxJCFqUPv0IacW0PmSivOZFLE908inPToMmii/roG+MRV/O8FU88i8tFsxV3a06MFUw0Qu7RmAtdV5/HVVaOVMTWNOWSwMljLhzhcB6XIS7OK5V6AvRDNN7t5VJWQs1J40UmalbK56usBG/CuCHSYuc+rkUGeMCViNRARPrzW52N3oQLe6WifNliSuuGaH3czbVNudI9s7ZLUCLHVwWlyES522o1t14uvmbblmVTKqFjaZYJFSTPP4dLL1kU1z7p0lzdbRulmEWLxoQX+z9ce7A8GqEEucllLxePuZwdJl1Lezu0hoswvTPt61DrFcRuujV/2cmlxaGBC7Aw6cpovGANwRiSdOAWJ5AGy4gLL64dl0QhUEAuEUNws+XxV+OKGPdw/hESGYF9XEGaFC7sNLMSXWJjHsnanYi87VK428N2uxpOjOFANcagLM5l+7mSycM8KknZpKLcGi6jmzWGr/vLurZ/0g4u9AZuAoeb5r1ceQhyiTPY1E4wUR6u/F3H2ojSpXMMriBPT9cezTto8Cx+MsglHL4fv1Rxrb1LVw9yvyQpJ3AhFnLZfuRLH2QsOG3FGGD20X/th/u5bFAt16Bt308KjF+MNOXgl/SquIEySX3GhaZvc67KZbDxcCDORz2N8yCWPaY5lyQZO7lQ29fnZbt3Xu6qoge4+DjXl/MocySPOp9rlvdyznahRyHEYd77v3LhugOXDv4J65QXfl803BDAdaWBEDhfVx7nKofjoVCgxnUAqw/UAUDPn788BDvQuG4TDtdtUPvzjSlXAB8DvaDOhhrmhwbywylXAm8CvaouikJTL93gs3y7Yy4VYbIxOHrcMizPqWOjqO9l3Uz52kibQy4xxOgqhJvD+w5rvokOcAlGvNCfeqCv1ste1stzLm0f71Iq3ZfTrPfuE5nhPtF+LvQE2lffQC7pYtQy3tdzdrKvd5TLVVzDetScS3nEKmmwDyt1Cev1kX3YfbvzNK4fzrlw+cB6vm+uiUgf2zdXI62241LawCb7Pi5FXFPF8KpzDoF/Sw2lg+GrHNbno1mhPu+VCF/vfMnw06PnUl6j48dVHD3jHNHPua+fc3o/5yp/zsGi0vYtzi3Pz5mHd4T6BWMIlewacd63AAAAAElFTkSuQmCC', @@ -243,6 +246,7 @@ Cypress.Commands.add( title: contentTitle, description: contentDescription, subjects: contentSubjects, + effective: contentEffective, target_url: target_url, shows_people: false, language, @@ -277,6 +281,7 @@ Cypress.Commands.add( description: contentDescription, head_title: contentHeadTitle, subjects: contentSubjects, + effective: contentEffective, blocks: blocks, blocks_layout: blocksLayout, allow_discussion: allow_discussion, @@ -302,6 +307,7 @@ Cypress.Commands.add( description: contentDescription, head_title: contentHeadTitle, subjects: contentSubjects, + effective: contentEffective, allow_discussion: allow_discussion, review_state: reviewState, contact_name: contentContactName, diff --git a/acceptance/cypress/tests/search-ui.cy.js b/acceptance/cypress/tests/search-ui.cy.js index c4cfca6..a9cf42f 100644 --- a/acceptance/cypress/tests/search-ui.cy.js +++ b/acceptance/cypress/tests/search-ui.cy.js @@ -11,6 +11,7 @@ context('Search Acceptance Tests (UI)', () => { contentId: 'alpha', contentTitle: 'Alpha Beta Gaga Colorful', path: '/', + contentEffective: '2018-01-21T08:00:00', }); cy.request({ method: 'POST', @@ -28,6 +29,7 @@ context('Search Acceptance Tests (UI)', () => { contentId: 'beta', contentTitle: 'Beta Colorful', path: '', + contentEffective: '2018-02-21T08:00:00', }); cy.request({ method: 'POST', @@ -45,6 +47,7 @@ context('Search Acceptance Tests (UI)', () => { contentId: 'gamma', contentTitle: 'Gamma Colorful', path: '', + contentEffective: '2018-03-21T08:00:00', }); cy.request({ method: 'POST', diff --git a/acceptance/docker-compose.yml b/acceptance/docker-compose.yml index 10df559..c16abda 100644 --- a/acceptance/docker-compose.yml +++ b/acceptance/docker-compose.yml @@ -14,6 +14,7 @@ services: environment: RAZZLE_INTERNAL_API_PATH: http://backend-acceptance:55001/plone RAZZLE_API_PATH: http://localhost:55001/plone + HOST: 0.0.0.0 ports: - 3000:3000 - 3001:3001 @@ -51,7 +52,7 @@ services: # Use plone.volto rather than kitconcept.volto for the acceptance # testing, which makes login in tests "more conventional". - otherwise # it's no difference, both could make sense. - ADDONS: 'plone.app.robotframework==2.0.0 plone.app.contenttypes plone.restapi plone.volto kitconcept.solr==1.0.0a1' + ADDONS: 'plone.app.robotframework==2.0.0 plone.app.contenttypes plone.restapi plone.volto kitconcept.solr==1.0.0a5' APPLY_PROFILES: plone.app.contenttypes:plone-content,plone.restapi:default,plone.volto:default-homepage,kitconcept.solr:default,collective.solr:default CONFIGURE_PACKAGES: plone.app.contenttypes,plone.restapi,plone.volto,plone.volto.cors,kitconcept.solr,collective.solr SOLR_HOST: solr-acceptance diff --git a/acceptance/solr/etc/conf/schema.xml b/acceptance/solr/etc/conf/schema.xml index 5ed4ed4..342e6d7 100644 --- a/acceptance/solr/etc/conf/schema.xml +++ b/acceptance/solr/etc/conf/schema.xml @@ -347,7 +347,7 @@ + name="contact_email" type="string" indexed="true" stored="false" /> + 4.5 ${solr.data.dir:} - + - - - - - - - - - - + + + + + + + + + + - + - + true ignored_ @@ -180,7 +203,9 @@ - + explicit 10 @@ -189,9 +214,13 @@ - + - + solrpingquery @@ -202,7 +231,9 @@ - + none json @@ -218,7 +249,9 @@ - + suggestDictionary org.apache.solr.spelling.suggest.Suggester @@ -229,4 +262,4 @@ - \ No newline at end of file + diff --git a/news/15.feature b/news/15.feature new file mode 100644 index 0000000..a0341f8 --- /dev/null +++ b/news/15.feature @@ -0,0 +1 @@ +Add support for sidebar facet conditions @reebalazs diff --git a/src/actions/solrsearch/solrsearch.js b/src/actions/solrsearch/solrsearch.js index 214e214..1d5387d 100644 --- a/src/actions/solrsearch/solrsearch.js +++ b/src/actions/solrsearch/solrsearch.js @@ -41,10 +41,11 @@ export function solrSearchContent(url, options, subrequest = null) { !options['portal_type'] && !options['review_state']; - if (emptySearchCondition) { + if (!options.doEmptySearch && emptySearchCondition) { // If none of the conditions are specified, we don't do a server // search but return an empty result set in a shortcut. // Note that an empty `q` parameter would fail anyway. + // This behavior is configurable by the `doEmptySearch` option. return resetSolrSearchContent(subrequest); } @@ -92,8 +93,7 @@ export function solrSearchContent(url, options, subrequest = null) { '&', ) : '', - // SearchableText is inserted in all cases, if missing * will be applied - `q=${options.SearchableText !== undefined ? options.SearchableText : '*'}`, + `q=${options.SearchableText ?? ''}`, // Default batch size is injected here `rows=${ options.b_size !== undefined ? options.b_size : settings.defaultPageSize diff --git a/src/actions/solrsearch/solrsearch.test.js b/src/actions/solrsearch/solrsearch.test.js index 01ca9f6..ecd6819 100644 --- a/src/actions/solrsearch/solrsearch.test.js +++ b/src/actions/solrsearch/solrsearch.test.js @@ -28,6 +28,14 @@ describe('SOLR search action', () => { expect(action.subrequest).toBe(null); }); + it('doEmptySearch option', () => { + const url = '/blog'; + const action = solrSearchContent(url, { doEmptySearch: true }); + + expect(action.type).toEqual(SOLR_SEARCH_CONTENT); + expect(action.subrequest).toBe(null); + }); + it('if SearchableText, portal_type, review_state are all missing, no request is made and results are cleared, with subrequest', () => { const url = '/blog'; const action = solrSearchContent(url, {}, 'my-subrequest'); @@ -36,7 +44,7 @@ describe('SOLR search action', () => { expect(action.subrequest).toEqual('my-subrequest'); }); - it('if SearchableText is missing but portal_type is specified, q=* will be provided', () => { + it('if SearchableText is missing but portal_type is specified, q= will be provided', () => { const url = '/blog'; const portalType = 'Document'; const action = solrSearchContent(url, { portal_type: portalType }); @@ -44,11 +52,11 @@ describe('SOLR search action', () => { expect(action.type).toEqual(SOLR_SEARCH_CONTENT); expect(action.request.op).toEqual('get'); expect(action.request.path).toEqual( - `${url}/@solr?portal_type=${portalType}&q=*&rows=25`, + `${url}/@solr?portal_type=${portalType}&q=&rows=25`, ); }); - it('if SearchableText is missing but review_state is specified, q=* will be provided', () => { + it('if SearchableText is missing but review_state is specified, q= will be provided', () => { const url = '/blog'; const reviewState = 'published'; const action = solrSearchContent(url, { review_state: reviewState }); @@ -56,7 +64,7 @@ describe('SOLR search action', () => { expect(action.type).toEqual(SOLR_SEARCH_CONTENT); expect(action.request.op).toEqual('get'); expect(action.request.path).toEqual( - `${url}/@solr?review_state=${reviewState}&q=*&rows=25`, + `${url}/@solr?review_state=${reviewState}&q=&rows=25`, ); }); }); @@ -107,7 +115,7 @@ describe('SOLR search action', () => { expect(action.type).toEqual(SOLR_SEARCH_CONTENT); expect(action.request.op).toEqual('get'); expect(action.request.path).toEqual( - `${url}/@solr?portal_type:list=Document&portal_type:list=Image&review_state:list=published&review_state:list=private&q=*&rows=25`, + `${url}/@solr?portal_type:list=Document&portal_type:list=Image&review_state:list=published&review_state:list=private&q=&rows=25`, ); }); @@ -131,7 +139,7 @@ describe('SOLR search action', () => { expect(action.subrequest).toEqual('my-subrequest'); expect(action.request.op).toEqual('get'); expect(action.request.path).toEqual( - `${url}/@solr?portal_type=${portalType}&q=*&rows=25`, + `${url}/@solr?portal_type=${portalType}&q=&rows=25`, ); }); diff --git a/src/components/theme/SolrSearch/SearchConditions.jsx b/src/components/theme/SolrSearch/SearchConditions.jsx new file mode 100644 index 0000000..a4621de --- /dev/null +++ b/src/components/theme/SolrSearch/SearchConditions.jsx @@ -0,0 +1,127 @@ +import { SearchConditionsField } from './SearchConditionsField'; +import { useCallback, useMemo } from 'react'; +import { bToA, aToB } from './base64Helpers'; + +function isEmpty(obj) { + for (const prop in obj) { + if (Object.hasOwn(obj, prop)) { + return false; + } + } + return true; +} + +export const encodeConditionTree = (conditionTree) => + isEmpty(conditionTree) ? '' : bToA(JSON.stringify(conditionTree)); + +export const decodeConditionTree = (encoded, { catchError } = {}) => { + if (encoded) { + try { + return JSON.parse(aToB(encoded)); + } catch (exc) { + if (catchError) { + // eslint-disable-next-line no-console + console.warn( + `Ignored broken facet_conditions value [${encoded}] [${exc.message}]`, + ); + } else { + throw exc; + } + } + } + return {}; +}; + +const prunedField = (fieldName, v) => + isEmpty(v) ? undefined : { [fieldName]: v }; + +export const pruneConditionTree = (conditionTree) => + Object.entries(conditionTree).reduce( + (condition, [fieldName, field]) => ({ + ...condition, + ...prunedField(fieldName, { + ...prunedField( + 'c', + Object.entries(field.c || {}).reduce( + (fieldC, [value, checked]) => ({ + ...fieldC, + ...(checked ? { [value]: true } : undefined), + }), + {}, + ), + ), + ...(field.p ? { p: field.p } : undefined), + ...(field.m ? { m: true } : undefined), + }), + }), + {}, + ); + +export const SearchConditions = ({ + groupSelect, + facetFields, + conditionTree = {}, + setConditionTree = () => {}, +}) => { + facetFields = facetFields || []; + + const setCondition = useCallback( + (fieldName, value, checked) => + setConditionTree((conditionTree) => ({ + ...conditionTree, + [fieldName]: { + ...(conditionTree[fieldName] || {}), + c: { + ...(conditionTree[fieldName]?.c || {}), + [value]: checked, + }, + }, + })), + [setConditionTree], + ); + + const setContains = useCallback( + (fieldName, contains) => + setConditionTree((conditionTree) => ({ + ...conditionTree, + [fieldName]: { + ...(conditionTree[fieldName] || {}), + p: contains, + }, + })), + [setConditionTree], + ); + + const setMore = useCallback( + (fieldName, more) => + setConditionTree((conditionTree) => ({ + ...conditionTree, + [fieldName]: { + ...(conditionTree[fieldName] || {}), + m: more(conditionTree[fieldName]?.m), + }, + })), + [setConditionTree], + ); + + return useMemo( + () => + facetFields.length > 0 ? ( +
+ {facetFields.map(([fieldDef, values], index) => ( + + ))} +
+ ) : null, + // eslint-disable-next-line react-hooks/exhaustive-deps + [JSON.stringify(facetFields), conditionTree], + ); +}; diff --git a/src/components/theme/SolrSearch/SearchConditions.test.js b/src/components/theme/SolrSearch/SearchConditions.test.js new file mode 100644 index 0000000..5f5e402 --- /dev/null +++ b/src/components/theme/SolrSearch/SearchConditions.test.js @@ -0,0 +1,128 @@ +import { + encodeConditionTree, + decodeConditionTree, + pruneConditionTree, +} from './SearchConditions'; +import { bToA } from './base64Helpers'; + +// polyfill needed because of jsDom version used by jest +import { TextEncoder, TextDecoder } from 'util'; +Object.assign(global, { TextDecoder, TextEncoder }); + +describe('SOLR SearchConditions', () => { + describe('encodeConditionTree', () => { + it('works', () => { + expect(encodeConditionTree({ foo: true })).toEqual(bToA('{"foo":true}')); + }); + it('empty gives empty string', () => { + expect(encodeConditionTree({})).toEqual(''); + }); + it('utf', () => { + // it's important that the binary conversion works with utf, + // as standard btoa / atob does not, and would give error + // for accented characters when converted back from python. + expect(encodeConditionTree({ foo: 'Atommüll' })).toEqual( + 'eyJmb28iOiJBdG9tbcO8bGwifQ==', + ); + }); + it('utf and back', () => { + expect(encodeConditionTree({ foo: 'Atommüll' })).toEqual( + bToA('{"foo":"Atommüll"}'), + ); + }); + }); + describe('decodeConditionTree', () => { + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(jest.fn()); + }); + afterEach(() => { + // eslint-disable-next-line no-console + console.warn.mockRestore(); + }); + it('works', () => { + expect(decodeConditionTree(bToA('{"foo":true}'))).toEqual({ foo: true }); + }); + it('undefined gives empty', () => { + expect(decodeConditionTree(undefined)).toEqual({}); + }); + it('empty string gives empty', () => { + expect(decodeConditionTree('')).toEqual({}); + }); + it('errors', () => { + expect(() => decodeConditionTree('BORKEN')).toThrow(); + }); + it('errors with {catchError: false}', () => { + expect(() => + decodeConditionTree('BORKEN', { catchError: false }), + ).toThrow(); + }); + it('errors are ignored with {catchError: true}', () => { + expect(decodeConditionTree('BORKEN', { catchError: true })).toEqual({}); + // eslint-disable-next-line no-console + expect(console.warn.mock.calls).toHaveLength(1); + }); + }); + describe('pruneConditionTree', () => { + it('works', () => { + expect( + pruneConditionTree({ + foo: { c: { v1: true, v2: false }, p: 'pref1', m: true }, + bar: { c: { v3: true, v4: false }, p: '', m: false }, + }), + ).toEqual({ + foo: { c: { v1: true }, p: 'pref1', m: true }, + bar: { c: { v3: true } }, + }); + }); + it('works with empty conditions', () => { + expect( + pruneConditionTree({ + foo: { p: 'pref1', m: true }, + bar: { p: '', m: false }, + }), + ).toEqual({ + foo: { p: 'pref1', m: true }, + }); + }); + it('accepts default fields', () => { + expect( + pruneConditionTree({ + foo: { c: { v1: true, v2: true } }, + baz: { c: {}, p: '', v: false }, + }), + ).toEqual({ foo: { c: { v1: true, v2: true } } }); + }); + it('accepts empty fields', () => { + expect( + pruneConditionTree({ + foo: { c: { v1: true, v2: true } }, + baz: {}, + }), + ).toEqual({ foo: { c: { v1: true, v2: true } } }); + }); + it('prunes default fields', () => { + expect( + pruneConditionTree({ + foo: { c: { v1: true, v2: true } }, + baz: { c: {}, p: '', v: false }, + }), + ).toEqual({ foo: { c: { v1: true, v2: true } } }); + }); + it('prunes empty fields', () => { + expect( + pruneConditionTree({ + foo: { c: { v1: true, v2: true } }, + baz: { c: { v1: false } }, + }), + ).toEqual({ foo: { c: { v1: true, v2: true } } }); + }); + it('prunes empty fields without default', () => { + expect( + pruneConditionTree({ + foo: { c: { v1: true, v2: true } }, + baz: {}, + }), + ).toEqual({ foo: { c: { v1: true, v2: true } } }); + }); + }); +}); diff --git a/src/components/theme/SolrSearch/SearchConditionsField.jsx b/src/components/theme/SolrSearch/SearchConditionsField.jsx new file mode 100644 index 0000000..67ced0b --- /dev/null +++ b/src/components/theme/SolrSearch/SearchConditionsField.jsx @@ -0,0 +1,118 @@ +import { SearchConditionsValue } from './SearchConditionsValue'; +import { useCallback, useMemo } from 'react'; +import { ShowMoreIndicator } from './ShowMoreIndicator'; +import { SearchConditionsFieldSearch } from './SearchConditionsFieldSearch'; +import { useIntl } from 'react-intl'; + +// Hardcoded ATM and MUST match the value in kitconcept.solr +const limit_less = 5; + +const empty = {}; + +const getIntlMessage = (id) => ({ id, defaultMessage: id }); + +export const SearchConditionsField = ({ + fieldDef, + values: v, + conditionTree: c, + setCondition: setC, + setContains: setP, + setMore: setM, +}) => { + const intl = useIntl(); + const { name } = fieldDef; + const setCondition = useCallback( + (value, checked) => setC(name, value, checked), + [setC, name], + ); + const condition = useMemo(() => c[name]?.c || empty, [c, name]); + const setContains = (contains) => setP(name, contains); + const contains = c[name]?.p; + const setMore = (more) => setM(name, more); + const more = c[name]?.m; + + // Strip the last value which is appended to signal that + // there are more values + const hasMore = !more && v.length > limit_less; + if (hasMore) { + v = v.slice(0, limit_less); + } + + // Sorting values by count, alphabetic + const selectValues = useMemo( + () => + v.toSorted( + ([aLabel, aCount], [bLabel, bCount]) => + Math.sign(bCount - aCount) * 2 + + Math.sign(aLabel.localeCompare(bLabel)), + ), + [v], + ); + + const remainingSelected = useMemo( + () => + Object.entries( + selectValues.reduce( + (remaining, [value, _]) => ({ + ...remaining, + [value]: false, + }), + condition, + ), + ).reduce( + (remainingSelected, [name, check]) => + check ? remainingSelected.concat([name]) : remainingSelected, + [], + ), + [condition, selectValues], + ); + + const values = useMemo( + () => selectValues.concat(remainingSelected.map((value) => [value, null])), + [selectValues, remainingSelected], + ); + + return useMemo( + () => ( +
+
+ {intl.formatMessage(getIntlMessage(fieldDef.label ?? fieldDef.name))} + +
+
+ {values.map(([value, counter], index) => ( + + ))} +
+
+ {(more || hasMore) && ( + + )} +
+
+ ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(fieldDef), + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(values), + // eslint-disable-next-line react-hooks/exhaustive-deps + contains, + // eslint-disable-next-line react-hooks/exhaustive-deps + more, + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(condition), + ], + ); +}; diff --git a/src/components/theme/SolrSearch/SearchConditionsFieldSearch.jsx b/src/components/theme/SolrSearch/SearchConditionsFieldSearch.jsx new file mode 100644 index 0000000..6860599 --- /dev/null +++ b/src/components/theme/SolrSearch/SearchConditionsFieldSearch.jsx @@ -0,0 +1,24 @@ +import { useIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + search: { + id: 'Search...', + defaultMessage: 'Search...', + }, +}); + +export const SearchConditionsFieldSearch = ({ value, setValue }) => { + const intl = useIntl(); + const onChange = (evt) => setValue?.(evt.target.value); + return ( +
+ + +
+ ); +}; diff --git a/src/components/theme/SolrSearch/SearchConditionsValue.jsx b/src/components/theme/SolrSearch/SearchConditionsValue.jsx new file mode 100644 index 0000000..d460378 --- /dev/null +++ b/src/components/theme/SolrSearch/SearchConditionsValue.jsx @@ -0,0 +1,46 @@ +import { Checkbox } from 'semantic-ui-react'; +import { useCallback, useMemo } from 'react'; + +const ValueLabel = ({ value }) => ( + {value} +); + +export const SearchConditionsValue = ({ + fieldDef, + value, + counter, + condition, + setCondition: setC, +}) => { + const setCondition = useCallback((checked) => setC(value, checked), [ + setC, + value, + ]); + const checked = condition[value]; + + return useMemo( + () => + value ? ( +
+ {counter != null ? ( +
+ {counter < 100 ? counter : '...'} +
+ ) : ( +
+ )} +
+ +
+
+ setCondition(data.checked)} + checked={checked} + /> +
+
+ ) : null, + // eslint-disable-next-line react-hooks/exhaustive-deps + [JSON.stringify(fieldDef), value, counter, checked, setCondition], + ); +}; diff --git a/src/components/theme/SolrSearch/SearchQuery.jsx b/src/components/theme/SolrSearch/SearchQuery.jsx new file mode 100644 index 0000000..ac0614e --- /dev/null +++ b/src/components/theme/SolrSearch/SearchQuery.jsx @@ -0,0 +1,29 @@ +import { + decodeConditionTree, + encodeConditionTree, + pruneConditionTree, +} from './SearchConditions'; + +export const queryStateFromParams = (params) => ({ + searchword: params.SearchableText || '', + sortOn: params.sort_on || 'relevance', + sortOrder: params.sort_order || '', + groupSelect: parseInt(params.group_select) || 0, + allowLocal: (params.allow_local || '').toLowerCase() === 'true', + local: (params.local || '').toLowerCase() === 'true', + facetConditions: decodeConditionTree(params.facet_conditions, { + catchError: true, + }), +}); + +export const queryStateToParams = (queryState) => ({ + SearchableText: queryState.searchword, + sort_on: queryState.sortOn, + sort_order: queryState.sortOrder, + group_select: '' + queryState.groupSelect, + allow_local: '' + (queryState.allowLocal || false), + local: '' + (queryState.local || false), + facet_conditions: encodeConditionTree( + pruneConditionTree(queryState.facetConditions), + ), +}); diff --git a/src/components/theme/SolrSearch/SearchQuery.test.jsx b/src/components/theme/SolrSearch/SearchQuery.test.jsx new file mode 100644 index 0000000..022b684 --- /dev/null +++ b/src/components/theme/SolrSearch/SearchQuery.test.jsx @@ -0,0 +1,115 @@ +import { queryStateFromParams, queryStateToParams } from './SearchQuery'; +import { bToA } from './base64Helpers'; + +// polyfill needed because of jsDom version used by jest +import { TextEncoder, TextDecoder } from 'util'; +Object.assign(global, { TextDecoder, TextEncoder }); + +describe('SOLR SearchQuery', () => { + describe('queryStateFromParams', () => { + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(jest.fn()); + }); + afterEach(() => { + // eslint-disable-next-line no-console + console.warn.mockRestore(); + }); + it('works', () => { + expect( + queryStateFromParams({ + allow_local: 'true', + facet_conditions: bToA('{"foo":{"m":true}}'), + group_select: '2', + local: 'true', + SearchableText: 'foobar', + sort_on: 'alphabetic', + sort_order: 'reverse', + }), + ).toEqual({ + allowLocal: true, + facetConditions: { foo: { m: true } }, + groupSelect: 2, + local: true, + searchword: 'foobar', + sortOn: 'alphabetic', + sortOrder: 'reverse', + }); + }); + it('initial', () => { + expect(queryStateFromParams({})).toEqual({ + allowLocal: false, + facetConditions: {}, + groupSelect: 0, + local: false, + searchword: '', + sortOn: 'relevance', + sortOrder: '', + }); + }); + }); + describe('queryStateToParams', () => { + it('works', () => { + expect( + queryStateToParams({ + allowLocal: true, + facetConditions: { foo: { m: true } }, + groupSelect: 2, + local: true, + searchword: 'foobar', + sortOn: 'alphabetic', + sortOrder: 'reverse', + }), + ).toEqual({ + allow_local: 'true', + facet_conditions: bToA('{"foo":{"m":true}}'), + group_select: '2', + local: 'true', + SearchableText: 'foobar', + sort_on: 'alphabetic', + sort_order: 'reverse', + }); + }); + it('defaults ', () => { + expect( + queryStateToParams({ + allowLocal: false, + facetConditions: {}, + groupSelect: 0, + local: false, + searchword: '', + sortOn: 'relevance', + sortOrder: '', + }), + ).toEqual({ + allow_local: 'false', + facet_conditions: '', + group_select: '0', + local: 'false', + SearchableText: '', + sort_on: 'relevance', + sort_order: '', + }); + }); + it('prunes condition tree', () => { + expect( + queryStateToParams({ + allowLocal: true, + facetConditions: { foo: { p: 'prefix', m: false }, bar: {} }, + groupSelect: 2, + local: true, + searchword: 'foobar', + sortOn: 'alphabetic', + sortOrder: 'reverse', + }), + ).toEqual({ + allow_local: 'true', + facet_conditions: bToA('{"foo":{"p":"prefix"}}'), + group_select: '2', + local: 'true', + SearchableText: 'foobar', + sort_on: 'alphabetic', + sort_order: 'reverse', + }); + }); + }); +}); diff --git a/src/components/theme/SolrSearch/SelectLayout.jsx b/src/components/theme/SolrSearch/SelectLayout.jsx new file mode 100644 index 0000000..c4a8ec5 --- /dev/null +++ b/src/components/theme/SolrSearch/SelectLayout.jsx @@ -0,0 +1,67 @@ +import { useEffect } from 'react'; +import { useIntl, defineMessages } from 'react-intl'; +import { Icon } from '@plone/volto/components'; +import listBulletSVG from '@plone/volto/icons/list-bullet.svg'; +import gridSVG from './icons/grid.svg'; + +const messages = defineMessages({ + layoutLabel: { + // DE: Darstellung: + id: 'Layout:', + defaultMessage: 'Layout:', + }, +}); + +const filterSupportedLayouts = (layouts) => { + const supportedLayouts = (layouts?.length > 0 + ? layouts + : ['list'] + ).filter((layout) => ['list', 'grid'].includes(layout)); + if (layouts && supportedLayouts.length !== layouts.length) { + // eslint-disable-next-line no-console + console.warn( + `Unsupported layouts are ignored from list: ${JSON.stringify(layouts)}`, + ); + } + return supportedLayouts; +}; + +export const SelectLayout = ({ onChange, value, layouts }) => { + const intl = useIntl(); + const activeClass = (name) => (value === name ? 'active' : 'inactive'); + + const supportedLayouts = filterSupportedLayouts(layouts); + + useEffect(() => { + if (!supportedLayouts.includes(value)) { + // If selected layout is not allowed: select the default + onChange(supportedLayouts[0]); + } + }, [supportedLayouts, value, onChange]); + + return supportedLayouts.length >= 2 ? ( + + + {intl.formatMessage(messages.layoutLabel)} + + onChange('list')} + onKeyDown={() => {}} + role="button" + tabindex="0" + > + + + onChange('grid')} + onKeyDown={() => {}} + role="button" + tabindex="0" + > + + + + ) : null; +}; diff --git a/src/components/theme/SolrSearch/ShowMoreIndicator.jsx b/src/components/theme/SolrSearch/ShowMoreIndicator.jsx new file mode 100644 index 0000000..dd6489e --- /dev/null +++ b/src/components/theme/SolrSearch/ShowMoreIndicator.jsx @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; +import { Icon } from '@plone/volto/components'; +import downSVG from './icons/down-key-nofill.svg'; +import upSVG from './icons/up-key-nofill.svg'; +import { useIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + showMore: { + // Mehr anzeigen + id: 'Show more', + defaultMessage: 'Show more', + }, + showLess: { + // Weniger anzeigen + id: 'Show less', + defaultMessage: 'Show less', + }, +}); + +export const ShowMoreIndicator = ({ value, setValue }) => { + const intl = useIntl(); + const onClick = useCallback((evt) => setValue((v) => !v), [setValue]); + return ( + {}} + role="button" + tabindex="0" + > + {value ? ( + <> + {intl.formatMessage(messages.showLess)} + + + ) : ( + <> + {intl.formatMessage(messages.showMore)} + + + )} + + ); +}; diff --git a/src/components/theme/SolrSearch/SolrSearch.jsx b/src/components/theme/SolrSearch/SolrSearch.jsx index 0cbf5de..ebe712f 100644 --- a/src/components/theme/SolrSearch/SolrSearch.jsx +++ b/src/components/theme/SolrSearch/SolrSearch.jsx @@ -32,8 +32,11 @@ import paginationRightSVG from '@plone/volto/icons/right-key.svg'; import { searchContent } from '@plone/volto/actions'; import { DefaultResultItem } from './resultItems'; import { SelectSorting } from './SelectSorting'; +import { SelectLayout } from './SelectLayout'; import { SearchResultInfo } from './SearchResultInfo'; import { SearchTabs } from './SearchTabs'; +import { SearchConditions } from './SearchConditions'; +import { queryStateFromParams, queryStateToParams } from './SearchQuery'; const messages = defineMessages({ TypeSearchWords: { @@ -127,11 +130,9 @@ class SolrSearch extends Component { searchAction: PropTypes.func, getSearchReducer: PropTypes.func, showSearchInput: PropTypes.bool, + doEmptySearch: PropTypes.bool, contentTypeSearchResultViews: PropTypes.object, contentTypeSearchResultDefaultView: PropTypes.func, - searchableText: PropTypes.string, - subject: PropTypes.string, - path: PropTypes.string, items: PropTypes.arrayOf( PropTypes.shape({ '@id': PropTypes.string, @@ -165,20 +166,15 @@ class SolrSearch extends Component { total: 0, batching: null, searchableText: null, - subject: null, path: null, }; constructor(props) { super(props); this.state = { + ...queryStateFromParams({}), currentPage: 1, isClient: false, - active: 'relevance', - searchword: '', - groupSelect: 0, - allowLocal: false, - local: false, }; this.inputRef = createRef(); } @@ -189,17 +185,14 @@ class SolrSearch extends Component { * @returns {undefined} */ componentDidMount() { - this.doSearch(); const location = this.props.history.location; - const qoptions = qs.parse(location.search); + const params = qs.parse(location.search); + this.setState({ + ...this.queryStateFromSearchParams(), isClient: true, - searchword: this.props.searchableText || '', - active: qoptions.sort_on || 'relevance', - groupSelect: parseInt(qoptions.group_select) || 0, - allowLocal: (qoptions.allow_local || '').toLowerCase() === 'true', - local: (qoptions.local || '').toLowerCase() === 'true', }); + this.doSearch(params); // put focus to the search input field if (this.props.showSearchInput) { this.inputRef.current.focus(); @@ -213,117 +206,80 @@ class SolrSearch extends Component { * @returns {undefined} */ UNSAFE_componentWillReceiveProps = (nextProps) => { - if (this.props.location.search !== nextProps.location.search) { - this.doSearch(); + const search = nextProps.location.search; + if (this.props.location.search !== search) { + const params = qs.parse(search); + this.setState( + { + ...queryStateFromParams(params), + currentPage: 1, + }, + () => this.doSearch(params), + ); } }; + searchParams = () => qs.parse(this.props.history.location.search); + queryStateFromSearchParams = () => queryStateFromParams(this.searchParams()); + /** - * Search based on the given searchableText, subject and path. + * Search based on the given search params * @method doSearch - * @param {string} searchableText The searchable text string - * @param {string} subject The subject (tag) - * @param {string} path The path to restrict the search to + * @param {string} params The search params * @returns {undefined} */ + doSearch = (params) => { + this.props.searchContent('', { + ...params, + sort_on: params.sort_on !== 'relevance' ? params.sort_on : '', + b_start: (this.state.currentPage - 1) * config.settings.defaultPageSize, + path_prefix: getPathPrefix(window.location), + doEmptySearch: this.props.doEmptySearch, + }); + }; - doSearch = () => { - const location = this.props.history.location; - const qoptions = qs.parse(location.search); - this.setState({ currentPage: 1 }); - const options = { - ...qoptions, - path_prefix: getPathPrefix(location), - use_site_search_settings: 1, - metadata_fields: ['effective', 'UID', 'start'], - hl: 'true', - }; - this.props.searchContent('', options); + updateSearch = () => { + this.doSearch(this.searchParams()); + this.props.history.replace({ + search: qs.stringify(queryStateToParams(this.state)), + }); }; handleQueryPaginationChange = (e, { activePage }) => { - const { settings } = config; window.scrollTo(0, 0); - const qoptions = qs.parse(this.props.history.location.search); - const options = { - ...qoptions, - use_site_search_settings: 1, - metadata_fields: ['effective', 'UID', 'start'], - hl: 'true', - }; - - this.setState({ currentPage: activePage }, () => { - this.props.searchContent('', { - ...options, - b_start: (this.state.currentPage - 1) * settings.defaultPageSize, - }); - }); + this.setState({ currentPage: activePage }, () => this.updateSearch()); }; - onSortChange = (selectedOption, sort_order) => { - const qoptions = qs.parse(this.props.history.location.search); - const options = { - ...qoptions, - use_site_search_settings: 1, - metadata_fields: ['effective', 'UID', 'start'], - hl: 'true', - }; - options.sort_on = selectedOption; - options.sort_order = sort_order || 'ascending'; - if (selectedOption === 'relevance') { - delete options.sort_on; - delete options.sort_order; - } - let searchParams = qs.stringify(options); - this.setState({ currentPage: 1, active: selectedOption }, () => { - // eslint-disable-next-line no-restricted-globals - this.props.history.replace({ - search: searchParams, - }); - }); + onSortChange = (selectedOption, selectedSortOrder) => { + this.setState( + { + currentPage: 1, + sortOn: selectedOption, + sortOrder: selectedSortOrder || 'ascending', + }, + () => this.updateSearch(), + ); }; setGroupSelect = (groupSelect) => { - const qoptions = qs.parse(this.props.history.location.search); - const options = { - ...qoptions, - group_select: groupSelect, - }; - let searchParams = qs.stringify(options); - this.setState({ currentPage: 1, groupSelect }, () => { - // eslint-disable-next-line no-restricted-globals - this.props.history.replace({ - search: searchParams, - }); - }); + this.setState({ currentPage: 1, facetConditions: {}, groupSelect }, () => + this.updateSearch(), + ); }; setLocal = (local) => { - const qoptions = qs.parse(this.props.history.location.search); - const options = { - ...qoptions, - local: local, - }; - let searchParams = qs.stringify(options); - this.setState({ currentPage: 1, local }, () => { - // eslint-disable-next-line no-restricted-globals - this.props.history.replace({ - search: searchParams, - }); - }); + this.setState({ currentPage: 1, local }, () => this.updateSearch()); }; + setFacetConditions = (facetConditions) => { + this.setState({ facetConditions }, () => this.updateSearch()); + }; + + setConditionTree = (f) => + this.setFacetConditions(f(this.state.facetConditions)); + onSubmit = (event) => { - this.props.history.push({ - pathname: this.props.pathname, - search: qs.stringify({ - SearchableText: this.state.searchword, - active: this.state.active, - group_select: this.state.groupSelect, - allow_local: this.state.allowLocal || undefined, - local: this.state.local, - }), - }); + this.updateSearch(); event.preventDefault(); }; @@ -368,7 +324,7 @@ class SolrSearch extends Component { /> ) : null} {this.props.total > 0 ? (
+ { + this.setState({ layout: value }); + }} + /> { this.onSortChange(selectedOption, order); }} @@ -389,48 +352,69 @@ class SolrSearch extends Component {
) : null} -
-
- - - - - -
- {this.props.items?.map((item, index) => ( -
- {createElement(resultTypeMapper(item['@type']), { - key: item['@id'], - item, - })} -
- ))} - {this.props.batching && ( -
- , - icon: true, - 'aria-disabled': !this.props.batching.prev, - className: !this.props.batching.prev ? 'disabled' : null, - }} - nextItem={{ - content: , - icon: true, - 'aria-disabled': !this.props.batching.next, - className: !this.props.batching.next ? 'disabled' : null, - }} - /> +
+ + + + + + +
+
+ {this.props.items?.map((item, index) => ( +
+ {createElement(resultTypeMapper(item['@type']), { + key: item['@id'], + item, + layout: this.state.layout, + })} +
+ ))}
- )} -
+ {this.props.batching && + this.props.total / settings.defaultPageSize > 1 && ( +
+ + ), + icon: true, + 'aria-disabled': !this.props.batching.prev, + className: !this.props.batching.prev + ? 'disabled' + : null, + }} + nextItem={{ + content: ( + + ), + icon: true, + 'aria-disabled': !this.props.batching.next, + className: !this.props.batching.next + ? 'disabled' + : null, + }} + /> +
+ )} +
+
{this.state.isClient && ( @@ -481,7 +465,6 @@ export const __test__ = connect( loading, batching, intl: state.intl, - searchableText: qs.parse(props.history.location.search).SearchableText, pathname: props.history.location.pathname, contentTypeSearchResultViews: contentTypeSearchResultViewsWithDefault( props.contentTypeSearchResultViews, @@ -504,6 +487,8 @@ export default compose( const { items, facetGroups, + facetFields, + layouts, total, loaded, loading, @@ -512,12 +497,13 @@ export default compose( return { items, facetGroups, + facetFields, + layouts, total, loaded, loading, batching, intl: state.intl, - searchableText: qs.parse(props.history.location.search).SearchableText, pathname: props.history.location.pathname, contentTypeSearchResultViews: contentTypeSearchResultViewsWithDefault( props.contentTypeSearchResultViews, @@ -539,7 +525,7 @@ export default compose( dispatch( searchActionWithDefault(searchAction)('', { ...qs.parse(location.search), - path_prefix: getPathPrefix(location), + path_prefix: getPathPrefix(window.location), use_site_search_settings: 1, metadata_fields: ['effective', 'UID', 'start'], hl: 'true', diff --git a/src/components/theme/SolrSearch/base64Helpers.js b/src/components/theme/SolrSearch/base64Helpers.js new file mode 100644 index 0000000..c33c576 --- /dev/null +++ b/src/components/theme/SolrSearch/base64Helpers.js @@ -0,0 +1,17 @@ +function bytesToBase64(bytes) { + const binString = String.fromCodePoint(...bytes); + return btoa(binString); +} + +function base64ToBytes(base64) { + const binString = atob(base64); + return Uint8Array.from(binString, (m) => m.codePointAt(0)); +} + +// Usage +// bytesToBase64(new TextEncoder().encode("a Ā 𐀀 文 🦄")); // "YSDEgCDwkICAIOaWhyDwn6aE" +// new TextDecoder().decode(base64ToBytes("YSDEgCDwkICAIOaWhyDwn6aE")); // "a Ā 𐀀 文 🦄" + +export const bToA = (base64) => bytesToBase64(new TextEncoder().encode(base64)); + +export const aToB = (utf) => new TextDecoder().decode(base64ToBytes(utf)); diff --git a/src/components/theme/SolrSearch/base64Helpers.test.js b/src/components/theme/SolrSearch/base64Helpers.test.js new file mode 100644 index 0000000..4c3f064 --- /dev/null +++ b/src/components/theme/SolrSearch/base64Helpers.test.js @@ -0,0 +1,18 @@ +import { aToB, bToA } from './base64Helpers'; + +// polyfill needed because of jsDom version used by jest +import { TextEncoder, TextDecoder } from 'util'; +Object.assign(global, { TextDecoder, TextEncoder }); + +describe('base64helpers', () => { + describe('bToA', () => { + it('works', () => { + expect(bToA('a Ā 𐀀 文 🦄')).toEqual('YSDEgCDwkICAIOaWhyDwn6aE'); + }); + }); + describe('aToB', () => { + it('works', () => { + expect(aToB('YSDEgCDwkICAIOaWhyDwn6aE')).toEqual('a Ā 𐀀 文 🦄'); + }); + }); +}); diff --git a/src/components/theme/SolrSearch/icons/fallback-avatar.svg b/src/components/theme/SolrSearch/icons/fallback-avatar.svg new file mode 100644 index 0000000..65b4963 --- /dev/null +++ b/src/components/theme/SolrSearch/icons/fallback-avatar.svg @@ -0,0 +1,25 @@ + + + Group 13 Copy 2 + + + + + + + + + + + + + + + + + + diff --git a/src/components/theme/SolrSearch/icons/grid.svg b/src/components/theme/SolrSearch/icons/grid.svg new file mode 100644 index 0000000..a95cee9 --- /dev/null +++ b/src/components/theme/SolrSearch/icons/grid.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/components/theme/SolrSearch/icons/location.svg b/src/components/theme/SolrSearch/icons/location.svg index ba6127d..26d44f9 100644 --- a/src/components/theme/SolrSearch/icons/location.svg +++ b/src/components/theme/SolrSearch/icons/location.svg @@ -1,11 +1,15 @@ - - Group 10 Copy 2 - - - - - - + + Group 10 Copy 2 + + + + + - \ No newline at end of file + + diff --git a/src/components/theme/SolrSearch/icons/phone.svg b/src/components/theme/SolrSearch/icons/phone.svg new file mode 100644 index 0000000..f28644c --- /dev/null +++ b/src/components/theme/SolrSearch/icons/phone.svg @@ -0,0 +1,9 @@ + + + Fill 1 Copy + + + + + + diff --git a/src/components/theme/SolrSearch/resultItems/ImageResultItem.jsx b/src/components/theme/SolrSearch/resultItems/ImageResultItem.jsx index 97a6636..ac49892 100644 --- a/src/components/theme/SolrSearch/resultItems/ImageResultItem.jsx +++ b/src/components/theme/SolrSearch/resultItems/ImageResultItem.jsx @@ -7,7 +7,7 @@ import ResultItemPreviewImage from './helpers/ResultItemPreviewImage'; import IconForContentType from './helpers/IconForContentType'; const ImageResultItem = ({ item }) => ( -
+
{/* */} diff --git a/src/components/theme/SolrSearch/resultItems/PersonResultItem.jsx b/src/components/theme/SolrSearch/resultItems/PersonResultItem.jsx new file mode 100644 index 0000000..d9908bb --- /dev/null +++ b/src/components/theme/SolrSearch/resultItems/PersonResultItem.jsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import ResultItemPreviewImage from './helpers/ResultItemPreviewImage'; +import phoneSVG from '../icons/phone.svg'; +import emailSVG from '@plone/volto/icons/email.svg'; +import fallbackAvatarSVG from '../icons/fallback-avatar.svg'; +import { Icon } from '@plone/volto/components'; // ?? +import config from '@plone/volto/registry'; + +const PersonResultItem = ({ item }) => ( +
+
+ {/* + + */} +
+ + + + +
+
+

{item['@id']}

+

+ + {item.title} + +

+ {item?.highlighting && item.highlighting.length > 0 ? ( +
+ + {' ...'} +
+ ) : ( +
+ + {item.description + ? item.description.length > 200 + ? item.description.slice(0, 199) + '...' + : item.description + : ''} + +
+ )} +
+ {item.extras.contact_phone ? ( + + + {item.extras.contact_phone} + + ) : null} + + {item.extras.contact_email ? ( + + + {item.extras.contact_email} + + ) : null} +
+
+
+
+
+); + +export default PersonResultItem; diff --git a/src/components/theme/SolrSearch/resultItems/helpers/ImageType.jsx b/src/components/theme/SolrSearch/resultItems/helpers/ImageType.jsx index 175d1af..03de270 100644 --- a/src/components/theme/SolrSearch/resultItems/helpers/ImageType.jsx +++ b/src/components/theme/SolrSearch/resultItems/helpers/ImageType.jsx @@ -12,6 +12,8 @@ export const getImageType = (mimeType) => { } }; -const ImageType = ({ mimeType }) => getImageType(mimeType); +const ImageType = ({ mimeType }) => ( + {getImageType(mimeType)} +); export default ImageType; diff --git a/src/components/theme/SolrSearch/resultItems/helpers/ResultItemPreviewImage.jsx b/src/components/theme/SolrSearch/resultItems/helpers/ResultItemPreviewImage.jsx index 6c431d8..8bd2d81 100644 --- a/src/components/theme/SolrSearch/resultItems/helpers/ResultItemPreviewImage.jsx +++ b/src/components/theme/SolrSearch/resultItems/helpers/ResultItemPreviewImage.jsx @@ -19,10 +19,17 @@ export const previewImageContent = ({ }; }; -const ResultItemPreviewImage = ({ item, ...rest }) => { +const LinkToImage = ({ item, children }) => ( + {children} +); + +const EmptyWrapper = ({ item, children }) => <>{children}; + +const ResultItemPreviewImage = ({ item, Wrapper = LinkToImage, ...rest }) => { const content = previewImageContent(item); + Wrapper = Wrapper !== null ? Wrapper : EmptyWrapper; if (content.image_scales) { - // Show the link also conditionally. + // Show the wrapper also conditionally. let Image; if (!config.settings.contentTypeSearchResultAlwaysUseLegacyImage) { Image = config.getComponent({ name: 'Image' }).component; @@ -37,7 +44,7 @@ const ResultItemPreviewImage = ({ item, ...rest }) => { ); } return ( - + { height="125" {...rest} /> - + ); } return ( - + {item.title} { height="125" {...rest} /> - + ); } else { return null; diff --git a/src/components/theme/SolrSearch/resultItems/helpers/ResultItemPreviewImage.test.jsx b/src/components/theme/SolrSearch/resultItems/helpers/ResultItemPreviewImage.test.jsx index 6961fc9..9dfff9f 100644 --- a/src/components/theme/SolrSearch/resultItems/helpers/ResultItemPreviewImage.test.jsx +++ b/src/components/theme/SolrSearch/resultItems/helpers/ResultItemPreviewImage.test.jsx @@ -166,6 +166,29 @@ describe('ResultItemPreviewImage', () => { expect(rendered).toMatchSnapshot(); }); + test('Wrapper=null removes link', () => { + const component = create( + + + , + ); + const rendered = component.toJSON(); + expect(rendered).toMatchSnapshot(); + }); + + test('Custom Wrapper', () => { + const Wrapper = ({ item, children }) => ( +
{children}
+ ); + const component = create( + + + , + ); + const rendered = component.toJSON(); + expect(rendered).toMatchSnapshot(); + }); + describe('legacy image', () => { let origComponent; let origContentTypeSearchResultAlwaysUseLegacyImage; @@ -233,5 +256,28 @@ describe('ResultItemPreviewImage', () => { ).toThrow(); mockError.mockRestore(); }); + + test('Wrapper=null removes link', () => { + const component = create( + + + , + ); + const rendered = component.toJSON(); + expect(rendered).toMatchSnapshot(); + }); + + test('Custom Wrapper', () => { + const Wrapper = ({ item, children }) => ( +
{children}
+ ); + const component = create( + + + , + ); + const rendered = component.toJSON(); + expect(rendered).toMatchSnapshot(); + }); }); }); diff --git a/src/components/theme/SolrSearch/resultItems/helpers/__snapshots__/ResultItemPreviewImage.test.jsx.snap b/src/components/theme/SolrSearch/resultItems/helpers/__snapshots__/ResultItemPreviewImage.test.jsx.snap index 5d6d1ce..f751287 100644 --- a/src/components/theme/SolrSearch/resultItems/helpers/__snapshots__/ResultItemPreviewImage.test.jsx.snap +++ b/src/components/theme/SolrSearch/resultItems/helpers/__snapshots__/ResultItemPreviewImage.test.jsx.snap @@ -1,5 +1,333 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`ResultItemPreviewImage Custom Wrapper 1`] = ` +
+ +
+`; + +exports[`ResultItemPreviewImage Wrapper=null removes link 1`] = ` + +`; + +exports[`ResultItemPreviewImage legacy image Custom Wrapper 1`] = ` +
+ +
+`; + +exports[`ResultItemPreviewImage legacy image Wrapper=null removes link 1`] = ` + +`; + exports[`ResultItemPreviewImage legacy image if contentTypeSearchResultAlwaysUseLegacyImage is true 1`] = ` { Event: searchResultItems.EventResultItem, Image: searchResultItems.ImageResultItem, 'News Item': searchResultItems.NewsItemResultItem, + Person: searchResultItems.PersonResultItem, }; config.views.contentTypeSearchResultDefaultView = searchResultItems.DefaultResultItem; @@ -55,6 +56,7 @@ const applyConfig = (config) => { contentTypeSearchResultDefaultView: config.views.contentTypeSearchResultDefaultView, showSearchInput: true, + doEmptySearch: false, }; // Wrapper for a customized Solr Search component that can be used @@ -65,6 +67,8 @@ const applyConfig = (config) => { config.addonReducers = { ...config.addonReducers, ...reducers }; config.addonRoutes = [...config.addonRoutes, ...routes(config)]; + config.settings.baseColor = '#f0f0f0'; // needed as svg cannot use css + return config; }; diff --git a/src/reducers/solrsearch/solrsearch.js b/src/reducers/solrsearch/solrsearch.js index 8d18fe2..d3d2058 100644 --- a/src/reducers/solrsearch/solrsearch.js +++ b/src/reducers/solrsearch/solrsearch.js @@ -14,6 +14,8 @@ const initialState = { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: false, @@ -103,6 +105,8 @@ export default function search(state = initialState, action = {}) { items: [], total: 0, facetGroups: [], + facetFields: [], + layouts: [], batching: {}, }), error: null, @@ -130,6 +134,8 @@ export default function search(state = initialState, action = {}) { ), total: action.result.response.numFound, facetGroups: action.result.facet_groups || [], + facetFields: action.result.facet_fields || [], + layouts: action.result.layouts || [], loaded: true, loading: false, batching: getBatching(action), @@ -152,6 +158,8 @@ export default function search(state = initialState, action = {}) { items: [], total: 0, facetGroups: [], + facetFields: [], + layouts: [], loading: false, loaded: false, batching: {}, @@ -180,6 +188,8 @@ export default function search(state = initialState, action = {}) { items: [], total: 0, facetGroups: [], + facetFields: [], + layouts: [], loading: false, loaded: false, batching: {}, diff --git a/src/reducers/solrsearch/solrsearch.test.js b/src/reducers/solrsearch/solrsearch.test.js index 1a2da84..a72a1e4 100644 --- a/src/reducers/solrsearch/solrsearch.test.js +++ b/src/reducers/solrsearch/solrsearch.test.js @@ -11,6 +11,8 @@ describe('SOLR search reducer', () => { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: false, @@ -28,6 +30,8 @@ describe('SOLR search reducer', () => { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: true, @@ -73,6 +77,8 @@ describe('SOLR search reducer', () => { }, ], facetGroups: [], + facetFields: [], + layouts: [], total: 1, loaded: true, loading: false, @@ -91,6 +97,8 @@ describe('SOLR search reducer', () => { error: 'failed', items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: false, @@ -108,6 +116,8 @@ describe('SOLR search reducer', () => { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: false, @@ -126,6 +136,8 @@ describe('SOLR search reducer', () => { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: false, @@ -135,6 +147,8 @@ describe('SOLR search reducer', () => { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: true, @@ -153,6 +167,8 @@ describe('SOLR search reducer', () => { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: true, @@ -182,6 +198,25 @@ describe('SOLR search reducer', () => { ['Three', 3], ['Four', 4], ], + facet_fields: [ + [ + 'fooField', + [ + [null, 21], + ['twoFoo', 2], + ['oneFoo', 1], + ], + ], + [ + 'barField', + [ + [null, 21], + ['twoBar', 2], + ['oneBar', 1], + ], + ], + ], + layouts: [undefined, undefined, ['list', 'grid'], ['grid']], }, }, ), @@ -210,6 +245,25 @@ describe('SOLR search reducer', () => { ['Three', 3], ['Four', 4], ], + facetFields: [ + [ + 'fooField', + [ + [null, 21], + ['twoFoo', 2], + ['oneFoo', 1], + ], + ], + [ + 'barField', + [ + [null, 21], + ['twoBar', 2], + ['oneBar', 1], + ], + ], + ], + layouts: [undefined, undefined, ['list', 'grid'], ['grid']], total: 1, loaded: true, loading: false, @@ -228,6 +282,8 @@ describe('SOLR search reducer', () => { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: true, @@ -247,6 +303,8 @@ describe('SOLR search reducer', () => { error: 'failed', items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: false, @@ -265,6 +323,8 @@ describe('SOLR search reducer', () => { error: null, items: ['random'], facetGroups: [], + facetFields: [], + layouts: [], total: 1, loaded: true, loading: false, @@ -307,6 +367,8 @@ describe('SOLR search reducer', () => { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: false, @@ -348,6 +410,8 @@ describe('SOLR search reducer', () => { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: false, @@ -377,6 +441,8 @@ describe('SOLR search reducer', () => { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: false, @@ -400,6 +466,8 @@ describe('SOLR search reducer', () => { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: false, @@ -424,6 +492,8 @@ describe('SOLR search reducer', () => { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: false, @@ -452,6 +522,8 @@ describe('SOLR search reducer', () => { error: null, items: [], facetGroups: [], + facetFields: [], + layouts: [], total: 0, loaded: false, loading: false, diff --git a/src/theme/solrsearch.less b/src/theme/solrsearch.less index 88672d8..e7d81c4 100644 --- a/src/theme/solrsearch.less +++ b/src/theme/solrsearch.less @@ -37,13 +37,32 @@ .sorting-bar { display: flex; + width: 100%; align-items: baseline; - justify-content: space-between; + justify-content: flex-end; + // justify-content: space-between; margin-bottom: 1em; - color: @solr-grey; + + .layout-field { + padding: 0 2px 0 0; + border-width: 0 1px 0 0; + border-style: solid; + .layout-label { + padding: 0 8px 0 0; + } + .layout-selector { + padding: 0 6px 0 0; + vertical-align: text-top; + &:not(.active) { + color: @solr-grey; + // color: red; + cursor: pointer; + } + } + } .sort-field { - margin-left: auto; + margin-left: 16px; .sort-by { padding-right: 0.3em; @@ -77,8 +96,29 @@ } } + .dimmer { + // Fix semantic-ui dimmer. Without this, + // it does not cover the entire height, of the viewport, + // which becomes noticable when you scroll after the dimmer is active. + position: fixed !important; + } + #content-core { + display: flex; + flex-direction: column; + justify-content: space-between; margin-top: 1.5em; + &.layout-grid .search-items { + display: grid; + flex: 0 0 none; + column-gap: 16px; + grid-template-columns: 50% 50%; + grid-template-rows: auto; + } + + .search-footer { + flex: 0 0 none; + } article.tileItem { // margin-bottom: 30px; @@ -110,8 +150,7 @@ img.previewImage { width: 250px; - margin-top: 0.5em; - margin-right: 0.5em; + margin: 0.5em; aspect-ratio: 2; float: right; object-fit: cover; @@ -199,6 +238,92 @@ } } +// Facet conditions + +.section-search, +.section-\@\@search { + .searchContentWrapper { + display: flex; + section#content-core { + flex: 1 1 auto; + } + .dimmer { + position: fixed !important; + top: 50% !important; + left: 50% !important; + width: 100px; + height: 100px; + border: 1px solid lightgray; + opacity: 0.9; + } + .searchConditions { + width: 220px; + flex: 0 0 none; + margin-top: 2em; + margin-right: 0.5em; + .searchConditionsField { + padding-bottom: 1rem; + margin-bottom: 2em; + .searchConditionsFieldHeader { + margin-right: 1em; + font-weight: bold; + .searchConditionsFieldSearch { + width: 100%; + margin-top: 0.5rem; + margin-bottom: 1rem; + input { + padding-left: 0.5rem; + background: @solr-search-facet-tab-inactive-color; + } + } + } + .searchConditionsFieldContent { + overflow: scroll; + max-height: 11em; + padding-right: 1em; + } + .searchConditionsFieldFooter { + border-bottom: 1px; + border-bottom-style: solid; + margin-top: 0.5em; + margin-right: 1em; + text-align: right; + .showMoreIndicator { + color: @solr-color-interactive-blue; + font-size: 13px; + svg { + margin-left: 0.5rem; + fill: @solr-color-interactive-blue; + vertical-align: middle; + } + } + } + } + .searchConditionsValue { + display: flex; + margin-bottom: 0.5em; + font-size: 13px; + gap: 0.5em; + .searchConditionsLabel { + overflow: hidden; + flex: 1 1 auto; + text-overflow: ellipsis; + white-space: nowrap; + } + .searchConditionsCounter { + width: 2em; + flex: 0 0 auto; + font-weight: bold; + text-align: center; + } + .searchConditionsCheckbox { + flex: 0 0 none; + } + } + } + } +} + // Content types .section-search, @@ -212,33 +337,57 @@ font-size: 0.9rem; } - .memberResultItem { - @baseColor: #006b96; - + .personResultItem { .itemWrapper { display: flex; .itemImageWrapper { + position: relative; flex: none; margin-right: 20px; + + img.profileImage { + position: absolute; + left: 0; + width: 64px; + aspect-ratio: 1; + border-radius: 50%; + object-fit: cover; + } } .itemContent { flex: 1 1 auto; - .itemPhoneEmailBar { - color: @solr-grey; - - .itemPhone { - margin-right: 16px; + color: @solr-grey; + + .itemField { + padding-right: 12px; + border-width: 0 1px 0 0; + border-style: solid; + margin-right: 12px; + line-height: 2; + white-space: nowrap; + &:last-child { + padding-right: 0; + border-width: 0; } + --overflow-x: hide; + } - .itemIcon { - margin-right: 4px; - vertical-align: text-bottom; - } + .itemIcon { + margin-right: 4px; + vertical-align: text-bottom; } } } } + + .imageResultItem { + .tileFooter { + .imageType { + margin-right: 8px; + } + } + } }