diff --git a/bundlesize.config.json b/bundlesize.config.json index a4d61ec0fab..8770eabbb8e 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,11 +10,11 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "121.5 kB" + "maxSize": "122.5 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "253 kB" + "maxSize": "256 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", diff --git a/packages/instantsearch.js/src/connectors/feeds/FeedContainer.ts b/packages/instantsearch.js/src/connectors/feeds/FeedContainer.ts new file mode 100644 index 00000000000..1a986f757d7 --- /dev/null +++ b/packages/instantsearch.js/src/connectors/feeds/FeedContainer.ts @@ -0,0 +1,289 @@ +import algoliasearchHelper from 'algoliasearch-helper'; + +import { + createInitArgs, + createRenderArgs, + storeRenderState, +} from '../../lib/utils'; + +import type { + InstantSearch, + UiState, + IndexUiState, + Widget, + IndexWidget, + DisposeOptions, + RenderOptions, +} from '../../types'; +import type { SearchParameters } from 'algoliasearch-helper'; + +export function createFeedContainer( + feedID: string, + parentIndex: IndexWidget, + instantSearchInstance: InstantSearch +): IndexWidget { + let localWidgets: Array = []; + let initialized = false; + + const container: IndexWidget = { + $$type: 'ais.feedContainer', + $$widgetType: 'ais.feedContainer', + _isolated: true, + + getIndexName: () => parentIndex.getIndexName(), + getIndexId: () => feedID, + getHelper: () => parentIndex.getHelper(), + + getResults() { + const parentResults = parentIndex.getResults(); + if (!parentResults) return null; + if (!parentResults.feeds) { + // Single-feed backward compat: no feeds array means the parent result + // itself is the only feed. + if (feedID === '') { + parentResults._state = parentIndex.getHelper()!.state; + return parentResults; + } + return null; + } + const feed = parentResults.feeds.find((f) => f.feedID === feedID); + if (!feed) return null; + // Optimistic state patching — same as index widget (index.ts:365-370) + feed._state = parentIndex.getHelper()!.state; + return feed; + }, + + getResultsForWidget() { + return this.getResults(); + }, + + getParent: () => parentIndex, + getWidgets: () => localWidgets, + getScopedResults: () => parentIndex.getScopedResults(), + getPreviousState: () => null, + createURL: ( + nextState: SearchParameters | ((state: IndexUiState) => IndexUiState) + ) => parentIndex.createURL(nextState), + scheduleLocalSearch: () => parentIndex.scheduleLocalSearch(), + + addWidgets(widgets) { + const flatWidgets = widgets.reduce>( + (acc, w) => acc.concat(Array.isArray(w) ? w : [w]), + [] + ); + flatWidgets.forEach((widget) => { + widget.parent = container; + }); + localWidgets = localWidgets.concat(flatWidgets); + + if (initialized) { + flatWidgets.forEach((widget) => { + if (widget.getRenderState) { + const renderState = widget.getRenderState( + instantSearchInstance.renderState[container.getIndexId()] || {}, + createInitArgs( + instantSearchInstance, + container, + instantSearchInstance._initialUiState + ) + ); + storeRenderState({ + renderState, + instantSearchInstance, + parent: container, + }); + } + }); + + flatWidgets.forEach((widget) => { + if (widget.init) { + widget.init( + createInitArgs( + instantSearchInstance, + container, + instantSearchInstance._initialUiState + ) + ); + } + }); + + // Merge children's search params (e.g. disjunctiveFacets) into the + // parent's helper state so they're included in the composition request. + // uiState is {} because URL-derived refinements are already on the + // parent state; children only need to declare structural params. + const parentHelper = parentIndex.getHelper()!; + const withChildParams = container.getWidgetSearchParameters( + parentHelper.state, + { uiState: {} } + ); + if (withChildParams !== parentHelper.state) { + parentHelper.state = withChildParams; + } + } + + return container; + }, + + removeWidgets(widgets) { + const flatWidgets = widgets.reduce>( + (acc, w) => acc.concat(Array.isArray(w) ? w : [w]), + [] + ); + const helper = parentIndex.getHelper(); + + flatWidgets.forEach((widget) => { + if (widget.dispose && helper) { + widget.dispose({ + helper, + state: helper.state, + recommendState: helper.recommendState, + parent: container, + }); + } + }); + + localWidgets = localWidgets.filter((w) => !flatWidgets.includes(w)); + return container; + }, + + init() { + initialized = true; + + localWidgets.forEach((widget) => { + if (widget.getRenderState) { + const renderState = widget.getRenderState( + instantSearchInstance.renderState[container.getIndexId()] || {}, + createInitArgs( + instantSearchInstance, + container, + instantSearchInstance._initialUiState + ) + ); + storeRenderState({ + renderState, + instantSearchInstance, + parent: container, + }); + } + }); + + localWidgets.forEach((widget) => { + if (widget.init) { + widget.init( + createInitArgs( + instantSearchInstance, + container, + instantSearchInstance._initialUiState + ) + ); + } + }); + }, + + render() { + localWidgets.forEach((widget) => { + if (widget.getRenderState) { + const renderState = widget.getRenderState( + instantSearchInstance.renderState[container.getIndexId()] || {}, + createRenderArgs( + instantSearchInstance, + container, + widget + ) as RenderOptions + ); + storeRenderState({ + renderState, + instantSearchInstance, + parent: container, + }); + } + }); + + localWidgets.forEach((widget) => { + if (widget.render) { + widget.render( + createRenderArgs( + instantSearchInstance, + container, + widget + ) as RenderOptions + ); + } + }); + }, + + dispose(disposeOptions?: DisposeOptions) { + const helper = parentIndex.getHelper(); + + // Chain through children's dispose to return a cleaned state + // (e.g. RefinementList.dispose removes its disjunctiveFacet declaration). + // This mirrors how the index widget's removeWidgets chains dispose calls. + let cleanedState = disposeOptions?.state ?? helper?.state; + + localWidgets.forEach((widget) => { + if (widget.dispose && helper) { + const next = widget.dispose({ + helper, + state: cleanedState!, + recommendState: helper.recommendState, + parent: container, + }); + + if (next instanceof algoliasearchHelper.RecommendParameters) { + // ignore — FeedContainer doesn't manage recommend state + } else if (next) { + cleanedState = next; + } + } + }); + + localWidgets = []; + initialized = false; + return cleanedState; + }, + + getWidgetState(uiState: UiState) { + return this.getWidgetUiState(uiState); + }, + + getWidgetUiState( + uiState: TUiState + ): TUiState { + const helper = parentIndex.getHelper()!; + const widgetUiStateOptions = { + searchParameters: helper.state, + helper, + }; + return localWidgets.reduce( + (state, widget) => + widget.getWidgetUiState + ? (widget.getWidgetUiState(state, widgetUiStateOptions) as TUiState) + : state, + uiState + ); + }, + + getWidgetSearchParameters( + searchParameters: SearchParameters, + { uiState }: { uiState: IndexUiState } + ) { + return localWidgets.reduce( + (params, widget) => + widget.getWidgetSearchParameters + ? widget.getWidgetSearchParameters(params, { uiState }) + : params, + searchParameters + ); + }, + + refreshUiState() { + // no-op: FeedContainer doesn't own UI state + }, + + setIndexUiState() { + // no-op: FeedContainer delegates to parent + }, + }; + + return container; +} diff --git a/packages/instantsearch.js/src/connectors/feeds/__tests__/FeedContainer-test.ts b/packages/instantsearch.js/src/connectors/feeds/__tests__/FeedContainer-test.ts new file mode 100644 index 00000000000..69ce0fa26ed --- /dev/null +++ b/packages/instantsearch.js/src/connectors/feeds/__tests__/FeedContainer-test.ts @@ -0,0 +1,523 @@ +/** + * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts + */ + +import { SearchParameters, SearchResults } from 'algoliasearch-helper'; + +import { createInstantSearch } from '../../../../test/createInstantSearch'; +import { createWidget } from '../../../../test/createWidget'; +import { index } from '../../../widgets'; +import { createFeedContainer } from '../FeedContainer'; + +import type { IndexWidget } from '../../../types'; +import type { SearchResponse } from 'algoliasearch-helper/types/algoliasearch'; + +function makeParentWithFeeds( + feedIDs: string[], + instantSearchInstance: ReturnType +): IndexWidget { + const parent = index({ indexName: 'test' }); + const state = instantSearchInstance.helper!.state; + const response: SearchResponse = { + hits: [], + nbHits: 0, + page: 0, + nbPages: 0, + hitsPerPage: 10, + processingTimeMS: 1, + query: '', + params: '', + exhaustiveNbHits: true, + }; + + const results = new SearchResults(state, [response]); + (results as any).feeds = feedIDs.map((feedID) => { + const feedResponse: SearchResponse = { + ...response, + hits: [{ objectID: `hit-${feedID}` }], + nbHits: 1, + }; + const feedResults = new SearchResults(state, [feedResponse]); + (feedResults as any).feedID = feedID; + return feedResults; + }); + + // Mock getResults to return results with feeds + parent.getResults = () => results; + parent.getHelper = () => instantSearchInstance.helper!; + + return parent; +} + +describe('FeedContainer', () => { + describe('identity', () => { + it('has $$type ais.feedContainer', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + expect(container.$$type).toBe('ais.feedContainer'); + expect(container.$$widgetType).toBe('ais.feedContainer'); + }); + + it('is isolated', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + expect(container._isolated).toBe(true); + }); + + it('uses feedID as indexId', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + expect(container.getIndexId()).toBe('products'); + }); + + it('returns parent indexName', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'my-index' }); + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + expect(container.getIndexName()).toBe('my-index'); + }); + }); + + describe('helper sharing', () => { + it('returns parent helper', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + const mockHelper = instantSearchInstance.helper!; + parent.getHelper = () => mockHelper; + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + expect(container.getHelper()).toBe(mockHelper); + }); + }); + + describe('getResults', () => { + it('returns null when parent has no results', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + parent.getResults = () => null; + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + expect(container.getResults()).toBeNull(); + }); + + it('returns null when parent results have no feeds', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + const state = new SearchParameters({ index: 'test' }); + const results = new SearchResults(state, [ + { + hits: [], + nbHits: 0, + page: 0, + nbPages: 0, + hitsPerPage: 10, + processingTimeMS: 1, + query: '', + params: '', + exhaustiveNbHits: true, + }, + ]); + parent.getResults = () => results; + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + expect(container.getResults()).toBeNull(); + }); + + it('returns matching feed results by feedID', () => { + const instantSearchInstance = createInstantSearch(); + const parent = makeParentWithFeeds( + ['products', 'articles'], + instantSearchInstance + ); + + const container = createFeedContainer( + 'articles', + parent, + instantSearchInstance + ); + + const result = container.getResults(); + expect(result).not.toBeNull(); + expect(result!.hits).toEqual([{ objectID: 'hit-articles' }]); + }); + + it('returns null when feedID not found in feeds', () => { + const instantSearchInstance = createInstantSearch(); + const parent = makeParentWithFeeds(['products'], instantSearchInstance); + + const container = createFeedContainer( + 'nonexistent', + parent, + instantSearchInstance + ); + + expect(container.getResults()).toBeNull(); + }); + + it('patches _state with parent helper state', () => { + const instantSearchInstance = createInstantSearch(); + const parent = makeParentWithFeeds(['products'], instantSearchInstance); + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const result = container.getResults(); + expect(result!._state).toBe(instantSearchInstance.helper!.state); + }); + }); + + describe('widget management', () => { + it('addWidgets adds widgets and sets parent', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const widget = createWidget(); + container.addWidgets([widget]); + + expect(container.getWidgets()).toContain(widget); + expect(widget.parent).toBe(container); + }); + + it('addWidgets returns the container for chaining', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const result = container.addWidgets([createWidget()]); + expect(result).toBe(container); + }); + + it('removeWidgets removes widgets and calls dispose', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + parent.getHelper = () => instantSearchInstance.helper!; + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const widget = createWidget(); + container.addWidgets([widget]); + container.removeWidgets([widget]); + + expect(container.getWidgets()).not.toContain(widget); + expect(widget.dispose).toHaveBeenCalled(); + }); + + it('addWidgets inits widgets only after container init', () => { + const instantSearchInstance = createInstantSearch({ + started: true, + } as any); + const parent = index({ indexName: 'test' }); + parent.getHelper = () => instantSearchInstance.helper!; + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const widget = createWidget(); + container.addWidgets([widget]); + + // Widget is stored but not yet initialized + expect(widget.init).not.toHaveBeenCalled(); + + // After container init, stored widgets are initialized + container.init({} as any); + expect(widget.init).toHaveBeenCalled(); + + // Widgets added after init are initialized immediately + const widget2 = createWidget(); + container.addWidgets([widget2]); + expect(widget2.init).toHaveBeenCalled(); + }); + + it('addWidgets flattens nested widget arrays', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const widget1 = createWidget(); + const widget2 = createWidget(); + container.addWidgets([[widget1], widget2] as any); + + expect(container.getWidgets()).toEqual( + expect.arrayContaining([widget1, widget2]) + ); + }); + }); + + describe('lifecycle', () => { + it('init calls init on all child widgets', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + parent.getHelper = () => instantSearchInstance.helper!; + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const widget1 = createWidget(); + const widget2 = createWidget(); + container.addWidgets([widget1, widget2]); + + container.init({} as any); + + expect(widget1.init).toHaveBeenCalled(); + expect(widget2.init).toHaveBeenCalled(); + }); + + it('render calls render on all child widgets', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + parent.getHelper = () => instantSearchInstance.helper!; + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const widget1 = createWidget(); + const widget2 = createWidget(); + container.addWidgets([widget1, widget2]); + + container.render({} as any); + + expect(widget1.render).toHaveBeenCalled(); + expect(widget2.render).toHaveBeenCalled(); + }); + + it('dispose disposes all child widgets and clears the list', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + parent.getHelper = () => instantSearchInstance.helper!; + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const widget1 = createWidget(); + const widget2 = createWidget(); + container.addWidgets([widget1, widget2]); + + container.dispose(); + + expect(widget1.dispose).toHaveBeenCalled(); + expect(widget2.dispose).toHaveBeenCalled(); + expect(container.getWidgets()).toEqual([]); + }); + }); + + describe('delegation', () => { + it('delegates getScopedResults to parent', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + const scopedResults = [{ indexId: 'test', results: null, helper: null }]; + parent.getScopedResults = () => scopedResults as any; + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + expect(container.getScopedResults()).toBe(scopedResults); + }); + + it('delegates createURL to parent', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + parent.createURL = jest.fn(() => '#test'); + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const state = new SearchParameters({ index: 'test' }); + container.createURL(state); + expect(parent.createURL).toHaveBeenCalledWith(state); + }); + + it('delegates scheduleLocalSearch to parent', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + parent.scheduleLocalSearch = jest.fn(); + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + container.scheduleLocalSearch(); + expect(parent.scheduleLocalSearch).toHaveBeenCalled(); + }); + }); + + describe('getWidgetSearchParameters', () => { + it('reduces through child widgets', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const widget = createWidget({ + getWidgetSearchParameters: jest.fn((params: SearchParameters) => + params.addDisjunctiveFacet('brand') + ), + }); + + container.addWidgets([widget]); + + const state = new SearchParameters({ index: 'test' }); + const result = container.getWidgetSearchParameters(state, { + uiState: {}, + }); + + expect(result.disjunctiveFacets).toContain('brand'); + }); + }); + + describe('getResultsForWidget', () => { + it('returns same as getResults', () => { + const instantSearchInstance = createInstantSearch(); + const parent = makeParentWithFeeds(['products'], instantSearchInstance); + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const widget = createWidget(); + expect(container.getResultsForWidget(widget)).toBe( + container.getResults() + ); + }); + }); + + describe('getWidgetUiState', () => { + it('reduces through child widgets', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + parent.getHelper = () => instantSearchInstance.helper!; + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const widget = createWidget({ + getWidgetUiState: jest.fn((uiState) => ({ + ...uiState, + refinementList: { brand: ['Apple'] }, + })), + }); + + container.addWidgets([widget]); + + const result = container.getWidgetUiState({}); + expect(result).toEqual({ + refinementList: { brand: ['Apple'] }, + }); + }); + }); + + describe('dispose return value', () => { + it('returns cleaned state after chaining through children', () => { + const instantSearchInstance = createInstantSearch(); + const parent = index({ indexName: 'test' }); + parent.getHelper = () => instantSearchInstance.helper!; + + const container = createFeedContainer( + 'products', + parent, + instantSearchInstance + ); + + const cleanedState = new SearchParameters({ index: 'cleaned' }); + const widget = createWidget({ + dispose: jest.fn(() => cleanedState), + }); + + container.addWidgets([widget]); + + const result = container.dispose({ + helper: instantSearchInstance.helper!, + state: instantSearchInstance.helper!.state, + recommendState: instantSearchInstance.helper!.recommendState, + parent: container, + }); + + expect(result).toBe(cleanedState); + }); + }); +}); diff --git a/packages/instantsearch.js/src/connectors/feeds/__tests__/connectFeeds-test.ts b/packages/instantsearch.js/src/connectors/feeds/__tests__/connectFeeds-test.ts new file mode 100644 index 00000000000..54b72b61cef --- /dev/null +++ b/packages/instantsearch.js/src/connectors/feeds/__tests__/connectFeeds-test.ts @@ -0,0 +1,366 @@ +/** + * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts + */ + +import { SearchParameters, SearchResults } from 'algoliasearch-helper'; + +import { createResultsWithFeeds } from '../../../../test/createFeedsTestHelpers'; +import { createInstantSearch } from '../../../../test/createInstantSearch'; +import { + createInitOptions, + createRenderOptions, +} from '../../../../test/createWidget'; +import connectFeeds from '../connectFeeds'; + +import type { SearchResponse } from 'algoliasearch-helper/types/algoliasearch'; + +describe('connectFeeds', () => { + describe('Usage', () => { + it('fails when no renderer is given', () => { + expect(() => + // @ts-expect-error + connectFeeds({}) + ).toThrowErrorMatchingInlineSnapshot(` + "The render function is not valid (received type Object). + + See documentation: https://www.algolia.com/doc/api-reference/widgets/feeds/js/#connector" + `); + }); + + it('fails when searchScope is not global', () => { + expect(() => + connectFeeds(() => {})({ + // @ts-expect-error + searchScope: 'local', + }) + ).toThrowErrorMatchingInlineSnapshot(` + "The \`searchScope\` option currently only supports \\"global\\". + + See documentation: https://www.algolia.com/doc/api-reference/widgets/feeds/js/#connector" + `); + }); + + it('fails when searchScope is missing', () => { + expect(() => + connectFeeds(() => {})({ + // @ts-expect-error + searchScope: undefined, + }) + ).toThrowErrorMatchingInlineSnapshot(` + "The \`searchScope\` option currently only supports \\"global\\". + + See documentation: https://www.algolia.com/doc/api-reference/widgets/feeds/js/#connector" + `); + }); + }); + + it('is a widget', () => { + const render = jest.fn(); + const unmount = jest.fn(); + + const customFeeds = connectFeeds(render, unmount); + const widget = customFeeds({ searchScope: 'global' }); + + expect(widget).toEqual( + expect.objectContaining({ + $$type: 'ais.feeds', + init: expect.any(Function), + render: expect.any(Function), + dispose: expect.any(Function), + getRenderState: expect.any(Function), + getWidgetSearchParameters: expect.any(Function), + }) + ); + }); + + describe('init', () => { + it('throws when compositionID is not set', () => { + const feedsWidget = connectFeeds(() => {})({ + searchScope: 'global', + }); + + expect(() => { + feedsWidget.init!(createInitOptions()); + }).toThrowErrorMatchingInlineSnapshot(` + "The \`feeds\` widget requires a composition-based InstantSearch instance (compositionID must be set). + + See documentation: https://www.algolia.com/doc/api-reference/widgets/feeds/js/#connector" + `); + }); + + it('calls renderFn with empty feedIDs on init', () => { + const renderFn = jest.fn(); + const instantSearchInstance = createInstantSearch({ + compositionID: 'my-comp', + } as any); + + const feedsWidget = connectFeeds(renderFn)({ + searchScope: 'global', + }); + + feedsWidget.init!(createInitOptions({ instantSearchInstance })); + + expect(renderFn).toHaveBeenCalledTimes(1); + expect(renderFn).toHaveBeenCalledWith( + expect.objectContaining({ + feedIDs: [], + widgetParams: { + searchScope: 'global', + }, + }), + true + ); + }); + }); + + describe('render', () => { + it('computes feedIDs from results.feeds', () => { + const renderFn = jest.fn(); + const instantSearchInstance = createInstantSearch({ + compositionID: 'my-comp', + } as any); + + const feedsWidget = connectFeeds(renderFn)({ + searchScope: 'global', + }); + + const results = createResultsWithFeeds( + ['products', 'articles'], + instantSearchInstance.helper!.state + ); + + feedsWidget.init!(createInitOptions({ instantSearchInstance })); + feedsWidget.render!( + createRenderOptions({ instantSearchInstance, results }) + ); + + expect(renderFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + feedIDs: ['products', 'articles'], + }), + false + ); + }); + + it('applies transformFeeds to reorder feeds', () => { + const renderFn = jest.fn(); + const instantSearchInstance = createInstantSearch({ + compositionID: 'my-comp', + } as any); + + const feedsWidget = connectFeeds(renderFn)({ + searchScope: 'global', + transformFeeds: (feeds) => feeds.reverse(), + }); + + const results = createResultsWithFeeds( + ['products', 'articles'], + instantSearchInstance.helper!.state + ); + + feedsWidget.init!(createInitOptions({ instantSearchInstance })); + feedsWidget.render!( + createRenderOptions({ instantSearchInstance, results }) + ); + + expect(renderFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + feedIDs: ['articles', 'products'], + }), + false + ); + }); + + it('applies transformFeeds to filter feeds', () => { + const renderFn = jest.fn(); + const instantSearchInstance = createInstantSearch({ + compositionID: 'my-comp', + } as any); + + const feedsWidget = connectFeeds(renderFn)({ + searchScope: 'global', + transformFeeds: (feeds) => + feeds.filter((feedID) => feedID === 'products'), + }); + + const results = createResultsWithFeeds( + ['products', 'articles'], + instantSearchInstance.helper!.state + ); + + feedsWidget.init!(createInitOptions({ instantSearchInstance })); + feedsWidget.render!( + createRenderOptions({ instantSearchInstance, results }) + ); + + expect(renderFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + feedIDs: ['products'], + }), + false + ); + }); + + it('handles render with no results', () => { + const renderFn = jest.fn(); + const instantSearchInstance = createInstantSearch({ + compositionID: 'my-comp', + } as any); + + const feedsWidget = connectFeeds(renderFn)({ + searchScope: 'global', + }); + + feedsWidget.init!(createInitOptions({ instantSearchInstance })); + feedsWidget.render!( + createRenderOptions({ + instantSearchInstance, + results: undefined as any, + }) + ); + + expect(renderFn).toHaveBeenLastCalledWith( + expect.objectContaining({ feedIDs: [] }), + false + ); + }); + + it('handles single-feed backward compat (no feeds property)', () => { + const renderFn = jest.fn(); + const instantSearchInstance = createInstantSearch({ + compositionID: 'my-comp', + } as any); + + const feedsWidget = connectFeeds(renderFn)({ + searchScope: 'global', + }); + + const state = instantSearchInstance.helper!.state; + const response: SearchResponse = { + hits: [{ objectID: '1' }], + nbHits: 1, + page: 0, + nbPages: 1, + hitsPerPage: 10, + processingTimeMS: 1, + query: '', + params: '', + exhaustiveNbHits: true, + }; + const results = new SearchResults(state, [response]); + + feedsWidget.init!(createInitOptions({ instantSearchInstance })); + feedsWidget.render!( + createRenderOptions({ instantSearchInstance, results }) + ); + + expect(renderFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + feedIDs: [''], + }), + false + ); + }); + + it('throws when transformFeeds does not return an array', () => { + const renderFn = jest.fn(); + const instantSearchInstance = createInstantSearch({ + compositionID: 'my-comp', + } as any); + + const feedsWidget = connectFeeds(renderFn)({ + searchScope: 'global', + transformFeeds: () => 'products' as any, + }); + + const results = createResultsWithFeeds( + ['products'], + instantSearchInstance.helper!.state + ); + + feedsWidget.init!(createInitOptions({ instantSearchInstance })); + + expect(() => { + feedsWidget.render!( + createRenderOptions({ instantSearchInstance, results }) + ); + }).toThrowErrorMatchingInlineSnapshot(` + "The \`transformFeeds\` option expects a function that returns an Array. + + See documentation: https://www.algolia.com/doc/api-reference/widgets/feeds/js/#connector" + `); + }); + }); + + describe('dispose', () => { + it('calls unmountFn', () => { + const renderFn = jest.fn(); + const unmountFn = jest.fn(); + + const feedsWidget = connectFeeds(renderFn, unmountFn)({ + searchScope: 'global', + }); + + feedsWidget.dispose!({} as any); + + expect(unmountFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('getWidgetSearchParameters', () => { + it('passes through search parameters unchanged', () => { + const feedsWidget = connectFeeds(() => {})({ + searchScope: 'global', + }); + + const state = new SearchParameters({ index: 'test' }); + expect( + feedsWidget.getWidgetSearchParameters!(state, { uiState: {} }) + ).toBe(state); + }); + }); + + describe('getWidgetRenderState', () => { + it('returns empty feedIDs when no results', () => { + const feedsWidget = connectFeeds(() => {})({ + searchScope: 'global', + }); + + const renderState = feedsWidget.getWidgetRenderState( + createRenderOptions({ results: undefined as any }) + ); + + expect(renderState.feedIDs).toEqual([]); + }); + + it('computes feedIDs from results (stateless)', () => { + const feedsWidget = connectFeeds(() => {})({ + searchScope: 'global', + }); + + const results = createResultsWithFeeds(['a', 'b']); + + const renderState = feedsWidget.getWidgetRenderState( + createRenderOptions({ results }) + ); + + expect(renderState.feedIDs).toEqual(['a', 'b']); + }); + }); + + describe('getRenderState', () => { + it('merges feeds into renderState', () => { + const feedsWidget = connectFeeds(() => {})({ + searchScope: 'global', + }); + + const renderState = feedsWidget.getRenderState( + {}, + createRenderOptions() + ); + + expect(renderState.feeds).toBeDefined(); + expect(renderState.feeds.feedIDs).toEqual(['']); + }); + }); +}); diff --git a/packages/instantsearch.js/src/connectors/feeds/connectFeeds.ts b/packages/instantsearch.js/src/connectors/feeds/connectFeeds.ts new file mode 100644 index 00000000000..869bc614f18 --- /dev/null +++ b/packages/instantsearch.js/src/connectors/feeds/connectFeeds.ts @@ -0,0 +1,143 @@ +import { + checkRendering, + createDocumentationMessageGenerator, + noop, +} from '../../lib/utils'; + +import type { Connector } from '../../types'; + +const withUsage = createDocumentationMessageGenerator({ + name: 'feeds', + connector: true, +}); + +export type FeedsRenderState = { + feedIDs: string[]; +}; + +export type FeedsConnectorParams = { + /** + * Explicit search scope. Currently only 'global' is supported + * (future-proofing for per-feed search parameters). + */ + searchScope: 'global'; + + /** + * Optional: transform/reorder/filter feed IDs before rendering. + */ + transformFeeds?: (feeds: string[]) => string[]; +}; + +export type FeedsWidgetDescription = { + $$type: 'ais.feeds'; + renderState: FeedsRenderState; + indexRenderState: { + feeds: FeedsRenderState; + }; +}; + +export type FeedsConnector = Connector< + FeedsWidgetDescription, + FeedsConnectorParams +>; + +const connectFeeds: FeedsConnector = function connectFeeds( + renderFn, + unmountFn = noop +) { + checkRendering(renderFn, withUsage()); + + return (widgetParams) => { + const { searchScope, transformFeeds = (feeds) => feeds } = widgetParams; + + if (searchScope !== 'global') { + throw new Error( + withUsage('The `searchScope` option currently only supports "global".') + ); + } + + return { + $$type: 'ais.feeds', + $$widgetType: 'ais.feeds', + + init(initOptions) { + const { instantSearchInstance } = initOptions; + + if (!instantSearchInstance.compositionID) { + throw new Error( + withUsage( + 'The `feeds` widget requires a composition-based InstantSearch instance (compositionID must be set).' + ) + ); + } + + renderFn( + { + ...this.getWidgetRenderState(initOptions), + instantSearchInstance, + }, + true + ); + }, + + render(renderOptions) { + const { instantSearchInstance } = renderOptions; + + renderFn( + { + ...this.getWidgetRenderState(renderOptions), + instantSearchInstance, + }, + false + ); + }, + + dispose() { + unmountFn(); + }, + + getWidgetSearchParameters(state) { + return state; + }, + + getRenderState(renderState, renderOptions) { + return { + ...renderState, + feeds: this.getWidgetRenderState(renderOptions), + }; + }, + + getWidgetRenderState({ results }) { + if (!results) { + return { feedIDs: [], widgetParams }; + } + + let feedIDs = results.feeds + ? results.feeds.map((f: { feedID: string }) => f.feedID) + : ['']; + + feedIDs = transformFeeds(feedIDs); + + if (!Array.isArray(feedIDs)) { + throw new Error( + withUsage( + 'The `transformFeeds` option expects a function that returns an Array.' + ) + ); + } + + if (!feedIDs.every((feedID: string) => typeof feedID === 'string')) { + throw new Error( + withUsage( + 'The `transformFeeds` option expects a function that returns an array of feed IDs (strings).' + ) + ); + } + + return { feedIDs, widgetParams }; + }, + }; + }; +}; + +export default connectFeeds; diff --git a/packages/instantsearch.js/src/connectors/index.ts b/packages/instantsearch.js/src/connectors/index.ts index fb08e3cd87c..f96af17556e 100644 --- a/packages/instantsearch.js/src/connectors/index.ts +++ b/packages/instantsearch.js/src/connectors/index.ts @@ -55,4 +55,5 @@ export { default as connectRelevantSort } from './relevant-sort/connectRelevantS export { default as connectFrequentlyBoughtTogether } from './frequently-bought-together/connectFrequentlyBoughtTogether'; export { default as connectLookingSimilar } from './looking-similar/connectLookingSimilar'; export { default as connectChat } from './chat/connectChat'; +export { default as connectFeeds } from './feeds/connectFeeds'; export { default as connectFilterSuggestions } from './filter-suggestions/connectFilterSuggestions'; diff --git a/packages/instantsearch.js/src/connectors/index.umd.ts b/packages/instantsearch.js/src/connectors/index.umd.ts index 8ad9a7de314..dd2774c66b6 100644 --- a/packages/instantsearch.js/src/connectors/index.umd.ts +++ b/packages/instantsearch.js/src/connectors/index.umd.ts @@ -63,4 +63,5 @@ Please use InstantSearch.js with a packaging system: https://www.algolia.com/doc/guides/building-search-ui/installation/js/#with-a-packaging-system` ); }; +export { default as connectFeeds } from './feeds/connectFeeds'; export { default as connectFilterSuggestions } from './filter-suggestions/connectFilterSuggestions'; diff --git a/packages/instantsearch.js/src/lib/utils/isIndexWidget.ts b/packages/instantsearch.js/src/lib/utils/isIndexWidget.ts index eeb74b46113..0cfa1af05eb 100644 --- a/packages/instantsearch.js/src/lib/utils/isIndexWidget.ts +++ b/packages/instantsearch.js/src/lib/utils/isIndexWidget.ts @@ -1,7 +1,9 @@ +import { indexWidgetTypes } from '../../types'; + import type { Widget, IndexWidget } from '../../types'; export function isIndexWidget( widget: Widget | IndexWidget ): widget is IndexWidget { - return widget.$$type === 'ais.index'; + return indexWidgetTypes.includes(widget.$$type as (typeof indexWidgetTypes)[number]); } diff --git a/packages/instantsearch.js/src/lib/utils/render-args.ts b/packages/instantsearch.js/src/lib/utils/render-args.ts index 3eaabbcda78..7a3bb6eafbc 100644 --- a/packages/instantsearch.js/src/lib/utils/render-args.ts +++ b/packages/instantsearch.js/src/lib/utils/render-args.ts @@ -1,4 +1,10 @@ -import type { InstantSearch, UiState, Widget, IndexWidget } from '../../types'; +import type { + InstantSearch, + UiState, + Widget, + IndexWidget, + IndexRenderState, +} from '../../types'; export function createInitArgs( instantSearchInstance: InstantSearch, @@ -49,3 +55,25 @@ export function createRenderArgs( error: instantSearchInstance.error, }; } + +export function storeRenderState({ + renderState, + instantSearchInstance, + parent, +}: { + renderState: IndexRenderState; + instantSearchInstance: InstantSearch; + parent?: IndexWidget; +}) { + const parentIndexName = parent + ? parent.getIndexId() + : instantSearchInstance.mainIndex.getIndexId(); + + instantSearchInstance.renderState = { + ...instantSearchInstance.renderState, + [parentIndexName]: { + ...instantSearchInstance.renderState[parentIndexName], + ...renderState, + }, + }; +} diff --git a/packages/instantsearch.js/src/middlewares/createMetadataMiddleware.ts b/packages/instantsearch.js/src/middlewares/createMetadataMiddleware.ts index 4c6e412c6eb..9f41696b349 100644 --- a/packages/instantsearch.js/src/middlewares/createMetadataMiddleware.ts +++ b/packages/instantsearch.js/src/middlewares/createMetadataMiddleware.ts @@ -1,6 +1,7 @@ import { createInitArgs, getAlgoliaAgent, + isIndexWidget, safelyRunOnBrowser, } from '../lib/utils'; @@ -62,9 +63,9 @@ function extractWidgetPayload( params, }); - if (widget.$$type === 'ais.index') { + if (isIndexWidget(widget)) { extractWidgetPayload( - (widget as IndexWidget).getWidgets(), + widget.getWidgets(), instantSearchInstance, payload ); diff --git a/packages/instantsearch.js/src/types/widget.ts b/packages/instantsearch.js/src/types/widget.ts index 5904e24002b..88920dc38a8 100644 --- a/packages/instantsearch.js/src/types/widget.ts +++ b/packages/instantsearch.js/src/types/widget.ts @@ -55,6 +55,9 @@ export type DisposeOptions = { parent: IndexWidget; }; +export const indexWidgetTypes = ['ais.index', 'ais.feedContainer'] as const; +export type IndexWidgetType = (typeof indexWidgetTypes)[number]; + // @MAJOR: Remove these exported types if we don't need them export type BuiltinTypes = | 'ais.analytics' @@ -67,6 +70,8 @@ export type BuiltinTypes = | 'ais.configureRelatedItems' | 'ais.currentRefinements' | 'ais.dynamicWidgets' + | 'ais.feedContainer' + | 'ais.feeds' | 'ais.frequentlyBoughtTogether' | 'ais.geoSearch' | 'ais.hierarchicalMenu' @@ -107,6 +112,8 @@ export type BuiltinWidgetTypes = | 'ais.configureRelatedItems' | 'ais.currentRefinements' | 'ais.dynamicWidgets' + | 'ais.feedContainer' + | 'ais.feeds' | 'ais.frequentlyBoughtTogether' | 'ais.geoSearch' | 'ais.hierarchicalMenu' diff --git a/packages/instantsearch.js/src/widgets/index/index.ts b/packages/instantsearch.js/src/widgets/index/index.ts index e74f20e0791..b02a1ce3720 100644 --- a/packages/instantsearch.js/src/widgets/index/index.ts +++ b/packages/instantsearch.js/src/widgets/index/index.ts @@ -9,6 +9,7 @@ import { isIndexWidget, createInitArgs, createRenderArgs, + storeRenderState, defer, } from '../../lib/utils'; import { addWidgetId } from '../../lib/utils/addWidgetId'; @@ -19,10 +20,10 @@ import type { IndexUiState, Widget, ScopedResult, - IndexRenderState, RenderOptions, RecommendResponse, SearchClient, + IndexWidgetType, } from '../../types'; import type { AlgoliaSearchHelper as Helper, @@ -103,8 +104,8 @@ type LocalWidgetRecommendParametersOptions = WidgetSearchParametersOptions & { }; export type IndexWidgetDescription = { - $$type: 'ais.index'; - $$widgetType: 'ais.index'; + $$type: IndexWidgetType; + $$widgetType: IndexWidgetType; }; export type IndexWidget = Omit< @@ -1065,28 +1066,6 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { export default index; -function storeRenderState({ - renderState, - instantSearchInstance, - parent, -}: { - renderState: IndexRenderState; - instantSearchInstance: InstantSearch; - parent?: IndexWidget; -}) { - const parentIndexName = parent - ? parent.getIndexId() - : instantSearchInstance.mainIndex.getIndexId(); - - instantSearchInstance.renderState = { - ...instantSearchInstance.renderState, - [parentIndexName]: { - ...instantSearchInstance.renderState[parentIndexName], - ...renderState, - }, - }; -} - /** * Walk up the parent chain to find the closest isolated index, or fall back to mainHelper */ diff --git a/packages/instantsearch.js/test/createFeedsTestHelpers.ts b/packages/instantsearch.js/test/createFeedsTestHelpers.ts new file mode 100644 index 00000000000..57bb3b0904d --- /dev/null +++ b/packages/instantsearch.js/test/createFeedsTestHelpers.ts @@ -0,0 +1,36 @@ +import { createSingleSearchResponse } from '@instantsearch/mocks'; +import { SearchParameters, SearchResults } from 'algoliasearch-helper'; + +import { index } from '../src/widgets'; + +import type { IndexWidget } from '../src/types'; +import type { createInstantSearch } from './createInstantSearch'; + +export function createResultsWithFeeds( + feedIDs: string[], + state?: SearchParameters +): SearchResults { + const searchState = state || new SearchParameters({ index: 'test' }); + const response = createSingleSearchResponse(); + + const results = new SearchResults(searchState, [response]); + (results as any).feeds = feedIDs.map((feedID) => { + const feedResponse = createSingleSearchResponse({ + hits: [{ objectID: `hit-${feedID}` }], + nbHits: 1, + }); + const feedResults = new SearchResults(searchState, [feedResponse]); + (feedResults as any).feedID = feedID; + return feedResults; + }); + + return results; +} + +export function createParentWithHelper( + instantSearchInstance: ReturnType +): IndexWidget { + const parent = index({ indexName: 'test' }); + parent.getHelper = () => instantSearchInstance.helper!; + return parent; +}