├── .babelrc ├── .gitignore ├── .storybook ├── addons.js └── config.js ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── ActiveFilters.js ├── CustomWidget.js ├── Elasticsearch.js ├── Facet.js ├── Listener.js ├── Pagination.js ├── QueryBuilder │ ├── QueryBuilder.js │ ├── Rule.js │ └── utils.js ├── Results.js ├── SearchBox.js ├── SharedContextProvider.js ├── index.js ├── style.css └── utils.js ├── stories ├── active-filters.stories.js ├── facet.stories.js ├── index.stories.js ├── pagination.stories.js ├── query-builder.stories.js ├── results.stories.js ├── searchbox.stories.js ├── shared.stories.js └── utils.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "useBuiltIns": "usage", 7 | "corejs": { "version": 3, "proposals": true } 8 | } 9 | ], 10 | "@babel/preset-react" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.example 4 | 5 | # production 6 | dist/main.js 7 | /example/build 8 | 9 | # misc 10 | .DS_Store 11 | .env.local 12 | .env.development.local 13 | .env.test.local 14 | .env.production.local 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register'; 2 | import '@storybook/addon-links/register'; 3 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | // automatically import all files ending in *.stories.js 4 | const req = require.context('../stories', true, /\.stories\.js$/); 5 | function loadStories() { 6 | req.keys().forEach(filename => req(filename)); 7 | } 8 | 9 | configure(loadStories, module); 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-present beta.gouv.fr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Elasticsearch 2 | 3 | [![Version](https://img.shields.io/npm/v/react-elasticsearch.svg)](https://npmjs.org/package/react-elasticsearch) 4 | [![Downloads](https://img.shields.io/npm/dt/react-elasticsearch.svg)](https://npmjs.org/package/react-elasticsearch) 5 | [![License](https://img.shields.io/npm/l/react-elasticsearch.svg)](https://github.com/rap2hpoutre/react-elasticsearch/blob/master/package.json) 6 | 7 | UI components for React + Elasticsearch. Create search applications using declarative components. 8 | ## Usage 9 | **👉 [Documentation and playable demo available here](https://react-elasticsearch.raph.site/).** 10 | 11 | ```jsx 12 | const MySearchComponent = () => ( 13 | 14 | 15 | 16 | 17 | 20 | // Map on result hits and display whatever you want. 21 | data.map(item => ) 22 | } 23 | /> 24 | 25 | ); 26 | ``` 27 | 28 | ## Install 29 | 30 | ``` 31 | npm i react-elasticsearch 32 | yarn add react-elasticsearch 33 | ``` 34 | 35 | ## Develop 36 | 37 | You can test components with storybook (20+ examples). 38 | 39 | ``` 40 | npm run storybook 41 | ``` 42 | 43 | ## Main features 44 | 45 | - 🏝 Released under **MIT licence**. 46 | - 👩‍🎨 Each component is built with React and is **customisable**. Not too much extra features nor magic. 47 | - 💅 It comes with **no style** so it's the developers responsibility to implement their own. 48 | - 🐿 **35.32KB gzipped** for the whole lib, compatible with old browsers: >0.03% usage. 49 | - 🔮 No legacy: **created in 2019**, **updated in 2021** with hooks. 50 | 51 | ## Why? 52 | 53 | We started building the search experience 54 | of the french [Cultural Heritage Open Platform](https://www.pop.culture.gouv.fr/) 55 | with [ReactiveSearch](https://opensource.appbase.io/reactivesearch/), a well-known 56 | search UI components lib for React. 57 | After some weeks, we realized we had spent a lot of time tweaking and hacking the lib; 58 | we had rewrote almost every components ourselves. We opened issues and pull requests on the repository, 59 | but it seemed the lib was a bit stuck in a rewrite process. 60 | We found out that we need a simple lib that can be easily extended with a similar API, 61 | we created this one. This lib has many less feature than others, it's not even a decent competitor. 62 | But since it helped us building a search experiences, it has been released. Hope it could help you! 63 | 64 | ## Contributing 65 | 66 | Open issues and PR here: https://github.com/betagouv/react-elasticsearch 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-elasticsearch", 3 | "version": "3.0.2", 4 | "description": "", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "build": "webpack --mode production", 8 | "storybook": "start-storybook -p 6006", 9 | "build-storybook": "build-storybook" 10 | }, 11 | "keywords": [], 12 | "author": "rap2hpoutre", 13 | "license": "MIT", 14 | "prettier": { 15 | "printWidth": 100 16 | }, 17 | "browserslist": [ 18 | ">0.03%" 19 | ], 20 | "devDependencies": { 21 | "@babel/core": "^7.14.3", 22 | "@babel/preset-env": "^7.14.2", 23 | "@babel/preset-react": "^7.13.13", 24 | "@storybook/addon-actions": "^6.2.9", 25 | "@storybook/addon-links": "^6.2.9", 26 | "@storybook/addons": "^6.2.9", 27 | "@storybook/react": "^6.2.9", 28 | "babel-loader": "^8.2.2", 29 | "core-js": "^3.1.4", 30 | "lodash": "^4.17.14", 31 | "qs": "^6.7.0", 32 | "react": "17.0.2", 33 | "react-autosuggest": "^9.4.3", 34 | "react-dom": "17.0.2", 35 | "unfetch": "^4.1.0", 36 | "webpack": "^4.35.3", 37 | "webpack-cli": "^3.3.5" 38 | }, 39 | "peerDependencies": { 40 | "react": "^16.0.0 || ^17.0", 41 | "react-dom": "^16.0 || ^17.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ActiveFilters.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useSharedContext } from "./SharedContextProvider"; 3 | 4 | export default function({ items }) { 5 | const [{ widgets }, dispatch] = useSharedContext(); 6 | const activeFilters = [...widgets] 7 | .filter(([, v]) => (Array.isArray(v.value) ? v.value.length : v.value)) 8 | .map(([k, v]) => ({ 9 | key: k, 10 | value: Array.isArray(v.value) ? v.value.join(", ") : v.value 11 | })); 12 | 13 | // On filter remove, update widget properties. 14 | function removeFilter(id) { 15 | const widget = widgets.get(id); 16 | dispatch({ 17 | type: "setWidget", 18 | key: id, 19 | ...widget, 20 | value: widget.isFacet ? [] : "" 21 | }); 22 | } 23 | 24 | return ( 25 |
26 | {items ? ( 27 | items(activeFilters, removeFilter) 28 | ) : ( 29 | 39 | )} 40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/CustomWidget.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useSharedContext } from "./SharedContextProvider"; 3 | 4 | export default function({ children }) { 5 | const [ctx, dispatch] = useSharedContext(); 6 | return <>{React.Children.map(children, child => React.cloneElement(child, { ctx, dispatch }))}; 7 | } 8 | -------------------------------------------------------------------------------- /src/Elasticsearch.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { SharedContextProvider } from "./SharedContextProvider"; 3 | import Listener from "./Listener"; 4 | 5 | // Main component. See storybook for usage. 6 | export default function({ children, url, onChange, headers }) { 7 | const initialState = { url, listenerEffect: null, widgets: new Map(), headers }; 8 | 9 | const reducer = (state, action) => { 10 | const { widgets } = state; 11 | switch (action.type) { 12 | case "setWidget": 13 | const widget = { 14 | needsQuery: action.needsQuery, 15 | needsConfiguration: action.needsConfiguration, 16 | isFacet: action.isFacet, 17 | wantResults: action.wantResults, 18 | query: action.query, 19 | value: action.value, 20 | configuration: action.configuration, 21 | result: action.result 22 | }; 23 | widgets.set(action.key, widget); 24 | return { ...state, widgets }; 25 | case "deleteWidget": 26 | widgets.delete(action.key, widget); 27 | return { ...state, widgets }; 28 | case "setListenerEffect": 29 | return { ...state, listenerEffect: action.value }; 30 | default: 31 | return state; 32 | } 33 | }; 34 | 35 | return ( 36 | 37 | 38 | {children} 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/Facet.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { toTermQueries } from "./utils"; 3 | import { useSharedContext } from "./SharedContextProvider"; 4 | 5 | export default function({ 6 | fields, 7 | id, 8 | initialValue, 9 | seeMore, 10 | placeholder, 11 | showFilter = true, 12 | filterValueModifier, 13 | itemsPerBlock, 14 | items 15 | }) { 16 | const [{ widgets }, dispatch] = useSharedContext(); 17 | // Current filter (search inside facet value). 18 | const [filterValue, setFilterValue] = useState(""); 19 | // Number of itemns displayed in facet. 20 | const [size, setSize] = useState(itemsPerBlock || 5); 21 | // The actual selected items in facet. 22 | const [value, setValue] = useState(initialValue || []); 23 | // Data from internal queries (Elasticsearch queries are performed via Listener) 24 | const { result } = widgets.get(id) || {}; 25 | const data = (result && result.data) || []; 26 | const total = (result && result.total) || 0; 27 | 28 | // Update widgets properties on state change. 29 | useEffect(() => { 30 | dispatch({ 31 | type: "setWidget", 32 | key: id, 33 | needsQuery: true, 34 | needsConfiguration: true, 35 | isFacet: true, 36 | wantResults: false, 37 | query: { bool: { should: toTermQueries(fields, value) } }, 38 | value, 39 | configuration: { size, filterValue, fields, filterValueModifier }, 40 | result: data && total ? { data, total } : null 41 | }); 42 | }, [size, filterValue, value]); 43 | 44 | // If widget value was updated elsewhere (ex: from active filters deletion) 45 | // We have to update and dispatch the component. 46 | useEffect(() => { 47 | widgets.get(id) && setValue(widgets.get(id).value); 48 | }, [isValueReady()]); 49 | 50 | // Destroy widget from context (remove from the list to unapply its effects) 51 | useEffect(() => () => dispatch({ type: "deleteWidget", key: id }), []); 52 | 53 | // Checks if widget value is the same as actual value. 54 | function isValueReady() { 55 | return !widgets.get(id) || widgets.get(id).value == value; 56 | } 57 | 58 | // On checkbox status change, add or remove current agg to selected 59 | function handleChange(item, checked) { 60 | const newValue = checked 61 | ? [...new Set([...value, item.key])] 62 | : value.filter(f => f !== item.key); 63 | setValue(newValue); 64 | } 65 | 66 | // Is current item checked? 67 | function isChecked(item) { 68 | return value.includes(item.key); 69 | } 70 | 71 | return ( 72 |
73 | {showFilter ? ( 74 | { 79 | setFilterValue(e.target.value); 80 | }} 81 | /> 82 | ) : null} 83 | {items 84 | ? items(data, { handleChange, isChecked }) 85 | : data.map(item => ( 86 | 94 | ))} 95 | {data.length === size ? ( 96 | 99 | ) : null} 100 |
101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /src/Listener.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { useSharedContext } from "./SharedContextProvider"; 3 | import { msearch, queryFrom, defer } from "./utils"; 4 | 5 | // This component needs to be cleaned. 6 | export default function ({ children, onChange }) { 7 | const [{ url, listenerEffect, widgets, headers }, dispatch] = useSharedContext(); 8 | 9 | // We need to prepare some data in each render. 10 | // This needs to be done out of the effect function. 11 | function widgetThat(key) { 12 | return new Map([...widgets].filter(([, v]) => v[key])); 13 | } 14 | function mapFrom(key) { 15 | return new Map([...widgets].filter(([, v]) => v[key]).map(([k, v]) => [k, v[key]])); 16 | } 17 | const configurableWidgets = widgetThat("needsConfiguration"); 18 | const facetWidgets = widgetThat("isFacet"); 19 | const searchWidgets = widgetThat("needsQuery"); 20 | const resultWidgets = widgetThat("wantResults"); 21 | const queries = mapFrom("query"); 22 | const configurations = mapFrom("configuration"); 23 | const values = mapFrom("value"); 24 | 25 | useEffect(() => { 26 | // Apply custom callback effect on every change, useful for query params. 27 | if (onChange) { 28 | // Add pages to params. 29 | const pages = [...configurations] 30 | .filter(([, v]) => v.page && v.page > 1) 31 | .map(([k, v]) => [`${k}Page`, v.page]); 32 | // Run the change callback with all params. 33 | onChange(new Map([...pages, ...values])); 34 | } 35 | // Run the deferred (thx algolia) listener effect. 36 | listenerEffect && listenerEffect(); 37 | }); 38 | 39 | // Run effect on update for each change in queries or configuration. 40 | useEffect(() => { 41 | // If you are debugging and your debug path leads you here, you might 42 | // check configurableWidgets and searchWidgets actually covers 43 | // the whole list of components that are configurables and queryable. 44 | const queriesReady = queries.size === searchWidgets.size; 45 | const configurationsReady = configurations.size === configurableWidgets.size; 46 | const isAtLeastOneWidgetReady = searchWidgets.size + configurableWidgets.size > 0; 47 | if (queriesReady && configurationsReady && isAtLeastOneWidgetReady) { 48 | // The actual query to ES is deffered, to wait for all effects 49 | // and context operations before running. 50 | defer(() => { 51 | dispatch({ 52 | type: "setListenerEffect", 53 | value: () => { 54 | const msearchData = []; 55 | resultWidgets.forEach((r, id) => { 56 | const { itemsPerPage, page, sort } = r.configuration; 57 | msearchData.push({ 58 | query: { 59 | query: queryFrom(queries), 60 | size: itemsPerPage, 61 | from: (page - 1) * itemsPerPage, 62 | sort, 63 | }, 64 | data: (result) => result.hits.hits, 65 | total: (result) => result.hits.total, 66 | id, 67 | }); 68 | }); 69 | 70 | // Fetch data for internal facet components. 71 | facetWidgets.forEach((f, id) => { 72 | const fields = f.configuration.fields; 73 | const size = f.configuration.size; 74 | const filterValue = f.configuration.filterValue; 75 | const filterValueModifier = f.configuration.filterValueModifier; 76 | 77 | // Get the aggs (elasticsearch queries) from fields 78 | // Dirtiest part, because we build a raw query from various params 79 | function aggsFromFields() { 80 | // Remove current query from queries list (do not react to self) 81 | function withoutOwnQueries() { 82 | const q = new Map(queries); 83 | q.delete(id); 84 | return q; 85 | } 86 | // Transform a single field to agg query 87 | function aggFromField(field) { 88 | const t = { field, order: { _count: "desc" }, size }; 89 | if (filterValue) { 90 | t.include = !filterValueModifier 91 | ? `.*${filterValue}.*` 92 | : filterValueModifier(filterValue); 93 | } 94 | return { [field]: { terms: t } }; 95 | } 96 | // Actually build the query from fields 97 | let result = {}; 98 | fields.forEach((f) => { 99 | result = { ...result, ...aggFromField(f) }; 100 | }); 101 | return { query: queryFrom(withoutOwnQueries()), size: 0, aggs: result }; 102 | } 103 | msearchData.push({ 104 | query: aggsFromFields(), 105 | data: (result) => { 106 | // Merge aggs (if there is more than one for a facet), 107 | // then remove duplicate and add doc_count (sum), 108 | // then sort and slice to get only 10 first. 109 | const map = new Map(); 110 | fields 111 | .map((f) => result.aggregations[f].buckets) 112 | .reduce((a, b) => a.concat(b)) 113 | .forEach((i) => { 114 | map.set(i.key, { 115 | key: i.key, 116 | doc_count: map.has(i.key) 117 | ? i.doc_count + map.get(i.key).doc_count 118 | : i.doc_count, 119 | }); 120 | }); 121 | return [...map.values()].sort((x, y) => y.doc_count - x.doc_count).slice(0, size); 122 | }, 123 | total: (result) => result.hits.total, 124 | id: id, 125 | }); 126 | }); 127 | 128 | // Fetch the data. 129 | async function fetchData() { 130 | // Only if there is a query to run. 131 | if (msearchData.length) { 132 | const result = await msearch(url, msearchData, headers); 133 | result.responses.forEach((response, key) => { 134 | const widget = widgets.get(msearchData[key].id); 135 | if (response.status !== 200) { 136 | console.error(response.error.reason); 137 | return; 138 | } 139 | widget.result = { 140 | data: msearchData[key].data(response), 141 | total: msearchData[key].total(response), 142 | }; 143 | // Update widget 144 | dispatch({ type: "setWidget", key: msearchData[key].id, ...widget }); 145 | }); 146 | } 147 | } 148 | fetchData(); 149 | // Destroy the effect listener to avoid infinite loop! 150 | dispatch({ type: "setListenerEffect", value: null }); 151 | }, 152 | }); 153 | }); 154 | } 155 | }, [JSON.stringify(Array.from(queries)), JSON.stringify(Array.from(configurations))]); 156 | 157 | return <>{children}; 158 | } 159 | -------------------------------------------------------------------------------- /src/Pagination.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // The main objective is to have this display: 4 | // 5 | // (1) (2) (3) (4) (5) ... (1000) on page 1 6 | // (1) ... (7) (8) (9) (10) (11) ... (1000) on page 9 7 | // (1) ... (996) (997) (998) (999) (1000) on page 1000 8 | // 9 | // X and Y are used to simulate "..." with different keys. Just like my code in 1997. 10 | function buttons(page, max) { 11 | if (page < 5 || page > max) { 12 | return [...[...Array(Math.min(max, 5)).keys()].map(e => e + 1), ...(max > 6 ? ["x", max] : [])]; 13 | } else if (page >= 5 && page <= max - 4) { 14 | return [1, "x", page - 2, page - 1, page, page + 1, page + 2, "y", max]; 15 | } else if (page === 5 && max === 5) { 16 | return [1, 2, 3, 4, 5]; 17 | } 18 | return [1, "x", max - 4, max - 3, max - 2, max - 1, max]; 19 | } 20 | 21 | export default function({ onChange, total, itemsPerPage, page }) { 22 | const max = Math.min(Math.ceil(total / itemsPerPage), 10000 / itemsPerPage); 23 | 24 | return ( 25 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/QueryBuilder/QueryBuilder.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { useSharedContext } from "../SharedContextProvider"; 3 | import { 4 | defaultOperators, 5 | defaultCombinators, 6 | mergedQueries, 7 | uuidv4, 8 | withUniqueKey 9 | } from "./utils"; 10 | import Rule from "./Rule"; 11 | 12 | export default function QueryBuilder({ 13 | fields, 14 | operators, 15 | combinators, 16 | templateRule, 17 | initialValue, 18 | id, 19 | autoComplete 20 | }) { 21 | const [, dispatch] = useSharedContext(); 22 | operators = operators || defaultOperators; 23 | combinators = combinators || defaultCombinators; 24 | templateRule = templateRule || { 25 | field: fields[0].value, 26 | operator: operators[0].value, 27 | value: "", 28 | combinator: "AND", 29 | index: 0 30 | }; 31 | const [rules, setRules] = useState(withUniqueKey(initialValue || [templateRule])); 32 | 33 | useEffect(() => { 34 | const queries = mergedQueries( 35 | rules.map(r => ({ 36 | ...r, 37 | query: operators.find(o => o.value === r.operator).query(r.field, r.value) 38 | })) 39 | ); 40 | dispatch({ 41 | type: "setWidget", 42 | key: id, 43 | needsQuery: true, 44 | needsConfiguration: false, 45 | isFacet: false, 46 | wantResults: false, 47 | query: { bool: queries }, 48 | value: rules.map(r => ({ 49 | field: r.field, 50 | operator: r.operator, 51 | value: r.value, 52 | combinator: r.combinator, 53 | index: r.index 54 | })), 55 | configuration: null, 56 | result: null 57 | }); 58 | }, [JSON.stringify(rules)]); 59 | 60 | // Destroy widget from context (remove from the list to unapply its effects) 61 | useEffect(() => () => dispatch({ type: "deleteWidget", key: id }), []); 62 | 63 | return ( 64 |
65 | {rules.map(rule => ( 66 | { 78 | setRules([...rules, { ...templateRule, index: rules.length, key: uuidv4() }]); 79 | }} 80 | onDelete={index => { 81 | setRules( 82 | rules 83 | .filter(e => e.index !== index) 84 | .filter(e => e) 85 | .map((v, k) => ({ ...v, index: k })) 86 | ); 87 | }} 88 | onChange={r => { 89 | rules[r.index] = { ...r, key: rules[r.index].key }; 90 | setRules([...rules]); 91 | }} 92 | /> 93 | ))} 94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/QueryBuilder/Rule.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import Autosuggest from "react-autosuggest"; 3 | import { useSharedContext } from "../SharedContextProvider"; 4 | import { msearch } from "../utils"; 5 | 6 | export default function Rule({ fields, operators, combinators, ...props }) { 7 | const [{ url, headers }] = useSharedContext(); 8 | const [combinator, setCombinator] = useState(props.combinator); 9 | const [field, setField] = useState(props.field); 10 | const [operator, setOperator] = useState(props.operator); 11 | const [value, setValue] = useState(props.value); 12 | const [suggestions, setSuggestions] = useState([]); 13 | 14 | useEffect(() => { 15 | props.onChange({ field, operator, value, combinator, index: props.index }); 16 | }, [field, operator, value, combinator]); 17 | 18 | const combinatorElement = props.index ? ( 19 | 30 | ) : null; 31 | 32 | const deleteButton = props.index ? ( 33 | 36 | ) : null; 37 | 38 | let input = null; 39 | if (operators.find(o => o.value === operator && o.useInput)) { 40 | // Autocomplete zone. 41 | if (props.autoComplete && !Array.isArray(field)) { 42 | input = ( 43 | { 46 | let query; 47 | const suggestionQuery = operators.find(o => o.value === operator).suggestionQuery; 48 | if (suggestionQuery) { 49 | query = suggestionQuery(field, value); 50 | } else { 51 | const terms = { field, include: `.*${value}.*`, order: { _count: "desc" }, size: 10 }; 52 | query = { query: { match_all: {} }, aggs: { [field]: { terms } }, size: 0 }; 53 | } 54 | const suggestions = await msearch(url, [{ query, id: "queryBuilder" }], headers); 55 | setSuggestions(suggestions.responses[0].aggregations[field].buckets.map(e => e.key)); 56 | }} 57 | onSuggestionsClearRequested={() => setSuggestions([])} 58 | getSuggestionValue={suggestion => suggestion} 59 | renderSuggestion={suggestion =>
{suggestion}
} 60 | inputProps={{ 61 | value, 62 | onChange: (event, { newValue }) => setValue(newValue), 63 | className: "react-es-rule-value", 64 | autoComplete: "new-password" 65 | }} 66 | /> 67 | ); 68 | } else { 69 | input = ( 70 | setValue(e.target.value)} 75 | /> 76 | ); 77 | } 78 | } 79 | return ( 80 |
81 | {combinatorElement} 82 | 95 | 108 | {input} 109 | 112 | {deleteButton} 113 |
114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /src/QueryBuilder/utils.js: -------------------------------------------------------------------------------- 1 | export function mergedQueries(queries) { 2 | let obj = { must: [], must_not: [], should: [], should_not: [] }; 3 | queries 4 | .filter(q => q.query) 5 | .forEach((q, k) => { 6 | let combinator = q.combinator; 7 | if (k === 0) { 8 | combinator = queries.length === 1 ? "AND" : queries[1].combinator; 9 | } 10 | obj[combinator === "AND" ? "must" : "should"].push(q.query); 11 | }); 12 | return obj; 13 | } 14 | 15 | function query(key, value, cb, shouldOrMust = "should") { 16 | if (Array.isArray(key)) { 17 | return { bool: { [shouldOrMust]: key.map(k => cb(k, value)) } }; 18 | } 19 | return cb(key, value); 20 | } 21 | 22 | export const defaultOperators = [ 23 | { 24 | value: "==", 25 | text: "equals", 26 | useInput: true, 27 | query: (key, value) => value && query(key, value, (k, v) => ({ term: { [k]: v } })) 28 | }, 29 | { 30 | value: "!=", 31 | text: "not equals", 32 | useInput: true, 33 | query: (key, value) => 34 | value && query(key, value, (k, v) => ({ bool: { must_not: { term: { [k]: v } } } }), "must") 35 | }, 36 | { 37 | value: ">=", 38 | text: "greater than or equals to", 39 | useInput: true, 40 | query: (key, value) => value && query(key, value, (k, v) => ({ range: { [k]: { gte: v } } })) 41 | }, 42 | { 43 | value: "<=", 44 | text: "lesser than or equals to", 45 | useInput: true, 46 | query: (key, value) => value && query(key, value, (k, v) => ({ range: { [k]: { lte: v } } })) 47 | }, 48 | { 49 | value: ">", 50 | text: "greater than", 51 | useInput: true, 52 | query: (key, value) => value && query(key, value, (k, v) => ({ range: { [k]: { gt: v } } })) 53 | }, 54 | { 55 | value: "<", 56 | text: "lesser than", 57 | useInput: true, 58 | query: (key, value) => value && query(key, value, (k, v) => ({ range: { [k]: { lt: v } } })) 59 | }, 60 | { 61 | value: "∃", 62 | text: "exists", 63 | useInput: false, 64 | query: key => 65 | query(key, null, k => ({ 66 | bool: { 67 | // Must exists ... 68 | must: { exists: { field: k } }, 69 | // ... and must be not empty. 70 | must_not: { term: { [k]: "" } } 71 | } 72 | })) 73 | }, 74 | { 75 | value: "!∃", 76 | text: "does not exist", 77 | useInput: false, 78 | query: key => 79 | query( 80 | key, 81 | null, 82 | k => ({ 83 | bool: { 84 | // Should be ... 85 | should: [ 86 | // ... empty string ... 87 | { term: { [k]: "" } }, 88 | // ... or not exists. 89 | { bool: { must_not: { exists: { field: k } } } } 90 | ] 91 | } 92 | }), 93 | "must" 94 | ) 95 | }, 96 | { 97 | value: "*", 98 | text: "contains", 99 | useInput: true, 100 | query: (key, value) => value && query(key, value, (k, v) => ({ wildcard: { [k]: `*${v}*` } })) 101 | }, 102 | { 103 | value: "!*", 104 | text: "does not contains", 105 | useInput: true, 106 | query: (key, value) => 107 | value && 108 | query(key, value, (k, v) => ({ bool: { must_not: { wildcard: { [k]: `*${v}*` } } } }), "must") 109 | }, 110 | { 111 | value: "^", 112 | text: "start with", 113 | useInput: true, 114 | query: (key, value) => value && query(key, value, (k, v) => ({ wildcard: { [k]: `${v}*` } })) 115 | } 116 | ]; 117 | 118 | export const defaultCombinators = [{ value: "AND", text: "AND" }, { value: "OR", text: "OR" }]; 119 | 120 | export function uuidv4() { 121 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => { 122 | const r = (Math.random() * 16) | 0, 123 | v = c == "x" ? r : (r & 0x3) | 0x8; 124 | return v.toString(16); 125 | }); 126 | } 127 | export function withUniqueKey(rules) { 128 | return rules.map(r => ({ ...r, key: uuidv4() })); 129 | } 130 | -------------------------------------------------------------------------------- /src/Results.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useSharedContext } from "./SharedContextProvider"; 3 | import Pagination from "./Pagination"; 4 | 5 | // Pagination, informations about results (like "30 results") 6 | // and size (number items per page) are customizable. 7 | export default function({ itemsPerPage, initialPage = 1, pagination, stats, items, id, sort }) { 8 | const [{ widgets }, dispatch] = useSharedContext(); 9 | const [initialization, setInitialization] = useState(true); 10 | const [page, setPage] = useState(initialPage); 11 | const widget = widgets.get(id); 12 | const data = widget && widget.result && widget.result.data ? widget.result.data : []; 13 | const total = widget && widget.result && widget.result.total ? (widget.result.total.hasOwnProperty('value') ? widget.result.total.value: widget.result.total) : 0; 14 | itemsPerPage = itemsPerPage || 10; 15 | 16 | useEffect(() => { 17 | setPage(initialization ? initialPage : 1); 18 | return () => setInitialization(false); 19 | }, [total]); 20 | 21 | // Update context with page (and itemsPerPage) 22 | useEffect(() => { 23 | dispatch({ 24 | type: "setWidget", 25 | key: id, 26 | needsQuery: false, 27 | needsConfiguration: true, 28 | isFacet: false, 29 | wantResults: true, 30 | query: null, 31 | value: null, 32 | configuration: { itemsPerPage, page, sort }, 33 | result: data && total ? { data, total } : null 34 | }); 35 | }, [page, sort]); 36 | 37 | // Destroy widget from context (remove from the list to unapply its effects) 38 | useEffect(() => () => dispatch({ type: "deleteWidget", key: id }), []); 39 | 40 | const defaultPagination = () => ( 41 | setPage(p)} total={total} itemsPerPage={itemsPerPage} page={page} /> 42 | ); 43 | 44 | return ( 45 |
46 | {stats ? stats(total) : <>{total} results} 47 |
{items(data)}
48 | {pagination ? pagination(total, itemsPerPage, page, setPage) : defaultPagination()} 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/SearchBox.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { useSharedContext } from "./SharedContextProvider"; 3 | 4 | export default function({ customQuery, fields, id, initialValue, placeholder }) { 5 | const [{ widgets }, dispatch] = useSharedContext(); 6 | const [value, setValue] = useState(initialValue || ""); 7 | 8 | // Update external query on mount. 9 | useEffect(() => { 10 | update(value); 11 | }, []); 12 | 13 | // If widget value was updated elsewhere (ex: from active filters deletion) 14 | // We have to update and dispatch the component. 15 | useEffect(() => { 16 | widgets.get(id) && update(widgets.get(id).value); 17 | }, [isValueReady()]); 18 | 19 | // Build a query from a value. 20 | function queryFromValue(query) { 21 | if (customQuery) { 22 | return customQuery(query); 23 | } else if (fields) { 24 | return query ? { multi_match: { query, type: "phrase", fields } } : { match_all: {} }; 25 | } 26 | return { match_all: {} }; 27 | } 28 | 29 | // This functions updates the current values, then dispatch 30 | // the new widget properties to context. 31 | // Called on mount and value change. 32 | function update(v) { 33 | setValue(v); 34 | dispatch({ 35 | type: "setWidget", 36 | key: id, 37 | needsQuery: true, 38 | needsConfiguration: false, 39 | isFacet: false, 40 | wantResults: false, 41 | query: queryFromValue(v), 42 | value: v, 43 | configuration: null, 44 | result: null 45 | }); 46 | } 47 | 48 | // Checks if widget value is the same as actual value. 49 | function isValueReady() { 50 | return !widgets.get(id) || widgets.get(id).value == value; 51 | } 52 | 53 | // Destroy widget from context (remove from the list to unapply its effects) 54 | useEffect(() => () => dispatch({ type: "deleteWidget", key: id }), []); 55 | 56 | return ( 57 |
58 | update(e.target.value)} 62 | placeholder={placeholder || "search…"} 63 | /> 64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/SharedContextProvider.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useReducer } from "react"; 2 | 3 | // Todo: add comments. Component purpose! 4 | export const SharedContext = createContext(); 5 | 6 | export const SharedContextProvider = ({ reducer, initialState, children }) => { 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | }; 13 | 14 | export const useSharedContext = () => useContext(SharedContext); 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Elasticsearch from "./Elasticsearch"; 2 | import Results from "./Results"; 3 | import SearchBox from "./SearchBox"; 4 | import Facet from "./Facet"; 5 | import Pagination from "./Pagination"; 6 | import Listener from "./Listener"; 7 | import ActiveFilters from "./ActiveFilters"; 8 | import QueryBuilder from "./QueryBuilder/QueryBuilder"; 9 | import CustomWidget from "./CustomWidget"; 10 | import { fromUrlQueryString, toUrlQueryString, msearch } from "./utils"; 11 | 12 | export { 13 | Elasticsearch, 14 | Results, 15 | SearchBox, 16 | Facet, 17 | Pagination, 18 | Listener, 19 | fromUrlQueryString, 20 | toUrlQueryString, 21 | ActiveFilters, 22 | QueryBuilder, 23 | CustomWidget, 24 | msearch 25 | }; 26 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | .react-es-active-filters > ul > li, 2 | .react-es-facet { 3 | background-color: #fafafa; 4 | margin: 5px 7px 5px 0; 5 | padding: 5px 7px; 6 | border: 1px solid #ddd; 7 | border-radius: 2px; 8 | box-shadow: 1px 2px 2px 0 rgba(197, 197, 197, 0.5); 9 | } 10 | .react-es-active-filters > ul { 11 | list-style: none; 12 | padding: 0; 13 | } 14 | .react-es-active-filters > ul > li { 15 | display: inline; 16 | } 17 | .react-es-active-filters > ul > li > button { 18 | margin-left: 5px; 19 | } 20 | .react-es-facet { 21 | max-width: 300px; 22 | } 23 | .react-es-facet input[type="text"] { 24 | width: 100%; 25 | } 26 | .react-es-facet > label { 27 | display: block; 28 | } 29 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import fetch from "unfetch"; 2 | import qs from "qs"; 3 | 4 | // Search with msearch to elasticsearch instance 5 | // Todo reject. 6 | export function msearch(url, msearchData, headers = {}) { 7 | return new Promise(async (resolve, reject) => { 8 | headers = { 9 | ...{ Accept: "application/json", "Content-Type": "application/x-ndjson" }, 10 | ...headers 11 | }; 12 | const body = msearchData.reduce((acc, val) => { 13 | const [p, q] = [{ preference: val.id }, val.query].map(JSON.stringify); 14 | return `${acc}${p}\n${q}\n`; 15 | }, ""); 16 | const rawResponse = await fetch(`${url}/_msearch`, { method: "POST", headers, body }); 17 | const response = await rawResponse.json(); 18 | resolve(response); 19 | }); 20 | } 21 | 22 | // Build a query from a Map of queries 23 | export function queryFrom(queries) { 24 | return { bool: { must: queries.size === 0 ? { match_all: {} } : Array.from(queries.values()) } }; 25 | } 26 | 27 | // Convert fields to term queries 28 | export function toTermQueries(fields, selectedValues) { 29 | const queries = []; 30 | for (let i in fields) { 31 | for (let j in selectedValues) { 32 | queries.push({ term: { [fields[i]]: selectedValues[j] } }); 33 | } 34 | } 35 | return queries; 36 | } 37 | 38 | // Todo: clean this ugly funtion 39 | export function fromUrlQueryString(str) { 40 | return new Map([ 41 | ...Object.entries(qs.parse(str.replace(/^\?/, ""))).map(([k, v]) => { 42 | try { 43 | return [k, JSON.parse(v)]; 44 | } catch (e) { 45 | return [k, v] 46 | } 47 | }) 48 | ]); 49 | } 50 | 51 | // Todo: clean this ugly funtion 52 | export function toUrlQueryString(params) { 53 | return qs.stringify( 54 | Object.fromEntries( 55 | new Map( 56 | Array.from(params) 57 | .filter(([_k, v]) => (Array.isArray(v) ? v.length : v)) 58 | .map(([k, v]) => [k, JSON.stringify(v)]) 59 | ) 60 | ) 61 | ); 62 | } 63 | 64 | const resolved = Promise.resolve(); 65 | export const defer = f => { 66 | resolved.then(f); 67 | }; 68 | -------------------------------------------------------------------------------- /stories/active-filters.stories.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { Elasticsearch, SearchBox, Results, ActiveFilters, Facet } from "../src"; 4 | import { url } from "./utils"; 5 | import "../src/style.css"; 6 | 7 | storiesOf("ActiveFilters", module) 8 | .add("active", () => { 9 | return ( 10 | 11 |

Display active filters

12 |
{``}
13 | Active Filters: 14 | 15 | 16 | 17 | 20 | data.map(({ _source: s, _id }) => ( 21 |
22 | {s.TICO} - {s.AUTR} 23 |
24 | )) 25 | } 26 | pagination={() => <>} 27 | /> 28 |
29 | ); 30 | }) 31 | .add("Active filter (change component order)", () => { 32 | return ( 33 | 34 |

Active filter (change component order)

35 | 36 | Recherche: 37 | 38 | Filtres: 39 | 40 | 43 | data.map(({ _source: s, _id }) => ( 44 |
45 | {s.TICO} - {s.AUTR} 46 |
47 | )) 48 | } 49 | /> 50 |
51 | ); 52 | }); 53 | -------------------------------------------------------------------------------- /stories/facet.stories.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { Elasticsearch, SearchBox, Results, Facet } from "../src"; 4 | import { url } from "./utils"; 5 | 6 | function CollapsableFacet({ initialCollapsed, title, ...rest }) { 7 | const [collapsed, setCollapsed] = useState(true); 8 | 9 | function FacetWrapper() { 10 | if (!collapsed) { 11 | return ; 12 | } 13 | return
; 14 | } 15 | return ( 16 |
17 |
18 | {title} 19 | 26 |
27 | {FacetWrapper()} 28 |
29 | ); 30 | } 31 | 32 | storiesOf("Facet", module) 33 | .add("collapsable", () => { 34 | return ( 35 | 36 | 37 | 38 | 41 | data.map(({ _source: s, _id }) => ( 42 |
43 | {s.TICO} - {s.AUTR} 44 |
45 | )) 46 | } 47 | pagination={() => <>} 48 | /> 49 |
50 | ); 51 | }) 52 | .add("customized", () => { 53 | return ( 54 | 55 | 62 | 65 | data.map(({ _source, _id, _score }) => ( 66 |
67 | {_source.TICO} - score: {_score} 68 |
69 | )) 70 | } 71 | /> 72 |
73 | ); 74 | }) 75 | .add("modify filter value", () => { 76 | return ( 77 | 78 | `${v}.*`} 80 | placeholder="type first letters" 81 | id="autr" 82 | fields={["AUTR.keyword"]} 83 | /> 84 | 87 | data.map(({ _source, _id, _score }) => ( 88 |
89 | {_source.TICO} - score: {_score} 90 |
91 | )) 92 | } 93 | /> 94 |
95 | ); 96 | }) 97 | .add("facet with custom render items", () => { 98 | return ( 99 | 100 | 101 | { 105 | return data.map(item => ( 106 |
handleChange(item, !isChecked(item))} 109 | > 110 | -> {item.key} 111 |
112 | )); 113 | }} 114 | /> 115 | 118 | data.map(({ _source: s, _id }) => ( 119 |
120 | {s.TICO} - {s.AUTR} 121 |
122 | )) 123 | } 124 | pagination={() => <>} 125 | /> 126 |
127 | ); 128 | }); 129 | -------------------------------------------------------------------------------- /stories/index.stories.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { 4 | Elasticsearch, 5 | SearchBox, 6 | Facet, 7 | Results, 8 | ActiveFilters, 9 | toUrlQueryString, 10 | fromUrlQueryString 11 | } from "../src"; 12 | import { customQuery, customQueryMovie, url } from "./utils"; 13 | 14 | storiesOf("Elasticsearch", module) 15 | .add("basic usage", () => { 16 | return ( 17 | 18 | 19 |
20 | 21 |
22 |
23 | 24 |
25 | 28 | data.map(({ _source, _score, _id }) => ( 29 |
30 | {_source.TICO} - score: {_score} 31 |
32 | )) 33 | } 34 | /> 35 |
36 | ); 37 | }) 38 | .add("with url params", () => ) 39 | .add("movie database", () => { 40 | return ( 41 | 47 | 48 | 51 | data.map(({ _source, _score, _id }) => ( 52 |
53 | 54 | 55 | {_source.original_title} - {_source.tagline} 56 | {" "} 57 | - score: {_score} 58 |
59 | )) 60 | } 61 | /> 62 |
63 | ); 64 | }); 65 | 66 | function WithUrlParams() { 67 | const [queryString, setQueryString] = useState(""); 68 | 69 | const initialValues = fromUrlQueryString("main=%22h%22&resultPage=2"); 70 | return ( 71 | { 74 | setQueryString(toUrlQueryString(values)); 75 | }} 76 | > 77 |
Params: {queryString}
78 | 79 |
80 | 81 | 82 | data.map(({ _source, _id }) =>
{_source.TICO}
)} 86 | /> 87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /stories/pagination.stories.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { action } from "@storybook/addon-actions"; 4 | import { Pagination } from "../src"; 5 | 6 | storiesOf("Pagination", module).add("with various status", () => { 7 | const paginations = [1, 3, 5, 12, 35, 38, 40].map(i => ( 8 |
9 |

On page {i}

10 | 11 |
12 | )); 13 | return ( 14 |
15 | {paginations} 16 |

On page 1

17 | 18 |

On page 5

19 | 20 |
21 | ); 22 | }); 23 | -------------------------------------------------------------------------------- /stories/query-builder.stories.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { Elasticsearch, QueryBuilder, Results, fromUrlQueryString, toUrlQueryString } from "../src"; 4 | import { url } from "./utils"; 5 | 6 | storiesOf("QueryBuilder", module) 7 | .add("simple", () => { 8 | return ( 9 | 10 | 11 | data.map(({ _source, _id }) =>
{_source.TICO}
)} 14 | /> 15 |
16 | ); 17 | }) 18 | .add("autoComplete", () => { 19 | return ( 20 | 21 | 26 | data.map(({ _source, _id }) =>
{_source.TICO}
)} 29 | /> 30 |
31 | ); 32 | }) 33 | .add("custom query and operators", () => { 34 | const regexify = v => 35 | `.*${v 36 | .replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&") 37 | .replace(/([A-Z])/gi, (_w, x) => `[${x.toUpperCase()}${x.toLowerCase()}]`)}.*`; 38 | const operators = [ 39 | { 40 | value: "==", 41 | text: "contains (case insensitive)", 42 | useInput: true, 43 | query: (key, value) => (value ? { regexp: { [key]: regexify(value) } } : null), 44 | suggestionQuery: (field, value) => { 45 | return { 46 | query: { match_all: {} }, 47 | aggs: { 48 | [field]: { 49 | terms: { field, include: regexify(value), order: { _count: "desc" }, size: 10 } 50 | } 51 | }, 52 | size: 0 53 | }; 54 | } 55 | } 56 | ]; 57 | return ( 58 | 59 | 65 | data.map(({ _source, _id }) =>
{_source.TICO}
)} 68 | /> 69 |
70 | ); 71 | }) 72 | .add("multiple fields", () => { 73 | return ( 74 | 75 | 83 | 86 | data.map(({ _source, _id }) => ( 87 |
88 | {_source.AUTR} - {_source.TICO} 89 |
90 | )) 91 | } 92 | /> 93 |
94 | ); 95 | }) 96 | .add("listen changes (with url params)", () => ); 97 | 98 | function WithUrlParams() { 99 | const [queryString, setQueryString] = useState(""); 100 | 101 | const initialValues = fromUrlQueryString( 102 | "qb=%5B%7B%22field%22%3A%22AUTR.keyword%22%2C%22operator%22%3A%22%2A%22%2C%22value%22%3A%22jean%22%2C%22combinator%22%3A%22AND%22%2C%22index%22%3A0%7D%2C%7B%22field%22%3A%22AUTR.keyword%22%2C%22operator%22%3A%22%2A%22%2C%22value%22%3A%22marc%22%2C%22combinator%22%3A%22OR%22%2C%22index%22%3A1%7D%5D" 103 | ); 104 | 105 | return ( 106 | { 109 | setQueryString(toUrlQueryString(values)); 110 | }} 111 | > 112 |
Params: {queryString}
113 | 118 | data.map(({ _source, _id }) =>
{_source.TICO}
)} 121 | /> 122 |
123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /stories/results.stories.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { Elasticsearch, Results } from "../src"; 4 | import { url } from "./utils"; 5 | 6 | storiesOf("Results", module) 7 | .add("vanilla", () => { 8 | return ( 9 | 10 | 13 | data.map(({ _source, _id, _score }) => ( 14 |
15 | {_source.TICO} - score: {_score} - id: {_id} 16 |
17 | )) 18 | } 19 | /> 20 |
21 | ); 22 | }) 23 | .add("with custom pagination", () => { 24 | return ( 25 | 26 | 29 | data.map(({ _source, _id, _score }) => ( 30 |
31 | {_source.TICO} - score: {_score} - id: {_id} 32 |
33 | )) 34 | } 35 | pagination={(total, itemsPerPage, page) => ( 36 |
37 | Total : {total} - ItemsPerPage : {itemsPerPage} - Page: {page} CUSTOM! 38 |
39 | )} 40 | /> 41 |
42 | ); 43 | }) 44 | .add("with custom stats", () => { 45 | return ( 46 | 47 | 50 | data.map(({ _source, _id, _score }) => ( 51 |
52 | {_source.TICO} - score: {_score} - id: {_id} 53 |
54 | )) 55 | } 56 | stats={total =>
{total} results CUSTOM!
} 57 | /> 58 |
59 | ); 60 | }) 61 | .add("sortable (DMIS desc)", () => ); 62 | 63 | function WithSortable() { 64 | const [sortKey, setSortKey] = useState("DMIS.keyword"); 65 | const [sortOrder, setSortOrder] = useState("desc"); 66 | const [sortQuery, setSortQuery] = useState([{ [sortKey]: { order: sortOrder } }]); 67 | 68 | useEffect(() => { 69 | setSortQuery([{ [sortKey]: { order: sortOrder } }]); 70 | }, [sortKey, sortOrder]); 71 | 72 | return ( 73 | 74 | Sort by:{" "} 75 | 82 | 86 | 90 | data.map(({ _source, _id }) => ( 91 |
92 | {_source.DMIS} - {_source.TICO.substr(0, 50)} 93 |
94 | )) 95 | } 96 | /> 97 |
98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /stories/searchbox.stories.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { Elasticsearch, SearchBox, Results } from "../src"; 4 | import { customQuery, url } from "./utils"; 5 | 6 | storiesOf("SearchBox", module) 7 | .add("with default query", () => { 8 | return ( 9 | 10 |

Search on AUTR field

11 |
{``}
12 | 13 | 16 | data.map(({ _source: s, _id }) => ( 17 |
18 | {s.TICO} - {s.AUTR} 19 |
20 | )) 21 | } 22 | pagination={() => <>} 23 | /> 24 |
25 | ); 26 | }) 27 | .add("with custom query", () => { 28 | return ( 29 | 30 |

Search on TICO field with custom query

31 |
{``}
32 | 33 | 36 | data.map(({ _source: s, _id }) => ( 37 |
38 | {s.TICO} 39 |
40 | )) 41 | } 42 | pagination={() => <>} 43 | /> 44 |
45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /stories/shared.stories.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { Elasticsearch, SearchBox, Results, CustomWidget } from "../src"; 4 | import { url } from "./utils"; 5 | 6 | function MyComponent({ ctx }) { 7 | let query; 8 | if (ctx.widgets.get("main")) { 9 | query = ctx.widgets.get("main").query 10 | } else { 11 | query = ""; 12 | } 13 | return
Main query : {JSON.stringify(query)}
; 14 | } 15 | 16 | storiesOf("CustomWidget", module).add("active", () => { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 26 | data.map(({ _source: s, _id }) => ( 27 |
28 | {s.TICO} 29 |
30 | )) 31 | } 32 | pagination={() => <>} 33 | /> 34 |
35 | ); 36 | }); 37 | -------------------------------------------------------------------------------- /stories/utils.js: -------------------------------------------------------------------------------- 1 | export function customQuery(query) { 2 | if (!query) { 3 | return { match_all: {} }; 4 | } 5 | return { multi_match: { query, type: "phrase", fields: ["TICO"] } }; 6 | } 7 | 8 | export function customQueryMovie(query) { 9 | if (!query) { 10 | return { match_all: {} }; 11 | } 12 | return { 13 | bool: { 14 | should: [ 15 | { multi_match: { query, type: "phrase", fields: ["overview", "original_title"] } }, 16 | { multi_match: { query, type: "phrase_prefix", fields: ["original_title"] } } 17 | ] 18 | } 19 | }; 20 | } 21 | 22 | export const url = "http://pop-api-staging.eu-west-3.elasticbeanstalk.com/search/merimee"; 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | module: { 5 | rules: [ 6 | { 7 | test: /\.(js|jsx)$/, 8 | exclude: /node_modules/, 9 | use: { 10 | loader: "babel-loader" 11 | } 12 | } 13 | ] 14 | }, 15 | 16 | entry: "./src/index.js", 17 | 18 | output: { 19 | path: path.resolve(__dirname, "dist/"), 20 | publicPath: "", 21 | // You can do fun things here like use the [hash] keyword to generate unique 22 | // filenames, but for this purpose rinse.js is fine. This file and path will 23 | // be what you put in package.json's "main" field 24 | filename: "main.js", 25 | // This field determines how things are importable when installed from other 26 | // sources. UMD may not be correct now and there is an open issue to fix this, 27 | // but until then, more reading can be found here: 28 | // https://webpack.js.org/configuration/output/#output-librarytarget 29 | libraryTarget: "umd", 30 | globalObject: "typeof self !== 'undefined' ? self : this" 31 | }, 32 | 33 | externals: [ 34 | { 35 | react: { 36 | root: "React", 37 | commonjs2: "react", 38 | commonjs: "react", 39 | amd: "react" 40 | }, 41 | "react-dom": { 42 | root: "ReactDOM", 43 | commonjs2: "react-dom", 44 | commonjs: "react-dom", 45 | amd: "react-dom" 46 | } 47 | } 48 | ] 49 | }; 50 | --------------------------------------------------------------------------------