vue-instantsearch 4.25.0 → 4.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -16,7 +16,7 @@
16
16
  "autocomplete"
17
17
  ],
18
18
  "license": "MIT",
19
- "version": "4.25.0",
19
+ "version": "4.26.0",
20
20
  "files": [
21
21
  "vue2",
22
22
  "vue3",
@@ -37,8 +37,8 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@swc/helpers": "0.5.18",
40
- "instantsearch-ui-components": "0.24.0",
41
- "instantsearch.js": "4.94.0",
40
+ "instantsearch-ui-components": "0.26.0",
41
+ "instantsearch.js": "4.96.0",
42
42
  "mitt": "^2.1.0"
43
43
  },
44
44
  "peerDependencies": {
@@ -62,9 +62,9 @@
62
62
  "@vue/test-utils": "1.3.0",
63
63
  "@vue/test-utils2": "npm:@vue/test-utils@2.0.0-rc.11",
64
64
  "algoliasearch": "5.1.1",
65
- "algoliasearch-helper": "3.28.1",
65
+ "algoliasearch-helper": "3.29.0",
66
66
  "babel-preset-vue": "2.0.2",
67
- "instantsearch.css": "8.14.0",
67
+ "instantsearch.css": "8.16.0",
68
68
  "rollup-plugin-vue2": "npm:rollup-plugin-vue@4.3.2",
69
69
  "rollup-plugin-vue3": "npm:rollup-plugin-vue@6.0.0",
70
70
  "vue": "2.7.14",
@@ -81,5 +81,5 @@
81
81
  "vuex": "3.5.1",
82
82
  "vuex4": "npm:vuex@4.0.0"
83
83
  },
84
- "gitHead": "f80018c93d613832ccde1cdf98dc688140239865"
84
+ "gitHead": "17e816d7f2c0c3ace2552f9e5322a2e45d6c3492"
85
85
  }
@@ -46,7 +46,7 @@ function getAllComponents() {
46
46
 
47
47
  try {
48
48
  suitClass = mixins
49
- .find((mixin) => mixin.methods && mixin.methods.suit)
49
+ .find(mixin => mixin.methods && mixin.methods.suit)
50
50
  .methods.suit();
51
51
  } catch (e) {
52
52
  /* no suit class, so will fail the assertions */
@@ -78,17 +78,21 @@ function getAllComponents() {
78
78
  props.trackedFilters = {};
79
79
  } else if (name === 'AisIndex') {
80
80
  props.indexName = 'indexName';
81
+ } else if (name === 'AisFeeds') {
82
+ props.searchScope = 'global';
81
83
  } else {
82
84
  props.attribute = 'attr';
83
85
  }
84
86
 
85
87
  const Component = {
86
- render: renderCompat((h) =>
88
+ render: renderCompat(h =>
87
89
  h(
88
90
  AisInstantSearch,
89
91
  {
90
92
  props: {
91
- indexName: 'instant_search',
93
+ ...(name === 'AisFeeds'
94
+ ? { compositionID: 'my-composition' }
95
+ : { indexName: 'instant_search' }),
92
96
  searchClient: {
93
97
  search() {
94
98
  return new Promise({ results: [] });
@@ -0,0 +1,180 @@
1
+ import { createFeedContainer } from 'instantsearch.js/es/connectors/feeds/FeedContainer';
2
+ import { connectFeeds } from 'instantsearch.js/es/connectors/index';
3
+
4
+ import { createSuitMixin } from '../mixins/suit';
5
+ import { createWidgetMixin } from '../mixins/widget';
6
+ import { isVue3, renderCompat, getScopedSlot } from '../util/vue-compat';
7
+
8
+ const AisFeedProvider = {
9
+ name: 'AisFeedProvider',
10
+ props: {
11
+ feedContainer: {
12
+ type: Object,
13
+ required: true,
14
+ },
15
+ },
16
+ provide() {
17
+ return {
18
+ $_ais_getParentIndex: () => this.feedContainer,
19
+ };
20
+ },
21
+ render: renderCompat(function(h) {
22
+ const defaultSlot = getScopedSlot(this, 'default');
23
+ return h('div', {}, defaultSlot ? defaultSlot() : []);
24
+ }),
25
+ };
26
+
27
+ export default {
28
+ name: 'AisFeeds',
29
+ mixins: [
30
+ createWidgetMixin(
31
+ {
32
+ connector: connectFeeds,
33
+ },
34
+ {
35
+ $$widgetType: 'ais.feeds',
36
+ }
37
+ ),
38
+ createSuitMixin({ name: 'Feeds' }),
39
+ ],
40
+ props: {
41
+ searchScope: {
42
+ type: String,
43
+ required: true,
44
+ },
45
+ transformFeeds: {
46
+ type: Function,
47
+ default: undefined,
48
+ },
49
+ },
50
+ data() {
51
+ return {
52
+ feedContainers: new Map(),
53
+ removalTimer: null,
54
+ pendingRemovals: new Map(),
55
+ };
56
+ },
57
+ watch: {
58
+ state(newState) {
59
+ if (!newState) return;
60
+ this.reconcileContainers(newState.feedIDs || []);
61
+ },
62
+ },
63
+ [isVue3 ? 'beforeUnmount' : 'beforeDestroy']() {
64
+ if (this.removalTimer !== null) {
65
+ clearTimeout(this.removalTimer);
66
+ this.removalTimer = null;
67
+ }
68
+
69
+ const toRemove = Array.from(
70
+ new Set([
71
+ ...this.feedContainers.values(),
72
+ ...this.pendingRemovals.values(),
73
+ ])
74
+ );
75
+ this.pendingRemovals.clear();
76
+ this.feedContainers.clear();
77
+ if (toRemove.length > 0) {
78
+ this.getParentIndex().removeWidgets(toRemove);
79
+ }
80
+ },
81
+ methods: {
82
+ reconcileContainers(feedIDs) {
83
+ const parentIndex = this.getParentIndex();
84
+ const activeFeedIDs = new Set(feedIDs);
85
+
86
+ // Remove disappeared feeds (deferred)
87
+ const toRemove = [];
88
+ this.feedContainers.forEach((container, id) => {
89
+ if (!activeFeedIDs.has(id)) {
90
+ toRemove.push([id, container]);
91
+ this.feedContainers.delete(id);
92
+ }
93
+ });
94
+ if (toRemove.length > 0) {
95
+ toRemove.forEach(([id, container]) => {
96
+ this.pendingRemovals.set(id, container);
97
+ });
98
+
99
+ if (this.removalTimer !== null) {
100
+ clearTimeout(this.removalTimer);
101
+ }
102
+
103
+ this.removalTimer = setTimeout(() => {
104
+ const widgetsToRemove = Array.from(this.pendingRemovals.values());
105
+ this.pendingRemovals.clear();
106
+ this.removalTimer = null;
107
+
108
+ if (widgetsToRemove.length > 0) {
109
+ parentIndex.removeWidgets(widgetsToRemove);
110
+ }
111
+ }, 0);
112
+ }
113
+
114
+ // Create new FeedContainers (synchronous — must be registered before render)
115
+ const toAdd = [];
116
+ feedIDs.forEach(feedID => {
117
+ if (!this.feedContainers.has(feedID)) {
118
+ const pendingContainer = this.pendingRemovals.get(feedID);
119
+ if (pendingContainer) {
120
+ this.pendingRemovals.delete(feedID);
121
+ this.feedContainers.set(feedID, pendingContainer);
122
+ return;
123
+ }
124
+
125
+ const container = createFeedContainer(
126
+ feedID,
127
+ parentIndex,
128
+ this.instantSearchInstance
129
+ );
130
+ this.feedContainers.set(feedID, container);
131
+ toAdd.push(container);
132
+ }
133
+ });
134
+ if (toAdd.length > 0) {
135
+ parentIndex.addWidgets(toAdd);
136
+ }
137
+ },
138
+ },
139
+ render: renderCompat(function(h) {
140
+ if (!this.state) {
141
+ return h('div', { class: [this.suit()] });
142
+ }
143
+
144
+ const feedIDs = this.state.feedIDs || [];
145
+ const defaultSlot = getScopedSlot(this, 'default');
146
+
147
+ const children = feedIDs.map(feedID => {
148
+ const container = this.feedContainers.get(feedID);
149
+ if (!container || !defaultSlot) {
150
+ return null;
151
+ }
152
+
153
+ const slotContent = defaultSlot({ feedID });
154
+
155
+ return h(
156
+ AisFeedProvider,
157
+ isVue3
158
+ ? {
159
+ key: feedID,
160
+ feedContainer: container,
161
+ scopedSlots: {
162
+ default: () => slotContent,
163
+ },
164
+ }
165
+ : { key: feedID, props: { feedContainer: container } },
166
+ slotContent
167
+ );
168
+ });
169
+
170
+ return h('div', { class: [this.suit()] }, children);
171
+ }),
172
+ computed: {
173
+ widgetParams() {
174
+ return {
175
+ searchScope: this.searchScope,
176
+ transformFeeds: this.transformFeeds,
177
+ };
178
+ },
179
+ },
180
+ };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts
3
+ */
4
+
5
+ jest.unmock('instantsearch.js/es');
6
+
7
+ import { createCompositionClient } from '@instantsearch/mocks';
8
+
9
+ import { mount, nextTick } from '../../../test/utils';
10
+ import Feeds from '../Feeds';
11
+ import InstantSearch from '../InstantSearch';
12
+
13
+ const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
14
+
15
+ describe('AisFeeds (integration)', () => {
16
+ it('registers feed containers and scopes slot descendants', async () => {
17
+ const wrapper = mount({
18
+ data() {
19
+ return {
20
+ searchClient: createCompositionClient({}),
21
+ indexName: 'indexName',
22
+ compositionID: 'my-composition',
23
+ };
24
+ },
25
+ template: `
26
+ <InstantSearch
27
+ v-bind="{ searchClient, indexName, compositionID }"
28
+ >
29
+ <Feeds search-scope="global" v-slot="{ feedID }">
30
+ <ScopedFeed :feedid="feedID || 'default'" />
31
+ </Feeds>
32
+ </InstantSearch>
33
+ `,
34
+ components: {
35
+ Feeds,
36
+ InstantSearch,
37
+ ScopedFeed: {
38
+ name: 'ScopedFeed',
39
+ inject: ['$_ais_getParentIndex'],
40
+ props: {
41
+ feedid: {
42
+ type: String,
43
+ required: true,
44
+ },
45
+ },
46
+ template: `<div class="scoped-feed">{{ feedid }}:{{ $_ais_getParentIndex().getIndexId() }}</div>`,
47
+ },
48
+ },
49
+ });
50
+
51
+ await wait(20);
52
+ await nextTick();
53
+
54
+ expect(wrapper.findAll('.scoped-feed')).toHaveLength(1);
55
+ expect(wrapper.find('.scoped-feed').text()).toBe('default:');
56
+
57
+ const widgets = wrapper
58
+ .findComponent(InstantSearch)
59
+ .vm.instantSearchInstance.mainIndex.getWidgets();
60
+ expect(widgets.some(widget => widget.$$type === 'ais.feeds')).toBe(true);
61
+ expect(widgets.some(widget => widget.$$type === 'ais.feedContainer')).toBe(
62
+ true
63
+ );
64
+ });
65
+ });
@@ -0,0 +1,221 @@
1
+ /**
2
+ * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts
3
+ */
4
+
5
+ import { mount, nextTick } from '../../../test/utils';
6
+ import { __setState } from '../../mixins/widget';
7
+ import Feeds from '../Feeds';
8
+
9
+ jest.mock('../../mixins/widget');
10
+ jest.mock('instantsearch.js/es/connectors/feeds/FeedContainer', () => ({
11
+ createFeedContainer: jest.fn(feedID => ({ __feedID: feedID })),
12
+ }));
13
+
14
+ describe('AisFeeds', () => {
15
+ beforeEach(() => {
16
+ __setState(null);
17
+ });
18
+
19
+ it('passes arguments to connector', () => {
20
+ const transformFeeds = feeds => feeds;
21
+ const wrapper = mount(Feeds, {
22
+ propsData: {
23
+ searchScope: 'global',
24
+ transformFeeds,
25
+ },
26
+ });
27
+
28
+ expect(wrapper.vm.widgetParams).toEqual({
29
+ searchScope: 'global',
30
+ transformFeeds,
31
+ });
32
+ });
33
+
34
+ it('renders slot content in feed order and updates when order changes', async () => {
35
+ const parentIndex = {
36
+ addWidgets: jest.fn(),
37
+ removeWidgets: jest.fn(),
38
+ };
39
+
40
+ const wrapper = mount({
41
+ components: { Feeds },
42
+ template: `
43
+ <Feeds search-scope="global" v-slot="{ feedID }">
44
+ <div class="slot-feed">{{ feedID }}</div>
45
+ </Feeds>
46
+ `,
47
+ });
48
+
49
+ const feedsComponent = wrapper.findComponent(Feeds).vm;
50
+ feedsComponent.getParentIndex = () => parentIndex;
51
+ feedsComponent.state = { feedIDs: ['products', 'articles'] };
52
+ await nextTick();
53
+
54
+ const feeds = wrapper.findAll('.slot-feed');
55
+ expect(feeds).toHaveLength(2);
56
+ expect(feeds.at(0).text()).toBe('products');
57
+ expect(feeds.at(1).text()).toBe('articles');
58
+
59
+ feedsComponent.state = { feedIDs: ['articles', 'products'] };
60
+ await nextTick();
61
+
62
+ const reordered = wrapper.findAll('.slot-feed');
63
+ expect(reordered).toHaveLength(2);
64
+ expect(reordered.at(0).text()).toBe('articles');
65
+ expect(reordered.at(1).text()).toBe('products');
66
+ });
67
+
68
+ it('removes disappeared feeds with deferred parent cleanup', async () => {
69
+ jest.useFakeTimers();
70
+ const parentIndex = {
71
+ addWidgets: jest.fn(),
72
+ removeWidgets: jest.fn(),
73
+ };
74
+
75
+ const wrapper = mount({
76
+ components: { Feeds },
77
+ template: `
78
+ <Feeds search-scope="global" v-slot="{ feedID }">
79
+ <div class="slot-feed">{{ feedID }}</div>
80
+ </Feeds>
81
+ `,
82
+ });
83
+
84
+ const feedsComponent = wrapper.findComponent(Feeds).vm;
85
+ feedsComponent.getParentIndex = () => parentIndex;
86
+ feedsComponent.pendingRemovals = new Map();
87
+ feedsComponent.removalTimer = null;
88
+ feedsComponent.state = { feedIDs: ['products', 'articles'] };
89
+ await nextTick();
90
+
91
+ feedsComponent.state = { feedIDs: ['products'] };
92
+ await nextTick();
93
+
94
+ expect(wrapper.findAll('.slot-feed')).toHaveLength(1);
95
+ expect(wrapper.find('.slot-feed').text()).toBe('products');
96
+ expect(parentIndex.removeWidgets).not.toHaveBeenCalled();
97
+
98
+ jest.runAllTimers();
99
+ await nextTick();
100
+ expect(parentIndex.removeWidgets).toHaveBeenCalledTimes(1);
101
+ jest.useRealTimers();
102
+ });
103
+
104
+ it('reuses a pending feed container when the feed reappears before deferred cleanup', async () => {
105
+ jest.useFakeTimers();
106
+ const parentIndex = {
107
+ addWidgets: jest.fn(),
108
+ removeWidgets: jest.fn(),
109
+ };
110
+
111
+ const wrapper = mount({
112
+ components: { Feeds },
113
+ template: `
114
+ <Feeds search-scope="global" v-slot="{ feedID }">
115
+ <div class="slot-feed">{{ feedID }}</div>
116
+ </Feeds>
117
+ `,
118
+ });
119
+
120
+ const feedsComponent = wrapper.findComponent(Feeds).vm;
121
+ feedsComponent.getParentIndex = () => parentIndex;
122
+ feedsComponent.state = { feedIDs: ['products'] };
123
+ await nextTick();
124
+
125
+ const [firstFeedContainer] = parentIndex.addWidgets.mock.calls[0][0];
126
+
127
+ feedsComponent.state = { feedIDs: [] };
128
+ await nextTick();
129
+
130
+ expect(wrapper.findAll('.slot-feed')).toHaveLength(0);
131
+ expect(parentIndex.removeWidgets).not.toHaveBeenCalled();
132
+
133
+ feedsComponent.state = { feedIDs: ['products'] };
134
+ await nextTick();
135
+
136
+ expect(parentIndex.addWidgets).toHaveBeenCalledTimes(1);
137
+ expect(feedsComponent.feedContainers.get('products')).toStrictEqual(
138
+ firstFeedContainer
139
+ );
140
+ expect(wrapper.findAll('.slot-feed')).toHaveLength(1);
141
+
142
+ jest.runAllTimers();
143
+ await nextTick();
144
+
145
+ expect(parentIndex.removeWidgets).not.toHaveBeenCalled();
146
+ expect(feedsComponent.feedContainers.get('products')).toStrictEqual(
147
+ firstFeedContainer
148
+ );
149
+ jest.useRealTimers();
150
+ });
151
+
152
+ it('scopes slot descendants to the matching feed container', async () => {
153
+ const parentIndex = {
154
+ addWidgets: jest.fn(),
155
+ removeWidgets: jest.fn(),
156
+ };
157
+
158
+ const ScopedFeed = {
159
+ name: 'ScopedFeed',
160
+ inject: ['$_ais_getParentIndex'],
161
+ props: {
162
+ feedid: {
163
+ type: String,
164
+ required: true,
165
+ },
166
+ },
167
+ template: `<div class="scoped-feed">{{ feedid }}:{{ $_ais_getParentIndex().__feedID }}</div>`,
168
+ };
169
+
170
+ const wrapper = mount({
171
+ components: { Feeds, ScopedFeed },
172
+ template: `
173
+ <Feeds search-scope="global" v-slot="{ feedID }">
174
+ <ScopedFeed :feedid="feedID" />
175
+ </Feeds>
176
+ `,
177
+ });
178
+
179
+ const feedsComponent = wrapper.findComponent(Feeds).vm;
180
+ feedsComponent.getParentIndex = () => parentIndex;
181
+ feedsComponent.state = { feedIDs: ['products', 'articles'] };
182
+ await nextTick();
183
+
184
+ const scoped = wrapper.findAll('.scoped-feed');
185
+ expect(scoped).toHaveLength(2);
186
+ expect(scoped.at(0).text()).toBe('products:products');
187
+ expect(scoped.at(1).text()).toBe('articles:articles');
188
+ });
189
+
190
+ it('mounts containers for all feedIDs even with empty slot content', async () => {
191
+ const parentIndex = {
192
+ addWidgets: jest.fn(),
193
+ removeWidgets: jest.fn(),
194
+ };
195
+
196
+ const wrapper = mount({
197
+ components: { Feeds },
198
+ template: `
199
+ <Feeds search-scope="global" v-slot="{ feedID }">
200
+ <div v-if="feedID === 'products'" class="slot-feed">{{ feedID }}</div>
201
+ </Feeds>
202
+ `,
203
+ });
204
+
205
+ const feedsComponent = wrapper.findComponent(Feeds).vm;
206
+ feedsComponent.getParentIndex = () => parentIndex;
207
+ feedsComponent.state = { feedIDs: ['products', 'articles'] };
208
+ await nextTick();
209
+
210
+ expect(wrapper.findAll('.slot-feed')).toHaveLength(1);
211
+ expect(wrapper.find('.slot-feed').text()).toBe('products');
212
+ expect(parentIndex.addWidgets).toHaveBeenCalledTimes(1);
213
+ expect(parentIndex.addWidgets.mock.calls[0][0]).toHaveLength(2);
214
+ expect(parentIndex.addWidgets.mock.calls[0][0][0].__feedID).toBe(
215
+ 'products'
216
+ );
217
+ expect(parentIndex.addWidgets.mock.calls[0][0][1].__feedID).toBe(
218
+ 'articles'
219
+ );
220
+ });
221
+ });
package/src/widgets.js CHANGED
@@ -33,3 +33,4 @@ export { default as AisVoiceSearch } from './components/VoiceSearch.vue';
33
33
  export { default as AisRelevantSort } from './components/RelevantSort.vue';
34
34
  export { default as AisDynamicWidgets } from './components/DynamicWidgets';
35
35
  export { default as AisExperimentalDynamicWidgets } from './components/ExperimentalDynamicWidgets';
36
+ export { default as AisFeeds } from './components/Feeds';