Search

<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
  }
}
  • Content:
    /** @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);
    }
    
  • URL: /components/raw/search/search.js
  • Filesystem Path: src/components/organisms/search/search.js
  • Size: 4.2 KB
  • Content:
    @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;
      }
    }
  • URL: /components/raw/search/search.scss
  • Filesystem Path: src/components/organisms/search/search.scss
  • Size: 1.9 KB

Search

Headless Solr Search, where the backend provide us a json REST-API and Frontend does all the rendering part via preact (not react!).

SetUp

to setup the frontend part for the search you have to follow the steps:

1 adding preact package

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.

2. Tell eslint to be able to handle preact

you need to add some code to the .eslint.js, so eslint can handle preact

parserOptions

parserOptions: {
    ecmaVersion: 2018,
    sourceType: 'module',
    ecmaFeatures: {
      jsx: true,
    },
  },

extends

If there is already an extends with optins, just add it to the list.

extends: ['eslint-config-developit']

rules

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,
},

settings

add this to the settings. If there is already settings, just add ‘react’ settings to the list.

settings: {
  'react': {
    'version': 'detect',
    'pragma': 'h'
  },
}

Babel

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',
  }],
],

paths

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',

Search folder

copy the whole search folder. Its the folder where you are reading this readme file right now ;)

Folder structure

├── search
  ├── components
  ├── helpers
  ├── modules
  ├── scss
  ├── store
  ├── config.js
  ├── README.md
  ├── search.config.js
  ├── search.js
  ├── search.nunj
  ├── search.scss
  • component: We´re splitting the search page into little components, this is the place where we save them
  • helpers: functions that can be re-used, should live in here
  • modules: Some pages have multiple search page. In case that the mark and/or the logic differs, you can create multiple “pages” aka modules in here
  • scss (search.scss): all the stylings lives here
  • store: we are using a store called “zustand” (similiar to redux), its lightweight and easy to use. We use it mainly to store responses. But you might find yourself needing to store some more UI stuff than that. You are free to add more store´s and use them as you like
  • config.js: global variables for the search should be store in here
  • READM.md: this file
  • search.config.js: the search.config is neede for fractal
  • search.js: the file where we init the search-app
  • search.nunj: the initial markup of the search page

Search Logic

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.

Search Rendering

You can change the markup as you like. Of course, make sure not to break the search-logic.