diff --git a/.circleci/config.yml b/.circleci/config.yml index d2f2832b55..6fa3763c76 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -245,7 +245,7 @@ jobs: path: junit/jest/ - run: name: Type Checking - command: yarn run type-check + command: yarn run type-check:v4 vue v3: <<: *defaults diff --git a/examples/js/algolia-experiences/favicon.png b/examples/js/algolia-experiences/favicon.png new file mode 100644 index 0000000000..e681c65988 Binary files /dev/null and b/examples/js/algolia-experiences/favicon.png differ diff --git a/examples/js/algolia-experiences/index.html b/examples/js/algolia-experiences/index.html new file mode 100644 index 0000000000..ad90bc8257 --- /dev/null +++ b/examples/js/algolia-experiences/index.html @@ -0,0 +1,73 @@ + + + + + Algolia Experiences demo + + + + + + +
+

Buy historical books now!

+
+
+ + diff --git a/examples/js/algolia-experiences/local.html b/examples/js/algolia-experiences/local.html new file mode 100644 index 0000000000..36a7249aa3 --- /dev/null +++ b/examples/js/algolia-experiences/local.html @@ -0,0 +1,73 @@ + + + + + Algolia Experiences local demo + + + + + + +
+

Buy historical books now!

+
+
+ + diff --git a/examples/js/algolia-experiences/package.json b/examples/js/algolia-experiences/package.json new file mode 100644 index 0000000000..2d0c8b644d --- /dev/null +++ b/examples/js/algolia-experiences/package.json @@ -0,0 +1,19 @@ +{ + "name": "example-algolia-experiences", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "BABEL_ENV=parcel parcel index.html local.html", + "build": "BABEL_ENV=parcel parcel build index.html local.html --public-url .", + "website:examples": "BABEL_ENV=parcel parcel build index.html local.html --public-url . --dist-dir=../../../website/examples/js/algolia-experiences" + }, + "browserslist": "firefox 68, chrome 78, IE 11", + "devDependencies": { + "@babel/core": "7.15.5", + "@parcel/core": "2.10.0", + "@parcel/packager-raw-url": "2.10.0", + "@parcel/transformer-webmanifest": "2.10.0", + "parcel": "2.10.0", + "typescript": "5.5.2" + } +} diff --git a/jest.config.js b/jest.config.js index d30318f795..934bb71cc4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,11 @@ // @ts-check +/** @type {any} */ +const packagejson = require('./package.json'); +/** @type {'3' | '4' | '5'} */ +const algoliaSearchMajor = + packagejson.devDependencies.algoliasearch.split('.')[0]; + /** @type {import('@jest/types').Config.InitialOptions} */ const config = { rootDir: process.cwd(), @@ -17,7 +23,8 @@ const config = { '/packages/react-instantsearch-router-nextjs/__tests__', '/packages/react-instantsearch-nextjs/__tests__', '/__utils__/', - ], + algoliaSearchMajor !== '5' && '/packages/algolia-experiences', + ].filter((x) => x !== false), watchPathIgnorePatterns: [ '/packages/*/cjs', '/packages/*/dist', @@ -30,7 +37,9 @@ const config = { 'jest-watch-typeahead/filename', 'jest-watch-typeahead/testname', ], - transformIgnorePatterns: ['node_modules/(?!(search-insights)/)'], + transformIgnorePatterns: [ + 'node_modules/(?!(search-insights|algoliasearch)/)', + ], transform: { '^.+\\.(j|t)sx?$': 'babel-jest', '^.+\\.vue$': '@vue/vue2-jest', diff --git a/package.json b/package.json index 291a2efac3..218db12963 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "lint:fix": "eslint --ext .js,.ts,.tsx,.vue --fix .", "type-check": "tsc && lerna run type-check", "type-check:v3": "tsc --project tsconfig.v3.json", + "type-check:v4": "tsc --project tsconfig.v4.json", "test": "jest && lerna run test", "test:ci": "./scripts/retry.sh 3 'jest --maxWorkers=4 --ci' && lerna run test --concurrency=1", "test:ci:v4": "./scripts/retry.sh 3 'jest --maxWorkers=4 --ci' && lerna run test:v4 --concurrency=1", diff --git a/packages/algolia-experiences/README.md b/packages/algolia-experiences/README.md new file mode 100644 index 0000000000..f54d72f776 --- /dev/null +++ b/packages/algolia-experiences/README.md @@ -0,0 +1,30 @@ +# `algolia-experiences` + +This package allows you to use Algolia without code, by creating experiences in the Algolia dashboard and embedding them in your website. + +## Usage + +To get started, load the script tag and configuration in your HTML file, and add a `data-experience-id` attribute to the container where you want to render the UI. + +```html + + + + + + + + +
+ +``` + +The `data-experience-id` attribute should be set to the experience ID you want to use. You can find the experience ID in the dashboard. + +For styling, you can use the [instantsearch.css](https://www.npmjs.com/package/instantsearch.css) package. diff --git a/packages/algolia-experiences/package.json b/packages/algolia-experiences/package.json new file mode 100644 index 0000000000..e9011b016e --- /dev/null +++ b/packages/algolia-experiences/package.json @@ -0,0 +1,21 @@ +{ + "name": "algolia-experiences", + "license": "MIT", + "version": "1.0.2", + "main": "src/index.ts", + "jsdelivr": "dist/algolia-experiences.production.min.js", + "unpkg": "dist/algolia-experiences.production.min.js", + "files": [ + "dist" + ], + "dependencies": { + "instantsearch.js": "4.74.0", + "algoliasearch": "5.1.1" + }, + "devDependencies": { + "@instantsearch/testutils": "1.44.0" + }, + "scripts": { + "build": "rollup -c rollup.config.js" + } +} diff --git a/packages/algolia-experiences/rollup.config.js b/packages/algolia-experiences/rollup.config.js new file mode 100644 index 0000000000..258242f17d --- /dev/null +++ b/packages/algolia-experiences/rollup.config.js @@ -0,0 +1,89 @@ +import path from 'path'; + +import babel from 'rollup-plugin-babel'; +import commonjs from 'rollup-plugin-commonjs'; +import resolve from 'rollup-plugin-node-resolve'; +import replace from 'rollup-plugin-replace'; +import { uglify } from 'rollup-plugin-uglify'; + +import packageJson from '../../package.json'; + +const version = + process.env.NODE_ENV === 'production' + ? packageJson.version + : `UNRELEASED (${new Date().toUTCString()})`; +const algolia = '© Algolia, Inc. and contributors; MIT License'; +const link = 'https://github.com/algolia/instantsearch'; +const license = `/*! algolia-experiences ${version} | ${algolia} | ${link} */`; + +const plugins = [ + { + /** + * This plugin is a workaround for the fact that the `algoliasearch/lite` + * package resolves to the UMD by default in this version of rollup. + * Revisit when rollup > 1. + */ + name: 'handle-algoliasearch-lite', + resolveId(source) { + if (source !== 'algoliasearch/lite') return null; + return path.join( + path.dirname(path.resolve(require.resolve('algoliasearch'))), + 'lite', + 'lite.esm.browser.js' + ); + }, + }, + resolve({ + browser: true, + preferBuiltins: false, + extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'], + }), + babel({ + rootMode: 'upward', + runtimeHelpers: true, + exclude: /node_modules|algoliasearch-helper/, + extensions: ['.js', '.ts', '.tsx'], + }), + commonjs(), +]; + +const createConfiguration = ({ mode, filename }) => ({ + input: 'src/index.ts', + output: { + file: `dist/${filename}`, + name: 'instantsearch', + format: 'umd', + banner: license, + sourcemap: true, + }, + onwarn(warning, warn) { + if (warning.code === 'CIRCULAR_DEPENDENCY') + throw new Error(warning.message); + + warn(warning); + }, + plugins: [ + ...plugins, + replace({ + __DEV__: mode === 'development', + 'process.env.NODE_ENV': JSON.stringify('production'), + }), + mode === 'production' && + uglify({ + output: { + preamble: license, + }, + }), + ].filter(Boolean), +}); + +export default [ + createConfiguration({ + mode: 'development', + filename: 'algolia-experiences.development.js', + }), + createConfiguration({ + mode: 'production', + filename: 'algolia-experiences.production.min.js', + }), +]; diff --git a/packages/algolia-experiences/src/__tests__/get-information.test.ts b/packages/algolia-experiences/src/__tests__/get-information.test.ts new file mode 100644 index 0000000000..34f896da0d --- /dev/null +++ b/packages/algolia-experiences/src/__tests__/get-information.test.ts @@ -0,0 +1,47 @@ +/** + * @jest-environment jsdom + */ +import { getElements, getSettings } from '../get-information'; + +beforeEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; +}); + +describe('getSettings', () => { + test('should return the settings', () => { + document.head.innerHTML = ` + + `; + + expect(getSettings()).toEqual({ appId: 'appId', apiKey: 'apiKey' }); + }); + + test('should throw if no meta tag found', () => { + document.head.innerHTML = ''; + + expect(() => getSettings()).toThrow('No meta tag found'); + }); +}); + +describe('getElements', () => { + test('should return the elements', () => { + document.body.innerHTML = ` +
+
+ `; + + expect(getElements()).toEqual( + new Map([ + ['1', document.querySelector('[data-experience-id="1"]')!], + ['2', document.querySelector('[data-experience-id="2"]')!], + ]) + ); + }); + + test('should return an empty map if no elements found', () => { + document.body.innerHTML = ''; + + expect(getElements()).toEqual(new Map()); + }); +}); diff --git a/packages/algolia-experiences/src/__tests__/render.test.ts b/packages/algolia-experiences/src/__tests__/render.test.ts new file mode 100644 index 0000000000..1271bfde58 --- /dev/null +++ b/packages/algolia-experiences/src/__tests__/render.test.ts @@ -0,0 +1,479 @@ +/** + * @jest-environment jsdom + */ +import { + createMultiSearchResponse, + createSearchClient, +} from '@instantsearch/mocks'; +import { wait } from '@instantsearch/testutils'; +import instantsearch from 'instantsearch.js'; + +import { configToIndex, injectStyles } from '../render'; + +import type { Configuration } from '../types'; + +describe('injectStyles', () => { + it('should inject styles', () => { + injectStyles(); + const style = document.head.querySelector('style'); + expect(style).toBeDefined(); + expect(style?.textContent).not.toHaveLength(0); + }); +}); + +describe('configToIndex', () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + beforeEach(() => { + error.mockClear(); + }); + + it('errors if element not found', () => { + const elements = new Map(); + const config = { id: 'foo', indexName: 'bar', name: 'Foo', blocks: [] }; + const result = configToIndex(config, elements); + expect(result).toEqual([]); + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith( + '[Algolia Experiences] Element with id foo not found' + ); + }); + + it('returns index with widgets', () => { + const elements = new Map([ + ['foo', document.createElement('div')], + ]); + const config: Configuration = { + id: 'foo', + indexName: 'bar', + name: 'Foo', + blocks: [{ type: 'ais.hits', parameters: {}, children: [] }], + }; + const result = configToIndex(config, elements); + expect(result).toHaveLength(1); + expect(result[0].getIndexName()).toEqual('bar'); + expect(result[0].getIndexId()).toEqual('foo'); + expect(result[0].getWidgets()).toHaveLength(1); + expect(result[0].getWidgets()[0].$$type).toBe('ais.hits'); + }); + + it('maps to the right widget types', () => { + const elements = new Map([ + ['foo', document.createElement('div')], + ]); + const config: Configuration = { + id: 'foo', + indexName: 'bar', + name: 'Foo', + blocks: [ + { type: 'ais.hits', parameters: {}, children: [] }, + { type: 'ais.infiniteHits', parameters: {}, children: [] }, + { + type: 'ais.frequentlyBoughtTogether', + parameters: { objectIDs: [''] }, + children: [], + }, + { + type: 'ais.lookingSimilar', + parameters: { objectIDs: [''] }, + children: [], + }, + { + type: 'ais.relatedProducts', + parameters: { objectIDs: [''] }, + children: [], + }, + { type: 'ais.trendingItems', parameters: {}, children: [] }, + { + type: 'ais.refinementList', + parameters: { attribute: 's' }, + }, + { + type: 'ais.menu', + parameters: { attribute: 's' }, + }, + { + type: 'ais.hierarchicalMenu', + parameters: { attributes: ['s'] }, + }, + { + type: 'ais.breadcrumb', + parameters: { attributes: ['s'] }, + }, + { + type: 'ais.numericMenu', + parameters: { attribute: 's', items: [{ label: '1', end: 0 }] }, + }, + { + type: 'ais.rangeInput', + parameters: { attribute: 's' }, + }, + { + type: 'ais.rangeSlider', + parameters: { attribute: 's' }, + }, + { + type: 'ais.ratingMenu', + parameters: { attribute: 's' }, + }, + { + type: 'ais.toggleRefinement', + parameters: { attribute: 's' }, + }, + ], + }; + const result = configToIndex(config, elements); + expect(result).toHaveLength(1); + expect(result[0].getWidgets()).toHaveLength(config.blocks.length); + expect(result[0].getWidgets().map((w) => w.$$type)).toEqual([ + 'ais.hits', + 'ais.infiniteHits', + 'ais.frequentlyBoughtTogether', + 'ais.lookingSimilar', + 'ais.relatedProducts', + 'ais.trendingItems', + 'ais.refinementList', + 'ais.menu', + 'ais.hierarchicalMenu', + 'ais.breadcrumb', + 'ais.numericMenu', + 'ais.rangeInput', + 'ais.rangeSlider', + 'ais.ratingMenu', + 'ais.toggleRefinement', + ]); + }); + + describe('template widgets', () => { + it('maps children to item template', async () => { + const searchClient = createSearchClient({ + search: () => + Promise.resolve( + createMultiSearchResponse({ + hits: [ + { + objectID: 'foo', + name: 'foo', + image: 'bar', + _highlightResult: { + name: { + value: 'fo__ais-highlight__o__/ais-highlight__', + matchLevel: 'full' as const, + matchedWords: [], + }, + }, + } as any, + ], + }) + ), + }); + const search = instantsearch({ searchClient }); + const elements = new Map([ + ['foo', document.createElement('div')], + ]); + const config: Configuration = { + id: 'foo', + indexName: 'bar', + name: 'Foo', + blocks: [ + { + type: 'ais.hits', + parameters: {}, + children: [ + { + type: 'div', + parameters: { + class: [ + { type: 'string', value: 'item-' }, + { type: 'attribute', path: ['objectID'] }, + ], + }, + children: [ + { + type: 'paragraph', + parameters: { + text: [ + { type: 'string', value: 'cols: ' }, + { type: 'highlight', path: ['name'] }, + ], + }, + }, + { + type: 'image', + parameters: { + src: [{ type: 'attribute', path: ['image'] }], + alt: [{ type: 'string', value: 'alt value' }], + }, + }, + ], + }, + ], + }, + ], + }; + const result = configToIndex(config, elements); + expect(result).toHaveLength(1); + expect(result[0].getWidgets()).toHaveLength(1); + + expect(elements.get('foo')?.innerHTML).toMatchInlineSnapshot(` +
+
+ `); + + search.addWidgets(result); + search.start(); + await wait(100); + + expect(elements.get('foo')?.innerHTML).toMatchInlineSnapshot(` +
+
+
    +
  1. +
    +

    + cols: + + + fo + + + o + + +

    + alt value +
    +
  2. +
+
+
+ `); + }); + }); + + describe('panel widgets', () => { + it('maps header parameter to panel header', async () => { + const searchClient = createSearchClient({ + search: () => + Promise.resolve( + createMultiSearchResponse({ + hits: [ + { + objectID: 'foo', + name: 'foo', + image: 'bar', + _highlightResult: { + name: { + value: 'fo__ais-highlight__o__/ais-highlight__', + matchLevel: 'full' as const, + matchedWords: [], + }, + }, + } as any, + ], + }) + ), + }); + const search = instantsearch({ searchClient }); + const elements = new Map([ + ['foo', document.createElement('div')], + ]); + + const config: Configuration = { + id: 'foo', + indexName: 'bar', + name: 'Foo', + blocks: [ + { + type: 'ais.refinementList', + parameters: { attribute: 'a', header: 'header text' }, + }, + ], + }; + + const result = configToIndex(config, elements); + + expect(result).toHaveLength(1); + expect(result[0].getWidgets()).toHaveLength(1); + + expect(elements.get('foo')?.innerHTML).toMatchInlineSnapshot(` +
+
+ `); + + search.addWidgets(result); + search.start(); + await wait(100); + + expect(elements.get('foo')?.innerHTML).toMatchInlineSnapshot(` +
+
+
+ + header text + +
+
+
+
+
+
+
+
+
+ `); + }); + }); + + describe('configure', () => { + it('applies configure widget', async () => { + const searchClient = createSearchClient({ + search: jest.fn(() => + Promise.resolve( + createMultiSearchResponse({ + hits: [ + { + objectID: 'foo', + name: 'foo', + image: 'bar', + _highlightResult: { + name: { + value: 'fo__ais-highlight__o__/ais-highlight__', + matchLevel: 'full' as const, + matchedWords: [], + }, + }, + } as any, + ], + }) + ) + ), + }); + const search = instantsearch({ searchClient }); + const elements = new Map([ + ['foo', document.createElement('div')], + ]); + + const config: Configuration = { + id: 'foo', + indexName: 'bar', + name: 'Foo', + blocks: [ + { + type: 'ais.configure', + parameters: { hitsPerPage: 5 }, + }, + ], + }; + + const result = configToIndex(config, elements); + + expect(result).toHaveLength(1); + expect(result[0].getWidgets()).toHaveLength(1); + + expect(elements.get('foo')).toBeEmptyDOMElement(); + + search.addWidgets(result); + search.start(); + await wait(100); + + expect(searchClient.search).toHaveBeenCalledWith([ + { + indexName: 'bar', + params: { + hitsPerPage: 5, + }, + }, + ]); + }); + }); + + describe('other widgets', () => { + it('renders pagination', async () => { + const searchClient = createSearchClient({ + search: () => + Promise.resolve( + createMultiSearchResponse({ + hits: [], + }) + ), + }); + const search = instantsearch({ searchClient }); + const elements = new Map([ + ['foo', document.createElement('div')], + ]); + + const config: Configuration = { + id: 'foo', + indexName: 'bar', + name: 'Foo', + blocks: [ + { + type: 'ais.pagination', + parameters: {}, + }, + ], + }; + + const result = configToIndex(config, elements); + + expect(result).toHaveLength(1); + expect(result[0].getWidgets()).toHaveLength(1); + + expect(elements.get('foo')?.innerHTML).toMatchInlineSnapshot(` +
+
+ `); + + search.addWidgets(result); + search.start(); + await wait(100); + + expect(elements.get('foo')?.innerHTML).toMatchInlineSnapshot(` +
+
+
    +
  • + + « + +
  • +
  • + + ‹ + +
  • +
  • + + 1 + +
  • +
  • + + › + +
  • +
  • + + » + +
  • +
+
+
+ `); + }); + }); +}); diff --git a/packages/algolia-experiences/src/__tests__/setup-instantsearch.test.ts b/packages/algolia-experiences/src/__tests__/setup-instantsearch.test.ts new file mode 100644 index 0000000000..2c6c93e23a --- /dev/null +++ b/packages/algolia-experiences/src/__tests__/setup-instantsearch.test.ts @@ -0,0 +1,115 @@ +/** + * @jest-environment jsdom + */ +import { castToJestMock, wait } from '@instantsearch/testutils'; + +import { fetchConfiguration } from '../get-configuration'; +import { setupInstantSearch } from '../setup-instantsearch'; + +jest.mock('../get-configuration', () => { + const actual = jest.requireActual('../get-configuration'); + return { + ...actual, + fetchConfiguration: jest.fn(() => Promise.resolve([])), + }; +}); + +describe('setup of InstantSearch', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + beforeEach(() => { + errorSpy.mockReset(); + document.head.innerHTML = ''; + document.body.innerHTML = ''; + }); + + test('error logged if no meta tag found', () => { + document.head.innerHTML = ''; + + setupInstantSearch(); + + expect(errorSpy).toHaveBeenCalledWith( + '[Algolia Experiences] No meta tag found' + ); + }); + + test('no error logged if no elements found', () => { + document.head.innerHTML = ` + + `; + + setupInstantSearch(); + + expect(errorSpy).not.toHaveBeenCalled(); + }); + + test('search is exposed on the window', () => { + document.head.innerHTML = ` + + `; + + setupInstantSearch(); + + expect(window.__search).toBeDefined(); + }); + + test('styles are injected', () => { + document.head.innerHTML = ` + + `; + + setupInstantSearch(); + + expect( + document.head.querySelectorAll('[data-source=instantsearch]') + ).toHaveLength(1); + }); + + test('configuration is fetched and rendered', async () => { + castToJestMock(fetchConfiguration).mockImplementationOnce(() => + Promise.resolve([ + { + id: 'fake-configuration', + name: 'Fake Configuration', + indexName: 'fake-index-name', + blocks: [ + { + type: 'grid', + children: [], + }, + ], + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + }, + ]) + ); + + document.head.innerHTML = ` + + `; + document.body.innerHTML = ` +
+ `; + + setupInstantSearch(); + + expect( + document.querySelector('[data-experience-id="fake-configuration"]')! + .children + ).toHaveLength(0); + + await wait(0); + + expect( + document.querySelector('[data-experience-id="fake-configuration"]')! + .children + ).not.toHaveLength(0); + + expect( + document.querySelector('[data-experience-id="fake-configuration"]')! + .innerHTML + ).toMatchInlineSnapshot(` +
+
+ `); + }); +}); diff --git a/packages/algolia-experiences/src/get-configuration.ts b/packages/algolia-experiences/src/get-configuration.ts new file mode 100644 index 0000000000..77c1bd84a2 --- /dev/null +++ b/packages/algolia-experiences/src/get-configuration.ts @@ -0,0 +1,120 @@ +import type { Settings } from './get-information'; +import type { Block, Configuration } from './types'; + +export function fetchConfiguration( + ids: string[], + settings: Settings +): Promise { + return Promise.all( + ids.map((id) => + getExperience({ + id, + ...settings, + }) + ) + ); +} + +export type Experience = { + id: string; + name: string; + indexName: string; + blocks: Block[]; + createdAt: string; + updatedAt: string; +}; + +const LOCAL = false; +const API_BASE = LOCAL + ? 'http://localhost:3000/1' + : 'https://experiences-main.platform.algolia.net/1'; + +type ApiParams = { + appId: string; + apiKey: string; +} & TEndpointParams; + +type RequestParams = ApiParams<{ + endpoint: string; + method?: Request['method']; + data?: Record; +}>; + +export type DeleteExperienceParams = ApiParams>; +export function deleteExperience({ + id, + appId, + apiKey, +}: DeleteExperienceParams) { + return buildRequest({ + appId, + apiKey, + endpoint: `collections/${id}`, + method: 'DELETE', + }); +} + +export type GetExperienceParams = ApiParams>; +export function getExperience({ + id, + appId, + apiKey, +}: GetExperienceParams): Promise { + return buildRequest({ + appId, + apiKey, + endpoint: `collections/${id}`, + }); +} + +export type UpsertExperienceParams = ApiParams<{ experience: Configuration }>; +export function upsertExperience({ + experience, + appId, + apiKey, +}: UpsertExperienceParams): Promise> { + return buildRequest({ + appId, + apiKey, + endpoint: `collections`, + method: 'POST', + data: experience, + }); +} + +export type ListExperiencesParams = ApiParams>; +export function listExperiences({ + appId, + apiKey, +}: ListExperiencesParams): Promise { + return buildRequest({ + appId, + apiKey, + endpoint: 'collections', + }); +} + +function buildRequest({ + appId, + apiKey, + endpoint, + method = 'GET', + data, +}: RequestParams) { + return fetch(`${API_BASE}/${endpoint}`, { + method, + headers: { + 'X-Algolia-Application-ID': appId, + 'X-Algolia-API-Key': apiKey, + }, + body: data ? JSON.stringify(data) : undefined, + }) + .then((res) => { + if (!res.ok) { + throw new Error(res.statusText); + } + + return res; + }) + .then((res) => res.json()); +} diff --git a/packages/algolia-experiences/src/get-information.ts b/packages/algolia-experiences/src/get-information.ts new file mode 100644 index 0000000000..ba6fa00f33 --- /dev/null +++ b/packages/algolia-experiences/src/get-information.ts @@ -0,0 +1,34 @@ +export type Settings = { + appId: string; + apiKey: string; +}; + +export function getSettings(): Settings { + const metaConfiguration = document.querySelector( + 'meta[name="algolia-configuration"]' + ); + + if (!metaConfiguration || !metaConfiguration.content) { + throw new Error('No meta tag found'); + } + + const { appId, apiKey } = JSON.parse(metaConfiguration.content); + + if (!appId || !apiKey) { + throw new Error('Missing appId or apiKey in the meta tag'); + } + + return { appId, apiKey }; +} + +export function getElements() { + const elements = new Map(); + document + .querySelectorAll('[data-experience-id]') + .forEach((element) => { + const id = element.dataset.experienceId!; + elements.set(id, element); + }); + + return elements; +} diff --git a/packages/algolia-experiences/src/index.ts b/packages/algolia-experiences/src/index.ts new file mode 100644 index 0000000000..a7ea3bab18 --- /dev/null +++ b/packages/algolia-experiences/src/index.ts @@ -0,0 +1,5 @@ +import { setupInstantSearch } from './setup-instantsearch'; + +if (typeof window === 'object') { + document.addEventListener('DOMContentLoaded', setupInstantSearch); +} diff --git a/packages/algolia-experiences/src/render.tsx b/packages/algolia-experiences/src/render.tsx new file mode 100644 index 0000000000..6cf219a389 --- /dev/null +++ b/packages/algolia-experiences/src/render.tsx @@ -0,0 +1,257 @@ +/** @jsx h */ +import { getPropertyByPath } from 'instantsearch.js/es/lib/utils'; +import { index, panel } from 'instantsearch.js/es/widgets'; +import { h, Fragment } from 'preact'; + +import { error } from './util'; +import { widgets } from './widgets'; + +import type { + Block, + Configuration, + PanelWidget, + PanelWidgetTypes, + TemplateAttribute, + TemplateChild, + TemplateText, + TemplateWidgetTypes, +} from './types'; +import type { Widget } from 'instantsearch.js'; +import type { ComponentChildren, JSX } from 'preact'; + +export function injectStyles() { + const style = document.createElement('style'); + style.dataset.source = 'instantsearch'; + + // @TODO: decide if this should be for all columns or only a specific type + style.textContent = ` + .ais-Grid { + display: grid; + grid-template-columns: minmax(min-content, 200px) 1fr; + gap: 1em; + } + `; + document.head.appendChild(style); +} + +export function configToIndex( + config: Configuration, + elements: Map +) { + const container = elements.get(config.id); + if (!container) { + error(`Element with id ${config.id} not found`); + return []; + } + + return [ + index({ + indexName: config.indexName, + indexId: config.id, + }).addWidgets( + config.blocks.flatMap((block) => blockToWidget(block, container)) + ), + ]; +} + +const hitWidgets = new Set([ + 'ais.hits', + 'ais.infiniteHits', + 'ais.frequentlyBoughtTogether', + 'ais.lookingSimilar', + 'ais.relatedProducts', + 'ais.trendingItems', +]); +function isTemplateWidget( + child: Block +): child is Block & { children: TemplateChild[] } { + return hitWidgets.has(child.type as any); +} + +const panelWidgets = new Set([ + 'ais.refinementList', + 'ais.menu', + 'ais.hierarchicalMenu', + 'ais.breadcrumb', + 'ais.numericMenu', + 'ais.rangeInput', + 'ais.rangeSlider', + 'ais.ratingMenu', + 'ais.toggleRefinement', +]); +function isPanelWidget(child: Block): child is PanelWidget { + return panelWidgets.has(child.type as any); +} + +const tagNames = new Map( + Object.entries({ + paragraph: 'p', + span: 'span', + h2: 'h2', + div: 'div', + link: 'a', + image: 'img', + }) +); + +function renderText(text: TemplateText[number], hit: any, components: any) { + if (text.type === 'string') { + return text.value; + } + + if (text.type === 'attribute') { + return getPropertyByPath(hit, text.path); + } + + if (text.type === 'highlight') { + return components.Highlight({ + hit, + attribute: text.path, + }); + } + + if (text.type === 'snippet') { + return components.Snippet({ + hit, + attribute: text.path, + }); + } + + return null; +} + +function renderAttribute(text: TemplateAttribute[number], hit: any) { + if (text.type === 'string') { + return text.value; + } + + if (text.type === 'attribute') { + return getPropertyByPath(hit, text.path); + } + + return null; +} + +function blockToWidget(child: Block, container: HTMLElement): Widget[] { + if (child.type === 'ais.configure') { + return [widgets[child.type]({ ...child.parameters })]; + } + + const widgetContainer = container.appendChild(document.createElement('div')); + + if (child.type === 'grid') { + widgetContainer.classList.add('ais-Grid'); + + return child.children + .map((column) => { + return blockToWidget(column, widgetContainer); + }) + .flat(1); + } + + if (child.type === 'column') { + widgetContainer.classList.add('ais-Column'); + + return child.children + .map((column) => { + return blockToWidget(column, widgetContainer); + }) + .flat(1); + } + + if (isTemplateWidget(child)) { + // type cast is needed here because the spread adding `container` and `templates` loses the type discriminant + const parameters = child.parameters as Parameters< + typeof widgets['ais.hits'] + >[0]; + const widget = widgets[child.type] as typeof widgets['ais.hits']; + + return [ + widget({ + ...parameters, + container: widgetContainer, + templates: { + item: (hit: any, { components }) => { + if (!child.children.length) { + return no item template given; + } + + function renderChild(ch: TemplateChild) { + const Tag = tagNames.get(ch.type) as keyof JSX.IntrinsicElements; + if (!Tag) { + return ; + } + + let children: ComponentChildren = null; + if ('text' in ch.parameters) { + children = ch.parameters.text.map((text) => + renderText(text, hit, components) + ); + } else if ('children' in ch) { + children = ch.children.map(renderChild); + } + + const attributes = Object.fromEntries( + Object.entries(ch.parameters) + .filter( + (tuple): tuple is [string, TemplateAttribute] => + tuple[0] !== 'text' + ) + .map(([key, value]) => [ + key, + value.map((item) => renderAttribute(item, hit)).join(''), + ]) + ); + + return {children}; + } + + return child.children.map(renderChild); + }, + }, + }), + ]; + } + + if (isPanelWidget(child)) { + // type cast is needed here because the spread adding `container` loses the type discriminant + const { + header, + collapsed: defaultCollapsed, + ...parameters + } = child.parameters as Parameters< + typeof widgets['ais.refinementList'] + >[0] & { header: string; collapsed: boolean }; + const widget = widgets[child.type] as typeof widgets['ais.refinementList']; + return [ + panel({ + templates: { + header, + collapseButtonText: ({ collapsed }) => ( + // @TODO: put this style in a stylesheet + {collapsed ? '+' : '-'} + ), + }, + collapsed: + typeof defaultCollapsed === 'undefined' + ? undefined + : () => defaultCollapsed, + })(widget)({ + ...parameters, + container: widgetContainer, + }), + ]; + } + + // type cast is needed here because the spread adding `container` loses the type discriminant + const parameters = child.parameters as Parameters< + typeof widgets['ais.pagination'] + >[0]; + const widget = widgets[child.type] as typeof widgets['ais.pagination']; + return [ + widget({ + ...parameters, + container: widgetContainer, + }), + ]; +} diff --git a/packages/algolia-experiences/src/setup-instantsearch.ts b/packages/algolia-experiences/src/setup-instantsearch.ts new file mode 100644 index 0000000000..d969618228 --- /dev/null +++ b/packages/algolia-experiences/src/setup-instantsearch.ts @@ -0,0 +1,41 @@ +/** @jsx h */ + +import { liteClient as algoliasearch } from 'algoliasearch/lite'; +import InstantSearch from 'instantsearch.js/es/lib/InstantSearch'; + +import { fetchConfiguration } from './get-configuration'; +import { getElements, getSettings } from './get-information'; +import { configToIndex, injectStyles } from './render'; +import { error } from './util'; + +declare global { + interface Window { + __search: InstantSearch; + } +} + +export function setupInstantSearch() { + try { + const settings = getSettings(); + + const searchClient = algoliasearch(settings.appId, settings.apiKey); + const search = new InstantSearch({ + searchClient, + }); + window.__search = search; + + const elements = getElements(); + + injectStyles(); + + fetchConfiguration([...elements.keys()], settings).then((configuration) => { + search + .addWidgets( + configuration.flatMap((config) => configToIndex(config, elements)) + ) + .start(); + }); + } catch (err) { + error((err as Error).message); + } +} diff --git a/packages/algolia-experiences/src/types.ts b/packages/algolia-experiences/src/types.ts new file mode 100644 index 0000000000..9670f162f8 --- /dev/null +++ b/packages/algolia-experiences/src/types.ts @@ -0,0 +1,109 @@ +import type { widgets } from './widgets'; + +type StaticString = { type: 'string'; value: string }; +type Attribute = { type: 'attribute'; path: string[] }; +type Highlight = { type: 'highlight' | 'snippet'; path: string[] }; +export type TemplateText = Array; +export type TemplateAttribute = Array; +type RegularParameters = { + class?: TemplateAttribute; +}; +export type TemplateChild = + | { + type: 'paragraph' | 'span' | 'h2'; + parameters: { + text: TemplateText; + } & RegularParameters; + } + | { + type: 'div'; + parameters: RegularParameters; + children: TemplateChild[]; + } + | { + type: 'image'; + parameters: { + src: TemplateAttribute; + alt: TemplateAttribute; + } & RegularParameters; + } + | { + type: 'link'; + parameters: { + href: TemplateAttribute; + } & RegularParameters; + children: TemplateChild[]; + }; + +export type TemplateWidgetTypes = + | 'ais.hits' + | 'ais.infiniteHits' + | 'ais.frequentlyBoughtTogether' + | 'ais.lookingSimilar' + | 'ais.relatedProducts' + | 'ais.trendingItems'; + +export type TemplateWidget< + TKeys extends TemplateWidgetTypes = TemplateWidgetTypes +> = { + [key in TKeys]: { + type: key; + parameters: Omit< + Parameters[0], + 'container' | 'templates' + >; + children: TemplateChild[]; + }; +}[TKeys]; + +export type PanelWidgetTypes = + | 'ais.refinementList' + | 'ais.menu' + | 'ais.hierarchicalMenu' + | 'ais.breadcrumb' + | 'ais.numericMenu' + | 'ais.rangeInput' + | 'ais.rangeSlider' + | 'ais.ratingMenu' + | 'ais.toggleRefinement'; +export type PanelWidget = { + [key in TKeys]: { + type: key; + parameters: Omit[0], 'container'> & { + header?: string; + collapsed?: boolean; + }; + }; +}[TKeys]; + +type RegularWidget = + { + [key in TKeys]: { + type: key; + parameters: Omit[0], 'container'>; + }; + }[TKeys]; + +export type Block = + | { + [key in keyof typeof widgets]: key extends TemplateWidgetTypes + ? TemplateWidget + : key extends PanelWidgetTypes + ? PanelWidget + : RegularWidget; + }[keyof typeof widgets] + | { + type: 'grid'; + children: Block[]; + } + | { + type: 'column'; + children: Block[]; + }; + +export type Configuration = { + id: string; + name: string; + indexName: string; + blocks: Block[]; +}; diff --git a/packages/algolia-experiences/src/util.ts b/packages/algolia-experiences/src/util.ts new file mode 100644 index 0000000000..caf9aa3f90 --- /dev/null +++ b/packages/algolia-experiences/src/util.ts @@ -0,0 +1,9 @@ +// @TODO: hook up to some way it can be set runtime, maybe query params +const VERBOSE = true; + +export function error(message: string) { + if (VERBOSE) { + // eslint-disable-next-line no-console + console.error(`[Algolia Experiences] ${message}`); + } +} diff --git a/packages/algolia-experiences/src/widgets.ts b/packages/algolia-experiences/src/widgets.ts new file mode 100644 index 0000000000..5ea95f293d --- /dev/null +++ b/packages/algolia-experiences/src/widgets.ts @@ -0,0 +1,51 @@ +import { + breadcrumb, + clearRefinements, + configure, + currentRefinements, + frequentlyBoughtTogether, + hierarchicalMenu, + hits, + hitsPerPage, + infiniteHits, + lookingSimilar, + menu, + numericMenu, + pagination, + rangeInput, + rangeSlider, + ratingMenu, + refinementList, + relatedProducts, + searchBox, + sortBy, + stats, + toggleRefinement, + trendingItems, +} from 'instantsearch.js/es/widgets'; + +export const widgets = { + 'ais.breadcrumb': breadcrumb, + 'ais.clearRefinements': clearRefinements, + 'ais.configure': configure, + 'ais.currentRefinements': currentRefinements, + 'ais.frequentlyBoughtTogether': frequentlyBoughtTogether, + 'ais.hierarchicalMenu': hierarchicalMenu, + 'ais.hits': hits, + 'ais.hitsPerPage': hitsPerPage, + 'ais.infiniteHits': infiniteHits, + 'ais.lookingSimilar': lookingSimilar, + 'ais.menu': menu, + 'ais.numericMenu': numericMenu, + 'ais.pagination': pagination, + 'ais.rangeInput': rangeInput, + 'ais.rangeSlider': rangeSlider, + 'ais.ratingMenu': ratingMenu, + 'ais.refinementList': refinementList, + 'ais.relatedProducts': relatedProducts, + 'ais.searchBox': searchBox, + 'ais.sortBy': sortBy, + 'ais.stats': stats, + 'ais.toggleRefinement': toggleRefinement, + 'ais.trendingItems': trendingItems, +}; diff --git a/tsconfig.v3.json b/tsconfig.v3.json index 67aa42c367..d08df32654 100644 --- a/tsconfig.v3.json +++ b/tsconfig.v3.json @@ -5,6 +5,7 @@ "es", "tests/e2e", "packages/create-instantsearch-app/src/templates/**/*", + "packages/algolia-experiences", // this test has specific code for v3 and v4, so already checked in the v4 test "packages/instantsearch.js/src/middlewares/__tests__/createMetadataMiddleware.ts", diff --git a/tsconfig.v4.json b/tsconfig.v4.json new file mode 100644 index 0000000000..1f194eaf41 --- /dev/null +++ b/tsconfig.v4.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig", + "exclude": [ + "**/es", + "**/dist", + "tests/e2e", + "examples/react/next/next-env.d.ts", + "examples/react/react-native", + "packages/create-instantsearch-app/src/templates/**/*", + "packages/algolia-experiences" + ] +}