├── .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 | [](https://npmjs.org/package/react-elasticsearch)
4 | [](https://npmjs.org/package/react-elasticsearch)
5 | [](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 |
30 | {activeFilters.map(({ key, value }) => {
31 | return (
32 | -
33 | {`${key}: ${value}`}
34 |
35 |
36 | );
37 | })}
38 |
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 |
26 | {buttons(page, max)
27 | .filter(e => (Number.isInteger(e) ? e <= max : e))
28 | .map(i => {
29 | if (Number.isInteger(i)) {
30 | return (
31 | -
32 |
33 |
34 | );
35 | }
36 | return - …
;
37 | })}
38 |
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 |
--------------------------------------------------------------------------------