diff --git a/package-lock.json b/package-lock.json index f38742fdb..02c97e5de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2707,9 +2707,9 @@ "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==" }, "node_modules/@eslint/eslintrc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.1.tgz", - "integrity": "sha512-bxvbYnBPN1Gibwyp6NrpnFzA3YtRL3BBAyEAFVIpNTm2Rn4Vy87GA5M4aSn3InRrlsbX5N0GW7XIx+U4SAEKdQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.2.tgz", + "integrity": "sha512-lTVWHs7O2hjBFZunXTZYnYqtB9GakA1lnxIf+gKq2nY5gxkkNi/lQvveW6t8gFdOHTg6nG50Xs95PrLqVpcaLg==", "dev": true, "dependencies": { "ajv": "^6.12.4", @@ -3438,9 +3438,9 @@ } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.5.tgz", - "integrity": "sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.6.tgz", + "integrity": "sha512-R7xHtBSNm+9SyvpJkdQl+qrM3Hm2fea3Ef197M3mUug+v+yR+Rhfbs7PBtcBUVnIWJ4JcAdjvij+c8hXS9p5aw==", "engines": { "node": ">=6.0.0" } @@ -15036,6 +15036,45 @@ "ajv": ">=5.0.0" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, "node_modules/ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", @@ -16210,9 +16249,9 @@ } }, "node_modules/bonjour-service": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.0.11.tgz", - "integrity": "sha512-drMprzr2rDTCtgEE3VgdA9uUFaUHF+jXduwYSThHJnKMYM+FhI9Z3ph+TX3xy0LtgYHae6CHYPJ/2UnK8nQHcA==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.0.12.tgz", + "integrity": "sha512-pMmguXYCu63Ug37DluMKEHdxc+aaIf/ay4YbF8Gxtba+9d3u+rmEWy61VK3Z3hp8Rskok3BunHYnG0dUHAsblw==", "dev": true, "dependencies": { "array-flatten": "^2.1.2", @@ -16754,9 +16793,9 @@ } }, "node_modules/camel-case/node_modules/tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true }, "node_modules/camelcase": { @@ -19290,9 +19329,9 @@ } }, "node_modules/cypress/node_modules/tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true }, "node_modules/cypress/node_modules/universalify": { @@ -19886,9 +19925,9 @@ } }, "node_modules/dot-case/node_modules/tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true }, "node_modules/dot-prop": { @@ -19997,9 +20036,9 @@ } }, "node_modules/downshift/node_modules/tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true }, "node_modules/duplexer": { @@ -20073,9 +20112,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.117", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.117.tgz", - "integrity": "sha512-ypZHxY+Sf/PXu7LVN+xoeanyisnJeSOy8Ki439L/oLueZb4c72FI45zXcK3gPpmTwyufh9m6NnbMLXnJh/0Fxg==" + "version": "1.4.118", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.118.tgz", + "integrity": "sha512-maZIKjnYDvF7Fs35nvVcyr44UcKNwybr93Oba2n3HkKDFAtk0svERkLN/HyczJDS3Fo4wU9th9fUQd09ZLtj1w==" }, "node_modules/element-resize-detector": { "version": "1.2.4", @@ -20378,9 +20417,9 @@ } }, "node_modules/es5-shim": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.5.tgz", - "integrity": "sha512-vfQ4UAai8szn0sAubCy97xnZ4sJVDD1gt/Grn736hg8D7540wemIb1YPrYZSTqlM2H69EQX1or4HU/tSwRTI3w==", + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.6.tgz", + "integrity": "sha512-Ay5QQE78I2WKUoZVZjL0AIuiIjsmXwZGkyCTH9+n6J1anPbb0ymDA27ASa2Lt0rhOpAlEKy2W0d17gJ1XOQ5eQ==", "dev": true, "engines": { "node": ">=0.4.0" @@ -20497,12 +20536,12 @@ } }, "node_modules/eslint": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.13.0.tgz", - "integrity": "sha512-D+Xei61eInqauAyTJ6C0q6x9mx7kTUC1KZ0m0LSEexR0V+e94K12LmWX076ZIsldwfQ2RONdaJe0re0TRGQbRQ==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.14.0.tgz", + "integrity": "sha512-3/CE4aJX7LNEiE3i6FeodHmI/38GZtWCsAtsymScmzYapx8q1nVVb+eLcLSzATmCPXw5pT4TqVs1E0OmxAd9tw==", "dev": true, "dependencies": { - "@eslint/eslintrc": "^1.2.1", + "@eslint/eslintrc": "^1.2.2", "@humanwhocodes/config-array": "^0.9.2", "ajv": "^6.10.0", "chalk": "^4.0.0", @@ -27911,9 +27950,9 @@ } }, "node_modules/listr2/node_modules/tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true }, "node_modules/load-json-file": { @@ -28351,9 +28390,9 @@ } }, "node_modules/lower-case/node_modules/tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true }, "node_modules/lowercase-keys": { @@ -29550,9 +29589,9 @@ } }, "node_modules/no-case/node_modules/tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true }, "node_modules/node-fetch": { @@ -30908,9 +30947,9 @@ } }, "node_modules/param-case/node_modules/tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true }, "node_modules/parent-module": { @@ -31037,9 +31076,9 @@ } }, "node_modules/pascal-case/node_modules/tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true }, "node_modules/pascalcase": { @@ -37385,12 +37424,12 @@ } }, "node_modules/use-composed-ref": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.2.1.tgz", - "integrity": "sha512-6+X1FLlIcjvFMAeAD/hcxDT8tmyrWnbSPMU0EnxQuDLIxokuFzWliXBiYZuGIx+mrAMLBw0WFfCkaPw8ebzAhw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.3.0.tgz", + "integrity": "sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==", "dev": true, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "node_modules/use-isomorphic-layout-effect": { @@ -38404,23 +38443,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/webpack-dev-server/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/webpack-dev-server/node_modules/ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", @@ -39184,51 +39206,57 @@ } }, "packages/snap-client": { - "version": "0.26.0", + "name": "@searchspring/snap-client", + "version": "0.26.1", "license": "MIT", "dependencies": { - "@searchspring/snap-toolbox": "^0.26.0", + "@searchspring/snap-toolbox": "^0.26.1", "deepmerge": "^4.2.2" } }, "packages/snap-controller": { - "version": "0.26.0", + "name": "@searchspring/snap-controller", + "version": "0.26.1", "license": "MIT", "dependencies": { - "@searchspring/snap-toolbox": "^0.26.0", + "@searchspring/snap-toolbox": "^0.26.1", "deepmerge": "^4.2.2" }, "devDependencies": { - "@searchspring/snap-client": "^0.26.0", - "@searchspring/snap-event-manager": "^0.26.0", - "@searchspring/snap-logger": "^0.26.0", - "@searchspring/snap-profiler": "^0.26.0", - "@searchspring/snap-store-mobx": "^0.26.0", - "@searchspring/snap-tracker": "^0.26.0", - "@searchspring/snap-url-manager": "^0.26.0" + "@searchspring/snap-client": "^0.26.1", + "@searchspring/snap-event-manager": "^0.26.1", + "@searchspring/snap-logger": "^0.26.1", + "@searchspring/snap-profiler": "^0.26.1", + "@searchspring/snap-store-mobx": "^0.26.1", + "@searchspring/snap-tracker": "^0.26.1", + "@searchspring/snap-url-manager": "^0.26.1" } }, "packages/snap-event-manager": { - "version": "0.26.0", + "name": "@searchspring/snap-event-manager", + "version": "0.26.1", "license": "MIT" }, "packages/snap-logger": { - "version": "0.26.0", + "name": "@searchspring/snap-logger", + "version": "0.26.1", "license": "MIT" }, "packages/snap-preact": { - "version": "0.26.0", + "name": "@searchspring/snap-preact", + "version": "0.26.1", "license": "MIT", "dependencies": { - "@searchspring/snap-client": "^0.26.0", - "@searchspring/snap-controller": "^0.26.0", - "@searchspring/snap-event-manager": "^0.26.0", - "@searchspring/snap-logger": "^0.26.0", - "@searchspring/snap-profiler": "^0.26.0", - "@searchspring/snap-store-mobx": "^0.26.0", - "@searchspring/snap-toolbox": "^0.26.0", - "@searchspring/snap-tracker": "^0.26.0", - "@searchspring/snap-url-manager": "^0.26.0", + "@searchspring/snap-client": "^0.26.1", + "@searchspring/snap-controller": "^0.26.1", + "@searchspring/snap-event-manager": "^0.26.1", + "@searchspring/snap-logger": "^0.26.1", + "@searchspring/snap-preact-components": "^0.26.1", + "@searchspring/snap-profiler": "^0.26.1", + "@searchspring/snap-store-mobx": "^0.26.1", + "@searchspring/snap-toolbox": "^0.26.1", + "@searchspring/snap-tracker": "^0.26.1", + "@searchspring/snap-url-manager": "^0.26.1", "deepmerge": "^4.2.2", "intersection-observer": "^0.12.0", "is-plain-object": "^5.0.0" @@ -39238,12 +39266,20 @@ } }, "packages/snap-preact-components": { - "version": "0.26.0", + "name": "@searchspring/snap-preact-components", + "version": "0.26.1", "license": "MIT", "dependencies": { "@emotion/react": "^11.7.1", - "@searchspring/snap-preact": "^0.26.0", - "@searchspring/snap-toolbox": "^0.26.0", + "@searchspring/snap-client": "^0.26.1", + "@searchspring/snap-controller": "^0.26.1", + "@searchspring/snap-event-manager": "^0.26.1", + "@searchspring/snap-logger": "^0.26.1", + "@searchspring/snap-profiler": "^0.26.1", + "@searchspring/snap-store-mobx": "^0.26.1", + "@searchspring/snap-toolbox": "^0.26.1", + "@searchspring/snap-tracker": "^0.26.1", + "@searchspring/snap-url-manager": "^0.26.1", "classnames": "^2.3.1", "deepmerge": "^4.2.2", "mobx-react-lite": "^3.2.3", @@ -39252,13 +39288,13 @@ }, "devDependencies": { "@mdx-js/loader": "^1.6.22", - "@searchspring/snap-client": "^0.26.0", - "@searchspring/snap-controller": "^0.26.0", - "@searchspring/snap-event-manager": "^0.26.0", - "@searchspring/snap-logger": "^0.26.0", - "@searchspring/snap-profiler": "^0.26.0", - "@searchspring/snap-store-mobx": "^0.26.0", - "@searchspring/snap-url-manager": "^0.26.0", + "@searchspring/snap-client": "^0.26.1", + "@searchspring/snap-controller": "^0.26.1", + "@searchspring/snap-event-manager": "^0.26.1", + "@searchspring/snap-logger": "^0.26.1", + "@searchspring/snap-profiler": "^0.26.1", + "@searchspring/snap-store-mobx": "^0.26.1", + "@searchspring/snap-url-manager": "^0.26.1", "@storybook/addon-actions": "^6.4.9", "@storybook/addon-controls": "^6.4.9", "@storybook/addon-docs": "^6.4.9", @@ -39441,47 +39477,6 @@ } } }, - "packages/snap-preact-components/node_modules/@storybook/addon-docs/node_modules/@storybook/source-loader": { - "version": "6.4.22", - "resolved": "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-6.4.22.tgz", - "integrity": "sha512-O4RxqPgRyOgAhssS6q1Rtc8LiOvPBpC1EqhCYWRV3K+D2EjFarfQMpjgPj18hC+QzpUSfzoBZYqsMECewEuLNw==", - "dev": true, - "dependencies": { - "@storybook/addons": "6.4.22", - "@storybook/client-logger": "6.4.22", - "@storybook/csf": "0.0.2--canary.87bc651.0", - "core-js": "^3.8.2", - "estraverse": "^5.2.0", - "global": "^4.4.0", - "loader-utils": "^2.0.0", - "lodash": "^4.17.21", - "prettier": ">=2.2.1 <=2.3.0", - "regenerator-runtime": "^0.13.7" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0", - "react-dom": "^16.8.0 || ^17.0.0" - } - }, - "packages/snap-preact-components/node_modules/@storybook/addon-docs/node_modules/react-element-to-jsx-string": { - "version": "14.3.4", - "resolved": "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-14.3.4.tgz", - "integrity": "sha512-t4ZwvV6vwNxzujDQ+37bspnLwA4JlgUPWhLjBJWsNIDceAf6ZKUTCjdm08cN6WeZ5pTMKiCJkmAYnpmR4Bm+dg==", - "dev": true, - "dependencies": { - "@base2/pretty-print-object": "1.0.1", - "is-plain-object": "5.0.0", - "react-is": "17.0.2" - }, - "peerDependencies": { - "react": "^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1", - "react-dom": "^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1" - } - }, "packages/snap-preact-components/node_modules/@types/yargs": { "version": "15.0.14", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", @@ -39681,11 +39676,12 @@ } }, "packages/snap-preact-demo": { - "version": "0.26.0", + "name": "@searchspring/snap-preact-demo", + "version": "0.26.1", "license": "MIT", "dependencies": { - "@searchspring/snap-preact": "^0.26.0", - "@searchspring/snap-preact-components": "^0.26.0", + "@searchspring/snap-preact": "^0.26.1", + "@searchspring/snap-preact-components": "^0.26.1", "deepmerge": "^4.2.2", "mobx": "^6.3.12", "mobx-react": "^7.2.1", @@ -39718,37 +39714,42 @@ } }, "packages/snap-profiler": { - "version": "0.26.0", + "name": "@searchspring/snap-profiler", + "version": "0.26.1", "license": "MIT" }, "packages/snap-shared": { - "version": "0.26.0", + "name": "@searchspring/snap-shared", + "version": "0.26.1", "license": "MIT", "devDependencies": { - "@searchspring/snap-client": "^0.26.0" + "@searchspring/snap-client": "^0.26.1" } }, "packages/snap-store-mobx": { - "version": "0.26.0", + "name": "@searchspring/snap-store-mobx", + "version": "0.26.1", "license": "MIT", "dependencies": { - "@searchspring/snap-toolbox": "^0.26.0", + "@searchspring/snap-toolbox": "^0.26.1", "mobx": "^6.3.12" }, "devDependencies": { - "@searchspring/snap-url-manager": "^0.26.0" + "@searchspring/snap-url-manager": "^0.26.1" } }, "packages/snap-toolbox": { - "version": "0.26.0", + "name": "@searchspring/snap-toolbox", + "version": "0.26.1", "license": "MIT" }, "packages/snap-tracker": { - "version": "0.26.0", + "name": "@searchspring/snap-tracker", + "version": "0.26.1", "license": "MIT", "dependencies": { - "@searchspring/snap-store-mobx": "^0.26.0", - "@searchspring/snap-toolbox": "^0.26.0", + "@searchspring/snap-store-mobx": "^0.26.1", + "@searchspring/snap-toolbox": "^0.26.1", "@types/uuid": "^8.3.4", "deepmerge": "^4.2.2", "uuid": "^8.3.2" @@ -39763,7 +39764,8 @@ } }, "packages/snap-url-manager": { - "version": "0.26.0", + "name": "@searchspring/snap-url-manager", + "version": "0.26.1", "license": "MIT", "dependencies": { "deepmerge": "^4.2.2", @@ -41736,9 +41738,9 @@ "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==" }, "@eslint/eslintrc": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.1.tgz", - "integrity": "sha512-bxvbYnBPN1Gibwyp6NrpnFzA3YtRL3BBAyEAFVIpNTm2Rn4Vy87GA5M4aSn3InRrlsbX5N0GW7XIx+U4SAEKdQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.2.tgz", + "integrity": "sha512-lTVWHs7O2hjBFZunXTZYnYqtB9GakA1lnxIf+gKq2nY5gxkkNi/lQvveW6t8gFdOHTg6nG50Xs95PrLqVpcaLg==", "dev": true, "requires": { "ajv": "^6.12.4", @@ -42291,9 +42293,9 @@ } }, "@jridgewell/resolve-uri": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.5.tgz", - "integrity": "sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew==" + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.6.tgz", + "integrity": "sha512-R7xHtBSNm+9SyvpJkdQl+qrM3Hm2fea3Ef197M3mUug+v+yR+Rhfbs7PBtcBUVnIWJ4JcAdjvij+c8hXS9p5aw==" }, "@jridgewell/sourcemap-codec": { "version": "1.4.11", @@ -44516,21 +44518,21 @@ "@searchspring/snap-client": { "version": "file:packages/snap-client", "requires": { - "@searchspring/snap-toolbox": "^0.26.0", + "@searchspring/snap-toolbox": "^0.26.1", "deepmerge": "^4.2.2" } }, "@searchspring/snap-controller": { "version": "file:packages/snap-controller", "requires": { - "@searchspring/snap-client": "^0.26.0", - "@searchspring/snap-event-manager": "^0.26.0", - "@searchspring/snap-logger": "^0.26.0", - "@searchspring/snap-profiler": "^0.26.0", - "@searchspring/snap-store-mobx": "^0.26.0", - "@searchspring/snap-toolbox": "^0.26.0", - "@searchspring/snap-tracker": "^0.26.0", - "@searchspring/snap-url-manager": "^0.26.0", + "@searchspring/snap-client": "^0.26.1", + "@searchspring/snap-event-manager": "^0.26.1", + "@searchspring/snap-logger": "^0.26.1", + "@searchspring/snap-profiler": "^0.26.1", + "@searchspring/snap-store-mobx": "^0.26.1", + "@searchspring/snap-toolbox": "^0.26.1", + "@searchspring/snap-tracker": "^0.26.1", + "@searchspring/snap-url-manager": "^0.26.1", "deepmerge": "^4.2.2" } }, @@ -44543,15 +44545,16 @@ "@searchspring/snap-preact": { "version": "file:packages/snap-preact", "requires": { - "@searchspring/snap-client": "^0.26.0", - "@searchspring/snap-controller": "^0.26.0", - "@searchspring/snap-event-manager": "^0.26.0", - "@searchspring/snap-logger": "^0.26.0", - "@searchspring/snap-profiler": "^0.26.0", - "@searchspring/snap-store-mobx": "^0.26.0", - "@searchspring/snap-toolbox": "^0.26.0", - "@searchspring/snap-tracker": "^0.26.0", - "@searchspring/snap-url-manager": "^0.26.0", + "@searchspring/snap-client": "^0.26.1", + "@searchspring/snap-controller": "^0.26.1", + "@searchspring/snap-event-manager": "^0.26.1", + "@searchspring/snap-logger": "^0.26.1", + "@searchspring/snap-preact-components": "^0.26.1", + "@searchspring/snap-profiler": "^0.26.1", + "@searchspring/snap-store-mobx": "^0.26.1", + "@searchspring/snap-toolbox": "^0.26.1", + "@searchspring/snap-tracker": "^0.26.1", + "@searchspring/snap-url-manager": "^0.26.1", "@types/react": "^17.0.20", "deepmerge": "^4.2.2", "intersection-observer": "^0.12.0", @@ -44563,15 +44566,15 @@ "requires": { "@emotion/react": "^11.7.1", "@mdx-js/loader": "^1.6.22", - "@searchspring/snap-client": "^0.26.0", - "@searchspring/snap-controller": "^0.26.0", - "@searchspring/snap-event-manager": "^0.26.0", - "@searchspring/snap-logger": "^0.26.0", - "@searchspring/snap-preact": "^0.26.0", - "@searchspring/snap-profiler": "^0.26.0", - "@searchspring/snap-store-mobx": "^0.26.0", - "@searchspring/snap-toolbox": "^0.26.0", - "@searchspring/snap-url-manager": "^0.26.0", + "@searchspring/snap-client": "^0.26.1", + "@searchspring/snap-controller": "^0.26.1", + "@searchspring/snap-event-manager": "^0.26.1", + "@searchspring/snap-logger": "^0.26.1", + "@searchspring/snap-profiler": "^0.26.1", + "@searchspring/snap-store-mobx": "^0.26.1", + "@searchspring/snap-toolbox": "^0.26.1", + "@searchspring/snap-tracker": "^0.26.1", + "@searchspring/snap-url-manager": "^0.26.1", "@storybook/addon-actions": "^6.4.9", "@storybook/addon-controls": "^6.4.9", "@storybook/addon-docs": "^6.4.9", @@ -44684,37 +44687,6 @@ "remark-slug": "^6.0.0", "ts-dedent": "^2.0.0", "util-deprecate": "^1.0.2" - }, - "dependencies": { - "@storybook/source-loader": { - "version": "6.4.22", - "resolved": "https://registry.npmjs.org/@storybook/source-loader/-/source-loader-6.4.22.tgz", - "integrity": "sha512-O4RxqPgRyOgAhssS6q1Rtc8LiOvPBpC1EqhCYWRV3K+D2EjFarfQMpjgPj18hC+QzpUSfzoBZYqsMECewEuLNw==", - "dev": true, - "requires": { - "@storybook/addons": "6.4.22", - "@storybook/client-logger": "6.4.22", - "@storybook/csf": "0.0.2--canary.87bc651.0", - "core-js": "^3.8.2", - "estraverse": "^5.2.0", - "global": "^4.4.0", - "loader-utils": "^2.0.0", - "lodash": "^4.17.21", - "prettier": ">=2.2.1 <=2.3.0", - "regenerator-runtime": "^0.13.7" - } - }, - "react-element-to-jsx-string": { - "version": "14.3.4", - "resolved": "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-14.3.4.tgz", - "integrity": "sha512-t4ZwvV6vwNxzujDQ+37bspnLwA4JlgUPWhLjBJWsNIDceAf6ZKUTCjdm08cN6WeZ5pTMKiCJkmAYnpmR4Bm+dg==", - "dev": true, - "requires": { - "@base2/pretty-print-object": "1.0.1", - "is-plain-object": "5.0.0", - "react-is": "17.0.2" - } - } } }, "@types/yargs": { @@ -44874,8 +44846,8 @@ "@babel/runtime": "^7.16.7", "@lhci/cli": "^0.8.2", "@searchspring/browserslist-config-snap": "^1.0.5", - "@searchspring/snap-preact": "^0.26.0", - "@searchspring/snap-preact-components": "^0.26.0", + "@searchspring/snap-preact": "^0.26.1", + "@searchspring/snap-preact-components": "^0.26.1", "babel-loader": "^8.2.3", "core-js": "^3.20.2", "css-loader": "^6.5.1", @@ -44901,14 +44873,14 @@ "@searchspring/snap-shared": { "version": "file:packages/snap-shared", "requires": { - "@searchspring/snap-client": "^0.26.0" + "@searchspring/snap-client": "^0.26.1" } }, "@searchspring/snap-store-mobx": { "version": "file:packages/snap-store-mobx", "requires": { - "@searchspring/snap-toolbox": "^0.26.0", - "@searchspring/snap-url-manager": "^0.26.0", + "@searchspring/snap-toolbox": "^0.26.1", + "@searchspring/snap-url-manager": "^0.26.1", "mobx": "^6.3.12" } }, @@ -44918,8 +44890,8 @@ "@searchspring/snap-tracker": { "version": "file:packages/snap-tracker", "requires": { - "@searchspring/snap-store-mobx": "^0.26.0", - "@searchspring/snap-toolbox": "^0.26.0", + "@searchspring/snap-store-mobx": "^0.26.1", + "@searchspring/snap-toolbox": "^0.26.1", "@types/uuid": "^8.3.4", "deepmerge": "^4.2.2", "uuid": "^8.3.2" @@ -51828,6 +51800,35 @@ "dev": true, "requires": {} }, + "ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "requires": { + "ajv": "^8.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + } + } + }, "ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", @@ -52761,9 +52762,9 @@ } }, "bonjour-service": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.0.11.tgz", - "integrity": "sha512-drMprzr2rDTCtgEE3VgdA9uUFaUHF+jXduwYSThHJnKMYM+FhI9Z3ph+TX3xy0LtgYHae6CHYPJ/2UnK8nQHcA==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.0.12.tgz", + "integrity": "sha512-pMmguXYCu63Ug37DluMKEHdxc+aaIf/ay4YbF8Gxtba+9d3u+rmEWy61VK3Z3hp8Rskok3BunHYnG0dUHAsblw==", "dev": true, "requires": { "array-flatten": "^2.1.2", @@ -53196,9 +53197,9 @@ }, "dependencies": { "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true } } @@ -54351,6 +54352,7 @@ "dev": true, "optional": true, "requires": { + "cosmiconfig": "^7", "ts-node": "^10.7.0" } }, @@ -55159,9 +55161,9 @@ } }, "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true }, "universalify": { @@ -55630,9 +55632,9 @@ }, "dependencies": { "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true } } @@ -55718,9 +55720,9 @@ }, "dependencies": { "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true } } @@ -55798,9 +55800,9 @@ "dev": true }, "electron-to-chromium": { - "version": "1.4.117", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.117.tgz", - "integrity": "sha512-ypZHxY+Sf/PXu7LVN+xoeanyisnJeSOy8Ki439L/oLueZb4c72FI45zXcK3gPpmTwyufh9m6NnbMLXnJh/0Fxg==" + "version": "1.4.118", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.118.tgz", + "integrity": "sha512-maZIKjnYDvF7Fs35nvVcyr44UcKNwybr93Oba2n3HkKDFAtk0svERkLN/HyczJDS3Fo4wU9th9fUQd09ZLtj1w==" }, "element-resize-detector": { "version": "1.2.4", @@ -56053,9 +56055,9 @@ } }, "es5-shim": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.5.tgz", - "integrity": "sha512-vfQ4UAai8szn0sAubCy97xnZ4sJVDD1gt/Grn736hg8D7540wemIb1YPrYZSTqlM2H69EQX1or4HU/tSwRTI3w==", + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/es5-shim/-/es5-shim-4.6.6.tgz", + "integrity": "sha512-Ay5QQE78I2WKUoZVZjL0AIuiIjsmXwZGkyCTH9+n6J1anPbb0ymDA27ASa2Lt0rhOpAlEKy2W0d17gJ1XOQ5eQ==", "dev": true }, "es6-shim": { @@ -56141,12 +56143,12 @@ } }, "eslint": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.13.0.tgz", - "integrity": "sha512-D+Xei61eInqauAyTJ6C0q6x9mx7kTUC1KZ0m0LSEexR0V+e94K12LmWX076ZIsldwfQ2RONdaJe0re0TRGQbRQ==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.14.0.tgz", + "integrity": "sha512-3/CE4aJX7LNEiE3i6FeodHmI/38GZtWCsAtsymScmzYapx8q1nVVb+eLcLSzATmCPXw5pT4TqVs1E0OmxAd9tw==", "dev": true, "requires": { - "@eslint/eslintrc": "^1.2.1", + "@eslint/eslintrc": "^1.2.2", "@humanwhocodes/config-array": "^0.9.2", "ajv": "^6.10.0", "chalk": "^4.0.0", @@ -61808,9 +61810,9 @@ } }, "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true } } @@ -62160,9 +62162,9 @@ }, "dependencies": { "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true } } @@ -63104,9 +63106,9 @@ }, "dependencies": { "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true } } @@ -64180,9 +64182,9 @@ }, "dependencies": { "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true } } @@ -64292,9 +64294,9 @@ }, "dependencies": { "tslib": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", - "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true } } @@ -69176,9 +69178,9 @@ "dev": true }, "use-composed-ref": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.2.1.tgz", - "integrity": "sha512-6+X1FLlIcjvFMAeAD/hcxDT8tmyrWnbSPMU0EnxQuDLIxokuFzWliXBiYZuGIx+mrAMLBw0WFfCkaPw8ebzAhw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.3.0.tgz", + "integrity": "sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==", "dev": true, "requires": {} }, @@ -69980,13 +69982,6 @@ "uri-js": "^4.2.2" } }, - "ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "requires": {} - }, "ajv-keywords": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", diff --git a/packages/snap-controller/src/Autocomplete/AutocompleteController.ts b/packages/snap-controller/src/Autocomplete/AutocompleteController.ts index 25b933cde..0572ec270 100644 --- a/packages/snap-controller/src/Autocomplete/AutocompleteController.ts +++ b/packages/snap-controller/src/Autocomplete/AutocompleteController.ts @@ -1,9 +1,9 @@ import deepmerge from 'deepmerge'; import { StorageStore, StorageType, ErrorType } from '@searchspring/snap-store-mobx'; -import { url } from '@searchspring/snap-toolbox'; import { AbstractController } from '../Abstract/AbstractController'; import { getSearchParams } from '../utils/getParams'; +import { ControllerTypes } from '../types'; import type { AutocompleteStore } from '@searchspring/snap-store-mobx'; import type { @@ -45,7 +45,7 @@ type AutocompleteTrackMethods = { }; export class AutocompleteController extends AbstractController { - public type = 'autocomplete'; + public type = ControllerTypes.autocomplete; public store: AutocompleteStore; public config: AutocompleteControllerConfig; public storage: StorageStore; @@ -89,6 +89,7 @@ export class AutocompleteController extends AbstractController { // cancel search if no input or query doesn't match current urlState if (ac.response.autocomplete.query != ac.controller.urlManager.state.query) { + ac.controller.store.loading = false; return false; } }); diff --git a/packages/snap-controller/src/Finder/FinderController.ts b/packages/snap-controller/src/Finder/FinderController.ts index d75554b5b..5d291e2a4 100644 --- a/packages/snap-controller/src/Finder/FinderController.ts +++ b/packages/snap-controller/src/Finder/FinderController.ts @@ -4,6 +4,7 @@ import { ErrorType } from '@searchspring/snap-store-mobx'; import { AbstractController } from '../Abstract/AbstractController'; import { getSearchParams } from '../utils/getParams'; +import { ControllerTypes } from '../types'; import type { FinderStore } from '@searchspring/snap-store-mobx'; import type { FinderControllerConfig, BeforeSearchObj, AfterSearchObj, ControllerServices, NextEvent, ContextVariables } from '../types'; @@ -23,7 +24,7 @@ const defaultConfig: FinderControllerConfig = { }; export class FinderController extends AbstractController { - public type = 'finder'; + public type = ControllerTypes.finder; public store: FinderStore; config: FinderControllerConfig; diff --git a/packages/snap-controller/src/Recommendation/RecommendationController.ts b/packages/snap-controller/src/Recommendation/RecommendationController.ts index 739f3bcf9..480312613 100644 --- a/packages/snap-controller/src/Recommendation/RecommendationController.ts +++ b/packages/snap-controller/src/Recommendation/RecommendationController.ts @@ -3,6 +3,7 @@ import deepmerge from 'deepmerge'; import { BeaconType, BeaconCategory } from '@searchspring/snap-tracker'; import { LogMode } from '@searchspring/snap-logger'; import { AbstractController } from '../Abstract/AbstractController'; +import { ControllerTypes } from '../types'; import { ErrorType } from '@searchspring/snap-store-mobx'; import type { BeaconEvent } from '@searchspring/snap-tracker'; @@ -29,7 +30,7 @@ const defaultConfig: RecommendationControllerConfig = { }; export class RecommendationController extends AbstractController { - public type = 'recommendation'; + public type = ControllerTypes.recommendation; public store: RecommendationStore; config: RecommendationControllerConfig; events = { @@ -242,8 +243,8 @@ export class RecommendationController extends AbstractController { const params = { tag: this.config.tag, batched: this.config.batched, - ...this.config.globals, branch: this.config.branch || 'production', + ...this.config.globals, }; const shopperId = this.tracker.context.shopperId; const cart = this.tracker.cookies.cart.get(); diff --git a/packages/snap-controller/src/Search/SearchController.ts b/packages/snap-controller/src/Search/SearchController.ts index 014e4d154..103b3931f 100644 --- a/packages/snap-controller/src/Search/SearchController.ts +++ b/packages/snap-controller/src/Search/SearchController.ts @@ -3,6 +3,7 @@ import deepmerge from 'deepmerge'; import { AbstractController } from '../Abstract/AbstractController'; import { StorageStore, StorageType, ErrorType } from '@searchspring/snap-store-mobx'; import { getSearchParams } from '../utils/getParams'; +import { ControllerTypes } from '../types'; import type { BeaconEvent } from '@searchspring/snap-tracker'; import type { SearchStore } from '@searchspring/snap-store-mobx'; @@ -42,7 +43,7 @@ type SearchTrackMethods = { }; export class SearchController extends AbstractController { - public type = 'search'; + public type = ControllerTypes.search; public store: SearchStore; config: SearchControllerConfig; storage: StorageStore; diff --git a/packages/snap-controller/src/types.ts b/packages/snap-controller/src/types.ts index 11584ae75..677027396 100644 --- a/packages/snap-controller/src/types.ts +++ b/packages/snap-controller/src/types.ts @@ -45,6 +45,13 @@ export type AfterStoreObj = { response: any; }; +export enum ControllerTypes { + search = 'search', + autocomplete = 'autocomplete', + finder = 'finder', + recommendation = 'recommendation', +} + export type ControllerServices = { client: Client; store: AbstractStore; diff --git a/packages/snap-preact-components/package.json b/packages/snap-preact-components/package.json index 0de477f11..5c6a49872 100644 --- a/packages/snap-preact-components/package.json +++ b/packages/snap-preact-components/package.json @@ -26,8 +26,15 @@ }, "dependencies": { "@emotion/react": "^11.7.1", - "@searchspring/snap-preact": "^0.26.1", + "@searchspring/snap-client": "^0.26.1", + "@searchspring/snap-controller": "^0.26.1", + "@searchspring/snap-event-manager": "^0.26.1", + "@searchspring/snap-logger": "^0.26.1", + "@searchspring/snap-profiler": "^0.26.1", + "@searchspring/snap-store-mobx": "^0.26.1", "@searchspring/snap-toolbox": "^0.26.1", + "@searchspring/snap-tracker": "^0.26.1", + "@searchspring/snap-url-manager": "^0.26.1", "classnames": "^2.3.1", "deepmerge": "^4.2.2", "mobx-react-lite": "^3.2.3", diff --git a/packages/snap-preact-components/src/components/Atoms/Icon/Icon.tsx b/packages/snap-preact-components/src/components/Atoms/Icon/Icon.tsx index 8b40e0f8a..996e2423d 100644 --- a/packages/snap-preact-components/src/components/Atoms/Icon/Icon.tsx +++ b/packages/snap-preact-components/src/components/Atoms/Icon/Icon.tsx @@ -14,6 +14,7 @@ const CSS = { fill: color || theme.colors?.primary, width: width || size, height: height || size, + position: 'relative', }), }; diff --git a/packages/snap-preact-components/src/components/Molecules/Carousel/Carousel.tsx b/packages/snap-preact-components/src/components/Molecules/Carousel/Carousel.tsx index 7462a3964..52a3a8bbd 100644 --- a/packages/snap-preact-components/src/components/Molecules/Carousel/Carousel.tsx +++ b/packages/snap-preact-components/src/components/Molecules/Carousel/Carousel.tsx @@ -7,7 +7,6 @@ import classnames from 'classnames'; import { observer } from 'mobx-react-lite'; import deepmerge from 'deepmerge'; import SwiperCore, { Pagination, Navigation } from 'swiper/core'; -import 'swiper/swiper.min.css'; import { Icon, IconProps } from '../../Atoms/Icon/Icon'; import { Swiper, SwiperSlide } from 'swiper/react'; @@ -75,9 +74,35 @@ const CSS = { '.swiper-container': { display: 'flex', flexDirection: 'column', + marginLeft: 'auto', + marginRight: 'auto', + position: 'relative', + overflow: 'hidden', + listStyle: 'none', + padding: 0, + zIndex: 1, + }, + '.swiper-container-vertical': { + '.swiper-wrapper': { + flexDirection: 'column', + }, }, '.swiper-wrapper': { order: 0, + position: 'relative', + width: '100%', + height: '100%', + zIndex: 1, + display: 'flex', + transitionProperty: 'transform', + boxSizing: 'content-box', + }, + '.swiper-slide': { + flexShrink: 0, + width: '100%', + height: '100%', + position: 'relative', + transitionProperty: 'transform', }, '.swiper-pagination': { display: 'flex', @@ -101,6 +126,15 @@ const CSS = { background: theme?.colors?.primary || '#000', }, }, + '.swiper-container-pointer-events': { + touchAction: 'pan-y', + '&.swiper-container-vertical': { + touchAction: 'pan-x', + }, + }, + '.swiper-slide-invisible-blank': { + visibility: 'hidden', + }, }), }; diff --git a/packages/snap-preact-components/src/components/Organisms/BranchOverride/BranchOverride.stories.tsx b/packages/snap-preact-components/src/components/Organisms/BranchOverride/BranchOverride.stories.tsx new file mode 100644 index 000000000..055e80217 --- /dev/null +++ b/packages/snap-preact-components/src/components/Organisms/BranchOverride/BranchOverride.stories.tsx @@ -0,0 +1,126 @@ +import { h, Fragment } from 'preact'; + +import { ArgsTable, PRIMARY_STORY } from '@storybook/addon-docs/blocks'; + +import { BranchOverride } from './BranchOverride'; +import { componentArgs } from '../../../utilities'; +import Readme from '../BranchOverride/readme.md'; + +export default { + title: `Organisms/BranchOverride`, + component: BranchOverride, + parameters: { + docs: { + page: () => ( +
+ + +
+ ), + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + name: { + description: 'bundle branch name', + type: { required: true }, + table: { + type: { + summary: 'string', + }, + }, + control: { type: 'text' }, + }, + details: { + description: 'Object containing details for branch override', + type: { required: false }, + table: { + type: { + summary: '{ url: string; lastModified: string }', + }, + }, + control: { type: 'object' }, + }, + error: { + description: 'Object containing error message and description', + type: { required: false }, + table: { + type: { + summary: '{ message: string; description: string }', + }, + }, + control: { type: 'object' }, + }, + onRemoveClick: { + description: 'optional function to run on remove button click', + table: { + type: { + summary: '(e: Event, name: string) => void', + }, + }, + action: 'onRemoveClick', + }, + darkMode: { + description: 'force dark darkMode', + type: { required: false }, + table: { + type: { + summary: 'boolean', + }, + }, + control: { type: 'boolean' }, + }, + ...componentArgs, + }, +}; + +const Template = (args) => ; + +export const Auto = Template.bind({}); +Auto.args = { + name: 'next', + details: { + url: 'https://snapui.searchspring.io/y56s6x/next/bundle.js', + lastModified: '1 Feb 2022 1:02:03 GMT', + }, +}; + +export const Dark = Template.bind({}); +Dark.args = { + name: 'next', + details: { + url: 'https://snapui.searchspring.io/y56s6x/next/bundle.js', + lastModified: '1 Feb 2022 1:02:03 GMT', + }, + darkMode: true, +}; + +export const Error = Template.bind({}); +Error.args = { + name: 'testing', + error: { + message: 'Branch not found!', + description: 'Incorrect branch name or branch no longer exists.', + }, +}; + +export const Light = Template.bind({}); +Light.args = { + name: 'next', + details: { + url: 'https://snapui.searchspring.io/y56s6x/next/bundle.js', + lastModified: '1 Feb 2022 1:02:03 GMT', + }, + darkMode: false, +}; diff --git a/packages/snap-preact-components/src/components/Organisms/BranchOverride/BranchOverride.test.tsx b/packages/snap-preact-components/src/components/Organisms/BranchOverride/BranchOverride.test.tsx new file mode 100644 index 000000000..cf1b66fe7 --- /dev/null +++ b/packages/snap-preact-components/src/components/Organisms/BranchOverride/BranchOverride.test.tsx @@ -0,0 +1,281 @@ +import { h } from 'preact'; + +import { render, waitFor } from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; + +import { BranchOverride } from './BranchOverride'; +import { ThemeProvider } from '../../../providers'; + +describe('BranchOverride Component', () => { + const branch = 'branch'; + const url = 'https://snapui.searchspring.io/y56s6x/branch/bundle.js'; + const lastModified = '07 Jan 2022 22:42:39 GMT'; + + const props = { + name: branch, + details: { + url, + lastModified, + }, + }; + + it('displays branch bundle details', async () => { + const rendered = render(); + + // wait for rendering of component + await waitFor(() => { + const overrideElement = rendered.container.querySelector('.ss__branch-override'); + expect(overrideElement).toBeInTheDocument(); + expect(overrideElement.classList).toHaveLength(2); + + const styles = getComputedStyle(overrideElement); + expect(styles.background).toBe('rgba(255, 255, 255, 0.95)'); + }); + + // branch name + const bottomLeftElement = rendered.container.querySelector('.ss__branch-override .ss__branch-override__bottom__left'); + expect(bottomLeftElement).toBeInTheDocument(); + expect(bottomLeftElement.innerHTML).toContain(branch); + + // branch modified date + const bottomRightElement = rendered.container.querySelector('.ss__branch-override .ss__branch-override__bottom__right'); + expect(bottomRightElement).toBeInTheDocument(); + expect(bottomRightElement.innerHTML).toContain(lastModified); + }); + + it('can set dark mode', async () => { + const rendered = render(); + + // wait for rendering of component + await waitFor(() => { + const overrideElement = rendered.container.querySelector('.ss__branch-override'); + expect(overrideElement).toBeInTheDocument(); + const styles = getComputedStyle(overrideElement); + expect(styles.background).toBe('rgba(59, 35, 173, 0.9)'); + }); + }); + + it('can be collapsed and uncollapsed', async () => { + const rendered = render(); + + // wait for rendering of component + let overrideElement; + await waitFor(() => { + overrideElement = rendered.container.querySelector('.ss__branch-override'); + expect(overrideElement).toBeInTheDocument(); + expect(overrideElement).not.toHaveClass('ss__branch-override--collapsed'); + }); + + const collapseButton = overrideElement.querySelector('.ss__branch-override__top__collapse'); + expect(collapseButton).toBeInTheDocument(); + userEvent.click(collapseButton); + await waitFor(() => expect(overrideElement).toHaveClass('ss__branch-override--collapsed')); + + userEvent.click(overrideElement); + await waitFor(() => expect(overrideElement).not.toHaveClass('ss__branch-override--collapsed')); + }); + + it(`has a remove button that calls 'onRemove'`, async () => { + const removeFn = jest.fn(); + const rendered = render(); + + // wait for rendering of component + let overrideElement; + await waitFor(() => { + overrideElement = rendered.container.querySelector('.ss__branch-override'); + expect(overrideElement).toBeInTheDocument(); + }); + + const closeButton = overrideElement.querySelector('.ss__branch-override__top__button'); + expect(closeButton).toBeInTheDocument(); + userEvent.click(closeButton); + expect(removeFn).toHaveBeenCalledTimes(1); + }); + + it('displays branch failure on bad branch', async () => { + const name = 'badBranch'; + const error = { + message: 'Branch not found...', + description: 'Unable to find the branch.', + }; + + const rendered = render(); + + // wait for rendering of component + await waitFor(() => { + const overrideElement = rendered.container.querySelector('.ss__branch-override'); + expect(overrideElement).toBeInTheDocument(); + const styles = getComputedStyle(overrideElement); + expect(styles.background).toBe('rgba(130, 6, 6, 0.9)'); + }); + + // branch name + const bottomRightElement = rendered.container.querySelector('.ss__branch-override .ss__branch-override__bottom__right'); + expect(bottomRightElement).toBeInTheDocument(); + expect(bottomRightElement.innerHTML).toContain(name); + + // error message + const bottomLeftElement = rendered.container.querySelector('.ss__branch-override .ss__branch-override__bottom__left'); + expect(bottomLeftElement).toBeInTheDocument(); + expect(bottomLeftElement.textContent).toContain(error.message); + + // error description + const bottomContentElement = rendered.container.querySelector('.ss__branch-override .ss__branch-override__bottom__content'); + expect(bottomContentElement).toBeInTheDocument(); + expect(bottomContentElement.textContent).toContain(error.description); + }); + + it(`displays branch failure when both 'error' and 'details' props are provided`, async () => { + const error = { + message: 'Branch not found...', + description: 'Unable to find the branch.', + }; + + const rendered = render(); + + // wait for rendering of component + await waitFor(() => { + const overrideElement = rendered.container.querySelector('.ss__branch-override'); + expect(overrideElement).toBeInTheDocument(); + const styles = getComputedStyle(overrideElement); + expect(styles.background).toBe('rgba(130, 6, 6, 0.9)'); + }); + + // branch name + const bottomRightElement = rendered.container.querySelector('.ss__branch-override .ss__branch-override__bottom__right'); + expect(bottomRightElement).toBeInTheDocument(); + expect(bottomRightElement.innerHTML).toContain(props.name); + + // error message + const bottomLeftElement = rendered.container.querySelector('.ss__branch-override .ss__branch-override__bottom__left'); + expect(bottomLeftElement).toBeInTheDocument(); + expect(bottomLeftElement.textContent).toContain(error.message); + + // error description + const bottomContentElement = rendered.container.querySelector('.ss__branch-override .ss__branch-override__bottom__content'); + expect(bottomContentElement).toBeInTheDocument(); + expect(bottomContentElement.textContent).toContain(error.description); + }); + + it('can disable styles', async () => { + const rendered = render(); + + // wait for rendering of component + await waitFor(() => { + const overrideElement = rendered.container.querySelector('.ss__branch-override'); + expect(overrideElement).toBeInTheDocument(); + expect(overrideElement.classList).toHaveLength(1); + }); + }); + + it('can add additional styles', async () => { + const rendered = render(); + + // wait for rendering of component + await waitFor(() => { + const overrideElement = rendered.container.querySelector('.ss__branch-override'); + expect(overrideElement).toBeInTheDocument(); + const styles = getComputedStyle(overrideElement); + expect(styles.background).toBe('blue'); + }); + }); + + it('can add additional styles when default styles are disabled', async () => { + const rendered = render(); + + // wait for rendering of component + await waitFor(() => { + const overrideElement = rendered.container.querySelector('.ss__branch-override'); + expect(overrideElement).toBeInTheDocument(); + const styles = getComputedStyle(overrideElement); + expect(styles.background).toBe('blue'); + }); + }); + + it('renders with classname', async () => { + const className = 'classy'; + const rendered = render(); + + // wait for rendering of component + await waitFor(() => { + const overrideElement = rendered.container.querySelector('.ss__branch-override'); + expect(overrideElement).toBeInTheDocument(); + expect(overrideElement).toHaveClass(className); + }); + }); + + describe('component theming works', () => { + it('is themeable with ThemeProvider', async () => { + const globalTheme = { + components: { + branchOverride: { + className: 'classy', + }, + }, + }; + + const rendered = render( + + + + ); + + // wait for rendering of component + await waitFor(() => { + const overrideElement = rendered.container.querySelector('.ss__branch-override'); + expect(overrideElement).toBeInTheDocument(); + expect(overrideElement).toHaveClass(globalTheme.components.branchOverride.className); + }); + }); + + it('is themeable with theme prop', async () => { + const propTheme = { + components: { + branchOverride: { + className: 'classy', + }, + }, + }; + + const rendered = render(); + + // wait for rendering of component + await waitFor(() => { + const overrideElement = rendered.container.querySelector('.ss__branch-override'); + expect(overrideElement).toBeInTheDocument(); + expect(overrideElement).toHaveClass(propTheme.components.branchOverride.className); + }); + }); + + it('the theme prop overrides ThemeProvider', async () => { + const globalTheme = { + components: { + branchOverride: { + className: 'not classy', + }, + }, + }; + const propTheme = { + components: { + branchOverride: { + className: 'classy', + }, + }, + }; + + const rendered = render( + + + + ); + + // wait for rendering of component + await waitFor(() => { + const overrideElement = rendered.container.querySelector('.ss__branch-override'); + expect(overrideElement).toBeInTheDocument(); + expect(overrideElement).toHaveClass(propTheme.components.branchOverride.className); + expect(overrideElement).not.toHaveClass(globalTheme.components.branchOverride.className); + }); + }); + }); +}); diff --git a/packages/snap-preact-components/src/components/Organisms/BranchOverride/BranchOverride.tsx b/packages/snap-preact-components/src/components/Organisms/BranchOverride/BranchOverride.tsx new file mode 100644 index 000000000..a9f07f531 --- /dev/null +++ b/packages/snap-preact-components/src/components/Organisms/BranchOverride/BranchOverride.tsx @@ -0,0 +1,328 @@ +/** @jsx jsx */ +import { h, Fragment } from 'preact'; + +import { jsx, css } from '@emotion/react'; +import classnames from 'classnames'; +import { useState, useEffect } from 'preact/hooks'; +import { Icon, IconProps } from '../../Atoms/Icon/Icon'; + +import { ComponentProps } from '../../../types'; +import { defined } from '../../../utilities'; +import { Theme, useTheme } from '../../../providers'; + +const CSS = { + override: ({ theme }) => + css({ + width: '360px', + height: '120px', + overflow: 'hidden', + fontSize: '14px', + position: 'fixed', + zIndex: 9999, + cursor: 'auto', + bottom: '50px', + right: 0, + background: theme.main.background, + color: theme.main.color, + border: theme.main.border, + borderRight: 0, + borderTopLeftRadius: '5px', + borderBottomLeftRadius: '5px', + boxShadow: theme.main.boxShadow, + transition: 'height ease 0.2s, right ease 0.5s 0.2s', + '&.ss__branch-override--collapsed': { + transition: 'height ease 0.5s 0.5s, right ease 0.5s', + right: '-316px', + height: '50px', + cursor: 'pointer', + }, + '.ss__branch-override__top': { + padding: '10px', + background: theme.top.background, + borderBottom: theme.top.border, + '.ss__branch-override__top__logo': { + display: 'inline-block', + height: '30px', + maxHeight: '30px', + verticalAlign: 'middle', + }, + '.ss__branch-override__top__collapse': { + display: 'inline-block', + float: 'right', + padding: '5px', + cursor: 'pointer', + }, + '.ss__branch-override__top__button': { + borderRadius: '5px', + padding: '6px', + height: '100%', + lineHeight: '14px', + textAlign: 'center', + cursor: 'pointer', + fontSize: '10px', + border: theme.top.button.border, + color: theme.top.button.color, + float: 'right', + marginRight: '14px', + }, + }, + '.ss__branch-override__bottom': { + padding: '10px 15px', + fontSize: '12px', + '.ss__branch-override__bottom__left': { + fontWeight: 'bold', + fontStyle: theme.bottom.branch.style, + color: theme.bottom.branch.color, + fontSize: '14px', + lineHeight: '20px', + display: 'inline-flex', + alignItems: 'center', + maxWidth: '180px', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + svg: { + marginRight: '10px', + }, + }, + '.ss__branch-override__bottom__right': { + float: 'right', + fontStyle: 'italic', + color: theme.bottom.additional.color, + fontSize: '12px', + lineHeight: '20px', + }, + '.ss__branch-override__bottom__content': { + marginTop: '10px', + }, + }, + }), +}; + +const darkTheme = { + main: { + border: '0', + background: 'rgba(59, 35, 173, 0.9)', + color: '#fff', + boxShadow: '#4c3ce2 1px 1px 3px 0px', + }, + top: { + background: 'rgba(59, 35, 173, 0.3)', + border: '1px solid #4c3de1', + logo: { + src: 'https://snapui.searchspring.io/searchspring_light.svg', + }, + button: { + border: '1px solid #fff', + color: '#fff', + content: 'STOP PREVIEW', + }, + close: { + fill: '#fff', + }, + }, + bottom: { + content: 'Preview functionality may differ from production.', + branch: { + color: '#03cee1', + style: 'italic', + }, + additional: { + color: '#03cee1', + }, + }, +}; + +const lightTheme = { + main: { + border: '1px solid #ccc', + background: 'rgba(255, 255, 255, 0.95)', + color: '#515151', + boxShadow: 'rgba(81, 81, 81, 0.5) 1px 1px 3px 0px', + }, + top: { + border: '1px solid #ccc', + logo: { + src: 'https://snapui.searchspring.io/searchspring.svg', + }, + button: { + border: '1px solid #515151', + color: '#515151', + content: 'STOP PREVIEW', + }, + close: { + fill: '#515151', + }, + }, + bottom: { + content: 'Preview functionality may differ from production.', + branch: { + color: '#3a23ad', + style: 'italic', + }, + additional: { + color: '#3a23ad', + }, + }, +}; + +const failureTheme = { + main: { + border: '0', + background: 'rgba(130, 6, 6, 0.9)', + color: '#fff', + boxShadow: 'rgba(130, 6, 6, 0.4) 1px 1px 3px 0px', + }, + top: { + background: 'rgba(130, 6, 6, 0.3)', + border: '1px solid #760000', + logo: { + src: 'https://snapui.searchspring.io/searchspring_light.svg', + }, + button: { + border: '1px solid #fff', + color: '#fff', + content: 'REMOVE', + }, + close: { + fill: '#fff', + }, + }, + bottom: { + content: 'Incorrect branch name or branch no longer exists.', + branch: { + color: '#be9628', + style: 'italic', + }, + additional: { + color: '#be9628', + }, + }, +}; + +const themes = { + darkTheme, + lightTheme, + failureTheme, +}; + +export const BranchOverride = (properties: BranchOverrideProps): JSX.Element => { + const globalTheme: Theme = useTheme(); + const theme = { ...globalTheme, ...properties.theme }; + + let props: BranchOverrideProps = { + // global theme + ...globalTheme?.components?.branchOverride, + // props + ...properties, + ...properties.theme?.components?.branchOverride, + }; + + const { name, details, error, className, darkMode, disableStyles, style, onRemoveClick } = props; + + const subProps: BranchOverrideSubProps = { + icon: { + // default props + className: 'ss__branch-override__bottom__left__icon', + size: '12px', + // global theme + ...globalTheme?.components?.icon, + // inherited props + ...defined({ + disableStyles, + }), + // component theme overrides + theme: props.theme, + }, + }; + + const prefersDark = typeof darkMode == 'boolean' ? darkMode : window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)').matches : false; + const [themeName, setThemeName] = useState(prefersDark ? 'darkTheme' : 'lightTheme'); + const [collapsed, setCollapsed] = useState(0); + + if (error) { + setThemeName('failureTheme'); + } + + const styling: { css?: any } = {}; + if (!disableStyles) { + styling.css = [CSS.override({ theme: themes[themeName] }), style]; + } else if (style) { + styling.css = [style]; + } + + return ( + (details || error) && + name && ( +
{ + e.preventDefault(); + e.stopPropagation(); + setCollapsed(0); + }} + > +
+ + +
{ + e.preventDefault(); + e.stopPropagation(); + setCollapsed(1); + }} + > + +
+ +
{ + e.preventDefault(); + e.stopPropagation(); + onRemoveClick && onRemoveClick(e, name); + }} + > + {themes[themeName].top.button.content} +
+
+ +
+ + {error ? ( + <> + + {error.message} + + ) : ( + name + )} + + + {error ? name : details?.lastModified} +
{error?.description || themes[themeName].bottom.content}
+
+
+ ) + ); +}; + +interface BranchOverrideSubProps { + icon: IconProps; +} + +export interface BranchOverrideProps extends ComponentProps { + name: string; + error?: { + message: string; + description: string; + }; + details?: { + url: string; + lastModified: string; + }; + onRemoveClick?: (e, name: string) => void; + darkMode?: boolean; +} diff --git a/packages/snap-preact-components/src/components/Organisms/BranchOverride/index.ts b/packages/snap-preact-components/src/components/Organisms/BranchOverride/index.ts new file mode 100644 index 000000000..a09d183f8 --- /dev/null +++ b/packages/snap-preact-components/src/components/Organisms/BranchOverride/index.ts @@ -0,0 +1 @@ +export * from './BranchOverride'; diff --git a/packages/snap-preact-components/src/components/Organisms/BranchOverride/readme.md b/packages/snap-preact-components/src/components/Organisms/BranchOverride/readme.md new file mode 100644 index 000000000..b413dd4b7 --- /dev/null +++ b/packages/snap-preact-components/src/components/Organisms/BranchOverride/readme.md @@ -0,0 +1,64 @@ +# BranchOverride + +Renders a popup to show when a branch override is in place. +Executes `onRemoveClick` prop when the remove button is clicked. +Must have `name` and either `details` or `error` props to render. + +## Components Used +- Icon + +## Usage + +### name +The required `name` prop expects a string containing the name of the override branch. + +```jsx + +``` + +### details +The `details` prop expects an object containing strings for the `url` and `lastModified` date of the override branch bundle. + +```jsx +const details = { + url: 'https://snapui.searchspring.io/y56s6x/next/bundle.js', + lastModified: '1 Feb 2022 1:02:03 GMT' +}; + + +``` + +### error +The `error` prop expects an object containing strings for the `message` and `description` of the error. + +```jsx +const error = { + message: 'Branch not found!', + description: 'Incorrect branch name or branch no longer exists.' +}; + + +``` + +### onRemoveClick +The `onRemoveClick` prop is a function to be called when the 'remove' button is clicked + +```jsx +const whenRemoved = (e, name) => { + console.log(`remove clicked in the override for the '${name}' branch`); +}; + + +``` + +### darkMode +The `darkMode` prop is used to set the component styling to prefer (or not to prefer) dark mode. By default the component will auto detect the browser preference. + +```jsx +const details = { + url: 'https://snapui.searchspring.io/y56s6x/next/bundle.js', + lastModified: '1 Feb 2022 1:02:03 GMT' +}; + + +``` \ No newline at end of file diff --git a/packages/snap-preact-components/src/index.ts b/packages/snap-preact-components/src/index.ts index 3a89725d5..870409c90 100644 --- a/packages/snap-preact-components/src/index.ts +++ b/packages/snap-preact-components/src/index.ts @@ -29,6 +29,7 @@ export * from './components/Molecules/FacetSlider'; // ORGANISMS export * from './components/Organisms/Autocomplete'; +export * from './components/Organisms/BranchOverride'; export * from './components/Organisms/Facet'; export * from './components/Organisms/Facets'; export * from './components/Organisms/FilterSummary'; diff --git a/packages/snap-preact-components/src/utilities/snapify.ts b/packages/snap-preact-components/src/utilities/snapify.ts index 6ef1a26c1..5d8f56c40 100644 --- a/packages/snap-preact-components/src/utilities/snapify.ts +++ b/packages/snap-preact-components/src/utilities/snapify.ts @@ -1,14 +1,24 @@ import { h, render } from 'preact'; -/* searchspring imports */ -import { createSearchController, createAutocompleteController, createRecommendationsController } from '@searchspring/snap-preact'; -import type { - SearchController, - AutocompleteController, - RecommendationController, - SearchControllerConfig, - AutocompleteControllerConfig, - RecommendationControllerConfig, -} from '@searchspring/snap-controller'; + +import { SearchController, AutocompleteController, RecommendationController } from '@searchspring/snap-controller'; +import { Client } from '@searchspring/snap-client'; +import { SearchStore, AutocompleteStore, RecommendationStore } from '@searchspring/snap-store-mobx'; +import { UrlManager, UrlTranslator, reactLinker } from '@searchspring/snap-url-manager'; +import { EventManager } from '@searchspring/snap-event-manager'; +import { Profiler } from '@searchspring/snap-profiler'; +import { Logger } from '@searchspring/snap-logger'; +import { Tracker } from '@searchspring/snap-tracker'; + +import type { ClientConfig, ClientGlobals } from '@searchspring/snap-client'; +import type { SearchControllerConfig, AutocompleteControllerConfig, RecommendationControllerConfig } from '@searchspring/snap-controller'; + +type CreateConfig = { + client: { + globals?: ClientGlobals; + config?: ClientConfig; + }; + controller: SearchControllerConfig | AutocompleteControllerConfig | RecommendationControllerConfig; +}; const controllers = {}; const client = { @@ -21,7 +31,7 @@ export class Snapify { return controllers[id]; } - const cntrlr: RecommendationController = (controllers[id] = createRecommendationsController({ client, controller: config })); + const cntrlr: RecommendationController = (controllers[id] = createRecommendationController({ client, controller: config })); cntrlr.on('afterStore', async ({ controller }: { controller: RecommendationController }, next) => { controller.log.debug('controller', controller); @@ -71,3 +81,50 @@ export class Snapify { return cntrlr; } } + +function createSearchController(config: CreateConfig): SearchController { + const urlManager = new UrlManager(new UrlTranslator(), reactLinker); + + const cntrlr = new SearchController(config.controller as SearchControllerConfig, { + client: new Client(config.client.globals, config.client.config), + store: new SearchStore(config.controller as SearchControllerConfig, { urlManager }), + urlManager, + eventManager: new EventManager(), + profiler: new Profiler(), + logger: new Logger(), + tracker: new Tracker(config.client.globals), + }); + + return cntrlr; +} + +function createRecommendationController(config: CreateConfig): RecommendationController { + const urlManager = new UrlManager(new UrlTranslator(), reactLinker).detach(true); + const cntrlr = new RecommendationController(config.controller as RecommendationControllerConfig, { + client: new Client(config.client.globals, config.client.config), + store: new RecommendationStore(config.controller as RecommendationControllerConfig, { urlManager }), + urlManager, + eventManager: new EventManager(), + profiler: new Profiler(), + logger: new Logger(), + tracker: new Tracker(config.client.globals), + }); + + return cntrlr; +} + +function createAutocompleteController(config: CreateConfig): AutocompleteController { + const urlManager = new UrlManager(new UrlTranslator(), reactLinker).detach(); + + const cntrlr = new AutocompleteController(config.controller as AutocompleteControllerConfig, { + client: new Client(config.client.globals, config.client.config), + store: new AutocompleteStore(config.controller as AutocompleteControllerConfig, { urlManager }), + urlManager, + eventManager: new EventManager(), + profiler: new Profiler(), + logger: new Logger(), + tracker: new Tracker(config.client.globals), + }); + + return cntrlr; +} diff --git a/packages/snap-preact-demo/public/email.html b/packages/snap-preact-demo/public/email.html index 97269562b..dad651101 100644 --- a/packages/snap-preact-demo/public/email.html +++ b/packages/snap-preact-demo/public/email.html @@ -17,7 +17,7 @@ document.cookie = 'ssShopperId=; Max-Age=-99999999;'; const siteID = '8uyt2m'; - const profile = "email-test2"; + const profile = "email"; const component = "Email"; const results = [{"id":"175547","mappings":{"core":{"uid":"175547","name":"Off She Goes White Skinny Jeans","sku":"C-JU-W1-P1034","msrp":75,"price":58,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/use_3_thumb_med.jpg","url":"/product/C-JU-W1-P1034","rating":"5","brand":"Just USA","popularity":4455,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/use_3_large.jpg","ratingCount":1111}},"attributes":{}},{"id":"182022","mappings":{"core":{"uid":"182022","name":"Stripe Out Blue Off-The-Shoulder Dress","sku":"C-AD-I2-69PST","msrp":50,"price":48,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/2950_copyright_reddressboutique_2017__thumb_med.jpg","url":"/product/C-AD-I2-69PST","rating":"5","brand":"Adrienne","popularity":1135,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/2950_copyright_reddressboutique_2017__large.jpg","ratingCount":1111}},"attributes":{}},{"id":"177035","mappings":{"core":{"uid":"177035","name":"Spring Ahead White Print Off-The-Shoulder Dress","sku":"C-AD-W1-906FP","msrp":50,"price":48,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/copyright_rdb_studio_2_4758_thumb_med.jpg","url":"/product/C-AD-W1-906FP","rating":"5","brand":"Adrienne","popularity":3052,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/copyright_rdb_studio_2_4758_large.jpg","ratingCount":1111}},"attributes":{}},{"id":"182818","mappings":{"core":{"uid":"182818","name":"Take Me To Havana White Print Off-The-Shoulder Dress","sku":"C-AD-W1-924FP","msrp":50,"price":42,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/4303_copyright_reddressboutique_2017__thumb_med.jpg","url":"/product/C-AD-W1-924FP","rating":"5","brand":"Adrienne","popularity":752,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/4303_copyright_reddressboutique_2017__large.jpg","ratingCount":1111}},"attributes":{}},{"id":"180178","mappings":{"core":{"uid":"180178","name":"For The Romantic White Off-The-Shoulder Dress","sku":"C-DB-W1-14107","msrp":50,"price":48,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/17_03_20_studio_26619_thumb_med.jpg","url":"/product/C-DB-W1-14107","rating":"5","brand":"Ever After","popularity":1404,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/17_03_20_studio_26619_large.jpg","ratingCount":1111}},"attributes":{}},{"id":"181323","mappings":{"core":{"uid":"181323","name":"As Cute As They Come Purple Off-The-Shoulder Dress","sku":"C-EN-V2-D7422","msrp":50,"price":44,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/2940_copyright_reddressboutique_2017__thumb_med.jpg","url":"/product/C-EN-V2-D7422","rating":"5","brand":"Aura L\u0027atiste","popularity":4213,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/2940_copyright_reddressboutique_2017__large.jpg","ratingCount":1111}},"attributes":{}},{"id":"183818","mappings":{"core":{"uid":"183818","name":"Artist\u0027s Touch Blue Print Off-The-Shoulder Dress","sku":"C-FT-I4-D5340","msrp":50,"price":42,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/5-31-17adventureswithcarolineandhollyn0624_thumb_med.jpg","url":"/product/C-FT-I4-D5340","rating":"5","brand":"Flying Tomato","popularity":1342,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/5-31-17adventureswithcarolineandhollyn0624_large.jpg","ratingCount":1111}},"attributes":{}},{"id":"181825","mappings":{"core":{"uid":"181825","name":"Downtown Romantic Red Floral Print Dress","sku":"C-IL-R4-955BO","msrp":50,"price":49,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/4180_copyright_reddressboutique_2017__thumb_med.jpg","url":"/product/C-IL-R4-955BO","rating":"5","brand":"Illa Illa","popularity":900,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/4180_copyright_reddressboutique_2017__large.jpg","ratingCount":1111}},"attributes":{}},{"id":"183040","mappings":{"core":{"uid":"183040","name":"Fringe Airy Feeling White Print Dress","sku":"C-MIT-W1-41080","msrp":50,"price":39,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/5237_copyright_reddressboutique_2017__thumb_med.jpg","url":"/product/C-MIT-W1-41080","rating":"5","brand":"Mitto Shop","popularity":2471,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/5237_copyright_reddressboutique_2017__large.jpg","ratingCount":1111}},"attributes":{}},{"id":"178222","mappings":{"core":{"uid":"178222","name":"Salt And Sun White Open Shoulder Cover-Up","sku":"C-VL-W1-D460S","msrp":50,"price":44,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/2m4a5824-2_thumb_med.jpg","url":"/product/C-VL-W1-D460S","rating":"5","brand":"Velzera","popularity":2677,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/2m4a5824-2_large.jpg","ratingCount":1111}},"attributes":{}},{"id":"180422","mappings":{"core":{"uid":"180422","name":"Beach To Boardwalk Blue Tie Dye Maxi Dress","sku":"C-LS-I3-65NLP","msrp":50,"price":48,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/2931_copyright_reddressboutique_2017_thumb_med.jpg","url":"/product/C-LS-I3-65NLP","rating":"5","brand":"Love Stitch","popularity":2639,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/2931_copyright_reddressboutique_2017_large.jpg","ratingCount":1111}},"attributes":{}},{"id":"180940","mappings":{"core":{"uid":"180940","name":"Beach Babe White Off-The-Shoulder Cover-Up","sku":"C-VL-W1-D411S","msrp":50,"price":42,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/2m4a9284_thumb_med.jpg","url":"/product/C-VL-W1-D411S","rating":"5","brand":"Velzera","popularity":1323,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/2m4a9284_large.jpg","ratingCount":1111}},"attributes":{}},{"id":"180944","mappings":{"core":{"uid":"180944","name":"Everlasting Sun White Cover-Up","sku":"C-VL-W1-D480S","msrp":50,"price":44,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/2m4a8074_thumb_med.jpg","url":"/product/C-VL-W1-D480S","rating":"5","brand":"Velzera","popularity":1067,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/2m4a8074_large.jpg","ratingCount":1111}},"attributes":{}},{"id":"181887","mappings":{"core":{"uid":"181887","name":"Pure Happiness White Print Dress","sku":"C-ST-I3-12370","msrp":50,"price":44,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/1505_copyright_reddressboutique_2017__thumb_med.jpg","url":"/product/C-ST-I3-12370","rating":"5","brand":"Aura L\u0027atiste","popularity":299,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/1505_copyright_reddressboutique_2017__large.jpg","ratingCount":1111}},"attributes":{}},{"id":"176815","mappings":{"core":{"uid":"176815","name":"Spring To Mind Coral Off-The-Shoulder Dress","sku":"C-TCE-O1-D8349","msrp":50,"price":38,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/copyright_rdb_studio_2_5457_thumb_med.jpg","url":"/product/C-TCE-O1-D8349","rating":"5","brand":"TCEC","popularity":3607,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/copyright_rdb_studio_2_5457_large.jpg","ratingCount":1111}},"attributes":{}},{"id":"177983","mappings":{"core":{"uid":"177983","name":"Putting Class In Classic White Striped Dress","sku":"C-TCE-W1-D8326","msrp":50,"price":38,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/17_02_22_studio_set_02_15200951_thumb_med.jpg","url":"/product/C-TCE-W1-D8326","rating":"5","brand":"TCEC","popularity":1073,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/17_02_22_studio_set_02_15200951_large.jpg","ratingCount":1111}},"attributes":{}},{"id":"181845","mappings":{"core":{"uid":"181845","name":"Escape To Mexico Red Off-The-Shoulder Dress","sku":"C-US-R4-94464","msrp":50,"price":42,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/2457_copyright_reddressboutique_2017__thumb_med.jpg","url":"/product/C-US-R4-94464","rating":"5","brand":"Under Skies","popularity":2034,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/2457_copyright_reddressboutique_2017__large.jpg","ratingCount":1111}},"attributes":{}},{"id":"176982","mappings":{"core":{"uid":"176982","name":"Fancy Femme White Off-The-Shoulder Dress","sku":"C-MB-W1-16589","msrp":50,"price":50,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/copyright_rdb_studio_2_5602_thumb_med.jpg","url":"/product/C-MB-W1-16589","rating":"5","brand":"Marine","popularity":6,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/copyright_rdb_studio_2_5602_large.jpg","ratingCount":1111}},"attributes":{}},{"id":"181642","mappings":{"core":{"uid":"181642","name":"Spring Ahead Mint Print Off-The-Shoulder Dress","sku":"C-AD-E2-906FP","msrp":50,"price":48,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/2429_copyright_reddressboutique_2017__thumb_med.jpg","url":"/product/C-AD-E2-906FP","rating":"5","brand":"Adrienne","popularity":465,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/2429_copyright_reddressboutique_2017__large.jpg","ratingCount":1111}},"attributes":{}},{"id":"178432","mappings":{"core":{"uid":"178432","name":"Spring Ahead Powder Blue Off-The-Shoulder Dress","sku":"C-AD-I1-1906P","msrp":50,"price":48,"thumbnailImageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_thumb_med/copyright_rdb_studio_2_5021_thumb_med.jpg","url":"/product/C-AD-I1-1906P","rating":"5","brand":"Adrienne","popularity":897,"imageUrl":"https://searchspring-demo-content.s3.amazonaws.com/demo/fashion/product_images_large/copyright_rdb_studio_2_5021_large.jpg","ratingCount":1111}},"attributes":{}}]; @@ -86,7 +86,7 @@ - + diff --git a/packages/snap-preact-demo/tests/cypress/integration/email/email.spec.js b/packages/snap-preact-demo/tests/cypress/integration/email/email.spec.js index ee92af148..6b768f2f7 100644 --- a/packages/snap-preact-demo/tests/cypress/integration/email/email.spec.js +++ b/packages/snap-preact-demo/tests/cypress/integration/email/email.spec.js @@ -39,7 +39,7 @@ describe('Email Recs', () => { describe('Tests Email Recs', () => { it('has a controller with an products in store immediately', function () { - cy.snapController('recommend_email-test20').then(({ store }) => { + cy.snapController('recommend_email_0').then(({ store }) => { expect(store.results.length).to.equal(20); }); }); diff --git a/packages/snap-preact-demo/tests/cypress/integration/recommendation/recommendation.spec.js b/packages/snap-preact-demo/tests/cypress/integration/recommendation/recommendation.spec.js index dae00ab28..9ae7b45ad 100644 --- a/packages/snap-preact-demo/tests/cypress/integration/recommendation/recommendation.spec.js +++ b/packages/snap-preact-demo/tests/cypress/integration/recommendation/recommendation.spec.js @@ -21,7 +21,7 @@ const config = { nextArrow: '.ss__recommendation .ss__carousel__next', prevArrow: '.ss__recommendation .ss__carousel__prev', activeSlide: '.ss__recommendation .swiper-slide-active', - controller: 'recommend_similar0', + controller: 'recommend_similar_0', }, }, }; diff --git a/packages/snap-preact-demo/tests/cypress/integration/tracking/track.spec.js b/packages/snap-preact-demo/tests/cypress/integration/tracking/track.spec.js index 33493b355..fd7fb0dd1 100644 --- a/packages/snap-preact-demo/tests/cypress/integration/tracking/track.spec.js +++ b/packages/snap-preact-demo/tests/cypress/integration/tracking/track.spec.js @@ -191,7 +191,7 @@ describe('Tracking', () => { it('tracked all recommendation interaction events', () => { cy.visit('https://localhost:2222/product.html'); - cy.snapController('recommend_similar0').then(({ store }) => { + cy.snapController('recommend_similar_0').then(({ store }) => { expect(store).to.haveOwnProperty('results'); expect(store.results).to.have.length.above(0); diff --git a/packages/snap-preact/jest.config.js b/packages/snap-preact/jest.config.js index ca224b4d4..8559ec0ed 100644 --- a/packages/snap-preact/jest.config.js +++ b/packages/snap-preact/jest.config.js @@ -2,4 +2,10 @@ const rootConfig = require('../../jest.base.config.json'); module.exports = { ...rootConfig, displayName: 'snap-preact', + moduleNameMapper: { + '^react$': 'preact/compat', + '^react-dom/test-utils$': 'preact/test-utils', + '^react-dom$': 'preact/compat', + '\\.(css|less|sass|scss)$': '/__mocks__/styleMock.js', + }, }; diff --git a/packages/snap-preact/package.json b/packages/snap-preact/package.json index fff3e6a03..09cc0d53b 100644 --- a/packages/snap-preact/package.json +++ b/packages/snap-preact/package.json @@ -16,7 +16,7 @@ "dev": "tsc --watch", "format": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'", "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "jest --watch" }, "dependencies": { @@ -24,6 +24,7 @@ "@searchspring/snap-controller": "^0.26.1", "@searchspring/snap-event-manager": "^0.26.1", "@searchspring/snap-logger": "^0.26.1", + "@searchspring/snap-preact-components": "^0.26.1", "@searchspring/snap-profiler": "^0.26.1", "@searchspring/snap-store-mobx": "^0.26.1", "@searchspring/snap-toolbox": "^0.26.1", diff --git a/packages/snap-preact/src/Instantiators/README.md b/packages/snap-preact/src/Instantiators/README.md index 842ae509d..fe2fd8e4b 100644 --- a/packages/snap-preact/src/Instantiators/README.md +++ b/packages/snap-preact/src/Instantiators/README.md @@ -1,17 +1,17 @@ # Instantiators ## RecommendationInstantiator -The `RecommendationInstantiator` class handles the targetting and creation of recommendation controllers from querying the DOM. +The `RecommendationInstantiator` class handles the targetting and creation of recommendation controllers. The instantiator looks for targets in the DOM, creates a controller and injects components into the DOM. -### controllers +### controller -The `controllers` property contains an object of all recommendation instance that has been found on the page. Each instance will have its own `RecommendationController` instance created and added to the `controllers` object. +The `controller` property is an object of recommendation controller instances that have been created. -All controllers can be accessed via the `controllers` object where the key is the id of the controller that was created. The controller id is generated based on the `profile` attribute and it's occurance count (starting at 0.) It follows the following format: +All controllers can be accessed via the `controller` object where the key is the id of the controller that was created. The controller id is generated based on the `profile` attribute and it's occurance count (starting at 0.) It follows the following format: ```typescript -id: `recommend_${tag + (profileCount[tag] - 1)}`, +id: `recommend_${tag}_${profileCount[tag] - 1}`, ``` For example, if the page contains the following single recommendation instance: @@ -20,17 +20,8 @@ For example, if the page contains the following single recommendation instance: ``` -The controller id would be `recommend_trending0` and can be accesed as follows: +The controller id would be `recommend_trending_0`. -```typescript -import { Snap } from '@searchspring/snap-preact'; - -const snap = new Snap(config); -const recommendations = snap.recommendations; -const controllers = recommendations.controllers; -const { recommend_trending0 } = controllers; - -console.log("recommend_trending0", recommend_trending0) ``` ### client @@ -50,7 +41,7 @@ A reference to the shared [@searchspring/snap-logger](https://github.com/searchs ### config -A reference to the `config.instantiators.recommendation` config object as part of the config that was provided to Snap. +A reference to the config object used in instantiation. ### uses diff --git a/packages/snap-preact/src/Instantiators/RecommendationInstantiator.test.tsx b/packages/snap-preact/src/Instantiators/RecommendationInstantiator.test.tsx new file mode 100644 index 000000000..784934f71 --- /dev/null +++ b/packages/snap-preact/src/Instantiators/RecommendationInstantiator.test.tsx @@ -0,0 +1,361 @@ +import { RecommendationInstantiator, RecommendationInstantiatorConfig } from './RecommendationInstantiator'; +import type { PluginGrouping } from '@searchspring/snap-controller'; + +import { Logger } from '@searchspring/snap-logger'; +import { MockClient } from '@searchspring/snap-shared'; + +const DEFAULT_PROFILE = 'trending'; + +const Component = (props) => { + const controller = props.controller; + return
{controller.type}
; +}; + +const wait = (time = 1) => { + return new Promise((resolve) => { + setTimeout(resolve, time); + }); +}; + +const baseConfig: RecommendationInstantiatorConfig = { + client: { + globals: { + siteId: '8uyt2m', + }, + }, + components: { + Default: async () => Component, + }, + config: { + branch: 'production', + }, +}; + +describe('RecommendationInstantiator', () => { + beforeEach(() => { + delete window.searchspring; + }); + + it('throws if configuration is not provided', () => { + expect(() => { + // @ts-ignore - testing bad instantiation + const recommendationInstantiator = new RecommendationInstantiator(); + }).toThrow(); + }); + + it('throws if configuration is missing branch config', () => { + const invalidConfig = { + client: { + globals: { + siteId: '8uyt2m', + }, + }, + components: { + Default: async () => Component, + }, + config: {}, + }; + + expect(() => { + // @ts-ignore - testing bad instantiation + const recommendationInstantiator = new RecommendationInstantiator(invalidConfig); + }).toThrow(); + }); + + it('throws if configuration is missing client globals', () => { + const invalidConfig = { + components: { + Default: async () => Component, + }, + config: { + branch: 'production', + }, + }; + + expect(() => { + // @ts-ignore - testing bad instantiation + const recommendationInstantiator = new RecommendationInstantiator(invalidConfig); + }).toThrow(); + }); + + it('throws if configuration is missing component mapping', () => { + const invalidConfig = { + client: { + globals: { + siteId: '8uyt2m', + }, + }, + components: {}, + config: { + branch: 'production', + }, + }; + + expect(() => { + // @ts-ignore - testing bad instantiation + const recommendationInstantiator = new RecommendationInstantiator(invalidConfig); + }).toThrow(); + }); + + it('creates a proper RecommendationInstantiator object with minimal configuration', () => { + const recommendationInstantiator = new RecommendationInstantiator(baseConfig); + + // services are defined + expect(recommendationInstantiator.logger).toBeDefined(); + expect(recommendationInstantiator.client).toBeDefined(); + expect(recommendationInstantiator.tracker).toBeDefined(); + + // properties are defined + expect(recommendationInstantiator.config).toStrictEqual(baseConfig); + expect(recommendationInstantiator.context).toStrictEqual({}); + expect(recommendationInstantiator.controller).toStrictEqual({}); + + // @ts-ignore - checking private property + expect(recommendationInstantiator.client.globals.siteId).toBe(baseConfig.client.globals.siteId); + }); + + it('skips creation and logs a warning when it finds a target without a profile', async () => { + document.body.innerHTML = ``; + + const client = new MockClient(baseConfig.client.globals, {}); + const clientSpy = jest.spyOn(client, 'recommend'); + + const recommendationInstantiator = new RecommendationInstantiator(baseConfig, { client }); + expect(Object.keys(recommendationInstantiator.controller).length).toBe(0); + expect(clientSpy).toHaveBeenCalledTimes(0); + }); + + it('logs an error when the profile response does not contain templateParameters', async () => { + document.body.innerHTML = ``; + + const logger = new Logger('RecommendationInstantiator '); + const loggerSpy = jest.spyOn(logger, 'error'); + const client = new MockClient(baseConfig.client.globals, {}); + client.mockData.updateConfig({ recommend: { profile: 'missingParameters' } }); + const clientSpy = jest.spyOn(client, 'recommend'); + + const recommendationInstantiator = new RecommendationInstantiator(baseConfig, { logger, client }); + await wait(); + expect(Object.keys(recommendationInstantiator.controller).length).toBe(1); + expect(clientSpy).toHaveBeenCalledTimes(1); + expect(loggerSpy).toHaveBeenCalledTimes(1); + expect(loggerSpy).toHaveBeenCalledWith(`profile '${DEFAULT_PROFILE}' found on [object HTMLScriptElement] is missing templateParameters!`); + }); + + it('logs an error when the profile response does not contain a component', async () => { + document.body.innerHTML = ``; + + const logger = new Logger('RecommendationInstantiator '); + const loggerSpy = jest.spyOn(logger, 'error'); + const client = new MockClient(baseConfig.client.globals, {}); + client.mockData.updateConfig({ recommend: { profile: 'missingComponent' } }); + const clientSpy = jest.spyOn(client, 'recommend'); + + const recommendationInstantiator = new RecommendationInstantiator(baseConfig, { logger, client }); + await wait(); + expect(Object.keys(recommendationInstantiator.controller).length).toBe(1); + expect(clientSpy).toHaveBeenCalledTimes(1); + expect(loggerSpy).toHaveBeenCalledTimes(1); + expect(loggerSpy).toHaveBeenCalledWith(`profile '${DEFAULT_PROFILE}' found on [object HTMLScriptElement] is missing component!`); + }); + + it('logs an error when the profile response does not find a mapped component', async () => { + document.body.innerHTML = ``; + + const logger = new Logger('RecommendationInstantiator '); + const loggerSpy = jest.spyOn(logger, 'error'); + const client = new MockClient(baseConfig.client.globals, {}); + const clientSpy = jest.spyOn(client, 'recommend'); + + const modifiedConfig = { + ...baseConfig, + components: { + Recs: () => Component, + }, + }; + + const recommendationInstantiator = new RecommendationInstantiator(modifiedConfig, { logger, client }); + await wait(); + expect(Object.keys(recommendationInstantiator.controller).length).toBe(1); + expect(clientSpy).toHaveBeenCalledTimes(1); + expect(loggerSpy).toHaveBeenCalledTimes(1); + expect(loggerSpy).toHaveBeenCalledWith( + `profile '${DEFAULT_PROFILE}' found on [object HTMLScriptElement] is expecting component mapping for 'Default' - verify instantiator config.` + ); + }); + + it('creates a controller when it finds a target', async () => { + document.body.innerHTML = ``; + + const client = new MockClient(baseConfig.client.globals, {}); + const clientSpy = jest.spyOn(client, 'recommend'); + + const recommendationInstantiator = new RecommendationInstantiator(baseConfig, { client }); + await wait(); + expect(Object.keys(recommendationInstantiator.controller).length).toBe(1); + Object.keys(recommendationInstantiator.controller).forEach((controllerId, index) => { + const controller = recommendationInstantiator.controller[controllerId]; + expect(controllerId).toBe(`recommend_${DEFAULT_PROFILE}_${index}`); + }); + expect(clientSpy).toHaveBeenCalledTimes(1); + }); + + it('creates a controller for each target it finds', async () => { + document.body.innerHTML = ` + + + + `; + + const profileCount = {}; + + const client = new MockClient(baseConfig.client.globals, {}); + const clientSpy = jest.spyOn(client, 'recommend'); + + const recommendationInstantiator = new RecommendationInstantiator(baseConfig, { client }); + await wait(); + expect(Object.keys(recommendationInstantiator.controller).length).toBe(4); + Object.keys(recommendationInstantiator.controller).forEach((controllerId, index) => { + const controller = recommendationInstantiator.controller[controllerId]; + profileCount[controller.context.profile] = profileCount[controller.context.profile] + 1 || 0; + expect(controllerId).toBe(`recommend_${controller.context.profile}_${profileCount[controller.context.profile]}`); + }); + expect(clientSpy).toHaveBeenCalledTimes(4); + }); + + it('makes the context found on the target available', async () => { + document.body.innerHTML = ``; + + const client = new MockClient(baseConfig.client.globals, {}); + const clientSpy = jest.spyOn(client, 'recommend'); + + const recommendationInstantiator = new RecommendationInstantiator(baseConfig, { client }); + await wait(); + expect(Object.keys(recommendationInstantiator.controller).length).toBe(1); + Object.keys(recommendationInstantiator.controller).forEach((controllerId, index) => { + const controller = recommendationInstantiator.controller[controllerId]; + expect(controller.context).toStrictEqual({ + profile: 'trending', + shopper: { + id: 'snapdev', + }, + product: 'sku1', + options: { + branch: 'testing', + categories: ['cats', 'dogs'], + limit: 5, + siteId: 'abc123', + }, + }); + }); + + expect(clientSpy).toHaveBeenCalledTimes(1); + expect(clientSpy).toHaveBeenCalledWith({ + batched: true, + branch: 'testing', + categories: ['cats', 'dogs'], + limits: 5, + product: 'sku1', + shopper: 'snapdev', + siteId: 'abc123', + tag: 'trending', + }); + }); + + it('will utilize attachments (plugins / middleware) added via methods upon creation of controller', async () => { + document.body.innerHTML = ``; + + const plugin = jest.fn(); + const plugin2 = jest.fn(); + const plugin3 = jest.fn(); + const plugin4 = jest.fn(); + const middlewareFn = jest.fn(); + const middleware = async (things, next) => { + middlewareFn(); + await next(); + }; + + const client = new MockClient(baseConfig.client.globals, {}); + const clientSpy = jest.spyOn(client, 'recommend'); + + const recommendationInstantiator = new RecommendationInstantiator(baseConfig, { client }); + recommendationInstantiator.plugin(plugin, 'param1', { thing: 'here' }); + recommendationInstantiator.plugin(plugin2); + recommendationInstantiator.on('beforeSearch', middleware); + recommendationInstantiator.on('afterSearch', middleware); + recommendationInstantiator.on('afterStore', middleware); + recommendationInstantiator.use({ + plugins: [[plugin3, 'p1', 'p2', 'p3'] as PluginGrouping, [plugin4] as PluginGrouping], + middleware: { + beforeSearch: middleware, + afterSearch: middleware, + afterStore: middleware, + }, + }); + await wait(); + + Object.keys(recommendationInstantiator.controller).forEach((controllerId, index) => { + const controller = recommendationInstantiator.controller[controllerId]; + expect(clientSpy).toHaveBeenCalledTimes(1); + expect(middlewareFn).toHaveBeenCalledTimes(6); + expect(plugin).toHaveBeenCalledTimes(1); + expect(plugin).toHaveBeenCalledWith(controller, 'param1', { thing: 'here' }); + expect(plugin2).toHaveBeenCalledTimes(1); + expect(plugin2).toHaveBeenCalledWith(controller); + expect(plugin3).toHaveBeenCalledTimes(1); + expect(plugin3).toHaveBeenCalledWith(controller, 'p1', 'p2', 'p3'); + expect(plugin4).toHaveBeenCalledTimes(1); + expect(plugin4).toHaveBeenCalledWith(controller); + }); + }); + + it('will utilize config based attachments (plugins / middleware) on created controller', async () => { + document.body.innerHTML = ``; + + const plugin = jest.fn(); + const plugin2 = jest.fn(); + const middlewareFn = jest.fn(); + const middleware = async (things, next) => { + middlewareFn(); + await next(); + }; + + const attachmentConfig = { + ...baseConfig, + config: { + branch: baseConfig.config.branch, + plugins: [[plugin, 'param1', { thing: 'here' }] as PluginGrouping, [plugin2] as PluginGrouping], + middleware: { + beforeSearch: middleware, + afterSearch: middleware, + afterStore: middleware, + }, + }, + }; + + const client = new MockClient(baseConfig.client.globals, {}); + const clientSpy = jest.spyOn(client, 'recommend'); + + const recommendationInstantiator = new RecommendationInstantiator(attachmentConfig as RecommendationInstantiatorConfig, { client }); + await wait(); + + Object.keys(recommendationInstantiator.controller).forEach((controllerId, index) => { + const controller = recommendationInstantiator.controller[controllerId]; + expect(clientSpy).toHaveBeenCalledTimes(1); + expect(middlewareFn).toHaveBeenCalledTimes(3); + expect(plugin).toHaveBeenCalledTimes(1); + expect(plugin).toHaveBeenCalledWith(controller, 'param1', { thing: 'here' }); + expect(plugin2).toHaveBeenCalledTimes(1); + expect(plugin2).toHaveBeenCalledWith(controller); + }); + }); +}); diff --git a/packages/snap-preact/src/Instantiators/RecommendationInstantiator.tsx b/packages/snap-preact/src/Instantiators/RecommendationInstantiator.tsx index a40a63bc2..a9f3865c5 100644 --- a/packages/snap-preact/src/Instantiators/RecommendationInstantiator.tsx +++ b/packages/snap-preact/src/Instantiators/RecommendationInstantiator.tsx @@ -2,16 +2,21 @@ import { render } from 'preact'; import deepmerge from 'deepmerge'; import { DomTargeter, getContext } from '@searchspring/snap-toolbox'; +import { Client } from '@searchspring/snap-client'; +import { Logger } from '@searchspring/snap-logger'; +import { Tracker } from '@searchspring/snap-tracker'; -import type { Logger } from '@searchspring/snap-logger'; +import type { ClientConfig, ClientGlobals } from '@searchspring/snap-client'; import type { UrlTranslatorConfig } from '@searchspring/snap-url-manager'; -import type { Client } from '@searchspring/snap-client'; -import type { Tracker } from '@searchspring/snap-tracker'; import type { AbstractController, RecommendationController, Attachments, ContextVariables } from '@searchspring/snap-controller'; import type { Middleware } from '@searchspring/snap-event-manager'; import type { SnapControllerServices, RootComponent } from '../types'; export type RecommendationInstantiatorConfig = { + client?: { + globals: ClientGlobals; + config?: ClientConfig; + }; components: { [name: string]: () => Promise | RootComponent; }; @@ -22,34 +27,33 @@ export type RecommendationInstantiatorConfig = { limit?: number; } & Attachments; selector?: string; - services?: SnapControllerServices; url?: UrlTranslatorConfig; context?: ContextVariables; }; export type RecommendationInstantiatorServices = { - client: Client; - logger: Logger; - tracker: Tracker; + client?: Client; + logger?: Logger; + tracker?: Tracker; }; export class RecommendationInstantiator { - controllers: { + public client: Client; + public tracker: Tracker; + public logger: Logger; + public controller: { [key: string]: RecommendationController; } = {}; - client: Client; - tracker: Tracker; - logger: Logger; - config: RecommendationInstantiatorConfig; - context: ContextVariables; - uses: Attachments[] = []; - plugins: { (cntrlr: AbstractController): Promise }[] = []; - middleware: { event: string; func: Middleware[] }[] = []; + public config: RecommendationInstantiatorConfig; + public context: ContextVariables; public targeter: DomTargeter; - constructor(config: RecommendationInstantiatorConfig, { client, logger, tracker }: RecommendationInstantiatorServices, context?: ContextVariables) { + private uses: Attachments[] = []; + private plugins: { func: (cntrlr: AbstractController, ...args) => Promise; args: unknown[] }[] = []; + private middleware: { event: string; func: Middleware[] }[] = []; + + constructor(config: RecommendationInstantiatorConfig, services?: RecommendationInstantiatorServices, context?: ContextVariables) { this.config = config; - this.context = deepmerge(context || {}, config.context || {}); if (!this.config) { throw new Error(`Recommendation Instantiator config is required`); @@ -59,13 +63,18 @@ export class RecommendationInstantiator { throw new Error(`Recommendation Instantiator config must contain 'branch' property`); } - if (!this.config.components || typeof this.config.components != 'object') { + if (!this.config.components || typeof this.config.components != 'object' || !Object.keys(this.config.components).length) { throw new Error(`Recommendation Instantiator config must contain 'components' mapping property`); } - this.client = this.config.services?.client || client; - this.tracker = this.config.services?.tracker || tracker; - this.logger = this.config.services?.logger || logger; + if ((!services?.client || !services?.tracker) && !this.config?.client?.globals?.siteId) { + throw new Error(`Recommendation Instantiator config must contain a valid config.client.globals.siteId value`); + } + + this.context = deepmerge(context || {}, config.context || {}); + this.client = services?.client || new Client(this.config.client.globals, this.config.client.config); + this.tracker = services?.tracker || new Tracker(this.config.client.globals); + this.logger = services?.logger || new Logger('RecommendationInstantiator '); const profileCount = {}; this.targeter = new DomTargeter( @@ -79,18 +88,21 @@ export class RecommendationInstantiator { element: (target, origElement) => { const profile = origElement.getAttribute('profile'); - if (profile) { - const recsContainer = document.createElement('div'); - recsContainer.setAttribute('searchspring-recommend', profile); - return recsContainer; - } else { - this.logger.warn(`'profile' attribute is missing from
`; + }); + + it('throws if configuration is not provided', () => { + expect(() => { + // @ts-ignore - testing bad instantiation + const snap = new Snap(); + }).toThrow(); + }); + + it('throws if configuration is not complete', () => { + const config = { + client: {}, + }; + + expect(() => { + // @ts-ignore - testing bad instantiation + const snap = new Snap(config); + }).toThrow(); + }); + + it('uses the logger to log an error when no context is found', () => { + document.body.innerHTML = ''; + + const logger = new Logger('Snap Preact '); + const spy = jest.spyOn(logger, 'error'); + const snap = new Snap(baseConfig, { logger }); + expect(spy).toHaveBeenCalledWith('failed to find global context'); + }); + + it('creates a proper Snap object with minimal configuration', () => { + const snap = new Snap(baseConfig); + + // services are defined + expect(snap.logger).toBeDefined(); + expect(snap.client).toBeDefined(); + expect(snap.tracker).toBeDefined(); + + // properties are defined + expect(snap.config).toStrictEqual(baseConfig); + expect(snap.context).toStrictEqual({}); + expect(snap.controllers).toStrictEqual({}); + + // @ts-ignore - checking private property + expect(snap.client.globals.siteId).toBe(baseConfig.client.globals.siteId); + }); + + it('merges config found in context and prioritizes the config found in the context', () => { + document.body.innerHTML = ``; + + const snap = new Snap(baseConfig); + + // @ts-ignore - checking private property + expect(snap.client.globals.siteId).toBe('yyyyyy'); + }); + + it('merges contexts and prioritizes the context found in the script', () => { + document.body.innerHTML = ``; + + const contextConfig = { + ...baseConfig, + context: { + shopper: { + id: 'snapdevconfig', + }, + }, + }; + + const snap = new Snap(contextConfig); + expect(snap.context.shopper.id).toBe('snapdevscript'); + }); + + it('has branch override functionality', () => { + const branchParam = 'override'; + cookies.set(BRANCH_COOKIE, branchParam, 'Lax', 3600000); + + const logger = new Logger('Snap Preact '); + const spy = jest.spyOn(logger, 'warn'); + const snap = new Snap(baseConfig, { logger }); + expect(spy).toHaveBeenCalledWith(`...loading build... '${branchParam}'`); + + cookies.unset(BRANCH_COOKIE); + }); + + it('exposes itself globally on the window', () => { + const snap = new Snap(baseConfig); + expect(window.searchspring).toBeDefined(); + expect(window.searchspring.context).toBe(snap.context); + expect(window.searchspring.client).toBe(snap.client); + }); + + it('automatically tracks the shopper id when provided', () => { + const contextConfig = { + ...baseConfig, + context: { + shopper: { + id: 'snapdev', + }, + }, + }; + + const tracker = new Tracker(baseConfig.client.globals); + const spy = jest.spyOn(tracker.track.shopper, 'login'); + const snap = new Snap(contextConfig, { tracker }); + expect(spy).toHaveBeenCalledWith({ id: contextConfig.context.shopper.id }); + }); + + it('automatically sets the shopper cart when provided', () => { + const contextConfig = { + ...baseConfig, + context: { + shopper: { + id: 'snapdev', + cart: [{ sku: 'sku1' }, { sku: 'sku2' }, { sku: 'sku3' }], + }, + }, + }; + + const tracker = new Tracker(baseConfig.client.globals); + const spy = jest.spyOn(tracker.cookies.cart, 'set'); + const snap = new Snap(contextConfig, { tracker }); + expect(spy).toHaveBeenCalledWith(['sku1', 'sku2', 'sku3']); + }); + + describe('creates search controllers via config', () => { + it(`can create a search controller`, () => { + const searchConfig = { + ...baseConfig, + controllers: { + search: [ + { + config: { + id: 'search', + settings: { + redirects: { + merchandising: false, + }, + }, + }, + }, + ], + }, + }; + const snap = new Snap(searchConfig); + + const search = snap.controllers.search; + expect(search).toBeDefined(); + expect(search.id).toBe('search'); + expect(search.type).toBe('search'); + expect((search.config as SearchControllerConfig).settings.redirects.merchandising).toBe(false); + + // it has not searched and is not searching + expect(search.store.loading).toBe(false); + expect(search.store.loaded).toBe(false); + }); + + it(`can create multiple search controllers`, () => { + const searchConfig = { + ...baseConfig, + controllers: { + search: [ + { + config: { + id: 'searchOne', + }, + }, + { + config: { + id: 'searchTwo', + }, + }, + ], + }, + }; + const snap = new Snap(searchConfig); + + expect(snap.controllers.searchOne).toBeDefined(); + expect(snap.controllers.searchTwo).toBeDefined(); + }); + + it(`does not run the controller 'search' method when a targeter is NOT found`, async () => { + const searchConfig = { + ...baseConfig, + controllers: { + search: [ + { + config: { + id: 'search', + }, + targeters: [ + { + selector: '#searchspring-dne', + hideTarget: true, + component: () => { + return Component; + }, + }, + ], + }, + ], + }, + }; + + const client = new MockClient(baseConfig.client.globals, {}); + const spy = jest.spyOn(client, 'search'); + const snap = new Snap(searchConfig, { client }); + + const search = snap.controllers.search; + expect(search).toBeDefined(); + + await wait(); + + expect(spy).toHaveBeenCalledTimes(0); + expect(search.store.loading).toBe(false); + expect(search.store.loaded).toBe(false); + }); + + it(`logs an error when targeter has invalid configuration`, async () => { + const searchConfig = { + ...baseConfig, + controllers: { + search: [ + { + config: { + id: 'search', + }, + targeters: [ + { + selector: '#searchspring-content', + hideTarget: true, + component: () => Component, + }, + ], + }, + ], + }, + }; + + const logger = new Logger(); + const spy = jest.spyOn(logger, 'error'); + + // valid config - no errors logged + new Snap(searchConfig, { logger }); + expect(spy).toHaveBeenCalledTimes(0); + + // invalid config - logs error due to missing selector + delete searchConfig.controllers.search[0].targeters[0].selector; + delete window.searchspring.controller.search; + new Snap(searchConfig, { logger }); + expect(spy).toHaveBeenCalledTimes(1); + + // invalid config - logs error due to missing component + searchConfig.controllers.search[0].targeters[0].selector = '#searchspring-content'; + delete searchConfig.controllers.search[0].targeters[0].component; + delete window.searchspring.controller.search; + new Snap(searchConfig, { logger }); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it(`runs the controller 'search' method when a targeter selector is found`, async () => { + const searchConfig = { + ...baseConfig, + controllers: { + search: [ + { + config: { + id: 'search', + }, + targeters: [ + { + selector: '#searchspring-content', + hideTarget: true, + component: () => { + return Component; + }, + }, + ], + }, + ], + }, + }; + + const client = new MockClient(baseConfig.client.globals, {}); + const spy = jest.spyOn(client, 'search'); + const snap = new Snap(searchConfig, { client }); + + const search = snap.controllers.search; + expect(search).toBeDefined(); + + await wait(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(search.store.loading).toBe(false); + expect(search.store.loaded).toBe(true); + }); + + it(`runs the onTarget function when a targeter selector is found`, async () => { + const onTarget = jest.fn(); + + const searchConfig = { + ...baseConfig, + controllers: { + search: [ + { + config: { + id: 'search', + }, + targeters: [ + { + selector: '#searchspring-content', + hideTarget: true, + onTarget, + component: () => Component, + }, + ], + }, + ], + }, + }; + + const client = new MockClient(baseConfig.client.globals, {}); + const snap = new Snap(searchConfig, { client }); + + const search = snap.controllers.search; + expect(search).toBeDefined(); + + await wait(); + + expect(onTarget).toHaveBeenCalledTimes(1); + }); + + it(`runs the controller 'search' method when prefetch is set when selector not found`, async () => { + const searchConfig = { + ...baseConfig, + controllers: { + search: [ + { + config: { + id: 'search', + }, + targeters: [ + { + prefetch: true, + selector: '#searchspring-dne', + hideTarget: true, + component: () => { + return Component; + }, + }, + ], + }, + ], + }, + }; + + const client = new MockClient(baseConfig.client.globals, {}); + const spy = jest.spyOn(client, 'search'); + const snap = new Snap(searchConfig, { client }); + + const search = snap.controllers.search; + expect(search).toBeDefined(); + + await wait(); + + expect(spy).toHaveBeenCalledTimes(1); + expect(search.store.loading).toBe(false); + expect(search.store.loaded).toBe(true); + }); + }); + + describe('creates autocomplete controllers via config', () => { + it(`can create an autocomplete controller`, async () => { + const acConfig = { + ...baseConfig, + controllers: { + autocomplete: [ + { + config: { + id: 'ac', + selector: '.ss-ac-input', + settings: { + initializeFromUrl: false, + }, + }, + }, + ], + }, + }; + const snap = new Snap(acConfig); + + const autocomplete = await snap.getController('ac'); + expect(autocomplete.id).toBe('ac'); + expect(autocomplete.type).toBe('autocomplete'); + expect((autocomplete.config as AutocompleteControllerConfig).settings.initializeFromUrl).toBe(false); + + // it has not searched and is not searching + expect(autocomplete.store.loading).toBe(false); + expect(autocomplete.store.loaded).toBe(false); + }); + + it(`can create multiple autocomplete controllers`, async () => { + const acConfig = { + ...baseConfig, + controllers: { + autocomplete: [ + { + config: { + selector: '.ss-ac-input-one', + id: 'acOne', + }, + }, + { + config: { + selector: '.ss-ac-input-two', + id: 'acTwo', + }, + }, + ], + }, + }; + const snap = new Snap(acConfig); + const [acOne, acTwo] = await snap.getControllers('acOne', 'acTwo'); + expect(acOne.id).toBe('acOne'); + expect(acOne.type).toBe('autocomplete'); + expect(acTwo.id).toBe('acTwo'); + expect(acTwo.type).toBe('autocomplete'); + }); + + it(`logs an error when targeter has invalid configuration`, async () => { + document.body.innerHTML = ``; + + const acConfig = { + ...baseConfig, + controllers: { + autocomplete: [ + { + config: { + selector: '.ss-ac-input', + id: 'ac', + }, + targeters: [ + { + selector: '.ss-ac-input', + hideTarget: true, + component: async () => Component, + }, + ], + }, + ], + }, + }; + + const logger = new Logger(); + const spy = jest.spyOn(logger, 'error'); + + new Snap(acConfig, { logger }); + await wait(); + expect(spy).toHaveBeenCalledTimes(0); + + delete acConfig.controllers.autocomplete[0].targeters[0].selector; + new Snap(acConfig, { logger }); + expect(spy).toHaveBeenCalledTimes(1); + + acConfig.controllers.autocomplete[0].targeters[0].selector = '#searchspring-content'; + delete acConfig.controllers.autocomplete[0].targeters[0].component; + new Snap(acConfig, { logger }); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it(`creates targeter provided in config`, async () => { + document.body.innerHTML = ``; + + const acConfig = { + ...baseConfig, + controllers: { + autocomplete: [ + { + config: { + selector: '.ss-ac-input', + id: 'ac', + }, + targeters: [ + { + name: 'acTarget', + selector: '.ss-ac-input', + hideTarget: true, + component: async () => Component, + }, + ], + }, + ], + }, + }; + const snap = new Snap(acConfig); + const ac = await snap.getController('ac'); + await wait(); + expect(ac.id).toBe('ac'); + expect(ac.targeters.acTarget).toBeDefined(); + expect((ac.store as AutocompleteStore).state.input).toBeUndefined(); + }); + + it(`runs the onTarget function when a targeter selector is found`, async () => { + const onTarget = jest.fn(); + + document.body.innerHTML = ``; + + const acConfig = { + ...baseConfig, + controllers: { + autocomplete: [ + { + config: { + selector: '.ss-ac-input', + id: 'ac', + }, + targeters: [ + { + selector: '.ss-ac-input', + hideTarget: true, + component: async () => Component, + onTarget, + }, + ], + }, + ], + }, + }; + const snap = new Snap(acConfig); + const ac = await snap.getController('ac'); + await wait(); + expect(onTarget).toHaveBeenCalledTimes(1); + }); + }); + + describe('creates finder controllers via config', () => { + it(`can create a finder controller`, async () => { + const finderConfig = { + ...baseConfig, + controllers: { + finder: [ + { + config: { + id: 'finder', + url: '/', + fields: [ + { + field: 'ss_category_hierarchy', + }, + ], + }, + }, + ], + }, + }; + const snap = new Snap(finderConfig); + + const finder = await snap.getController('finder'); + expect(finder.id).toBe('finder'); + expect(finder.type).toBe('finder'); + + // it has not searched and is not searching + expect(finder.store.loading).toBe(false); + expect(finder.store.loaded).toBe(false); + }); + + it(`can create multiple finder controllers`, async () => { + const finderConfig = { + ...baseConfig, + controllers: { + finder: [ + { + config: { + id: 'finderOne', + url: '/', + fields: [ + { + field: 'ss_category_hierarchy', + }, + ], + }, + }, + { + config: { + id: 'finderTwo', + url: '/', + fields: [ + { + field: 'ss_category_hierarchy', + }, + ], + }, + }, + ], + }, + }; + const snap = new Snap(finderConfig); + const [finderOne, finderTwo] = await snap.getControllers('finderOne', 'finderTwo'); + expect(finderOne.id).toBe('finderOne'); + expect(finderOne.type).toBe('finder'); + expect(finderTwo.id).toBe('finderTwo'); + expect(finderTwo.type).toBe('finder'); + }); + + it(`logs an error when targeter has invalid configuration`, async () => { + document.body.innerHTML = `
`; + + const finderConfig = { + ...baseConfig, + controllers: { + finder: [ + { + config: { + id: 'finder', + url: '/', + fields: [ + { + field: 'ss_category_hierarchy', + }, + ], + }, + targeters: [ + { + name: 'finder_hierarchy', + selector: '#searchspring-finder-hierarchy', + component: async () => Component, + }, + ], + }, + ], + }, + }; + + const logger = new Logger(); + const spy = jest.spyOn(logger, 'error'); + + new Snap(finderConfig, { logger }); + await wait(); + expect(spy).toHaveBeenCalledTimes(0); + + delete finderConfig.controllers.finder[0].targeters[0].selector; + new Snap(finderConfig, { logger }); + expect(spy).toHaveBeenCalledTimes(1); + + finderConfig.controllers.finder[0].targeters[0].selector = '#searchspring-content'; + delete finderConfig.controllers.finder[0].targeters[0].component; + new Snap(finderConfig, { logger }); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it(`creates targeter provided in config`, async () => { + document.body.innerHTML = `
`; + + const finderConfig = { + ...baseConfig, + controllers: { + finder: [ + { + config: { + id: 'finder', + url: '/', + fields: [ + { + field: 'ss_category_hierarchy', + }, + ], + }, + targeters: [ + { + name: 'finderTargeter', + selector: '#searchspring-finder-hierarchy', + component: async () => Component, + }, + ], + }, + ], + }, + }; + const snap = new Snap(finderConfig); + const finder = await snap.getController('finder'); + await wait(); + expect(finder.id).toBe('finder'); + expect(finder.targeters.finderTargeter).toBeDefined(); + }); + + it(`runs the onTarget function when a targeter selector is found`, async () => { + const onTarget = jest.fn(); + + document.body.innerHTML = `
`; + + const finderConfig = { + ...baseConfig, + controllers: { + finder: [ + { + config: { + id: 'finder', + url: '/', + fields: [ + { + field: 'ss_category_hierarchy', + }, + ], + }, + targeters: [ + { + name: 'finderTargeter', + selector: '#searchspring-finder-hierarchy', + component: async () => Component, + onTarget, + }, + ], + }, + ], + }, + }; + const snap = new Snap(finderConfig); + const finder = await snap.getController('finder'); + await wait(); + expect(onTarget).toHaveBeenCalledTimes(1); + }); + }); + + describe('creates recommendation controllers via config', () => { + it(`can create a recommendation controller`, async () => { + const recommendationConfig = { + ...baseConfig, + controllers: { + recommendation: [ + { + config: { + id: 'trendingRecs', + tag: 'trending', + }, + }, + ], + }, + }; + const snap = new Snap(recommendationConfig); + + const finder = await snap.getController('trendingRecs'); + expect(finder.id).toBe('trendingRecs'); + expect(finder.type).toBe('recommendation'); + + // it has not searched and is not searching + expect(finder.store.loading).toBe(false); + expect(finder.store.loaded).toBe(false); + }); + + it(`can create multiple recommendation controllers`, async () => { + const recommendationConfig = { + ...baseConfig, + controllers: { + recommendation: [ + { + config: { + id: 'trendingRecsOne', + tag: 'trending', + }, + }, + { + config: { + id: 'trendingRecsTwo', + tag: 'trending', + }, + }, + ], + }, + }; + const snap = new Snap(recommendationConfig); + const [trendingRecsOne, trendingRecsTwo] = await snap.getControllers('trendingRecsOne', 'trendingRecsTwo'); + expect(trendingRecsOne.id).toBe('trendingRecsOne'); + expect(trendingRecsOne.type).toBe('recommendation'); + expect(trendingRecsTwo.id).toBe('trendingRecsTwo'); + expect(trendingRecsTwo.type).toBe('recommendation'); + }); + + it(`logs an error when targeter has invalid configuration`, async () => { + document.body.innerHTML = ``; + + const recommendationConfig = { + ...baseConfig, + controllers: { + recommendation: [ + { + config: { + id: 'trendingRecs', + tag: 'trending', + }, + targeters: [ + { + name: 'recsTargeter', + selector: '#ss-trending-recs', + component: async () => Component, + }, + ], + }, + ], + }, + }; + + const logger = new Logger(); + const spy = jest.spyOn(logger, 'error'); + + new Snap(recommendationConfig, { logger }); + await wait(); + expect(spy).toHaveBeenCalledTimes(0); + + delete recommendationConfig.controllers.recommendation[0].targeters[0].selector; + new Snap(recommendationConfig, { logger }); + expect(spy).toHaveBeenCalledTimes(1); + + recommendationConfig.controllers.recommendation[0].targeters[0].selector = '#ss-trending-recs'; + delete recommendationConfig.controllers.recommendation[0].targeters[0].component; + new Snap(recommendationConfig, { logger }); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it(`creates targeter provided in config`, async () => { + document.body.innerHTML = ``; + + const recommendationConfig = { + ...baseConfig, + controllers: { + recommendation: [ + { + config: { + id: 'trendingRecs', + tag: 'trending', + }, + targeters: [ + { + name: 'recsTargeter', + selector: '#ss-trending-recs', + component: async () => Component, + }, + ], + }, + ], + }, + }; + const snap = new Snap(recommendationConfig); + const recommendation = await snap.getController('trendingRecs'); + await wait(); + expect(recommendation.id).toBe('trendingRecs'); + expect(recommendation.targeters.recsTargeter).toBeDefined(); + }); + + it(`runs the onTarget function when a targeter selector is found`, async () => { + const onTarget = jest.fn(); + + document.body.innerHTML = ``; + + const recommendationConfig = { + ...baseConfig, + controllers: { + recommendation: [ + { + config: { + id: 'trendingRecs', + tag: 'trending', + }, + targeters: [ + { + name: 'recsTargeter', + selector: '#ss-trending-recs', + component: async () => Component, + onTarget, + }, + ], + }, + ], + }, + }; + const snap = new Snap(recommendationConfig); + const recommendation = await snap.getController('trendingRecs'); + await wait(); + expect(onTarget).toHaveBeenCalledTimes(1); + }); + }); + + describe(`the 'getInstantiator' method`, () => { + it('rejects if requested instantiator does not exist', async () => { + const snap = new Snap(baseConfig); + + await expect(async () => { + await snap.getInstantiator('recommendation'); + }).rejects.toBe(`getInstantiator could not find instantiator with id: recommendation`); + }); + + it('returns an instantiator when the requested id exists', async () => { + const instantiatorConfig = { + ...baseConfig, + instantiators: { + recommendation: { + components: { + thing: () => Component, + }, + config: { + branch: 'production', + }, + }, + }, + }; + + const snap = new Snap(instantiatorConfig); + + const instantiator = await snap.getInstantiator('recommendation'); + expect(instantiator).toBeDefined(); + expect(instantiator.config.config.branch).toBe('production'); + }); + }); + + describe(`the 'getController(s)' method`, () => { + it('rejects if requested controller does not exist', async () => { + const snap = new Snap(baseConfig); + + await expect(async () => { + await snap.getController('search'); + }).rejects.toBe(`getController could not find controller with id: search`); + }); + + it('returns a controller with the requested id when it exists', async () => { + const searchConfig = { + ...baseConfig, + controllers: { + search: [ + { + config: { + id: 'search', + }, + }, + ], + }, + }; + const snap = new Snap(searchConfig); + + const search = await snap.getController('search'); + expect(search).toBeDefined(); + expect(search.id).toBe('search'); + }); + + it('rejects if requested controller does not exist', async () => { + const snap = new Snap(baseConfig); + + await expect(async () => { + const [searchOne, searchTwo] = await snap.getControllers('searchOne', 'searchTwo'); + }).rejects.toBe(`getController could not find controller with id: searchOne`); + }); + + it('rejects if requested controller does not exist', async () => { + const searchConfig = { + ...baseConfig, + controllers: { + search: [ + { + config: { + id: 'searchOne', + }, + }, + ], + }, + }; + const snap = new Snap(searchConfig); + + await expect(async () => { + const [searchOne, searchTwo] = await snap.getControllers('searchOne', 'searchTwo'); + }).rejects.toBe(`getController could not find controller with id: searchTwo`); + }); + + it('returns controllers with the requested ids when they exists', async () => { + const searchConfig = { + ...baseConfig, + controllers: { + search: [ + { + config: { + id: 'searchOne', + }, + }, + { + config: { + id: 'searchTwo', + }, + }, + ], + }, + }; + const snap = new Snap(searchConfig); + + const [searchOne, searchTwo] = await snap.getControllers('searchOne', 'searchTwo'); + expect(searchOne).toBeDefined(); + expect(searchOne.id).toBe('searchOne'); + expect(searchTwo).toBeDefined(); + expect(searchTwo.id).toBe('searchTwo'); + }); + }); + + describe(`the 'createController' method`, () => { + it('can create a search controller', async () => { + const snap = new Snap(baseConfig); + const searchConfig = { + id: 's', + }; + + const search = await snap.createController('search', searchConfig); + expect(search.id).toBe(searchConfig.id); + expect(search.type).toBe('search'); + }); + + it('can create an autocomplete controller', async () => { + const snap = new Snap(baseConfig); + const autocompleteConfig = { + id: 'ac', + }; + + const autocomplete = await snap.createController('autocomplete', autocompleteConfig); + expect(autocomplete.id).toBe(autocompleteConfig.id); + expect(autocomplete.type).toBe('autocomplete'); + }); + + it('can create a finder controller', async () => { + const snap = new Snap(baseConfig); + const finderConfig = { + id: 'f', + }; + + const finder = await snap.createController('finder', finderConfig); + expect(finder.id).toBe(finderConfig.id); + expect(finder.type).toBe('finder'); + }); + + it('can create a recommendation controller', async () => { + const snap = new Snap(baseConfig); + const recommendationConfig = { + tag: 'profile', + id: 'rec', + }; + + const recommendation = await snap.createController('recommendation', recommendationConfig); + expect(recommendation.id).toBe(recommendationConfig.id); + expect(recommendation.type).toBe('recommendation'); + }); + + it('executes an optional callback function when creating a controller', async () => { + const snap = new Snap(baseConfig); + const searchConfig = { + id: 's', + }; + + const callback = jest.fn(); + const search = await snap.createController('search', searchConfig, undefined, undefined, undefined, callback); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(search); + }); + }); +}); diff --git a/packages/snap-preact/src/Snap.tsx b/packages/snap-preact/src/Snap.tsx index e21d6327b..bcec9b31d 100644 --- a/packages/snap-preact/src/Snap.tsx +++ b/packages/snap-preact/src/Snap.tsx @@ -6,6 +6,7 @@ import { Logger, LogMode } from '@searchspring/snap-logger'; import { Tracker } from '@searchspring/snap-tracker'; import { version, DomTargeter, url, cookies, featureFlags } from '@searchspring/snap-toolbox'; import { getContext } from '@searchspring/snap-toolbox'; +import { ControllerTypes } from '@searchspring/snap-controller'; import type { ClientConfig, ClientGlobals } from '@searchspring/snap-client'; import type { @@ -27,10 +28,10 @@ import type { UrlTranslatorConfig } from '@searchspring/snap-url-manager'; import { default as createSearchController } from './create/createSearchController'; import { RecommendationInstantiator, RecommendationInstantiatorConfig } from './Instantiators/RecommendationInstantiator'; -import type { SnapControllerServices, RootComponent } from './types'; +import type { SnapControllerServices, SnapControllerConfigs, RootComponent } from './types'; -const BRANCH_COOKIE = 'ssBranch'; -const SS_DEV_COOKIE = 'ssDev'; +export const BRANCH_COOKIE = 'ssBranch'; +export const SS_DEV_COOKIE = 'ssDev'; type ExtendedTarget = Target & { name?: string; @@ -45,7 +46,7 @@ type ExtendedTarget = Target & { export type SnapConfig = { context?: ContextVariables; url?: UrlTranslatorConfig; - client: { + client?: { globals: ClientGlobals; config?: ClientConfig; }; @@ -62,7 +63,7 @@ export type SnapConfig = { }[]; autocomplete?: { config: AutocompleteControllerConfig; - targeters: ExtendedTarget[]; + targeters?: ExtendedTarget[]; services?: SnapControllerServices; url?: UrlTranslatorConfig; context?: ContextVariables; @@ -84,14 +85,48 @@ export type SnapConfig = { }; }; -type ControllerTypes = SearchController | AutocompleteController | FinderController | RecommendationController; -enum DynamicImportNames { - SEARCH = 'searchController', - AUTOCOMPLETE = 'autocompleteController', - FINDER = 'finderController', - RECOMMENDATION = 'recommendationController', -} +type SnapServices = { + client?: Client; + tracker?: Tracker; + logger?: Logger; +}; +type Controllers = SearchController | AutocompleteController | FinderController | RecommendationController; + +const COMPONENT_ERROR = `Uncaught Error - Invalid value passed as the component. +This usually happens when you pass a JSX Element, and not a function that returns the component, in the snap config. + + instead of - + + targeters: [ + { + selector: '#searchspring-content', + hideTarget: true, + component: , + }, + ] + + or - + + targeters: [ + { + selector: '#searchspring-content', + hideTarget: true, + component: Content, + }, + ] + + please try - + + targeters: [ + { + selector: '#searchspring-content', + hideTarget: true, + component: () => Content + }, + ] + +The error above happened in the following targeter in the Snap Config`; export class Snap { config: SnapConfig; logger: Logger; @@ -99,11 +134,11 @@ export class Snap { tracker: Tracker; context: ContextVariables; _controllerPromises: { - [controllerConfigId: string]: Promise; + [controllerConfigId: string]: Promise; }; controllers: { - [controllerConfigId: string]: ControllerTypes; + [controllerConfigId: string]: Controllers; }; _instantiatorPromises: { @@ -114,69 +149,72 @@ export class Snap { return this._instantiatorPromises[id] || Promise.reject(`getInstantiator could not find instantiator with id: ${id}`); }; - public getController = (id: string): Promise => { + public getController = (id: string): Promise => { return this._controllerPromises[id] || Promise.reject(`getController could not find controller with id: ${id}`); }; - public getControllers = (...controllerIds: string[]): Promise => { + public getControllers = (...controllerIds: string[]): Promise => { const getControllerPromises = []; controllerIds.forEach((id) => getControllerPromises.push(this.getController(id))); return Promise.all(getControllerPromises); }; - public createController = ( - type: DynamicImportNames, + public createController = async ( + type: keyof typeof ControllerTypes, config: ControllerConfigs, - services: SnapControllerServices, - urlConfig: UrlTranslatorConfig, - resolve: (value?: ControllerTypes | PromiseLike) => void, - context?: ContextVariables - ): Promise => { + services?: SnapControllerServices, + urlConfig?: UrlTranslatorConfig, + context?: ContextVariables, + callback?: (value?: Controllers | PromiseLike) => void | Promise + ): Promise => { let importPromise; switch (type) { - case DynamicImportNames.SEARCH: + case ControllerTypes.search: importPromise = import('./create/createSearchController'); break; - case DynamicImportNames.AUTOCOMPLETE: + case ControllerTypes.autocomplete: importPromise = import('./create/createAutocompleteController'); break; - case DynamicImportNames.FINDER: + case ControllerTypes.finder: importPromise = import('./create/createFinderController'); break; - case DynamicImportNames.RECOMMENDATION: + case ControllerTypes.recommendation: importPromise = import('./create/createRecommendationController'); break; } - return importPromise.then((_) => { - if (!this.controllers[config.id]) { - this.controllers[config.id] = _.default( - { - url: deepmerge(this.config.url || {}, urlConfig || {}), - controller: config, - context: deepmerge(this.context || {}, context || {}), - }, - { - client: services?.client || this.client, - store: services?.store, - urlManager: services?.urlManager, - eventManager: services?.eventManager, - profiler: services?.profiler, - logger: services?.logger, - tracker: services?.tracker || this.tracker, - } - ); - resolve(this.controllers[config.id]); - } + const creationFunc: (config: SnapControllerConfigs, services: SnapControllerServices) => Controllers = (await importPromise).default; - return this.controllers[config.id]; - }); + if (!this.controllers[config.id]) { + this.controllers[config.id] = creationFunc( + { + url: deepmerge(this.config.url || {}, urlConfig || {}), + controller: config, + context: deepmerge(this.context || {}, context || {}), + }, + { + client: services?.client || this.client, + store: services?.store, + urlManager: services?.urlManager, + eventManager: services?.eventManager, + profiler: services?.profiler, + logger: services?.logger, + tracker: services?.tracker || this.tracker, + } + ); + } + + if (callback) { + await callback(this.controllers[config.id]); + } + + return this.controllers[config.id]; }; - constructor(config: SnapConfig) { + constructor(config: SnapConfig, services?: SnapServices) { this.config = config; - this.logger = new Logger('Snap Preact '); + this.logger = services?.logger || new Logger('Snap Preact '); let globalContext: ContextVariables = {}; try { @@ -191,14 +229,16 @@ export class Snap { isMergeableObject: isPlainObject, }); - this.context = deepmerge(globalContext || {}, this.config.context || {}); + this.context = deepmerge(this.config.context || {}, globalContext || {}, { + isMergeableObject: isPlainObject, + }); - if (!this.config?.client?.globals?.siteId) { + if ((!services?.client || !services?.tracker) && !this.config?.client?.globals?.siteId) { throw new Error(`Snap: config provided must contain a valid config.client.globals.siteId value`); } - this.client = new Client(this.config.client.globals, this.config.client.config); - this.tracker = new Tracker(this.config.client.globals); + this.client = services?.client || new Client(this.config.client.globals, this.config.client.config); + this.tracker = services?.tracker || new Tracker(this.config.client.globals); this._controllerPromises = {}; this._instantiatorPromises = {}; this.controllers = {}; @@ -244,7 +284,7 @@ export class Snap { const branchScript = document.createElement('script'); const src = `${path}${branchParam}/bundle.js`; branchScript.src = src; - branchScript.setAttribute(BRANCH_COOKIE, ''); + branchScript.setAttribute(BRANCH_COOKIE, branchParam); document.head.appendChild(branchScript); new DomTargeter( @@ -255,15 +295,36 @@ export class Snap { action: 'append', // before, after, append, prepend element: () => { const branchContainer = document.createElement('div'); - branchContainer.className = 'ss__branch--target'; + branchContainer.id = 'searchspring-branch-override'; return branchContainer; }, }, }, ], async (target, elem) => { + let bundleDetails, error; + try { + const getBundleDetails = (await import('./getBundleDetails/getBundleDetails')).getBundleDetails; + bundleDetails = await getBundleDetails(src); + } catch (err) { + error = err; + } + const BranchOverride = (await import('./components/BranchOverride')).BranchOverride; - render(, elem); + render( + { + cookies.unset(BRANCH_COOKIE); + const urlState = url(window.location.href); + delete urlState.params.query['branch']; + window.location.href = urlState.url(); + }} + />, + elem + ); } ); @@ -329,7 +390,7 @@ export class Snap { const targetFunction = async (target, elem, originalElem) => { runSearch(); const onTarget = target.onTarget as OnTarget; - onTarget && onTarget(target, elem, originalElem); + onTarget && (await onTarget(target, elem, originalElem)); try { const Component = await (target as ExtendedTarget).component(); @@ -337,48 +398,11 @@ export class Snap { render(, elem); }); } catch (err) { - this.logger.error( - `Uncaught Error - Invalid value passed as the component. - This usually happens when you pass a JSX Element, and not a function that returns the component, in the snap config. - - instead of - - - targeters: [ - { - selector: '#searchspring-content', - hideTarget: true, - component: , - }, - ] - - or - - - targeters: [ - { - selector: '#searchspring-content', - hideTarget: true, - component: Content, - }, - ] - - please try - - - targeters: [ - { - selector: '#searchspring-content', - hideTarget: true, - component: () => Content - }, - ] - - -The error above happened in the following targeter in the Snap Config`, - target - ); + this.logger.error(COMPONENT_ERROR, target); } }; - controller?.targeters?.forEach(async (target, target_index) => { + controller?.targeters?.forEach((target, target_index) => { if (!target.selector) { throw new Error(`Targets at index ${target_index} missing selector value (string).`); } @@ -423,36 +447,43 @@ The error above happened in the following targeter in the Snap Config`, const targetFunction = async (target, elem, originalElem) => { const onTarget = target.onTarget as OnTarget; - onTarget && onTarget(target, elem, originalElem); + onTarget && (await onTarget(target, elem, originalElem)); - const Component = (await (target as ExtendedTarget).component()) as React.ElementType<{ - controller: AutocompleteController; - input: HTMLInputElement | string | Element; - }>; + try { + const Component = (await (target as ExtendedTarget).component()) as React.ElementType<{ + controller: AutocompleteController; + input: HTMLInputElement | string | Element; + }>; - setTimeout(() => { - render(, elem); - }); + setTimeout(() => { + render(, elem); + }); + } catch (err) { + this.logger.error(COMPONENT_ERROR, target); + } }; if (!controller?.targeters || controller?.targeters.length === 0) { this.createController( - DynamicImportNames.AUTOCOMPLETE, + ControllerTypes.autocomplete, controller.config, controller.services, controller.url, - resolve, - controller.context + controller.context, + (cntrlr) => { + resolve(cntrlr); + } ); } - controller?.targeters?.forEach(async (target, target_index) => { + controller?.targeters?.forEach((target, target_index) => { if (!target.selector) { throw new Error(`Targets at index ${target_index} missing selector value (string).`); } if (!target.component) { throw new Error(`Targets at index ${target_index} missing component value (Component).`); } + const targeter = new DomTargeter( [ { @@ -472,12 +503,14 @@ The error above happened in the following targeter in the Snap Config`, ], async (target, elem, originalElem) => { const cntrlr = await this.createController( - DynamicImportNames.AUTOCOMPLETE, + ControllerTypes.autocomplete, controller.config, controller.services, controller.url, - resolve, - controller.context + controller.context, + (cntrlr) => { + resolve(cntrlr); + } ); runBind(); targetFunction({ controller: cntrlr, ...target }, elem, originalElem); @@ -506,27 +539,33 @@ The error above happened in the following targeter in the Snap Config`, }; const targetFunction = async (target, elem, originalElem) => { const onTarget = target.onTarget as OnTarget; - onTarget && onTarget(target, elem, originalElem); + onTarget && (await onTarget(target, elem, originalElem)); - const Component = await (target as ExtendedTarget).component(); + try { + const Component = await (target as ExtendedTarget).component(); - setTimeout(() => { - render(, elem); - }); + setTimeout(() => { + render(, elem); + }); + } catch (err) { + this.logger.error(COMPONENT_ERROR, target); + } }; if (!controller?.targeters || controller?.targeters.length === 0) { this.createController( - DynamicImportNames.FINDER, + ControllerTypes.finder, controller.config, controller.services, controller.url, - resolve, - controller.context + controller.context, + (cntrlr) => { + resolve(cntrlr); + } ); } - controller?.targeters?.forEach(async (target, target_index) => { + controller?.targeters?.forEach((target, target_index) => { if (!target.selector) { throw new Error(`Targets at index ${target_index} missing selector value (string).`); } @@ -535,12 +574,14 @@ The error above happened in the following targeter in the Snap Config`, } const targeter = new DomTargeter([{ ...target }], async (target, elem, originalElem) => { const cntrlr = await this.createController( - DynamicImportNames.FINDER, + ControllerTypes.finder, controller.config, controller.services, controller.url, - resolve, - controller.context + controller.context, + (cntrlr) => { + resolve(cntrlr); + } ); runSearch(); targetFunction({ controller: cntrlr, ...target }, elem, originalElem); @@ -568,27 +609,33 @@ The error above happened in the following targeter in the Snap Config`, }; const targetFunction = async (target, elem, originalElem) => { const onTarget = target.onTarget as OnTarget; - onTarget && onTarget(target, elem, originalElem); + onTarget && (await onTarget(target, elem, originalElem)); - const Component = await (target as ExtendedTarget).component(); + try { + const Component = await (target as ExtendedTarget).component(); - setTimeout(() => { - render(, elem); - }); + setTimeout(() => { + render(, elem); + }); + } catch (err) { + this.logger.error(COMPONENT_ERROR, target); + } }; if (!controller?.targeters || controller?.targeters.length === 0) { this.createController( - DynamicImportNames.RECOMMENDATION, + ControllerTypes.recommendation, controller.config, controller.services, controller.url, - resolve, - controller.context + controller.context, + (cntrlr) => { + resolve(cntrlr); + } ); } - controller?.targeters?.forEach(async (target, target_index) => { + controller?.targeters?.forEach((target, target_index) => { if (!target.selector) { throw new Error(`Targets at index ${target_index} missing selector value (string).`); } @@ -597,12 +644,14 @@ The error above happened in the following targeter in the Snap Config`, } const targeter = new DomTargeter([{ ...target }], async (target, elem, originalElem) => { const cntrlr = await this.createController( - DynamicImportNames.RECOMMENDATION, + ControllerTypes.recommendation, controller.config, controller.services, controller.url, - resolve, - controller.context + controller.context, + (cntrlr) => { + resolve(cntrlr); + } ); runSearch(); targetFunction({ controller: cntrlr, ...target }, elem, originalElem); @@ -621,13 +670,13 @@ The error above happened in the following targeter in the Snap Config`, if (this.config?.instantiators?.recommendation) { try { - this._instantiatorPromises.recommendations = import('./Instantiators/RecommendationInstantiator').then(({ RecommendationInstantiator }) => { + this._instantiatorPromises.recommendation = import('./Instantiators/RecommendationInstantiator').then(({ RecommendationInstantiator }) => { return new RecommendationInstantiator( this.config.instantiators.recommendation, { - client: this.config.instantiators.recommendation?.services?.client || this.client, - tracker: this.config.instantiators.recommendation?.services?.tracker || this.tracker, - logger: this.config.instantiators.recommendation?.services?.logger || this.logger, + client: this.client, + tracker: this.tracker, + logger: this.logger, }, this.context ); diff --git a/packages/snap-preact/src/components/BranchOverride.ts b/packages/snap-preact/src/components/BranchOverride.ts new file mode 100644 index 000000000..7b613a396 --- /dev/null +++ b/packages/snap-preact/src/components/BranchOverride.ts @@ -0,0 +1 @@ +export { BranchOverride } from '@searchspring/snap-preact-components'; diff --git a/packages/snap-preact/src/components/BranchOverride.tsx b/packages/snap-preact/src/components/BranchOverride.tsx deleted file mode 100644 index 3c14d277f..000000000 --- a/packages/snap-preact/src/components/BranchOverride.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { useState, useEffect } from 'preact/hooks'; -import { url, cookies } from '@searchspring/snap-toolbox'; - -type FileHeaderDetails = { - lastModified: string; -}; - -const icons = { - 'close-thin': - 'M56 5.638l-22.362 22.362 22.362 22.362-5.638 5.638-22.362-22.362-22.362 22.362-5.638-5.638 22.362-22.362-22.362-22.362 5.638-5.638 22.362 22.362 22.362-22.362z', - warn: 'M31.2981 5.28228C29.8323 2.74341 26.1677 2.74341 24.7019 5.28228L0.515899 47.1737C-0.94992 49.7126 0.88235 52.8861 3.81399 52.8861H52.186C55.1176 52.8861 56.9499 49.7126 55.4841 47.1737L31.2981 5.28228ZM25.2229 35.0037L24.8264 18.837C24.8264 18.655 24.8923 18.4729 25.047 18.3686C25.1794 18.2387 25.3776 18.1601 25.5759 18.1601H30.4241C30.6223 18.1601 30.8206 18.238 30.953 18.3686C31.1071 18.4729 31.1736 18.655 31.1736 18.837L30.7988 35.0037C30.7988 35.3679 30.4682 35.6542 30.0493 35.6542H25.9724C25.5759 35.6542 25.2453 35.3679 25.2229 35.0037ZM25.1788 43.9593V39.0131C25.1788 38.5447 25.487 38.1541 25.8618 38.1541H30.0929C30.4894 38.1541 30.82 38.5447 30.82 39.0131V43.9593C30.82 44.4277 30.4894 44.8183 30.0929 44.8183H25.8618C25.487 44.8183 25.1788 44.4277 25.1788 43.9593Z', -}; - -const fetchBundleDetails = async (url: string): Promise => { - return new Promise((resolve, reject) => { - const request = new XMLHttpRequest(); - request.open('HEAD', url, true); - - request.onreadystatechange = () => { - if (request.readyState === XMLHttpRequest.DONE) { - const status = request.status; - if (status === 0 || (status >= 200 && status < 400)) { - resolve({ - lastModified: request.getResponseHeader('Last-Modified').split(',')[1], - }); - } else { - reject(); - } - } - }; - - request.onerror = () => reject(); - - request.send(); - }); -}; - -const darkTheme = { - main: { - border: '0', - background: 'rgba(59, 35, 173, 0.9)', - color: '#fff', - boxShadow: '#4c3ce2 1px 1px 3px 0px', - }, - top: { - background: 'rgba(59, 35, 173, 0.3)', - border: '1px solid #4c3de1', - logo: { - src: 'https://snapui.searchspring.io/searchspring_light.svg', - }, - button: { - border: '1px solid #fff', - content: 'STOP PREVIEW', - }, - close: { - fill: '#fff', - }, - }, - details: { - content: 'Preview functionality may differ from production.', - branch: { - color: '#03cee1', - style: 'italic', - }, - additional: { - color: '#03cee1', - }, - }, -}; - -const lightTheme = { - main: { - border: '1px solid #ccc', - background: 'rgba(255, 255, 255, 0.9)', - color: '#515151', - boxShadow: 'rgba(81, 81, 81, 0.5) 1px 1px 3px 0px', - }, - top: { - background: 'rgba(255, 255, 255, 0.3)', - border: '1px solid #ccc', - logo: { - src: 'https://snapui.searchspring.io/searchspring.svg', - }, - button: { - border: '1px solid #515151', - content: 'STOP PREVIEW', - }, - close: { - fill: '#515151', - }, - }, - details: { - content: 'Preview functionality may differ from production.', - branch: { - color: '#3a23ad', - style: 'italic', - }, - additional: { - color: '#3a23ad', - }, - }, -}; - -const failureTheme = { - main: { - border: '0', - background: 'rgba(130, 6, 6, 0.9)', - color: '#fff', - boxShadow: 'rgba(130, 6, 6, 0.4) 1px 1px 3px 0px', - }, - top: { - background: 'rgba(130, 6, 6, 0.3)', - border: '1px solid #760000', - logo: { - src: 'https://snapui.searchspring.io/searchspring_light.svg', - }, - button: { - border: '1px solid #fff', - content: 'REMOVE', - }, - close: { - fill: '#fff', - }, - }, - details: { - content: 'Incorrect branch name or branch no longer exists.', - branch: { - color: '#be9628', - style: 'none', - }, - additional: { - color: '#be9628', - }, - }, -}; - -const themes = { - darkTheme, - lightTheme, - failureTheme, -}; - -export const BranchOverride = (props: { branch: string; cookieName: string; bundleUrl: string }): JSX.Element => { - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; - const [themeName, setThemeName] = useState(prefersDark ? 'darkTheme' : 'lightTheme'); - const [details, setDetails] = useState(null); - useEffect(() => { - async function getDetails() { - try { - const details = await fetchBundleDetails(props.bundleUrl); - setDetails(details); - } catch (err) { - setThemeName('failureTheme'); - setDetails('failure'); - } - } - getDetails(); - }, []); - - return ( - props.branch && - details && ( -
{ - e.preventDefault(); - e.stopPropagation(); - const popup = document.querySelector('.ss__branch-override') as HTMLDivElement; - popup.style.transition = 'height ease 0.2s, right ease 0.5s 0.2s'; - popup.style.right = '-1px'; - popup.style.height = '120px'; - popup.style.cursor = 'auto'; - }} - > -
- - -
{ - e.preventDefault(); - e.stopPropagation(); - const popup = document.querySelector('.ss__branch-override') as HTMLDivElement; - popup.style.transition = 'height ease 0.5s 0.5s, right ease 0.5s'; - popup.style.right = '-316px'; - popup.style.height = '50px'; - popup.style.cursor = 'pointer'; - }} - > - - - -
- -
{ - e.preventDefault(); - e.stopPropagation(); - cookies.unset(props.cookieName); - const urlState = url(window.location.href); - delete urlState.params.query['branch']; - window.location.href = urlState.url(); - }} - > - {themes[themeName].top.button.content} -
-
- -
- - {details == 'failure' ? ( - <> - - - - Branch not found! - - ) : ( - props.branch - )} - - - - {details?.lastModified || props.branch} - -
- {themes[themeName].details.content} -
-
- ) - ); -}; diff --git a/packages/snap-preact/src/create/createAutocompleteController.test.ts b/packages/snap-preact/src/create/createAutocompleteController.test.ts new file mode 100644 index 000000000..0ad9480ea --- /dev/null +++ b/packages/snap-preact/src/create/createAutocompleteController.test.ts @@ -0,0 +1,217 @@ +import { Client } from '@searchspring/snap-client'; +import { AutocompleteStore } from '@searchspring/snap-store-mobx'; +import { UrlManager, UrlTranslator, reactLinker } from '@searchspring/snap-url-manager'; +import { EventManager } from '@searchspring/snap-event-manager'; +import { Profiler } from '@searchspring/snap-profiler'; +import { Logger } from '@searchspring/snap-logger'; +import { Tracker } from '@searchspring/snap-tracker'; + +import { createAutocompleteController } from './index'; + +import type { SnapAutocompleteControllerConfig } from '../types'; +import type { UrlTranslatorConfig } from '@searchspring/snap-url-manager'; + +const createConfig: SnapAutocompleteControllerConfig = { + client: { + globals: { + siteId: '8uyt2m', + }, + config: { + meta: { + cache: { + purgeable: false, + }, + }, + }, + }, + controller: { + id: 'ac', + selector: '.inputelem', + }, + context: { + shopper: { + id: 'snapdev', + }, + custom: { + testing: true, + }, + }, +}; + +describe('createAutocompleteController', () => { + beforeEach(() => { + delete window.searchspring; + }); + + it('throws when incomplete configuration is used', () => { + expect(() => { + // @ts-ignore - testing invalid config passed + const controller = createAutocompleteController({}); + }).toThrow(); + + expect(() => { + const bareConfig = { + controller: { + id: 'ac', + }, + }; + + // @ts-ignore - testing invalid config passed + const controller = createAutocompleteController(bareConfig); + }).toThrow(); + + expect(() => { + const bareConfig = { + client: { + globals: { + siteId: 'xxxxxx', + }, + }, + }; + + // @ts-ignore - testing invalid config passed + const controller = createAutocompleteController(bareConfig); + }).toThrow(); + }); + + it('creates an autocomplete controller', () => { + const controller = createAutocompleteController(createConfig); + + expect(controller).toBeDefined(); + expect(controller.id).toBe(createConfig.controller.id); + expect(controller.context).toBe(createConfig.context); + expect(controller.config.selector).toBe(createConfig.controller.selector); + + // services + expect(controller.client).toBeDefined(); + expect(controller.store).toBeDefined(); + expect(controller.urlManager).toBeDefined(); + expect(controller.eventManager).toBeDefined(); + expect(controller.profiler).toBeDefined(); + expect(controller.log).toBeDefined(); + expect(controller.tracker).toBeDefined(); + + // other + expect(controller.urlManager.detached).toBeDefined(); + expect(controller.client.globals.siteId).toBe(createConfig.client.globals.siteId); + expect(controller.client.config.meta.cache.purgeable).toBe(createConfig.client.config.meta.cache.purgeable); + expect(controller.tracker.globals.siteId).toBe(createConfig.client.globals.siteId); + }); + + it('creates an autocomplete controller with custom UrlTranslator config', () => { + const customUrlConfig = { + ...createConfig, + url: { + settings: { + coreType: 'query', + customType: 'query', + }, + parameters: { + core: { + query: { name: 'query', type: 'query' }, + page: { name: 'p', type: 'query' }, + }, + }, + }, + }; + const controller = createAutocompleteController(customUrlConfig as SnapAutocompleteControllerConfig); + + expect(controller).toBeDefined(); + expect(controller.urlManager).toBeDefined(); + + const translatorConfig = controller.urlManager.getTranslatorConfig() as UrlTranslatorConfig; + // check for custom settings + for (const [key, value] of Object.entries(customUrlConfig.url.settings)) { + expect(translatorConfig.settings[key]).toBe(value); + } + // check for custom parameter configuration + for (const [key, value] of Object.entries(customUrlConfig.url.parameters.core)) { + expect(translatorConfig.parameters.core[key]).toStrictEqual(value); + } + }); + + describe('custom services', () => { + it('creates an autocomplete controller with custom Client service', () => { + const clientConfig = { siteId: 'custom' }; + const customClient = new Client(clientConfig); + + const controller = createAutocompleteController(createConfig, { client: customClient }); + + expect(controller).toBeDefined(); + expect(controller.client).toBe(customClient); + expect(controller.client.globals.siteId).toBe(clientConfig.siteId); + }); + + it('creates an autocomplete controller with custom Store service', () => { + const storeConfig = { + ...createConfig.controller, + settings: { + facets: { + pinFiltered: false, + }, + }, + }; + const customUrlManager = new UrlManager(new UrlTranslator(), reactLinker); + const customStore = new AutocompleteStore(storeConfig, { urlManager: customUrlManager }); + + const controller = createAutocompleteController(createConfig, { store: customStore }); + + expect(controller).toBeDefined(); + expect(controller.store).toBe(customStore); + }); + + it('creates an autocomplete controller with custom UrlManager service', () => { + const customTranslatorConfig = { + settings: { + coreType: 'hash', + }, + } as UrlTranslatorConfig; + const customUrlManager = new UrlManager(new UrlTranslator(customTranslatorConfig), reactLinker); + const controller = createAutocompleteController(createConfig, { urlManager: customUrlManager }); + + expect(controller).toBeDefined(); + expect(controller.urlManager.detached).toBeDefined(); + + const translatorConfig = controller.urlManager.getTranslatorConfig() as UrlTranslatorConfig; + expect(translatorConfig.settings.coreType).toBe(customTranslatorConfig.settings.coreType); + }); + + it('creates an autocomplete controller with custom EventManager service', () => { + const customEventManager = new EventManager(); + + const controller = createAutocompleteController(createConfig, { eventManager: customEventManager }); + + expect(controller).toBeDefined(); + expect(controller.eventManager).toBe(customEventManager); + }); + + it('creates an autocomplete controller with custom Profiler service', () => { + const customProfiler = new Profiler('customProfiler'); + + const controller = createAutocompleteController(createConfig, { profiler: customProfiler }); + + expect(controller).toBeDefined(); + expect(controller.profiler).toBe(customProfiler); + expect(controller.profiler.namespace).toBe('customProfiler'); + }); + + it('creates an autocomplete controller with custom Logger service', () => { + const customLogger = new Logger('customLogger'); + + const controller = createAutocompleteController(createConfig, { logger: customLogger }); + + expect(controller).toBeDefined(); + expect(controller.log).toBe(customLogger); + }); + + it('creates an autocomplete controller with custom Tracker service', () => { + const customTracker = new Tracker({ siteId: 'custom' }); + + const controller = createAutocompleteController(createConfig, { tracker: customTracker }); + + expect(controller).toBeDefined(); + expect(controller.tracker).toBe(customTracker); + expect(controller.tracker.globals.siteId).toBe('custom'); + }); + }); +}); diff --git a/packages/snap-preact/src/create/createFinderController.test.ts b/packages/snap-preact/src/create/createFinderController.test.ts new file mode 100644 index 000000000..2dca590f7 --- /dev/null +++ b/packages/snap-preact/src/create/createFinderController.test.ts @@ -0,0 +1,232 @@ +import { Client } from '@searchspring/snap-client'; +import { FinderStore } from '@searchspring/snap-store-mobx'; +import { UrlManager, UrlTranslator, reactLinker } from '@searchspring/snap-url-manager'; +import { EventManager } from '@searchspring/snap-event-manager'; +import { Profiler } from '@searchspring/snap-profiler'; +import { Logger } from '@searchspring/snap-logger'; +import { Tracker } from '@searchspring/snap-tracker'; + +import { createFinderController } from './index'; + +import type { SnapFinderControllerConfig } from '../types'; +import type { UrlTranslatorConfig } from '@searchspring/snap-url-manager'; + +const createConfig: SnapFinderControllerConfig = { + client: { + globals: { + siteId: '8uyt2m', + }, + config: { + meta: { + cache: { + purgeable: false, + }, + }, + }, + }, + controller: { + id: 'finder', + url: '/', + fields: [ + { + field: 'size_footwear', + label: 'Size', + }, + { + field: 'color_family', + label: 'Color', + }, + { + field: 'brand', + label: 'Brand', + }, + ], + }, + context: { + shopper: { + id: 'snapdev', + }, + custom: { + testing: true, + }, + }, +}; + +describe('createFinderController', () => { + beforeEach(() => { + delete window.searchspring; + }); + + it('throws when incomplete configuration is used', () => { + expect(() => { + // @ts-ignore - testing invalid config passed + const controller = createFinderController({}); + }).toThrow(); + + expect(() => { + const bareConfig = { + controller: { + id: 'ac', + }, + }; + + // @ts-ignore - testing invalid config passed + const controller = createFinderController(bareConfig); + }).toThrow(); + + expect(() => { + const bareConfig = { + client: { + globals: { + siteId: 'xxxxxx', + }, + }, + }; + + // @ts-ignore - testing invalid config passed + const controller = createFinderController(bareConfig); + }).toThrow(); + }); + + it('creates an finder controller', () => { + const controller = createFinderController(createConfig); + + expect(controller).toBeDefined(); + expect(controller.id).toBe(createConfig.controller.id); + expect(controller.context).toBe(createConfig.context); + expect(controller.config.url).toBe(createConfig.controller.url); + expect(controller.config.fields).toStrictEqual(createConfig.controller.fields); + + // services + expect(controller.client).toBeDefined(); + expect(controller.store).toBeDefined(); + expect(controller.urlManager).toBeDefined(); + expect(controller.eventManager).toBeDefined(); + expect(controller.profiler).toBeDefined(); + expect(controller.log).toBeDefined(); + expect(controller.tracker).toBeDefined(); + + // other + expect(controller.urlManager.detached).toBeDefined(); + expect(controller.client.globals.siteId).toBe(createConfig.client.globals.siteId); + expect(controller.client.config.meta.cache.purgeable).toBe(createConfig.client.config.meta.cache.purgeable); + expect(controller.tracker.globals.siteId).toBe(createConfig.client.globals.siteId); + }); + + it('creates an finder controller with custom UrlTranslator config', () => { + const customUrlConfig = { + ...createConfig, + url: { + settings: { + coreType: 'query', + customType: 'query', + }, + parameters: { + core: { + query: { name: 'query', type: 'query' }, + page: { name: 'p', type: 'query' }, + }, + }, + }, + }; + const controller = createFinderController(customUrlConfig as SnapFinderControllerConfig); + + expect(controller).toBeDefined(); + expect(controller.urlManager).toBeDefined(); + + const translatorConfig = controller.urlManager.getTranslatorConfig() as UrlTranslatorConfig; + // check for custom settings + for (const [key, value] of Object.entries(customUrlConfig.url.settings)) { + expect(translatorConfig.settings[key]).toBe(value); + } + // check for custom parameter configuration + for (const [key, value] of Object.entries(customUrlConfig.url.parameters.core)) { + expect(translatorConfig.parameters.core[key]).toStrictEqual(value); + } + }); + + describe('custom services', () => { + it('creates an finder controller with custom Client service', () => { + const clientConfig = { siteId: 'custom' }; + const customClient = new Client(clientConfig); + + const controller = createFinderController(createConfig, { client: customClient }); + + expect(controller).toBeDefined(); + expect(controller.client).toBe(customClient); + expect(controller.client.globals.siteId).toBe(clientConfig.siteId); + }); + + it('creates an finder controller with custom Store service', () => { + const storeConfig = { + ...createConfig.controller, + settings: { + facets: { + pinFiltered: false, + }, + }, + }; + const customUrlManager = new UrlManager(new UrlTranslator(), reactLinker); + const customStore = new FinderStore(storeConfig, { urlManager: customUrlManager }); + + const controller = createFinderController(createConfig, { store: customStore }); + + expect(controller).toBeDefined(); + expect(controller.store).toBe(customStore); + }); + + it('creates an finder controller with custom UrlManager service', () => { + const customTranslatorConfig = { + settings: { + coreType: 'hash', + }, + } as UrlTranslatorConfig; + const customUrlManager = new UrlManager(new UrlTranslator(customTranslatorConfig), reactLinker); + const controller = createFinderController(createConfig, { urlManager: customUrlManager }); + + expect(controller).toBeDefined(); + expect(controller.urlManager.detached).toBeDefined(); + + const translatorConfig = controller.urlManager.getTranslatorConfig() as UrlTranslatorConfig; + expect(translatorConfig.settings.coreType).toBe(customTranslatorConfig.settings.coreType); + }); + + it('creates an finder controller with custom EventManager service', () => { + const customEventManager = new EventManager(); + + const controller = createFinderController(createConfig, { eventManager: customEventManager }); + + expect(controller).toBeDefined(); + expect(controller.eventManager).toBe(customEventManager); + }); + + it('creates an finder controller with custom Profiler service', () => { + const customProfiler = new Profiler('customProfiler'); + + const controller = createFinderController(createConfig, { profiler: customProfiler }); + + expect(controller).toBeDefined(); + expect(controller.profiler).toBe(customProfiler); + expect(controller.profiler.namespace).toBe('customProfiler'); + }); + + it('creates an finder controller with custom Logger service', () => { + const customLogger = new Logger('customLogger'); + + const controller = createFinderController(createConfig, { logger: customLogger }); + + expect(controller).toBeDefined(); + expect(controller.log).toBe(customLogger); + }); + + it('creates an finder controller with custom Tracker service', () => { + const customTracker = new Tracker({ siteId: 'custom' }); + + const controller = createFinderController(createConfig, { tracker: customTracker }); + + expect(controller).toBeDefined(); + expect(controller.tracker).toBe(customTracker); + expect(controller.tracker.globals.siteId).toBe('custom'); + }); + }); +}); diff --git a/packages/snap-preact/src/create/createRecommendationController.test.ts b/packages/snap-preact/src/create/createRecommendationController.test.ts new file mode 100644 index 000000000..a5ddc0039 --- /dev/null +++ b/packages/snap-preact/src/create/createRecommendationController.test.ts @@ -0,0 +1,217 @@ +import { Client } from '@searchspring/snap-client'; +import { RecommendationStore } from '@searchspring/snap-store-mobx'; +import { UrlManager, UrlTranslator, reactLinker } from '@searchspring/snap-url-manager'; +import { EventManager } from '@searchspring/snap-event-manager'; +import { Profiler } from '@searchspring/snap-profiler'; +import { Logger } from '@searchspring/snap-logger'; +import { Tracker } from '@searchspring/snap-tracker'; + +import { createRecommendationController } from './index'; + +import type { SnapRecommendationControllerConfig } from '../types'; +import type { UrlTranslatorConfig } from '@searchspring/snap-url-manager'; + +const createConfig: SnapRecommendationControllerConfig = { + client: { + globals: { + siteId: '8uyt2m', + }, + config: { + meta: { + cache: { + purgeable: false, + }, + }, + }, + }, + controller: { + id: 'recommendation', + tag: 'profile', + }, + context: { + shopper: { + id: 'snapdev', + }, + custom: { + testing: true, + }, + }, +}; + +describe('createRecommendationController', () => { + beforeEach(() => { + delete window.searchspring; + }); + + it('throws when incomplete configuration is used', () => { + expect(() => { + // @ts-ignore - testing invalid config passed + const controller = createRecommendationController({}); + }).toThrow(); + + expect(() => { + const bareConfig = { + controller: { + id: 'ac', + }, + }; + + // @ts-ignore - testing invalid config passed + const controller = createRecommendationController(bareConfig); + }).toThrow(); + + expect(() => { + const bareConfig = { + client: { + globals: { + siteId: 'xxxxxx', + }, + }, + }; + + // @ts-ignore - testing invalid config passed + const controller = createRecommendationController(bareConfig); + }).toThrow(); + }); + + it('creates an recommendation controller', () => { + const controller = createRecommendationController(createConfig); + + expect(controller).toBeDefined(); + expect(controller.id).toBe(createConfig.controller.id); + expect(controller.context).toBe(createConfig.context); + expect(controller.config.tag).toBe(createConfig.controller.tag); + + // services + expect(controller.client).toBeDefined(); + expect(controller.store).toBeDefined(); + expect(controller.urlManager).toBeDefined(); + expect(controller.eventManager).toBeDefined(); + expect(controller.profiler).toBeDefined(); + expect(controller.log).toBeDefined(); + expect(controller.tracker).toBeDefined(); + + // other + expect(controller.urlManager.detached).toBeDefined(); + expect(controller.client.globals.siteId).toBe(createConfig.client.globals.siteId); + expect(controller.client.config.meta.cache.purgeable).toBe(createConfig.client.config.meta.cache.purgeable); + expect(controller.tracker.globals.siteId).toBe(createConfig.client.globals.siteId); + }); + + it('creates an recommendation controller with custom UrlTranslator config', () => { + const customUrlConfig = { + ...createConfig, + url: { + settings: { + coreType: 'query', + customType: 'query', + }, + parameters: { + core: { + query: { name: 'query', type: 'query' }, + page: { name: 'p', type: 'query' }, + }, + }, + }, + }; + const controller = createRecommendationController(customUrlConfig as SnapRecommendationControllerConfig); + + expect(controller).toBeDefined(); + expect(controller.urlManager).toBeDefined(); + + const translatorConfig = controller.urlManager.getTranslatorConfig() as UrlTranslatorConfig; + // check for custom settings + for (const [key, value] of Object.entries(customUrlConfig.url.settings)) { + expect(translatorConfig.settings[key]).toBe(value); + } + // check for custom parameter configuration + for (const [key, value] of Object.entries(customUrlConfig.url.parameters.core)) { + expect(translatorConfig.parameters.core[key]).toStrictEqual(value); + } + }); + + describe('custom services', () => { + it('creates an recommendation controller with custom Client service', () => { + const clientConfig = { siteId: 'custom' }; + const customClient = new Client(clientConfig); + + const controller = createRecommendationController(createConfig, { client: customClient }); + + expect(controller).toBeDefined(); + expect(controller.client).toBe(customClient); + expect(controller.client.globals.siteId).toBe(clientConfig.siteId); + }); + + it('creates an recommendation controller with custom Store service', () => { + const storeConfig = { + ...createConfig.controller, + settings: { + facets: { + pinFiltered: false, + }, + }, + }; + const customUrlManager = new UrlManager(new UrlTranslator(), reactLinker); + const customStore = new RecommendationStore(storeConfig, { urlManager: customUrlManager }); + + const controller = createRecommendationController(createConfig, { store: customStore }); + + expect(controller).toBeDefined(); + expect(controller.store).toBe(customStore); + }); + + it('creates an recommendation controller with custom UrlManager service', () => { + const customTranslatorConfig = { + settings: { + coreType: 'hash', + }, + } as UrlTranslatorConfig; + const customUrlManager = new UrlManager(new UrlTranslator(customTranslatorConfig), reactLinker); + const controller = createRecommendationController(createConfig, { urlManager: customUrlManager }); + + expect(controller).toBeDefined(); + expect(controller.urlManager.detached).toBeDefined(); + + const translatorConfig = controller.urlManager.getTranslatorConfig() as UrlTranslatorConfig; + expect(translatorConfig.settings.coreType).toBe(customTranslatorConfig.settings.coreType); + }); + + it('creates an recommendation controller with custom EventManager service', () => { + const customEventManager = new EventManager(); + + const controller = createRecommendationController(createConfig, { eventManager: customEventManager }); + + expect(controller).toBeDefined(); + expect(controller.eventManager).toBe(customEventManager); + }); + + it('creates an recommendation controller with custom Profiler service', () => { + const customProfiler = new Profiler('customProfiler'); + + const controller = createRecommendationController(createConfig, { profiler: customProfiler }); + + expect(controller).toBeDefined(); + expect(controller.profiler).toBe(customProfiler); + expect(controller.profiler.namespace).toBe('customProfiler'); + }); + + it('creates an recommendation controller with custom Logger service', () => { + const customLogger = new Logger('customLogger'); + + const controller = createRecommendationController(createConfig, { logger: customLogger }); + + expect(controller).toBeDefined(); + expect(controller.log).toBe(customLogger); + }); + + it('creates an recommendation controller with custom Tracker service', () => { + const customTracker = new Tracker({ siteId: 'custom' }); + + const controller = createRecommendationController(createConfig, { tracker: customTracker }); + + expect(controller).toBeDefined(); + expect(controller.tracker).toBe(customTracker); + expect(controller.tracker.globals.siteId).toBe('custom'); + }); + }); +}); diff --git a/packages/snap-preact/src/create/createSearchController.test.ts b/packages/snap-preact/src/create/createSearchController.test.ts new file mode 100644 index 000000000..1cc061adb --- /dev/null +++ b/packages/snap-preact/src/create/createSearchController.test.ts @@ -0,0 +1,216 @@ +import { Client } from '@searchspring/snap-client'; +import { SearchStore } from '@searchspring/snap-store-mobx'; +import { UrlManager, UrlTranslator, reactLinker } from '@searchspring/snap-url-manager'; +import { EventManager } from '@searchspring/snap-event-manager'; +import { Profiler } from '@searchspring/snap-profiler'; +import { Logger } from '@searchspring/snap-logger'; +import { Tracker } from '@searchspring/snap-tracker'; + +import { createSearchController } from './index'; + +import type { SnapSearchControllerConfig } from '../types'; +import type { UrlTranslatorConfig } from '@searchspring/snap-url-manager'; + +const createConfig: SnapSearchControllerConfig = { + client: { + globals: { + siteId: '8uyt2m', + }, + config: { + meta: { + cache: { + purgeable: false, + }, + }, + }, + }, + controller: { + id: 'search', + }, + context: { + shopper: { + id: 'snapdev', + }, + custom: { + testing: true, + }, + }, +}; + +describe('createSearchController', () => { + beforeEach(() => { + delete window.searchspring; + }); + + it('throws when incomplete configuration is used', () => { + expect(() => { + // @ts-ignore - testing invalid config passed + const controller = createSearchController({}); + }).toThrow(); + + expect(() => { + const bareConfig = { + controller: { + id: 'ac', + }, + }; + + // @ts-ignore - testing invalid config passed + const controller = createSearchController(bareConfig); + }).toThrow(); + + expect(() => { + const bareConfig = { + client: { + globals: { + siteId: 'xxxxxx', + }, + }, + }; + + // @ts-ignore - testing invalid config passed + const controller = createSearchController(bareConfig); + }).toThrow(); + }); + + it('creates an search controller', () => { + const controller = createSearchController(createConfig); + + expect(controller).toBeDefined(); + expect(controller.id).toBe(createConfig.controller.id); + expect(controller.context).toBe(createConfig.context); + expect(controller.config.tag).toBe(createConfig.controller.tag); + + // services + expect(controller.client).toBeDefined(); + expect(controller.store).toBeDefined(); + expect(controller.urlManager).toBeDefined(); + expect(controller.eventManager).toBeDefined(); + expect(controller.profiler).toBeDefined(); + expect(controller.log).toBeDefined(); + expect(controller.tracker).toBeDefined(); + + // other + expect(controller.urlManager.detached).not.toBeDefined(); + expect(controller.client.globals.siteId).toBe(createConfig.client.globals.siteId); + expect(controller.client.config.meta.cache.purgeable).toBe(createConfig.client.config.meta.cache.purgeable); + expect(controller.tracker.globals.siteId).toBe(createConfig.client.globals.siteId); + }); + + it('creates an search controller with custom UrlTranslator config', () => { + const customUrlConfig = { + ...createConfig, + url: { + settings: { + coreType: 'query', + customType: 'query', + }, + parameters: { + core: { + query: { name: 'query', type: 'query' }, + page: { name: 'p', type: 'query' }, + }, + }, + }, + }; + const controller = createSearchController(customUrlConfig as SnapSearchControllerConfig); + + expect(controller).toBeDefined(); + expect(controller.urlManager).toBeDefined(); + + const translatorConfig = controller.urlManager.getTranslatorConfig() as UrlTranslatorConfig; + // check for custom settings + for (const [key, value] of Object.entries(customUrlConfig.url.settings)) { + expect(translatorConfig.settings[key]).toBe(value); + } + // check for custom parameter configuration + for (const [key, value] of Object.entries(customUrlConfig.url.parameters.core)) { + expect(translatorConfig.parameters.core[key]).toStrictEqual(value); + } + }); + + describe('custom services', () => { + it('creates an search controller with custom Client service', () => { + const clientConfig = { siteId: 'custom' }; + const customClient = new Client(clientConfig); + + const controller = createSearchController(createConfig, { client: customClient }); + + expect(controller).toBeDefined(); + expect(controller.client).toBe(customClient); + expect(controller.client.globals.siteId).toBe(clientConfig.siteId); + }); + + it('creates an search controller with custom Store service', () => { + const storeConfig = { + ...createConfig.controller, + settings: { + facets: { + pinFiltered: false, + }, + }, + }; + const customUrlManager = new UrlManager(new UrlTranslator(), reactLinker); + const customStore = new SearchStore(storeConfig, { urlManager: customUrlManager }); + + const controller = createSearchController(createConfig, { store: customStore }); + + expect(controller).toBeDefined(); + expect(controller.store).toBe(customStore); + }); + + it('creates an search controller with custom UrlManager service', () => { + const customTranslatorConfig = { + settings: { + coreType: 'hash', + }, + } as UrlTranslatorConfig; + const customUrlManager = new UrlManager(new UrlTranslator(customTranslatorConfig), reactLinker); + const controller = createSearchController(createConfig, { urlManager: customUrlManager }); + + expect(controller).toBeDefined(); + expect(controller.urlManager.detached).not.toBeDefined(); + + const translatorConfig = controller.urlManager.getTranslatorConfig() as UrlTranslatorConfig; + expect(translatorConfig.settings.coreType).toBe(customTranslatorConfig.settings.coreType); + }); + + it('creates an search controller with custom EventManager service', () => { + const customEventManager = new EventManager(); + + const controller = createSearchController(createConfig, { eventManager: customEventManager }); + + expect(controller).toBeDefined(); + expect(controller.eventManager).toBe(customEventManager); + }); + + it('creates an search controller with custom Profiler service', () => { + const customProfiler = new Profiler('customProfiler'); + + const controller = createSearchController(createConfig, { profiler: customProfiler }); + + expect(controller).toBeDefined(); + expect(controller.profiler).toBe(customProfiler); + expect(controller.profiler.namespace).toBe('customProfiler'); + }); + + it('creates an search controller with custom Logger service', () => { + const customLogger = new Logger('customLogger'); + + const controller = createSearchController(createConfig, { logger: customLogger }); + + expect(controller).toBeDefined(); + expect(controller.log).toBe(customLogger); + }); + + it('creates an search controller with custom Tracker service', () => { + const customTracker = new Tracker({ siteId: 'custom' }); + + const controller = createSearchController(createConfig, { tracker: customTracker }); + + expect(controller).toBeDefined(); + expect(controller.tracker).toBe(customTracker); + expect(controller.tracker.globals.siteId).toBe('custom'); + }); + }); +}); diff --git a/packages/snap-preact/src/create/index.ts b/packages/snap-preact/src/create/index.ts index ea9c9f714..abb054811 100644 --- a/packages/snap-preact/src/create/index.ts +++ b/packages/snap-preact/src/create/index.ts @@ -1,4 +1,4 @@ export { default as createAutocompleteController } from './createAutocompleteController'; export { default as createFinderController } from './createFinderController'; -export { default as createRecommendationsController } from './createRecommendationController'; +export { default as createRecommendationController } from './createRecommendationController'; export { default as createSearchController } from './createSearchController'; diff --git a/packages/snap-preact/src/getBundleDetails/getBundleDetails.test.ts b/packages/snap-preact/src/getBundleDetails/getBundleDetails.test.ts new file mode 100644 index 000000000..7a3890f43 --- /dev/null +++ b/packages/snap-preact/src/getBundleDetails/getBundleDetails.test.ts @@ -0,0 +1,55 @@ +import { getBundleDetails } from './getBundleDetails'; + +const xhrMock: Partial = { + DONE: 4, + open: jest.fn(), + send: jest.fn(), + setRequestHeader: jest.fn(), + status: 200, + readyState: 4, +}; + +jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock as XMLHttpRequest); + +describe('getBundleDetails function', () => { + beforeAll(() => { + const modifiedDate = '07 Jan 2022 22:42:39 GMT'; + xhrMock.getResponseHeader = jest.fn(() => { + // return "Last-Modified" date + return `Fri, ${modifiedDate}`; + }); + }); + + afterAll(() => jest.clearAllMocks); + + it('fetches bundle details from requested bundle URL', () => { + const url = 'https://snapui.searchspring.io/siteId/next/bundle.js'; + + const fetchPromise = getBundleDetails(url).then((details) => { + expect(details.lastModified).toBe('07 Jan 2022 22:42:39 GMT'); + expect(details.url).toBe(url); + }); + + expect(xhrMock.open).toBeCalledWith('HEAD', url, true); + (xhrMock.onreadystatechange as EventListener)(new Event('')); + + return fetchPromise; + }); + + it('rejects when bundle is not found', async () => { + const url = 'https://snapui.searchspring.io/siteId/dne/bundle.js'; + // @ts-ignore + xhrMock.status = 403; + + const fetchPromise = getBundleDetails(url); + + expect(xhrMock.open).toBeCalledWith('HEAD', url, true); + (xhrMock.onreadystatechange as EventListener)(new Event('')); + + try { + await fetchPromise; + } catch (e) { + expect(e).toStrictEqual({ description: 'Incorrect branch name or branch no longer exists.', message: 'Branch not found!' }); + } + }); +}); diff --git a/packages/snap-preact/src/getBundleDetails/getBundleDetails.ts b/packages/snap-preact/src/getBundleDetails/getBundleDetails.ts new file mode 100644 index 000000000..4014aae73 --- /dev/null +++ b/packages/snap-preact/src/getBundleDetails/getBundleDetails.ts @@ -0,0 +1,29 @@ +type BundleDetails = { + url: string; + lastModified: string; +}; + +export const getBundleDetails = async (url: string): Promise => { + return new Promise((resolve, reject) => { + const request = new XMLHttpRequest(); + request.open('HEAD', url, true); + + request.onreadystatechange = () => { + if (request.readyState === request.DONE) { + const status = request.status; + if (status === 0 || (status >= 200 && status < 400)) { + resolve({ + url, + lastModified: request.getResponseHeader('Last-Modified').split(',')[1].trim(), + }); + } else { + reject({ message: 'Branch not found!', description: 'Incorrect branch name or branch no longer exists.' }); + } + } + }; + + request.onerror = () => reject({ message: 'Branch load fail!', description: 'There was an error with the request.' }); + + request.send(); + }); +}; diff --git a/packages/snap-preact/src/integration.test.tsx b/packages/snap-preact/src/integration.test.tsx new file mode 100644 index 000000000..718c0b25c --- /dev/null +++ b/packages/snap-preact/src/integration.test.tsx @@ -0,0 +1,126 @@ +import { h } from 'preact'; + +import '@testing-library/jest-dom/extend-expect'; +import { waitFor } from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; + +import { Snap, BRANCH_COOKIE } from './Snap'; + +const baseConfig = { + client: { + globals: { + siteId: 'xxxxxx', + }, + }, +}; +const context = { + config: {}, + shopper: { + id: 'snapdev', + }, +}; + +const xhrMock: Partial = { + DONE: 4, + open: jest.fn(), + send: jest.fn(), + setRequestHeader: jest.fn(), + status: 200, + readyState: 4, +}; + +const modifiedDate = '07 Jan 2022 22:42:39 GMT'; + +jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock as XMLHttpRequest); + +describe('Snap Preact Integration', () => { + beforeAll(() => { + xhrMock.getResponseHeader = jest.fn(() => { + // return "Last-Modified" date + return `Fri, ${modifiedDate}`; + }); + + delete window.location; + + // @ts-ignore + window.location = { + href: 'https://www.merch.com?branch=branch', + }; + }); + + beforeEach(() => { + const contextString = `config = ${JSON.stringify(context.config)}; shopper = ${JSON.stringify(context.shopper)};`; + document.body.innerHTML = ``; + }); + + afterAll(() => jest.clearAllMocks); + + it(`automatically grabs context from #searchspring-context using 'getContext'`, () => { + const snap = new Snap(baseConfig); + + expect(snap.context).toStrictEqual(context); + }); + + it(`merges context from #searchspring-context with context in the config and the context takes priority`, () => { + const contextConfig = { + ...baseConfig, + context: { + shopper: { + id: 'snapper', + }, + category: 'something', + }, + }; + const snap = new Snap(contextConfig); + + expect(snap.context).toStrictEqual({ ...context, category: 'something' }); + }); + + it(`can use the 'config' in a script context to set context`, () => { + const config = { + context: { + category: 'something', + }, + }; + + const contextString = `config = ${JSON.stringify(config)}; shopper = ${JSON.stringify(context.shopper)};`; + document.body.innerHTML = ``; + + const snap = new Snap(baseConfig); + + expect(snap.context).toStrictEqual({ ...context, config: config, category: 'something' }); + }); + + it(`can use the 'config' in a script context to set siteId`, () => { + const config = { + client: { + globals: { + siteId: 'yyyyyy', + }, + }, + }; + + const contextString = `config = ${JSON.stringify(config)}; shopper = ${JSON.stringify(context.shopper)};`; + document.body.innerHTML = ``; + + const snap = new Snap(baseConfig); + + expect(snap.context).toStrictEqual({ ...context, config: config }); + // @ts-ignore - verifying globals using context set siteId + expect(snap.client.globals.siteId).toBe(config.client.globals.siteId); + }); + + it(`takes the branch param from the URL and add a new script block`, async () => { + const url = 'https://snapui.searchspring.io/xxxxxx/branch/bundle.js'; + + // handle mock XHR of bundle file + expect(xhrMock.open).toBeCalledWith('HEAD', url, true); + + // wait for rendering of new script block + await waitFor(() => { + const overrideElement = document.querySelector(`script[${BRANCH_COOKIE}]`); + expect(overrideElement).toBeInTheDocument(); + expect(overrideElement).toHaveAttribute('src', url); + }); + }); +}); diff --git a/packages/snap-preact/src/types.ts b/packages/snap-preact/src/types.ts index 277df3e08..9cf69158a 100644 --- a/packages/snap-preact/src/types.ts +++ b/packages/snap-preact/src/types.ts @@ -24,6 +24,12 @@ export type SnapControllerServices = { tracker?: Tracker; }; +export type SnapControllerConfigs = + | SnapSearchControllerConfig + | SnapAutocompleteControllerConfig + | SnapFinderControllerConfig + | SnapRecommendationControllerConfig; + export type RootComponent = React.ElementType<{ controller: AbstractController }>; export type SnapSearchControllerConfig = { diff --git a/packages/snap-shared/src/MockClient/MockClient.ts b/packages/snap-shared/src/MockClient/MockClient.ts index 95de7ff7f..491bc2e08 100644 --- a/packages/snap-shared/src/MockClient/MockClient.ts +++ b/packages/snap-shared/src/MockClient/MockClient.ts @@ -36,6 +36,10 @@ export class MockClient extends Client { return Promise.all([this.meta() as MetaResponseModel, autocompleteData as AutocompleteResponseModel]); } + async recommend() { + return this.mockData.recommend(); + } + async trending(): Promise { return this.mockData.trending(); } diff --git a/packages/snap-shared/src/MockData/recommend/profile/8uyt2m/default.json b/packages/snap-shared/src/MockData/recommend/profile/8uyt2m/default.json index 1bf594151..874ace59a 100644 --- a/packages/snap-shared/src/MockData/recommend/profile/8uyt2m/default.json +++ b/packages/snap-shared/src/MockData/recommend/profile/8uyt2m/default.json @@ -1,21 +1,17 @@ { "profile": { "tag": "trending", - "placement": "home-page", + "placement": "product-page", "display": { "threshold": 4, "template": { - "name": "preactTest", - "uuid": "34fdf15e-7ee1-447f-94c5-ea91086062e1", - "markup": "RecsHorizontalComponent", - "styles": ".preact-header {\r\n color: blue;\r\n}" + "name": "smc__production", + "uuid": "aefcf718-8514-44c3-bff6-80c15dbc42fc", + "component": "Default", + "branch": "production", + "group": "smc" }, - "templateParameters": { - "headerText": "Preact Recommended Products", - "count": 12, - "component": "Recs2", - "extraVar": "" - } + "templateParameters": { "headerText": "Trending Products" } } } -} \ No newline at end of file +} diff --git a/packages/snap-shared/src/MockData/recommend/profile/8uyt2m/missingComponent.json b/packages/snap-shared/src/MockData/recommend/profile/8uyt2m/missingComponent.json new file mode 100644 index 000000000..bfc8711f0 --- /dev/null +++ b/packages/snap-shared/src/MockData/recommend/profile/8uyt2m/missingComponent.json @@ -0,0 +1,16 @@ +{ + "profile": { + "tag": "trending", + "placement": "product-page", + "display": { + "threshold": 4, + "template": { + "name": "smc__production", + "uuid": "aefcf718-8514-44c3-bff6-80c15dbc42fc", + "branch": "production", + "group": "smc" + }, + "templateParameters": { "headerText": "Trending Products" } + } + } +} diff --git a/packages/snap-shared/src/MockData/recommend/profile/8uyt2m/missingParameters.json b/packages/snap-shared/src/MockData/recommend/profile/8uyt2m/missingParameters.json new file mode 100644 index 000000000..fedf35dd3 --- /dev/null +++ b/packages/snap-shared/src/MockData/recommend/profile/8uyt2m/missingParameters.json @@ -0,0 +1,16 @@ +{ + "profile": { + "tag": "trending", + "placement": "product-page", + "display": { + "threshold": 4, + "template": { + "name": "smc__production", + "uuid": "aefcf718-8514-44c3-bff6-80c15dbc42fc", + "component": "Default", + "branch": "production", + "group": "smc" + } + } + } +} diff --git a/packages/snap-url-manager/src/Translators/Url/UrlTranslator.test.ts b/packages/snap-url-manager/src/Translators/Url/UrlTranslator.test.ts index 37720db89..cff3a8673 100644 --- a/packages/snap-url-manager/src/Translators/Url/UrlTranslator.test.ts +++ b/packages/snap-url-manager/src/Translators/Url/UrlTranslator.test.ts @@ -1,4 +1,4 @@ -import { UrlTranslator } from './UrlTranslator'; +import { UrlTranslator, CoreMap } from './UrlTranslator'; import { UrlState, ParamLocationType } from '../../types'; describe('UrlTranslator', () => { @@ -68,7 +68,7 @@ describe('UrlTranslator', () => { const queryString = new customTranslator({ urlRoot: '/search#view:grid', - parameters: { core: { page: { type: 'hash' as ParamLocationType } } }, + parameters: { core: { page: { type: 'hash' } } }, }); const params = { @@ -127,19 +127,19 @@ describe('UrlTranslator', () => { expect(defaultConfig.settings).toEqual({ corePrefix: '', - customType: ParamLocationType.HASH, + customType: ParamLocationType.hash, rootParams: true, }); expect(defaultConfig.parameters.core).toEqual({ - query: { name: 'q', type: ParamLocationType.QUERY }, - oq: { name: 'oq', type: ParamLocationType.QUERY }, - rq: { name: 'rq', type: ParamLocationType.QUERY }, - tag: { name: 'tag', type: ParamLocationType.QUERY }, - page: { name: 'page', type: ParamLocationType.QUERY }, - pageSize: { name: 'pageSize', type: ParamLocationType.HASH }, - sort: { name: 'sort', type: ParamLocationType.HASH }, - filter: { name: 'filter', type: ParamLocationType.HASH }, + query: { name: 'q', type: ParamLocationType.query }, + oq: { name: 'oq', type: ParamLocationType.query }, + rq: { name: 'rq', type: ParamLocationType.query }, + tag: { name: 'tag', type: ParamLocationType.query }, + page: { name: 'page', type: ParamLocationType.query }, + pageSize: { name: 'pageSize', type: ParamLocationType.hash }, + sort: { name: 'sort', type: ParamLocationType.hash }, + filter: { name: 'filter', type: ParamLocationType.hash }, }); expect(defaultConfig.parameters.custom).toEqual({}); @@ -153,20 +153,20 @@ describe('UrlTranslator', () => { }); const config = queryString.getConfig(); - expect(config.settings.customType).toEqual(ParamLocationType.HASH); + expect(config.settings.customType).toEqual(ParamLocationType.hash); }); it('can set the type of all core params with one setting', () => { const queryString = new UrlTranslator({ settings: { - coreType: ParamLocationType.HASH, + coreType: ParamLocationType.hash, }, }); const config = queryString.getConfig(); Object.keys(config.parameters.core).forEach((coreKey) => { - const coreParamConfig = config.parameters.core[coreKey]; - expect(coreParamConfig.type).toEqual(ParamLocationType.HASH); + const coreParamConfig = config.parameters.core[coreKey as keyof CoreMap]; + expect(coreParamConfig.type).toEqual(ParamLocationType.hash); }); }); @@ -179,14 +179,14 @@ describe('UrlTranslator', () => { const config = queryString.getConfig(); expect(config.parameters.core).toEqual({ - query: { name: 'q', type: ParamLocationType.QUERY }, - oq: { name: 'oq', type: ParamLocationType.QUERY }, - rq: { name: 'rq', type: ParamLocationType.QUERY }, - tag: { name: 'tag', type: ParamLocationType.QUERY }, - page: { name: 'page', type: ParamLocationType.QUERY }, - pageSize: { name: 'pageSize', type: ParamLocationType.HASH }, - sort: { name: 'sort', type: ParamLocationType.HASH }, - filter: { name: 'filter', type: ParamLocationType.HASH }, + query: { name: 'q', type: ParamLocationType.query }, + oq: { name: 'oq', type: ParamLocationType.query }, + rq: { name: 'rq', type: ParamLocationType.query }, + tag: { name: 'tag', type: ParamLocationType.query }, + page: { name: 'page', type: ParamLocationType.query }, + pageSize: { name: 'pageSize', type: ParamLocationType.hash }, + sort: { name: 'sort', type: ParamLocationType.hash }, + filter: { name: 'filter', type: ParamLocationType.hash }, }); }); }); @@ -214,8 +214,10 @@ describe('UrlTranslator', () => { expect(params.query).toBe('correct'); - expect(params.filter.color).toEqual(['blue']); - expect(params.filter.brand).toEqual(['nike', 'adidas']); + expect(params).toHaveProperty('filter', { + color: ['blue'], + brand: ['nike', 'adidas'], + }); }); it('deserializes core state correctly', () => { @@ -255,7 +257,7 @@ describe('UrlTranslator', () => { '/#/q:shoes/oq:shoez/rq:shiny/tag:taggy/page:7/pageSize:40/filter:color:red/filter:color:orange/filter:brand:adidas/filter:price:99.99:299.99/sort:name:desc'; const queryString = new UrlTranslator({ settings: { - coreType: ParamLocationType.HASH, + coreType: ParamLocationType.hash, }, }); const params: UrlState = queryString.deserialize(url); @@ -291,7 +293,7 @@ describe('UrlTranslator', () => { '?q=shoes&oq=shoez&rq=shiny&tag=taggy&page=7&pageSize=40&filter.color=red&filter.color=orange&filter.brand=adidas&filter.price.low=99.99&filter.price.high=299.99&sort.name=desc'; const queryString = new UrlTranslator({ settings: { - coreType: ParamLocationType.QUERY, + coreType: ParamLocationType.query, }, }); const params: UrlState = queryString.deserialize(url); @@ -327,7 +329,7 @@ describe('UrlTranslator', () => { '?q=shoes&oq=shoez&rq=shiny&tag=taggy&page=7&pageSize=40&filter.color=red&filter.color=orange&filter.brand=adidas&filter.price.low=99.99&filter.price.high=299.99&sort.name=desc'; const queryString = new UrlTranslator({ settings: { - coreType: ParamLocationType.HASH, + coreType: ParamLocationType.hash, }, }); const params: UrlState = queryString.deserialize(url); @@ -348,14 +350,14 @@ describe('UrlTranslator', () => { const queryString = new UrlTranslator({ parameters: { core: { - query: { name: 'query', type: ParamLocationType.HASH }, - oq: { name: 'originalQuery', type: ParamLocationType.HASH }, - rq: { name: 'refinedQuery', type: ParamLocationType.HASH }, - tag: { name: 'landingPage', type: ParamLocationType.HASH }, - page: { name: 'p', type: ParamLocationType.HASH }, - pageSize: { name: 'size', type: ParamLocationType.QUERY }, - filter: { name: 'facet', type: ParamLocationType.QUERY }, - sort: { name: 'order', type: ParamLocationType.QUERY }, + query: { name: 'query', type: ParamLocationType.hash }, + oq: { name: 'originalQuery', type: ParamLocationType.hash }, + rq: { name: 'refinedQuery', type: ParamLocationType.hash }, + tag: { name: 'landingPage', type: ParamLocationType.hash }, + page: { name: 'p', type: ParamLocationType.hash }, + pageSize: { name: 'size', type: ParamLocationType.query }, + filter: { name: 'facet', type: ParamLocationType.query }, + sort: { name: 'order', type: ParamLocationType.query }, }, }, }); @@ -450,7 +452,7 @@ describe('UrlTranslator', () => { parameters: { core: { filter: { - type: ParamLocationType.QUERY, + type: ParamLocationType.query, }, }, }, @@ -632,7 +634,7 @@ describe('UrlTranslator', () => { const translator = new UrlTranslator({ settings: { - coreType: ParamLocationType.HASH, + coreType: ParamLocationType.hash, }, }); @@ -666,7 +668,7 @@ describe('UrlTranslator', () => { const translator = new UrlTranslator({ settings: { - coreType: ParamLocationType.QUERY, + coreType: ParamLocationType.query, }, }); @@ -701,14 +703,14 @@ describe('UrlTranslator', () => { const translator = new UrlTranslator({ parameters: { core: { - query: { name: 'query', type: ParamLocationType.HASH }, - oq: { name: 'originalQuery', type: ParamLocationType.HASH }, - rq: { name: 'refinedQuery', type: ParamLocationType.HASH }, - tag: { name: 'landingPage', type: ParamLocationType.HASH }, - page: { name: 'p', type: ParamLocationType.HASH }, - pageSize: { name: 'size', type: ParamLocationType.QUERY }, - filter: { name: 'facet', type: ParamLocationType.QUERY }, - sort: { name: 'order', type: ParamLocationType.QUERY }, + query: { name: 'query', type: ParamLocationType.hash }, + oq: { name: 'originalQuery', type: ParamLocationType.hash }, + rq: { name: 'refinedQuery', type: ParamLocationType.hash }, + tag: { name: 'landingPage', type: ParamLocationType.hash }, + page: { name: 'p', type: ParamLocationType.hash }, + pageSize: { name: 'size', type: ParamLocationType.query }, + filter: { name: 'facet', type: ParamLocationType.query }, + sort: { name: 'order', type: ParamLocationType.query }, }, }, }); @@ -835,11 +837,11 @@ describe('UrlTranslator', () => { parameters: { core: { query: { name: 'search' }, - sort: { name: 'order', type: ParamLocationType.QUERY }, + sort: { name: 'order', type: ParamLocationType.query }, }, custom: { - ga: { type: ParamLocationType.HASH }, - googs: { type: ParamLocationType.QUERY }, + ga: { type: ParamLocationType.hash }, + googs: { type: ParamLocationType.query }, }, }, }; @@ -859,7 +861,7 @@ describe('UrlTranslator', () => { const config = { urlRoot: 'https://www.website.com/search.html', settings: { - customType: ParamLocationType.HASH, + customType: ParamLocationType.hash, }, }; const translator = new UrlTranslator(config); @@ -878,7 +880,7 @@ describe('UrlTranslator', () => { const config = { urlRoot: 'https://www.website.com/search.html', settings: { - customType: ParamLocationType.QUERY, + customType: ParamLocationType.query, }, }; const translator = new UrlTranslator(config); diff --git a/packages/snap-url-manager/src/Translators/Url/UrlTranslator.ts b/packages/snap-url-manager/src/Translators/Url/UrlTranslator.ts index bee9a1656..fe7ca0f99 100644 --- a/packages/snap-url-manager/src/Translators/Url/UrlTranslator.ts +++ b/packages/snap-url-manager/src/Translators/Url/UrlTranslator.ts @@ -5,64 +5,68 @@ import { UrlState, Translator, UrlStateSort, RangeValueProperties, UrlStateFilte type UrlParameter = { key: Array; value: string; - type: ParamLocationType; + type: keyof typeof ParamLocationType; }; type MapOptions = { - name?: string; - type?: ParamLocationType; + name: string; + type: keyof typeof ParamLocationType; }; -type CoreMap = { - query?: MapOptions; - oq?: MapOptions; - rq?: MapOptions; - tag?: MapOptions; - page?: MapOptions; - pageSize?: MapOptions; - sort?: MapOptions; - filter?: MapOptions; +type UnnamedMapOptions = { + type: keyof typeof ParamLocationType; +}; + +export type CoreMap = { + query: MapOptions; + oq: MapOptions; + rq: MapOptions; + tag: MapOptions; + page: MapOptions; + pageSize: MapOptions; + sort: MapOptions; + filter: MapOptions; }; type CustomMap = { - [param: string]: { - type?: ParamLocationType; - }; + [param: string]: UnnamedMapOptions; }; export type UrlTranslatorParametersConfig = { - core?: CoreMap; - custom?: CustomMap; + core: CoreMap; + custom: CustomMap; }; export type UrlTranslatorConfig = { - urlRoot?: string; - settings?: { - corePrefix?: string; - coreType?: ParamLocationType; - customType?: ParamLocationType; - rootParams?: boolean; + urlRoot: string; + settings: { + corePrefix: string; + coreType?: keyof typeof ParamLocationType; + customType: keyof typeof ParamLocationType; + rootParams: boolean; }; - parameters?: UrlTranslatorParametersConfig; + parameters: UrlTranslatorParametersConfig; }; +type DeepPartial = Partial<{ [P in keyof T]: DeepPartial }>; + const defaultConfig: UrlTranslatorConfig = { urlRoot: '', settings: { corePrefix: '', - customType: ParamLocationType.HASH, + customType: ParamLocationType.hash, rootParams: true, }, parameters: { core: { - query: { name: 'q', type: ParamLocationType.QUERY }, - oq: { name: 'oq', type: ParamLocationType.QUERY }, - rq: { name: 'rq', type: ParamLocationType.QUERY }, - tag: { name: 'tag', type: ParamLocationType.QUERY }, - page: { name: 'page', type: ParamLocationType.QUERY }, - pageSize: { name: 'pageSize', type: ParamLocationType.HASH }, - sort: { name: 'sort', type: ParamLocationType.HASH }, - filter: { name: 'filter', type: ParamLocationType.HASH }, + query: { name: 'q', type: ParamLocationType.query }, + oq: { name: 'oq', type: ParamLocationType.query }, + rq: { name: 'rq', type: ParamLocationType.query }, + tag: { name: 'tag', type: ParamLocationType.query }, + page: { name: 'page', type: ParamLocationType.query }, + pageSize: { name: 'pageSize', type: ParamLocationType.hash }, + sort: { name: 'sort', type: ParamLocationType.hash }, + filter: { name: 'filter', type: ParamLocationType.hash }, }, custom: {}, }, @@ -74,29 +78,31 @@ export class UrlTranslator implements Translator { protected config: UrlTranslatorConfig; protected reverseMapping: Record = {}; - constructor(config: UrlTranslatorConfig = {}) { - this.config = deepmerge(defaultConfig, config); + constructor(config?: DeepPartial) { + this.config = deepmerge(defaultConfig, (config as UrlTranslatorConfig) || {}); + + Object.keys(this.config.parameters.core).forEach((param: string) => { + const coreParam = this.config.parameters.core[param as keyof CoreMap]; - Object.keys(this.config.parameters.core).forEach((param) => { // param prefix if (this.config.settings.corePrefix) { - this.config.parameters.core[param].name = this.config.settings.corePrefix + this.config.parameters.core[param].name; + coreParam.name = this.config.settings.corePrefix + coreParam.name; } // global type override - const paramType = this.config.settings.coreType; - if (paramType && Object.values(ParamLocationType).includes(paramType)) { - this.config.parameters.core[param].type = (config?.parameters?.core && config?.parameters?.core[param]?.type) || paramType; + const paramType = this.config.settings?.coreType; + if (paramType && Object.values(ParamLocationType).includes(paramType as ParamLocationType)) { + coreParam.type = (config?.parameters?.core && coreParam.type) || paramType; } // create reverse mapping for quick lookup later - this.reverseMapping[this.config.parameters.core[param].name] = param; + this.reverseMapping[coreParam.name] = param; }); - const implicit = this.config.settings.customType; - if (implicit && !Object.values(ParamLocationType).includes(implicit)) { + const implicit = this.config.settings?.customType; + if (implicit && !Object.values(ParamLocationType).includes(implicit as ParamLocationType)) { // invalid type specified - falling back to hash as implicit type - this.config.settings.customType = ParamLocationType.HASH; + this.config.settings.customType = ParamLocationType.hash; } } @@ -133,7 +139,7 @@ export class UrlTranslator implements Translator { .filter((v) => v) .map((kvPair) => { const [key, value] = kvPair.split('=').map((v) => decodeURIComponent(v.replace(/\+/g, ' '))); - return { key: key.split('.'), value, type: ParamLocationType.QUERY }; + return { key: key.split('.'), value, type: ParamLocationType.query }; }) .filter((param) => { // remove core fields that do not contain a value @@ -143,7 +149,7 @@ export class UrlTranslator implements Translator { } protected parseHashString(hashString: string): Array { - const params = []; + const params: Array = []; const justHashString = hashString.split('#').join('/') || ''; justHashString .split('/') @@ -159,15 +165,15 @@ export class UrlTranslator implements Translator { }) .forEach((decodedHashEntries) => { if (decodedHashEntries.length == 1) { - params.push({ key: [decodedHashEntries[0]], value: undefined, type: ParamLocationType.HASH }); + params.push({ key: [decodedHashEntries[0]], value: '', type: ParamLocationType.hash }); } else if (decodedHashEntries.length && decodedHashEntries.length <= 3) { const [value, ...keys] = decodedHashEntries.reverse(); - params.push({ key: keys.reverse(), value, type: ParamLocationType.HASH }); + params.push({ key: keys.reverse(), value, type: ParamLocationType.hash }); } else if (decodedHashEntries.length && decodedHashEntries.length == 4) { // range filter const [path0, path1, low, high] = decodedHashEntries; - params.push({ key: [path0, path1, 'low'], value: low, type: ParamLocationType.HASH }); - params.push({ key: [path0, path1, 'high'], value: high, type: ParamLocationType.HASH }); + params.push({ key: [path0, path1, 'low'], value: low, type: ParamLocationType.hash }); + params.push({ key: [path0, path1, 'high'], value: high, type: ParamLocationType.hash }); } }); @@ -177,15 +183,15 @@ export class UrlTranslator implements Translator { // parse params into state protected paramsToState(params: Array): UrlState { - const coreOtherParams = [], - coreFilterParams = [], - coreSortParams = [], - otherParams = []; + const coreOtherParams: Array = [], + coreFilterParams: Array = [], + coreSortParams: Array = [], + otherParams: Array = []; params?.forEach((param) => { const coreStateKey = this.reverseMapping[param.key[0]]; - const coreConfig: MapOptions = this.config.parameters.core[coreStateKey]; - const customStateKey: MapOptions = this.config.parameters.custom[param.key[0]]; + const coreConfig: MapOptions = this.config.parameters.core[coreStateKey as keyof CoreMap]; + const customStateKey: UnnamedMapOptions = this.config.parameters.custom[param.key[0]]; if (coreStateKey) { // core state @@ -354,8 +360,8 @@ export class UrlTranslator implements Translator { const rootUrlParams = this.config.settings.rootParams ? this.parseUrlParams(this.config.urlRoot) : []; const stateParams = this.stateToParams(state); const params = [...rootUrlParams, ...stateParams]; - const queryParams = params.filter((p) => p.type == ParamLocationType.QUERY); - const hashParams = params.filter((p) => p.type == ParamLocationType.HASH); + const queryParams = params.filter((p) => p.type == ParamLocationType.query); + const hashParams = params.filter((p) => p.type == ParamLocationType.hash); const paramString = (queryParams.length @@ -423,7 +429,7 @@ export class UrlTranslator implements Translator { typeof value[RangeValueProperties.LOW] != 'undefined' && typeof value[RangeValueProperties.HIGH] != 'undefined' ) { - if (filterConfig.type == ParamLocationType.QUERY) { + if (filterConfig.type == ParamLocationType.query) { return [ { key: [filterConfig.name, key, RangeValueProperties.LOW], @@ -436,7 +442,7 @@ export class UrlTranslator implements Translator { type: filterConfig.type, }, ]; - } else if (filterConfig.type == ParamLocationType.HASH) { + } else if (filterConfig.type == ParamLocationType.hash) { return [ { key: [filterConfig.name, key, '' + (value[RangeValueProperties.LOW] ?? '*')], @@ -478,7 +484,7 @@ export class UrlTranslator implements Translator { }) .map((param) => { if (CORE_FIELDS.includes(param) && !except.includes(param)) { - const paramConfig = this.config.parameters.core[param]; + const paramConfig = this.config.parameters.core[param as keyof CoreMap]; if (param == 'page' && state[param] == 1) { // do not include page 1 } else { @@ -512,9 +518,9 @@ export class UrlTranslator implements Translator { }) ); } else { - params = params.concat({ key: [...currentPath, key], value: undefined, type }); + params = params.concat({ key: [...currentPath, key], value: '', type }); } - } else if (typeof value == 'object' && Object.keys(value).length) { + } else if (typeof value == 'object' && Object.keys(value || {}).length) { addRecursive(value as Record, [...currentPath, key]); } else { const customConfig = this.config.parameters.custom[currentPath[0] || key]; diff --git a/packages/snap-url-manager/src/UrlManager/UrlManager.ts b/packages/snap-url-manager/src/UrlManager/UrlManager.ts index 468271f76..8fd0d9f15 100644 --- a/packages/snap-url-manager/src/UrlManager/UrlManager.ts +++ b/packages/snap-url-manager/src/UrlManager/UrlManager.ts @@ -273,7 +273,7 @@ export class UrlManager { return this.linker(this); } - subscribe(cb: (prev: ImmutableObject, next?: ImmutableObject) => void): () => void { + subscribe(cb: (prev?: ImmutableObject, next?: ImmutableObject) => void): () => void { return this.watcherPool.subscribe(() => { const prevState = this.prevState; const state = this.mergedState; @@ -287,7 +287,7 @@ function removeArrayDuplicates(array: Array | any): Array | any { if (Array.isArray(array) && array.length) { return array.reduce( (accu, item) => { - if (!accu.some((keep) => compareObjects(keep, item))) { + if (!accu.some((keep: unknown) => compareObjects(keep, item))) { accu.push(item); } @@ -299,7 +299,7 @@ function removeArrayDuplicates(array: Array | any): Array | any { return array; } -function arrayConcatMerger(current: unknown, other: unknown): Array { +function arrayConcatMerger(current: unknown, other: unknown): Array | undefined { if (current instanceof Array && other instanceof Array) { return removeArrayDuplicates([...current, ...other]); } diff --git a/packages/snap-url-manager/src/integration.test.ts b/packages/snap-url-manager/src/integration.test.ts index 736bb26f3..fbd3b5062 100644 --- a/packages/snap-url-manager/src/integration.test.ts +++ b/packages/snap-url-manager/src/integration.test.ts @@ -1,6 +1,6 @@ import { UrlManager } from './UrlManager/UrlManager'; import { QueryStringTranslator, UrlTranslator } from './Translators'; -import { ParamLocationType } from './types'; +import { UrlState, ParamLocationType } from './types'; let url = ''; @@ -195,15 +195,15 @@ describe('UrlManager Integration Tests', () => { return url; } - go(_url) { + go(_url: string) { url = _url; } - serialize(state) { + serialize(state: UrlState) { return '#' + JSON.stringify(state); } - deserialize(url) { + deserialize(url: string) { return JSON.parse(url.replace(/^#/, '') || '{}'); } } @@ -225,15 +225,15 @@ describe('UrlManager Integration Tests', () => { return window.location.hash; } - go(hash) { + go(hash: string) { window.location.hash = hash; } - serialize(state) { + serialize(state: UrlState) { return '#' + super.serialize(state).split('?').pop(); } - deserialize(url) { + deserialize(url: string) { return super.deserialize('?' + url.replace(/^\#?\/*/, '')); } } @@ -456,8 +456,8 @@ describe('UrlManager Integration Tests', () => { query: { name: 'search' }, }, custom: { - store: { type: ParamLocationType.HASH }, - view: { type: ParamLocationType.QUERY }, + store: { type: ParamLocationType.hash }, + view: { type: ParamLocationType.query }, }, }, }; diff --git a/packages/snap-url-manager/src/types.ts b/packages/snap-url-manager/src/types.ts index 2244fe415..5ee92b560 100644 --- a/packages/snap-url-manager/src/types.ts +++ b/packages/snap-url-manager/src/types.ts @@ -51,6 +51,6 @@ export interface TranslatorConfig { } export enum ParamLocationType { - HASH = 'hash', - QUERY = 'query', + hash = 'hash', + query = 'query', } diff --git a/packages/snap-url-manager/tsconfig.json b/packages/snap-url-manager/tsconfig.json index 86a882798..dc2e50e03 100644 --- a/packages/snap-url-manager/tsconfig.json +++ b/packages/snap-url-manager/tsconfig.json @@ -4,6 +4,7 @@ "src" ], "compilerOptions": { - "outDir": "./dist/esm" + "outDir": "./dist/esm", + "strict": true, } } \ No newline at end of file