├── .nvmrc
├── .babelrc
├── .prettierignore
├── examples
├── ssr
│ ├── .browserslistrc
│ ├── babel.config.js
│ ├── postcss.config.js
│ ├── src
│ │ ├── views
│ │ │ ├── About.vue
│ │ │ ├── Home.vue
│ │ │ └── Search.vue
│ │ ├── entry-client.js
│ │ ├── App.vue
│ │ ├── router.js
│ │ └── entry-server.js
│ ├── public
│ │ ├── favicon.ico
│ │ └── index.html
│ ├── vue.config.js
│ ├── .gitignore
│ ├── not.eslintrc.js
│ ├── README.md
│ └── package.json
├── media
│ ├── vue.config.js
│ ├── babel.config.js
│ ├── public
│ │ ├── favicon.ico
│ │ └── index.html
│ ├── src
│ │ └── main.js
│ ├── .gitignore
│ ├── README.md
│ └── package.json
├── default-theme
│ ├── vue.config.js
│ ├── babel.config.js
│ ├── public
│ │ ├── favicon.ico
│ │ └── index.html
│ ├── .gitignore
│ ├── src
│ │ ├── main.js
│ │ └── App.css
│ ├── README.md
│ └── package.json
├── e-commerce
│ ├── babel.config.js
│ ├── vue.config.js
│ ├── .prettierrc
│ ├── src
│ │ ├── utils.js
│ │ ├── images
│ │ │ ├── cover.jpg
│ │ │ └── cover-mobile.jpg
│ │ ├── main.js
│ │ └── widgets
│ │ │ ├── ClearRefinements.vue
│ │ │ └── PriceSlider.css
│ ├── public
│ │ ├── favicon.png
│ │ ├── manifest.webmanifest
│ │ └── index.html
│ ├── .editorconfig
│ ├── .eslintrc.js
│ ├── .gitignore
│ ├── README.md
│ └── package.json
├── nuxt
│ ├── static
│ │ ├── favicon.ico
│ │ └── README.md
│ ├── nuxt.config.js
│ ├── pages
│ │ ├── index.vue
│ │ └── README.md
│ ├── layouts
│ │ ├── README.md
│ │ └── default.vue
│ ├── README.md
│ └── package.json
└── build.sh
├── .codesandbox
└── ci.json
├── .gitattributes
├── .prettierrc
├── .github
├── vue-instantsearch-readme.png
├── ISSUE_TEMPLATE
│ ├── feature.md
│ └── bug.md
└── CONTRIBUTING.md
├── .vscode
├── settings.json
└── extensions.json
├── ship.config.js
├── wdio.local.conf.js
├── .storybook
├── addons.js
└── config.js
├── scripts
├── es-index-template.js
├── build-vue2.sh
├── build-vue3.sh
├── test-vue3.sh
└── clean-node-modules.sh
├── wdio.saucelabs.conf.js
├── .eslintignore
├── website
└── _redirects
├── .editorconfig
├── netlify.toml
├── src
├── util
│ ├── warn.js
│ ├── testutils
│ │ ├── client.js
│ │ └── helper.js
│ ├── vue-compat
│ │ ├── index.js
│ │ ├── index-3.js
│ │ └── index-2.js
│ ├── suit.js
│ ├── __tests__
│ │ ├── unescape.test.js
│ │ ├── warn.test.js
│ │ └── suit.test.js
│ ├── polyfills.js
│ └── unescape.js
├── mixins
│ ├── __mocks__
│ │ ├── panel.js
│ │ └── widget.js
│ ├── suit.js
│ ├── __tests__
│ │ └── suit.test.js
│ ├── panel.js
│ └── widget.js
├── components
│ ├── __tests__
│ │ ├── __snapshots__
│ │ │ ├── Configure.js.snap
│ │ │ ├── InstantSearch.js.snap
│ │ │ ├── Hits.js.snap
│ │ │ ├── HitsPerPage.js.snap
│ │ │ ├── StateResults.js.snap
│ │ │ ├── Snippet.js.snap
│ │ │ ├── Autocomplete.js.snap
│ │ │ ├── Highlight.js.snap
│ │ │ ├── SortBy.js.snap
│ │ │ ├── Panel.js.snap
│ │ │ ├── VoiceSearch.js.snap
│ │ │ ├── ClearRefinements.js.snap
│ │ │ ├── SearchBox.js.snap
│ │ │ └── ToggleRefinement.js.snap
│ │ ├── __Template.js
│ │ ├── ConfigureRelatedItems.js
│ │ ├── QueryRuleContext.js
│ │ ├── PoweredBy.js
│ │ ├── Autocomplete.js
│ │ ├── Stats.js
│ │ ├── Snippet.js
│ │ ├── QueryRuleCustomData.js
│ │ ├── Configure.js
│ │ ├── Index.js
│ │ ├── RelevantSort.js
│ │ ├── HitsPerPage.js
│ │ ├── Highlight.js
│ │ ├── Panel.js
│ │ └── Index-integration.js
│ ├── Snippet.vue
│ ├── InstantSearchSsr.js
│ ├── Highlight.vue
│ ├── QueryRuleContext.js
│ ├── ConfigureRelatedItems.js
│ ├── Stats.vue
│ ├── Configure.js
│ ├── Panel.vue
│ ├── QueryRuleCustomData.vue
│ ├── Index.js
│ ├── StateResults.vue
│ ├── Autocomplete.vue
│ ├── RelevantSort.vue
│ ├── HierarchicalMenuList.vue
│ ├── Hits.vue
│ ├── SortBy.vue
│ ├── Highlighter.vue
│ ├── ClearRefinements.vue
│ ├── HitsPerPage.vue
│ ├── __Template.vue
│ ├── NumericMenu.vue
│ ├── ToggleRefinement.vue
│ ├── MenuSelect.vue
│ ├── Breadcrumb.vue
│ ├── DynamicWidgets.js
│ └── InstantSearch.js
├── instantsearch.js
├── plugin.js
├── instantsearch.umd.js
├── connectors
│ └── connectStateResults.js
├── __tests__
│ └── index.js
└── widgets.js
├── .gitignore
├── stories
├── MemoryRouter.js
├── PoweredBy.stories.js
├── __Template.stories.js
├── Stats.stories.js
├── RatingMenu.stories.js
├── Panel.stories.js
├── RelevantSort.stories.js
├── DynamicWidgets.stories.js
├── Snippet.stories.js
├── SearchBox.stories.js
├── Highlight.stories.js
├── utils.js
├── ToggleRefinement.stories.js
├── ClearRefinements.stories.js
├── Index.stories.js
├── RefinementList.stories.js
├── HitsPerPage.stories.js
├── SortBy.stories.js
├── MenuSelect.stories.js
├── NumericMenu.stories.js
├── Pagination.stories.js
├── Configure.stories.js
└── Menu.stories.js
├── .eslintrc.js
├── LICENSE
├── jest.setup.js
├── __mocks__
└── instantsearch.js
│ └── es.js
├── test
└── utils
│ └── index.js
├── README.md
└── rollup.config.js
/.nvmrc:
--------------------------------------------------------------------------------
1 | 12.16.3
2 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | presets: ['es2015']
3 | }
4 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | build
2 | coverage
3 | docs
4 | dist
5 |
--------------------------------------------------------------------------------
/examples/ssr/.browserslistrc:
--------------------------------------------------------------------------------
1 | > 1%
2 | last 2 versions
3 | not ie <= 8
4 |
--------------------------------------------------------------------------------
/.codesandbox/ci.json:
--------------------------------------------------------------------------------
1 | {
2 | "sandboxes": ["/examples/e-commerce"]
3 | }
4 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | docs/* linguist-documentation
2 | build/* linguist-documentation
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "es5"
4 | }
5 |
--------------------------------------------------------------------------------
/examples/media/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | publicPath: './',
3 | };
4 |
--------------------------------------------------------------------------------
/examples/ssr/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: ['@vue/app'],
3 | };
4 |
--------------------------------------------------------------------------------
/examples/default-theme/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | publicPath: './',
3 | };
4 |
--------------------------------------------------------------------------------
/examples/media/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: ['@vue/app'],
3 | };
4 |
--------------------------------------------------------------------------------
/examples/default-theme/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: ['@vue/app'],
3 | };
4 |
--------------------------------------------------------------------------------
/examples/e-commerce/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: ['@vue/app'],
3 | };
4 |
--------------------------------------------------------------------------------
/examples/e-commerce/vue.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | publicPath: '/examples/e-commerce',
3 | };
4 |
--------------------------------------------------------------------------------
/examples/ssr/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | autoprefixer: {},
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/examples/e-commerce/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "proseWrap": "never",
3 | "singleQuote": true,
4 | "trailingComma": "es5"
5 | }
6 |
--------------------------------------------------------------------------------
/examples/e-commerce/src/utils.js:
--------------------------------------------------------------------------------
1 | export function formatNumber(value) {
2 | return Number(value).toLocaleString();
3 | }
4 |
--------------------------------------------------------------------------------
/examples/ssr/src/views/About.vue:
--------------------------------------------------------------------------------
1 |
2 | This is an about page
3 | Go to the search page:
5 |
6 |
7 |
12 |
--------------------------------------------------------------------------------
/src/mixins/__mocks__/panel.js:
--------------------------------------------------------------------------------
1 | export const createPanelProviderMixin = jest.fn(() => ({}));
2 |
3 | export const createPanelConsumerMixin = jest.fn(({ mapStateToCanRefine }) => ({
4 | methods: {
5 | mapStateToCanRefine,
6 | },
7 | }));
8 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/Configure.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders with scoped slots 1`] = `
4 |
6 | Use this component to have a different layout based on a certain state. 7 |
8 |9 | Fill in the slot, and get access to the following things: 10 |
11 |12 | results: [ 13 | "query", 14 | "hits", 15 | "page" 16 | ] 17 |18 |
19 | state: [ 20 | "query" 21 | ] 22 |23 |
6 | This widget doesn't render anything without a filled in default slot. 7 |
8 |9 | query, function to refine and results are provided. 10 |
11 |12 | refine: Function 13 |14 |
15 | currentRefinement: "" 16 |17 |
20 | indices
21 |
22 | :
23 |
25 | [
26 | {
27 | "index": "bla",
28 | "label": "bla bla bla ",
29 | "hits": [
30 | {
31 | "objectID": 1,
32 | "name": "hi"
33 | }
34 | ],
35 | "results": {}
36 | }
37 | ]
38 |
39 |
9 |
15 |
{{ item }}
16 | 7 | This is the body of the Panel. 8 |
9 |17 | This is the body of the Panel. 18 |
19 |37 | This is the body of the Panel. 38 |
39 |47 | This is the body of the Panel. 48 |
49 |8 | Use this component to have a different layout based on a certain state. 9 |
10 |11 | Fill in the slot, and get access to the following things: 12 |
13 |results: {{ Object.keys(state.results) }}
14 | state: {{ Object.keys(state.state) }}
15 | This widget doesn't render anything without a filled in default slot.
12 |query, function to refine and results are provided.
13 |refine: Function14 |
currentRefinement: "{{ state.currentRefinement }}"
15 | indices:{{ state.indices }}
18 |
27 |
41 |
42 |50 | status: recognizing 51 |
52 |53 | errorCode: 54 |
55 |56 | isListening: true 57 |
58 |59 | transcript: Hello 60 |
61 |62 | isSpeechFinal: false 63 |
64 |65 | isBrowserSupported: true 66 |
67 | by default', () => {
8 | __setState({
9 | items: [{ text: 'this is user data' }, { text: 'this too!' }],
10 | });
11 |
12 | const wrapper = mount(QueryRuleCustomData);
13 |
14 | expect(wrapper.html()).toMatchInlineSnapshot(`
15 |
16 |
17 |
18 | {
19 | "text": "this is user data"
20 | }
21 |
22 |
23 |
24 |
25 | {
26 | "text": "this too!"
27 | }
28 |
29 |
30 |
31 | `);
32 | });
33 |
34 | it('gives the items to the main slot', () => {
35 | const items = [{ text: 'this is user data' }, { text: 'this too!' }];
36 | __setState({
37 | items,
38 | });
39 |
40 | mount(QueryRuleCustomData, {
41 | scopedSlots: {
42 | default(props) {
43 | expect(props).toEqual({
44 | items,
45 | });
46 | },
47 | },
48 | });
49 | });
50 |
51 | it('gives individual items to the item slot', () => {
52 | const items = [{ text: 'this is user data' }, { text: 'this too!' }];
53 | expect.assertions(items.length);
54 | __setState({
55 | items,
56 | });
57 |
58 | mount(QueryRuleCustomData, {
59 | scopedSlots: {
60 | item(props) {
61 | expect(props).toEqual({
62 | item: expect.objectContaining({ text: expect.any(String) }),
63 | });
64 | },
65 | },
66 | });
67 | });
68 |
69 | it('accepts transformItems', () => {
70 | const transformItems = jest.fn();
71 | const wrapper = mount(QueryRuleCustomData, {
72 | propsData: {
73 | transformItems,
74 | },
75 | });
76 |
77 | expect(wrapper.vm.widgetParams).toEqual({
78 | transformItems,
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/src/components/__tests__/Configure.js:
--------------------------------------------------------------------------------
1 | import { mount } from '../../../test/utils';
2 | import { __setState } from '../../mixins/widget';
3 | import Configure from '../Configure';
4 |
5 | jest.mock('../../mixins/widget');
6 |
7 | const defaultState = {
8 | widgetParams: {
9 | searchParameters: {
10 | hitsPerPage: 5,
11 | },
12 | },
13 | };
14 |
15 | const defaultProps = {
16 | hitsPerPage: 5,
17 | };
18 |
19 | const defaultSlot = `
20 |
21 |
22 | hitsPerPage: {{ searchParameters.hitsPerPage }}
23 |
24 |
25 | `;
26 |
27 | it('accepts SearchParameters from attributes', () => {
28 | const wrapper = mount(Configure, {
29 | propsData: {
30 | ...defaultProps,
31 | distinct: true,
32 | },
33 | });
34 |
35 | expect(wrapper.vm.widgetParams.searchParameters).toEqual({
36 | hitsPerPage: 5,
37 | distinct: true,
38 | });
39 | });
40 |
41 | it('renders null without default slot', () => {
42 | __setState(null);
43 |
44 | const wrapper = mount(Configure, {
45 | propsData: defaultProps,
46 | });
47 |
48 | expect(wrapper).toHaveEmptyHTML();
49 | });
50 |
51 | it('renders null without state', () => {
52 | __setState(null);
53 |
54 | const wrapper = mount({
55 | components: { Configure },
56 | data() {
57 | return { props: defaultProps };
58 | },
59 | template: `
60 |
61 | ${defaultSlot}
62 |
63 | `,
64 | });
65 |
66 | expect(wrapper).toHaveEmptyHTML();
67 | });
68 |
69 | it('renders with scoped slots', () => {
70 | __setState({ ...defaultState });
71 |
72 | const wrapper = mount({
73 | components: { Configure },
74 | data() {
75 | return { props: defaultProps };
76 | },
77 | template: `
78 |
79 | ${defaultSlot}
80 |
81 | `,
82 | });
83 |
84 | expect(wrapper.html()).toMatchSnapshot();
85 | });
86 |
--------------------------------------------------------------------------------
/stories/utils.js:
--------------------------------------------------------------------------------
1 | import algoliasearch from 'algoliasearch/lite';
2 |
3 | export const previewWrapper = ({
4 | searchClient = algoliasearch('latency', '6be0576ff61c053d5f9a3225e2a90f76'),
5 | insightsClient,
6 | indexName = 'instant_search',
7 | hits = `
8 |
9 |
10 | -
15 |
19 |
20 |
21 |
22 |
23 | Rating: {{ item.rating }}✭
24 | Price: {{ item.price }}$
25 |
26 |
27 |
28 |
29 | `,
30 | filters = `
31 |
32 |
33 | `,
34 | routing,
35 | } = {}) => () => ({
36 | template: `
37 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | ${filters}
50 |
51 |
52 |
53 |
54 |
55 | ${hits}
56 |
57 |
58 |
59 |
60 |
61 | `,
62 | data() {
63 | return {
64 | searchClient,
65 | routing,
66 | insightsClient,
67 | };
68 | },
69 | });
70 |
--------------------------------------------------------------------------------
/stories/ToggleRefinement.stories.js:
--------------------------------------------------------------------------------
1 | import { storiesOf } from '@storybook/vue';
2 | import { previewWrapper } from './utils';
3 |
4 | storiesOf('ais-toggle-refinement', module)
5 | .addDecorator(previewWrapper())
6 | .add('default', () => ({
7 | template: `
8 |
12 | `,
13 | }))
14 | .add('with an on value', () => ({
15 | template: `
16 |
21 | `,
22 | }))
23 | .add('with an on value (with multiple values)', () => ({
24 | template: `
25 |
30 | `,
31 | }))
32 | .add('with an off value', () => ({
33 | template: `
34 |
39 | `,
40 | }))
41 | .add('with a custom render', () => ({
42 | template: `
43 |
47 |
48 |
49 | {{ value.name }}
50 | {{ value.isRefined ? '(is enabled)' : '(is disabled)' }}
51 |
52 |
53 |
54 | `,
55 | }))
56 | .add('with a Panel', () => ({
57 | template: `
58 |
59 | Toggle Refinement
60 |
64 | Footer
65 |
66 | `,
67 | }));
68 |
--------------------------------------------------------------------------------
/__mocks__/instantsearch.js/es.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-commonjs */
2 | const isPlainObject = require('lodash/isPlainObject');
3 |
4 | class RoutingManager {
5 | constructor(routing) {
6 | this._routing = routing;
7 | }
8 | }
9 |
10 | class Helper {
11 | constructor() {
12 | this.search = jest.fn();
13 | this.setClient = jest.fn(() => this);
14 | this.setIndex = jest.fn(() => this);
15 | }
16 | }
17 |
18 | const fakeInstantSearch = jest.fn(
19 | ({
20 | indexName,
21 | searchClient,
22 | routing,
23 | stalledSearchDelay,
24 | searchFunction,
25 | }) => {
26 | if (!searchClient && !isPlainObject(searchClient)) {
27 | throw new Error('need searchClient to be a plain object');
28 | }
29 | if (!indexName) {
30 | throw new Error('need indexName to be a string');
31 | }
32 |
33 | const instantsearchInstance = {
34 | _stalledSearchDelay: stalledSearchDelay || 200,
35 | _searchFunction: searchFunction,
36 | routing: new RoutingManager(routing),
37 | helper: new Helper(),
38 | client: searchClient,
39 | start: jest.fn(() => {
40 | instantsearchInstance.started = true;
41 | }),
42 | dispose: jest.fn(() => {
43 | instantsearchInstance.started = false;
44 | }),
45 | mainIndex: {
46 | $$type: 'ais.index',
47 | _widgets: [],
48 | addWidgets(widgets) {
49 | this._widgets.push(...widgets);
50 | },
51 | getWidgets() {
52 | return this._widgets;
53 | },
54 | },
55 | addWidgets(widgets) {
56 | instantsearchInstance.mainIndex.addWidgets(widgets);
57 | },
58 | removeWidgets(widgets) {
59 | widgets.forEach(widget => {
60 | const i = instantsearchInstance.mainIndex._widgets.findIndex(widget);
61 | if (i === -1) {
62 | return;
63 | }
64 | instantsearchInstance.mainIndex._widgets.splice(i, 1);
65 | });
66 | },
67 | };
68 |
69 | return instantsearchInstance;
70 | }
71 | );
72 |
73 | module.exports = fakeInstantSearch;
74 |
--------------------------------------------------------------------------------
/src/components/__Template.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
15 |
16 |
17 |
18 |
19 |
65 |
--------------------------------------------------------------------------------
/stories/ClearRefinements.stories.js:
--------------------------------------------------------------------------------
1 | import { storiesOf } from '@storybook/vue';
2 | import { previewWrapper } from './utils';
3 |
4 | storiesOf('ais-clear-refinements', module)
5 | .addDecorator(previewWrapper())
6 | .add('default', () => ({
7 | template: `
8 |
9 | `,
10 | }))
11 | .add('also clearing query', () => ({
12 | template: `
13 |
14 |
15 |
16 |
17 |
18 | TIP: type something first
19 |
20 | `,
21 | }))
22 | .add('not clearing "brand"', () => ({
23 | template: `
24 |
25 |
26 |
27 |
28 |
35 |
36 |
37 |
38 |
39 | `,
40 | }))
41 | .add('with a custom label', () => ({
42 | template: `
43 |
44 | Remove the refinements
45 |
46 | `,
47 | }))
48 | .add('with a custom render', () => ({
49 | template: `
50 |
51 |
52 |
58 |
59 |
60 | `,
61 | }))
62 | .add('with a Panel', () => ({
63 | template: `
64 |
65 | Clear refinements
66 |
67 | Footer
68 |
69 | `,
70 | }));
71 |
--------------------------------------------------------------------------------
/test/utils/index.js:
--------------------------------------------------------------------------------
1 | import {
2 | isVue3,
3 | createApp as _createApp,
4 | createSSRApp as _createSSRApp,
5 | nextTick as _nextTick,
6 | Vue2,
7 | } from '../../src/util/vue-compat';
8 |
9 | export const htmlCompat = function(html) {
10 | if (isVue3) {
11 | return html
12 | .replace(/disabled=""/g, 'disabled="disabled"')
13 | .replace(/hidden=""/g, 'hidden="hidden"')
14 | .replace(/novalidate=""/g, 'novalidate="novalidate"')
15 | .replace(/required=""/g, 'required="required"');
16 | } else {
17 | return html;
18 | }
19 | };
20 |
21 | export const mount = isVue3
22 | ? (component, options = {}) => {
23 | const {
24 | propsData,
25 | mixins,
26 | provide,
27 | slots,
28 | scopedSlots,
29 | stubs,
30 | ...restOptions
31 | } = options;
32 | // If we `import` this, it will try to import Vue3-only APIs like `defineComponent`,
33 | // and jest will fail. So we need to `require` it.
34 | const wrapper = require('@vue/test-utils2').mount(component, {
35 | ...restOptions,
36 | props: propsData,
37 | global: {
38 | mixins,
39 | provide,
40 | stubs,
41 | },
42 | slots: {
43 | ...slots,
44 | ...scopedSlots,
45 | },
46 | });
47 | wrapper.destroy = wrapper.unmount;
48 | wrapper.htmlCompat = function() {
49 | return htmlCompat(this.html());
50 | };
51 | return wrapper;
52 | }
53 | : (component, options = {}) => {
54 | const wrapper = require('@vue/test-utils').mount(component, options);
55 | wrapper.htmlCompat = function() {
56 | return htmlCompat(this.html());
57 | };
58 | return wrapper;
59 | };
60 |
61 | export const createApp = props => {
62 | if (isVue3) {
63 | return _createApp(props);
64 | } else {
65 | return new Vue2(props);
66 | }
67 | };
68 |
69 | export const createSSRApp = props => {
70 | if (isVue3) {
71 | return _createSSRApp(props);
72 | } else {
73 | return new Vue2(props);
74 | }
75 | };
76 |
77 | export const nextTick = () => (isVue3 ? _nextTick() : Vue2.nextTick());
78 |
--------------------------------------------------------------------------------
/src/util/testutils/helper.js:
--------------------------------------------------------------------------------
1 | export const createSerializedState = () => ({
2 | _rawResults: [
3 | {
4 | hits: [
5 | {
6 | objectID: 'doggos',
7 | name: 'the dog',
8 | },
9 | ],
10 | nbHits: 1071,
11 | page: 0,
12 | nbPages: 200,
13 | hitsPerPage: 5,
14 | processingTimeMS: 3,
15 | facets: {
16 | genre: {
17 | Comedy: 1071,
18 | Drama: 290,
19 | Romance: 202,
20 | },
21 | },
22 | exhaustiveFacetsCount: true,
23 | exhaustiveNbHits: true,
24 | query: 'hi',
25 | queryAfterRemoval: 'hi',
26 | params:
27 | 'query=hi&hitsPerPage=5&page=0&highlightPreTag=__ais-highlight__&highlightPostTag=__%2Fais-highlight__&facets=%5B%22genre%22%5D&tagFilters=&facetFilters=%5B%5B%22genre%3AComedy%22%5D%5D',
28 | index: 'movies',
29 | },
30 | {
31 | hits: [{ objectID: 'doggos' }],
32 | nbHits: 5131,
33 | page: 0,
34 | nbPages: 1000,
35 | hitsPerPage: 1,
36 | processingTimeMS: 7,
37 | facets: {
38 | genre: {
39 | Comedy: 1071,
40 | Drama: 1642,
41 | Romance: 474,
42 | },
43 | },
44 | exhaustiveFacetsCount: true,
45 | exhaustiveNbHits: true,
46 | query: 'hi',
47 | queryAfterRemoval: 'hi',
48 | params:
49 | 'query=hi&hitsPerPage=1&page=0&highlightPreTag=__ais-highlight__&highlightPostTag=__%2Fais-highlight__&attributesToRetrieve=%5B%5D&attributesToHighlight=%5B%5D&attributesToSnippet=%5B%5D&tagFilters=&analytics=false&clickAnalytics=false&facets=genre',
50 | index: 'movies',
51 | },
52 | ],
53 | _state: {
54 | index: 'movies',
55 | query: 'hi',
56 | facets: [],
57 | disjunctiveFacets: ['genre'],
58 | hierarchicalFacets: [],
59 | facetsRefinements: {},
60 | facetsExcludes: {},
61 | disjunctiveFacetsRefinements: { genre: ['Comedy'] },
62 | numericRefinements: {},
63 | tagRefinements: [],
64 | hierarchicalFacetsRefinements: {},
65 | hitsPerPage: 5,
66 | page: 0,
67 | highlightPreTag: '__ais-highlight__',
68 | highlightPostTag: '__/ais-highlight__',
69 | },
70 | });
71 |
--------------------------------------------------------------------------------
/src/components/NumericMenu.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
13 |
14 | -
19 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
79 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/SearchBox.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`renders HTML correctly 1`] = `
4 |
5 |
57 |
58 | `;
59 |
--------------------------------------------------------------------------------
/src/components/__tests__/Index.js:
--------------------------------------------------------------------------------
1 | import { mount } from '../../../test/utils';
2 | import Index from '../Index';
3 | import { __setWidget } from '../../mixins/widget';
4 | import { Vue2, isVue3, isVue2 } from '../../util/vue-compat';
5 | jest.mock('../../mixins/widget');
6 |
7 | beforeEach(() => {
8 | jest.resetAllMocks();
9 | });
10 |
11 | it('passes props to widgetParams', () => {
12 | const wrapper = mount(Index, {
13 | propsData: {
14 | indexName: 'the name',
15 | indexId: 'the id',
16 | },
17 | });
18 |
19 | expect(wrapper.vm.widgetParams).toEqual({
20 | indexName: 'the name',
21 | indexId: 'the id',
22 | });
23 | });
24 |
25 | it('renders just a div by default', () => {
26 | const wrapper = mount(Index, {
27 | propsData: {
28 | indexName: 'index name',
29 | },
30 | });
31 |
32 | expect(wrapper.html()).toMatchInlineSnapshot(`
33 |
34 |
35 | `);
36 | });
37 |
38 | it('renders its children', () => {
39 | const wrapper = mount(Index, {
40 | propsData: {
41 | indexName: 'index name',
42 | },
43 | slots: {
44 | default: 'hi there!',
45 | },
46 | });
47 |
48 | expect(wrapper.html()).toMatchInlineSnapshot(`
49 |
50 |
51 | hi there!
52 |
53 |
54 | `);
55 | });
56 |
57 | it('provides the index widget', done => {
58 | const indexWidget = { $$type: 'ais.index' };
59 | __setWidget(indexWidget);
60 |
61 | const ChildComponent = {
62 | inject: ['$_ais_getParentIndex'],
63 | mounted() {
64 | this.$nextTick(() => {
65 | expect(typeof this.$_ais_getParentIndex).toBe('function');
66 | expect(this.$_ais_getParentIndex()).toEqual(indexWidget);
67 | done();
68 | });
69 | },
70 | render() {
71 | return null;
72 | },
73 | };
74 |
75 | if (isVue2) {
76 | Vue2.config.errorHandler = done;
77 | }
78 |
79 | mount(
80 | {
81 | components: { Index, ChildComponent },
82 | template: `
83 |
84 |
85 |
86 | `,
87 | },
88 | isVue3 && {
89 | global: {
90 | config: {
91 | errorHandler: done,
92 | },
93 | },
94 | }
95 | );
96 | });
97 |
--------------------------------------------------------------------------------
/src/components/__tests__/RelevantSort.js:
--------------------------------------------------------------------------------
1 | import { mount } from '../../../test/utils';
2 | import RelevantSort from '../RelevantSort.vue';
3 | import { __setState } from '../../mixins/widget';
4 | jest.mock('../../mixins/widget');
5 |
6 | describe('renders correctly', () => {
7 | test('no virtual replica', () => {
8 | __setState({
9 | isVirtualReplica: false,
10 | isRelevantSorted: false,
11 | });
12 | const wrapper = mount(RelevantSort);
13 | expect(wrapper).toHaveEmptyHTML();
14 | });
15 |
16 | test('not relevant sorted', () => {
17 | __setState({
18 | isVirtualReplica: true,
19 | isRelevantSorted: false,
20 | });
21 | const wrapper = mount(RelevantSort);
22 | expect(wrapper.html()).toMatchInlineSnapshot(`
23 |
24 |
25 |
26 |
31 |
32 | `);
33 | });
34 |
35 | test('relevant sorted', () => {
36 | __setState({
37 | isVirtualReplica: true,
38 | isRelevantSorted: true,
39 | });
40 | const wrapper = mount(RelevantSort);
41 | expect(wrapper.html()).toMatchInlineSnapshot(`
42 |
43 |
44 |
45 |
50 |
51 | `);
52 | });
53 | });
54 |
55 | it("calls the connector's refine function with 0 and undefined", async () => {
56 | __setState({
57 | isRelevantSorted: true,
58 | isVirtualReplica: true,
59 | refine: jest.fn(() => {
60 | wrapper.vm.state.isRelevantSorted = !wrapper.vm.state.isRelevantSorted;
61 | }),
62 | });
63 |
64 | const wrapper = mount(RelevantSort);
65 |
66 | const button = wrapper.find('button');
67 |
68 | await button.trigger('click');
69 | expect(wrapper.vm.state.refine).toHaveBeenLastCalledWith(0);
70 |
71 | await button.trigger('click');
72 | expect(wrapper.vm.state.refine).toHaveBeenLastCalledWith(undefined);
73 |
74 | await button.trigger('click');
75 | expect(wrapper.vm.state.refine).toHaveBeenLastCalledWith(0);
76 | });
77 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [![Vue InstantSearch logo][logo]][website]
2 |
3 | InstantSearch projects: **Vue InstantSearch**
4 | | [InstantSearch.js][instantsearch.js-github]
5 | | [React InstantSearch][react-instantsearch-github]
6 | | [Angular InstantSearch][angular-instantsearch-github]
7 | | [InstantSearch Android][instantsearch-android-github]
8 | | [InstantSearch iOS][instantsearch-ios-github].
9 |
10 | ## Vue InstantSearch
11 |
12 | > ⚡ Lightning-fast search for Vue.js apps
13 |
14 | Built by [Algolia][algolia-website].
15 |
16 | This repository holds the code for the Vue InstantSearch project.
17 |
18 | ## Documentation
19 |
20 | There's a dedicated documentation available at [www.algolia.com/doc/guides/building-search-ui/getting-started/vue][website].
21 |
22 | ## Installation
23 |
24 | Vue InstantSearch is available in the npm registry. Install it:
25 |
26 | ```sh
27 | # with npm
28 | npm install --save vue-instantsearch
29 |
30 | # with yarn
31 | yarn add vue-instantsearch
32 | ```
33 |
34 | To learn more about the usage, follow our [getting started guide][getting-started-guide].
35 |
36 | ## Troubleshooting
37 |
38 | Encountering an issue? Before reaching out to support, we recommend heading to our [FAQ](https://www.algolia.com/doc/guides/building-search-ui/troubleshooting/faq/vue/) where you will find answers for the most common issues and gotchas with the library.
39 |
40 | ## Contributing, dev, release
41 |
42 | We welcome all contributors, from casual to regular. You are only
43 | one command away to start the developer environment,
44 | [read our CONTRIBUTING guide](.github/CONTRIBUTING.md).
45 |
46 | [logo]: .github/vue-instantsearch-readme.png
47 | [website]: https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/vue/
48 | [getting-started-guide]: https://www.algolia.com/doc/guides/building-search-ui/getting-started/vue/
49 | [algolia-website]: https://www.algolia.com/
50 | [instantsearch.js-github]: https://github.com/algolia/instantsearch.js
51 | [react-instantsearch-github]: https://github.com/algolia/react-instantsearch
52 | [instantsearch-android-github]: https://github.com/algolia/instantsearch-android
53 | [instantsearch-ios-github]: https://github.com/algolia/instantsearch-ios
54 | [angular-instantsearch-github]: https://github.com/algolia/angular-instantsearch
55 |
--------------------------------------------------------------------------------
/src/util/vue-compat/index-2.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 |
3 | const isVue2 = true;
4 | const isVue3 = false;
5 | const Vue2 = Vue;
6 | const version = Vue.version;
7 |
8 | export { Vue, Vue2, isVue2, isVue3, version };
9 |
10 | export function renderCompat(fn) {
11 | return function(createElement) {
12 | return fn.call(this, createElement);
13 | };
14 | }
15 |
16 | export function getDefaultSlot(component) {
17 | return component.$slots.default;
18 | }
19 |
20 | // Vue3-only APIs
21 | export const computed = undefined;
22 | export const createApp = undefined;
23 | export const createSSRApp = undefined;
24 | export const createRef = undefined;
25 | export const customRef = undefined;
26 | export const defineAsyncComponent = undefined;
27 | export const defineComponent = undefined;
28 | export const del = undefined;
29 | export const getCurrentInstance = undefined;
30 | export const h = undefined;
31 | export const inject = undefined;
32 | export const isRaw = undefined;
33 | export const isReactive = undefined;
34 | export const isReadonly = undefined;
35 | export const isRef = undefined;
36 | export const markRaw = undefined;
37 | export const nextTick = undefined;
38 | export const onActivated = undefined;
39 | export const onBeforeMount = undefined;
40 | export const onBeforeUnmount = undefined;
41 | export const onBeforeUpdate = undefined;
42 | export const onDeactivated = undefined;
43 | export const onErrorCaptured = undefined;
44 | export const onMounted = undefined;
45 | export const onServerPrefetch = undefined;
46 | export const onUnmounted = undefined;
47 | export const onUpdated = undefined;
48 | export const provide = undefined;
49 | export const proxyRefs = undefined;
50 | export const reactive = undefined;
51 | export const readonly = undefined;
52 | export const ref = undefined;
53 | export const set = undefined;
54 | export const shallowReactive = undefined;
55 | export const shallowReadonly = undefined;
56 | export const shallowRef = undefined;
57 | export const toRaw = undefined;
58 | export const toRef = undefined;
59 | export const toRefs = undefined;
60 | export const triggerRef = undefined;
61 | export const unref = undefined;
62 | export const useCSSModule = undefined;
63 | export const useCssModule = undefined;
64 | export const warn = undefined;
65 | export const watch = undefined;
66 | export const watchEffect = undefined;
67 |
--------------------------------------------------------------------------------
/.github/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | First of all, thanks for contributing! You can check out the issues tagged with "difficulty: easy ❄️" for a start.
4 |
5 | ## Get ready for contributions
6 |
7 | You'll first need to install the dependencies:
8 |
9 | ```sh
10 | yarn install
11 | ```
12 |
13 | Then we recommend that you run:
14 |
15 | ```sh
16 | yarn test:watch
17 | ```
18 |
19 | This will watch the files for changes and build the CommonJS bundle that is required by the tests.
20 | It will the run the test on that newly generated build.
21 |
22 | ## Commit format
23 |
24 | The project uses [conventional commit format](https://github.com/angular/angular.js/blob/master/CONTRIBUTING.md) to automate the updates of the CHANGELOG.md.
25 |
26 | ## Releasing the library
27 |
28 | To release the library, the first step is to create a "release PR" by running:
29 |
30 | ```bash
31 | yarn release
32 | ```
33 |
34 | For that script to work, you need to provide `GITHUB_TOKEN` environment variable. You can either prepend it or put it in `.env` file.
35 |
36 | ```bash
37 | GITHUB_TOKEN=xyz yarn release
38 |
39 | or
40 |
41 | echo "GITHUB_TOKEN=xyz" >> .env
42 | yarn release
43 | ```
44 |
45 | You can create a token at [GitHub](https://github.com/settings/tokens/new) with `Full control of private repositories` scope.
46 |
47 | This will ask you the new version of the library, and update all the required files accordingly.
48 | At the end of the process, the release branch is pushed to GitHub and a Pull Request is automatically created.
49 |
50 | Once the changes are approved you can merge it there. Then CircleCI will be triggered and it will run
51 |
52 | ```bash
53 | yarn shipjs trigger
54 | ```
55 |
56 | This will:
57 |
58 | - publish the new version on NPM
59 | - tag and push the tag to GitHub
60 |
61 | ## Documentation
62 |
63 | You can either directly click on "EDIT ON GITHUB" links on the live documentation: https://community.algolia.com/vue-instantsearch/.
64 |
65 | Or you can run the documentation locally:
66 |
67 | ```sh
68 | $ npm run docs:watch
69 | ```
70 |
71 | ### Deploying documentation
72 |
73 | The documentation is automatically deployed on temporary URLs by [netlify](https://www.netlify.com/) on pull requests.
74 |
75 | To release the documentation to the community website, you can run:
76 |
77 | ```bash
78 | yarn run docs:deploy
79 | ```
80 |
--------------------------------------------------------------------------------
/src/components/ToggleRefinement.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
13 |
28 |
29 |
30 |
31 |
32 |
84 |
--------------------------------------------------------------------------------
/stories/Index.stories.js:
--------------------------------------------------------------------------------
1 | import { storiesOf } from '@storybook/vue';
2 | import algoliasearch from 'algoliasearch';
3 |
4 | storiesOf('ais-index', module)
5 | .add('default', () => ({
6 | template: `
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | `,
29 | data() {
30 | return {
31 | searchClient: algoliasearch(
32 | 'latency',
33 | '6be0576ff61c053d5f9a3225e2a90f76'
34 | ),
35 | };
36 | },
37 | }))
38 | .add('shared and individual widgets', () => ({
39 | template: `
40 |
41 |
42 |
43 |
44 |
45 |
46 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | `,
68 | data() {
69 | return {
70 | searchClient: algoliasearch(
71 | 'latency',
72 | '6be0576ff61c053d5f9a3225e2a90f76'
73 | ),
74 | };
75 | },
76 | }));
77 |
--------------------------------------------------------------------------------
/src/components/MenuSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
13 |
36 |
37 |
38 |
39 |
40 |
92 |
--------------------------------------------------------------------------------
/stories/RefinementList.stories.js:
--------------------------------------------------------------------------------
1 | import { storiesOf } from '@storybook/vue';
2 | import { previewWrapper } from './utils';
3 |
4 | storiesOf('ais-refinement-list', module)
5 | .addDecorator(previewWrapper({ filters: '' }))
6 | .add('default', () => ({
7 | template: `
8 |
9 | `,
10 | }))
11 | .add('with searchbox', () => ({
12 | template: `
13 |
17 | `,
18 | }))
19 | .add('with show more', () => ({
20 | template: `
21 |
25 | `,
26 | }))
27 | .add('with transform items', () => ({
28 | template: `
29 |
33 | `,
34 | methods: {
35 | transformItems(items) {
36 | return items.map(item =>
37 | Object.assign(item, {
38 | label: item.label.toLocaleUpperCase(),
39 | })
40 | );
41 | },
42 | },
43 | }))
44 | .add('item custom rendering', () => ({
45 | template: `
46 |
47 |
48 |
53 |
54 | `,
55 | }))
56 | .add('full custom rendering', () => ({
57 | template: `
58 |
59 |
66 |
67 |
76 |
77 |
80 |
81 | `,
82 | }));
83 |
--------------------------------------------------------------------------------
/stories/HitsPerPage.stories.js:
--------------------------------------------------------------------------------
1 | import { storiesOf } from '@storybook/vue';
2 | import { previewWrapper } from './utils';
3 |
4 | storiesOf('ais-hits-per-page', module)
5 | .addDecorator(previewWrapper())
6 | .add('default', () => ({
7 | template: `
8 |
14 | `,
15 | }))
16 | .add('with different default', () => ({
17 | template: `
18 |
24 | `,
25 | }))
26 | .add('with transform items', () => ({
27 | template: `
28 |
35 | `,
36 | methods: {
37 | transformItems(items) {
38 | return items.map(item =>
39 | Object.assign({}, item, {
40 | label: item.label.toUpperCase(),
41 | })
42 | );
43 | },
44 | },
45 | }))
46 | .add('with a custom render', () => ({
47 | template: `
48 |
54 |
55 |
56 |
66 |
67 |
68 | `,
69 | }))
70 | .add('with a Panel', () => ({
71 | template: `
72 |
73 | Hits per page
74 |
80 | Footer
81 |
82 | `,
83 | }));
84 |
--------------------------------------------------------------------------------
/stories/SortBy.stories.js:
--------------------------------------------------------------------------------
1 | import { storiesOf } from '@storybook/vue';
2 | import { previewWrapper } from './utils';
3 |
4 | storiesOf('ais-sort-by', module)
5 | .addDecorator(previewWrapper())
6 | .add('default', () => ({
7 | template: `
8 |
15 | `,
16 | }))
17 | .add('with transform items', () => ({
18 | template: `
19 |
27 | `,
28 | methods: {
29 | transformItems(items) {
30 | return items.map(item =>
31 | Object.assign({}, item, {
32 | label: item.label.toUpperCase(),
33 | })
34 | );
35 | },
36 | },
37 | }))
38 | .add('with custom render', () => ({
39 | template: `
40 |
47 |
48 |
49 | -
50 |
53 |
54 |
55 |
56 |
57 | `,
58 | }))
59 | .add('with a Panel', () => ({
60 | template: `
61 |
62 | Sort By
63 |
70 | Footer
71 |
72 | `,
73 | }));
74 |
--------------------------------------------------------------------------------
/src/components/__tests__/HitsPerPage.js:
--------------------------------------------------------------------------------
1 | import { mount } from '../../../test/utils';
2 | import { __setState } from '../../mixins/widget';
3 | import HitsPerPage from '../HitsPerPage.vue';
4 |
5 | jest.mock('../../mixins/widget');
6 | jest.mock('../../mixins/panel');
7 |
8 | const defaultState = {
9 | items: [
10 | {
11 | label: '10 results',
12 | value: 10,
13 | default: true,
14 | },
15 | {
16 | label: '20 results',
17 | value: 20,
18 | },
19 | ],
20 | };
21 |
22 | const defaultProps = {
23 | items: [
24 | {
25 | label: '10 results',
26 | value: 10,
27 | default: true,
28 | },
29 | {
30 | label: '20 results',
31 | value: 20,
32 | },
33 | ],
34 | };
35 |
36 | it('accepts a transformItems prop', () => {
37 | __setState({ ...defaultState });
38 |
39 | const transformItems = () => {};
40 |
41 | const wrapper = mount(HitsPerPage, {
42 | propsData: {
43 | ...defaultProps,
44 | transformItems,
45 | },
46 | });
47 |
48 | expect(wrapper.vm.widgetParams.transformItems).toBe(transformItems);
49 | });
50 |
51 | it('renders correctly', () => {
52 | __setState({ ...defaultState });
53 |
54 | const wrapper = mount(HitsPerPage, {
55 | propsData: defaultProps,
56 | });
57 |
58 | expect(wrapper.html()).toMatchSnapshot();
59 | });
60 |
61 | it('calls `refine` with the `value` on `change`', async () => {
62 | __setState({
63 | ...defaultState,
64 | refine: jest.fn(),
65 | });
66 |
67 | const wrapper = mount(HitsPerPage, {
68 | propsData: defaultProps,
69 | });
70 |
71 | await wrapper.setData({
72 | selected: 20,
73 | });
74 |
75 | await wrapper.find('select').trigger('change');
76 |
77 | expect(wrapper.vm.state.refine).toHaveBeenLastCalledWith(20);
78 | });
79 |
80 | it('calls the Panel mixin with `hasNoResults`', async () => {
81 | __setState({
82 | ...defaultState,
83 | hasNoResults: false,
84 | });
85 |
86 | const wrapper = mount(HitsPerPage, {
87 | propsData: defaultProps,
88 | });
89 |
90 | const mapStateToCanRefine = () =>
91 | wrapper.vm.mapStateToCanRefine(wrapper.vm.state);
92 |
93 | expect(mapStateToCanRefine()).toBe(true);
94 |
95 | await wrapper.setData({
96 | state: {
97 | hasNoResults: true,
98 | },
99 | });
100 |
101 | expect(mapStateToCanRefine()).toBe(false);
102 |
103 | expect(wrapper.vm.mapStateToCanRefine({})).toBe(false);
104 | });
105 |
--------------------------------------------------------------------------------
/src/components/__tests__/Highlight.js:
--------------------------------------------------------------------------------
1 | import { mount } from '../../../test/utils';
2 | import Highlight from '../Highlight.vue';
3 |
4 | jest.unmock('instantsearch.js/es');
5 |
6 | test('renders proper HTML', () => {
7 | const hit = {
8 | _highlightResult: {
9 | attr: {
10 | value: `content`,
11 | },
12 | },
13 | };
14 |
15 | const wrapper = mount(Highlight, {
16 | propsData: {
17 | attribute: 'attr',
18 | hit,
19 | },
20 | });
21 |
22 | expect(wrapper.html()).toMatchSnapshot();
23 | });
24 |
25 | test('renders proper HTML with highlightTagName', () => {
26 | const hit = {
27 | _highlightResult: {
28 | attr: {
29 | value: `content`,
30 | },
31 | },
32 | };
33 |
34 | const wrapper = mount(Highlight, {
35 | propsData: {
36 | attribute: 'attr',
37 | highlightedTagName: 'marquee',
38 | hit,
39 | },
40 | });
41 |
42 | expect(wrapper.html()).toMatchSnapshot();
43 | });
44 |
45 | test('should render an empty string in production if attribute is not highlighted', () => {
46 | process.env.NODE_ENV = 'production';
47 | const hit = {
48 | _highlightResult: {},
49 | };
50 |
51 | const wrapper = mount(Highlight, {
52 | propsData: {
53 | attribute: 'attr',
54 | hit,
55 | },
56 | });
57 |
58 | expect(wrapper.html()).toMatchSnapshot();
59 | });
60 |
61 | test('allows usage of dot delimited path to access nested attribute', () => {
62 | const hit = {
63 | _highlightResult: {
64 | attr: {
65 | nested: {
66 | value: `nested val`,
67 | },
68 | },
69 | },
70 | };
71 |
72 | const wrapper = mount(Highlight, {
73 | propsData: {
74 | attribute: 'attr.nested',
75 | hit,
76 | },
77 | });
78 |
79 | expect(wrapper.html()).toMatchSnapshot();
80 | });
81 |
82 | test('retains whitespace nodes', () => {
83 | const hit = {
84 | _highlightResult: {
85 | attr: {
86 | value: `content searching`,
87 | },
88 | },
89 | };
90 |
91 | const wrapper = mount(Highlight, {
92 | propsData: {
93 | attribute: 'attr',
94 | highlightedTagName: 'marquee',
95 | hit,
96 | },
97 | });
98 |
99 | expect(wrapper.html()).toBe(
100 | `con ing`
101 | );
102 | });
103 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import vueV2 from 'rollup-plugin-vue2';
2 | import vueV3 from 'rollup-plugin-vue3';
3 | import buble from 'rollup-plugin-buble';
4 | import filesize from 'rollup-plugin-filesize';
5 | import resolve from 'rollup-plugin-node-resolve';
6 | import commonjs from 'rollup-plugin-commonjs';
7 | import { terser } from 'rollup-plugin-terser';
8 | import replace from 'rollup-plugin-replace';
9 | import json from 'rollup-plugin-json';
10 |
11 | if (process.env.VUE_VERSION !== 'vue2' && process.env.VUE_VERSION !== 'vue3') {
12 | throw new Error(
13 | 'The environment variable VUE_VERSION (`vue2` | `vue3`) is required.'
14 | );
15 | }
16 |
17 | const processEnv = conf => ({
18 | // parenthesis to avoid syntax errors in places where {} is interpreted as a block
19 | 'process.env': `(${JSON.stringify(conf)})`,
20 | });
21 |
22 | const vuePlugin = process.env.VUE_VERSION === 'vue3' ? vueV3 : vueV2;
23 | const outputDir = process.env.VUE_VERSION === 'vue3' ? 'vue3' : 'vue2';
24 |
25 | const plugins = [
26 | vuePlugin({ compileTemplate: true, css: false }),
27 | commonjs(),
28 | json(),
29 | buble({
30 | transforms: {
31 | dangerousForOf: true,
32 | },
33 | }),
34 | replace(processEnv({ NODE_ENV: 'production' })),
35 | terser({
36 | sourcemap: true,
37 | }),
38 | filesize(),
39 | ];
40 |
41 | const external = id =>
42 | ['algoliasearch-helper', 'instantsearch.js', 'vue', 'mitt'].some(
43 | dep => id === dep || id.startsWith(`${dep}/`)
44 | );
45 |
46 | export default [
47 | {
48 | input: 'src/instantsearch.js',
49 | external,
50 | output: [
51 | {
52 | sourcemap: true,
53 | file: `${outputDir}/cjs/index.js`,
54 | format: 'cjs',
55 | exports: 'named',
56 | },
57 | ],
58 | plugins: [...plugins],
59 | },
60 | {
61 | input: 'src/instantsearch.js',
62 | external,
63 | output: [
64 | {
65 | sourcemap: true,
66 | dir: `${outputDir}/es`,
67 | format: 'es',
68 | },
69 | ],
70 | preserveModules: true,
71 | plugins: [...plugins],
72 | },
73 | {
74 | input: 'src/instantsearch.umd.js',
75 | external: ['vue'],
76 | output: [
77 | {
78 | sourcemap: true,
79 | file: `${outputDir}/umd/index.js`,
80 | format: 'umd',
81 | name: 'VueInstantSearch',
82 | exports: 'named',
83 | globals: {
84 | vue: 'Vue',
85 | },
86 | },
87 | ],
88 | plugins: [
89 | ...plugins,
90 | resolve({
91 | browser: true,
92 | preferBuiltins: false,
93 | }),
94 | ],
95 | },
96 | ];
97 |
--------------------------------------------------------------------------------
/stories/MenuSelect.stories.js:
--------------------------------------------------------------------------------
1 | import { storiesOf } from '@storybook/vue';
2 | import { previewWrapper } from './utils';
3 |
4 | storiesOf('ais-menu-select', module)
5 | .addDecorator(previewWrapper())
6 | .add('default', () => ({
7 | template: `
8 |
9 | `,
10 | }))
11 | .add('with a limit', () => ({
12 | template: `
13 |
17 | `,
18 | }))
19 | .add('with a custom sort', () => ({
20 | template: `
21 |
25 | `,
26 | }))
27 | .add('with a custom label', () => ({
28 | template: `
29 |
33 | `,
34 | }))
35 | .add('with a custom item slot', () => ({
36 | template: `
37 |
38 |
39 | {{ item.label }}
40 |
41 |
42 | `,
43 | }))
44 | .add('with transform items', () => ({
45 | template: `
46 |
51 | `,
52 | methods: {
53 | transformItems(items) {
54 | return items.map(item =>
55 | Object.assign({}, item, {
56 | label: item.label.toUpperCase(),
57 | })
58 | );
59 | },
60 | },
61 | }))
62 | .add('with a custom rendering', () => ({
63 | template: `
64 |
65 |
66 |
82 |
83 |
84 | `,
85 | }))
86 | .add('with a Panel', () => ({
87 | template: `
88 |
89 | Menu Select
90 |
91 | Footer
92 |
93 | `,
94 | }));
95 |
--------------------------------------------------------------------------------
/src/widgets.js:
--------------------------------------------------------------------------------
1 | export { default as AisAutocomplete } from './components/Autocomplete.vue';
2 | export { default as AisBreadcrumb } from './components/Breadcrumb.vue';
3 | export {
4 | default as AisClearRefinements,
5 | } from './components/ClearRefinements.vue';
6 | export { default as AisConfigure } from './components/Configure';
7 | export {
8 | default as AisExperimentalConfigureRelatedItems,
9 | } from './components/ConfigureRelatedItems';
10 | export {
11 | default as AisCurrentRefinements,
12 | } from './components/CurrentRefinements.vue';
13 | export {
14 | default as AisHierarchicalMenu,
15 | } from './components/HierarchicalMenu.vue';
16 | export { default as AisHighlight } from './components/Highlight.vue';
17 | export { default as AisHits } from './components/Hits.vue';
18 | export { default as AisHitsPerPage } from './components/HitsPerPage.vue';
19 | export { default as AisIndex } from './components/Index';
20 | export { default as AisInstantSearch } from './components/InstantSearch';
21 | export { default as AisInstantSearchSsr } from './components/InstantSearchSsr';
22 | export { default as AisInfiniteHits } from './components/InfiniteHits.vue';
23 | export { default as AisMenu } from './components/Menu.vue';
24 | export { default as AisMenuSelect } from './components/MenuSelect.vue';
25 | export { default as AisNumericMenu } from './components/NumericMenu.vue';
26 | export { default as AisPagination } from './components/Pagination.vue';
27 | export { default as AisPanel } from './components/Panel.vue';
28 | export { default as AisPoweredBy } from './components/PoweredBy.vue';
29 | export { default as AisQueryRuleContext } from './components/QueryRuleContext';
30 | export {
31 | default as AisQueryRuleCustomData,
32 | } from './components/QueryRuleCustomData.vue';
33 | export { default as AisRangeInput } from './components/RangeInput.vue';
34 | export { default as AisRatingMenu } from './components/RatingMenu.vue';
35 | export { default as AisRefinementList } from './components/RefinementList.vue';
36 | export { default as AisStateResults } from './components/StateResults.vue';
37 | export { default as AisSearchBox } from './components/SearchBox.vue';
38 | export { default as AisSnippet } from './components/Snippet.vue';
39 | export { default as AisSortBy } from './components/SortBy.vue';
40 | export { default as AisStats } from './components/Stats.vue';
41 | export {
42 | default as AisToggleRefinement,
43 | } from './components/ToggleRefinement.vue';
44 | export { default as AisVoiceSearch } from './components/VoiceSearch.vue';
45 | export { default as AisRelevantSort } from './components/RelevantSort.vue';
46 | export { default as AisDynamicWidgets } from './components/DynamicWidgets';
47 |
--------------------------------------------------------------------------------
/src/components/__tests__/Panel.js:
--------------------------------------------------------------------------------
1 | import { mount } from '../../../test/utils';
2 | import Panel from '../Panel.vue';
3 |
4 | describe('default render', () => {
5 | const defaultSlot = `
6 | This is the body of the Panel.
7 | `;
8 |
9 | it('renders correctly', () => {
10 | const wrapper = mount(Panel, {
11 | slots: {
12 | default: defaultSlot,
13 | },
14 | });
15 |
16 | expect(wrapper.html()).toMatchSnapshot();
17 | });
18 |
19 | it('renders correctly without refinement', async () => {
20 | const wrapper = mount(Panel, {
21 | slots: {
22 | default: defaultSlot,
23 | },
24 | });
25 |
26 | await wrapper.setData({
27 | canRefine: false,
28 | });
29 |
30 | expect(wrapper.html()).toMatchSnapshot();
31 | });
32 |
33 | it('passes data without refinement', async () => {
34 | const defaultScopedSlot = jest.fn();
35 | const headerScopedSlot = jest.fn();
36 | const footerScopedSlot = jest.fn();
37 | const wrapper = mount(Panel, {
38 | scopedSlots: {
39 | default: defaultScopedSlot,
40 | header: headerScopedSlot,
41 | footer: footerScopedSlot,
42 | },
43 | });
44 |
45 | await wrapper.setData({
46 | canRefine: false,
47 | });
48 |
49 | expect(defaultScopedSlot).toHaveBeenCalledWith({ hasRefinements: false });
50 | expect(headerScopedSlot).toHaveBeenCalledWith({ hasRefinements: false });
51 | expect(footerScopedSlot).toHaveBeenCalledWith({ hasRefinements: false });
52 | });
53 |
54 | it('passes data with refinement', async () => {
55 | const defaultScopedSlot = jest.fn();
56 | const headerScopedSlot = jest.fn();
57 | const footerScopedSlot = jest.fn();
58 | const wrapper = mount(Panel, {
59 | scopedSlots: {
60 | default: defaultScopedSlot,
61 | header: headerScopedSlot,
62 | footer: footerScopedSlot,
63 | },
64 | });
65 |
66 | await wrapper.setData({
67 | canRefine: true,
68 | });
69 |
70 | expect(defaultScopedSlot).toHaveBeenCalledWith({ hasRefinements: true });
71 | expect(headerScopedSlot).toHaveBeenCalledWith({ hasRefinements: true });
72 | expect(footerScopedSlot).toHaveBeenCalledWith({ hasRefinements: true });
73 | });
74 |
75 | it('renders correctly with header', () => {
76 | const wrapper = mount(Panel, {
77 | slots: {
78 | default: defaultSlot,
79 | header: `Header`,
80 | },
81 | });
82 |
83 | expect(wrapper.html()).toMatchSnapshot();
84 | });
85 |
86 | it('renders correctly with footer', () => {
87 | const wrapper = mount(Panel, {
88 | slots: {
89 | default: defaultSlot,
90 | footer: `Footer`,
91 | },
92 | });
93 |
94 | expect(wrapper.html()).toMatchSnapshot();
95 | });
96 | });
97 |
--------------------------------------------------------------------------------
/src/mixins/widget.js:
--------------------------------------------------------------------------------
1 | import { isVue3 } from '../util/vue-compat';
2 | import { warn } from '../util/warn';
3 |
4 | export const createWidgetMixin = ({ connector } = {}) => ({
5 | inject: {
6 | instantSearchInstance: {
7 | from: '$_ais_instantSearchInstance',
8 | default() {
9 | const tag = this.$options._componentTag;
10 | throw new TypeError(
11 | `It looks like you forgot to wrap your Algolia search component "<${tag}>" inside of an "" component.`
12 | );
13 | },
14 | },
15 | getParentIndex: {
16 | from: '$_ais_getParentIndex',
17 | default() {
18 | return () => this.instantSearchInstance.mainIndex;
19 | },
20 | },
21 | },
22 | data() {
23 | return {
24 | state: null,
25 | };
26 | },
27 | created() {
28 | if (typeof connector === 'function') {
29 | this.factory = connector(this.updateState, () => {});
30 | this.widget = this.factory(this.widgetParams);
31 | this.getParentIndex().addWidgets([this.widget]);
32 |
33 | if (
34 | this.instantSearchInstance.__initialSearchResults &&
35 | !this.instantSearchInstance.started
36 | ) {
37 | if (typeof this.instantSearchInstance.__forceRender !== 'function') {
38 | throw new Error(
39 | 'You are using server side rendering with instead of .'
40 | );
41 | }
42 | this.instantSearchInstance.__forceRender(
43 | this.widget,
44 | this.getParentIndex()
45 | );
46 | }
47 | } else if (connector !== true) {
48 | warn(
49 | `You are using the InstantSearch widget mixin, but didn't provide a connector.
50 | While this is technically possible, and will give you access to the Helper,
51 | it's not the recommended way of making custom components.
52 |
53 | If you want to disable this message, pass { connector: true } to the mixin.
54 |
55 | Read more on using connectors: https://alg.li/vue-custom`
56 | );
57 | }
58 | },
59 | [isVue3 ? 'beforeUnmount' : 'beforeDestroy']() {
60 | if (this.widget) {
61 | this.getParentIndex().removeWidgets([this.widget]);
62 | }
63 | },
64 | watch: {
65 | widgetParams: {
66 | handler(nextWidgetParams) {
67 | this.state = null;
68 | this.getParentIndex().removeWidgets([this.widget]);
69 | this.widget = this.factory(nextWidgetParams);
70 | this.getParentIndex().addWidgets([this.widget]);
71 | },
72 | deep: true,
73 | },
74 | },
75 | methods: {
76 | updateState(state = {}, isFirstRender) {
77 | if (!isFirstRender) {
78 | // Avoid updating the state on first render
79 | // otherwise there will be a flash of placeholder data
80 | this.state = state;
81 | }
82 | },
83 | },
84 | });
85 |
--------------------------------------------------------------------------------
/stories/NumericMenu.stories.js:
--------------------------------------------------------------------------------
1 | import { storiesOf } from '@storybook/vue';
2 | import { previewWrapper } from './utils';
3 |
4 | storiesOf('ais-numeric-menu', module)
5 | .addDecorator(previewWrapper())
6 | .add('default', () => ({
7 | template: `
8 |
18 | `,
19 | }))
20 | .add('with transform items', () => ({
21 | template: `
22 |
33 | `,
34 | methods: {
35 | transformItems(items) {
36 | return items.map(item =>
37 | Object.assign({}, item, { label: `👉 ${item.label}` })
38 | );
39 | },
40 | },
41 | }))
42 | .add('with a custom render', () => ({
43 | template: `
44 |
54 |
55 |
56 | -
61 |
65 | {{ item.label }}
66 |
67 |
68 |
69 |
70 |
71 | `,
72 | }))
73 | .add('with a Panel', () => ({
74 | template: `
75 |
76 | Numeric Menu
77 |
87 | Footer
88 |
89 | `,
90 | }));
91 |
--------------------------------------------------------------------------------
/src/components/__tests__/__snapshots__/ToggleRefinement.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`custom default render renders correctly 1`] = `
4 |
14 | `;
15 |
16 | exports[`custom default render renders correctly with a URL for the href 1`] = `
17 |
29 | `;
30 |
31 | exports[`custom default render renders correctly with the value selected 1`] = `
32 |
42 | `;
43 |
44 | exports[`custom default render renders correctly without refinement 1`] = `
45 |
55 | `;
56 |
57 | exports[`default render renders correctly 1`] = `
58 |
59 |
72 |
73 | `;
74 |
75 | exports[`default render renders correctly without refinement (with 0) 1`] = `
76 |
77 |
90 |
91 | `;
92 |
93 | exports[`default render renders correctly without refinement (with null) 1`] = `
94 |
95 |
105 |
106 | `;
107 |
--------------------------------------------------------------------------------
/stories/Pagination.stories.js:
--------------------------------------------------------------------------------
1 | import { storiesOf } from '@storybook/vue';
2 | import { previewWrapper } from './utils';
3 |
4 | storiesOf('ais-pagination', module)
5 | .addDecorator(previewWrapper())
6 | .add('default', () => ({
7 | template: `
8 |
9 | `,
10 | }))
11 | .add('with a padding', () => ({
12 | template: `
13 |
14 | `,
15 | }))
16 | .add('with a total pages', () => ({
17 | template: `
18 |
19 | `,
20 | }))
21 | .add('complete custom rendering', () => ({
22 | template: `
23 |
27 |
34 |
43 |
44 | `,
45 | }))
46 | .add('with named slots', () => ({
47 | template: `
48 |
49 |
50 |
56 |
57 |
58 |
64 |
65 |
66 |
67 |
72 | {{page}}
73 |
74 |
75 |
76 |
82 |
83 |
84 |
90 |
91 | `,
92 | }))
93 | .add('with a Panel', () => ({
94 | template: `
95 |
96 | Pagination
97 |
98 | Footer
99 |
100 | `,
101 | }));
102 |
--------------------------------------------------------------------------------
/src/components/Breadcrumb.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
12 |
13 | -
14 |
20 |
Home
21 |
22 |
23 | Home
24 |
25 |
26 | -
31 |
37 | {{ item.label }}
43 | {{ item.label }}
44 |
45 |
46 |
47 |
48 |
49 |
50 |
100 |
--------------------------------------------------------------------------------
/src/components/DynamicWidgets.js:
--------------------------------------------------------------------------------
1 | import { createWidgetMixin } from '../mixins/widget';
2 | import { connectDynamicWidgets } from 'instantsearch.js/es/connectors';
3 | import { createSuitMixin } from '../mixins/suit';
4 | import { _objectSpread } from '../util/polyfills';
5 | import { isVue3, renderCompat, getDefaultSlot } from '../util/vue-compat';
6 |
7 | function getWidgetAttribute(vnode) {
8 | const props = isVue3
9 | ? vnode.props
10 | : vnode.componentOptions && vnode.componentOptions.propsData;
11 | if (props) {
12 | if (props.attribute) {
13 | return props.attribute;
14 | }
15 | if (Array.isArray(props.attributes)) {
16 | return props.attributes[0];
17 | }
18 | }
19 |
20 | let children;
21 | if (isVue3) {
22 | children =
23 | vnode.children && vnode.children.default && vnode.children.default();
24 | } else {
25 | children =
26 | vnode.componentOptions && vnode.componentOptions.children
27 | ? vnode.componentOptions.children
28 | : vnode.children;
29 | }
30 |
31 | if (Array.isArray(children)) {
32 | // return first child with a truthy attribute
33 | return children.reduce(
34 | (acc, curr) => acc || getWidgetAttribute(curr),
35 | undefined
36 | );
37 | }
38 |
39 | return undefined;
40 | }
41 |
42 | export default {
43 | name: 'AisExperimentalDynamicWidgets',
44 | mixins: [
45 | createWidgetMixin({ connector: connectDynamicWidgets }),
46 | createSuitMixin({ name: 'DynamicWidgets' }),
47 | ],
48 | props: {
49 | transformItems: {
50 | type: Function,
51 | default: undefined,
52 | },
53 | },
54 | render: renderCompat(function(h) {
55 | const components = new Map();
56 |
57 | (getDefaultSlot(this) || []).forEach(vnode => {
58 | const attribute = getWidgetAttribute(vnode);
59 | if (attribute) {
60 | components.set(
61 | attribute,
62 | h('div', { key: attribute, class: [this.suit('widget')] }, [vnode])
63 | );
64 | }
65 | });
66 |
67 | // by default, render everything, but hidden so that the routing doesn't disappear
68 | if (!this.state) {
69 | const allComponents = [];
70 | components.forEach(component => allComponents.push(component));
71 |
72 | return h(
73 | 'div',
74 | _objectSpread(
75 | {
76 | class: [this.suit()],
77 | },
78 | { attrs: { hidden: true } }
79 | ),
80 | allComponents
81 | );
82 | }
83 |
84 | return h(
85 | 'div',
86 | { class: [this.suit()] },
87 | this.state.attributesToRender.map(attribute => components.get(attribute))
88 | );
89 | }),
90 | computed: {
91 | widgetParams() {
92 | return {
93 | transformItems: this.transformItems,
94 | // we do not pass "widgets" to the connector, since Vue is in charge of rendering
95 | widgets: [],
96 | };
97 | },
98 | },
99 | };
100 |
--------------------------------------------------------------------------------
/src/components/InstantSearch.js:
--------------------------------------------------------------------------------
1 | import instantsearch from 'instantsearch.js/es';
2 | import { createInstantSearchComponent } from '../util/createInstantSearchComponent';
3 | import { warn } from '../util/warn';
4 | import { renderCompat, getDefaultSlot } from '../util/vue-compat';
5 |
6 | const oldApiWarning = `Vue InstantSearch: You used the prop api-key or app-id.
7 | These have been replaced by search-client.
8 |
9 | See more info here: https://www.algolia.com/doc/api-reference/widgets/instantsearch/vue/#widget-param-search-client`;
10 |
11 | export default createInstantSearchComponent({
12 | name: 'AisInstantSearch',
13 | props: {
14 | searchClient: {
15 | type: Object,
16 | required: true,
17 | },
18 | insightsClient: {
19 | type: Function,
20 | default: undefined,
21 | },
22 | indexName: {
23 | type: String,
24 | required: true,
25 | },
26 | routing: {
27 | default: undefined,
28 | validator(value) {
29 | if (
30 | typeof value === 'boolean' ||
31 | (!value.router && !value.stateMapping)
32 | ) {
33 | warn(
34 | 'The `routing` option expects an object with `router` and/or `stateMapping`.\n\nSee https://www.algolia.com/doc/api-reference/widgets/instantsearch/vue/#widget-param-routing'
35 | );
36 | return false;
37 | }
38 | return true;
39 | },
40 | },
41 | stalledSearchDelay: {
42 | type: Number,
43 | default: undefined,
44 | },
45 | searchFunction: {
46 | type: Function,
47 | default: undefined,
48 | },
49 | initialUiState: {
50 | type: Object,
51 | default: undefined,
52 | },
53 | apiKey: {
54 | type: String,
55 | default: undefined,
56 | validator(value) {
57 | if (value) {
58 | warn(oldApiWarning);
59 | }
60 | return false;
61 | },
62 | },
63 | appId: {
64 | type: String,
65 | default: undefined,
66 | validator(value) {
67 | if (value) {
68 | warn(oldApiWarning);
69 | }
70 | return false;
71 | },
72 | },
73 | middlewares: {
74 | type: Array,
75 | default: null,
76 | },
77 | },
78 | data() {
79 | return {
80 | instantSearchInstance: instantsearch({
81 | searchClient: this.searchClient,
82 | insightsClient: this.insightsClient,
83 | indexName: this.indexName,
84 | routing: this.routing,
85 | stalledSearchDelay: this.stalledSearchDelay,
86 | searchFunction: this.searchFunction,
87 | initialUiState: this.initialUiState,
88 | }),
89 | };
90 | },
91 | render: renderCompat(function(h) {
92 | return h(
93 | 'div',
94 | {
95 | class: {
96 | [this.suit()]: true,
97 | [this.suit('', 'ssr')]: false,
98 | },
99 | },
100 | getDefaultSlot(this)
101 | );
102 | }),
103 | });
104 |
--------------------------------------------------------------------------------
/src/components/__tests__/Index-integration.js:
--------------------------------------------------------------------------------
1 | jest.unmock('instantsearch.js/es');
2 | import { mount } from '../../../test/utils';
3 | import Index from '../Index';
4 | import instantsearch from 'instantsearch.js/es';
5 | import { createWidgetMixin } from '../../mixins/widget';
6 | import { createFakeClient } from '../../util/testutils/client';
7 |
8 | it('child widgets get added to their parent index', () => {
9 | const widgetInstance = {
10 | render() {},
11 | };
12 |
13 | const ChildComponent = {
14 | name: 'child',
15 | mixins: [createWidgetMixin({ connector: () => () => widgetInstance })],
16 | render() {
17 | return null;
18 | },
19 | };
20 |
21 | const rootAddWidgets = jest.fn();
22 |
23 | const wrapper = mount({
24 | components: { Index, ChildComponent },
25 | data() {
26 | return { props: { indexName: 'something' } };
27 | },
28 | provide: {
29 | $_ais_instantSearchInstance: {
30 | mainIndex: { addWidgets: rootAddWidgets },
31 | },
32 | },
33 | template: `
34 |
35 |
36 |
37 | `,
38 | });
39 |
40 | const indexWidget = wrapper.findComponent(Index).vm.widget;
41 | expect(indexWidget.getWidgets()).toContain(widgetInstance);
42 |
43 | expect(rootAddWidgets).toHaveBeenCalledTimes(1);
44 | expect(rootAddWidgets).toHaveBeenCalledWith([
45 | expect.objectContaining({ $$type: 'ais.index' }),
46 | ]);
47 | });
48 |
49 | it('child widgets render with right data', () => {
50 | const widgetInstance = {
51 | init: jest.fn(),
52 | render: jest.fn(),
53 | };
54 |
55 | const ChildComponent = {
56 | name: 'child',
57 | mixins: [createWidgetMixin({ connector: () => () => widgetInstance })],
58 | render() {
59 | return null;
60 | },
61 | };
62 |
63 | const search = instantsearch({
64 | indexName: 'root index',
65 | searchClient: createFakeClient(),
66 | });
67 |
68 | const wrapper = mount({
69 | components: { Index, ChildComponent },
70 | data() {
71 | return { props: { indexName: 'something' } };
72 | },
73 | provide: {
74 | $_ais_instantSearchInstance: search,
75 | },
76 | template: `
77 |
78 |
79 |
80 | `,
81 | });
82 |
83 | search.start();
84 |
85 | const indexWidget = wrapper.findComponent(Index).vm.widget;
86 |
87 | expect(indexWidget.getWidgets()).toContain(widgetInstance);
88 |
89 | expect(widgetInstance.render).not.toHaveBeenCalled();
90 | expect(widgetInstance.init).toHaveBeenCalledTimes(1);
91 |
92 | expect(widgetInstance.init).toHaveBeenCalledWith(
93 | expect.objectContaining({
94 | createURL: expect.any(Function),
95 | helper: expect.any(Object),
96 | instantSearchInstance: search,
97 | parent: indexWidget,
98 | state: expect.any(Object),
99 | templatesConfig: expect.any(Object),
100 | uiState: {},
101 | })
102 | );
103 | });
104 |
--------------------------------------------------------------------------------
/stories/Configure.stories.js:
--------------------------------------------------------------------------------
1 | import { storiesOf } from '@storybook/vue';
2 | import { previewWrapper } from './utils';
3 | import { withKnobs, object } from '@storybook/addon-knobs/vue';
4 |
5 | storiesOf('ais-configure', module)
6 | .addDecorator(
7 | previewWrapper({
8 | filters: ' ',
9 | })
10 | )
11 | .addDecorator(withKnobs)
12 | .add('default', () => ({
13 | template: `
14 |
15 | `,
16 | }))
17 | .add('with 1 hit per page', () => ({
18 | template: `
19 |
20 | `,
21 | }))
22 | .add('with 1 hit per page (kebab)', () => ({
23 | template: `
24 |
25 | `,
26 | }))
27 | .add('external toggler', () => ({
28 | template: `
29 |
30 |
31 |
32 |
33 | `,
34 | data() {
35 | return { hitsPerPage: 1 };
36 | },
37 | methods: {
38 | toggleHitsPerPage() {
39 | this.hitsPerPage = this.hitsPerPage === 1 ? 5 : 1;
40 | },
41 | },
42 | }))
43 | .add('inline toggler', () => ({
44 | template: `
45 |
46 |
47 | {{JSON.stringify(searchParameters, null, 2)}}
48 |
51 |
52 |
53 | `,
54 | }))
55 | .add('with display of the parameters', () => ({
56 | template: `
57 |
58 |
59 | {{ searchParameters }}
60 |
61 |
62 | `,
63 | }))
64 | .add('merging parameters', () => ({
65 | template: `
66 |
67 |
68 |
79 | currently applied filters: {{searchParameters}}
80 |
81 |
82 | `,
83 | }))
84 | .add('playground', () => ({
85 | template: `
86 |
87 |
88 | {{ searchParameters }}
89 |
90 |
91 | `,
92 | data() {
93 | return {
94 | knobs: object('search parameters', {
95 | hitsPerPage: 1,
96 | }),
97 | };
98 | },
99 | }));
100 |
--------------------------------------------------------------------------------
/stories/Menu.stories.js:
--------------------------------------------------------------------------------
1 | import { storiesOf } from '@storybook/vue';
2 | import { previewWrapper } from './utils';
3 | import {
4 | withKnobs,
5 | object,
6 | text,
7 | number,
8 | boolean,
9 | } from '@storybook/addon-knobs';
10 |
11 | storiesOf('ais-menu', module)
12 | .addDecorator(previewWrapper())
13 | .addDecorator(withKnobs)
14 | .add('default', () => ({
15 | template: `
16 |
17 | `,
18 | }))
19 | .add('with show more', () => ({
20 | template: `
21 |
27 | `,
28 | }))
29 | .add('with custom label', () => ({
30 | template: `
31 |
32 |
33 | {{isShowingMore ? 'View less' : 'View more'}}
34 |
35 |
36 | `,
37 | }))
38 | .add('with a different sort', () => ({
39 | template: `
40 |
41 | `,
42 | }))
43 | .add('with transform items', () => ({
44 | template: `
45 |
46 | `,
47 | methods: {
48 | transformItems(items) {
49 | return items.map(item =>
50 | Object.assign(item, {
51 | label: item.label.toLocaleUpperCase(),
52 | })
53 | );
54 | },
55 | },
56 | }))
57 | .add('with a custom render', () => ({
58 | template: `
59 |
60 |
61 |
62 | -
66 |
67 |
71 | {{item.label}} - {{item.count}}
72 |
73 |
74 |
75 |
76 |
77 |
78 | `,
79 | }))
80 | .add('with a Panel', () => ({
81 | template: `
82 |
83 | Menu
84 |
85 | Footer
86 |
87 | `,
88 | }))
89 | .add('playground', () => ({
90 | template: `
91 |
98 | `,
99 | data() {
100 | return {
101 | attribute: text('attribute', 'categories'),
102 | classNames: object('class-names', {}),
103 | limit: number('limit', 10),
104 | showMore: boolean('show-More', false),
105 | showMoreLimit: number('show-More-Limit', 20),
106 | };
107 | },
108 | }));
109 |
--------------------------------------------------------------------------------