<div class="search" id="search-app" data-endpoint="/api/_dummies/search/global.json" data-search-type="global" data-show-main-input="true" data-initial-filters="[]" data-restriction-filters="[]" data-show-count="true" data-show-facets="true" data-labels='{"search":{"unavailable":"Die Suche ist im Moment nicht verfügbar.","failed":"Ihre Suchanfrage konnte nicht verarbeitet werden.","placeholder":"Suchen Sie in unseren Themen","filter":"Filtern nach","filterActive":"Aktive Filter","submit":"Suche","header":"Mobilitätsprogramm-suche","subheader":"In venenatis bibendum blandit neque. Vulputate nunc neque neque sed diam sed urna.","searchField":"Suchbegriff","clearSearchField":"Suchbegriff leeren"},"sorting":{"sortBy":"Sortieren nach","sortByAsc":"Aufsteigend","sortByDesc":"Absteigend"},"columns":{"title":"TITLE","date":"DATE","goal":"GOAL/CONTENT","organizer":"ORGANIZER","type":"TYPE"},"document":{"category":"Kategorie","date":"Datum","time":"Uhrzeit","clock":"Uhr","location":"Ort","email":"E-Mail","phone":"Telefon","contact":"Kontakt","openingHours":"Öffnungszeiten","externalLink":"Externer Link"},"faceting":{"removeAllFilters":"Alle Filter zurücksetzen","filterClose":"Filter schließen","filterOpen":"Filter öffnen","filterFor":"Filtern nach","filterHits":"Treffer","filterActive":"Filter aktiv"},"results":{"heading":"Ergebnisse:","multiple":"Ergebnisse","single":"Ergebnis","nothingFound":"Keine Ergebnisse"},"pagination":{"loadMore":"Mehr laden"}}'></div>
#search-app.search()&attributes(attr)
{
"text": "Suche",
"attr": {
"data-endpoint": null,
"data-search-type": null,
"data-show-main-input": null,
"data-initial-filters": null,
"data-restriction-filters": null,
"data-show-count": null,
"data-show-facets": null,
"data-labels": null
}
}
/** @jsx h */
import 'preact/debug';
import { render } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import {
$search,
labels,
endpoint,
initialFilters,
restrictionFilters,
searchType,
} from './config';
import { searchStore } from './store/store';
import SearchGlobal from './modules/SearchGlobal';
import { fetchSearchData } from './helpers/helpers';
import Loading from './components/Loading';
if ($search) {
console.log($search);
const App = () => {
// set default states
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
const [searchData, setSearchData] = useState({});
const [pluginNamespace, setPluginNamespace] = useState('');
const setSearchJson = searchStore(state => state.setSearchJson);
function getSearchData(url = endpoint) {
// get search data
fetchSearchData(url)
.then(({ data }) => {
const searchData = data.content.colPos0[0].content.data;
setSearchJson(searchData);
setSearchData(searchData);
setPluginNamespace(searchData.form.pluginNamespace);
setIsLoading(false);
})
.catch((error) => {
console.error('Search Error:', error);
setIsLoading(false);
setIsError(true);
});
}
function handleUnload() {
// save url, searchData and scrollPosition when user leaves the page
if (window.location.hash) {
const { searchJson } = searchStore.getState();
const url = window.location.href;
const scrollPos = window.scrollY || document.documentElement.scrollTop;
const obj = {
url,
searchJson,
scrollPos,
};
sessionStorage.setItem(`search-${url}`, JSON.stringify(obj));
}
}
useEffect(() => {
/* if user opens the page and the searchParams are already in the sessionStorage (e.g user navigated back via browser back button)
* show the page with the same data and scrollPosition from before
* Else If, url has searchParams but its not in the sessionStorage, trigger ajax with the given url
* Else If, section has initial filters or restriction filters, we want to add them to the intial search page query
* Else, we fetch the search data via ajax
*/
const url = window.location.href;
const data = JSON.parse(sessionStorage.getItem(`search-${url}`));
if (data && data.url === url) {
setSearchJson(data.searchJson);
setPluginNamespace(data.searchJson.form.pluginNamespace);
setSearchData(data.searchJson);
setIsLoading(false);
setTimeout(() => window.scrollTo(0, data.scrollPos), 200);
} else if (window.location.hash) {
const hash = window.location.hash.substring(1);
getSearchData(`${endpoint}?${hash}`);
} else if (initialFilters.length || restrictionFilters.length) {
let searchEndpoint = endpoint;
const jonasFilters = [...initialFilters, ...restrictionFilters];
jonasFilters.forEach((jonasFilter, index) => {
searchEndpoint += `${
index === 0 ? '?' : '&'
}tx_solr[filter][]=${jonasFilter}`;
});
getSearchData(encodeURI(searchEndpoint));
} else {
getSearchData();
}
/*
* save actual url and the search json in sessionStorage when user leaves the page,
* so if the user leave the page (in the same tab) and comes back (with browser back button),
* we can show him the last result and scoll the user to the last position
* */
window.addEventListener('beforeunload', handleUnload);
// cleanup event on unMount
return () => {
window.removeEventListener('beforeunload', handleUnload);
};
}, []);
// show loading animation, while fetching
if (isLoading) return <Loading />;
if (isError) return <p>{labels.search.unavailable}</p>;
return (
<SearchGlobal
initialSearchData={searchData}
pluginNamespace={pluginNamespace}
restrictionFilters={restrictionFilters}
/>
);
};
$search && render(<App />, $search);
}
@import './scss/loading';
.search {
padding: 0 1.5rem;
position: relative;
z-index: 1;
background-color: $color-gray-xxlight;
padding-top: 2rem;
padding-bottom: 1rem;
@include mq($from: m) {
padding: 0 3rem;
padding-top: 4rem;
padding-bottom: 2rem;
}
@include mq($from: xl) {
padding: 0 9rem;
padding-top: 8rem;
padding-bottom: 6rem;
}
.sr-only {
clip: rect(0,0,0,0);
border: 0;
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
}
.search__inner {
margin: 0 auto;
max-width: 61rem;
@include mq($from: l) {
max-width: 127rem;
}
}
.search__text {
padding-top: 2rem;
}
.search__selectfields {
padding-top: 6rem;
padding-left: 0;
list-style-type: none;
display: flex;
flex-direction: column;
gap: 4rem;
.ts-select {
padding-top: 2rem;
}
}
.search__button {
display: flex;
justify-content: flex-end;
padding-top: 6rem;
}
.search__results {
padding-top: 14rem;
}
.search__results-items {
list-style: none;
display: grid;
grid-template-columns: 1fr;
gap: 6rem;
padding-top: 6rem;
padding-left: 0;
@include mq($from: 550px) {
grid-template-columns: 1fr 1fr;
}
@include mq($from: l) {
grid-template-columns: 1fr 1fr 1fr;
}
@include mq($from: header) {
grid-template-columns: 1fr 1fr 1fr 1fr;
}
.microcard {
width: 100%;
height: 100%;
@include mq($from: m) {
height: 40rem;
}
}
.microcard__inner {
justify-content: space-between;
}
.microcard__image {
flex: none;
aspect-ratio: 1/1;
max-height: 25rem;
}
.microcard__image__img {
max-height: 25rem;
}
.microcard__headline {
margin-bottom: 0;
.headline {
min-height: auto;
}
}
.microcard__icons {
margin-top: 0;
}
}
Headless Solr Search, where the backend provide us a json REST-API and Frontend does all the rendering part via preact (not react!).
to setup the frontend part for the search you have to follow the steps:
You need to add preact (and all necessary dependencies) with those two commands: yarn add preact zustand
and yarn add @babel/plugin-transform-react-jsx babel-plugin-jsx-pragmatic eslint-config-developit eslint-plugin-jest -D
.
you need to add some code to the .eslint.js, so eslint can handle preact
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
If there is already an extends with optins, just add it to the list.
extends: ['eslint-config-developit']
adding some preact rules. If there are already rules, just append them into the list
rules: {
'no-console': 0,
'no-else-return': 0,
'no-lonely-if': 0,
'no-self-assign': 0,
'react/jsx-indent-props': 0,
'react/no-danger': 0,
},
add this to the settings. If there is already settings
, just add ‘react’ settings to the list.
settings: {
'react': {
'version': 'detect',
'pragma': 'h'
},
}
Tell babel to handle preact as well, by adding this plugin list.
If there is already a plugins
list, just append the items to the array.
plugins: [
["@babel/plugin-transform-react-jsx", {
"pragma": "h",
"pragmaFrag": "Fragment",
}],
['babel-plugin-jsx-pragmatic', {
module: 'preact',
import: 'h',
export: 'h',
}],
],
In the frontend/config/paths.js
, under aliases
you need to add
react: 'preact/compat',
'react-dom/test-utils': 'preact/test-utils',
'react-dom': 'preact/compat',
copy the whole search folder. Its the folder where you are reading this readme file right now ;)
├── search
├── components
├── helpers
├── modules
├── scss
├── store
├── config.js
├── README.md
├── search.config.js
├── search.js
├── search.nunj
├── search.scss
The search logic is pretty simple: OnSubmit, we are sending a POST request to the Rest-API, sending the formData with it. We will get the same json back with new data.
Make sure the inputfields has the correct name, which the solr needed handle the request correctly.
Examples:
filter name should be name={pluginNamespace + '[filter][]'}
sorting name should be name={pluginNamespace + '[sort]'}
searchfield (inputfield) name should be name={pluginNamespace + '[q]'}
loadmore inputfield name should be name={pluginNamespace + '[page]'}
and so on.. If you are unsure when to use which name, ask the TYPO3 developer.
The default pluginNamespace is tx_solr
.
You can change the markup as you like. Of course, make sure not to break the search-logic.