diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..df3ddcba6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.json linguist-language=JSON-with-Comments \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..8144925d1 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# Answers Hitchhiker Theme + +A [Jambo](https://github.com/yext/jambo) theme for building Answers experiences. + +Additonal resources for integrating Answers can be found at https://hitchhikers.yext.com/. + +Need help? Ask a question in the [Hitchhiker's Community](https://hitchhikers.yext.com/community/c/answers). + +## Getting Started + +### Prerequisites +- Jambo, a static site generator, which can be installed with `npm i jambo` +- An Answers experience configured at https://yext.com. This will provide the `experienceKey` and the `apiKey` + +### Creating an Answers site + +Inside a new directory, initialize jambo with the theme: +```bash +npx jambo init --theme answers-hitchhiker-theme +``` + +Add a universal search page: +```bash +npx jambo page --name index --template universal-standard +``` + +Inside config/global_config.json, delete the "//" before "apiKey" and enter your `apiKey`. Do the same for the `experienceKey` inside config/locale_config.json. +You can find examples inside test-site/config. + +Build the site: +```bash +npx jambo build && grunt webpack +``` + +Finally, serve the site: +```bash +npx serve public +``` + +The site should now be available at http://localhost:5000. + +## Custom Jambo Commands + +This theme makes the following commands available when Jambo imports this theme. + +### Vertical Command +Creates a vertical page of an Answers experience. + +Example usage: +```bash +npx jambo vertical --name Locations --verticalKey locations --template vertical-standard +``` + +See `jambo vertical --help` for more info. + +### Card Command +Creates a new, custom card based on an existing card. + +Example usage: +```bash +npx jambo card --name custom-location --templateCardFolder cards/location-standard +``` + +See `jambo card --help` for more info. + +### Direct Answer Card +Creates a new, custom direct answer card. + +Example usage: +```bash +npx jambo directanswercard --name custom-directanswer --templateCardFolder directanswercards/allfields-standard +``` + +See `jambo directanswercard --help` for more info. \ No newline at end of file diff --git a/cards/faq-accordion/component.js b/cards/faq-accordion/component.js index dd2c828ca..d17b0a632 100644 --- a/cards/faq-accordion/component.js +++ b/cards/faq-accordion/component.js @@ -13,7 +13,7 @@ class faq_accordionCardComponent extends BaseCard['faq-accordion'] { */ dataForRender(profile) { return { - title: profile.name, // The header text of the card + title: profile.question || profile.name, // The header text of the card // subtitle: '', // The sub-header text of the card details: profile.answer ? ANSWERS.formatRichText(profile.answer, "answer", "_top") : null, // The text in the body of the card // If the card's details are longer than a certain character count, you can truncate the diff --git a/cards/faq-accordion/template.hbs b/cards/faq-accordion/template.hbs index dae229732..944e712c0 100644 --- a/cards/faq-accordion/template.hbs +++ b/cards/faq-accordion/template.hbs @@ -21,7 +21,7 @@ {{/if}} {{> 'details'}} - {{#if (any (all card.CTA1 card.CTA1.url) (all card.CTA2 card.CTA2.url))}} + {{#if (any (all card.CTA1 card.CTA1.url card.CTA1.label) (all card.CTA2 card.CTA2.url card.CTA2.label))}}
{{#if card.CTA1.url}}
diff --git a/cards/financial-professional-location/component.js b/cards/financial-professional-location/component.js index c82d92bf0..346d2cc9e 100644 --- a/cards/financial-professional-location/component.js +++ b/cards/financial-professional-location/component.js @@ -5,6 +5,12 @@ class financial_professional_locationCardComponent extends BaseCard['financial-p super(config, systemConfig); } + onMount() { + const onVerticalFullPageMap = !!document.querySelector('.js-answersVerticalFullPageMap'); + onVerticalFullPageMap && new VerticalFullPageMap.CardListenerAssigner({card: this}).addListenersToCard(); + super.onMount(); + } + /** * This returns an object that will be called `card` * in the template. Put all mapping logic here. @@ -13,7 +19,7 @@ class financial_professional_locationCardComponent extends BaseCard['financial-p */ dataForRender(profile) { return { - showOrdinal: true, // Whether to display the corresponding map pin number on the card + showOrdinal: true, // Show the map pin number on the card. Only supported for universal search title: profile.name, // The header text of the card // subtitle: '', // The sub-header text of the card url: profile.website || profile.landingPageUrl, // If the card title is a clickable link, set URL here diff --git a/cards/financial-professional-location/template.hbs b/cards/financial-professional-location/template.hbs index 89fc97b14..32aa56dfd 100644 --- a/cards/financial-professional-location/template.hbs +++ b/cards/financial-professional-location/template.hbs @@ -1,16 +1,19 @@ -
+
{{> image }}
{{#if (any card.title card.distance)}}
{{> ordinal }} {{> title }} - {{> distance }} +
+ {{> closeCardButton }} + {{> distance }} +
{{/if}} {{> subtitle }}
-
+
{{> address }} {{> details }} {{> list }} @@ -33,7 +36,7 @@ {{#*inline 'ordinal'}} {{#if card.showOrdinal}} -
+
{{result.ordinal}}
{{/if}} @@ -44,7 +47,7 @@
{{#if card.url}} {{card.title}} @@ -151,7 +154,7 @@ {{#*inline 'ctas'}} {{#if (any (all card.CTA1 card.CTA1.url) (all card.CTA2 card.CTA2.url))}} - {{/if}} +{{/inline}} + +{{#*inline 'closeCardButton'}} + {{/inline}} \ No newline at end of file diff --git a/cards/location-standard/component.js b/cards/location-standard/component.js index c8ade5834..981641ed1 100644 --- a/cards/location-standard/component.js +++ b/cards/location-standard/component.js @@ -5,6 +5,12 @@ class location_standardCardComponent extends BaseCard['location-standard'] { super(config, systemConfig); } + onMount() { + const onVerticalFullPageMap = !!document.querySelector('.js-answersVerticalFullPageMap'); + onVerticalFullPageMap && new VerticalFullPageMap.CardListenerAssigner({card: this}).addListenersToCard(); + super.onMount(); + } + /** * This returns an object that will be called `card` * in the template. Put all mapping logic here. @@ -27,7 +33,7 @@ class location_standardCardComponent extends BaseCard['location-standard'] { // details: profile.description, // The description for the card, displays below the address and phone // altText: '', // The alt-text of the displayed image // image: '', // The URL of the image to display on the card - showOrdinal: true, // If the ordinal should be displayed on the card + showOrdinal: true, // Show the map pin number on the card. Only supported for universal search CTA1: { // The primary call to action for the card label: 'Call', // The label of the CTA iconName: 'phone', // The icon to use for the CTA diff --git a/cards/location-standard/template.hbs b/cards/location-standard/template.hbs index 3295b4832..ccd68ce60 100644 --- a/cards/location-standard/template.hbs +++ b/cards/location-standard/template.hbs @@ -2,13 +2,16 @@ any card template. This value contains the ordinal of the card. }} -
+
{{> image }}
{{#if (any card.title card.distance)}}
{{> ordinalAndTitle displayOrdinal=(all card.showOrdinal result.ordinal) }} - {{> distance }} +
+ {{> closeCardButton }} + {{> distance }} +
{{/if}} {{#if card.subtitle}} @@ -16,7 +19,7 @@ {{card.subtitle}}
{{/if}} -
+
{{> contactInfo }} {{> details }} @@ -69,7 +72,7 @@ {{#*inline 'ctas'}} {{#if (any (all card.CTA1 card.CTA1.url) (all card.CTA2 card.CTA2.url))}} -
+
{{> CTA card.CTA1 ctaName="primaryCTA" }} {{> CTA card.CTA2 ctaName="secondaryCTA" }}
@@ -106,7 +109,7 @@ {{#if (any displayOrdinal card.title)}}
{{#if displayOrdinal}} -
+
{{result.ordinal}}
{{/if}} @@ -194,3 +197,12 @@
{{/if}} {{/inline}} + +{{#*inline 'closeCardButton'}} + +{{/inline}} \ No newline at end of file diff --git a/cards/multilang-faq-accordion/component.js b/cards/multilang-faq-accordion/component.js index d1cb61c43..f4c15c65d 100644 --- a/cards/multilang-faq-accordion/component.js +++ b/cards/multilang-faq-accordion/component.js @@ -13,7 +13,7 @@ class multilang_faq_accordionCardComponent extends BaseCard['multilang-faq-accor */ dataForRender(profile) { return { - title: profile.name, // The header text of the card + title: profile.question || profile.name, // The header text of the card // subtitle: '', // The sub-header text of the card details: profile.answer ? ANSWERS.formatRichText(profile.answer, "answer", "_top") : null, // The text in the body of the card // If the card's details are longer than a certain character count, you can truncate the diff --git a/cards/multilang-faq-accordion/template.hbs b/cards/multilang-faq-accordion/template.hbs index dae229732..944e712c0 100644 --- a/cards/multilang-faq-accordion/template.hbs +++ b/cards/multilang-faq-accordion/template.hbs @@ -21,7 +21,7 @@
{{/if}} {{> 'details'}} - {{#if (any (all card.CTA1 card.CTA1.url) (all card.CTA2 card.CTA2.url))}} + {{#if (any (all card.CTA1 card.CTA1.url card.CTA1.label) (all card.CTA2 card.CTA2.url card.CTA2.label))}}
{{#if card.CTA1.url}}
diff --git a/cards/multilang-financial-professional-location/component.js b/cards/multilang-financial-professional-location/component.js index d1ad5bd15..92c987044 100644 --- a/cards/multilang-financial-professional-location/component.js +++ b/cards/multilang-financial-professional-location/component.js @@ -5,6 +5,12 @@ class multilang_financial_professional_locationCardComponent extends BaseCard['m super(config, systemConfig); } + onMount() { + const onVerticalFullPageMap = !!document.querySelector('.js-answersVerticalFullPageMap'); + onVerticalFullPageMap && new VerticalFullPageMap.CardListenerAssigner({card: this}).addListenersToCard(); + super.onMount(); + } + /** * This returns an object that will be called `card` * in the template. Put all mapping logic here. @@ -13,7 +19,7 @@ class multilang_financial_professional_locationCardComponent extends BaseCard['m */ dataForRender(profile) { return { - showOrdinal: true, // Whether to display the corresponding map pin number on the card + showOrdinal: true, // Show the map pin number on the card. Only supported for universal search title: profile.name, // The header text of the card // subtitle: '', // The sub-header text of the card url: profile.website || profile.landingPageUrl, // If the card title is a clickable link, set URL here diff --git a/cards/multilang-financial-professional-location/template.hbs b/cards/multilang-financial-professional-location/template.hbs index aa433922f..f39bfa45d 100644 --- a/cards/multilang-financial-professional-location/template.hbs +++ b/cards/multilang-financial-professional-location/template.hbs @@ -1,16 +1,19 @@ -
+
{{> image }}
{{#if (any card.title card.distance)}}
{{> ordinal }} {{> title }} - {{> distance }} +
+ {{> closeCardButton }} + {{> distance }} +
{{/if}} {{> subtitle }}
-
+
{{> address }} {{> details }} {{> list }} @@ -33,7 +36,7 @@ {{#*inline 'ordinal'}} {{#if card.showOrdinal}} -
+
{{result.ordinal}}
{{/if}} @@ -151,7 +154,7 @@ {{#*inline 'ctas'}} {{#if (any (all card.CTA1 card.CTA1.url) (all card.CTA2 card.CTA2.url))}} -
+
{{> CTA card.CTA1 ctaName="primaryCTA" }} {{> CTA card.CTA2 ctaName="secondaryCTA" }}
@@ -182,4 +185,13 @@
{{/if}} +{{/inline}} + +{{#*inline 'closeCardButton'}} + {{/inline}} \ No newline at end of file diff --git a/cards/multilang-location-standard/component.js b/cards/multilang-location-standard/component.js index b01d73b9f..1c7f4b980 100644 --- a/cards/multilang-location-standard/component.js +++ b/cards/multilang-location-standard/component.js @@ -5,6 +5,12 @@ class multilang_location_standardCardComponent extends BaseCard['multilang-locat super(config, systemConfig); } + onMount() { + const onVerticalFullPageMap = !!document.querySelector('.js-answersVerticalFullPageMap'); + onVerticalFullPageMap && new VerticalFullPageMap.CardListenerAssigner({card: this}).addListenersToCard(); + super.onMount(); + } + /** * This returns an object that will be called `card` * in the template. Put all mapping logic here. @@ -27,7 +33,7 @@ class multilang_location_standardCardComponent extends BaseCard['multilang-locat // details: profile.description, // The description for the card, displays below the address and phone // altText: '', // The alt-text of the displayed image // image: '', // The URL of the image to display on the card - showOrdinal: true, // If the ordinal should be displayed on the card + showOrdinal: true, // Show the map pin number on the card. Only supported for universal search CTA1: { // The primary call to action for the card label: {{ translateJS phrase='Call' context='Call is a verb' }}, // The label of the CTA iconName: 'phone', // The icon to use for the CTA diff --git a/cards/multilang-location-standard/template.hbs b/cards/multilang-location-standard/template.hbs index e1cd0aa37..9a101f80d 100644 --- a/cards/multilang-location-standard/template.hbs +++ b/cards/multilang-location-standard/template.hbs @@ -2,13 +2,16 @@ any card template. This value contains the ordinal of the card. }} -
+
{{> image }}
{{#if (any card.title card.distance)}}
{{> ordinalAndTitle displayOrdinal=(all card.showOrdinal result.ordinal) }} - {{> distance }} +
+ {{> closeCardButton }} + {{> distance }} +
{{/if}} {{#if card.subtitle}} @@ -16,7 +19,7 @@ {{card.subtitle}}
{{/if}} -
+
{{> contactInfo }} {{> details }} @@ -69,7 +72,7 @@ {{#*inline 'ctas'}} {{#if (any (all card.CTA1 card.CTA1.url) (all card.CTA2 card.CTA2.url))}} -
+
{{> CTA card.CTA1 ctaName="primaryCTA" }} {{> CTA card.CTA2 ctaName="secondaryCTA" }}
@@ -106,7 +109,7 @@ {{#if (any displayOrdinal card.title)}}
{{#if displayOrdinal}} -
+
{{result.ordinal}}
{{/if}} @@ -194,3 +197,12 @@
{{/if}} {{/inline}} + +{{#*inline 'closeCardButton'}} + +{{/inline}} \ No newline at end of file diff --git a/cards/multilang-professional-location/component.js b/cards/multilang-professional-location/component.js index b3024ff0e..30423828c 100644 --- a/cards/multilang-professional-location/component.js +++ b/cards/multilang-professional-location/component.js @@ -5,6 +5,12 @@ class multilang_professional_locationCardComponent extends BaseCard['multilang-p super(config, systemConfig); } + onMount() { + const onVerticalFullPageMap = !!document.querySelector('.js-answersVerticalFullPageMap'); + onVerticalFullPageMap && new VerticalFullPageMap.CardListenerAssigner({card: this}).addListenersToCard(); + super.onMount(); + } + /** * This returns an object that will be called `card` * in the template. Put all mapping logic here. @@ -13,7 +19,7 @@ class multilang_professional_locationCardComponent extends BaseCard['multilang-p */ dataForRender(profile) { return { - showOrdinal: true, // Whether to display the corresponding map pin number on the card + showOrdinal: true, // Show the map pin number on the card. Only supported for universal search title: `${profile.firstName} ${profile.lastName}`, // The header text of the card // subtitle: '', // The sub-header text of the card url: profile.website || profile.landingPageUrl, // If the card title is a clickable link, set URL here diff --git a/cards/multilang-professional-location/template.hbs b/cards/multilang-professional-location/template.hbs index e647c411f..c8b65e6a2 100644 --- a/cards/multilang-professional-location/template.hbs +++ b/cards/multilang-professional-location/template.hbs @@ -1,16 +1,19 @@ -
+
{{> image }}
{{#if (any card.title card.distance)}}
{{> ordinal }} {{> title }} - {{> distance }} +
+ {{> closeCardButton }} + {{> distance }} +
{{/if}} {{> subtitle }}
-
+
{{> address }} {{> details }} {{> list }} @@ -31,7 +34,7 @@ {{#*inline 'ordinal'}} {{#if card.showOrdinal}} -
+
{{result.ordinal}}
{{/if}} @@ -148,7 +151,7 @@ {{#*inline 'ctas'}} {{#if (any (all card.CTA1 card.CTA1.url) (all card.CTA2 card.CTA2.url))}} -
+
{{> CTA card.CTA1 ctaName="primaryCTA" }} {{> CTA card.CTA2 ctaName="secondaryCTA" }}
@@ -179,4 +182,13 @@
{{/if}} +{{/inline}} + +{{#*inline 'closeCardButton'}} + {{/inline}} \ No newline at end of file diff --git a/cards/professional-location/component.js b/cards/professional-location/component.js index 2bc3e4446..951242742 100644 --- a/cards/professional-location/component.js +++ b/cards/professional-location/component.js @@ -5,6 +5,12 @@ class professional_locationCardComponent extends BaseCard['professional-location super(config, systemConfig); } + onMount() { + const onVerticalFullPageMap = !!document.querySelector('.js-answersVerticalFullPageMap'); + onVerticalFullPageMap && new VerticalFullPageMap.CardListenerAssigner({card: this}).addListenersToCard(); + super.onMount(); + } + /** * This returns an object that will be called `card` * in the template. Put all mapping logic here. @@ -13,7 +19,7 @@ class professional_locationCardComponent extends BaseCard['professional-location */ dataForRender(profile) { return { - showOrdinal: true, // Whether to display the corresponding map pin number on the card + showOrdinal: true, // Show the map pin number on the card. Only supported for universal search title: `${profile.firstName} ${profile.lastName}`, // The header text of the card // subtitle: '', // The sub-header text of the card url: profile.website || profile.landingPageUrl, // If the card title is a clickable link, set URL here diff --git a/cards/professional-location/template.hbs b/cards/professional-location/template.hbs index e647c411f..462172609 100644 --- a/cards/professional-location/template.hbs +++ b/cards/professional-location/template.hbs @@ -1,16 +1,19 @@ -
+
{{> image }}
{{#if (any card.title card.distance)}}
{{> ordinal }} {{> title }} - {{> distance }} +
+ {{> closeCardButton }} + {{> distance }} +
{{/if}} {{> subtitle }}
-
+
{{> address }} {{> details }} {{> list }} @@ -31,7 +34,7 @@ {{#*inline 'ordinal'}} {{#if card.showOrdinal}} -
+
{{result.ordinal}}
{{/if}} @@ -148,7 +151,7 @@ {{#*inline 'ctas'}} {{#if (any (all card.CTA1 card.CTA1.url) (all card.CTA2 card.CTA2.url))}} -
+
{{> CTA card.CTA1 ctaName="primaryCTA" }} {{> CTA card.CTA2 ctaName="secondaryCTA" }}
@@ -179,4 +182,13 @@
{{/if}} +{{/inline}} + +{{#*inline 'closeCardButton'}} + {{/inline}} \ No newline at end of file diff --git a/commands/addvertical.js b/commands/addvertical.js new file mode 100644 index 000000000..5936f5d7a --- /dev/null +++ b/commands/addvertical.js @@ -0,0 +1,288 @@ +const fs = require('fs-extra'); +const path = require('path'); +const { parse, stringify } = require('comment-json'); +const { spawnSync } = require('child_process'); + +const UserError = require('./helpers/errors/usererror'); +const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); + +/** + * VerticalAdder represents the `vertical` custom jambo command. The command adds + * a new page for the given Vertical and associates a card type with it. + */ +class VerticalAdder { + constructor(jamboConfig) { + this.config = jamboConfig; + } + + /** + * @returns {string} the alias for the add vertical command. + */ + static getAlias() { + return 'vertical'; + } + + /** + * @returns {string} a short description of the add vertical command. + */ + static getShortDescription() { + return 'create the page for a vertical'; + } + + /** + * @returns {Object} description of each argument for + * the add vertical command, keyed by name + */ + static args() { + return { + name: new ArgumentMetadata(ArgumentType.STRING, 'name of the vertical\'s page', true), + verticalKey: new ArgumentMetadata(ArgumentType.STRING, 'the vertical\'s key', true), + cardName: new ArgumentMetadata( + ArgumentType.STRING, 'card to use with vertical', false), + template: new ArgumentMetadata( + ArgumentType.STRING, 'page template to use within theme', true), + locales: new ArgumentMetadata( + ArgumentType.ARRAY, + 'additional locales to generate the page for', + false, + [], + ArgumentType.STRING) + }; + } + + /** + * @returns {Object} description of the vertical command and its parameters. + */ + static describe(jamboConfig) { + return { + displayName: 'Add Vertical', + params: { + name: { + displayName: 'Page Name', + required: true, + type: 'string' + }, + verticalKey: { + displayName: 'Vertical Key', + required: true, + type: 'string', + }, + cardName: { + displayName: 'Card Name', + type: 'singleoption', + options: this._getAvailableCards(jamboConfig) + }, + template: { + displayName: 'Page Template', + required: true, + type: 'singleoption', + options: this._getPageTemplates(jamboConfig) + }, + locales: { + displayName: 'Additional Page Locales', + type: 'multioption', + options: this._getAdditionalPageLocales(jamboConfig) + } + } + }; + } + + /** + * @param {Object} jamboConfig The Jambo configuration of the site. + * @returns {Array} The additional locales that are configured in + * locale_config.json + */ + static _getAdditionalPageLocales(jamboConfig) { + if (!jamboConfig) { + return []; + } + + const configDir = jamboConfig.dirs.config; + if (!configDir) { + return []; + } + + const localeConfig = path.resolve(configDir, 'locale_config.json'); + if (!fs.existsSync(localeConfig)) { + return []; + } + + const localeContentsRaw = fs.readFileSync(localeConfig, 'utf-8'); + let localeContentsJson; + try { + localeContentsJson = parse(localeContentsRaw); + } catch(err) { + throw new UserError('Could not parse locale_config.json ', err.stack); + } + + const defaultLocale = localeContentsJson.default; + const pageLocales = []; + for (const locale in localeContentsJson.localeConfig) { + // don't list the default locale as an option + if (locale !== defaultLocale) { + pageLocales.push(locale); + } + } + return pageLocales; + } + + /** + * @returns {Array} the names of the available cards in the Theme + */ + static _getAvailableCards(jamboConfig) { + const defaultTheme = jamboConfig.defaultTheme; + const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; + if (!defaultTheme || !themesDir) { + return []; + } + const themeCardsDir = path.join(themesDir, defaultTheme, 'cards'); + + const cards = fs.readdirSync(themeCardsDir, { withFileTypes: true }) + .filter(dirent => !dirent.isFile()) + .map(dirent => dirent.name); + + const customCardsDir = 'cards'; + if (fs.existsSync(customCardsDir)) { + fs.readdirSync(customCardsDir, { withFileTypes: true }) + .filter(dirent => !dirent.isFile() && !cards.includes(dirent.name)) + .forEach(dirent => cards.push(dirent.name)); + } + + return cards; + } + + /** + * @returns {Array} The page templates available in the current theme + */ + static _getPageTemplates(jamboConfig) { + const defaultTheme = jamboConfig.defaultTheme; + const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; + if (!defaultTheme || !themesDir) { + return []; + } + const pageTemplatesDir = path.resolve(themesDir, defaultTheme, 'templates'); + return fs.readdirSync(pageTemplatesDir); + } + + /** + * Executes the add vertical command with the provided arguments. + * + * @param {Object} args The arguments, keyed by name + */ + execute(args) { + this._validateArgs(args); + this._createVerticalPage(args.name, args.template, args.locales); + const cardName = args.cardName || this._getCardDefault(args.template); + this._configureVerticalPage(args.name, args.verticalKey, cardName); + } + + + /** + * Structural validation (missing required parameters, etc.) is handled by YArgs. This + * method provides an additional validation layer to ensure the provided template, + * cardName, and locales are valid. Any issue will result in a {@link UserError} being thrown. + * + * @param {Object} args The command parameters. + */ + _validateArgs(args) { + if (args.template === 'universal-standard') { + throw new UserError('A vertical cannot be initialized with the universal template'); + } + + const themeDir = this._getThemeDirectory(this.config); + const templateDir = path.join(themeDir, 'templates', args.template); + if (!fs.existsSync(templateDir)) { + throw new UserError(`${args.template} is not a valid template in the Theme`); + } + + const availableCards = VerticalAdder._getAvailableCards(this.config); + if (args.cardName && !availableCards.includes(args.cardName)) { + throw new UserError(`${args.cardName} is not a valid card`); + } + + if (args.locales.length) { + const supportedLocales = VerticalAdder._getAdditionalPageLocales(this.config); + args.locales.forEach(locale => { + if (!supportedLocales.includes(locale)) { + throw new UserError(`${locale} is not a locale supported by your site`); + } + }) + } + } + + /** + * Determines the default card type to use for a vertical. This is done by parsing the + * provided vertical template's page-config.json to find the cardType, if it exists. + * If the parsed JSON has no cardType, the 'standard' card is reported as the default. + * + * @param {string} template The vertical's template name. + * @returns {string} The default card type. + */ + _getCardDefault(template) { + const themeDir = this._getThemeDirectory(this.config); + const templateDir = path.join(themeDir, 'templates', template); + + const pageConfig = parse( + fs.readFileSync(path.join(templateDir, 'page-config.json'), 'utf-8')); + const verticalConfig = pageConfig.verticalsToConfig['']; + + return verticalConfig.cardType || 'standard'; + } + + /** + * Returns the path to the defaultTheme. If there is no defaultTheme, or + * the themes directory does not exist, null is returned. + * + * @param {Object} jamboConfig The Jambo configuration for the site. + * @returns The path to the defaultTheme, relative to the top-level of the site. + */ + _getThemeDirectory(jamboConfig) { + const defaultTheme = jamboConfig.defaultTheme; + const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; + if (!defaultTheme || !themesDir) { + return null; + } + + return path.join(themesDir, defaultTheme); + } + + /** + * Creates a page for the vertical using the provided name and template. If additional + * locales are provided, localized copies of the vertical page will be created as well. + * Any output from the `jambo page` command is piped through. + * + * @param {string} name The name of the vertical's page. + * @param {string} template The template to use. + * @param {Array} locales The additional locales to generate the page for. + */ + _createVerticalPage(name, template, locales) { + const args = ['--name', name, '--template', template]; + + if (locales.length) { + args.push('--locales', locales.join(' ')); + } + + spawnSync('npx jambo page', args, { shell: true, stdio: 'inherit' }); + } + + /** + * Updates the vertical page's configuration file. Specifically, placeholders for + * vertical key and card type are replaced with the provided values. + * + * @param {string} name The page name. + * @param {string} verticalKey The vertical's key. + * @param {string} cardName The card to be used with the vertical. + */ + _configureVerticalPage(name, verticalKey, cardName) { + const configFile = `config/${name}.json`; + + let rawConfig = fs.readFileSync(configFile, { encoding: 'utf-8' }); + rawConfig = rawConfig.replace(/\/g, verticalKey); + const parsedConfig = parse(rawConfig); + + parsedConfig.verticalsToConfig[verticalKey].cardType = cardName; + + fs.writeFileSync(configFile, stringify(parsedConfig, null, 2)); + } +} +module.exports = VerticalAdder; \ No newline at end of file diff --git a/commands/cardcreator.js b/commands/cardcreator.js index dfe5ca987..28f8ce887 100644 --- a/commands/cardcreator.js +++ b/commands/cardcreator.js @@ -1,5 +1,5 @@ const fs = require('fs-extra'); -const { addToPartials } = require('./helpers/utils/jamboconfigutils'); +const { containsPartial, addToPartials } = require('./helpers/utils/jamboconfigutils'); const path = require('path'); const UserError = require('./helpers/errors/usererror'); const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); @@ -73,10 +73,18 @@ class CardCreator { if (!defaultTheme || !themesDir) { return []; } - const cardsDir = path.join(themesDir, defaultTheme, 'cards'); - return fs.readdirSync(cardsDir, { withFileTypes: true }) - .filter(dirent => !dirent.isFile()) - .map(dirent => path.join(cardsDir, dirent.name)); + const themeCardsDir = path.join(themesDir, defaultTheme, 'cards'); + const cardPaths = new Set(); + const addCardsToSet = cardsDir => { + if (!fs.existsSync(cardsDir)) { + return; + } + fs.readdirSync(cardsDir, { withFileTypes: true }) + .filter(dirent => !dirent.isFile()) + .forEach(dirent => cardPaths.add(path.join('cards', dirent.name))); + }; + [themeCardsDir, 'cards'].forEach(dir => addCardsToSet(dir)); + return Array.from(cardPaths); } /** @@ -112,14 +120,23 @@ class CardCreator { throw new UserError(`A folder with name ${cardFolderName} already exists`); } - const cardFolder = `${this._customCardsDir}/${cardFolderName}`; + const newCardFolder = `${this._customCardsDir}/${cardFolderName}`; + const originalCardFolder = this._getOriginalCardFolder(defaultTheme, templateCardFolder); + !fs.existsSync(this._customCardsDir) && fs.mkdirSync(this._customCardsDir); + !containsPartial(this._customCardsDir) && addToPartials(this._customCardsDir); + fs.copySync(originalCardFolder, newCardFolder); + this._renameCardComponent(cardFolderName, newCardFolder); + } + + _getOriginalCardFolder(defaultTheme, templateCardFolder) { if (fs.existsSync(templateCardFolder)) { - !fs.existsSync(this._customCardsDir) && this._createCustomCardsDir(); - fs.copySync(templateCardFolder, cardFolder); - this._renameCardComponent(cardFolderName, cardFolder); - } else { - throw new UserError(`The folder ${templateCardFolder} does not exist`); + return templateCardFolder + } + const themeCardFolder = path.join(this.config.dirs.themes, defaultTheme, templateCardFolder); + if (fs.existsSync(themeCardFolder)) { + return themeCardFolder; } + throw new UserError(`The folder ${themeCardFolder} does not exist at the root or in the theme.`); } _renameCardComponent(customCardName, cardFolder) { @@ -155,14 +172,5 @@ class CardCreator { .replace(new RegExp(originalComponentName, 'g'), customCardName) .replace(/cards[/_](.*)[/_]template/g, `cards/${customCardName}/template`); } - - /** - * Creates the 'cards' directory in the Jambo repository and adds the newly - * created directory to the list of partials in the Jambo config. - */ - _createCustomCardsDir() { - fs.mkdirSync(this._customCardsDir); - addToPartials(this._customCardsDir); - } } module.exports = CardCreator; diff --git a/commands/directanswercardcreator.js b/commands/directanswercardcreator.js index b4c065253..211f3afa1 100644 --- a/commands/directanswercardcreator.js +++ b/commands/directanswercardcreator.js @@ -1,5 +1,5 @@ const fs = require('fs-extra'); -const { addToPartials } = require('./helpers/utils/jamboconfigutils'); +const { containsPartial, addToPartials } = require('./helpers/utils/jamboconfigutils'); const path = require('path'); const UserError = require('./helpers/errors/usererror'); const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); @@ -73,10 +73,18 @@ class DirectAnswerCardCreator { if (!defaultTheme || !themesDir) { return []; } - const daCardsDir = path.join(themesDir, defaultTheme, 'directanswercards'); - return fs.readdirSync(daCardsDir, { withFileTypes: true }) - .filter(dirent => !dirent.isFile()) - .map(dirent => path.join(daCardsDir, dirent.name)); + const themeCardsDir = path.join(themesDir, defaultTheme, 'directanswercards'); + const cardPaths = new Set(); + const addCardsToSet = cardsDir => { + if (!fs.existsSync(cardsDir)) { + return; + } + fs.readdirSync(cardsDir, { withFileTypes: true }) + .filter(dirent => !dirent.isFile()) + .forEach(dirent => cardPaths.add(path.join('directanswercards', dirent.name))); + }; + [themeCardsDir, 'directanswercards'].forEach(dir => addCardsToSet(dir)); + return Array.from(cardPaths); } /** @@ -112,14 +120,23 @@ class DirectAnswerCardCreator { throw new UserError(`A folder with name ${cardFolderName} already exists`); } - const cardFolder = `${this._customCardsDir}/${cardFolderName}`; + const newCardFolder = `${this._customCardsDir}/${cardFolderName}`; + const originalCardFolder = this._getOriginalCardFolder(defaultTheme, templateCardFolder); + !fs.existsSync(this._customCardsDir) && fs.mkdirSync(this._customCardsDir); + !containsPartial(this._customCardsDir) && addToPartials(this._customCardsDir); + fs.copySync(originalCardFolder, newCardFolder); + this._renameCardComponent(cardFolderName, newCardFolder); + } + + _getOriginalCardFolder(defaultTheme, templateCardFolder) { if (fs.existsSync(templateCardFolder)) { - !fs.existsSync(this._customCardsDir) && this._createCustomCardsDir(); - fs.copySync(templateCardFolder, cardFolder); - this._renameCardComponent(cardFolderName, cardFolder); - } else { - throw new UserError(`The folder ${templateCardFolder} does not exist`); + return templateCardFolder + } + const themeCardFolder = path.join(this.config.dirs.themes, defaultTheme, templateCardFolder); + if (fs.existsSync(themeCardFolder)) { + return themeCardFolder; } + throw new UserError(`The folder ${themeCardFolder} does not exist at the root or in the theme.`); } _renameCardComponent(customCardName, cardFolder) { @@ -159,15 +176,6 @@ class DirectAnswerCardCreator { /directanswercards[/_](.*)[/_]template/g, `directanswercards/${customCardName}/template`); } - - /** - * Creates the 'directanswercards' directory in the Jambo repository and adds the newly - * created directory to the list of partials in the Jambo config. - */ - _createCustomCardsDir() { - fs.mkdirSync(this._customCardsDir); - addToPartials(this._customCardsDir); - } } module.exports = DirectAnswerCardCreator; diff --git a/commands/helpers/utils/argumentmetadata.js b/commands/helpers/utils/argumentmetadata.js index 20476952a..49c546995 100644 --- a/commands/helpers/utils/argumentmetadata.js +++ b/commands/helpers/utils/argumentmetadata.js @@ -4,7 +4,8 @@ const ArgumentType = { STRING: 'string', NUMBER: 'number', - BOOLEAN: 'boolean' + BOOLEAN: 'boolean', + ARRAY: 'array' } Object.freeze(ArgumentType); @@ -13,11 +14,12 @@ Object.freeze(ArgumentType); * the type of the argument's values, if it is required, and an optional default. */ class ArgumentMetadata { - constructor(type, description, isRequired, defaultValue) { + constructor(type, description, isRequired, defaultValue, itemType) { this._type = type; this._isRequired = isRequired; this._defaultValue = defaultValue; this._description = description; + this._itemType = itemType; } /** @@ -27,6 +29,13 @@ class ArgumentMetadata { return this._type; } + /** + * @returns {ArgumentType} The type of the elements of an array argument. + */ + getItemType() { + return this._itemType; + } + /** * @returns {string} The description of the argument. */ @@ -47,6 +56,5 @@ class ArgumentMetadata { defaultValue() { return this._defaultValue; } - } module.exports = { ArgumentMetadata, ArgumentType }; \ No newline at end of file diff --git a/commands/helpers/utils/jamboconfigutils.js b/commands/helpers/utils/jamboconfigutils.js index 2e91f2222..c356cba95 100644 --- a/commands/helpers/utils/jamboconfigutils.js +++ b/commands/helpers/utils/jamboconfigutils.js @@ -52,4 +52,18 @@ exports.addToPartials = function (partialsPath) { existingPartials.push(partialsPath); fs.writeFileSync('jambo.json', stringify(jamboConfig, null, 2)); } -} \ No newline at end of file +} + +/** + * Returns whether or not the partialsPath exists in the partials object in the + * Jambo config + * + * @param {Object} jamboConfig The parsed jambo config + * @param {string} partialsPath The local path to the set of partials. + * @returns {boolean} + */ +exports.containsPartial = function (jamboConfig, partialsPath) { + return jamboConfig.dirs + && jamboConfig.dirs.partials + && jamboConfig.dirs.partials.includes(partialsPath); +} diff --git a/directanswercards/card_component.js b/directanswercards/card_component.js index f60dac9a3..66654eb2e 100644 --- a/directanswercards/card_component.js +++ b/directanswercards/card_component.js @@ -8,6 +8,7 @@ BaseDirectAnswerCard["{{componentName}}"] = class extends ANSWERS.Component { let data = config.data || {}; this.type = data.type || ''; this.answer = data.answer || {}; + this.snippet = this.answer.snippet || {}; this.relatedItem = data.relatedItem || {}; this.associatedEntityId = data.relatedItem && data.relatedItem.data && data.relatedItem.data.id; this.verticalConfigId = data.relatedItem && data.relatedItem.verticalConfigId; @@ -20,7 +21,7 @@ BaseDirectAnswerCard["{{componentName}}"] = class extends ANSWERS.Component { * @param {Object} data */ setState(data) { - let cardData = this.dataForRender(this.type, this.answer, this.relatedItem); + let cardData = this.dataForRender(this.type, this.answer, this.relatedItem, this.snippet); this.validateDataForRender(cardData); return super.setState({ diff --git a/directanswercards/documentsearch-standard/component.js b/directanswercards/documentsearch-standard/component.js new file mode 100644 index 000000000..11f254ecf --- /dev/null +++ b/directanswercards/documentsearch-standard/component.js @@ -0,0 +1,55 @@ +{{> directanswercards/card_component componentName = 'documentsearch-standard' }} + +class documentsearch_standardComponent extends BaseDirectAnswerCard['documentsearch-standard'] { + constructor(config = {}, systemConfig = {}) { + super(config, systemConfig); + } + + /** + * @param type the type of direct answer + * @param answer the full answer returned from the API, corresponds to response.directAnswer.answer. + * @param relatedItem profile of the related entity for the direct answer + * @param snippet the snippet for the document search direct answer + */ + dataForRender(type, answer, relatedItem, snippet) { + const relatedItemData = relatedItem.data || {}; + + return { + value: answer.value, + snippet: snippet && Formatter.highlightField(snippet.value, snippet.matchedSubstrings), // Text snippet to include alongside the answer + viewDetailsText: relatedItemData.fieldValues && relatedItemData.fieldValues.name, // Text below the direct answer and snippet + viewDetailsLink: relatedItemData.website || (relatedItemData.fieldValues && relatedItemData.fieldValues.landingPageUrl), // Link for the "view details" text + viewDetailsEventOptions: this.addDefaultEventOptions({ + ctaLabel: 'VIEW_DETAILS' + }), // The event options for viewDetails click analytics + linkTarget: '_top', // Target for all links in the direct answer + // CTA: { + // label: '', // The CTA's label + // iconName: 'chevron', // The icon to use for the CTA + // url: '', // The URL a user will be directed to when clicking + // target: '_top', // Where the new URL will be opened + // eventType: 'CTA_CLICK', // Type of Analytics event fired when clicking the CTA + // eventOptions: this.addDefaultEventOptions() // The event options for CTA click analytics + // }, + footerTextOnSubmission: 'Thank you for your feedback!', // Text to display in the footer when a thumbs up/down is clicked + footerText: 'Was this the answer you were looking for?', // Text to display in the footer + positiveFeedbackSrText: 'This answered my question', // Screen reader only text for thumbs-up + negativeFeedbackSrText: 'This did not answer my question', // Screen reader only text for thumbs-down + }; + } + + /** + * The template to render + * @returns {string} + * @override + */ + static defaultTemplateName (config) { + return 'directanswercards/documentsearch-standard'; + } +} + +ANSWERS.registerTemplate( + 'directanswercards/documentsearch-standard', + {{{stringifyPartial (read 'directanswercards/documentsearch-standard/template') }}} +); +ANSWERS.registerComponentType(documentsearch_standardComponent); diff --git a/directanswercards/documentsearch-standard/template.hbs b/directanswercards/documentsearch-standard/template.hbs new file mode 100644 index 000000000..92bc84ef6 --- /dev/null +++ b/directanswercards/documentsearch-standard/template.hbs @@ -0,0 +1,120 @@ +
+
+ {{> title }} +
+
+ {{> featured_snippet }} + {{> view_details_link }} +
+ {{> cta CTA linkTarget=linkTarget}} +
+
+ {{> footer}} +
+ +{{#*inline 'title'}} +{{#if value}} +

+ {{value}} +

+{{/if}} +{{/inline}} + +{{#* inline 'featured_snippet'}} +{{#if snippet}} +
+ {{{snippet}}} +
+{{/if}} +{{/inline}} + +{{#*inline 'view_details_link'}} +{{#if (all viewDetailsLink viewDetailsText)}} +
+ Read more about + + {{viewDetailsText}} + +
+{{/if}} +{{/inline}} + +{{#*inline 'footer'}} +{{#if (any footerTextOnSubmission footerText)}} +
+ +
+{{/if}} +{{/inline}} + +{{#*inline 'cta'}} +{{#if (all url label)}} + +{{/if}} +{{/inline}} diff --git a/global_config.json b/global_config.json index 5ee7157d7..696d46ecc 100644 --- a/global_config.json +++ b/global_config.json @@ -1,5 +1,5 @@ { - "sdkVersion": "1.7", // The version of the Answers SDK to use + "sdkVersion": "1.8", // The version of the Answers SDK to use // "apiKey": "", // The answers api key found on the experiences page. This will be provided automatically by the Yext CI system // "experienceVersion": "", // the Answers Experience version to use for API requests. This will be provided automatically by the Yext CI system // "businessId": "", // The business ID of the account. This will be provided automatically by the Yext CI system diff --git a/hbshelpers/chainedLookup.js b/hbshelpers/chainedLookup.js new file mode 100644 index 000000000..4cbb39016 --- /dev/null +++ b/hbshelpers/chainedLookup.js @@ -0,0 +1,18 @@ +/** + * A Handlebars helper that performs multiple lookups in a row. + * + * @param {Object} context The context to perform lookup on + * @param {...string} lookupChain The lookups to perform. + * @param {import('handlebars').HelperOptions} options + * @returns {any} The result of the lookup + */ +module.exports = function chainedLookup(context, ...lookupChain) { + let lookupResult = context; + for (const lookup of lookupChain.slice(0, -1)) { + if (!lookupResult || typeof lookupResult !== 'object') { + return undefined; + } + lookupResult = lookupResult[lookup]; + } + return lookupResult; +} \ No newline at end of file diff --git a/layouts/footer.hbs b/layouts/footer.hbs index c2b279107..8517ef82a 100644 --- a/layouts/footer.hbs +++ b/layouts/footer.hbs @@ -1 +1 @@ -{{!--
--}} +{{!--
--}} diff --git a/layouts/html.hbs b/layouts/html.hbs index d78bd5f5e..d41854428 100644 --- a/layouts/html.hbs +++ b/layouts/html.hbs @@ -147,7 +147,7 @@ height="0" width="0" style="display:none;visibility:hidden"> {{/if}} -
+
{{> overlay/markup/overlay-header }} {{> layouts/header }}
diff --git a/package-lock.json b/package-lock.json index 6903cd04e..2d4f179ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "answers-hitchhiker-theme", - "version": "1.19.0", + "version": "1.20.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4455,10 +4455,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/full-icu/-/full-icu-1.3.1.tgz", "integrity": "sha512-VMtK//85QJomhk3cXOCksNwOYaw1KWnYTS37GYGgyf7A3ajdBoPGhaJuJWAH2S2kq8GZeXkdKn+3Mfmgy11cVw==", - "dev": true, - "requires": { - "icu4c-data": "^0.64.2" - } + "dev": true }, "function-bind": { "version": "1.1.1", @@ -4749,12 +4746,6 @@ "safer-buffer": ">= 2.1.2 < 3" } }, - "icu4c-data": { - "version": "0.64.2", - "resolved": "https://registry.npmjs.org/icu4c-data/-/icu4c-data-0.64.2.tgz", - "integrity": "sha512-BPuTfkRTkplmK1pNrqgyOLJ0qB2UcQ12EotVLwiWh4ErtZR1tEYoRZk/LBLmlDfK5v574/lQYLB4jT9vApBiBQ==", - "dev": true - }, "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5792,9 +5783,9 @@ } }, "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", "dev": true }, "lodash._reinterpolate": { @@ -8369,6 +8360,12 @@ "punycode": "^2.1.0" } }, + "urijs": { + "version": "1.18.12", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.18.12.tgz", + "integrity": "sha512-WlvUkocbQ+GYhi8zkcbecbGYq7YLSd2I3InxAfqeh6mWvWalBE7bISDHcAL3J7STrWFfizuJ709srHD+RuABPQ==", + "dev": true + }, "urix": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", diff --git a/package.json b/package.json index f526a211f..64ccc5cfa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "answers-hitchhiker-theme", - "version": "1.19.0", + "version": "1.20.0", "description": "A starter answers theme for hitchhikers", "scripts": { "test": "cross-env NODE_ICU_DATA=node_modules/full-icu jest --verbose", @@ -34,7 +34,8 @@ "postcss": "^8.2.1", "serve": "^11.3.2", "simple-git": "^2.24.0", - "underscore.string": "^3.3.5" + "underscore.string": "^3.3.5", + "urijs": "1.18.12" }, "jest": { "verbose": true, diff --git a/patches/v1.15-and-earlier-commands-upgrade.patch b/patches/v1.15-and-earlier-commands-upgrade.patch new file mode 100644 index 000000000..f4257e0d9 --- /dev/null +++ b/patches/v1.15-and-earlier-commands-upgrade.patch @@ -0,0 +1,856 @@ +From 1d63dd4677a64ff84858eb061b6183943147c9cc Mon Sep 17 00:00:00 2001 +From: Connor Anderson +Date: Wed, 31 Mar 2021 20:45:00 -0400 +Subject: [PATCH] v1.15-and-earlier-commands-upgrade + +--- + .../commands/addvertical.js | 288 ++++++++++++++++++ + .../commands/cardcreator.js | 176 +++++++++++ + .../commands/directanswercardcreator.js | 181 +++++++++++ + .../commands/helpers/errors/usererror.js | 20 ++ + .../helpers/utils/argumentmetadata.js | 60 ++++ + .../helpers/utils/jamboconfigutils.js | 69 +++++ + 6 files changed, 794 insertions(+) + create mode 100644 themes/answers-hitchhiker-theme/commands/addvertical.js + create mode 100644 themes/answers-hitchhiker-theme/commands/cardcreator.js + create mode 100644 themes/answers-hitchhiker-theme/commands/directanswercardcreator.js + create mode 100644 themes/answers-hitchhiker-theme/commands/helpers/errors/usererror.js + create mode 100644 themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js + create mode 100644 themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js + +diff --git a/themes/answers-hitchhiker-theme/commands/addvertical.js b/themes/answers-hitchhiker-theme/commands/addvertical.js +new file mode 100644 +index 0000000..5936f5d +--- /dev/null ++++ b/themes/answers-hitchhiker-theme/commands/addvertical.js +@@ -0,0 +1,288 @@ ++const fs = require('fs-extra'); ++const path = require('path'); ++const { parse, stringify } = require('comment-json'); ++const { spawnSync } = require('child_process'); ++ ++const UserError = require('./helpers/errors/usererror'); ++const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); ++ ++/** ++ * VerticalAdder represents the `vertical` custom jambo command. The command adds ++ * a new page for the given Vertical and associates a card type with it. ++ */ ++class VerticalAdder { ++ constructor(jamboConfig) { ++ this.config = jamboConfig; ++ } ++ ++ /** ++ * @returns {string} the alias for the add vertical command. ++ */ ++ static getAlias() { ++ return 'vertical'; ++ } ++ ++ /** ++ * @returns {string} a short description of the add vertical command. ++ */ ++ static getShortDescription() { ++ return 'create the page for a vertical'; ++ } ++ ++ /** ++ * @returns {Object} description of each argument for ++ * the add vertical command, keyed by name ++ */ ++ static args() { ++ return { ++ name: new ArgumentMetadata(ArgumentType.STRING, 'name of the vertical\'s page', true), ++ verticalKey: new ArgumentMetadata(ArgumentType.STRING, 'the vertical\'s key', true), ++ cardName: new ArgumentMetadata( ++ ArgumentType.STRING, 'card to use with vertical', false), ++ template: new ArgumentMetadata( ++ ArgumentType.STRING, 'page template to use within theme', true), ++ locales: new ArgumentMetadata( ++ ArgumentType.ARRAY, ++ 'additional locales to generate the page for', ++ false, ++ [], ++ ArgumentType.STRING) ++ }; ++ } ++ ++ /** ++ * @returns {Object} description of the vertical command and its parameters. ++ */ ++ static describe(jamboConfig) { ++ return { ++ displayName: 'Add Vertical', ++ params: { ++ name: { ++ displayName: 'Page Name', ++ required: true, ++ type: 'string' ++ }, ++ verticalKey: { ++ displayName: 'Vertical Key', ++ required: true, ++ type: 'string', ++ }, ++ cardName: { ++ displayName: 'Card Name', ++ type: 'singleoption', ++ options: this._getAvailableCards(jamboConfig) ++ }, ++ template: { ++ displayName: 'Page Template', ++ required: true, ++ type: 'singleoption', ++ options: this._getPageTemplates(jamboConfig) ++ }, ++ locales: { ++ displayName: 'Additional Page Locales', ++ type: 'multioption', ++ options: this._getAdditionalPageLocales(jamboConfig) ++ } ++ } ++ }; ++ } ++ ++ /** ++ * @param {Object} jamboConfig The Jambo configuration of the site. ++ * @returns {Array} The additional locales that are configured in ++ * locale_config.json ++ */ ++ static _getAdditionalPageLocales(jamboConfig) { ++ if (!jamboConfig) { ++ return []; ++ } ++ ++ const configDir = jamboConfig.dirs.config; ++ if (!configDir) { ++ return []; ++ } ++ ++ const localeConfig = path.resolve(configDir, 'locale_config.json'); ++ if (!fs.existsSync(localeConfig)) { ++ return []; ++ } ++ ++ const localeContentsRaw = fs.readFileSync(localeConfig, 'utf-8'); ++ let localeContentsJson; ++ try { ++ localeContentsJson = parse(localeContentsRaw); ++ } catch(err) { ++ throw new UserError('Could not parse locale_config.json ', err.stack); ++ } ++ ++ const defaultLocale = localeContentsJson.default; ++ const pageLocales = []; ++ for (const locale in localeContentsJson.localeConfig) { ++ // don't list the default locale as an option ++ if (locale !== defaultLocale) { ++ pageLocales.push(locale); ++ } ++ } ++ return pageLocales; ++ } ++ ++ /** ++ * @returns {Array} the names of the available cards in the Theme ++ */ ++ static _getAvailableCards(jamboConfig) { ++ const defaultTheme = jamboConfig.defaultTheme; ++ const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; ++ if (!defaultTheme || !themesDir) { ++ return []; ++ } ++ const themeCardsDir = path.join(themesDir, defaultTheme, 'cards'); ++ ++ const cards = fs.readdirSync(themeCardsDir, { withFileTypes: true }) ++ .filter(dirent => !dirent.isFile()) ++ .map(dirent => dirent.name); ++ ++ const customCardsDir = 'cards'; ++ if (fs.existsSync(customCardsDir)) { ++ fs.readdirSync(customCardsDir, { withFileTypes: true }) ++ .filter(dirent => !dirent.isFile() && !cards.includes(dirent.name)) ++ .forEach(dirent => cards.push(dirent.name)); ++ } ++ ++ return cards; ++ } ++ ++ /** ++ * @returns {Array} The page templates available in the current theme ++ */ ++ static _getPageTemplates(jamboConfig) { ++ const defaultTheme = jamboConfig.defaultTheme; ++ const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; ++ if (!defaultTheme || !themesDir) { ++ return []; ++ } ++ const pageTemplatesDir = path.resolve(themesDir, defaultTheme, 'templates'); ++ return fs.readdirSync(pageTemplatesDir); ++ } ++ ++ /** ++ * Executes the add vertical command with the provided arguments. ++ * ++ * @param {Object} args The arguments, keyed by name ++ */ ++ execute(args) { ++ this._validateArgs(args); ++ this._createVerticalPage(args.name, args.template, args.locales); ++ const cardName = args.cardName || this._getCardDefault(args.template); ++ this._configureVerticalPage(args.name, args.verticalKey, cardName); ++ } ++ ++ ++ /** ++ * Structural validation (missing required parameters, etc.) is handled by YArgs. This ++ * method provides an additional validation layer to ensure the provided template, ++ * cardName, and locales are valid. Any issue will result in a {@link UserError} being thrown. ++ * ++ * @param {Object} args The command parameters. ++ */ ++ _validateArgs(args) { ++ if (args.template === 'universal-standard') { ++ throw new UserError('A vertical cannot be initialized with the universal template'); ++ } ++ ++ const themeDir = this._getThemeDirectory(this.config); ++ const templateDir = path.join(themeDir, 'templates', args.template); ++ if (!fs.existsSync(templateDir)) { ++ throw new UserError(`${args.template} is not a valid template in the Theme`); ++ } ++ ++ const availableCards = VerticalAdder._getAvailableCards(this.config); ++ if (args.cardName && !availableCards.includes(args.cardName)) { ++ throw new UserError(`${args.cardName} is not a valid card`); ++ } ++ ++ if (args.locales.length) { ++ const supportedLocales = VerticalAdder._getAdditionalPageLocales(this.config); ++ args.locales.forEach(locale => { ++ if (!supportedLocales.includes(locale)) { ++ throw new UserError(`${locale} is not a locale supported by your site`); ++ } ++ }) ++ } ++ } ++ ++ /** ++ * Determines the default card type to use for a vertical. This is done by parsing the ++ * provided vertical template's page-config.json to find the cardType, if it exists. ++ * If the parsed JSON has no cardType, the 'standard' card is reported as the default. ++ * ++ * @param {string} template The vertical's template name. ++ * @returns {string} The default card type. ++ */ ++ _getCardDefault(template) { ++ const themeDir = this._getThemeDirectory(this.config); ++ const templateDir = path.join(themeDir, 'templates', template); ++ ++ const pageConfig = parse( ++ fs.readFileSync(path.join(templateDir, 'page-config.json'), 'utf-8')); ++ const verticalConfig = pageConfig.verticalsToConfig['']; ++ ++ return verticalConfig.cardType || 'standard'; ++ } ++ ++ /** ++ * Returns the path to the defaultTheme. If there is no defaultTheme, or ++ * the themes directory does not exist, null is returned. ++ * ++ * @param {Object} jamboConfig The Jambo configuration for the site. ++ * @returns The path to the defaultTheme, relative to the top-level of the site. ++ */ ++ _getThemeDirectory(jamboConfig) { ++ const defaultTheme = jamboConfig.defaultTheme; ++ const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; ++ if (!defaultTheme || !themesDir) { ++ return null; ++ } ++ ++ return path.join(themesDir, defaultTheme); ++ } ++ ++ /** ++ * Creates a page for the vertical using the provided name and template. If additional ++ * locales are provided, localized copies of the vertical page will be created as well. ++ * Any output from the `jambo page` command is piped through. ++ * ++ * @param {string} name The name of the vertical's page. ++ * @param {string} template The template to use. ++ * @param {Array} locales The additional locales to generate the page for. ++ */ ++ _createVerticalPage(name, template, locales) { ++ const args = ['--name', name, '--template', template]; ++ ++ if (locales.length) { ++ args.push('--locales', locales.join(' ')); ++ } ++ ++ spawnSync('npx jambo page', args, { shell: true, stdio: 'inherit' }); ++ } ++ ++ /** ++ * Updates the vertical page's configuration file. Specifically, placeholders for ++ * vertical key and card type are replaced with the provided values. ++ * ++ * @param {string} name The page name. ++ * @param {string} verticalKey The vertical's key. ++ * @param {string} cardName The card to be used with the vertical. ++ */ ++ _configureVerticalPage(name, verticalKey, cardName) { ++ const configFile = `config/${name}.json`; ++ ++ let rawConfig = fs.readFileSync(configFile, { encoding: 'utf-8' }); ++ rawConfig = rawConfig.replace(/\/g, verticalKey); ++ const parsedConfig = parse(rawConfig); ++ ++ parsedConfig.verticalsToConfig[verticalKey].cardType = cardName; ++ ++ fs.writeFileSync(configFile, stringify(parsedConfig, null, 2)); ++ } ++} ++module.exports = VerticalAdder; +\ No newline at end of file +diff --git a/themes/answers-hitchhiker-theme/commands/cardcreator.js b/themes/answers-hitchhiker-theme/commands/cardcreator.js +new file mode 100644 +index 0000000..28f8ce8 +--- /dev/null ++++ b/themes/answers-hitchhiker-theme/commands/cardcreator.js +@@ -0,0 +1,176 @@ ++const fs = require('fs-extra'); ++const { containsPartial, addToPartials } = require('./helpers/utils/jamboconfigutils'); ++const path = require('path'); ++const UserError = require('./helpers/errors/usererror'); ++const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); ++ ++/** ++ * CardCreator represents the `card` custom jambo command. ++ * The command creates a new, custom card in the top-level 'cards' directory ++ * of a jambo repo. ++ */ ++class CardCreator { ++ constructor(jamboConfig) { ++ this.config = jamboConfig; ++ this._customCardsDir = 'cards'; ++ } ++ ++ /** ++ * @returns {string} the alias for the create card command. ++ */ ++ static getAlias() { ++ return 'card'; ++ } ++ ++ /** ++ * @returns {string} a short description of the create card command. ++ */ ++ static getShortDescription() { ++ return 'add a new card for use in the site'; ++ } ++ ++ /** ++ * @returns {Object} description of each argument for ++ * the create card command, keyed by name ++ */ ++ static args() { ++ return { ++ 'name': new ArgumentMetadata(ArgumentType.STRING, 'name for the new card', true), ++ 'templateCardFolder': new ArgumentMetadata(ArgumentType.STRING, 'folder of card to fork', true) ++ }; ++ } ++ ++ /** ++ * @returns {Object} description of the card command, including paths to ++ * all available cards ++ */ ++ static describe(jamboConfig) { ++ const cardPaths = this._getCardPaths(jamboConfig); ++ return { ++ displayName: 'Add Card', ++ params: { ++ name: { ++ displayName: 'Card Name', ++ required: true, ++ type: 'string' ++ }, ++ templateCardFolder: { ++ displayName: 'Template Card Folder', ++ required: true, ++ type: 'singleoption', ++ options: cardPaths ++ } ++ } ++ }; ++ } ++ ++ /** ++ * @returns {Array} the paths of the available cards ++ */ ++ static _getCardPaths(jamboConfig) { ++ const defaultTheme = jamboConfig.defaultTheme; ++ const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; ++ if (!defaultTheme || !themesDir) { ++ return []; ++ } ++ const themeCardsDir = path.join(themesDir, defaultTheme, 'cards'); ++ const cardPaths = new Set(); ++ const addCardsToSet = cardsDir => { ++ if (!fs.existsSync(cardsDir)) { ++ return; ++ } ++ fs.readdirSync(cardsDir, { withFileTypes: true }) ++ .filter(dirent => !dirent.isFile()) ++ .forEach(dirent => cardPaths.add(path.join('cards', dirent.name))); ++ }; ++ [themeCardsDir, 'cards'].forEach(dir => addCardsToSet(dir)); ++ return Array.from(cardPaths); ++ } ++ ++ /** ++ * Executes the create card command with the provided arguments. ++ * ++ * @param {Object} args The arguments, keyed by name ++ */ ++ execute(args) { ++ this._create(args.name, args.templateCardFolder); ++ } ++ ++ /** ++ * Creates a new, custom card in the top-level 'Cards' directory. This card ++ * will be based off either an existing custom card or one supplied by the ++ * Theme. ++ * ++ * @param {string} cardName The name of the new card. A folder with a ++ * lowercased version of this name will be ++ * created. ++ * @param {string} templateCardFolder The folder of the existing card on which ++ * the new one will be based. ++ */ ++ _create(cardName, templateCardFolder) { ++ const defaultTheme = this.config.defaultTheme; ++ const themeCardsDir = ++ `${this.config.dirs.themes}/${defaultTheme}/${this._customCardsDir}`; ++ ++ const cardFolderName = cardName.toLowerCase(); ++ const isFolderInUse = ++ fs.existsSync(`${themeCardsDir}/${cardFolderName}`) || ++ fs.existsSync(`${this._customCardsDir}/${cardFolderName}`); ++ if (isFolderInUse) { ++ throw new UserError(`A folder with name ${cardFolderName} already exists`); ++ } ++ ++ const newCardFolder = `${this._customCardsDir}/${cardFolderName}`; ++ const originalCardFolder = this._getOriginalCardFolder(defaultTheme, templateCardFolder); ++ !fs.existsSync(this._customCardsDir) && fs.mkdirSync(this._customCardsDir); ++ !containsPartial(this._customCardsDir) && addToPartials(this._customCardsDir); ++ fs.copySync(originalCardFolder, newCardFolder); ++ this._renameCardComponent(cardFolderName, newCardFolder); ++ } ++ ++ _getOriginalCardFolder(defaultTheme, templateCardFolder) { ++ if (fs.existsSync(templateCardFolder)) { ++ return templateCardFolder ++ } ++ const themeCardFolder = path.join(this.config.dirs.themes, defaultTheme, templateCardFolder); ++ if (fs.existsSync(themeCardFolder)) { ++ return themeCardFolder; ++ } ++ throw new UserError(`The folder ${themeCardFolder} does not exist at the root or in the theme.`); ++ } ++ ++ _renameCardComponent(customCardName, cardFolder) { ++ const cardComponentPath = path.resolve(cardFolder, 'component.js'); ++ const originalComponent = fs.readFileSync(cardComponentPath).toString(); ++ const renamedComponent = ++ this._getRenamedCardComponent(originalComponent, customCardName); ++ fs.writeFileSync(cardComponentPath, renamedComponent); ++ } ++ ++ /** ++ * Returns the internal contents for a newly-created card, updated based on ++ * the given customCardName. (e.g. StandardCardComponent -> [CustomName]CardComponent) ++ * @param {string} content ++ * @param {string} customCardName ++ * @returns {string} ++ */ ++ _getRenamedCardComponent(content, customCardName) { ++ const cardNameSuffix = 'CardComponent'; ++ const registerComponentTypeRegex = /\([\w_]+CardComponent\)/g; ++ const regexArray = [...content.matchAll(/componentName\s*=\s*'(.*)'/g)]; ++ if (regexArray.length === 0 || regexArray[0].length < 2) { ++ return content; ++ } ++ const originalComponentName = regexArray[0][1]; ++ ++ const customComponentClassName = ++ customCardName.replace(/-/g, '_') + cardNameSuffix; ++ ++ return content ++ .replace(/class (.*) extends/g, `class ${customComponentClassName} extends`) ++ .replace(registerComponentTypeRegex, `(${customComponentClassName})`) ++ .replace(new RegExp(originalComponentName, 'g'), customCardName) ++ .replace(/cards[/_](.*)[/_]template/g, `cards/${customCardName}/template`); ++ } ++} ++module.exports = CardCreator; +diff --git a/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js b/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js +new file mode 100644 +index 0000000..211f3af +--- /dev/null ++++ b/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js +@@ -0,0 +1,181 @@ ++const fs = require('fs-extra'); ++const { containsPartial, addToPartials } = require('./helpers/utils/jamboconfigutils'); ++const path = require('path'); ++const UserError = require('./helpers/errors/usererror'); ++const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); ++ ++/** ++ * DirectAnswerCardCreator represents the `directanswercard` custom jambo command. ++ * The command creates a new, custom direct answer card in the top-level ++ * 'directanswercards' directory of a jambo repo. ++ */ ++class DirectAnswerCardCreator { ++ constructor(jamboConfig) { ++ this.config = jamboConfig; ++ this._customCardsDir = 'directanswercards'; ++ } ++ ++ /** ++ * @returns {string} the alias for the create direct answer card command. ++ */ ++ static getAlias() { ++ return 'directanswercard'; ++ } ++ ++ /** ++ * @returns {string} a short description of the create direct answer card command. ++ */ ++ static getShortDescription() { ++ return 'add a new direct answer card for use in the site'; ++ } ++ ++ /** ++ * @returns {Object} description of each argument for ++ * the create direct answer card command, keyed by name ++ */ ++ static args() { ++ return { ++ 'name': new ArgumentMetadata(ArgumentType.STRING, 'name for the new direct answer card', true), ++ 'templateCardFolder': new ArgumentMetadata(ArgumentType.STRING, 'folder of direct answer card to fork', true) ++ }; ++ } ++ ++ /** ++ * @returns {Object} description of the direct answer card command, including paths to ++ * all available direct answer cards ++ */ ++ static describe(jamboConfig) { ++ const directAnswerCardPaths = this._getDirectAnswerCardPaths(jamboConfig); ++ return { ++ displayName: 'Add Direct Answer Card', ++ params: { ++ name: { ++ displayName: 'Direct Answer Card Name', ++ required: true, ++ type: 'string' ++ }, ++ templateCardFolder: { ++ displayName: 'Template Card Folder', ++ required: true, ++ type: 'singleoption', ++ options: directAnswerCardPaths ++ } ++ } ++ }; ++ } ++ ++ /** ++ * @returns {Array} the paths of the available direct answer cards ++ */ ++ static _getDirectAnswerCardPaths(jamboConfig) { ++ const defaultTheme = jamboConfig.defaultTheme; ++ const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; ++ if (!defaultTheme || !themesDir) { ++ return []; ++ } ++ const themeCardsDir = path.join(themesDir, defaultTheme, 'directanswercards'); ++ const cardPaths = new Set(); ++ const addCardsToSet = cardsDir => { ++ if (!fs.existsSync(cardsDir)) { ++ return; ++ } ++ fs.readdirSync(cardsDir, { withFileTypes: true }) ++ .filter(dirent => !dirent.isFile()) ++ .forEach(dirent => cardPaths.add(path.join('directanswercards', dirent.name))); ++ }; ++ [themeCardsDir, 'directanswercards'].forEach(dir => addCardsToSet(dir)); ++ return Array.from(cardPaths); ++ } ++ ++ /** ++ * Executes the create direct answer card command with the provided arguments. ++ * ++ * @param {Object ++ * [CustomName]Component) ++ * ++ * @param {string} content ++ * @param {string} customCardName ++ * @returns {string} ++ */ ++ _getRenamedCardComponent(content, customCardName) { ++ const cardNameSuffix = 'Component'; ++ const registerComponentTypeRegex = /\([\w_]+Component\)/g; ++ const regexArray = [...content.matchAll(/componentName\s*=\s*'(.*)'/g)]; ++ if (regexArray.length === 0 || regexArray[0].length < 2) { ++ return content; ++ } ++ const originalComponentName = regexArray[0][1]; ++ ++ const customComponentClassName = ++ customCardName.replace(/-/g, '_') + cardNameSuffix; ++ ++ return content ++ .replace(/class (.*) extends/g, `class ${customComponentClassName} extends`) ++ .replace(registerComponentTypeRegex, `(${customComponentClassName})`) ++ .replace(new RegExp(originalComponentName, 'g'), customCardName) ++ .replace( ++ /directanswercards[/_](.*)[/_]template/g, ++ `directanswercards/${customCardName}/template`); ++ } ++} ++ ++module.exports = DirectAnswerCardCreator; +diff --git a/themes/answers-hitchhiker-theme/commands/helpers/errors/usererror.js b/themes/answers-hitchhiker-theme/commands/helpers/errors/usererror.js +new file mode 100644 +index 0000000..d815b29 +--- /dev/null ++++ b/themes/answers-hitchhiker-theme/commands/helpers/errors/usererror.js +@@ -0,0 +1,20 @@ ++/** ++ * Represents errors that we may reasonably expect a user to make ++ */ ++class UserError extends Error { ++ constructor(message, stack) { ++ super(message); ++ ++ if (stack) { ++ this.stack = stack; ++ this.message = message; ++ } else { ++ Error.captureStackTrace(this, this.constructor); ++ } ++ ++ this.name = 'UserError' ++ this.exitCode = 13; ++ } ++} ++ ++module.exports = UserError; +\ No newline at end of file +diff --git a/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js b/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js +new file mode 100644 +index 0000000..49c5469 +--- /dev/null ++++ b/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js +@@ -0,0 +1,60 @@ ++/** ++ * An enum describing the different kinds of argument that are supported. ++ */ ++const ArgumentType = { ++ STRING: 'string', ++ NUMBER: 'number', ++ BOOLEAN: 'boolean', ++ ARRAY: 'array' ++} ++Object.freeze(ArgumentType); ++ ++/** ++ * A class outlining the metadata for a {@link Command}'s argument. This includes ++ * the type of the argument's values, if it is required, and an optional default. ++ */ ++class ArgumentMetadata { ++ constructor(type, description, isRequired, defaultValue, itemType) { ++ this._type = type; ++ this._isRequired = isRequired; ++ this._defaultValue = defaultValue; ++ this._description = description; ++ this._itemType = itemType; ++ } ++ ++ /** ++ * @returns {ArgumentType} The type of the argument, e.g. STRING, BOOLEAN, etc. ++ */ ++ getType() { ++ return this._type; ++ } ++ ++ /** ++ * @returns {ArgumentType} The type of the elements of an array argument. ++ */ ++ getItemType() { ++ return this._itemType; ++ } ++ ++ /** ++ * @returns {string} The description of the argument. ++ */ ++ getDescription() { ++ return this._description ++ } ++ ++ /** ++ * @returns {boolean} A boolean indicating if the argument is required. ++ */ ++ isRequired() { ++ return !!this._isRequired; ++ } ++ ++ /** ++ * @returns {string|boolean|number} Optional, a default value for the argument. ++ */ ++ defaultValue() { ++ return this._defaultValue; ++ } ++} ++module.exports = { ArgumentMetadata, ArgumentType }; +\ No newline at end of file +diff --git a/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js b/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js +new file mode 100644 +index 0000000..c356cba +--- /dev/null ++++ b/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js +@@ -0,0 +1,69 @@ ++const fs = require('file-system'); ++const path = require('path'); ++const mergeOptions = require('merge-options'); ++const { ++ parse, ++ stringify ++} = require('comment-json'); ++const UserError = require('../errors/usererror'); ++ ++/** ++ * Parses the repository's Jambo config file. If certain attributes are not ++ * present, defaults will be applied. ++ * ++ * @returns {Object} The parsed Jambo configuration, as an {@link Object}. ++ */ ++exports.parseJamboConfig = function () { ++ try { ++ let config = mergeOptions( ++ { ++ dirs: { ++ themes: 'themes', ++ config: 'config', ++ output: 'public', ++ pages: 'pages', ++ partials: ['partials'], ++ } ++ }, ++ parse(fs.readFileSync('jambo.json', 'utf8')) ++ ); ++ return config; ++ } catch (err) { ++ throw new UserError('Error parsing jambo.json', err.stack); ++ } ++} ++ ++/** ++ * Registers a new set of Handlebars partials in the Jambo configuration ++ * file. The set will not be registered if it has been already or if it ++ * comes from a Theme's 'static' directory. ++ * ++ * @param {string} partialsPath The local path to the set of partials. ++ */ ++exports.addToPartials = function (partialsPath) { ++ const jamboConfig = parseJamboConfig(); ++ const existingPartials = jamboConfig.dirs.partials; ++ ++ const shouldAddNewPartialsPath = ++ !existingPartials.includes(partialsPath) && ++ partialsPath.split(path.sep)[0] !== 'static'; ++ ++ if (shouldAddNewPartialsPath) { ++ existingPartials.push(partialsPath); ++ fs.writeFileSync('jambo.json', stringify(jamboConfig, null, 2)); ++ } ++} ++ ++/** ++ * Returns whether or not the partialsPath exists in the partials object in the ++ * Jambo config ++ * ++ * @param {Object} jamboConfig The parsed jambo config ++ * @param {string} partialsPath The local path to the set of partials. ++ * @returns {boolean} ++ */ ++exports.containsPartial = function (jamboConfig, partialsPath) { ++ return jamboConfig.dirs ++ && jamboConfig.dirs.partials ++ && jamboConfig.dirs.partials.includes(partialsPath); ++} +-- +2.30.2 + diff --git a/patches/v1.16-commands-upgrade.patch b/patches/v1.16-commands-upgrade.patch new file mode 100644 index 000000000..3ea5876b9 --- /dev/null +++ b/patches/v1.16-commands-upgrade.patch @@ -0,0 +1,655 @@ +From b9e6a4ddc84eecaa0cbb123bbd62152ae685ee11 Mon Sep 17 00:00:00 2001 +From: Connor Anderson +Date: Wed, 31 Mar 2021 20:36:13 -0400 +Subject: [PATCH] v1.16-commands-upgrade + +--- + .../commands/addvertical.js | 288 ++++++++++++++++++ + .../commands/cardcreator.js | 69 +++-- + .../commands/directanswercardcreator.js | 68 +++-- + .../helpers/utils/argumentmetadata.js | 14 +- + .../helpers/utils/jamboconfigutils.js | 16 +- + 5 files changed, 390 insertions(+), 65 deletions(-) + create mode 100644 themes/answers-hitchhiker-theme/commands/addvertical.js + +diff --git a/themes/answers-hitchhiker-theme/commands/addvertical.js b/themes/answers-hitchhiker-theme/commands/addvertical.js +new file mode 100644 +index 0000000..5936f5d +--- /dev/null ++++ b/themes/answers-hitchhiker-theme/commands/addvertical.js +@@ -0,0 +1,288 @@ ++const fs = require('fs-extra'); ++const path = require('path'); ++const { parse, stringify } = require('comment-json'); ++const { spawnSync } = require('child_process'); ++ ++const UserError = require('./helpers/errors/usererror'); ++const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); ++ ++/** ++ * VerticalAdder represents the `vertical` custom jambo command. The command adds ++ * a new page for the given Vertical and associates a card type with it. ++ */ ++class VerticalAdder { ++ constructor(jamboConfig) { ++ this.config = jamboConfig; ++ } ++ ++ /** ++ * @returns {string} the alias for the add vertical command. ++ */ ++ static getAlias() { ++ return 'vertical'; ++ } ++ ++ /** ++ * @returns {string} a short description of the add vertical command. ++ */ ++ static getShortDescription() { ++ return 'create the page for a vertical'; ++ } ++ ++ /** ++ * @returns {Object} description of each argument for ++ * the add vertical command, keyed by name ++ */ ++ static args() { ++ return { ++ name: new ArgumentMetadata(ArgumentType.STRING, 'name of the vertical\'s page', true), ++ verticalKey: new ArgumentMetadata(ArgumentType.STRING, 'the vertical\'s key', true), ++ cardName: new ArgumentMetadata( ++ ArgumentType.STRING, 'card to use with vertical', false), ++ template: new ArgumentMetadata( ++ ArgumentType.STRING, 'page template to use within theme', true), ++ locales: new ArgumentMetadata( ++ ArgumentType.ARRAY, ++ 'additional locales to generate the page for', ++ false, ++ [], ++ ArgumentType.STRING) ++ }; ++ } ++ ++ /** ++ * @returns {Object} description of the vertical command and its parameters. ++ */ ++ static describe(jamboConfig) { ++ return { ++ displayName: 'Add Vertical', ++ params: { ++ name: { ++ displayName: 'Page Name', ++ required: true, ++ type: 'string' ++ }, ++ verticalKey: { ++ displayName: 'Vertical Key', ++ required: true, ++ type: 'string', ++ }, ++ cardName: { ++ displayName: 'Card Name', ++ type: 'singleoption', ++ options: this._getAvailableCards(jamboConfig) ++ }, ++ template: { ++ displayName: 'Page Template', ++ required: true, ++ type: 'singleoption', ++ options: this._getPageTemplates(jamboConfig) ++ }, ++ locales: { ++ displayName: 'Additional Page Locales', ++ type: 'multioption', ++ options: this._getAdditionalPageLocales(jamboConfig) ++ } ++ } ++ }; ++ } ++ ++ /** ++ * @param {Object} jamboConfig The Jambo configuration of the site. ++ * @returns {Array} The additional locales that are configured in ++ * locale_config.json ++ */ ++ static _getAdditionalPageLocales(jamboConfig) { ++ if (!jamboConfig) { ++ return []; ++ } ++ ++ const configDir = jamboConfig.dirs.config; ++ if (!configDir) { ++ return []; ++ } ++ ++ const localeConfig = path.resolve(configDir, 'locale_config.json'); ++ if (!fs.existsSync(localeConfig)) { ++ return []; ++ } ++ ++ const localeContentsRaw = fs.readFileSync(localeConfig, 'utf-8'); ++ let localeContentsJson; ++ try { ++ localeContentsJson = parse(localeContentsRaw); ++ } catch(err) { ++ throw new UserError('Could not parse locale_config.json ', err.stack); ++ } ++ ++ const defaultLocale = localeContentsJson.default; ++ const pageLocales = []; ++ for (const locale in localeContentsJson.localeConfig) { ++ // don't list the default locale as an option ++ if (locale !== defaultLocale) { ++ pageLocales.push(locale); ++ } ++ } ++ return pageLocales; ++ } ++ ++ /** ++ * @returns {Array} the names of the available cards in the Theme ++ */ ++ static _getAvailableCards(jamboConfig) { ++ const defaultTheme = jamboConfig.defaultTheme; ++ const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; ++ if (!defaultTheme || !themesDir) { ++ return []; ++ } ++ const themeCardsDir = path.join(themesDir, defaultTheme, 'cards'); ++ ++ const cards = fs.readdirSync(themeCardsDir, { withFileTypes: true }) ++ .filter(dirent => !dirent.isFile()) ++ .map(dirent => dirent.name); ++ ++ const customCardsDir = 'cards'; ++ if (fs.existsSync(customCardsDir)) { ++ fs.readdirSync(customCardsDir, { withFileTypes: true }) ++ .filter(dirent => !dirent.isFile() && !cards.includes(dirent.name)) ++ .forEach(dirent => cards.push(dirent.name)); ++ } ++ ++ return cards; ++ } ++ ++ /** ++ * @returns {Array} The page templates available in the current theme ++ */ ++ static _getPageTemplates(jamboConfig) { ++ const defaultTheme = jamboConfig.defaultTheme; ++ const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; ++ if (!defaultTheme || !themesDir) { ++ return []; ++ } ++ const pageTemplatesDir = path.resolve(themesDir, defaultTheme, 'templates'); ++ return fs.readdirSync(pageTemplatesDir); ++ } ++ ++ /** ++ * Executes the add vertical command with the provided arguments. ++ * ++ * @param {Object} args The arguments, keyed by name ++ */ ++ execute(args) { ++ this._validateArgs(args); ++ this._createVerticalPage(args.name, args.template, args.locales); ++ const cardName = args.cardName || this._getCardDefault(args.template); ++ this._configureVerticalPage(args.name, args.verticalKey, cardName); ++ } ++ ++ ++ /** ++ * Structural validation (missing required parameters, etc.) is handled by YArgs. This ++ * method provides an additional validation layer to ensure the provided template, ++ * cardName, and locales are valid. Any issue will result in a {@link UserError} being thrown. ++ * ++ * @param {Object} args The command parameters. ++ */ ++ _validateArgs(args) { ++ if (args.template === 'universal-standard') { ++ throw new UserError('A vertical cannot be initialized with the universal template'); ++ } ++ ++ const themeDir = this._getThemeDirectory(this.config); ++ const templateDir = path.join(themeDir, 'templates', args.template); ++ if (!fs.existsSync(templateDir)) { ++ throw new UserError(`${args.template} is not a valid template in the Theme`); ++ } ++ ++ const availableCards = VerticalAdder._getAvailableCards(this.config); ++ if (args.cardName && !availableCards.includes(args.cardName)) { ++ throw new UserError(`${args.cardName} is not a valid card`); ++ } ++ ++ if (args.locales.length) { ++ const supportedLocales = VerticalAdder._getAdditionalPageLocales(this.config); ++ args.locales.forEach(locale => { ++ if (!supportedLocales.includes(locale)) { ++ throw new UserError(`${locale} is not a locale supported by your site`); ++ } ++ }) ++ } ++ } ++ ++ /** ++ * Determines the default card type to use for a vertical. This is done by parsing the ++ * provided vertical template's page-config.json to find the cardType, if it exists. ++ * If the parsed JSON has no cardType, the 'standard' card is reported as the default. ++ * ++ * @param {string} template The vertical's template name. ++ * @returns {string} The default card type. ++ */ ++ _getCardDefault(template) { ++ const themeDir = this._getThemeDirectory(this.config); ++ const templateDir = path.join(themeDir, 'templates', template); ++ ++ const pageConfig = parse( ++ fs.readFileSync(path.join(templateDir, 'page-config.json'), 'utf-8')); ++ const verticalConfig = pageConfig.verticalsToConfig['']; ++ ++ return verticalConfig.cardType || 'standard'; ++ } ++ ++ /** ++ * Returns the path to the defaultTheme. If there is no defaultTheme, or ++ * the themes directory does not exist, null is returned. ++ * ++ * @param {Object} jamboConfig The Jambo configuration for the site. ++ * @returns The path to the defaultTheme, relative to the top-level of the site. ++ */ ++ _getThemeDirectory(jamboConfig) { ++ const defaultTheme = jamboConfig.defaultTheme; ++ const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; ++ if (!defaultTheme || !themesDir) { ++ return null; ++ } ++ ++ return path.join(themesDir, defaultTheme); ++ } ++ ++ /** ++ * Creates a page for the vertical using the provided name and template. If additional ++ * locales are provided, localized copies of the vertical page will be created as well. ++ * Any output from the `jambo page` command is piped through. ++ * ++ * @param {string} name The name of the vertical's page. ++ * @param {string} template The template to use. ++ * @param {Array} locales The additional locales to generate the page for. ++ */ ++ _createVerticalPage(name, template, locales) { ++ const args = ['--name', name, '--template', template]; ++ ++ if (locales.length) { ++ args.push('--locales', locales.join(' ')); ++ } ++ ++ spawnSync('npx jambo page', args, { shell: true, stdio: 'inherit' }); ++ } ++ ++ /** ++ * Updates the vertical page's configuration file. Specifically, placeholders for ++ * vertical key and card type are replaced with the provided values. ++ * ++ * @param {string} name The page name. ++ * @param {string} verticalKey The vertical's key. ++ * @param {string} cardName The card to be used with the vertical. ++ */ ++ _configureVerticalPage(name, verticalKey, cardName) { ++ const configFile = `config/${name}.json`; ++ ++ let rawConfig = fs.readFileSync(configFile, { encoding: 'utf-8' }); ++ rawConfig = rawConfig.replace(/\/g, verticalKey); ++ const parsedConfig = parse(rawConfig); ++ ++ parsedConfig.verticalsToConfig[verticalKey].cardType = cardName; ++ ++ fs.writeFileSync(configFile, stringify(parsedConfig, null, 2)); ++ } ++} ++module.exports = VerticalAdder; +\ No newline at end of file +diff --git a/themes/answers-hitchhiker-theme/commands/cardcreator.js b/themes/answers-hitchhiker-theme/commands/cardcreator.js +index 7174819..28f8ce8 100644 +--- a/themes/answers-hitchhiker-theme/commands/cardcreator.js ++++ b/themes/answers-hitchhiker-theme/commands/cardcreator.js +@@ -1,5 +1,5 @@ + const fs = require('fs-extra'); +-const { addToPartials } = require('./helpers/utils/jamboconfigutils'); ++const { containsPartial, addToPartials } = require('./helpers/utils/jamboconfigutils'); + const path = require('path'); + const UserError = require('./helpers/errors/usererror'); + const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); +@@ -12,22 +12,20 @@ const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmeta + class CardCreator { + constructor(jamboConfig) { + this.config = jamboConfig; +- this.themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; +- this.defaultTheme = jamboConfig.defaultTheme; + this._customCardsDir = 'cards'; + } + + /** + * @returns {string} the alias for the create card command. + */ +- getAlias() { ++ static getAlias() { + return 'card'; + } + + /** + * @returns {string} a short description of the create card command. + */ +- getShortDescription() { ++ static getShortDescription() { + return 'add a new card for use in the site'; + } + +@@ -35,7 +33,7 @@ class CardCreator { + * @returns {Object} description of each argument for + * the create card command, keyed by name + */ +- args() { ++ static args() { + return { + 'name': new ArgumentMetadata(ArgumentType.STRING, 'name for the new card', true), + 'templateCardFolder': new ArgumentMetadata(ArgumentType.STRING, 'folder of card to fork', true) +@@ -46,8 +44,8 @@ class CardCreator { + * @returns {Object} description of the card command, including paths to + * all available cards + */ +- describe() { +- const cardPaths = this._getCardPaths(); ++ static describe(jamboConfig) { ++ const cardPaths = this._getCardPaths(jamboConfig); + return { + displayName: 'Add Card', + params: { +@@ -69,14 +67,24 @@ class CardCreator { + /** + * @returns {Array} the paths of the available cards + */ +- _getCardPaths() { +- if (!this.defaultTheme || !this.themesDir) { ++ static _getCardPaths(jamboConfig) { ++ const defaultTheme = jamboConfig.defaultTheme; ++ const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; ++ if (!defaultTheme || !themesDir) { + return []; + } +- const cardsDir = path.join(this.themesDir, this.defaultTheme, 'cards'); +- return fs.readdirSync(cardsDir, { withFileTypes: true }) +- .filter(dirent => !dirent.isFile()) +- .map(dirent => path.join(cardsDir, dirent.name)); ++ const themeCardsDir = path.join(themesDir, defaultTheme, 'cards'); ++ const cardPaths = new Set(); ++ const addCardsToSet = cardsDir => { ++ if (!fs.existsSync(cardsDir)) { ++ return; ++ } ++ fs.readdirSync(cardsDir, { withFileTypes: true }) ++ .filter(dirent => !dirent.isFile()) ++ .forEach(dirent => cardPaths.add(path.join('cards', dirent.name))); ++ }; ++ [themeCardsDir, 'cards'].forEach(dir => addCardsToSet(dir)); ++ return Array.from(cardPaths); + } + + /** +@@ -112,14 +120,23 @@ class CardCreator { + throw new UserError(`A folder with name ${cardFolderName} already exists`); + } + +- const cardFolder = `${this._customCardsDir}/${cardFolderName}`; ++ const newCardFolder = `${this._customCardsDir}/${cardFolderName}`; ++ const originalCardFolder = this._getOriginalCardFolder(defaultTheme, templateCardFolder); ++ !fs.existsSync(this._customCardsDir) && fs.mkdirSync(this._customCardsDir); ++ !containsPartial(this._customCardsDir) && addToPartials(this._customCardsDir); ++ fs.copySync(originalCardFolder, newCardFolder); ++ this._renameCardComponent(cardFolderName, newCardFolder); ++ } ++ ++ _getOriginalCardFolder(defaultTheme, templateCardFolder) { + if (fs.existsSync(templateCardFolder)) { +- !fs.existsSync(this._customCardsDir) && this._createCustomCardsDir(); +- fs.copySync(templateCardFolder, cardFolder); +- this._renameCardComponent(cardFolderName, cardFolder); +- } else { +- throw new UserError(`The folder ${templateCardFolder} does not exist`); ++ return templateCardFolder ++ } ++ const themeCardFolder = path.join(this.config.dirs.themes, defaultTheme, templateCardFolder); ++ if (fs.existsSync(themeCardFolder)) { ++ return themeCardFolder; + } ++ throw new UserError(`The folder ${themeCardFolder} does not exist at the root or in the theme.`); + } + + _renameCardComponent(customCardName, cardFolder) { +@@ -155,15 +172,5 @@ class CardCreator { + .replace(new RegExp(originalComponentName, 'g'), customCardName) + .replace(/cards[/_](.*)[/_]template/g, `cards/${customCardName}/template`); + } +- +- /** +- * Creates the 'cards' directory in the Jambo repository and adds the newly +- * created directory to the list of partials in the Jambo config. +- */ +- _createCustomCardsDir() { +- fs.mkdirSync(this._customCardsDir); +- addToPartials(this._customCardsDir); +- } + } +- +-module.exports = jamboConfig => new CardCreator(jamboConfig); ++module.exports = CardCreator; +diff --git a/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js b/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js +index fc98e22..211f3af 100644 +--- a/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js ++++ b/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js +@@ -1,5 +1,5 @@ + const fs = require('fs-extra'); +-const { addToPartials } = require('./helpers/utils/jamboconfigutils'); ++const { containsPartial, addToPartials } = require('./helpers/utils/jamboconfigutils'); + const path = require('path'); + const UserError = require('./helpers/errors/usererror'); + const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); +@@ -12,22 +12,20 @@ const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmeta + class DirectAnswerCardCreator { + constructor(jamboConfig) { + this.config = jamboConfig; +- this.themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; +- this.defaultTheme = jamboConfig.defaultTheme; + this._customCardsDir = 'directanswercards'; + } + + /** + * @returns {string} the alias for the create direct answer card command. + */ +- getAlias() { ++ static getAlias() { + return 'directanswercard'; + } + + /** + * @returns {string} a short description of the create direct answer card command. + */ +- getShortDescription() { ++ static getShortDescription() { + return 'add a new direct answer card for use in the site'; + } + +@@ -35,7 +33,7 @@ class DirectAnswerCardCreator { + * @returns {Object} description of each argument for + * the create direct answer card command, keyed by name + */ +- args() { ++ static args() { + return { + 'name': new ArgumentMetadata(ArgumentType.STRING, 'name for the new direct answer card', true), + 'templateCardFolder': new ArgumentMetadata(ArgumentType.STRING, 'folder of direct answer card to fork', true) +@@ -46,8 +44,8 @@ class DirectAnswerCardCreator { + * @returns {Object} description of the direct answer card command, including paths to + * all available direct answer cards + */ +- describe() { +- const directAnswerCardPaths = this._getDirectAnswerCardPaths(); ++ static describe(jamboConfig) { ++ const directAnswerCardPaths = this._getDirectAnswerCardPaths(jamboConfig); + return { + displayName: 'Add Direct Answer Card', + params: { +@@ -69,14 +67,24 @@ class DirectAnswerCardCreator { + /** + * @returns {Array} the paths of the available direct answer cards + */ +- _getDirectAnswerCardPaths() { +- if (!this.defaultTheme || !this.themesDir) { ++ static _getDirectAnswerCardPaths(jamboConfig) { ++ const defaultTheme = jamboConfig.defaultTheme; ++ const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; ++ if (!defaultTheme || !themesDir) { + return []; + } +- const daCardsDir = path.join(this.themesDir, this.defaultTheme, 'directanswercards'); +- return fs.readdirSync(daCardsDir, { withFileTypes: true }) +- .filter(dirent => !dirent.isFile()) +- .map(dirent => path.join(daCardsDir, dirent.name)); ++ const themeCardsDir = path.join(themesDir, defaultTheme, 'directanswercards'); ++ const cardPaths = new Set(); ++ const addCardsToSet = cardsDir => { ++ if (!fs.existsSync(cardsDir)) { ++ return; ++ } ++ fs.readdirSync(cardsDir, { withFileTypes: true }) ++ .filter(dirent => !dirent.isFile()) ++ .forEach(dirent => cardPaths.add(path.join('directanswercards', dirent.name))); ++ }; ++ [themeCardsDir, 'directanswercards'].forEach(dir => addCardsToSet(dir)); ++ return Array.from(cardPaths); + } + + /** +@@ -112,14 +120,23 @@ class DirectAnswerCardCreator { + throw new UserError(`A folder with name ${cardFolderName} already exists`); + } + +- const cardFolder = `${this._customCardsDir}/${cardFolderName}`; ++ const newCardFolder = `${this._customCardsDir}/${cardFolderName}`; ++ const originalCardFolder = this._getOriginalCardFolder(defaultTheme, templateCardFolder); ++ !fs.existsSync(this._customCardsDir) && fs.mkdirSync(this._customCardsDir); ++ !containsPartial(this._customCardsDir) && addToPartials(this._customCardsDir); ++ fs.copySync(originalCardFolder, newCardFolder); ++ this._renameCardComponent(cardFolderName, newCardFolder); ++ } ++ ++ _getOriginalCardFolder(defaultTheme, templateCardFolder) { + if (fs.existsSync(templateCardFolder)) { +- !fs.existsSync(this._customCardsDir) && this._createCustomCardsDir(); +- fs.copySync(templateCardFolder, cardFolder); +- this._renameCardComponent(cardFolderName, cardFolder); +- } else { +- throw new UserError(`The folder ${templateCardFolder} does not exist`); ++ return templateCardFolder ++ } ++ const themeCardFolder = path.join(this.config.dirs.themes, defaultTheme, templateCardFolder); ++ if (fs.existsSync(themeCardFolder)) { ++ return themeCardFolder; + } ++ throw new UserError(`The folder ${themeCardFolder} does not exist at the root or in the theme.`); + } + + _renameCardComponent(customCardName, cardFolder) { +@@ -159,15 +176,6 @@ class DirectAnswerCardCreator { + /directanswercards[/_](.*)[/_]template/g, + `directanswercards/${customCardName}/template`); + } +- +- /** +- * Creates the 'directanswercards' directory in the Jambo repository and adds the newly +- * created directory to the list of partials in the Jambo config. +- */ +- _createCustomCardsDir() { +- fs.mkdirSync(this._customCardsDir); +- addToPartials(this._customCardsDir); +- } + } + +-module.exports = jamboConfig => new DirectAnswerCardCreator(jamboConfig); ++module.exports = DirectAnswerCardCreator; +diff --git a/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js b/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js +index 2047695..49c5469 100644 +--- a/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js ++++ b/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js +@@ -4,7 +4,8 @@ + const ArgumentType = { + STRING: 'string', + NUMBER: 'number', +- BOOLEAN: 'boolean' ++ BOOLEAN: 'boolean', ++ ARRAY: 'array' + } + Object.freeze(ArgumentType); + +@@ -13,11 +14,12 @@ Object.freeze(ArgumentType); + * the type of the argument's values, if it is required, and an optional default. + */ + class ArgumentMetadata { +- constructor(type, description, isRequired, defaultValue) { ++ constructor(type, description, isRequired, defaultValue, itemType) { + this._type = type; + this._isRequired = isRequired; + this._defaultValue = defaultValue; + this._description = description; ++ this._itemType = itemType; + } + + /** +@@ -27,6 +29,13 @@ class ArgumentMetadata { + return this._type; + } + ++ /** ++ * @returns {ArgumentType} The type of the elements of an array argument. ++ */ ++ getItemType() { ++ return this._itemType; ++ } ++ + /** + * @returns {string} The description of the argument. + */ +@@ -47,6 +56,5 @@ class ArgumentMetadata { + defaultValue() { + return this._defaultValue; + } +- + } + module.exports = { ArgumentMetadata, ArgumentType }; +\ No newline at end of file +diff --git a/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js b/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js +index 2e91f22..c356cba 100644 +--- a/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js ++++ b/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js +@@ -52,4 +52,18 @@ exports.addToPartials = function (partialsPath) { + existingPartials.push(partialsPath); + fs.writeFileSync('jambo.json', stringify(jamboConfig, null, 2)); + } +-} +\ No newline at end of file ++} ++ ++/** ++ * Returns whether or not the partialsPath exists in the partials object in the ++ * Jambo config ++ * ++ * @param {Object} jamboConfig The parsed jambo config ++ * @param {string} partialsPath The local path to the set of partials. ++ * @returns {boolean} ++ */ ++exports.containsPartial = function (jamboConfig, partialsPath) { ++ return jamboConfig.dirs ++ && jamboConfig.dirs.partials ++ && jamboConfig.dirs.partials.includes(partialsPath); ++} +-- +2.30.2 + diff --git a/patches/v1.17-through-v1.19-commands-update.patch b/patches/v1.17-through-v1.19-commands-update.patch new file mode 100644 index 000000000..6f7ca4414 --- /dev/null +++ b/patches/v1.17-through-v1.19-commands-update.patch @@ -0,0 +1,546 @@ +From 56a0163383ef99178038d13d699ac975803761c0 Mon Sep 17 00:00:00 2001 +From: Connor Anderson +Date: Wed, 31 Mar 2021 20:30:45 -0400 +Subject: [PATCH] Upgrade the commands of Themes v1.17 through v1.19 + +--- + .../commands/addvertical.js | 288 ++++++++++++++++++ + .../commands/cardcreator.js | 48 +-- + .../commands/directanswercardcreator.js | 48 +-- + .../helpers/utils/argumentmetadata.js | 14 +- + .../helpers/utils/jamboconfigutils.js | 16 +- + 5 files changed, 370 insertions(+), 44 deletions(-) + create mode 100644 themes/answers-hitchhiker-theme/commands/addvertical.js + +diff --git a/themes/answers-hitchhiker-theme/commands/addvertical.js b/themes/answers-hitchhiker-theme/commands/addvertical.js +new file mode 100644 +index 0000000..5936f5d +--- /dev/null ++++ b/themes/answers-hitchhiker-theme/commands/addvertical.js +@@ -0,0 +1,288 @@ ++const fs = require('fs-extra'); ++const path = require('path'); ++const { parse, stringify } = require('comment-json'); ++const { spawnSync } = require('child_process'); ++ ++const UserError = require('./helpers/errors/usererror'); ++const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); ++ ++/** ++ * VerticalAdder represents the `vertical` custom jambo command. The command adds ++ * a new page for the given Vertical and associates a card type with it. ++ */ ++class VerticalAdder { ++ constructor(jamboConfig) { ++ this.config = jamboConfig; ++ } ++ ++ /** ++ * @returns {string} the alias for the add vertical command. ++ */ ++ static getAlias() { ++ return 'vertical'; ++ } ++ ++ /** ++ * @returns {string} a short description of the add vertical command. ++ */ ++ static getShortDescription() { ++ return 'create the page for a vertical'; ++ } ++ ++ /** ++ * @returns {Object} description of each argument for ++ * the add vertical command, keyed by name ++ */ ++ static args() { ++ return { ++ name: new ArgumentMetadata(ArgumentType.STRING, 'name of the vertical\'s page', true), ++ verticalKey: new ArgumentMetadata(ArgumentType.STRING, 'the vertical\'s key', true), ++ cardName: new ArgumentMetadata( ++ ArgumentType.STRING, 'card to use with vertical', false), ++ template: new ArgumentMetadata( ++ ArgumentType.STRING, 'page template to use within theme', true), ++ locales: new ArgumentMetadata( ++ ArgumentType.ARRAY, ++ 'additional locales to generate the page for', ++ false, ++ [], ++ ArgumentType.STRING) ++ }; ++ } ++ ++ /** ++ * @returns {Object} description of the vertical command and its parameters. ++ */ ++ static describe(jamboConfig) { ++ return { ++ displayName: 'Add Vertical', ++ params: { ++ name: { ++ displayName: 'Page Name', ++ required: true, ++ type: 'string' ++ }, ++ verticalKey: { ++ displayName: 'Vertical Key', ++ required: true, ++ type: 'string', ++ }, ++ cardName: { ++ displayName: 'Card Name', ++ type: 'singleoption', ++ options: this._getAvailableCards(jamboConfig) ++ }, ++ template: { ++ displayName: 'Page Template', ++ required: true, ++ type: 'singleoption', ++ options: this._getPageTemplates(jamboConfig) ++ }, ++ locales: { ++ displayName: 'Additional Page Locales', ++ type: 'multioption', ++ options: this._getAdditionalPageLocales(jamboConfig) ++ } ++ } ++ }; ++ } ++ ++ /** ++ * @param {Object} jamboConfig The Jambo configuration of the site. ++ * @returns {Array} The additional locales that are configured in ++ * locale_config.json ++ */ ++ static _getAdditionalPageLocales(jamboConfig) { ++ if (!jamboConfig) { ++ return []; ++ } ++ ++ const configDir = jamboConfig.dirs.config; ++ if (!configDir) { ++ return []; ++ } ++ ++ const localeConfig = path.resolve(configDir, 'locale_config.json'); ++ if (!fs.existsSync(localeConfig)) { ++ return []; ++ } ++ ++ const localeContentsRaw = fs.readFileSync(localeConfig, 'utf-8'); ++ let localeContentsJson; ++ try { ++ localeContentsJson = parse(localeContentsRaw); ++ } catch(err) { ++ throw new UserError('Could not parse locale_config.json ', err.stack); ++ } ++ ++ const defaultLocale = localeContentsJson.default; ++ const pageLocales = []; ++ for (const locale in localeContentsJson.localeConfig) { ++ // don't list the default locale as an option ++ if (locale !== defaultLocale) { ++ pageLocales.push(locale); ++ } ++ } ++ return pageLocales; ++ } ++ ++ /** ++ * @returns {Array} the names of the available cards in the Theme ++ */ ++ static _getAvailableCards(jamboConfig) { ++ const defaultTheme = jamboConfig.defaultTheme; ++ const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; ++ if (!defaultTheme || !themesDir) { ++ return []; ++ } ++ const themeCardsDir = path.join(themesDir, defaultTheme, 'cards'); ++ ++ const cards = fs.readdirSync(themeCardsDir, { withFileTypes: true }) ++ .filter(dirent => !dirent.isFile()) ++ .map(dirent => dirent.name); ++ ++ const customCardsDir = 'cards'; ++ if (fs.existsSync(customCardsDir)) { ++ fs.readdirSync(customCardsDir, { withFileTypes: true }) ++ .filter(dirent => !dirent.isFile() && !cards.includes(dirent.name)) ++ .forEach(dirent => cards.push(dirent.name)); ++ } ++ ++ return cards; ++ } ++ ++ /** ++ * @returns {Array} The page templates available in the current theme ++ */ ++ static _getPageTemplates(jamboConfig) { ++ const defaultTheme = jamboConfig.defaultTheme; ++ const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; ++ if (!defaultTheme || !themesDir) { ++ return []; ++ } ++ const pageTemplatesDir = path.resolve(themesDir, defaultTheme, 'templates'); ++ return fs.readdirSync(pageTemplatesDir); ++ } ++ ++ /** ++ * Executes the add vertical command with the provided arguments. ++ * ++ * @param {Object} args The arguments, keyed by name ++ */ ++ execute(args) { ++ this._validateArgs(args); ++ this._createVerticalPage(args.name, args.template, args.locales); ++ const cardName = args.cardName || this._getCardDefault(args.template); ++ this._configureVerticalPage(args.name, args.verticalKey, cardName); ++ } ++ ++ ++ /** ++ * Structural validation (missing required parameters, etc.) is handled by YArgs. This ++ * method provides an additional validation layer to ensure the provided template, ++ * cardName, and locales are valid. Any issue will result in a {@link UserError} being thrown. ++ * ++ * @param {Object} args The command parameters. ++ */ ++ _validateArgs(args) { ++ if (args.template === 'universal-standard') { ++ throw new UserError('A vertical cannot be initialized with the universal template'); ++ } ++ ++ const themeDir = this._getThemeDirectory(this.config); ++ const templateDir = path.join(themeDir, 'templates', args.template); ++ if (!fs.existsSync(templateDir)) { ++ throw new UserError(`${args.template} is not a valid template in the Theme`); ++ } ++ ++ const availableCards = VerticalAdder._getAvailableCards(this.config); ++ if (args.cardName && !availableCards.includes(args.cardName)) { ++ throw new UserError(`${args.cardName} is not a valid card`); ++ } ++ ++ if (args.locales.length) { ++ const supportedLocales = VerticalAdder._getAdditionalPageLocales(this.config); ++ args.locales.forEach(locale => { ++ if (!supportedLocales.includes(locale)) { ++ throw new UserError(`${locale} is not a locale supported by your site`); ++ } ++ }) ++ } ++ } ++ ++ /** ++ * Determines the default card type to use for a vertical. This is done by parsing the ++ * provided vertical template's page-config.json to find the cardType, if it exists. ++ * If the parsed JSON has no cardType, the 'standard' card is reported as the default. ++ * ++ * @param {string} template The vertical's template name. ++ * @returns {string} The default card type. ++ */ ++ _getCardDefault(template) { ++ const themeDir = this._getThemeDirectory(this.config); ++ const templateDir = path.join(themeDir, 'templates', template); ++ ++ const pageConfig = parse( ++ fs.readFileSync(path.join(templateDir, 'page-config.json'), 'utf-8')); ++ const verticalConfig = pageConfig.verticalsToConfig['']; ++ ++ return verticalConfig.cardType || 'standard'; ++ } ++ ++ /** ++ * Returns the path to the defaultTheme. If there is no defaultTheme, or ++ * the themes directory does not exist, null is returned. ++ * ++ * @param {Object} jamboConfig The Jambo configuration for the site. ++ * @returns The path to the defaultTheme, relative to the top-level of the site. ++ */ ++ _getThemeDirectory(jamboConfig) { ++ const defaultTheme = jamboConfig.defaultTheme; ++ const themesDir = jamboConfig.dirs && jamboConfig.dirs.themes; ++ if (!defaultTheme || !themesDir) { ++ return null; ++ } ++ ++ return path.join(themesDir, defaultTheme); ++ } ++ ++ /** ++ * Creates a page for the vertical using the provided name and template. If additional ++ * locales are provided, localized copies of the vertical page will be created as well. ++ * Any output from the `jambo page` command is piped through. ++ * ++ * @param {string} name The name of the vertical's page. ++ * @param {string} template The template to use. ++ * @param {Array} locales The additional locales to generate the page for. ++ */ ++ _createVerticalPage(name, template, locales) { ++ const args = ['--name', name, '--template', template]; ++ ++ if (locales.length) { ++ args.push('--locales', locales.join(' ')); ++ } ++ ++ spawnSync('npx jambo page', args, { shell: true, stdio: 'inherit' }); ++ } ++ ++ /** ++ * Updates the vertical page's configuration file. Specifically, placeholders for ++ * vertical key and card type are replaced with the provided values. ++ * ++ * @param {string} name The page name. ++ * @param {string} verticalKey The vertical's key. ++ * @param {string} cardName The card to be used with the vertical. ++ */ ++ _configureVerticalPage(name, verticalKey, cardName) { ++ const configFile = `config/${name}.json`; ++ ++ let rawConfig = fs.readFileSync(configFile, { encoding: 'utf-8' }); ++ rawConfig = rawConfig.replace(/\/g, verticalKey); ++ const parsedConfig = parse(rawConfig); ++ ++ parsedConfig.verticalsToConfig[verticalKey].cardType = cardName; ++ ++ fs.writeFileSync(configFile, stringify(parsedConfig, null, 2)); ++ } ++} ++module.exports = VerticalAdder; +\ No newline at end of file +diff --git a/themes/answers-hitchhiker-theme/commands/cardcreator.js b/themes/answers-hitchhiker-theme/commands/cardcreator.js +index dfe5ca9..28f8ce8 100644 +--- a/themes/answers-hitchhiker-theme/commands/cardcreator.js ++++ b/themes/answers-hitchhiker-theme/commands/cardcreator.js +@@ -1,5 +1,5 @@ + const fs = require('fs-extra'); +-const { addToPartials } = require('./helpers/utils/jamboconfigutils'); ++const { containsPartial, addToPartials } = require('./helpers/utils/jamboconfigutils'); + const path = require('path'); + const UserError = require('./helpers/errors/usererror'); + const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); +@@ -73,10 +73,18 @@ class CardCreator { + if (!defaultTheme || !themesDir) { + return []; + } +- const cardsDir = path.join(themesDir, defaultTheme, 'cards'); +- return fs.readdirSync(cardsDir, { withFileTypes: true }) +- .filter(dirent => !dirent.isFile()) +- .map(dirent => path.join(cardsDir, dirent.name)); ++ const themeCardsDir = path.join(themesDir, defaultTheme, 'cards'); ++ const cardPaths = new Set(); ++ const addCardsToSet = cardsDir => { ++ if (!fs.existsSync(cardsDir)) { ++ return; ++ } ++ fs.readdirSync(cardsDir, { withFileTypes: true }) ++ .filter(dirent => !dirent.isFile()) ++ .forEach(dirent => cardPaths.add(path.join('cards', dirent.name))); ++ }; ++ [themeCardsDir, 'cards'].forEach(dir => addCardsToSet(dir)); ++ return Array.from(cardPaths); + } + + /** +@@ -112,14 +120,23 @@ class CardCreator { + throw new UserError(`A folder with name ${cardFolderName} already exists`); + } + +- const cardFolder = `${this._customCardsDir}/${cardFolderName}`; ++ const newCardFolder = `${this._customCardsDir}/${cardFolderName}`; ++ const originalCardFolder = this._getOriginalCardFolder(defaultTheme, templateCardFolder); ++ !fs.existsSync(this._customCardsDir) && fs.mkdirSync(this._customCardsDir); ++ !containsPartial(this._customCardsDir) && addToPartials(this._customCardsDir); ++ fs.copySync(originalCardFolder, newCardFolder); ++ this._renameCardComponent(cardFolderName, newCardFolder); ++ } ++ ++ _getOriginalCardFolder(defaultTheme, templateCardFolder) { + if (fs.existsSync(templateCardFolder)) { +- !fs.existsSync(this._customCardsDir) && this._createCustomCardsDir(); +- fs.copySync(templateCardFolder, cardFolder); +- this._renameCardComponent(cardFolderName, cardFolder); +- } else { +- throw new UserError(`The folder ${templateCardFolder} does not exist`); ++ return templateCardFolder ++ } ++ const themeCardFolder = path.join(this.config.dirs.themes, defaultTheme, templateCardFolder); ++ if (fs.existsSync(themeCardFolder)) { ++ return themeCardFolder; + } ++ throw new UserError(`The folder ${themeCardFolder} does not exist at the root or in the theme.`); + } + + _renameCardComponent(customCardName, cardFolder) { +@@ -155,14 +172,5 @@ class CardCreator { + .replace(new RegExp(originalComponentName, 'g'), customCardName) + .replace(/cards[/_](.*)[/_]template/g, `cards/${customCardName}/template`); + } +- +- /** +- * Creates the 'cards' directory in the Jambo repository and adds the newly +- * created directory to the list of partials in the Jambo config. +- */ +- _createCustomCardsDir() { +- fs.mkdirSync(this._customCardsDir); +- addToPartials(this._customCardsDir); +- } + } + module.exports = CardCreator; +diff --git a/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js b/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js +index b4c0652..211f3af 100644 +--- a/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js ++++ b/themes/answers-hitchhiker-theme/commands/directanswercardcreator.js +@@ -1,5 +1,5 @@ + const fs = require('fs-extra'); +-const { addToPartials } = require('./helpers/utils/jamboconfigutils'); ++const { containsPartial, addToPartials } = require('./helpers/utils/jamboconfigutils'); + const path = require('path'); + const UserError = require('./helpers/errors/usererror'); + const { ArgumentMetadata, ArgumentType } = require('./helpers/utils/argumentmetadata'); +@@ -73,10 +73,18 @@ class DirectAnswerCardCreator { + if (!defaultTheme || !themesDir) { + return []; + } +- const daCardsDir = path.join(themesDir, defaultTheme, 'directanswercards'); +- return fs.readdirSync(daCardsDir, { withFileTypes: true }) +- .filter(dirent => !dirent.isFile()) +- .map(dirent => path.join(daCardsDir, dirent.name)); ++ const themeCardsDir = path.join(themesDir, defaultTheme, 'directanswercards'); ++ const cardPaths = new Set(); ++ const addCardsToSet = cardsDir => { ++ if (!fs.existsSync(cardsDir)) { ++ return; ++ } ++ fs.readdirSync(cardsDir, { withFileTypes: true }) ++ .filter(dirent => !dirent.isFile()) ++ .forEach(dirent => cardPaths.add(path.join('directanswercards', dirent.name))); ++ }; ++ [themeCardsDir, 'directanswercards'].forEach(dir => addCardsToSet(dir)); ++ return Array.from(cardPaths); + } + + /** +@@ -112,14 +120,23 @@ class DirectAnswerCardCreator { + throw new UserError(`A folder with name ${cardFolderName} already exists`); + } + +- const cardFolder = `${this._customCardsDir}/${cardFolderName}`; ++ const newCardFolder = `${this._customCardsDir}/${cardFolderName}`; ++ const originalCardFolder = this._getOriginalCardFolder(defaultTheme, templateCardFolder); ++ !fs.existsSync(this._customCardsDir) && fs.mkdirSync(this._customCardsDir); ++ !containsPartial(this._customCardsDir) && addToPartials(this._customCardsDir); ++ fs.copySync(originalCardFolder, newCardFolder); ++ this._renameCardComponent(cardFolderName, newCardFolder); ++ } ++ ++ _getOriginalCardFolder(defaultTheme, templateCardFolder) { + if (fs.existsSync(templateCardFolder)) { +- !fs.existsSync(this._customCardsDir) && this._createCustomCardsDir(); +- fs.copySync(templateCardFolder, cardFolder); +- this._renameCardComponent(cardFolderName, cardFolder); +- } else { +- throw new UserError(`The folder ${templateCardFolder} does not exist`); ++ return templateCardFolder ++ } ++ const themeCardFolder = path.join(this.config.dirs.themes, defaultTheme, templateCardFolder); ++ if (fs.existsSync(themeCardFolder)) { ++ return themeCardFolder; + } ++ throw new UserError(`The folder ${themeCardFolder} does not exist at the root or in the theme.`); + } + + _renameCardComponent(customCardName, cardFolder) { +@@ -159,15 +176,6 @@ class DirectAnswerCardCreator { + /directanswercards[/_](.*)[/_]template/g, + `directanswercards/${customCardName}/template`); + } +- +- /** +- * Creates the 'directanswercards' directory in the Jambo repository and adds the newly +- * created directory to the list of partials in the Jambo config. +- */ +- _createCustomCardsDir() { +- fs.mkdirSync(this._customCardsDir); +- addToPartials(this._customCardsDir); +- } + } + + module.exports = DirectAnswerCardCreator; +diff --git a/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js b/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js +index 2047695..49c5469 100644 +--- a/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js ++++ b/themes/answers-hitchhiker-theme/commands/helpers/utils/argumentmetadata.js +@@ -4,7 +4,8 @@ + const ArgumentType = { + STRING: 'string', + NUMBER: 'number', +- BOOLEAN: 'boolean' ++ BOOLEAN: 'boolean', ++ ARRAY: 'array' + } + Object.freeze(ArgumentType); + +@@ -13,11 +14,12 @@ Object.freeze(ArgumentType); + * the type of the argument's values, if it is required, and an optional default. + */ + class ArgumentMetadata { +- constructor(type, description, isRequired, defaultValue) { ++ constructor(type, description, isRequired, defaultValue, itemType) { + this._type = type; + this._isRequired = isRequired; + this._defaultValue = defaultValue; + this._description = description; ++ this._itemType = itemType; + } + + /** +@@ -27,6 +29,13 @@ class ArgumentMetadata { + return this._type; + } + ++ /** ++ * @returns {ArgumentType} The type of the elements of an array argument. ++ */ ++ getItemType() { ++ return this._itemType; ++ } ++ + /** + * @returns {string} The description of the argument. + */ +@@ -47,6 +56,5 @@ class ArgumentMetadata { + defaultValue() { + return this._defaultValue; + } +- + } + module.exports = { ArgumentMetadata, ArgumentType }; +\ No newline at end of file +diff --git a/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js b/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js +index 2e91f22..c356cba 100644 +--- a/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js ++++ b/themes/answers-hitchhiker-theme/commands/helpers/utils/jamboconfigutils.js +@@ -52,4 +52,18 @@ exports.addToPartials = function (partialsPath) { + existingPartials.push(partialsPath); + fs.writeFileSync('jambo.json', stringify(jamboConfig, null, 2)); + } +-} +\ No newline at end of file ++} ++ ++/** ++ * Returns whether or not the partialsPath exists in the partials object in the ++ * Jambo config ++ * ++ * @param {Object} jamboConfig The parsed jambo config ++ * @param {string} partialsPath The local path to the set of partials. ++ * @returns {boolean} ++ */ ++exports.containsPartial = function (jamboConfig, partialsPath) { ++ return jamboConfig.dirs ++ && jamboConfig.dirs.partials ++ && jamboConfig.dirs.partials.includes(partialsPath); ++} +-- +2.30.2 + diff --git a/postupgrade/PostUpgradeHandler.js b/postupgrade/PostUpgradeHandler.js index 6028619b0..19f3775cb 100644 --- a/postupgrade/PostUpgradeHandler.js +++ b/postupgrade/PostUpgradeHandler.js @@ -2,7 +2,7 @@ const fs = require('fs'); const fsExtra = require('fs-extra'); const path = require('path'); const { mergeJson, isGitSubmodule } = require('./utils'); -const simpleGit = require('simple-git/promise')(); +const { spawnSync } = require('child_process'); /** * PostUpgradeHandler performs filesystem changes after the Theme repository has been upgraded. @@ -18,6 +18,9 @@ class PostUpgradeHandler { if (!isGitSubmodule(this.themeDir)) { this.removeFromTheme('.git', '.gitignore', 'tests'); } + this.copyStaticFilesToTopLevel( + 'package.json', 'Gruntfile.js', 'webpack-config.js', 'package-lock.json'); + spawnSync('npm', ['install'], { stdio: 'inherit' }); const userGlobalConfigPath = path.relative(process.cwd(), path.join(this.configDir, this.globalConfigFile)); @@ -27,9 +30,6 @@ class PostUpgradeHandler { const mergedGlobalConfig = await this.mergeThemeGlobalConfig(userGlobalConfigPath, themeGlobalConfigPath); fs.writeFileSync(userGlobalConfigPath, mergedGlobalConfig); } - - this.copyStaticFilesToTopLevel( - 'package.json', 'Gruntfile.js', 'webpack-config.js', 'package-lock.json'); } /** diff --git a/script/core.hbs b/script/core.hbs index b4aa0998d..c99b65ff2 100644 --- a/script/core.hbs +++ b/script/core.hbs @@ -9,15 +9,12 @@ {{#babel}} function initAnswers() { const JAMBO_INJECTED_DATA = {{{ json env.JAMBO_INJECTED_DATA }}} || {}; - const pages = JAMBO_INJECTED_DATA.pages || {}; - const IS_STAGING = HitchhikerJS.isStaging(pages.stagingDomains || []); + const IS_STAGING = HitchhikerJS.isStaging(JAMBO_INJECTED_DATA?.pages?.stagingDomains || []); const injectedConfig = { experienceVersion: IS_STAGING ? 'STAGING' : 'PRODUCTION', + apiKey: HitchhikerJS.getInjectedProp('{{{global_config.experienceKey}}}', ['apiKey']), {{#with env.JAMBO_INJECTED_DATA}} {{#if businessId}}businessId: "{{businessId}}",{{/if}} - {{#with (lookup (lookup (lookup this 'answers') 'experiences') ../global_config.experienceKey)}} - {{#if apiKey}}apiKey: "{{apiKey}}",{{/if}} - {{/with}} {{/with}} }; const userConfig = { @@ -74,10 +71,14 @@ const absoluteURLRegex = /^(\/|[a-zA-Z]+:)/; return str && str.match(absoluteURLRegex); }); + + ANSWERS.registerHelper('close-card-svg', () => { + return ANSWERS.renderer.SafeString({{{stringifyPartial (read 'static/assets/images/close-card') }}}); + }); } }); {{> script/after-init}} } {{/babel}} - + \ No newline at end of file diff --git a/static/assets/images/close-card.svg b/static/assets/images/close-card.svg new file mode 100644 index 000000000..41f890623 --- /dev/null +++ b/static/assets/images/close-card.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/static/entry.js b/static/entry.js index cbd25f5f9..f571d728e 100644 --- a/static/entry.js +++ b/static/entry.js @@ -12,6 +12,8 @@ export { Formatters }; export { getDefaultMapApiKey } from './js/default-map-api-key'; export { isStaging } from './js/is-staging'; export { isMobile } from 'is-mobile'; +export { getInjectedProp } from './js/get-injected-prop'; +export { isHighlighted } from './js/is-highlighted'; // Used to transfigure the page for the Overlay import Overlay from './js/overlay/answers-frame/overlay'; @@ -24,4 +26,10 @@ global.CollapsibleFilters = CollapsibleFilters; // Import custom modules which can be accessed from HitchhikerJS.CustomModules import * as CustomModules from './js/custom-modules'; -export { CustomModules }; \ No newline at end of file +export { CustomModules }; + +import StorageKeys from './js/constants/storage-keys'; +export { StorageKeys }; + +import transformFacets from './js/transform-facets'; +export { transformFacets } \ No newline at end of file diff --git a/static/js/collapsible-filters/helpers.js b/static/js/collapsible-filters/helpers.js index 65dcf4175..be0fbbd57 100644 --- a/static/js/collapsible-filters/helpers.js +++ b/static/js/collapsible-filters/helpers.js @@ -3,8 +3,7 @@ */ export default class Helpers { static clearSearch() { - ANSWERS.core.setQuery(''); - ANSWERS.core.persistentStorage.set('query', ''); + ANSWERS.search(''); } /** @@ -24,21 +23,7 @@ export default class Helpers { * @param {Object} options */ static verticalSearch(options) { - const verticalKey = ANSWERS.core.globalStorage.getState('search-config').verticalKey; + const verticalKey = ANSWERS.core.storage.get('search-config').verticalKey; ANSWERS.core.verticalSearch(verticalKey, options); } - - /** - * The SDK does not support Facets on load, however the Facets - * component interacts with persistent storage in a way that suggests - * that it does. This is a temporary fix until the SDK is patched. - * @param {string} prefix - */ - static clearFacetsPersistentStorage(prefix = 'Facets') { - for (const urlParamKey in ANSWERS.core.persistentStorage.getAll()) { - if (urlParamKey.startsWith(prefix)) { - ANSWERS.core.persistentStorage.delete(urlParamKey, true) - } - } - } } \ No newline at end of file diff --git a/static/js/collapsible-filters/interactions.js b/static/js/collapsible-filters/interactions.js index fddafd407..8dd4d2976 100644 --- a/static/js/collapsible-filters/interactions.js +++ b/static/js/collapsible-filters/interactions.js @@ -1,19 +1,28 @@ +import QueryTriggers from '../constants/query-triggers'; +import StorageKeys from '../constants/storage-keys'; +import SearchStates from '../constants/search-states'; + /** * Interactions manages page interactions for collapsible filters. */ export default class Interactions { - constructor(domElements) { - const { filterEls, resultEls } = domElements; + constructor(config) { + const { filterEls, resultEls, disableScrollToTopOnToggle, templateName } = config; + this.collapsibleFiltersParentEl = document.querySelector('.CollapsibleFilters'); this.filterEls = filterEls || []; this.resultEls = resultEls || []; + this.templateName = templateName; this.viewResultsButton = document.getElementById('js-answersViewResultsButton'); this.searchBarContainer = document.getElementById('js-answersSearchBar'); this.resultsColumn = document.querySelector('.js-answersResultsColumn'); this.inactiveCssClass = 'CollapsibleFilters-inactive'; + this.collapsedcCssClass = 'CollapsibleFilters--collapsed'; + this.expandedCssClass = 'CollapsibleFilters--expanded'; this.resultsWrapper = document.querySelector('.js-answersResultsWrapper') || document.querySelector('.Answers-resultsWrapper'); this._updateStickyButton = this._updateStickyButton.bind(this); this._debouncedStickyUpdate = this._debouncedStickyUpdate.bind(this); + this._disableScrollToTopOnToggle = disableScrollToTopOnToggle; } /** @@ -112,19 +121,18 @@ export default class Interactions { * made by the searchbar is completed. */ registerCollapseFiltersOnSearchbarSearch() { - let pendingQueryUpdate = false; - - ANSWERS.core.globalStorage.on('update', 'query', () => { - pendingQueryUpdate = true; - }); - - ANSWERS.core.globalStorage.on('update', 'vertical-results', verticalResults => { - if (verticalResults.searchState !== 'search-complete' || !pendingQueryUpdate) { - return; + ANSWERS.core.storage.registerListener({ + eventType: 'update', + storageKey: StorageKeys.VERTICAL_RESULTS, + callback: verticalResults => { + const searchComplete = verticalResults.searchState === SearchStates.SEARCH_COMPLETE; + const queryTrigger = ANSWERS.core.storage.get(StorageKeys.QUERY_TRIGGER); + const isSearchbarSearch = queryTrigger === QueryTriggers.SEARCH_BAR; + if (searchComplete && isSearchbarSearch) { + this.collapseFilters(); + }; } - this.collapseFilters(); - pendingQueryUpdate = false; - }); + }) } /** @@ -169,6 +177,22 @@ export default class Interactions { } } + /** + * If isCollapsed is true, then set the css class for CollapsibleFilters + * indicating that the filters are collapsed. + * + * @param {boolean} isCollapsed + */ + toggleCollapsedStatusClass(isCollapsed) { + if (isCollapsed) { + this.collapsibleFiltersParentEl.classList.remove(this.expandedCssClass); + this.collapsibleFiltersParentEl.classList.add(this.collapsedcCssClass) + } else { + this.collapsibleFiltersParentEl.classList.add(this.expandedCssClass); + this.collapsibleFiltersParentEl.classList.remove(this.collapsedcCssClass); + } + } + /** * Either collapses or expands the collapsible filters panel. * @param {boolean} shouldCollapseFilters @@ -186,7 +210,10 @@ export default class Interactions { this.toggleInactiveClass(el, !shouldCollapseFilters); } this.toggleInactiveClass(this.viewResultsButton, shouldCollapseFilters); - this.scrollToTop(); + this.toggleCollapsedStatusClass(shouldCollapseFilters); + if (!this._disableScrollToTopOnToggle) { + this.scrollToTop(); + } ANSWERS.components.getActiveComponent('FilterLink').setState({ panelIsDisplayed: !shouldCollapseFilters }); @@ -203,4 +230,14 @@ export default class Interactions { this.resultsColumn.scrollTop = 0; } } + + /** + * Set the page template name as a CSS class on the footer so the it can be styled for CFilters + */ + setupFooter() { + const yxtFooter = document.querySelector('.js-yxtFooter'); + if (yxtFooter && this.templateName) { + yxtFooter.classList.add(this.templateName); + } + } } diff --git a/static/js/constants/query-triggers.js b/static/js/constants/query-triggers.js new file mode 100644 index 000000000..09296f1a6 --- /dev/null +++ b/static/js/constants/query-triggers.js @@ -0,0 +1,15 @@ +/** + * QueryTriggers is an ENUM of the possible triggers for a + * query update. This comes directly from the SDK. + * + * @enum {string} + */ +const QueryTriggers = { + INITIALIZE: 'initialize', + QUERY_PARAMETER: 'query-parameter', + SUGGEST: 'suggest', + FILTER_COMPONENT: 'filter-component', + PAGINATION: 'pagination', + SEARCH_BAR: 'search-bar' +}; +export default QueryTriggers; diff --git a/static/js/constants/search-states.js b/static/js/constants/search-states.js new file mode 100644 index 000000000..47dbf47f9 --- /dev/null +++ b/static/js/constants/search-states.js @@ -0,0 +1,12 @@ +/** + * SearchStates is an ENUM for the various stages of searching, + * used to show different templates + * + * @enum {string} + */ +const SearchStates = { + PRE_SEARCH: 'pre-search', + SEARCH_LOADING: 'search-loading', + SEARCH_COMPLETE: 'search-complete' +}; +export default SearchStates; diff --git a/static/js/constants/storage-keys.js b/static/js/constants/storage-keys.js new file mode 100644 index 000000000..b7dbc5153 --- /dev/null +++ b/static/js/constants/storage-keys.js @@ -0,0 +1,23 @@ +/** + * An enum listing SDK storage keys for the theme. Because + * the SDK does not expose StorageKeys, non-theme specific + * keys may also live in this file. + * + * @enum {string} + */ +const StorageKeys = { + // From SDK + VERTICAL_RESULTS: 'vertical-results', + QUERY: 'query', + ALTERNATIVE_VERTICALS: 'alternative-verticals', + QUERY_TRIGGER: 'queryTrigger', + + // Locator + LOCATOR_HOVERED_RESULT: 'locator-hovered-result', + LOCATOR_SELECTED_RESULT: 'locator-selected-result', + LOCATOR_NUM_CONCURRENT_SEARCH_THIS_AREA_CALLS: 'locator-num-concurrent-search-this-area-calls', + LOCATOR_MAP_PROPERTIES: 'locator-map-properties', + LOCATOR_CARD_FOCUS: 'locator-card-focus' +}; + +export default StorageKeys; diff --git a/static/js/formatters-internal.js b/static/js/formatters-internal.js index 70894b858..7ad725f08 100644 --- a/static/js/formatters-internal.js +++ b/static/js/formatters-internal.js @@ -1,12 +1,12 @@ import { parsePhoneNumberFromString } from 'libphonenumber-js' import { components__address__i18n__addressForCountry } from './address-i18n.js' -import CtaFormatter from '@yext/cta-formatter'; import { getDistanceUnit } from './units-i18n'; import OpenStatusMessageFactory from './hours/open-status/messagefactory.js'; import HoursTransformer from './hours/transformer.js'; import HoursStringsLocalizer from './hours/stringslocalizer.js'; import HoursTableBuilder from './hours/table/builder.js'; import { DayNames } from './hours/constants.js'; +import { generateCTAFieldTypeLink } from './formatters/generate-cta-field-type-link'; export function address(profile) { @@ -516,16 +516,7 @@ export function hoursList(profile, opts = {}, key = 'hours', locale) { return new HoursTableBuilder(hoursLocalizer).build(hours, standardizedOpts); } -/** - * @param {Object} cta Call To Action field type - * @return {string} The formatted url associated with the Call to Action object if the cta object exists, null otherwise - */ -export function generateCTAFieldTypeLink(cta) { - if (!cta) { - return null; - } - return CtaFormatter.generateCTAFieldTypeLink(cta); -} +export { generateCTAFieldTypeLink }; /** * Returns a localized price string for the given price field @@ -536,7 +527,7 @@ export function generateCTAFieldTypeLink(cta) { */ export function price(fieldValue = {}, locale) { const localeForFormatting = locale || _getDocumentLocale() || 'en'; - const price = fieldValue.value && parseInt(fieldValue.value); + const price = fieldValue.value && parseFloat(fieldValue.value); const currencyCode = fieldValue.currencyCode && fieldValue.currencyCode.split('-')[0]; if (!price || isNaN(price) || !currencyCode) { console.warn(`No price or currency code in the price fieldValue object: ${fieldValue}`); @@ -544,3 +535,29 @@ export function price(fieldValue = {}, locale) { } return price.toLocaleString(localeForFormatting, { style: 'currency', currency: currencyCode }); } + +/** + * Highlights snippets of the provided fieldValue according to the matched substrings. + * Each match will be wrapped in tags. + * + * @param {string} fieldValue The plain, un-highlighted text. + * @param {Array} matchedSubstrings The list of matched substrings to + * highlight. + */ +export function highlightField(fieldValue, matchedSubstrings = []) { + let highlightedString = fieldValue; + + // We must first sort the matchedSubstrings by decreasing offset. + const sortedMatches = matchedSubstrings.slice() + .sort((match1, match2) => match2.offset - match1.offset); + + sortedMatches.forEach(match => { + const { offset, length } = match; + highlightedString = + highlightedString.substr(0, offset) + + `${fieldValue.substr(offset, length)}`+ + highlightedString.substr(offset + length); + }); + + return highlightedString; +} \ No newline at end of file diff --git a/static/js/formatters.js b/static/js/formatters.js index 6437533fd..2235ff251 100644 --- a/static/js/formatters.js +++ b/static/js/formatters.js @@ -27,7 +27,8 @@ import { openStatus, hoursList, generateCTAFieldTypeLink, - price + price, + highlightField } from './formatters-internal.js'; import * as CustomFormatters from './formatters-custom.js'; @@ -58,7 +59,8 @@ let Formatters = { openStatus, hoursList, generateCTAFieldTypeLink, - price + price, + highlightField }; Formatters = Object.assign(Formatters, CustomFormatters); diff --git a/static/js/formatters/generate-cta-field-type-link.js b/static/js/formatters/generate-cta-field-type-link.js new file mode 100644 index 000000000..8b8ce6aec --- /dev/null +++ b/static/js/formatters/generate-cta-field-type-link.js @@ -0,0 +1,100 @@ +import CtaFormatter from '@yext/cta-formatter'; + +/** + * @param {{link: string, linkType: string}} cta the Calls To Action field object + * @returns {string} The formatted url associated with the Call to Action object if the cta object exists, null otherwise + */ +export function generateCTAFieldTypeLink(cta) { + if (!cta) { + return null; + } + const normalizedCTA = { + ...cta, + linkType: normalizeCtaLinkType(cta.linkType) + } + return CtaFormatter.generateCTAFieldTypeLink(normalizedCTA); +} + +// These translations were taken from the schema for the "Calls To Action" built-in field type +const phoneTranslations = { + cs: 'Telefon', + da: 'Telefonnummer', + de: 'Telefon', + en: 'Phone', + es: 'teléfono', + et: 'Telefon', + fi: 'Puhelin', + fr: 'Numéro de téléphone', + hr: 'Telefon', + hu: 'Telefonszám', + it: 'Telefono', + ja: '電話番号', + lt: 'Telefonas', + lv: 'Tālruņa numurs', + nb: 'Telefon', + nl: 'Telefoonnummer', + pl: 'Telefon', + pt: 'Telefone', + ro: 'Telefon', + sk: 'Telefón', + sv: 'Telefon', + tr: 'Telefon', + zh: '电话', + zh_TW: 'Phone' +}; + +const emailTranslations = { + cs: 'E-mail', + da: 'E-mailadresse', + de: 'E-Mail', + en: 'Email', + es: 'Correo electrónico', + et: 'E-post', + fi: 'Sähköposti', + fr: 'Email', + hr: 'Email', + hu: 'E-mail-cím', + it: 'E-mail', + ja: 'Eメール', + lt: 'El. paštas', + lv: 'E-pasts', + nb: 'E-post', + nl: 'E-mail', + pl: 'E-mail', + pt: 'E-mail', + ro: 'E-mail', + sk: 'E-mail', + sv: 'E-post', + tr: 'E-posta', + zh: '电子邮件', + zh_TW: 'Email' +} + +/** + * Normalizes a CTA's linkType to an enum by translating it to english, + * so it can be used by the @yext/cta-formatter library. + * + * @param {string} linkType + * @returns {string} + */ +function normalizeCtaLinkType(linkType) { + if (isLinkTypeInTranslations(linkType, Object.values(emailTranslations))) { + return 'Email'; + } else if (isLinkTypeInTranslations(linkType, Object.values(phoneTranslations))) { + return 'Phone' + } + return linkType; +} + +/** + * Whether or not the given CTA linkType is included in a translations object's values. + * + * @param {string} linkType + * @param {Array} translations + * @returns {boolean} + */ +function isLinkTypeInTranslations(linkType, translations) { + return !!translations.find(translation => { + return translation.toLowerCase() === linkType.toLowerCase() + }); +} diff --git a/static/js/get-injected-prop.js b/static/js/get-injected-prop.js new file mode 100644 index 000000000..67640bc60 --- /dev/null +++ b/static/js/get-injected-prop.js @@ -0,0 +1,50 @@ +import { isStaging } from './is-staging'; + +/** + * Gets the prop specified by the propPath from the JAMBO_INJECTED_DATA, + * under the given experienceKey. + * + * Will first choose the correct config, based on whether the browser + * is a STAGING or PRODUCTION environment, under + * JAMBO_INJECTED_DATA.answers.experiences[experienceKey].configByLabel. + * Defaults to the top-level config at + * JAMBO_INJECTED_DATA.answers.experiences[experienceKey] if configByLabel + * does not exist. + * + * @param {string} experienceKey + * @param {Array} propPath + * @returns {any} + */ +export function getInjectedProp(experienceKey, propPath) { + const injectedData = JSON.parse(process.env.JAMBO_INJECTED_DATA || '{}'); + const experiences = injectedData?.answers?.experiences; + if (!experiences || !experiences[experienceKey]) { + return undefined; + } + const currentConfig = getConfigForCurrentDomain(injectedData, experiences[experienceKey]); + let propAccumulator = currentConfig; + for (const propName of propPath) { + if (!propAccumulator.hasOwnProperty(propName)) { + return undefined; + } + propAccumulator = propAccumulator[propName]; + } + return propAccumulator; +} + +/** + * Chooses the STAGING or PRODUCTION config, defaulting to the top level config + * if configByLabel does not exist, or if the desired config does not exist. + * + * @param {Object} injectedData + * @param {Object} experienceConfig + * @returns {Object} + */ +function getConfigForCurrentDomain(injectedData, experienceConfig) { + const IS_STAGING = isStaging(injectedData?.pages?.domains); + const { configByLabel } = experienceConfig; + if (!configByLabel) { + return experienceConfig; + } + return (IS_STAGING ? configByLabel.STAGING : configByLabel.PRODUCTION) || experienceConfig; +} diff --git a/static/js/is-highlighted.js b/static/js/is-highlighted.js new file mode 100644 index 000000000..3246be6da --- /dev/null +++ b/static/js/is-highlighted.js @@ -0,0 +1,11 @@ +/** + * Checks if the given field has highlighted substrings. + * + * @param {string} fieldId The field's id. + * @param {Object} highlightedFields An Object, keyed by field id, that includes the + * higlight metadata for all applicable fields. + * @returns {boolean} + */ +export function isHighlighted(fieldId, highlightedFields) { + return Object.keys(highlightedFields).includes(fieldId); +} \ No newline at end of file diff --git a/static/js/locator-bundle.js b/static/js/locator-bundle.js new file mode 100644 index 000000000..4efbee9e1 --- /dev/null +++ b/static/js/locator-bundle.js @@ -0,0 +1,8 @@ +import { VerticalFullPageMapOrchestrator } from './theme-map/VerticalFullPageMapOrchestrator.js' +export { VerticalFullPageMapOrchestrator } + +import { ThemeMap } from './theme-map/ThemeMap.js' +export { ThemeMap } + +import { CardListenerAssigner } from './theme-map/CardListenerAssigner.js' +export { CardListenerAssigner } \ No newline at end of file diff --git a/static/js/theme-map/CardListenerAssigner.js b/static/js/theme-map/CardListenerAssigner.js new file mode 100644 index 000000000..0f11d34b5 --- /dev/null +++ b/static/js/theme-map/CardListenerAssigner.js @@ -0,0 +1,100 @@ +/** + * Responsible for assigning listeners to a locator location card + */ +class CardListenerAssigner { + constructor ({ card }) { + /** + * An answers location card + * + * @type {Answers.Component} + */ + this.card = card; + + /** + * The matcher to determine if the window width is within the mobile breakpoint + * + * @type {MediaQueryList} + */ + this.mobileMediaMatcher = window.matchMedia(`(max-width: 991px)`); + } + + /** + * Add the listeners to the card + */ + addListenersToCard () { + this._addCardClickListener(); + this._addLinkFocusListeners(); + } + + /** + * Set yxt-Card--pinFocused when the card is clicked + */ + _addCardClickListener () { + this.card._container.parentElement.addEventListener('click', () => { + if (this.mobileMediaMatcher.matches) { + return; + } + + const index = this._getCardIndex(); + this._storeCardFocusIndex(index); + this._removePinFocusFromAllCards(); + this._addPinFocusToCard(); + }); + } + + /** + * Set yxt-Card--pinFocused when any HTML element on the card gains focus. + * These include cards titles and CTAs + */ + _addLinkFocusListeners() { + this.card._container.querySelectorAll('a').forEach((el) => { + el.addEventListener('focus', () => { + if (this.mobileMediaMatcher.matches) { + return; + } + + const index = this._getCardIndex(); + this._storeCardFocusIndex(index); + this._removePinFocusFromAllCards(); + this._addPinFocusToCard(); + }) + }); + } + + /** + * Get the index of the card + * + * @returns {number} + */ + _getCardIndex () { + const { _index } = JSON.parse(this.card._container.parentElement.dataset.opts || {}); + return _index; + } + + /** + * Store the index in the ANSWERS storage as the focused locator card + * + * @param {number} index + */ + _storeCardFocusIndex (index) { + this.card.core.storage.set(HitchhikerJS.StorageKeys.LOCATOR_CARD_FOCUS, { index: index }); + } + + /** + * Remove .yxt-Card--pinFocused from all cards + */ + _removePinFocusFromAllCards () { + document.querySelectorAll('.yxt-Card--pinFocused').forEach((el) => { + el.classList.remove('yxt-Card--pinFocused'); + }); + } + + /** + * Add .yxt-Card--pinFocused to the card + */ + _addPinFocusToCard () { + this.card._container.parentElement.classList.add('yxt-Card--pinFocused'); + } +} + +export { CardListenerAssigner }; diff --git a/static/js/theme-map/ClusterPinImages.js b/static/js/theme-map/ClusterPinImages.js new file mode 100644 index 000000000..a82feaaec --- /dev/null +++ b/static/js/theme-map/ClusterPinImages.js @@ -0,0 +1,97 @@ +/** + * ClusterPinImages is meant to offer an accessible way to change the pin images for a cluster + * on the interactive map page. Given some config, an SVG should be customizable to + * have branding consistent styling in this file. + */ +class ClusterPinImages { + /** + * @param {Object} defaultPinConfig The configuration for the default pin + * @param {Object} hoveredPinConfig The configuration for the hovered pin + * @param {Object} selectedPinConfig The configuration for the selected pin + */ + constructor(defaultPinConfig = {}, hoveredPinConfig = {}, selectedPinConfig = {}) { + this.defaultPinConfig = defaultPinConfig; + this.hoveredPinConfig = hoveredPinConfig; + this.selectedPinConfig = selectedPinConfig; + } + + /** + * Generate standard theme pin given some parameters + * @param {string} pin.backgroundColor Background color for the pin + * @param {string} pin.strokeColor Stroke (border) color for the pin + * @param {string} pin.labelColor Label (text) color for the pin + * @param {string} pin.width The width of the pin + * @param {string} pin.height The height of the pin + * @param {string} pin.labelText The label text for the cluster pin (normally size of cluster) + * @return string The SVG of the pin + */ + generatePin ({ + backgroundColor = '#00759e', + strokeColor = 'black', + labelColor = 'white', + width = '24px', + height= '24px', + labelText = '' + } = {}) { + return ` + + + + + ${labelText} + + + + `; + }; + + /** + * Get the default pin image + * @param {Number} pinCount The number of pins in the cluster, for the pin label + * @param {Object} profile The profile data for the entity associated with the pin + */ + getDefaultPin (pinCount, profile) { + return this.generatePin({ + backgroundColor: this.defaultPinConfig.backgroundColor, + strokeColor: this.defaultPinConfig.strokeColor, + labelColor: this.defaultPinConfig.labelColor, + width: '24px', + height: '24px', + labelText: pinCount, + }); + } + + /** + * Get the hovered pin image + * @param {Number} pinCount The number of pins in the cluster, for the pin label + * @param {Object} profile The profile data for the entity associated with the pin + */ + getHoveredPin (pinCount, profile) { + return this.generatePin({ + backgroundColor: this.hoveredPinConfig.backgroundColor, + strokeColor: this.hoveredPinConfig.strokeColor, + labelColor: this.hoveredPinConfig.labelColor, + width: '24px', + height: '24px', + labelText: pinCount, + }); + } + + /** + * Get the selected pin image + * @param {Number} pinCount The number of pins in the cluster, for the pin label + * @param {Object} profile The profile data for the entity associated with the pin + */ + getSelectedPin (pinCount, profile) { + return this.generatePin({ + backgroundColor: this.selectedPinConfig.backgroundColor, + strokeColor: this.selectedPinConfig.strokeColor, + labelColor: this.selectedPinConfig.labelColor, + width: '24px', + height: '24px', + labelText: pinCount, + }); + } +} + +export { ClusterPinImages }; diff --git a/static/js/theme-map/Geo/Coordinate.js b/static/js/theme-map/Geo/Coordinate.js new file mode 100644 index 000000000..1893a0a92 --- /dev/null +++ b/static/js/theme-map/Geo/Coordinate.js @@ -0,0 +1,288 @@ +import { Unit, Projection, EARTH_RADIUS_MILES, EARTH_RADIUS_KILOMETERS } from './constants.js'; + +/** + * An array of property names to check in a Coordinate-like object for a value of or function that evaluates to degrees latitude + * @memberof Coordinate + * @inner + * @constant {string[]} + * @default + */ +const LATITUDE_ALIASES = ['latitude', 'lat']; + +/** + * An array of property names to check in a Coordinate-like object for a value of or function that evaluates to degrees longitude + * @memberof Coordinate + * @inner + * @constant {string[]} + * @default + */ +const LONGITUDE_ALIASES = ['longitude', 'lon', 'lng', 'long']; + +/** + * Find a truthy or 0 value in an object, searching the given keys + * @memberof Coordinate + * @inner + * @param {Object} object Object to find a value in + * @param {string[]} keys Keys to search in object + * @returns {*} The value found, or undefined if not found + */ +function findValue(object, keys) { + for (const key of keys) { + if (object[key] || object[key] === 0) { + return object[key]; + } + } +} + +/** + * @memberof Coordinate + * @inner + * @param {*} value + * @returns {number} + * @throws Will throw an error if value cannot be converted to a number. + */ +function forceNumber(value) { + switch (typeof value) { + case 'string': + case 'number': + const parsed = Number.parseFloat(value); + if (Number.isNaN(parsed)) { + throw new Error(`'${value}' must be convertible to a Number'`); + } + return parsed; + default: + throw new Error(`typeof '${value}' must be a number or a string that can be converted to a number, is '${typeof value}'`); + } +} + +/** + * @memberof Coordinate + * @inner + * @param {number} degrees + * @returns {number} Radians + */ +function degreesToRadians(degrees) { + return degrees * Math.PI / 180; +} + +/** + * @memberof Coordinate + * @inner + * @param {number} radians + * @returns {number} Degrees + */ +function radiansToDegrees(radians) { + return radians / Math.PI * 180; +} + +/** + * Calculate distance between two points using the [Haversine Formula]{@link https://en.wikipedia.org/wiki/Haversine_formula} + * @memberof Coordinate + * @inner + * @param {Coordinate} source + * @param {Coordinate} destination + * @returns {number} + */ +function haversineDistance(source, dest) { + const lat1Rads = degreesToRadians(source.latitude); + const lat2Rads = degreesToRadians(dest.latitude); + const deltaLat = lat2Rads - lat1Rads; + const deltaLon = degreesToRadians(dest.longitude - source.longitude); + + const a = Math.pow(Math.sin(deltaLat / 2), 2) + Math.cos(lat1Rads) * Math.cos(lat2Rads) * Math.pow(Math.sin(deltaLon / 2), 2); + return 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +/** + * Calculate the distance between two Mercator-projected latitudes in radians of longitude. + * In Mercator Projection, visual distance between longitudes is always the same but visual distance + * between latitudes is lowest at the equator and highest towards the poles. + * @memberof Coordinate + * @inner + * @param {number} latitudeA The source latitude in degrees + * @param {number} latitudeB The destination latitude in degrees + * @returns {number} Distance in radians of longitude + */ +function mercatorLatDistanceInRadians(latitudeA, latitudeB) { + const aTan = Math.tan(Math.PI / 360 * (latitudeA + 90)); + const bTan = Math.tan(Math.PI / 360 * (latitudeB + 90)); + + return Math.log(bTan / aTan); +} + +/** + * Add radians of longitude to a Mercator-projected latitude. + * In Mercator Projection, visual distance between longitudes is always the same but visual distance + * between latitudes is lowest at the equator and highest towards the poles. + * @memberof Coordinate + * @inner + * @param {number} startingLat The source latitude in degrees + * @param {number} radians Distance in radians of longitude + * @returns {number} The destination latitude in degrees + */ +function mercatorLatAddRadians(startingLat, radians) { + const aTan = Math.tan(Math.PI / 360 * (startingLat + 90)); + const bTan = aTan * Math.pow(Math.E, radians); + + return Math.atan(bTan) * 360 / Math.PI - 90; +} + +/** + * This class represents a point on a sphere defined by latitude and longitude. + * Latitude is a degree number in the range [-90, 90]. + * Longitude is a degree number without limits but is normalized to [-180, 180). + */ +class Coordinate { + /** + * Constructor takes either 1 or 2 arguments. + * 2 arguments: latitude and longitude. + * 1 argument: an object with at least one [latitude alias]{@link Coordinate~LATITUDE_ALIASES} and one one [longitude alias]{@link Coordinate~LONGITUDE_ALIASES}. + * @param {number|Object} latitudeOrObject + * @param {number} [longitude] Optional only if the first argument is a Coordinate-like object + */ + constructor(latitudeOrObject, longitude) { + let latitude = latitudeOrObject; + + if (typeof latitudeOrObject == 'object') { + latitude = findValue(latitudeOrObject, LATITUDE_ALIASES); + longitude = findValue(latitudeOrObject, LONGITUDE_ALIASES); + + latitude = typeof latitude == 'function' ? latitude() : latitude; + longitude = typeof longitude == 'function' ? longitude() : longitude; + } + + this.latitude = latitude; + this.longitude = longitude; + } + + /** + * Degrees latitude in the range [-90, 90]. + * If setting a value outside this range, it will be set to -90 or 90, whichever is closer. + * @type {number} + */ + get latitude() { + return this._lat; + } + + /** + * Degrees longitude in the range [-Infinity, Infinity]. + * @type {number} + */ + get longitude() { + return this._lon; + } + + /** + * Degrees longitude in the range [-180, 180). + * If the coordinate's longitude is outside this range, the equivalent value within it is used. + * Examples: 123 => 123, 270 => -90, -541 => 179 + * @type {number} + * @readonly + */ + get normalLon() { + return ((this._lon + 180) % 360 + 360) % 360 - 180 + } + + set latitude(newLat) { + this._lat = Math.max(-90, Math.min(forceNumber(newLat), 90)); + } + + set longitude(newLon) { + this._lon = forceNumber(newLon); + } + + /** + * Add distance to the coordinate to change its position. + * @param {number} latDist latitude distance + * @param {number} lonDist longitude distance + * @param {Geo.Unit} [unit=Unit.DEGREE] The unit of latDist and lonDist + * @param {Geo.Projection} [projection=Projection.SPHERICAL] The projection of Earth (not relevant when using a physical distance unit, e.g. Mile) + */ + add(latDist, lonDist, unit = Unit.DEGREE, projection = Projection.SPHERICAL) { + if (projection == Projection.MERCATOR && (unit == Unit.DEGREE || unit == Unit.RADIAN)) { + const latDistRad = unit == Unit.DEGREE ? degreesToRadians(latDist) : latDist; + const lonDistDeg = unit == Unit.DEGREE ? lonDist : radiansToDegrees(lonDist); + + this.latitude = mercatorLatAddRadians(this.latitude, latDistRad); + this.longitude += lonDistDeg; + } else { + switch (unit) { + case Unit.DEGREE: + this.latitude += latDist; + this.longitude += lonDist; + break; + case Unit.KILOMETER: + this.latitude += radiansToDegrees(latDist) * EARTH_RADIUS_KILOMETERS; + this.longitude += radiansToDegrees(lonDist) * EARTH_RADIUS_KILOMETERS * Math.cos(degreesToRadians(this.latitude)); + break; + case Unit.MILE: + this.latitude += radiansToDegrees(latDist) * EARTH_RADIUS_MILES; + this.longitude += radiansToDegrees(lonDist) * EARTH_RADIUS_MILES * Math.cos(degreesToRadians(this.latitude)); + break; + case Unit.RADIAN: + this.latitude += radiansToDegrees(latDist); + this.longitude += radiansToDegrees(lonDist); + break; + } + } + } + + /** + * Calculate the distance from this coordinate to another coordinate. + * @param {Coordinate} coordinate + * @param {Geo.Unit} [unit=Unit.MILE] The unit of distance + * @param {Geo.Projection} [projection=Projection.SPHERICAL] The projection of Earth (not relevant when using a physical distance unit, e.g. Mile) + * @returns {number} Distance in the requested unit + */ + distanceTo(coordinate, unit = Unit.MILE, projection = Projection.SPHERICAL) { + if (projection == Projection.MERCATOR && (unit == Unit.DEGREE || unit == Unit.RADIAN)) { + const latDist = mercatorLatDistanceInRadians(this.latitude, coordinate.latitude); + const absoluteLonDist = Math.abs(coordinate.normalLon - this.normalLon); + const lonDist = degreesToRadians(Math.min(absoluteLonDist, 360 - absoluteLonDist)); + + const radianDist = Math.sqrt(Math.pow(latDist, 2) + Math.pow(lonDist, 2)); + + switch (unit) { + case Unit.DEGREE: + return radiansToDegrees(radianDist); + case Unit.RADIAN: + return radianDist; + } + } else { + const radianDist = haversineDistance(this, coordinate); + + switch (unit) { + case Unit.DEGREE: + return radiansToDegrees(radianDist); + case Unit.KILOMETER: + return radianDist * EARTH_RADIUS_KILOMETERS; + case Unit.MILE: + return radianDist * EARTH_RADIUS_MILES; + case Unit.RADIAN: + return radianDist; + } + } + } + + /** + * Test if this coordinate has the same latitude and longitude as another. + * @param {Coordinate} coordinate + * @returns {boolean} + */ + equals(coordinate) { + return coordinate && coordinate.latitude === this.latitude && coordinate.longitude === this.longitude; + } + + /** + * Get the coordinate as a string that can be used in a search query. + * Example: {latitude: -45, longitude: 123} => '-45,123' + * @returns {string} + */ + searchQueryString() { + return `${this.latitude},${this.longitude}`; + } +} + +export { + Coordinate +}; diff --git a/static/js/theme-map/Geo/GeoBounds.js b/static/js/theme-map/Geo/GeoBounds.js new file mode 100644 index 000000000..8183249d0 --- /dev/null +++ b/static/js/theme-map/Geo/GeoBounds.js @@ -0,0 +1,133 @@ +import { Unit, Projection } from './constants.js'; +import { Coordinate } from './Coordinate.js'; +import { getNormalizedLongitude } from '../Util/helpers.js'; + +/** + * This class represents a bounded coordinate region of a sphere. + * The bounds are defined by two [Coordinates]{@link Coordinate}: southwest and northeast. + * If the northeast coordinate does not have a greater latitude and longitude than the soutwest + * coordinate, the behavior of this object is undefined. + */ +class GeoBounds { + /** + * Create a new GeoBounds with minimal area that contains all the given coordinates + * @param {Coordinate[]} coordinates + * @returns {GeoBounds} + */ + static fit(coordinates) { + // North/South bounds are the northernmost and southernmost points + const latitudes = coordinates.map(coordinate => coordinate.latitude); + const north = Math.max(...latitudes); + const south = Math.min(...latitudes); + + // East/West bounds need to be chosen to minimize the area that fits all pins + // Choose them by finding the largest area with no pins + const longitudes = coordinates + .map(coordinate => coordinate.normalLon) + .sort((i, j) => i - j); + + const splitIndex = longitudes + .map((longitude, i) => { + const next = i < longitudes.length - 1 ? longitudes[i + 1] : longitudes[0] + 360; + return { distance: next - longitude, index: i }; + }) + .reduce((max, distance) => distance.distance > max.distance ? distance : max) + .index; + + const east = longitudes[splitIndex]; + const west = longitudes[(splitIndex + 1) % longitudes.length]; + + return new this(new Coordinate(south, west), new Coordinate(north, east)); + } + + /** + * @param {Coordinate} sw Southwest coordinate + * @param {Coordinate} ne Northeast coordinate + */ + constructor(sw, ne) { + this._ne = new Coordinate(ne); + this._sw = new Coordinate(sw); + } + + /** + * Northeast coordinate + * @type {Coordinate} + */ + get ne() { + return this._ne; + } + + /** + * Southwest coordinate + * @type {Coordinate} + */ + get sw() { + return this._sw; + } + + set ne(newNE) { + this._ne = new Coordinate(newNE); + } + + set sw(newSW) { + this._sw = new Coordinate(newSW); + } + + /** + * Whether the coordinate lies within the region defined by the bounds. + * [Normalized longitudes]{@link Coordinate#normalLon} are used for the bounds and the coordinate. + * @param {Coordinate} coordinate + * @returns {boolean} + */ + contains(coordinate) { + const withinLatitude = this._sw.latitude <= coordinate.latitude && coordinate.latitude <= this._ne.latitude; + const longitudeSpansGlobe = this._ne.longitude - this._sw.longitude >= 360; + const withinNormalLon = this._sw.normalLon <= this._ne.normalLon ? + (this._sw.normalLon <= coordinate.normalLon && coordinate.normalLon <= this._ne.normalLon) : + (this._sw.normalLon <= coordinate.normalLon || coordinate.normalLon <= this._ne.normalLon); + + return withinLatitude && (longitudeSpansGlobe || withinNormalLon); + } + + /** + * Extend the bounds if necessary so that the coordinate is contained by them. + * @param {Coordinate} coordinate + */ + extend(coordinate) { + this._ne.latitude = Math.max(this._ne.latitude, coordinate.latitude); + this._sw.latitude = Math.min(this._sw.latitude, coordinate.latitude); + + if (!this.contains(coordinate)) { + const eastDist = ((coordinate.longitude - this._ne.longitude) % 360 + 360) % 360; + const westDist = ((this._sw.longitude - coordinate.longitude) % 360 + 360) % 360; + + if (eastDist < westDist) { + this._ne.longitude += eastDist; + } else { + this._sw.longitude -= westDist; + } + } + } + + /** + * Calculate the center of the bounds using the given projection. + * To find the visual center on a Mercator map, use Projection.MERCATOR. + * To find the center for geolocation or geosearch purposes, use Projection.SPHERICAL. + * @param {Geo.Projection} [projection=Projection.SPHERICAL] + * @returns {Coordinate} + */ + getCenter(projection = Projection.SPHERICAL) { + const nw = new Coordinate(this._ne.latitude, this._sw.longitude); + const latDist = this._sw.distanceTo(nw, Unit.DEGREE, projection); + const newLon = (nw.longitude + this._ne.longitude) / 2 + (this._ne.longitude < nw.longitude ? 180 : 0); + + nw.add(-latDist / 2, 0, Unit.DEGREE, projection); + nw.longitude = getNormalizedLongitude(newLon); + + return nw; + } +} + +export { + GeoBounds +}; diff --git a/static/js/theme-map/Geo/constants.js b/static/js/theme-map/Geo/constants.js new file mode 100644 index 000000000..3d17cde63 --- /dev/null +++ b/static/js/theme-map/Geo/constants.js @@ -0,0 +1,49 @@ +/** @namespace Geo */ + +/** + * @memberof Geo + * @enum {Symbol} + * @property {Symbol} DEGREE + * @property {Symbol} KILOMETER + * @property {Symbol} MILE + * @property {Symbol} RADIAN + * @readonly + */ +const Unit = Object.freeze({ + DEGREE: Symbol('deg'), + KILOMETER: Symbol('km'), + MILE: Symbol('mi'), + RADIAN: Symbol('r') +}); + +/** + * @memberof Geo + * @enum {Symbol} + * @property {Symbol} MERCATOR [Mercator Projection]{@link https://en.wikipedia.org/wiki/Mercator_projection} for flat maps of Earth + * @property {Symbol} SPHERICAL Earth as a sphere, a model approximately equal to the real Earth + * @readonly + */ +const Projection = Object.freeze({ + MERCATOR: Symbol('mercator'), + SPHERICAL: Symbol('spherical') +}); + +/** + * @memberof Geo + * @constant {number} + * @default + */ +const EARTH_RADIUS_MILES = 3959; +/** + * @memberof Geo + * @constant {number} + * @default + */ +const EARTH_RADIUS_KILOMETERS = 6371; + +export { + Unit, + Projection, + EARTH_RADIUS_MILES, + EARTH_RADIUS_KILOMETERS +}; diff --git a/static/js/theme-map/Historian/Historian.js b/static/js/theme-map/Historian/Historian.js new file mode 100644 index 000000000..58de31cd1 --- /dev/null +++ b/static/js/theme-map/Historian/Historian.js @@ -0,0 +1,123 @@ +import { Type, assertType, assertInstance } from '../Util/Assertions.js'; +import { stateChanged } from './helpers.js'; + +class HistorianOptions { + constructor() { + this.stateHandler = data => {}; + this.titleForState = data => document.title; + this.urlPathQueryForState = data => window.location.pathname + window.location.search; + } + + /** + * stateHandler: function(data) + * Called when a browser history event occurs, specifically when the ‘popstate’ + * event occurs. It is given the data saved in the state, equivalent to + * window.history.state. If the state is null, this function is not called. + */ + withStateHandler(stateHandler) { + assertType(stateHandler, Type.FUNCTION); + + this.stateHandler = stateHandler; + return this; + } + + /** + * titleForState: function(data) => string + * Gets the title of the page for a given state (search response data) + */ + withTitleForState(titleForState) { + assertType(titleForState, Type.FUNCTION); + + this.titleForState = titleForState; + return this; + } + + /** + * urlPathQueryForState: function(data) => string + * Gets the URL path and query for a given state (search response data) + */ + withURLPathQueryForState(urlPathQueryForState) { + assertType(urlPathQueryForState, Type.FUNCTION); + + this.urlPathQueryForState = urlPathQueryForState; + return this; + } + + build() { + return new Historian(this); + } +} + +class Historian { + constructor(options) { + assertInstance(options, HistorianOptions); + + this._stateHandler = options.stateHandler; + this._titleForState = options.titleForState; + this._urlPathQueryForState = options.urlPathQueryForState; + this._localClone = [null]; + + // Assigning stateHandler wrapper directly to window.onpopstate + // so that creating a new Historian will disable the active one + window.onpopstate = e => { + const state = (e && e.state) || window.history.state; + if (typeof state == 'number' && this._localClone[state]) { + // Fallback for when there is too much data to save to the state + this._stateHandler(this._localClone[state]); + } else if (state) { + this._stateHandler(state); + } + }; + } + + /** + * restoreState(data = window.history.state) + * Calls stateHander(data) + */ + restoreState(data = window.history.state) { + this._stateHandler(data); + } + + /** + * saveState(data, replace = false) + * Saves data (response object from the Oracle) to history to be retrieved + * when the browser navigates back. If replace == true, it will use + * window.replaceState instead of window.pushState to save the history. + */ + saveState(data, replace = false) { + const title = this._titleForState(data); + const url = this._urlPathQueryForState(data); + const historyState = this._localClone[this._localClone.length - 1] + ? this._localClone[this._localClone.length - 1] + : window.history.state; + + if (replace || !stateChanged(data, url, historyState)) { + try { + window.history.replaceState(data, title, url); + this._localClone[this._localClone.length - 1] = null; + } catch (e) { + if (e.name === 'DataCloneError') { + console.warn('Historian: Too much data to replace the state'); + window.history.replaceState(this._localClone.length - 1, title, url); + this._localClone[this._localClone.length - 1] = data; + } + } + } else { + try { + window.history.pushState(data, title, url); + this._localClone.push(null); + } catch (e) { + if (e.name === 'DataCloneError') { + console.warn('Historian: Too much data to push the state'); + window.history.pushState(this._localClone.length, title, url); + this._localClone.push(data); + } + } + } + } +} + +export { + Historian, + HistorianOptions +}; diff --git a/static/js/theme-map/Historian/helpers.js b/static/js/theme-map/Historian/helpers.js new file mode 100644 index 000000000..a677473e7 --- /dev/null +++ b/static/js/theme-map/Historian/helpers.js @@ -0,0 +1,40 @@ +import URI from 'urijs'; + +// This function tests whether a history state is the same as the current state. +// It is used to prevent duplicate states being pushed to the history. +function stateChanged(state, url, historyState) { + const currentUrl = window.location.href; + const newUrl = URI(url).absoluteTo(window.location.href).toString(); + // Compare URLs + if (newUrl != currentUrl) { + return true; + } + + let currentStateString; + let newStateString; + try { + currentStateString = JSON.stringify(historyState); + } catch (err) {} + try { + newStateString = JSON.stringify(state); + } catch (err) {} + // Compare stringified objects + // If strings are different, or if one is a string and the other is undefined, + // the state objects are different. + if (newStateString !== currentStateString) { + return true; + } + + // If neither object could be parsed by JSON.stringify, assume the states are different. + // Not necessarily true, but a deep comparison is complex and slow. + if (newStateString === currentStateString && typeof newStateString == 'undefined') { + return true; + } + + // The titles, URLs, and state objects are the same. + return false; +} + +export { + stateChanged +}; diff --git a/static/js/theme-map/Locator/Maestro.js b/static/js/theme-map/Locator/Maestro.js new file mode 100644 index 000000000..28abaf10c --- /dev/null +++ b/static/js/theme-map/Locator/Maestro.js @@ -0,0 +1,133 @@ +import { Type, assertType, assertInstance } from '../Util/Assertions.js'; +import { Historian, HistorianOptions } from '../Historian/Historian.js'; +import { Renderer, RendererOptions } from '../Renderer/Renderer.js'; +import { Oracle, OracleOptions } from './Oracle.js'; + + +class MaestroOptions extends OracleOptions { + constructor(searchForm) { + super(searchForm); + + this.dataPreprocessor = async data => data; + this.renderer = new RendererOptions().build(); + this.templateData = {}; + + this.historian = new HistorianOptions() + .withStateHandler(data => this.renderer.render(data)) + .withURLPathQueryForState(data => `${window.location.pathname}?${data.query}`) + .build(); + } + + /** + * dataPreprocessor: async function(data) => data + * Function called first after receiving search response data + * Can be used to modify results, such as custom sorting and filtering. + */ + withDataPreprocessor(cb) { + assertType(cb, Type.FUNCTION); + + this.dataPreprocessor = cb; + return this; + } + + /** + * historian: Historian + */ + withHistorian(historian) { + assertInstance(historian, Historian); + + this.historian = historian; + return this; + } + + /** + * renderer: Renderer + */ + withRenderer(renderer) { + assertInstance(renderer, Renderer); + + this.renderer = renderer; + return this; + } + + /** + * templateData: Object + * Additional data that will be added to the search response data and passed + * to the renderer + * This could be data that was put on the page by the soy template, for example: + * {“baseUrl”:”../”,”ctaText”:”Visit Store Website”}, etc. + */ + withTemplateData(templateData) { + this.templateData = templateData; + return this; + } + + build() { + return new Maestro(this); + } +} + +class Maestro extends Oracle { + constructor(options) { + assertInstance(options, MaestroOptions); + + super(options); + + this._historian = options.historian; + this._renderer = options.renderer; + this._templateData = options.templateData; + + this._submitCallback = async data => { + Object.assign(data, this._templateData); + data = await options.dataPreprocessor(data); + this._historian.saveState(data, !window.history.state); + this.render(data); + options.submitCallback(data); + }; + + if ((window.history.state || {}).query) { + // If navigating back from a different page, the state will still be there. + this._historian.restoreState(); + } else { + this.submit(); + } + } + + /** + * addRenderTarget(renderTarget) + * Calls renderer.register(renderTarget) + */ + addRenderTarget(renderTarget) { + this._renderer.register(renderTarget); + } + + /** + * removeRenderTarget(renderTarget) => bool + * Calls renderer.deregister(renderTarget) and returns result + */ + removeRenderTarget(renderTarget) { + return this._renderer.deregister(renderTarget); + } + + /** + * render(data) + * Calls renderer.render(data) + */ + render(data) { + this._renderer.render(data); + } + + /** + * searchAndRender(searchURL) + * Calls Oracle.search(searchURL) and passes response data to submitCallback + * Can be used to render special queries, such as all locations from a data URL + */ + searchAndRender(searchURL) { + this.constructor.search(searchURL).then(this._submitCallback.bind(this)); + } +} + +export { + Maestro, + MaestroOptions +}; diff --git a/static/js/theme-map/Locator/Oracle.js b/static/js/theme-map/Locator/Oracle.js new file mode 100644 index 000000000..d3316b65c --- /dev/null +++ b/static/js/theme-map/Locator/Oracle.js @@ -0,0 +1,195 @@ +import Raven from 'raven-js/dist/raven.js'; +import URI from 'urijs'; +import { Type, assertType, assertInstance } from '../Util/Assertions.js'; +import { SearchForm } from '../SearchForm/SearchForm.js'; + +// GENERATOR TODO (jronkin): Move Raven setup to a central location +Raven.config('https://13d7b1cf5db9462a8a6121dfd4d032c5@sentry.io/751790').install(); + +const fetchGetJSON = { + method: 'GET', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json' + } +}; + +class OracleOptions { + constructor(searchForm) { + assertInstance(searchForm, SearchForm); + + this.searchForm = searchForm; + this.beforeSubmit = async () => {}; + this.errorHandler = err => console.error(err); + this.finalHandler = () => {}; + this.submitCallback = data => {}; + } + + /** + * beforeSubmit: function() + * Function called before submission to do any required search setup + */ + withBeforeSubmit(cb) { + assertType(cb, Type.FUNCTION); + + this.beforeSubmit = cb; + return this; + } + + /** + * errorHandler: function(error) + * Function called to handle submission error + */ + withErrorHandler(cb) { + assertType(cb, Type.FUNCTION); + + this.errorHandler = cb; + return this; + } + + /** + * finalHandler: function() + * Function called after submission whether successful or not + */ + withFinalHandler(cb) { + assertType(cb, Type.FUNCTION); + + this.finalHandler = cb; + return this; + } + + /** + * submitCallback: function(data) + * Function called with the results of a submission search + */ + withSubmitCallback(cb) { + assertType(cb, Type.FUNCTION); + + this.submitCallback = cb; + return this; + } + + build() { + return new Oracle(this); + } +} + +class Oracle { + /** + * static async getAllResults(data, limit = Infinity) => data + * limit is the maximum number of results, including results from the given data + */ + static async getAllResults(data, limit = Infinity) { + if (!( + data + && data.response + && data.response.count + && data.response.entities + && data.searchURL + )) { + return data; + } + + const searches = [Promise.resolve(data)]; + const per = 50; // Max number of entities allowed per response + const totalEntities = Math.min(data.response.count, limit); + const searchURI = new URI(data.searchURL) + .removeQuery('per') + .addQuery('per', per); + + for (let count = data.response.entities.length; count < totalEntities; count += per) { + if (count + per > limit) { + searchURI.removeQuery('per').addQuery('per', limit - count); + } + + searches.push(this.search(searchURI + .removeQuery('offset') + .addQuery('offset', count) + .toString() + )); + } + + return Promise.all(searches).then(responses => { + data.response.entities = responses + .reduce((allEntities, newData) => allEntities.concat(newData.response.entities), []); + return data; + }); + } + + /** + * static async search(searchURL) => data + * Requests JSON from searchURL + */ + static async search(searchURL) { + try { + const response = await fetch(searchURL, fetchGetJSON); + const data = await response.json(); + data.searchURL = data.searchURL || searchURL; + return data; + } catch(err) { + Raven.captureException(err, { + extra: { queryString: searchURL } + }); + return Promise.reject(err); + } + } + + constructor(options) { + assertInstance(options, OracleOptions); + + this._beforeSubmit = options.beforeSubmit; + this._errorHandler = options.errorHandler; + this._finalHandler = options.finalHandler; + this._searchForm = options.searchForm; + this._submitCallback = options.submitCallback; + + this._searchForm.formElement.addEventListener('submit', this.submitHandler.bind(this)); + } + + /** + * getSearchForm() => SearchForm + */ + getSearchForm() { + return this._searchForm; + } + + /** + * submit() + * calls searchForm.submit() + */ + submit() { + this._searchForm.submit(); + } + + /** + * async submitHandler(e) + * Submits form with ajax search, where e is optional event + * Calls static search with searchURL = searchForm.formElement.action + searchForm.buildQuery() + * Passes search response to submitCallback with added data.searchURL = searchURL + * and data.query = searchForm.buildQuery() + * This function is assigned as submit handler to form. + */ + async submitHandler(e) { + if (e && typeof e.preventDefault == Type.FUNCTION) { + e.preventDefault(); + } + + await this._beforeSubmit(); + + const query = this._searchForm.buildQuery(); + if (query) { + const searchURL = new URI(this._searchForm.formElement.action).query(query).toString(); + this.constructor.search(searchURL) + .then(data => this._submitCallback(Object.assign(data, { query }))) + .catch(this._errorHandler) + .finally(this._finalHandler); + } else { + this._finalHandler(); + } + } +} + +export { + Oracle, + OracleOptions +}; diff --git a/static/js/theme-map/Maps/Map.js b/static/js/theme-map/Maps/Map.js new file mode 100644 index 000000000..d94617d20 --- /dev/null +++ b/static/js/theme-map/Maps/Map.js @@ -0,0 +1,823 @@ +import { Unit, Projection } from '../Geo/constants.js'; +import { Coordinate } from '../Geo/Coordinate.js'; +import { GeoBounds } from '../Geo/GeoBounds.js'; +import { Type, assertType, assertInstance } from '../Util/Assertions.js'; +import { MapPinOptions } from './MapPin.js'; +import { MapProvider } from './MapProvider.js'; +import { ProviderMapOptions } from './ProviderMap.js'; +import ZoomTriggers from './ZoomTriggers.js'; +import PanTriggers from './PanTriggers.js'; + +/** + * The maximum percent of the map height or width that can be taken up by padding. + * It's a number arbitrarily close to 1, because if total padding is >= 1 there's no space for pins. + * This shouldn't need to be changed. To set a Map's padding use {@link Map#setPadding}. + * @memberof Map + * @inner + * @constant {number} + * @default + */ +const MAX_PADDING = 0.98; + +/** + * Padding values are given in pixels as a number or a function that returns a number. + * They need to be converted to a non-negative fraction of the map's current height or width. + * @param {number|Map~paddingFunction} value Minimum number of pixels between the map's edge and a pin + * @param {number} basis The measurement that the padding will be a fraction of + * @returns {number} The padding value as a fraction of basis + */ +function normalizePadding(value, basis) { + return Math.max(typeof value == Type.FUNCTION ? value() : value || 0, 0) / basis; +} + +/** + * {@link Map} options class + */ +class MapOptions { + /** + * Initialize with default options + */ + constructor() { + this.controlEnabled = true; + this.defaultCenter = new Coordinate(39.83, -98.58); // Center of the USA + this.defaultZoom = 4; + this.legendPins = []; + this.padding = { bottom: () => 50, left: () => 50, right: () => 50, top: () => 50 }; + this.panHandler = (previousBounds, currentBounds) => {}; + this.panStartHandler = currentBounds => {}; + this.dragEndHandler = () => {}; + this.zoomChangedHandler = () => {}; + this.zoomEndHandler = () => {}; + this.canvasClickHandler = () => {}; + this.provider = null; + this.providerOptions = {}; + this.singlePinZoom = 14; + this.wrapper = null; + } + + /** + * @param {boolean} controlEnabled Whether the user can move and zoom the map + * @returns {MapOptions} + */ + withControlEnabled(controlEnabled) { + this.controlEnabled = controlEnabled; + return this; + } + + /** + * @param {Coordinate} defaultCenter The center on initial load and when calling {@link Map#fitCoordinates} with an empty array + * @returns {MapOptions} + */ + withDefaultCenter(defaultCenter) { + this.defaultCenter = new Coordinate(defaultCenter); + return this; + } + + /** + * @param {number} defaultZoom The zoom on initial load and when calling {@link Map#fitCoordinates} with an empty array + * @returns {MapOptions} + */ + withDefaultZoom(defaultZoom) { + this.defaultZoom = defaultZoom; + return this; + } + + /** + * @todo GENERATOR TODO Map legend not yet implemented + * @param {MapPin[]} legendPins Pins used to construct the map legend + * @returns {MapOptions} + */ + withLegendPins(legendPins) { + this.legendPins = Array.from(legendPins); + return this; + } + + /** + * Padding is used by {@link Map#fitCoordinates}. + * Padding can either be constant values or funtions that return a padding value. + * See {@link Map#setPadding} for more information. + * @param {Object} padding + * @param {number|Map~paddingFunction} padding.bottom Minimum number of pixels between the map's bottom edge and a pin + * @param {number|Map~paddingFunction} padding.left Minimum number of pixels between the map's left edge and a pin + * @param {number|Map~paddingFunction} padding.right Minimum number of pixels between the map's right edge and a pin + * @param {number|Map~paddingFunction} padding.top Minimum number of pixels between the map's top edge and a pin + * @returns {MapOptions} + * @see {@link Map#setPadding} + */ + withPadding(padding) { + this.padding = padding; + return this; + } + + /** + * @typedef Map~panHandler + * @function + * @param {GeoBounds} previousBounds The map bounds before the move + * @param {GeoBounds} currentBounds The map bounds after the move + */ + + /** + * @param {Map~panHandler} panHandler + * @returns {MapOptions} + */ + withPanHandler(panHandler) { + assertType(panHandler, Type.FUNCTION); + + this.panHandler = panHandler; + return this; + } + + /** + * @typedef Map~panStartHandler + * @function + * @param {GeoBounds} currentBounds The map bounds before the move + */ + + /** + * @param {Map~panStartHandler} panStartHandler + * @returns {MapOptions} + */ + withPanStartHandler(panStartHandler) { + assertType(panStartHandler, Type.FUNCTION); + + this.panStartHandler = panStartHandler; + return this; + } + + /** + * @typedef Map~dragEndHandler + * @function + */ + + /** + * @param {Map~dragEndHandler} dragEndHandler + * @returns {MapOptions} + */ + withDragEndHandler (dragEndHandler) { + assertType(dragEndHandler, Type.FUNCTION); + + this.dragEndHandler = dragEndHandler; + return this; + } + + /** + * @typedef Map~zoomChangedHandler + * @function + */ + + /** + * @param {Map~zoomChangedHandler} zoomChangedHandler + * @returns {MapOptions} + */ + withZoomChangedHandler (zoomChangedHandler) { + assertType(zoomChangedHandler, Type.FUNCTION); + + this.zoomChangedHandler = zoomChangedHandler; + return this; + } + + /** + * @typedef Map~zoomEndHandler + * @function + */ + + /** + * @param {Map~zoomEndHandler} zoomEndHandler + * @returns {MapOptions} + */ + withZoomEndHandler (zoomEndHandler) { + assertType(zoomEndHandler, Type.FUNCTION); + + this.zoomEndHandler = zoomEndHandler; + return this; + } + + /** + * @typedef Map~canvasClickHandler + * @function + */ + + /** + * @param {Map~canvasClickHandler} canvasClickHandler + * @returns {MapOptions} + */ + withCanvasClickHandler (mapCanvasClickHandler) { + assertType(canvasClickHandler, Type.FUNCTION); + + this.canvasClickHandler = canvasClickHandler; + return this; + } + + /** + * The MapProvider must be loaded before constructing a Map with it. + * @param {MapProvider} provider + * @returns {MapOptions} + */ + withProvider(provider) { + assertInstance(provider, MapProvider); + + this.provider = provider; + return this; + } + + /** + * @param {Object} providerOptions A free-form object used to set any additional provider-specific options in the {@link ProviderMap} + * @returns {MapOptions} + */ + withProviderOptions(providerOptions) { + this.providerOptions = providerOptions; + return this; + } + + /** + * @param {number} singlePinZoom The zoom when calling {@link Map#fitCoordinates} with an array containing one coordinate + * @returns {MapOptions} + */ + withSinglePinZoom(singlePinZoom) { + this.singlePinZoom = singlePinZoom; + return this; + } + + /** + * @param {HTMLElement} wrapper The wrapper element that the map will be inserted into. The existing contents of the element will be removed. + * @returns {MapOptions} + */ + withWrapper(wrapper) { + assertInstance(wrapper, HTMLElement); + + this.wrapper = wrapper; + return this; + } + + /** + * @returns {Map} + */ + build() { + return new Map(this); + } +} + +/** + * An interactive map that supports various map providers, such as Google Maps and Mapbox, with a + * single API. Code written using this class functions approximately the same regardless of the map + * provider used. Any map provider can be supported via a {@link MapProvider} instance. + */ +class Map { + /** + * The {@link MapProvider} for the map must be loaded before calling this constructor. + * @param {MapOptions} options + */ + constructor(options) { + assertInstance(options, MapOptions); + assertInstance(options.provider, MapProvider); + assertInstance(options.wrapper, HTMLElement); + + if (!options.provider.loaded) { + throw new Error(`'${options.provider.constructor.name}' is not loaded. The MapProvider must be loaded before calling Map constructor.`); + } + + this._defaultCenter = options.defaultCenter; + this._defaultZoom = options.defaultZoom; + this._legendPins = options.legendPins; + this._provider = options.provider; + this._singlePinZoom = options.singlePinZoom; + this._wrapper = options.wrapper; + + this._padding = {}; + this.setPadding(options.padding); + + this._cachedBounds = null; // Cached map bounds, invalidated on map move + + this._resolveIdle = () => {}; + this._resolveMoving = () => {}; + this._idlePromise = Promise.resolve(); + this._setIdle(); + this._zoomTrigger = ZoomTriggers.UNSET; + this._panTrigger = PanTriggers.UNSET; + + this.setPanHandler(options.panHandler); + this.setPanStartHandler(options.panStartHandler); + this.setDragEndHandler(options.dragEndHandler); + this.setZoomChangedHandler(options.zoomChangedHandler); + this.setZoomEndHandler(options.zoomEndHandler); + this.setCanvasClickHandler(options.canvasClickHandler); + + // Remove all child elements of wrapper + while (this._wrapper.firstChild) { + this._wrapper.removeChild(this._wrapper.lastChild); + } + + this._panHandlerRunning = false; + this._panStartHandlerRunning = false; + this._map = new ProviderMapOptions(options.provider, this._wrapper) + .withControlEnabled(options.controlEnabled) + .withPanHandler(() => this.panHandler()) + .withDragEndHandler(() => this._dragEndHandler()) + .withZoomChangedHandler(() => this.zoomChangedHandler()) + .withZoomEndHandler(() => this.zoomEndHandler()) + .withCanvasClickHandler(() => this._canvasClickHandler()) + .withPanStartHandler(() => this.panStartHandler()) + .withProviderOptions(options.providerOptions) + .build(); + + this.setZoomCenter(this._defaultZoom, this._defaultCenter); + this._currentBounds = this.getBounds(); + } + + /** + * Set the map bounds so that all the given coordinates are within the [padded]{@link MapOptions#withPadding} view. + * @param {Coordinate[]} coordinates + * @param {boolean} [animated=false] Whether to transition smoothly to the new bounds + * @param {number} [maxZoom=singlePinZoom] The max zoom level after fitting. Uses [singlePinZoom]{@link MapOptions#withSinglePinZoom} by default. + */ + fitCoordinates(coordinates, animated = false, maxZoom = this._singlePinZoom) { + if (!coordinates.length) { + this.setZoomCenter(this._defaultZoom, this._defaultCenter, animated); + } else if (coordinates.length == 1) { + this.setZoomCenter(this._singlePinZoom, coordinates[0], animated); + } else { + this.setBounds(GeoBounds.fit(coordinates), animated, this._padding, maxZoom); + } + } + + /** + * Get the current visible region of the map. If the map is zoomed out to show multiple copies of the + * world, the longitude bounds will be outside [-180, 180) but the center will always be within [-180, 180). + * @returns {GeoBounds} + */ + getBounds() { + if (!this._cachedBounds) { + const pixelHeight = this._wrapper.offsetHeight; + const pixelWidth = this._wrapper.offsetWidth; + const zoom = this.getZoom(); + const center = this.getCenter(); + + const degreesPerPixel = 360 / Math.pow(2, zoom + 8); + const width = pixelWidth * degreesPerPixel; + const height = pixelHeight * degreesPerPixel; + + this._cachedBounds = new GeoBounds(center, center); + this._cachedBounds.ne.add(height / 2, width / 2, Unit.DEGREE, Projection.MERCATOR); + this._cachedBounds.sw.add(-height / 2, -width / 2, Unit.DEGREE, Projection.MERCATOR); + + this.moving().then(() => this._cachedBounds = null); + } + + return new GeoBounds(this._cachedBounds.sw, this._cachedBounds.ne); + } + + /** + * @returns {Coordinate} The center of the current visible region of the map + */ + getCenter() { + return this._map.getCenter(); + } + + /** + * Intended for internal use only + * @returns {ProviderMap} The map's ProviderMap instance + */ + getProviderMap() { + return this._map; + } + + /** + * To standardize zoom for all providers, zoom level is calculated with this formula: + * zoom = log2(pixel width of equator) - 8. + * At zoom = 0, the entire world is 256 pixels wide. + * At zoom = 1, the entire world is 512 pixels wide. + * Zoom 2 → 1024 pixels, zoom 3 → 2056 pixels, etc. + * Negative and non-integer zoom levels are valid and follow the formula. + * @returns {number} The current zoom level of the map + */ + getZoom() { + return this._map.getZoom(); + } + + /** + * Returns when the map is not moving. + * Use map.idle().then(callback) to run callback immediately if the map is currently idle + * or once the map becomes idle if it's not. + * @async + */ + async idle() { + await this._idlePromise; + } + + /** + * Returns when the map is moving. + * Use map.moving().then(callback) to run callback immediately if the map is currently moving + * or once the map starts moving if it's not. + * @async + */ + async moving() { + await this._movingPromise; + } + + /** + * @returns {MapPinOptions} A MapPinOptions instance with the same provider as this map + */ + newPinOptions() { + return new MapPinOptions().withProvider(this._provider); + } + + /** + * Called when the map has finished moving, at most once per animation frame. + * Passes the current and previous bounds to the custom pan handler given by {@link MapOptions#withPanHandler} + */ + panHandler() { + // Throttle panHandler to run at most once per frame + if (this._panHandlerRunning) { + return; + } + + this._panHandlerRunning = true; + + requestAnimationFrame(() => { + const previousBounds = this._currentBounds; + this._currentBounds = this.getBounds(); + + this._panHandler(previousBounds, new GeoBounds( + new Coordinate(this._currentBounds.sw), + new Coordinate(this._currentBounds.ne) + ), this.getPanTrigger()); + + this._panHandlerRunning = false; + this.setPanTrigger(PanTriggers.UNSET); + }); + + this._setIdle(); + } + + /** + * Called when the map has started moving, at most once per animation frame. + * Passes the current bounds to the custom pan handler given by {@link MapOptions#withPanStartHandler} + */ + panStartHandler() { + // Throttle panStartHandler to run at most once per frame + if (this._panStartHandlerRunning) { + return; + } + + this._panStartHandlerRunning = true; + + // We assume that the pan trigger is the user if it was + // left unset by our locator code + if (this.getPanTrigger() === PanTriggers.UNSET) { + this.setPanTrigger(PanTriggers.USER); + } + + requestAnimationFrame(() => { + this._panStartHandler(new GeoBounds( + new Coordinate(this._currentBounds.sw), + new Coordinate(this._currentBounds.ne) + ), this.getPanTrigger()); + + this._panStartHandlerRunning = false; + }); + + this._setMoving(); + } + + /** + * Called when the map starts a zoom change + */ + zoomChangedHandler() { + // We assume that the zoom trigger is the user if it was + // left unset by our locator code + if (this.getZoomTrigger() === ZoomTriggers.UNSET) { + this.setZoomTrigger(ZoomTriggers.USER); + } + + this._zoomChangedHandler(this.getZoomTrigger()); + } + + /** + * Called when the map ends a zoom change + */ + zoomEndHandler() { + this._zoomEndHandler(this.getZoomTrigger()); + + if (this.getZoomTrigger() !== ZoomTriggers.UNSET) { + this.setZoomTrigger(ZoomTriggers.UNSET); + } + } + + getVisibleCenter() { + const visibleBounds = this.getVisibleBounds(); + const center = visibleBounds.getCenter(); + return center; + } + + getVisibleRadius() { + const visibleBounds = this.getVisibleBounds(); + return 1000 * visibleBounds.ne.distanceTo(this.getVisibleCenter(), Unit.KILOMETER); + } + + getVisibleBounds() { + const { ne, sw } = this.getBounds(); + const padding = this._padding; + + const pixelHeight = this._wrapper.offsetHeight; + const pixelWidth = this._wrapper.offsetWidth; + + // Padding is normalized to a fraction of the map height or width + let paddingBottom = normalizePadding(padding.bottom, pixelHeight); + let paddingLeft = normalizePadding(padding.left, pixelWidth); + let paddingRight = normalizePadding(padding.right, pixelWidth); + let paddingTop = normalizePadding(padding.top, pixelHeight); + + const bounds = new GeoBounds(sw, ne); + const nw = new Coordinate(bounds.ne.latitude, bounds.sw.longitude); + const height = bounds.sw.distanceTo(nw, Unit.DEGREE, Projection.MERCATOR); + const width = (bounds.ne.longitude - nw.longitude + 360) % 360; + + const newNorthEast = new Coordinate(ne); + const newSouthWest = new Coordinate(sw); + newNorthEast.add((-1 * paddingTop * height), (-1 * paddingRight * width), Unit.DEGREE, Projection.MERCATOR); + newSouthWest.add((paddingBottom * height), (paddingLeft * width), Unit.DEGREE, Projection.MERCATOR); + + const paddedBounds = new GeoBounds(newSouthWest, newNorthEast); + return paddedBounds; + } + + coordinateIsInVisibleBounds(coordinate) { + return this.getVisibleBounds().contains(coordinate); + } + + setCenterWithPadding(coordinate, animated = false) { + const { ne, sw } = this.getBounds(); + const padding = this._padding; + + const pixelHeight = this._wrapper.offsetHeight; + const pixelWidth = this._wrapper.offsetWidth; + + // Padding is normalized to a fraction of the map height or width + let paddingBottom = normalizePadding(padding.bottom, pixelHeight); + let paddingLeft = normalizePadding(padding.left, pixelWidth); + let paddingRight = normalizePadding(padding.right, pixelWidth); + let paddingTop = normalizePadding(padding.top, pixelHeight); + + let horizontalPadding = paddingLeft + paddingRight; + let verticalPadding = paddingBottom + paddingTop; + + if (horizontalPadding > MAX_PADDING) { + paddingLeft *= MAX_PADDING / horizontalPadding; + paddingRight *= MAX_PADDING / horizontalPadding; + horizontalPadding = MAX_PADDING; + } + + if (verticalPadding > MAX_PADDING) { + paddingBottom *= MAX_PADDING / verticalPadding; + paddingTop *= MAX_PADDING / verticalPadding; + verticalPadding = MAX_PADDING; + } + + const paddingInnerHeight = pixelHeight * (1 - verticalPadding); + const paddingInnerWidth = pixelWidth * (1 - horizontalPadding); + + const bounds = new GeoBounds(sw, ne); + const nw = new Coordinate(bounds.ne.latitude, bounds.sw.longitude); + + const height = bounds.sw.distanceTo(nw, Unit.DEGREE, Projection.MERCATOR); + const width = (bounds.ne.longitude - nw.longitude + 360) % 360; + + const center = new Coordinate(coordinate); + const deltaLat = (paddingTop - paddingBottom) / 2 * height; + const deltaLon = (paddingRight - paddingLeft) / 2 * width; + + center.add(deltaLat, deltaLon, Unit.DEGREE, Projection.MERCATOR); + + this.setCenter(center, animated); + } + + /** + * @param {Object} bounds + * @param {Object} bounds.ne The northeast corner of the bounds -- must be convertible to {@link Coordinate} + * @param {Object} bounds.sw The southwest corner of the bounds -- must be convertible to {@link Coordinate} + * @param {boolean} [animated=false] Whether to transition smoothly to the new bounds + * @param {Object} [padding={}] + * @param {number|Map~paddingFunction} padding.bottom Minimum number of pixels between the map's bottom edge and a pin + * @param {number|Map~paddingFunction} padding.left Minimum number of pixels between the map's left edge and a pin + * @param {number|Map~paddingFunction} padding.right Minimum number of pixels between the map's right edge and a pin + * @param {number|Map~paddingFunction} padding.top Minimum number of pixels between the map's top edge and a pin + * @param {number} [maxZoom=Infinity] + */ + setBounds({ ne, sw }, animated = false, padding = {}, maxZoom = Infinity) { + const pixelHeight = this._wrapper.offsetHeight; + const pixelWidth = this._wrapper.offsetWidth; + + // Padding is normalized to a fraction of the map height or width + let paddingBottom = normalizePadding(padding.bottom, pixelHeight); + let paddingLeft = normalizePadding(padding.left, pixelWidth); + let paddingRight = normalizePadding(padding.right, pixelWidth); + let paddingTop = normalizePadding(padding.top, pixelHeight); + + let horizontalPadding = paddingLeft + paddingRight; + let verticalPadding = paddingBottom + paddingTop; + + if (horizontalPadding > MAX_PADDING) { + paddingLeft *= MAX_PADDING / horizontalPadding; + paddingRight *= MAX_PADDING / horizontalPadding; + horizontalPadding = MAX_PADDING; + } + + if (verticalPadding > MAX_PADDING) { + paddingBottom *= MAX_PADDING / verticalPadding; + paddingTop *= MAX_PADDING / verticalPadding; + verticalPadding = MAX_PADDING; + } + + const paddingInnerHeight = pixelHeight * (1 - verticalPadding); + const paddingInnerWidth = pixelWidth * (1 - horizontalPadding); + + const bounds = new GeoBounds(sw, ne); + const nw = new Coordinate(bounds.ne.latitude, bounds.sw.longitude); + + const height = bounds.sw.distanceTo(nw, Unit.DEGREE, Projection.MERCATOR); + const width = (bounds.ne.longitude - nw.longitude + 360) % 360; + + const newHeight = Math.max(height, width * paddingInnerHeight / paddingInnerWidth) / (1 - verticalPadding); + const newWidth = Math.max(width, height * paddingInnerWidth / paddingInnerHeight) / (1 - horizontalPadding); + + const center = bounds.getCenter(Projection.MERCATOR); + const deltaLat = (paddingTop - paddingBottom) / 2 * newHeight; + const deltaLon = (paddingRight - paddingLeft) / 2 * newWidth; + + center.add(deltaLat, deltaLon, Unit.DEGREE, Projection.MERCATOR); + + const zoom = Math.min(Math.log2(pixelWidth * 360 / newWidth) - 8, maxZoom); + + this.setZoomCenter(zoom, center, animated); + } + + /** + * @param {Object} coordinate Must be convertible to {@link Coordinate} + * @param {boolean} [animated=false] Whether to transition smoothly to the new center + */ + setCenter(coordinate, animated = false) { + this.setPanTrigger(PanTriggers.API); + this._map.setCenter(new Coordinate(coordinate), animated); + } + + /** + * @typedef Map~paddingFunction + * @function + * @returns {number} Minimum number of pixels between the map's edge and a pin + */ + + /** + * Padding is used by {@link Map#fitCoordinates}. + * Padding can either be constant values or funtions that return a padding value. + * Constant values are good if the map should always have the same padding on every breakpoint. + * Padding functions are useful if the map should have different padding at different breakpoints or layouts. + * Inside the function, you can check window.innerWidth or any other condition before returning a number. + * @param {Object} padding + * @param {number|Map~paddingFunction} padding.bottom Minimum number of pixels between the map's bottom edge and a pin + * @param {number|Map~paddingFunction} padding.left Minimum number of pixels between the map's left edge and a pin + * @param {number|Map~paddingFunction} padding.right Minimum number of pixels between the map's right edge and a pin + * @param {number|Map~paddingFunction} padding.top Minimum number of pixels between the map's top edge and a pin + * @returns {MapOptions} + */ + setPadding({ + bottom = this._padding.bottom, + left = this._padding.left, + right = this._padding.right, + top = this._padding.top + }) { + this._padding = { bottom, left, right, top }; + return this; + } + + /** + * @param {Map~panHandler} panHandler + */ + setPanHandler(panHandler) { + assertType(panHandler, Type.FUNCTION); + + this._panHandler = panHandler; + } + + /** + * @param {Map~panStartHandler} panStartHandler + */ + setPanStartHandler(panStartHandler) { + assertType(panStartHandler, Type.FUNCTION); + + this._panStartHandler = panStartHandler; + } + + /** + * @param {Map~dragEndHandler} dragEndHandler + */ + setDragEndHandler(dragEndHandler) { + assertType(dragEndHandler, Type.FUNCTION); + + this._dragEndHandler = dragEndHandler; + } + + /** + * @param {Map~zoomChangedHandler} zoomChangedHandler + */ + setZoomChangedHandler(zoomChangedHandler) { + assertType(zoomChangedHandler, Type.FUNCTION); + + this._zoomChangedHandler = zoomChangedHandler; + } + + /** + * @param {Map~zoomEndHandler} zoomEndHandler + */ + setZoomEndHandler(zoomEndHandler) { + assertType(zoomEndHandler, Type.FUNCTION); + + this._zoomEndHandler = zoomEndHandler; + } + + /** + * @param {Map~canvasClickHandler} canvasClickHandler + */ + setCanvasClickHandler(canvasClickHandler) { + assertType(canvasClickHandler, Type.FUNCTION); + + this._canvasClickHandler = canvasClickHandler; + } + + /** + * @param {number} zoom + * @param {boolean} [animated=false] Whether to transition smoothly to the new zoom + * @see {@link Map#getZoom} + */ + setZoom(zoom, animated = false) { + this.setZoomTrigger(ZoomTriggers.API); + this._map.setZoom(zoom, animated); + } + + /** + * @param {number} zoom + * @param {Object} center Must be convertible to {@link Coordinate} + * @param {boolean} [animated=false] Whether to transition smoothly to the new bounds + * @see {@link Map#setZoom} + * @see {@link Map#setCenter} + */ + setZoomCenter(zoom, center, animated = false) { + this.setZoomTrigger(ZoomTriggers.API); + this.setPanTrigger(PanTriggers.API); + this._map.setZoomCenter(zoom, center, animated); + } + + /** + * The zoom trigger is the initiator of the zoom, this can be from a user + * on a double click, for example, or from the api when fitting the map around + * a cluster or a set of results. + * @param {ZoomTriggers} zoomTrigger + */ + setZoomTrigger(zoomTrigger) { + this._zoomTrigger = zoomTrigger; + } + + /** + * @return {ZoomTriggers} The trigger for the last zoom + */ + getZoomTrigger() { + return this._zoomTrigger; + } + + /** + * Sets the PanTrigger which indicates the reason for the most recent map pan + * @param {PanTriggers} panTrigger + */ + setPanTrigger(panTrigger) { + this._panTrigger = panTrigger; + } + + /** + * @return {PanTriggers} The trigger for the last pan + */ + getPanTrigger() { + return this._panTrigger; + } + + /** + * Set the map state to idle + */ + _setIdle() { + this._resolveMoving(); + this._movingPromise = new Promise(resolve => this._resolveMoving = resolve); + this._resolveIdle(); + } + + /** + * Set the map state to moving + */ + _setMoving() { + this._resolveIdle(); + this._idlePromise = new Promise(resolve => this._resolveIdle = resolve); + this._resolveMoving(); + } +} + +export { + MapOptions, + Map +}; diff --git a/static/js/theme-map/Maps/MapPin.js b/static/js/theme-map/Maps/MapPin.js new file mode 100644 index 000000000..5df47155d --- /dev/null +++ b/static/js/theme-map/Maps/MapPin.js @@ -0,0 +1,322 @@ +import { Coordinate } from '../Geo/Coordinate.js'; +import { Type, assertType, assertInstance } from '../Util/Assertions.js'; +import { Map } from './Map.js'; +import { MapProvider } from './MapProvider.js'; +import { PinProperties } from './PinProperties.js'; +import { ProviderPinOptions } from './ProviderPin.js'; + +/** + * {@link MapPin} options class + */ +class MapPinOptions { + /** + * Initialize with default options + */ + constructor() { + this.coordinate = new Coordinate(0, 0); + this.hideOffscreen = false; + this.icons = {}; + this.propertiesForStatus = status => new PinProperties(); + this.provider = null; + this.type = ''; + } + + /** + * @param {Object} coordinate Must be convertible to {@link Coordinate} + * @returns {MapPinOptions} + */ + withCoordinate(coordinate) { + this.coordinate = new Coordinate(coordinate); + return this; + } + + /** + * @param {boolean} hideOffscreen If true, the pin will only be rendered if it's in the visible portion of the map to improve performance + * @returns {MapPinOptions} + */ + withHideOffscreen(hideOffscreen) { + this.hideOffscreen = hideOffscreen; + return this; + } + + /** + * @param {string} key The unique name for the icon, used in {@link PinProperties#getIcon} and {@link PinProperties#setIcon} + * @param {string} icon The URL or data URI of the icon image + * @returns {MapPinOptions} + */ + withIcon(key, icon) { + this.icons[key] = icon; + return this; + } + + /** + * @param {string} id The unique id for the pin + * @returns {MapPinOptions} + */ + withId(id) { + this.id = id + return this; + } + + /** + * @typedef MapPin~propertiesForStatus + * @function + * @param {Object} status A generic object whose properties define the state of the pin, from {@link MapPin#setStatus} + * @returns {PinProperties} + * @see MapPin#setStatus + */ + + /** + * @param {MapPin~propertiesForStatus} propertiesForStatus + * @returns {MapPinOptions} + */ + withPropertiesForStatus(propertiesForStatus) { + assertType(propertiesForStatus, Type.FUNCTION); + + this.propertiesForStatus = propertiesForStatus; + return this; + } + + /** + * @param provider {MapProvider} + * @returns {MapPinOptions} + */ + withProvider(provider) { + assertInstance(provider, MapProvider); + + this.provider = provider; + return this; + } + + /** + * @param {string} type A string describing the type of the pin + * @returns {MapPinOptions} + */ + withType(type) { + this.type = type; + return this; + } + + /** + * @returns {MapPin} + */ + build() { + return new MapPin(this); + } +} + +/** + * A pin for a {@link Map} that displays at a given {@link Coordinate}. A MapPin can be displayed on + * at most one Map at a time. Pins support event handlers for clicking, hovering, and focusing. + * The pin can change its appearance based on its current status, which is changed by {@link MapPin#setStatus}. + */ +class MapPin { + /** + * @param {MapPinOptions} options + */ + constructor(options) { + assertInstance(options, MapPinOptions); + assertInstance(options.provider, MapProvider); + + if (!options.provider.loaded) { + throw new Error(`'${options.provider.constructor.name}' is not loaded. The MapProvider must be loaded before calling MapPin constructor.`); + } + + this._coordinate = options.coordinate; + this._hideOffscreen = options.hideOffscreen; + this._icons = { ...options.icons }; + this._propertiesForStatus = options.propertiesForStatus; + this._type = options.type; + + this._clickHandler = () => {}; + this._focusHandler = focused => this._hoverHandler(focused); + this._hoverHandler = hovered => {}; + + this._hidden = false; + this._hiddenUpdater = null; + + this._map = null; + + this._pin = new ProviderPinOptions(options.provider) + .withIcons({ ...this._icons }) + .withClickHandler(() => this._clickHandler()) + .withFocusHandler(focused => this._focusHandler(focused)) + .withHoverHandler(hovered => this._hoverHandler(hovered)) + .build(); + + this._id = options.id; + + this._pin.setCoordinate(options.coordinate); + + this._status = {}; + this.setStatus(this._status); + } + + /** + * @returns {Coordinate} The coordinate of the pin + */ + getCoordinate() { + return this._coordinate; + } + + /** + * Get the icon for a string key, such as 'default', 'hovered', or 'selected' + * @param {string} key The unique name of the icon + * @returns {string} The URL or data URI of the icon image + * @see MapPinOptions#withIcon + */ + getIcon(key) { + return this._icons[key]; + } + + /** + * Get the unique identifier for the map pin + * @returns {string} + */ + getId() { + return this._id; + } + + /** + * @returns {?Map} The map that the pin is currently on, or null if not on a map + */ + getMap() { + return this._map; + } + + /** + * Intended for internal use only + * @returns {ProviderPin} The pin's ProviderPin instance + */ + getProviderPin() { + return this._pin; + } + + /** + * Remove this pin from its current map, if on one. + */ + remove() { + this.setMap(null); + } + + /** + * @typedef MapPin~clickHandler + * @function + */ + + /** + * Set a handler function for when the pin is clicked, replacing any previously set click handler. + * @param {MapPin~clickHandler} clickHandler + */ + setClickHandler(clickHandler) { + assertType(clickHandler, Type.FUNCTION); + + this._clickHandler = clickHandler; + } + + /** + * @param {Object} coordinate Must be convertible to {@link Coordinate} + */ + setCoordinate(coordinate) { + this._coordinate = new Coordinate(coordinate); + this._pin.setCoordinate(this._coordinate); + + if (this._hideOffscreen) { + this._hideIfOffscreen(); + } + } + + /** + * @typedef MapPin~focusHandler + * @function + * @param {boolean} focused Whether the pin is currently focused + */ + + /** + * Set a handler function for when the pin is (un)focused, replacing any previously set focus handler. + * @param {MapPin~focusHandler} focusHandler + */ + setFocusHandler(focusHandler) { + assertType(focusHandler, Type.FUNCTION); + + this._focusHandler = focusHandler; + } + + /** + * @typedef MapPin~hoverHandler + * @function + * @param {boolean} hovered Whether the pin is currently hovered + */ + + /** + * Set a handler function for when the pin is (un)hovered, replacing any previously set hover handler. + * @param {MapPin~hoverHandler} hoverHandler + */ + setHoverHandler(hoverHandler) { + assertType(hoverHandler, Type.FUNCTION); + + this._hoverHandler = hoverHandler; + } + + /** + * Add the pin to a map, removing it from its current map if on one. + * @param {?Map} map + */ + setMap(map) { + if (map !== null) { + assertInstance(map, Map); + } + + this._pin.setMap(map, this._hidden ? null : this._map); + this._map = map; + this._hidden = false; + + if (map && this._hideOffscreen) { + const hiddenUpdater = async () => { + // Wait for the map to move, then stop moving + await map.moving(); + await map.idle(); + + // Make sure that the updater didn't get reset while waiting + if (this._hiddenUpdater == hiddenUpdater) { + this._hideIfOffscreen(); + hiddenUpdater(); + } + }; + + this._hiddenUpdater = hiddenUpdater; + hiddenUpdater(); + } else { + this._hiddenUpdater = null; + } + } + + /** + * Assign all properties in an object to the pin's status. + * Example: if the pin's status is { a: true, b: true }, passing in { a: false, c: true } will + * change the pin's status to { a: false, b: true, c: true } + * @param {Object} status + */ + setStatus(status) { + Object.assign(this._status, status); + this._pin.setProperties(this._propertiesForStatus(this._status)); + } + + _hideIfOffscreen() { + const isVisible = this._map.getBounds().contains(this._coordinate); + + if (this._hidden && isVisible) { + this._pin.setMap(this._map, null); + } else if (!this._hidden && !isVisible) { + this._pin.setMap(null, this._map); + } + + this._hidden = !isVisible; + } +} + +export { + MapPinOptions, + MapPin, + PinProperties +}; diff --git a/static/js/theme-map/Maps/MapProvider.js b/static/js/theme-map/Maps/MapProvider.js new file mode 100644 index 000000000..305ff8aa5 --- /dev/null +++ b/static/js/theme-map/Maps/MapProvider.js @@ -0,0 +1,178 @@ +import { Type, assertType, assertInstance } from '../Util/Assertions.js'; +import { ProviderMapOptions, ProviderMap } from './ProviderMap'; +import { ProviderPinOptions, ProviderPin } from './ProviderPin'; + +/** + * {@link MapProvider} options class + */ +class MapProviderOptions { + constructor() { + this.loadFunction = (resolve, reject, apiKey, options) => resolve(); + this.mapClass = ProviderMap; + this.pinClass = ProviderPin; + this.providerName = ''; + this.supportedLocales = []; + } + + /** + * @typedef MapProvider~loadFunction + * @function + * @param {function} resolve Callback with no arguments called when the load finishes successfully + * @param {function} reject Callback with no arguments called when the load fails + * @param {string} [apiKey] Provider API key + * @param {Object} [options={}] Additional provider-specific options + */ + + /** + * @param {MapProvider~loadFunction} + * @returns {MapProviderOptions} + */ + withLoadFunction(loadFunction) { + assertType(loadFunction, Type.FUNCTION); + + this.loadFunction = loadFunction; + return this; + } + + /** + * @param {Class.} mapClass Subclass of {@link ProviderMap} for the provider + * @returns {MapProviderOptions} + */ + withMapClass(mapClass) { + this.mapClass = mapClass; + return this; + } + + /** + * @param {Class.} mapClass Subclass of {@link ProviderPin} for the provider + * @returns {MapProviderOptions} + */ + withPinClass(pinClass) { + this.pinClass = pinClass; + return this; + } + + /** + * @param {string} providerName Name of the map provider + * @returns {MapProviderOptions} + */ + withProviderName(providerName) { + this.providerName = providerName; + return this; + } + + /** + * @param {string[]} supportedLocales The locales supported by the provider + * @returns {MapProviderOptions} + */ + withSupportedLocales(supportedLocales) { + this.supportedLocales = supportedLocales; + return this; + } + + /** + * @returns {MapProvider} + */ + build() { + return new MapProvider(this); + } +} + +/** + * This class is used for loading the API for a map provider such as Google Maps and creating {@link ProviderMap} and {@link ProviderPin} instances. + * Provider map implementations return an instance of this class for their provider that you can use + * to load the API and pass in to {@link MapOptions} and {@link MapPinOptions} objects as the provider. + * Example using GoogleMaps, an instance of this class: + * GoogleMaps.load().then(() => map = new MapOptions().withProvider(GoogleMaps).build()); + */ +class MapProvider { + /** + * @param {MapProviderOptions} options + */ + constructor(options) { + assertInstance(options, MapProviderOptions); + + this._loadFunction = options.loadFunction; + this._mapClass = options.mapClass; + this._pinClass = options.pinClass; + this._providerName = options.providerName; + this._supportedLocales = options.supportedLocales; + + this._loadPromise = new Promise((resolve, reject) => { + this._resolveLoad = resolve; + this._rejectLoad = reject; + }); + + this._loadInvoked = false; + this._loaded = false; + } + + /** + * Returns true if the map provider has been successfully loaded + * @type {boolean} + */ + get loaded() { + return this._loaded; + } + + /** + * @returns {ProviderMap.constructor} + * @see {@link MapProviderOptions#withMapClass} + */ + getMapClass() { + return this._mapClass; + } + + /** + * @returns {ProviderPin.constructor} + * @see {@link MapProviderOptions#withPinClass} + */ + getPinClass() { + return this._pinClass; + } + + /** + * @returns {string} + * @see {@link MapProviderOptions#withProviderName} + */ + getProviderName() { + return this._providerName; + } + + /** + * @returns {string[]} + * @see {@link MapProviderOptions#withSupportedLocales} + */ + getSupportedLocales() { + return this._supportedLocales; + } + + /** + * Call {@link MapPinOptions~loadFunction} and resolve or reject when loading succeeds or fails + * @async + * @param {string} [apiKey] Provider API key + * @param {Object} [options={}] Additional provider-specific options + */ + async load(apiKey, options = {}) { + if (!this._loadInvoked) { + this._loadInvoked = true; + this._loadFunction(this._resolveLoad, this._rejectLoad, apiKey, options); + } + + await this.ready(); + this._loaded = true; + } + + /** + * Resolves or rejects when the map provider has loaded successfully or unsuccessfully + * @async + */ + async ready() { + await this._loadPromise; + } +} + +export { + MapProviderOptions, + MapProvider +} diff --git a/static/js/theme-map/Maps/PanTriggers.js b/static/js/theme-map/Maps/PanTriggers.js new file mode 100644 index 000000000..4c3ee1e47 --- /dev/null +++ b/static/js/theme-map/Maps/PanTriggers.js @@ -0,0 +1,27 @@ +/** + * Describes the possible triggers for a map pan + * + * @enum {string} + */ +export default { + /** + * Indicates that the panTrigger is not set. + */ + UNSET: '', + /** + * Indicates that the pan occured as a result of user interaction. + * + * This includes moving the map or clicking on a pin cluster. + */ + USER: 'user', + /** + * Indicates that the map is panning due to programatic reason, and therefore a new + * search should not be ran if the panHandler is called while this PanTrigger is set + * on the Map. + * + * This includes the automatic centering of the map after a new search is ran, or the + * automatic centering of the map over a focused pin near the edge of the screen after + * clicking or tabbing onto it. + */ + API: 'api', +}; \ No newline at end of file diff --git a/static/js/theme-map/Maps/PinProperties.js b/static/js/theme-map/Maps/PinProperties.js new file mode 100644 index 000000000..b71b1e5e6 --- /dev/null +++ b/static/js/theme-map/Maps/PinProperties.js @@ -0,0 +1,180 @@ +/** + * This class is used to set the appearance of a {@link MapPin}. Most properties are supported by + * all pins, but some are only supported by HTML pins. + */ +class PinProperties { + constructor() { + // Properties supported by all pins + this._anchorX = 0.5; + this._anchorY = 1; + this._height = 39; + this._icon = 'default'; + this._srText = 'alt text'; + this._width = 33; + this._zIndex = 0; + + // Properties supported only by HTML pins + this._class = ''; + this._element = null; + } + + /** + * @returns {number} The point in the pin that should be positioned over the coordinate, from 0 (left edge) to 1 (right edge) + */ + getAnchorX() { + return this._anchorX; + } + + /** + * @returns {number} The point in the pin that should be positioned over the coordinate, from 0 (top edge) to 1 (bottom edge) + */ + getAnchorY() { + return this._anchorY; + } + + /** + * HTML pins only + * @returns {string} The class of the wrapper element for an HTML pin + */ + getClass() { + return this._class; + } + + /** + * HTML pins only + * @returns {string} The HTML pin element + */ + getElement() { + return this._element; + } + + /** + * @returns {number} The pixel height of the pin + */ + getHeight() { + return this._height; + } + + /** + * This returns a string key that can be used with {@link MapPin#getIcon} to get the icon image for a pin. + * @returns {string} The unique name of the icon + */ + getIcon() { + return this._icon; + } + + /** + * @returns {string} The text that a screen reader reads when focused on the pin + */ + getSRText() { + return this._srText; + } + + /** + * @returns {number} The pixel width of the pin + */ + getWidth() { + return this._width; + } + + /** + * @returns {number} The z-index of the pin + */ + getZIndex() { + return this._zIndex; + } + + /** + * @param {number} anchorX + * @returns {PinProperties} + * @see {PinProperties#getAnchorX} + */ + setAnchorX(anchorX) { + this._anchorX = anchorX; + return this; + } + + /** + * @param {number} anchorY + * @returns {PinProperties} + * @see {PinProperties#getAnchorY} + */ + setAnchorY(anchorY) { + this._anchorY = anchorY; + return this; + } + + /** + * @param {string} className + * @returns {PinProperties} + * @see {PinProperties#getClass} + */ + setClass(className) { + this._class = className; + return this; + } + + /** + * @param {HTMLElement} element + * @returns {PinProperties} + * @see {PinProperties#getElement} + */ + setElement(element) { + this._element = element; + return this; + } + + /** + * @param {number} height + * @returns {PinProperties} + * @see {PinProperties#getHeight} + */ + setHeight(height) { + this._height = height; + return this; + } + + /** + * @param {string} icon + * @returns {PinProperties} + * @see {PinProperties#getIcon} + */ + setIcon(icon) { + this._icon = icon; + return this; + } + + /** + * @param {string} srText + * @returns {PinProperties} + * @see {PinProperties#getSRText} + */ + setSRText(srText) { + this._srText = srText; + return this; + } + + /** + * @param {number} width + * @returns {PinProperties} + * @see {PinProperties#getWidth} + */ + setWidth(width) { + this._width = width; + return this; + } + + /** + * @param {number} zIndex + * @returns {PinProperties} + * @see {PinProperties#getZIndex} + */ + setZIndex(zIndex) { + this._zIndex = zIndex; + return this; + } +} + +export { + PinProperties +}; diff --git a/static/js/theme-map/Maps/ProviderMap.js b/static/js/theme-map/Maps/ProviderMap.js new file mode 100644 index 000000000..654c0330f --- /dev/null +++ b/static/js/theme-map/Maps/ProviderMap.js @@ -0,0 +1,228 @@ +import { Type, assertType, assertInstance } from '../Util/Assertions.js'; +import { MapProvider } from './MapProvider.js'; + +/** + * {@link ProviderMap} options class + */ +class ProviderMapOptions { + /** + * @param {MapProvider} provider + * @param {HTMLElement} wrapper The wrapper element that the map will be inserted into + */ + constructor(provider, wrapper) { + assertInstance(provider, MapProvider); + assertInstance(wrapper, HTMLElement); + + this.providerMapClass = provider.getMapClass(); + this.wrapper = wrapper; + + this.controlEnabled = true; + this.panHandler = () => {}; + this.panStartHandler = () => {}; + this.dragEndHandler = () => {}; + this.zoomChangedHandler = () => {}; + this.zoomEndHandler = () => {}; + this.canvasClickHandler = () => {}; + this.providerOptions = {}; + } + + /** + * @param {boolean} controlEnabled Whether the user can interact with the map + * @returns {ProviderMapOptions} + */ + withControlEnabled(controlEnabled) { + this.controlEnabled = controlEnabled; + return this; + } + + /** + * @typedef ProviderMap~panHandler + * @function + */ + + /** + * @param {ProviderMap~panHandler} panHandler Function called after the map bounds change + * @returns {ProviderMapOptions} + */ + withPanHandler(panHandler) { + assertType(panHandler, Type.FUNCTION); + + this.panHandler = panHandler; + return this; + } + + /** + * @typedef ProviderMap~panStartHandler + * @function + */ + + /** + * @param {ProviderMap~panStartHandler} panStartHandler Function called before the map bounds change + * @returns {ProviderMapOptions} + */ + withPanStartHandler(panStartHandler) { + assertType(panStartHandler, Type.FUNCTION); + + this.panStartHandler = panStartHandler; + return this; + } + + /** + * @typedef ProviderMap~dragEndHandler + * @function + */ + + /** + * @param {ProviderMap~dragEndHandler} dragEndHandler Function called after the map is dragged + * @returns {ProviderMapOptions} + */ + withDragEndHandler(dragEndHandler) { + assertType(dragEndHandler, Type.FUNCTION); + + this.dragEndHandler = dragEndHandler; + return this; + } + + /** + * @typedef ProviderMap~zoomChangedHandler + * @function + */ + + /** + * @param {ProviderMap~zoomChangedHandler} zoomChangedHandler Function called when the map starts a zoom change + * @returns {ProviderMapOptions} + */ + withZoomChangedHandler(zoomChangedHandler) { + assertType(zoomChangedHandler, Type.FUNCTION); + + this.zoomChangedHandler = zoomChangedHandler; + return this; + } + + /** + * @typedef ProviderMap~zoomEndHandler + * @function + */ + + /** + * @param {ProviderMap~zoomEndHandler} zoomEndHandler Function called when the map ends a zoom change + * @returns {ProviderMapOptions} + */ + withZoomEndHandler(zoomEndHandler) { + assertType(zoomEndHandler, Type.FUNCTION); + + this.zoomEndHandler = zoomEndHandler; + return this; + } + + /** + * @typedef ProviderMap~canvasClickHandler + * @function + */ + + /** + * @param {ProviderMap~canvasClickHandler} canvasClickHandler Function called when the map ends a zoom change + * @returns {ProviderMapOptions} + */ + withCanvasClickHandler(canvasClickHandler) { + assertType(canvasClickHandler, Type.FUNCTION); + + this.canvasClickHandler = canvasClickHandler; + return this; + } + + /** + * @param {Object} providerOptions A free-form object used to set any additional provider-specific options, usually by passing the object to the map's constructor + * @returns {ProviderMapOptions} + */ + withProviderOptions(providerOptions) { + this.providerOptions = providerOptions; + return this; + } + + /** + * @returns {ProviderMap} An instance of a subclass of {@link ProviderMap} for the given {@link MapProvider} + */ + build() { + const providerMapClass = this.providerMapClass; + return new providerMapClass(this); + } +} + +/** + * This class is an interface that should be implemented for each map provider, such as Google Maps. + * It is used as an API for a {@link Map} to control a provider-specific map instance. + * Ideally, this class should have minimal functionality so that adding a new provider is easy and + * behavior is as consistent as possible across all providers. + * @interface + */ +class ProviderMap { + /** + * The constructor creates a map instance using the provider's API and initializes it with all the + * given options. See {@link ProviderMapOptions} for the supported options. + * @param {ProviderMapOptions} options + */ + constructor(options) { + assertInstance(options, ProviderMapOptions); + + // When implementing a new MapProvider, call _panStartHandler when the map viewport starts + // changing, and call _panHandler when it stops. + this._panHandler = options.panHandler; + this._panStartHandler = options.panStartHandler; + this._dragEndHandler = options.dragEndHandler; + this._zoomChangedHandler = options.zoomChangedHandler; + this._zoomEndHandler = options.zoomEndHandler; + this._canvasClickHandler = options.canvasClickHandler; + } + + /** + * @returns {Coordinate} The current center of the map + */ + getCenter() { + throw new Error('not implemented'); + } + + /** + * Zoom level complies with the specifications in {@link Map#getZoom} + * @returns {number} The current zoom level of the map + */ + getZoom() { + throw new Error('not implemented'); + } + + /** + * @param {Coordinate} coordinate The new center for the map + * @param {boolean} animated Whether to transition smoothly to the new center + */ + setCenter(coordinate, animated) { + throw new Error('not implemented'); + } + + /** + * Zoom level complies with the specifications in {@link Map#getZoom} + * @param {number} zoom The new zoom level for the map + * @param {boolean} animated Whether to transition smoothly to the new zoom + */ + setZoom(zoom, animated) { + throw new Error('not implemented'); + } + + /** + * @param {number} zoom + * @param {Object} center Must be convertible to {@link Coordinate} + * @param {boolean} animated Whether to transition smoothly to the new bounds + * @see {@link ProviderMap#setZoom} + * @see {@link ProviderMap#setCenter} + */ + setZoomCenter(zoom, center, animated) { + // This method doesn't need to be implemented for each provider, + // but it can be overridden if this default function doesn't work. + this.setZoom(zoom, animated); + this.setCenter(center, animated); + } +} + +export { + ProviderMapOptions, + ProviderMap +}; diff --git a/static/js/theme-map/Maps/ProviderPin.js b/static/js/theme-map/Maps/ProviderPin.js new file mode 100644 index 000000000..59bdfbb9f --- /dev/null +++ b/static/js/theme-map/Maps/ProviderPin.js @@ -0,0 +1,145 @@ +import { Type, assertType, assertInstance } from '../Util/Assertions.js'; +import { MapProvider } from './MapProvider.js'; + +/** + * {@link ProviderPin} options class + */ +class ProviderPinOptions { + /** + * @param {MapProvider} provider + */ + constructor(provider) { + assertInstance(provider, MapProvider); + + this.providerPinClass = provider.getPinClass(); + + this.clickHandler = () => {}; + this.focusHandler = focused => {}; + this.hoverHandler = hovered => {}; + this.icons = {}; + } + + /** + * @typedef ProviderPin~clickHandler + * @function + */ + + /** + * @param {ProviderPin~clickHandler} clickHandler Function called when the pin is clicked + * @returns {ProviderPinOptions} + */ + withClickHandler(clickHandler) { + assertType(clickHandler, Type.FUNCTION); + + this.clickHandler = clickHandler; + return this; + } + + /** + * @typedef ProviderPin~focusHandler + * @function + * @param {boolean} focused Whether the pin is currently focused + */ + + /** + * @param {ProviderPin~focusHandler} focusHandler Function called when the pin becomes (un)focused + * @returns {ProviderPinOptions} + */ + withFocusHandler(focusHandler) { + assertType(focusHandler, Type.FUNCTION); + + this.focusHandler = focusHandler; + return this; + } + + /** + * @typedef ProviderPin~hoverHandler + * @function + * @param {boolean} hovered Whether the pin is currently hovered + */ + + /** + * @param {ProviderPin~hoverHandler} hoverHandler Function called when the pin becomes (un)hovered + * @returns {ProviderPinOptions} + */ + withHoverHandler(hoverHandler) { + assertType(hoverHandler, Type.FUNCTION); + + this.hoverHandler = hoverHandler; + return this; + } + + /** + * Similar to {@link MapPinOptions#withIcon}, but all icons are given as a map of key => icon. + * If a provider pin instance needs an icon to be a specialized class rather than a simple URL, + * the icons in this object can be converted in this function and assigned back to the icons object + * instead of being recreated from the URL every time the pin's icon changes. + * @param {Object} icons Map of a string key to the URL or data URI of the icon image + * @returns {ProviderPinOptions} + */ + withIcons(icons) { + this.icons = icons; + return this; + } + + /** + * @returns {ProviderPin} An instance of a subclass of {@link ProviderPin} for the given {@link MapProvider} + */ + build() { + const providerPinClass = this.providerPinClass; + return new providerPinClass(this); + } +} + +/** + * This class is an interface that should be implemented for each map provider, such as Google Maps. + * It is used as an API for a {@link MapPin} to control a provider-specific pin instance. + * Ideally, this class should have minimal functionality so that adding a new provider is easy and + * behavior is as consistent as possible across all providers. + * @interface + */ +class ProviderPin { + /** + * The constructor creates a pin instance using the provider's API and initializes it with all the + * given options. See {@link ProviderPinOptions} for the supported options. + * @param {ProviderPinOptions} options + */ + constructor(options) { + assertInstance(options, ProviderPinOptions); + + this._clickHandler = options.clickHandler; + this._focusHandler = options.focusHandler; + this._hoverHandler = options.hoverHandler; + this._icons = options.icons; + } + + /** + * @param {Coordinate} coordinate The position of the pin + */ + setCoordinate(coordinate) { + throw new Error('not implemented'); + } + + /** + * Remove the pin from its current map and, if themeMap is not null, add it to the new map. + * @param {?Map} themeMap The new map -- if null, the pin will not be shown on any map + * @param {?Map} currentMap The current map -- if null, the pin is not shown on any map + */ + setMap(themeMap, currentMap) { + throw new Error('not implemented'); + } + + /** + * Apply the given properties to modify the appearance of the pin. + * @param {PinProperties} pinProperties + * @see {@link PinProperties} + */ + setProperties(pinProperties) { + throw new Error('not implemented'); + } +} + +export { + ProviderPinOptions, + ProviderPin +}; diff --git a/static/js/theme-map/Maps/Providers/Apple.js b/static/js/theme-map/Maps/Providers/Apple.js new file mode 100644 index 000000000..fae16cbaa --- /dev/null +++ b/static/js/theme-map/Maps/Providers/Apple.js @@ -0,0 +1,67 @@ +import { Coordinate } from '../../Geo/Coordinate.js'; +import { MapProviderOptions } from '../MapProvider.js'; +import { ProviderMap } from '../ProviderMap.js'; +import { ProviderPin } from '../ProviderPin.js'; + +// Map Class + +class AppleMap extends ProviderMap { + constructor(options) { + super(options); + } + + getCenter() { + // TODO + } + + getZoom() { + // TODO + } + + setCenter(coordinate, animated) { + // TODO + } + + setZoom(zoom, animated) { + // TODO + } +} + +// Pin Class + +class ApplePin extends ProviderPin { + constructor(options) { + super(options); + } + + setCoordinate(coordinate) { + // TODO + } + + setMap(themeMap, currentMap) { + // TODO + } + + setProperties(pinProperties) { + // TODO + } +} + +// Load Function + +function load(resolve, reject, apiKey, options) { + // TODO +} + +// Exports + +const AppleMaps = new MapProviderOptions() + .withLoadFunction(load) + .withMapClass(AppleMap) + .withPinClass(ApplePin) + .withProviderName('Apple') + .build(); + +export { + AppleMaps +}; diff --git a/static/js/theme-map/Maps/Providers/Baidu.js b/static/js/theme-map/Maps/Providers/Baidu.js new file mode 100644 index 000000000..cb4ed39a1 --- /dev/null +++ b/static/js/theme-map/Maps/Providers/Baidu.js @@ -0,0 +1,374 @@ +/** @module Maps/Providers/Baidu */ + +import { Coordinate } from '../../Geo/Coordinate.js'; +import { MapProviderOptions } from '../MapProvider.js'; +import { ProviderMap } from '../ProviderMap.js'; +import { ProviderPin } from '../ProviderPin.js'; + +// Baidu zoom formula: equatorWidth = 2^zoom * 152.87572479248047 +// Our standard zoom formula: equatorWidth = 2^zoom * 256 +// To convert: +// 2^bdZoom * 152.87572479248047 = 2^zoom * 256 +// bdZoom = stdZoom + log2(1.6745627884839434) +const baiduZoomConversionConstant = Math.log2(1.6745627884839434); +const baiduMinZoom = 4; +const baiduMaxZoom = 19; + +// Baidu renders pins with negative longitude incorrectly. This class identifies them to fix. +const negativeLngPinClass = 'js-baidu-neg-lng-fix'; + +// The API key is needed for coordinate conversion. +// The load function will resolve apiKeyPromise with the key once it is invoked. +let resolveAPIKey; +const apiKeyPromise = new Promise(resolve => resolveAPIKey = resolve); +const geoconvBaseUrl = 'https://api.map.baidu.com/geoconv/v1/'; + +// Batch coordinate conversion requests to reduce network load +let gcj02ToBD09Requests = []; +const gcj02ToBD09GlobalCallback = 'gcj02ToBD09Callback_b872c21c'; +let gcj02ToBD09CallbackCounter = 0; +let gcj02ToBD09CallbackTimeout; + +/** +* This function converts coordinates from China's coordinate system GCJ-02 to Baidu's coordinate +* system BD-09. See {@link https://en.wikipedia.org/wiki/Baidu_Maps#Coordinate_system} for more info. +* @static +* @async +* @param {Coordinate[]} coordinates Coordinates in the GCJ-02 coordinate system +* @returns {Coordinate[]} Equivalent coordinates in the BD-09 coordinate system +*/ +async function gcj02ToBD09(coordinates) { + return await new Promise((resolve, reject) => { + gcj02ToBD09Requests.push({ coordinates, resolve, reject }); + + if (gcj02ToBD09Requests.length == 1) { + gcj02ToBD09CallbackTimeout = setTimeout(sendRequests, 100); + } + + // URL length can't exceed 2000 characters and each coordinate adds at most 40 characters to the URL. + // If approaching the limit, send the requests immediately instead of waiting for more. + if (gcj02ToBD09Requests.length > 40) { + clearTimeout(gcj02ToBD09CallbackTimeout); + sendRequests(); + } + + function sendRequests() { + const requests = gcj02ToBD09Requests; + gcj02ToBD09Requests = []; + const coordinates = [].concat(...(requests.map(request => request.coordinates))); + const callback = gcj02ToBD09GlobalCallback + '_' + gcj02ToBD09CallbackCounter++; + const script = document.createElement('script'); + + window[callback] = data => { + if (data.status) { + const err = new Error(`Unable to convert coordinates to BD-09: Received status code ${data.status}${data.message ? ': ' + data.message : ''}`); + requests.forEach(request => request.reject(err)); + } + + const convertedCoords = data.result.map(point => new Coordinate(point.y, point.x)); + let currentIndex = 0; + + requests.forEach(request => { + request.resolve(convertedCoords.slice(currentIndex, currentIndex += request.coordinates.length)); + }); + + delete window[callback]; + script.parentNode.removeChild(script); + }; + + apiKeyPromise.then(ak => { + const apiParams = { + ak, + callback, + coords: coordinates.map(coordinate => `${coordinate.longitude},${coordinate.latitude}`).join(';'), + from: 3, + to: 5 + }; + + script.src = geoconvBaseUrl + '?' + Object.entries(apiParams).map(([key, value]) => key + '=' + value).join('&'); + document.head.appendChild(script); + }); + } + }); +} + +// Map Class + +/** + * Baidu Maps documentation lives here: {@link http://lbsyun.baidu.com/cms/jsapi/reference/jsapi_reference_3_0.html} + * @implements {ProviderMap} + */ +class BaiduMap extends ProviderMap { + /** + * @param {ProviderMapOptions} options + */ + constructor(options) { + super(options); + + const isIE11 = !!(window.MSInputMethodContext && document.documentMode); + + this._wrapper = options.wrapper; + this.map = new BMap.Map(this._wrapper, { + enableMapClick: options.controlEnabled, + // A side effect of the negative pin longitude glitch is that pins don't render at higher zoom levels. + // For IE, 15 and above is broken. For other browsers, 19 and above. + maxZoom: isIE11 ? 14 : 18, + ...options.providerOptions + }); + + if (options.controlEnabled) { + this.map.enableScrollWheelZoom(); + this.map.addControl(new BMap.NavigationControl({ + anchor: BMAP_ANCHOR_TOP_RIGHT, + type: BMAP_NAVIGATION_CONTROL_ZOOM + })); + } else { + this.map.disableDragging(); + this.map.disableDoubleClickZoom(); + this.map.disablePinchToZoom(); + } + + this.map.addEventListener('movestart', () => this._panStartHandler()); + this.map.addEventListener('moveend', () => this._panHandler()); + this.map.addEventListener('zoomstart', () => this._panStartHandler()); + this.map.addEventListener('zoomend', () => { + this._wrapper.dataset.baiduZoom = this.map.getZoom(); + this._panHandler(); + }); + + // The map center has to be converted asynchronously via Baidu's API + this._centerReady = Promise.resolve(); + } + + getCenter() { + return new Coordinate(this.map.getCenter()); + } + + getZoom() { + return this.map.getZoom() - baiduZoomConversionConstant; + } + + setCenter(coordinate, animated) { + this._centerReady = gcj02ToBD09([coordinate]).then(([convertedCoord]) => { + const point = new BMap.Point(convertedCoord.longitude, convertedCoord.latitude); + this.map.panTo(point, { noAnimation: !animated }); + }); + } + + setZoom(zoom, animated) { + this._centerReady.then(() => { + this.map.setViewport({ + center: this.map.getCenter(), + zoom: Math.floor(zoom + baiduZoomConversionConstant) // Baidu only allows integer zoom + }, { enableAnimation: animated }); + }); + } +} + +// Pin Class + +/** + * Baidu Maps documentation lives here: {@link http://lbsyun.baidu.com/cms/jsapi/reference/jsapi_reference_3_0.html} + * @implements {ProviderPin} + */ +class BaiduPin extends ProviderPin { + /** + * @param {ProviderPinOptions} options + */ + constructor(options) { + super(options); + + this._pinEl = document.createElement('button'); + this._pinEl.style.backgroundSize = 'contain'; + this._pinEl.style.backgroundRepeat = 'no-repeat'; + this._pinEl.style.position = 'absolute'; + this._pinEl.style.top = '0'; + this._pinEl.style.left = '0'; + + this._pinAlt = document.createElement('span'); + this._pinAlt.classList.add('sr-only'); + this._pinEl.appendChild(this._pinAlt); + + this._wrapper = null; + this._zIndex = 0; + this._wrapperClass = ''; + this._originalWrapperClass = ''; + this._element = this._pinEl; + + // The pin coordinate has to be converted asynchronously via Baidu's API + this._coordinateReady = Promise.resolve(); + this._negativeLngFix = false; + + const that = this; + + class CustomMarker extends BMap.Marker { + initialize(map) { + that._wrapper = super.initialize(map); + + if (that._wrapper) { + that._wrapper.style.zIndex = that._zIndex; + that._originalWrapperClass = that._wrapper.getAttribute('class'); + that._wrapper.setAttribute('class', that._getClass()); + that._wrapper.appendChild(that._element); + + that._wrapper.addEventListener('click', () => that._clickHandler()); + that._wrapper.addEventListener('touchend', () => that._clickHandler()); + that._wrapper.addEventListener('focusin', () => that._focusHandler(true)); + that._wrapper.addEventListener('focusout', () => that._focusHandler(false)); + that._wrapper.addEventListener('mouseover', () => that._hoverHandler(true)); + that._wrapper.addEventListener('mouseout', () => that._hoverHandler(false)); + } + + return that._wrapper; + } + + draw() { + if (that._wrapper) { + const zIndex = that._wrapper.style.zIndex; + + super.draw(); + that._wrapper.style.zIndex = zIndex; + } else { + super.draw(); + } + } + } + + this.pin = new CustomMarker(new BMap.Point(0, 0)); + + // Remove the default icon and shadow by setting it to a transparent 0x0 pixel + const hiddenIcon = new BMap.Icon('data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', { height: 0, width: 0 }); + this.pin.setIcon(hiddenIcon); + this.pin.setShadow(hiddenIcon); + } + + setCoordinate(coordinate) { + this._coordinateReady = gcj02ToBD09([coordinate]).then(([convertedCoord]) => { + // To avoid Baidu's glitched rendering of pins with negative longitude, this will set the pin to a + // longitude exactly halfway around the world, and CSS will translate it to its correct position. + this._negativeLngFix = convertedCoord.longitude < 0; + this.pin.setPosition(new BMap.Point(convertedCoord.longitude + (this._negativeLngFix ? 180 : 0), convertedCoord.latitude)); + + if (this._wrapper) { + this._wrapper.classList[this._negativeLngFix ? 'add' : 'remove'](negativeLngPinClass); + } + }); + } + + setMap(themeMap, currentMap) { + this._coordinateReady.then(() => { + if (currentMap) { + currentMap.getProviderMap().map.removeOverlay(this.pin); + } + + if (themeMap) { + themeMap.getProviderMap().map.addOverlay(this.pin); + } + }); + } + + setProperties(pinProperties) { + const anchorX = pinProperties.getAnchorX(); + const anchorY = pinProperties.getAnchorY(); + const className = pinProperties.getClass(); + const element = pinProperties.getElement() || this._pinEl; + const height = pinProperties.getHeight(); + const icon = this._icons[pinProperties.getIcon()]; + const srText = pinProperties.getSRText(); + const width = pinProperties.getWidth(); + const zIndex = pinProperties.getZIndex(); + + this._pinEl.style.backgroundImage = icon ? `url("${icon}")` : ''; + this._pinEl.style.height = height + 'px'; + this._pinEl.style.transform = `translate(${-100 * anchorX}%,${-100 * anchorY}%)`; + this._pinEl.style.width = width + 'px'; + + this._pinAlt.innerText = srText; + + this._zIndex = zIndex; + this._wrapperClass = className; + this._element = element; + + if (this._wrapper) { + this._wrapper.style.zIndex = this._zIndex; + this._wrapper.setAttribute('class', this._getClass()); + + if (this._element != this._wrapper.children[0]) { + this._wrapper.removeChild(this._wrapper.children[0]); + this._wrapper.appendChild(this._element); + } + } + } + + _getClass() { + return `${this._originalWrapperClass} ${this._negativeLngFix ? negativeLngPinClass : ''} ${this._wrapperClass}`; + } +} + +// Load Function + +const yextAPIKey = 'bxNEoKKmzVSORkUQGwRxLbtis4Wwokdg'; +const baseUrl = 'https://api.map.baidu.com/getscript'; + +/** + * This function is called when calling {@link MapProvider#load} on {@link module:Maps/Providers/Baidu.BaiduMaps}. + * @param {function} resolve Callback with no arguments called when the load finishes successfully + * @param {function} reject Callback with no arguments called when the load fails + * @param {?string} apiKey Provider API key + * @param {Object} options Additional provider-specific options + * @param {Object} [options.params={}] Additional API params + * @param {string} [options.version='3.0'] API version + * @see {MapProvider~loadFunction} + */ +function load(resolve, reject, apiKey, { + params = {}, + version = '3.0' +}) { + window.BMAP_PROTOCOL = 'https'; + window.BMap_loadScriptTime = new Date().getTime(); + + const key = apiKey || yextAPIKey; + const apiParams = { + ak: key, + v: version, + ...params + }; + + resolveAPIKey(key); + + const script = document.createElement('script'); + script.src = baseUrl + '?' + Object.entries(apiParams).map(([key, value]) => key + '=' + value).join('&'); + script.onload = () => resolve(); + + document.head.appendChild(script); + + // Generate a style block to fix rendering of pins with negative longitude at each zoom level + let negativeLngFixCSS = ''; + for (let i = baiduMinZoom; i <= baiduMaxZoom; i++) { + const offset = 2 ** (i - baiduZoomConversionConstant + 7); + negativeLngFixCSS += `[data-baidu-zoom="${i}"] .${negativeLngPinClass}{transform:translateX(-${offset}px);}` + } + + const negativeLngFixStyle = document.createElement('style'); + negativeLngFixStyle.innerHTML = negativeLngFixCSS; + + document.head.appendChild(negativeLngFixStyle); +} + +// Exports + +/** + * @static + * @type {MapProvider} + */ +const BaiduMaps = new MapProviderOptions() + .withLoadFunction(load) + .withMapClass(BaiduMap) + .withPinClass(BaiduPin) + .withProviderName('Baidu') + .build(); + +export { + BaiduMaps, + gcj02ToBD09 +}; diff --git a/static/js/theme-map/Maps/Providers/Bing.js b/static/js/theme-map/Maps/Providers/Bing.js new file mode 100644 index 000000000..f40a44db2 --- /dev/null +++ b/static/js/theme-map/Maps/Providers/Bing.js @@ -0,0 +1,268 @@ +/** @module Maps/Providers/Bing */ + +import { Coordinate } from '../../Geo/Coordinate.js'; +import { LoadScript } from '../../Performance/LoadContent.js'; +import { MapProviderOptions } from '../MapProvider.js'; +import { ProviderMap } from '../ProviderMap.js'; +import { ProviderPin } from '../ProviderPin.js'; + +// Map Class + +// CustomOverlay for HTML Pins +let PinOverlay; + +function initPinOverlayClass() { + class PinOverlayClass extends Microsoft.Maps.CustomOverlay { + constructor() { + super({ beneathLabels: false }); + + this._container = document.createElement('div'); + this._map = null; + this._pins = new Set(); + this._viewChangeEventHandler = null; + + this._container.style.position = 'absolute'; + this._container.style.left = '0'; + this._container.style.top = '0'; + } + + addPin(pin) { + this._pins.add(pin); + this._container.appendChild(pin._wrapper); + + if (this._map) { + this.updatePinPosition(pin); + } + } + + onAdd() { + this._map = this.getMap(); + this.setHtmlElement(this._container); + } + + onLoad() { + this._viewChangeEventHandler = Microsoft.Maps.Events.addHandler(this._map, 'viewchange', () => this.updatePinPositions()); + this.updatePinPositions(); + } + + onRemove() { + Microsoft.Maps.Events.removeHandler(this._viewChangeEventHandler); + this._map = null; + } + + removePin(pin) { + this._pins.delete(pin); + this._container.removeChild(pin._wrapper); + } + + updatePinPosition(pin) { + if (!this._map) { + return; + } + + const topLeft = this._map.tryLocationToPixel(pin._location, Microsoft.Maps.PixelReference.control); + pin._wrapper.style.left = topLeft.x + 'px'; + pin._wrapper.style.top = topLeft.y + 'px'; + } + + updatePinPositions() { + this._pins.forEach(pin => this.updatePinPosition(pin)); + } + } + + PinOverlay = PinOverlayClass; +} + +/** + * @implements {ProviderMap} + */ +class BingMap extends ProviderMap { + /** + * @param {ProviderMapOptions} options + */ + constructor(options) { + super(options); + + this.wrapper = options.wrapper; + this.map = new Microsoft.Maps.Map(this.wrapper, { + disablePanning: !options.controlEnabled, + disableZooming: !options.controlEnabled, + showLocateMeButton: false, + showMapTypeSelector: false, + showScalebar: false, + showTrafficButton: false, + ...options.providerOptions + }); + + this.pinOverlay = new PinOverlay(this.map); + this.map.layers.insert(this.pinOverlay); + + Microsoft.Maps.Events.addHandler(this.map, 'viewchangestart', () => this._panStartHandler()); + Microsoft.Maps.Events.addHandler(this.map, 'viewchangeend', () => this._panHandler()); + } + + getCenter() { + return new Coordinate(this.map.getCenter()); + } + + getZoom() { + return this.map.getZoom(); + } + + setCenter(coordinate, animated) { + const center = new Microsoft.Maps.Location(coordinate.latitude, coordinate.longitude); + this.map.setView({ center }); + this.pinOverlay.updatePinPositions(); + } + + setZoom(zoom, animated) { + // Bing only allows integer zoom + this.map.setView({ zoom: Math.floor(zoom) }); + this.pinOverlay.updatePinPositions(); + } +} + +// Pin Class + +/** + * @implements {ProviderPin} + */ +class BingPin extends ProviderPin { + /** + * Bing pins need global callbacks to complete initialization. + * This function provides a unique ID to include in the name of the callback. + * @returns {number} An ID for the pin unique across all instances of BingPin + */ + static getId() { + this._pinId = (this._pinId || 0) + 1; + return this._pinId; + } + + /** + * @param {ProviderPinOptions} options + */ + constructor(options) { + super(options); + + this._pinEl = document.createElement('button'); + this._pinEl.style.backgroundSize = 'contain'; + this._pinEl.style.backgroundRepeat = 'no-repeat'; + this._pinEl.style.position = 'absolute'; + this._pinEl.style.top = '0'; + this._pinEl.style.left = '0'; + + this._pinAlt = document.createElement('span'); + this._pinAlt.classList.add('sr-only'); + this._pinEl.appendChild(this._pinAlt); + + this._wrapper = document.createElement('div'); + this._wrapper.appendChild(this._pinEl); + this._wrapper.style.position = 'absolute'; + + this._wrapper.addEventListener('click', () => this._clickHandler()); + this._wrapper.addEventListener('focusin', () => this._focusHandler(true)); + this._wrapper.addEventListener('focusout', () => this._focusHandler(false)); + this._wrapper.addEventListener('mouseover', () => this._hoverHandler(true)); + this._wrapper.addEventListener('mouseout', () => this._hoverHandler(false)); + + this._map = null; + this._location = new Microsoft.Maps.Location(0, 0); + } + + setCoordinate(coordinate) { + this._location = new Microsoft.Maps.Location(coordinate.latitude, coordinate.longitude); + + if (this._map) { + this._map.getProviderMap().pinOverlay.updatePinPosition(this); + } + } + + setMap(themeMap, currentMap) { + if (currentMap) { + currentMap.getProviderMap().pinOverlay.removePin(this); + } + + if (themeMap) { + themeMap.getProviderMap().pinOverlay.addPin(this); + } + + this._map = themeMap; + } + + setProperties(pinProperties) { + const anchorX = pinProperties.getAnchorX(); + const anchorY = pinProperties.getAnchorY(); + const className = pinProperties.getClass(); + const element = pinProperties.getElement() || this._pinEl; + const height = pinProperties.getHeight(); + const icon = this._icons[pinProperties.getIcon()]; + const srText = pinProperties.getSRText(); + const width = pinProperties.getWidth(); + const zIndex = pinProperties.getZIndex(); + + this._pinEl.style.backgroundImage = icon ? `url("${icon}")` : ''; + this._pinEl.style.height = height + 'px'; + this._pinEl.style.transform = `translate(${-100 * anchorX}%,${-100 * anchorY}%)`; + this._pinEl.style.width = width + 'px'; + + this._pinAlt.innerText = srText; + + this._wrapper.style.zIndex = zIndex; + this._wrapper.setAttribute('class', className); + + if (element != this._wrapper.children[0]) { + this._wrapper.removeChild(this._wrapper.children[0]); + this._wrapper.appendChild(element); + } + } +} + +// Load Function + +// Random token obtained from `echo BingMapsCallbackYext | md5 | cut -c -8` +const globalCallback = 'BingMapsCallback_593d7d33'; +const yextAPIKey = 'ApYPB8G-KZ_b2M0E8gi5PqOJDnJ2a7JXSOIHSzrYtJcX2AfyvQmgwFNSxPkAOhWm'; +const baseUrl = 'https://www.bing.com/api/maps/mapcontrol'; + +/** + * This function is called when calling {@link MapProvider#load} on {@link module:Maps/Providers/Bing.BingMaps}. + * @param {function} resolve Callback with no arguments called when the load finishes successfully + * @param {function} reject Callback with no arguments called when the load fails + * @param {?string} apiKey Provider API key + * @param {Object} options Additional provider-specific options + * @param {Object} [options.params={}] Additional API params + * @see {MapProvider~loadFunction} + */ +function load(resolve, reject, apiKey, { + params = {} +}) { + window[globalCallback] = () => { + initPinOverlayClass(); + resolve(); + }; + + const apiParams = { + callback: globalCallback, + key: apiKey || yextAPIKey, + ...params + }; + + LoadScript(baseUrl + '?' + Object.entries(apiParams).map(([key, value]) => key + '=' + value).join('&')); +} + +// Exports + +/** + * @static + * @type {MapProvider} + */ +const BingMaps = new MapProviderOptions() + .withLoadFunction(load) + .withMapClass(BingMap) + .withPinClass(BingPin) + .withProviderName('Bing') + .build(); + +export { + BingMaps +}; diff --git a/static/js/theme-map/Maps/Providers/Google.js b/static/js/theme-map/Maps/Providers/Google.js new file mode 100644 index 000000000..f5ceb3a6e --- /dev/null +++ b/static/js/theme-map/Maps/Providers/Google.js @@ -0,0 +1,235 @@ +/** @module Maps/Providers/Google */ + +import { Coordinate } from '../../Geo/Coordinate.js'; +import { LoadScript } from '../../Performance/LoadContent.js'; +import { MapProviderOptions } from '../MapProvider.js'; +import { ProviderMap } from '../ProviderMap.js'; +import { ProviderPin } from '../ProviderPin.js'; +import { debounce } from '../../Util/helpers'; + +/** + * @static + * @enum {string} + */ +const Library = { + PLACES: 'places' +}; + +// Map Class + +/** + * @implements {ProviderMap} + */ +class GoogleMap extends ProviderMap { + /** + * @param {ProviderMapOptions} options + */ + constructor(options) { + super(options); + + this.map = new google.maps.Map(options.wrapper, { + disableDefaultUI: !options.controlEnabled, + fullscreenControl: false, + gestureHandling: options.controlEnabled ? 'auto' : 'none', + mapTypeControl: false, + rotateControl: false, + scaleControl: false, + streetViewControl: false, + zoomControl: options.controlEnabled, + zoomControlOptions: { + position: google.maps.ControlPosition.RIGHT_TOP + }, + ...options.providerOptions + }); + + // Google getZoom only gives integer zoom, so we have to keep track otherwise. + this._currentZoom = null; + this._zoomValid = true; + this._zoomChangeListener = null; + + this._moving = false; + + const debouncedIdleEvent = debounce(() => { + this._moving = false; + this._panHandler(); + }, 100); + + google.maps.event.addListener(this.map, 'bounds_changed', () => { + if (!this._moving) { + this._moving = true; + this._panStartHandler(); + } else { + debouncedIdleEvent(); + } + }); + google.maps.event.addListener(this.map, 'idle', debouncedIdleEvent); + google.maps.event.addListener(this.map, 'dragend', () => { + this._dragEndHandler(); + }); + google.maps.event.addListener(this.map, 'zoom_changed', () => { + this._zoomChangedHandler(); + google.maps.event.addListenerOnce(this.map, 'idle', () => { + this._zoomEndHandler(); + }); + }); + google.maps.event.addListener(this.map, 'click', () => { + this._canvasClickHandler(); + }); + } + + getCenter() { + return new Coordinate(this.map.getCenter()); + } + + getZoom() { + return this._zoomValid ? this.map.getZoom() : this._currentZoom; + } + + setCenter(coordinate, animated) { + const latLng = new google.maps.LatLng(coordinate.latitude, coordinate.longitude); + + if (animated) { + this.map.panTo(latLng) + } else { + this.map.setCenter(latLng); + } + } + + setZoom(z, animated) { + const zoom = Math.floor(z); // Floor to avoid snapping + this.map.setZoom(zoom); + } + + setZoomCenter(zoom, center, animated = false) { + this.setCenter(center, animated); + this.setZoom(zoom, animated); + } +} + +// Pin Class + +/** + * @implements {ProviderPin} + * @todo GENERATOR TODO Full HTML pin support + */ +class GooglePin extends ProviderPin { + /** + * @param {ProviderPinOptions} options + */ + constructor(options) { + super(options); + + this.pin = new google.maps.Marker({ + optimized: false // For IE <= 11 compat + }); + + google.maps.event.addListener(this.pin, 'click', () => this._clickHandler()); + google.maps.event.addListener(this.pin, 'mouseover', () => this._hoverHandler(true)); + google.maps.event.addListener(this.pin, 'mouseout', () => this._hoverHandler(false)); + // GENERATOR TODO focus handler (if possible) + } + + setCoordinate(coordinate) { + this.pin.setPosition(new google.maps.LatLng(coordinate.latitude, coordinate.longitude)); + } + + setMap(themeMap, currentMap) { + this.pin.setMap(themeMap ? themeMap.getProviderMap().map : null); + } + + setProperties(pinProperties) { + const anchorX = pinProperties.getAnchorX(); + const anchorY = pinProperties.getAnchorY(); + const height = pinProperties.getHeight(); + const icon = this._icons[pinProperties.getIcon()]; + const width = pinProperties.getWidth(); + const zIndex = pinProperties.getZIndex(); + + const options = { zIndex }; + + if (icon) { + options.icon = { + anchor: new google.maps.Point(anchorX * width, anchorY * height), + scaledSize: new google.maps.Size(width, height), + url: this._icons[pinProperties.getIcon()] + } + } + + this.pin.setOptions(options); + } +} + +// Load Function + +// Random token obtained from `echo GoogleMapsCallbackYext | md5 | cut -c -8` +const globalCallback = 'GoogleMapsCallback_b7d77ff2'; +const yextClient = 'gme-yextinc'; +const baseUrl = 'https://maps.googleapis.com/maps/api/js'; + +/** + * This function is called when calling {@link MapProvider#load} on {@link module:Maps/Providers/Google.GoogleMaps}. + * @param {function} resolve Callback with no arguments called when the load finishes successfully + * @param {function} reject Callback with no arguments called when the load fails + * @param {?string} apiKey Provider API key + * @param {Object} options Additional provider-specific options + * @param {boolean} [options.autocomplete=false] Whether to include Google's autocomplete API + * @param {string} [options.channel=window.location.hostname] API key usage channel + * @param {string} [options.client] Google API enterprise client + * @param {string} [options.language] Language of the map + * @param {module:Maps/Providers/Google.Library[]} [options.libraries=[]] Additional Google libraries to load + * @param {Object} [options.params={}] Additional API params + * @see {MapProvider~loadFunction} + */ +function load(resolve, reject, apiKey, { + autocomplete = false, + channel = window.location.hostname, + client, + language, + libraries = [], + params = {} +}) { + window[globalCallback] = resolve; + + if (autocomplete) { + libraries.push(Library.PLACES); + } + + const apiParams = { + callback: globalCallback, + channel, + language, + libraries: libraries.join(','), + ...params + }; + + if (apiKey) { + apiParams.key = apiKey; + } + + if (client) { + apiParams.client = client; + } else if (!apiKey) { + apiParams.client = yextClient; + } + + LoadScript(baseUrl + '?' + Object.entries(apiParams).map(([key, value]) => key + '=' + value).join('&')); +} + +// Exports + +/** + * @static + * @type {MapProvider} + */ +const GoogleMaps = new MapProviderOptions() + .withSupportedLocales(['zh-CN', 'zn-HK', 'zh-TW', 'en-AU', 'en-GB', 'fr-CA', 'pt-BR', 'pt-PT', 'es-419']) + .withLoadFunction(load) + .withMapClass(GoogleMap) + .withPinClass(GooglePin) + .withProviderName('Google') + .build(); + +export { + GoogleMaps, + Library +}; diff --git a/static/js/theme-map/Maps/Providers/Leaflet.js b/static/js/theme-map/Maps/Providers/Leaflet.js new file mode 100644 index 000000000..3d8d314db --- /dev/null +++ b/static/js/theme-map/Maps/Providers/Leaflet.js @@ -0,0 +1,162 @@ +/** @module Maps/Providers/Leaflet */ + +import { Coordinate } from '../../Geo/Coordinate.js'; +import { MapProviderOptions } from '../MapProvider.js'; +import { ProviderMap } from '../ProviderMap.js'; +import { ProviderPin } from '../ProviderPin.js'; + +// Map Class + +/** + * @implements {ProviderMap} + */ +class LeafletMap extends ProviderMap { + /** + * @param {ProviderMapOptions} options + */ + constructor(options) { + super(options); + + // We need to setZoom on map init because otherwise it will default + // to zoom = undefined and will try to load infinite map tiles. + // This setZoom is immediately overridden by Map.constructor() + this.map = new L.map(options.wrapper, { + boxZoom: options.controlEnabled, + doubleClickZoom: options.controlEnabled, + dragging: options.controlEnabled, + zoom: 0, + zoomControl: options.controlEnabled, + zoomSnap: 0, + ...options.providerOptions + }); + + if (options.controlEnabled) { + this.map.zoomControl.setPosition('topright'); + } + + const params = options.providerOptions; + const tileLayerSrc = params.tileLayerSrc || 'https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}'; + const tileLayerConfig = params.tileLayerOptions || { + attribution: 'Map data © OpenStreetMap contributors, CC-BY-SA, Imagery © Mapbox', + id: 'mapbox/streets-v11', + }; + + tileLayerConfig.accessToken = this.constructor.apiKey; + + L.tileLayer(tileLayerSrc, tileLayerConfig).addTo(this.map); + + this.map.on('movestart', () => this._panStartHandler()); + this.map.on('moveend', () => this._panHandler()); + } + + getCenter() { + return new Coordinate(this.map.getCenter()); + } + + getZoom() { + return this.map.getZoom(); + } + + setCenter(coordinate, animated) { + const latLng = new L.latLng(coordinate.latitude, coordinate.longitude); + this.map.panTo(latLng, { animate: animated }); + } + + setZoom(zoom, animated) { + this.map.setZoom(zoom, { animate: animated }); + } +} + +// Pin Class + +/** + * @implements {ProviderPin} + * @todo GENERATOR TODO Full HTML pin support {@link https://leafletjs.com/reference-1.6.0.html#popup} + */ +class LeafletPin extends ProviderPin { + /** + * @param {ProviderPinOptions} options + */ + constructor(options) { + super(options); + + this.pin = new L.marker(); + + this.pin.on('click', () => this._clickHandler()); + this.pin.on('mouseover', () => this._hoverHandler(true)); + this.pin.on('mouseout', () => this._hoverHandler(false)); + // GENERATOR TODO focus handler (after HTML pin support) + } + + setCoordinate(coordinate) { + const latLng = new L.latLng(coordinate.latitude, coordinate.longitude); + this.pin.setLatLng(latLng); + } + + setMap(themeMap, currentMap) { + if (themeMap) { + this.pin.addTo(themeMap.getProviderMap().map); + } else { + this.pin.remove(); + } + } + + setProperties(pinProperties) { + const width = pinProperties.getWidth(); + const height = pinProperties.getHeight(); + const anchorX = pinProperties.getAnchorX(); + const anchorY = pinProperties.getAnchorY(); + + this.pin.setIcon(new L.icon({ + iconUrl: this._icons[pinProperties.getIcon()], + iconSize: [width, height], + iconAnchor: [anchorX * width, anchorY * height], + className: pinProperties.getClass() + })); + this.pin.setZIndexOffset(pinProperties.getZIndex()); + } +} + +// Load Function + +const yextAPIKey = 'pk.eyJ1IjoieWV4dCIsImEiOiJqNzVybUhnIn0.hTOO5A1yqfpN42-_z_GuLw'; +const baseUrl = 'https://unpkg.com/leaflet@1.6.0/dist/leaflet'; + +/** + * This function is called when calling {@link MapProvider#load} on {@link module:Maps/Providers/Leaflet.LeafletMaps}. + * @param {function} resolve Callback with no arguments called when the load finishes successfully + * @param {function} reject Callback with no arguments called when the load fails + * @param {?string} apiKey Provider API key + * @see {MapProvider~loadFunction} + */ +function load(resolve, reject, apiKey) { + LeafletMap.apiKey = apiKey || yextAPIKey; + + const mapStyle = document.createElement('link'); + mapStyle.rel = 'stylesheet'; + mapStyle.href = baseUrl + '.css'; + + const mapScript = document.createElement('script'); + mapScript.src = baseUrl + '.js'; + mapScript.onload = () => resolve(); + + document.head.appendChild(mapStyle); + document.head.appendChild(mapScript); +} + +// Exports + +/** + * @static + * @type {MapProvider} + */ +const LeafletMaps = new MapProviderOptions() + .withLoadFunction(load) + .withMapClass(LeafletMap) + .withPinClass(LeafletPin) + .withProviderName('Leaflet') + .build(); + +export { + LeafletMaps +}; diff --git a/static/js/theme-map/Maps/Providers/MapQuest.js b/static/js/theme-map/Maps/Providers/MapQuest.js new file mode 100644 index 000000000..8d23ed1a5 --- /dev/null +++ b/static/js/theme-map/Maps/Providers/MapQuest.js @@ -0,0 +1,107 @@ +/** @module Maps/Providers/MapQuest */ + +import { Coordinate } from '../../Geo/Coordinate.js'; +import { MapProviderOptions } from '../MapProvider.js'; +import { ProviderMap } from '../ProviderMap.js'; +import { LeafletMaps } from './Leaflet.js'; + +const LeafletPin = LeafletMaps.getPinClass(); + +// Map Class + +/** + * @implements {ProviderMap} + */ +class MapQuestMap extends ProviderMap { + constructor(options) { + super(options); + + this.map = L.mapquest.map(options.wrapper, { + boxZoom: options.controlEnabled, + center: new L.latLng(0, 0), + doubleClickZoom: options.controlEnabled, + dragging: options.controlEnabled, + layers: L.mapquest.tileLayer('map'), + zoom: 0, + zoomControl: options.controlEnabled, + zoomSnap: 0, + ...options.providerOptions + }); + + if (options.controlEnabled) { + this.map.zoomControl.setPosition('topright'); + } + + this.map.on('movestart', () => this._panStartHandler()); + this.map.on('moveend', () => this._panHandler()); + } + + getCenter() { + return new Coordinate(this.map.getCenter()); + } + + getZoom() { + return this.map.getZoom(); + } + + setCenter(coordinate, animated) { + const latLng = new L.latLng(coordinate.latitude, coordinate.longitude); + this.map.panTo(latLng, { animate: animated }); + } + + setZoom(zoom, animated) { + this.map.setZoom(zoom, { animate: animated }); + } +} + +// Pin Class + +/** + * @extends {LeafletPin} + */ +class MapQuestPin extends LeafletPin {} + +// Load Function + +const yextAPIKey = 'Fmjtd%7Cluu829urnh%2Cbn%3Do5-9w1ghy'; +const baseUrl = 'https://api.mqcdn.com/sdk/mapquest-js/v1.3.2/mapquest-maps'; + +/** + * This function is called when calling {@link MapProvider#load} on {@link module:Maps/Providers/MapQuest.MapQuestMaps}. + * @param {function} resolve Callback with no arguments called when the load finishes successfully + * @param {function} reject Callback with no arguments called when the load fails + * @param {?string} apiKey Provider API key + * @see {MapProvider~loadFunction} + */ +function load(resolve, reject, apiKey) { + const mapStyle = document.createElement('link'); + mapStyle.rel = 'stylesheet'; + mapStyle.href = baseUrl + '.css'; + + const mapScript = document.createElement('script'); + mapScript.src = baseUrl + '.js'; + mapScript.onload = () => { + L.mapquest.key = apiKey || yextAPIKey; + resolve(); + }; + + document.head.appendChild(mapStyle); + document.head.appendChild(mapScript); +} + +// Exports + +/** + * @static + * @type {MapProvider} + */ +const MapQuestMaps = new MapProviderOptions() + .withLoadFunction(load) + .withMapClass(MapQuestMap) + .withPinClass(MapQuestPin) + .withProviderName('MapQuest') + .build(); + +export { + MapQuestMaps +}; diff --git a/static/js/theme-map/Maps/Providers/Mapbox.js b/static/js/theme-map/Maps/Providers/Mapbox.js new file mode 100644 index 000000000..391aef642 --- /dev/null +++ b/static/js/theme-map/Maps/Providers/Mapbox.js @@ -0,0 +1,212 @@ +/** @module Maps/Providers/Mapbox */ + +import { Coordinate } from '../../Geo/Coordinate.js'; +import { MapProviderOptions } from '../MapProvider.js'; +import { ProviderMap } from '../ProviderMap.js'; +import { ProviderPin } from '../ProviderPin.js'; + +// TODO (jronkin) call map resize method when hidden/shown (CoreBev, used to be done in Core.js) + +// Map Class + +/** + * @implements {ProviderMap} + */ +class MapboxMap extends ProviderMap { + /** + * @param {ProviderMapOptions} options + */ + constructor(options) { + super(options); + + this.map = new mapboxgl.Map({ + container: options.wrapper, + interactive: options.controlEnabled, + style: 'mapbox://styles/mapbox/streets-v9', + ...options.providerOptions + }); + + // Add the zoom control + if (options.controlEnabled) { + const zoomControl = new mapboxgl.NavigationControl({showCompass: false}) + this.map.addControl(zoomControl); + } + + this.map.on('movestart', () => this._panStartHandler()); + this.map.on('moveend', () => this._panHandler()); + this.map.on('dragend', () => this._dragEndHandler()); + this.map.on('zoomstart', () => this._zoomChangedHandler()); + this.map.on('zoomend', () => this._zoomEndHandler()); + this.map.on('click', (e) => { + if (e.originalEvent.target.nodeName === 'CANVAS') { + this._canvasClickHandler(); + } + }); + } + + getCenter() { + return new Coordinate(this.map.getCenter()); + } + + getZoom() { + // Our standard zoom: at level 0, the world is 256 pixels wide and doubles each level + // Mapbox zoom: at level 0, the world is 512 pixels wide and doubles each level + return this.map.getZoom() + 1; + } + + setCenter(coordinate, animated) { + const center = new mapboxgl.LngLat(coordinate.longitude, coordinate.latitude); + + if (animated) { + this.map.panTo(center); + } else { + this.map.setCenter(center); + } + } + + setZoom(zoom, animated) { + // Our standard zoom: at level 0, the world is 256 pixels wide and doubles each level + // Mapbox zoom: at level 0, the world is 512 pixels wide and doubles each level + if (animated) { + this.map.zoomTo(zoom - 1); + } else { + this.map.setZoom(zoom - 1); + } + } + + setZoomCenter(zoom, coordinate, animated) { + const center = new mapboxgl.LngLat(coordinate.longitude, coordinate.latitude); + + // Our standard zoom: at level 0, the world is 256 pixels wide and doubles each level + // Mapbox zoom: at level 0, the world is 512 pixels wide and doubles each level + this.map[animated ? 'easeTo' : 'jumpTo']({ center, zoom: zoom - 1 }); + } +} + +// Pin Class + +/** + * @implements {ProviderPin} + */ +class MapboxPin extends ProviderPin { + /** + * @param {ProviderPinOptions} options + */ + constructor(options) { + super(options); + + this._pinEl = document.createElement('button'); + this._pinEl.style.backgroundSize = 'contain'; + this._pinEl.style.backgroundRepeat = 'no-repeat'; + this._pinEl.style.position = 'absolute'; + this._pinEl.style.top = '0'; + this._pinEl.style.left = '0'; + + this._pinAlt = document.createElement('span'); + this._pinAlt.classList.add('sr-only'); + this._pinEl.appendChild(this._pinAlt); + + this._wrapper = document.createElement('div'); + this._wrapper.appendChild(this._pinEl); + this._wrapper.style.position = 'absolute'; + + this.pin = new mapboxgl.Marker({ + element: this._wrapper + }); + + this._wrapper.addEventListener('click', () => this._clickHandler()); + this._wrapper.addEventListener('focusin', () => this._focusHandler(true)); + this._wrapper.addEventListener('focusout', () => this._focusHandler(false)); + this._wrapper.addEventListener('mouseover', () => this._hoverHandler(true)); + this._wrapper.addEventListener('mouseout', () => this._hoverHandler(false)); + } + + setCoordinate(coordinate) { + this.pin.setLngLat(new mapboxgl.LngLat(coordinate.longitude, coordinate.latitude)); + } + + setMap(themeMap, currentMap) { + if (themeMap) { + this.pin.addTo(themeMap.getProviderMap().map); + } else { + this.pin.remove(); + } + } + + setProperties(pinProperties) { + const anchorX = pinProperties.getAnchorX(); + const anchorY = pinProperties.getAnchorY(); + const className = pinProperties.getClass(); + const element = pinProperties.getElement() || this._pinEl; + const height = pinProperties.getHeight(); + const icon = this._icons[pinProperties.getIcon()]; + const srText = pinProperties.getSRText(); + const width = pinProperties.getWidth(); + const zIndex = pinProperties.getZIndex(); + + this._pinEl.style.backgroundImage = icon ? `url("${icon}")` : ''; + this._pinEl.style.height = height + 'px'; + this._pinEl.style.transform = `translate(${-100 * anchorX}%,${-100 * anchorY}%)`; + this._pinEl.style.width = width + 'px'; + + this._pinAlt.innerText = srText; + + this._wrapper.style.zIndex = zIndex; + this._wrapper.setAttribute('class', className); + + if (element != this._wrapper.children[0]) { + this._wrapper.removeChild(this._wrapper.children[0]); + this._wrapper.appendChild(element); + } + } +} + +// Load Function + +const yextAPIKey = 'pk.eyJ1IjoieWV4dCIsImEiOiJqNzVybUhnIn0.hTOO5A1yqfpN42-_z_GuLw'; + +/** + * This function is called when calling {@link MapProvider#load} on {@link module:Maps/Providers/Mapbox.MapboxMaps}. + * @param {function} resolve Callback with no arguments called when the load finishes successfully + * @param {function} reject Callback with no arguments called when the load fails + * @param {?string} apiKey Provider API key + * @param {Object} options Additional provider-specific options + * @param {string} [options.version='v1.6.1'] API version + * @see {MapProvider~loadFunction} + */ +function load(resolve, reject, apiKey, { + version = 'v1.6.1' +}) { + const baseUrl = `https://api.mapbox.com/mapbox-gl-js/${version}/mapbox-gl`; + + const mapStyle = document.createElement('link'); + mapStyle.rel = 'stylesheet'; + mapStyle.href = baseUrl + '.css'; + + const mapScript = document.createElement('script'); + mapScript.src = baseUrl + '.js'; + mapScript.onload = () => { + mapboxgl.accessToken = apiKey || yextAPIKey; + resolve(); + }; + + document.head.appendChild(mapStyle); + document.head.appendChild(mapScript); +} + +// Exports + +/** + * @static + * @type {MapProvider} + */ +const MapboxMaps = new MapProviderOptions() + .withLoadFunction(load) + .withMapClass(MapboxMap) + .withPinClass(MapboxPin) + .withProviderName('Mapbox') + .build(); + +export { + MapboxMaps +}; diff --git a/static/js/theme-map/Maps/ZoomTriggers.js b/static/js/theme-map/Maps/ZoomTriggers.js new file mode 100644 index 000000000..c0640c822 --- /dev/null +++ b/static/js/theme-map/Maps/ZoomTriggers.js @@ -0,0 +1,10 @@ +/** + * Describes the types of triggers for a zoom change event + * + * @enum {string} + */ +export default { + UNSET: '', + API: 'api', + USER: 'user' +}; diff --git a/static/js/theme-map/MobileStates.js b/static/js/theme-map/MobileStates.js new file mode 100644 index 000000000..3d85344a9 --- /dev/null +++ b/static/js/theme-map/MobileStates.js @@ -0,0 +1,13 @@ +/** + * Defines the possible mobile map states + * + * Map view and list view are mutually exclusive, however detail shown can only occur + * on the map view. + * + * @enum {string} + */ +export default { + MAP_VIEW: 'mapView', + LIST_VIEW: 'listView', + DETAIL_SHOWN: 'detailShown' +}; diff --git a/static/js/theme-map/Performance/LoadContent.js b/static/js/theme-map/Performance/LoadContent.js new file mode 100644 index 000000000..d0dad5e4a --- /dev/null +++ b/static/js/theme-map/Performance/LoadContent.js @@ -0,0 +1,33 @@ +/** + * Insert stylesheet link element into HTML from provided src url + * @param {string} url + */ +function LoadCSS(url) { + const style = document.createElement('link'); + + style.href = url; + style.rel = 'stylesheet'; + style.type = 'text/css'; + + document.head.appendChild(style); +} + +/** + * Insert script element into HTML from provided src url + * @param {string} src + * @param {function} [cb] Function that runs on script load + */ +function LoadScript(src, cb = () => {}) { + const script = document.createElement('script'); + + script.async = true; + script.onload = cb; + script.src = src; + + document.head.appendChild(script); +} + +export { + LoadCSS, + LoadScript +}; diff --git a/static/js/theme-map/PinClusterer/PinClusterer.js b/static/js/theme-map/PinClusterer/PinClusterer.js new file mode 100644 index 000000000..67d5e5b3c --- /dev/null +++ b/static/js/theme-map/PinClusterer/PinClusterer.js @@ -0,0 +1,465 @@ +import { Unit, Projection } from '../Geo/constants.js'; +import { GeoBounds } from '../Geo/GeoBounds.js'; +import { PinOptions } from '../Maps/MapPin.js'; +import { PinProperties } from '../Maps/PinProperties.js'; +import { Type, assertType, assertInstance } from '../Util/Assertions.js'; + +/** + * Represents a cluster of {@link MapPin}s on a map. + * @member {MapPin} clusterPin + * @member {MapPin[]} pins + * @see {PinCluster} + */ +class PinCluster { + /** + * @param {MapPin} clusterPin + * @param {MapPin[]} pins + */ + constructor(clusterPin, pins) { + this.clusterPin = clusterPin; + this.pins = [...pins]; + } + + /** + * Returns true if the cluster contains the pin with the given id, false otherwise + * + * @param {string} id The unique identifier for the pin + * @returns {boolean} + */ + containsPin(id) { + return this.pins.filter(pin => pin.getId() === id).length; + } +} + +/** + * {@link PinClusterer} options class + */ +class PinClustererOptions { + /** + * Initialize with default options + */ + constructor() { + this.autoUpdate = true; + this.clickHandler = cluster => {}; + this.clusterRadius = 50; + this.clusterZoomAnimated = true; + this.clusterZoomMax = Infinity; + this.hideOffscreen = false; + this.hoverHandler = (cluster, hovered) => {}; + this.iconTemplates = { + 'default': null, + 'hovered': null + }; + this.minClusterSize = 2; + this.propertiesForStatus = status => new PinProperties() + .setAnchorX(0.5) + .setAnchorY(0.5) + .setHeight(33) + .setIcon(status.hovered || status.focused ? 'hovered' : 'default') + .setWidth(33); + this.updateHandler = clusters => {}; + this.zoomOnClick = true; + } + + /** + * @param {boolean} autoUpdate Whether the clusters automatically update when the map zoom changes + * @returns {PinClustererOptions} + */ + withAutoUpdate(autoUpdate) { + this.autoUpdate = autoUpdate; + return this; + } + + /** + * @typedef PinClusterer~clickHandler + * @function + * @param {PinCluster} cluster The cluster whose pin was clicked + */ + + /** + * @param {PinClusterer~clickHandler} clickHandler Function that runs when a pin cluster is clicked + * @returns {PinClustererOptions} + */ + withClickHandler(clickHandler) { + assertType(clickHandler, Type.FUNCTION); + + this.clickHandler = clickHandler; + return this; + } + + /** + * @param {number} clusterRadius The max pixel distance from the center of a cluster to any pin in the cluster + * @returns {PinClustererOptions} + */ + withClusterRadius(clusterRadius) { + this.clusterRadius = clusterRadius; + return this; + } + + /** + * @param {boolean} clusterZoomAnimated Whether to animate map zoom on cluster click + * @returns {PinClustererOptions} + */ + withClusterZoomAnimated(clusterZoomAnimated) { + this.clusterZoomAnimated = clusterZoomAnimated; + return this; + } + + /** + * @param {number} clusterZoomMax Max zoom level for the map after clickimg a cluster + * @returns {PinClustererOptions} + */ + withClusterZoomMax(clusterZoomMax) { + this.clusterZoomMax = clusterZoomMax; + return this; + } + + /** + * @param {boolean} hideOffscreen If true, a cluster pin will only be rendered if it's in the visible portion of the map to improve performance + * @returns {PinClustererOptions} + */ + withHideOffscreen(hideOffscreen) { + this.hideOffscreen = hideOffscreen; + return this; + } + + /** + * @typedef PinClusterer~hoverHandler + * @function + * @param {PinCluster} cluster The cluster whose pin was hovered + * @param {boolean} hovered Whether the cluster pin is currently hovered + */ + + /** + * @param {PinClusterer~hoverHandler} hoverHandler Function that runs when a pin cluster is hovered + * @returns {PinClustererOptions} + */ + withHoverHandler(hoverHandler) { + assertType(hoverHandler, Type.FUNCTION); + + this.hoverHandler = hoverHandler; + return this; + } + + /** + * @typedef PinClusterer~iconTemplate + * @function + * @param {Object} data + * @param {Object} data.pinCount The number of pins in the cluster + * @returns {string} The URL or data URI of the icon image + */ + + /** + * @param {string} key The unique name for the icon, used in {@link PinProperties#getIcon} and {@link PinProperties#setIcon} + * @param {PinClusterer~iconTemplate} iconTemplate A template function that returns the pin icon for a cluster + * @returns {PinClustererOptions} + */ + withIconTemplate(key, iconTemplate) { + assertType(iconTemplate, Type.FUNCTION); + + this.iconTemplates[key] = iconTemplate; + return this; + } + + /** + * @param {number} minClusterSize The minimum number of pins in a cluster + * @returns {PinClustererOptions} + */ + withMinClusterSize(minClusterSize) { + this.minClusterSize = minClusterSize; + return this; + } + + /** + * @param {MapPin~propertiesForStatus} propertiesForStatus The propertiesForStatus function for the cluster pins + * @returns {PinClustererOptions} + */ + withPropertiesForStatus(propertiesForStatus) { + assertType(propertiesForStatus, Type.FUNCTION); + + this.propertiesForStatus = propertiesForStatus; + return this; + } + + /** + * @typedef PinClusterer~updateHandler + * @function + * @param {PinCluster[]} clusters All pin clusters after the update + */ + + /** + * @param {PinClusterer~updateHandler} updateHandler Function that runs after the clusters are updated + * @returns {PinClustererOptions} + */ + withUpdateHandler(updateHandler) { + assertType(updateHandler, Type.FUNCTION); + + this.updateHandler = updateHandler; + return this; + } + + /** + * @param {boolean} zoomOnClick Whether to zoom in to the pins in a cluster on cluster click + * @returns {PinClustererOptions} + */ + withZoomOnClick(zoomOnClick) { + this.zoomOnClick = zoomOnClick; + return this; + } + + /** + * @returns {PinClusterer} + */ + build() { + return new PinClusterer(this); + } +} + +/** + * Cluster {@link MapPin}s on a {@link Map} for any provider. PinClusterer can automatically update + * clusters as needed when the map changes. Clicking on a cluster expands it and fits the map to + * the pins. Clustering behavior can be customized by changing the radius and minimum pin count. + */ +class PinClusterer { + /** + * @param {PinClustererOptions} options + */ + constructor(options) { + assertInstance(options, PinClustererOptions); + + this._autoUpdate = options.autoUpdate; + this._clickHandler = options.clickHandler; + this._clusterRadius = options.clusterRadius; + this._clusterZoomAnimated = options.clusterZoomAnimated; + this._clusterZoomMax = options.clusterZoomMax; + this._hideOffscreen = options.hideOffscreen; + this._hoverHandler = options.hoverHandler; + this._iconTemplates = options.iconTemplates; + this._minClusterSize = options.minClusterSize; + this._propertiesForStatus = options.propertiesForStatus; + this._updateHandler = options.updateHandler; + this._zoomOnClick = options.zoomOnClick; + + this._clusters = []; + this.reset(false); + } + + /** + * @param {MapPin[]} pins The pins to be clustered. Any other pins on the map will be ignored. + * @param {?Map} map The {@link Map} to cluster the pins on. If not specified, it will be the map that all the pins are currently on. If not all pins are on the same map, this function will throw an error. + * @param {Geo.Projection} [mapProjection=Projection.MERCATOR] The projection of the map that the pins will be clustered on + */ + cluster(pins, map = null, mapProjection = Projection.MERCATOR) { + this.reset(); + + if (!pins.length) { + return; + } + + if (!map) { + // If no map was provided, infer the map from the pins. + map = pins[0].getMap(); + + // All pins must be on the same map. + if (pins.find(pin => pin.getMap() !== map)) { + throw new Error('Error: All pins must be on the same map'); + } + + // If map is null for all pins, the pins are all not on any map. + if (!map) { + throw new Error('Error: Pins are not on a map'); + } + } + + this._map = map; + this._mapProjection = mapProjection; + this._pins = pins; + + this.update(true); + + if (this._autoUpdate) { + const autoUpdater = async () => { + // Wait for the map to move, then stop moving + await map.moving(); + await map.idle(); + + // Make sure that the auto-updater didn't get reset while waiting + if (this._autoUpdater == autoUpdater) { + this.update(); + autoUpdater(); + } + }; + + this._autoUpdater = autoUpdater; + autoUpdater(); + } + } + + /** + * @returns {PinCluster[]} Current pin clusters + */ + getClusters() { + return [...this._clusters]; + } + + /** + * @param {boolean} [restorePins=true] Whether to put pins currently in clusters back on the map + */ + reset(restorePins = true) { + if (restorePins) { + this._pins.forEach(pin => pin.setMap(this._map)); + } + + this._autoUpdater = null; + this._currentZoom = null; + this._map = null; + this._mapProjection = null; + this._pins = []; + + this.update(true); + } + + /** + * @param {boolean} [force=false] If true, bypass checks that skip the update if deemed not necessary (e.g. if the zoom hasn't changed) + */ + update(force = false) { + if (!force && this._map && this._map.getZoom() == this._currentZoom) { + return; + } + + this._clusters.forEach(cluster => cluster.clusterPin.remove()); + this._clusters = []; + + if (!this._map || !this._pins.length) { + return; + } + + for (const pinCluster of this._generateClusters(this._pins)) { + if (pinCluster.length < this._minClusterSize) { + pinCluster.forEach(pin => pin.setMap(this._map)); + } else { + const coordinates = pinCluster.map(pin => pin.getCoordinate()); + const pinOptions = this._map.newPinOptions() + .withCoordinate(GeoBounds.fit(coordinates).getCenter(this._mapProjection)) + .withHideOffscreen(this._hideOffscreen) + .withPropertiesForStatus(this._propertiesForStatus); + + // Build cluster icon(s) from template + for (const [icon, template] of Object.entries(this._iconTemplates)) { + pinOptions.withIcon(icon, template({ pinCount: pinCluster.length })); + } + + const clusterPin = pinOptions.build(); + const newCluster = new PinCluster(clusterPin, pinCluster); + + // Remove all pins in cluster from map, replace with cluster pin + pinCluster.forEach(pin => pin.remove()); + clusterPin.setMap(this._map); + this._clusters.push(newCluster); + + // When clicked, fit map to all the pins in the cluster and update clusters + clusterPin.setFocusHandler(focused => { + clusterPin.setStatus({ focused }); + this._hoverHandler(newCluster, focused); + }); + clusterPin.setHoverHandler(hovered => { + clusterPin.setStatus({ hovered }); + this._hoverHandler(newCluster, hovered); + }); + clusterPin.setClickHandler(async () => { + if (this._zoomOnClick) { + const movingPromise = this._map.moving(); + + this._map.fitCoordinates(coordinates, this._clusterZoomAnimated, this._clusterZoomMax); + await movingPromise; + await this._map.idle(); + this.update(); + } + + this._clickHandler(newCluster); + }); + } + } + + // Save the zoom level of the clusters -- they don't have to be updated if zoom doesn't change + this._currentZoom = this._map.getZoom(); + this._updateHandler(this.getClusters()); + } + + /** + * Generate clusters of pins based on the options set by the {@PinClustererOptions} for this instance. + * The input is a set (array) of {@link MapPin}s and the output is a set (array) of groups of + * the pins such that: + * + * - Each pin is in exactly one cluster + * - Each pin is at most {@link PinClustererOptions~withClusterRadius|clusterRadius} pixels from the center of the cluster, as determined by the map projection and zoom level + * - Each cluster has at least one pin + * + * @protected + * @param {MapPin[]} pins + * @returns {MapPin[][]} An array of clusters (arrays) of pins. All pins are in exactly one cluster. + */ + _generateClusters(pins) { + const clusterRadiusRadians = this._clusterRadius * Math.PI / 2 ** (this._map.getZoom() + 7); + const pinsInRadius = pins.map((pin, index) => [index]); + const pinClusters = []; + + // Calculate the distances of each pin to each other pin + pins.forEach((pin, index) => { + for (let otherIndex = index; otherIndex < pins.length; otherIndex++) { + if (otherIndex != index) { + const distance = pin.getCoordinate().distanceTo(pins[otherIndex].getCoordinate(), Unit.RADIAN, this._mapProjection); + + if (distance <= clusterRadiusRadians) { + pinsInRadius[index].push(otherIndex); + pinsInRadius[otherIndex].push(index); + } + } + } + }); + + // Loop until there are no pins left to cluster + while (true) { + let maxCount = 0; + let chosenIndex; + + // Find the pin with the most other pins within radius + pinsInRadius.forEach((pinGroup, index) => { + if (pinGroup.length > maxCount) { + maxCount = pinGroup.length; + chosenIndex = index; + } + }); + + // If there are no more pins within clustering radius of another pin, break + if (!maxCount) { + break; + } + + // Add pins to a new cluster, and remove them from pinsInRadius + const chosenPins = pinsInRadius[chosenIndex]; + const cluster = []; + + pinsInRadius[chosenIndex] = []; + + for (const index of chosenPins) { + const pinGroup = pinsInRadius[index]; + + // Add the pin to this cluster and remove it from consideration for other clusters + cluster.push(pins[index]); + pinsInRadius[index] = []; + pinGroup.forEach(otherIndex => pinsInRadius[otherIndex].splice(pinsInRadius[otherIndex].indexOf(index), 1)); + } + + pinClusters.push(cluster); + } + + return pinClusters; + } +} + +export { + PinCluster, + PinClustererOptions, + PinClusterer +}; diff --git a/static/js/theme-map/PinImages.js b/static/js/theme-map/PinImages.js new file mode 100644 index 000000000..c49739177 --- /dev/null +++ b/static/js/theme-map/PinImages.js @@ -0,0 +1,105 @@ +/** + * PinImages is meant to offer an accessible way to change the pin images for result pin + * on the interactive map page. Given some config, an SVG should be customizable to + * have branding consistent styling in this file. + */ +class PinImages { + /** + * @param {Object} defaultPinConfig The configuration for the default pin + * @param {Object} hoveredPinConfig The configuration for the hovered pin + * @param {Object} selectedPinConfig The configuration for the selected pin + */ + constructor(defaultPinConfig = {}, hoveredPinConfig = {}, selectedPinConfig = {}) { + this.defaultPinConfig = defaultPinConfig; + this.hoveredPinConfig = hoveredPinConfig; + this.selectedPinConfig = selectedPinConfig; + } + + /** + * Generate standard theme pin given some parameters + * @param {string} pin.backgroundColor Background color for the pin + * @param {string} pin.strokeColor Stroke (border) color for the pin + * @param {string} pin.labelColor Label (text) color for the pin + * @param {string} pin.width The width of the pin + * @param {string} pin.height The height of the pin + * @param {string} pin.pinCount The index of the pin for the pin text + * @return string The SVG of the pin + */ + generatePin ({ + backgroundColor = '#00759e', + strokeColor = 'black', + labelColor = 'white', + width = '20px', + height= '27px', + index = '', + profile = '' + } = {}) { + return ` + + Path + + + + ${index} + + + + `; + }; + + /** + * Get the default pin image + * @param {Number} pinCount The pin index number for the pin label + * @param {Object} profile The profile data for the entity associated with the pin + */ + getDefaultPin (index, profile) { + return this.generatePin({ + backgroundColor: this.defaultPinConfig.backgroundColor, + strokeColor: this.defaultPinConfig.strokeColor, + labelColor: this.defaultPinConfig.labelColor, + width: '24', + height: '28', + index: '', + profile: profile + }); + } + + /** + * Get the hovered pin image + * @param {Number} pinCount The pin index number for the pin label + * @param {Object} profile The profile data for the entity associated with the pin + */ + getHoveredPin (index, profile) { + return this.generatePin({ + backgroundColor: this.hoveredPinConfig.backgroundColor, + strokeColor: this.hoveredPinConfig.strokeColor, + labelColor: this.hoveredPinConfig.labelColor, + width: '24', + height: '34', + index: '', + profile: profile + }); + } + + /** + * Get the selected pin image + * @param {Number} pinCount The pin index number for the pin label + * @param {Object} profile The profile data for the entity associated with the pin + */ + getSelectedPin (index, profile) { + return this.generatePin({ + backgroundColor: this.selectedPinConfig.backgroundColor, + strokeColor: this.selectedPinConfig.strokeColor, + labelColor: this.selectedPinConfig.labelColor, + width: '24', + height: '34', + index: '', + profile: profile + }); + } +} + +export { PinImages }; diff --git a/static/js/theme-map/Renderer/ElementRenderTarget.js b/static/js/theme-map/Renderer/ElementRenderTarget.js new file mode 100644 index 000000000..e8a5c02ac --- /dev/null +++ b/static/js/theme-map/Renderer/ElementRenderTarget.js @@ -0,0 +1,64 @@ +import { Type, assertType, assertInstance } from '../Util/Assertions.js'; +import { RenderTarget, RenderTargetOptions } from './RenderTarget.js'; + +class ElementRenderTargetOptions extends RenderTargetOptions { + constructor() { + super(); + + this.element = null; + this.templateFunction = data => ''; + } + + /** + * element: DOMNode + * Wrapper element for rendered content + */ + withElement(element) { + this.element = element; + return this; + } + + /** + * templateFunction: function(data) + * soy2js function or any function that takes data (response object from the + * Oracle) as an argument and returns an HTML string + */ + withTemplateFunction(templateFunction) { + assertType(templateFunction, Type.FUNCTION); + + this.templateFunction = templateFunction; + return this; + } + + build() { + return new ElementRenderTarget(this); + } +} + +class ElementRenderTarget extends RenderTarget { + constructor(options) { + assertInstance(options, ElementRenderTargetOptions); + + super(options); + + this._element = options.element; + this._templateFunction = options.templateFunction; + } + + /** + * async render(data) => DOMNode + * Calls templateFunction(data) and sets value to element.innerHTML, then returns element + */ + async render(data) { + if (this._element) { + this._element.innerHTML = this._templateFunction(data); + } + + return this._element; + } +} + +export { + ElementRenderTarget, + ElementRenderTargetOptions +}; diff --git a/static/js/theme-map/Renderer/MapRenderTarget.js b/static/js/theme-map/Renderer/MapRenderTarget.js new file mode 100644 index 000000000..0f75859c5 --- /dev/null +++ b/static/js/theme-map/Renderer/MapRenderTarget.js @@ -0,0 +1,110 @@ +import { Map } from '../Maps/Map.js'; +import { PinClusterer } from '../PinClusterer/PinClusterer.js'; +import { Type, assertType, assertInstance } from '../Util/Assertions.js'; +import { RenderTarget, RenderTargetOptions } from './RenderTarget.js'; + +class MapRenderTargetOptions extends RenderTargetOptions { + constructor() { + super(); + + this.idForEntity = entity => 'js-yl-' + entity.profile.meta.id; + this.map = null; + this.pinBuilder = (pinOptions, entity, index) => pinOptions.build(); + this.pinClusterer = null; + } + + withIdForEntity(idForEntity) { + assertType(idForEntity, Type.FUNCTION); + + this.idForEntity = idForEntity; + return this; + } + + /** + * map: SearchMap + */ + withMap(map) { + assertInstance(map, Map); + + this.map = map; + return this; + } + + withPinBuilder(pinBuilder) { + assertType(pinBuilder, Type.FUNCTION); + + this.pinBuilder = pinBuilder; + return this; + } + + withPinClusterer(pinClusterer) { + assertInstance(pinClusterer, PinClusterer); + + this.pinClusterer = pinClusterer; + return this; + } + + build() { + return new MapRenderTarget(this); + } +} + +class MapRenderTarget extends RenderTarget { + constructor(options) { + assertInstance(options, MapRenderTargetOptions); + + super(options); + + if (!options.map) { + return Promise.reject(new Error('map is null or undefined')); + } + + this._idForEntity = options.idForEntity; + this._map = options.map; + this._pinBuilder = options.pinBuilder; + this._pinClusterer = options.pinClusterer; + + this._pins = {}; + } + + getPins() { + return { ...this._pins }; + } + + /** + * async render(data) => SearchMap + * Calls map update function with data for pins, then returns map element + */ + async render(data) { + if (this._pinClusterer) { + this._pinClusterer.reset(false); + } + + Object.values(this._pins).forEach(pin => pin.remove()); + this._pins = {}; + + (data.response.entities || []).forEach((entity, index) => + this._pins[this._idForEntity(entity)] = this._pinBuilder(this._map.newPinOptions(), entity, index + 1) + ); + + const pins = Object.values(this._pins); + const coordinates = pins.map(pin => pin.getCoordinate()); + + if (coordinates.length && data.fitCoordinates) { + this._map.fitCoordinates(coordinates); + } + + if (this._pinClusterer) { + this._pinClusterer.cluster(pins, this._map); + } else { + pins.forEach(pin => pin.setMap(this._map)); + } + + return this._map; + } +} + +export { + MapRenderTarget, + MapRenderTargetOptions +}; diff --git a/static/js/theme-map/Renderer/RenderTarget.js b/static/js/theme-map/Renderer/RenderTarget.js new file mode 100644 index 000000000..0db910934 --- /dev/null +++ b/static/js/theme-map/Renderer/RenderTarget.js @@ -0,0 +1,67 @@ +import { Type, assertType, assertInstance } from '../Util/Assertions.js'; + +class RenderTargetOptions { + constructor() { + this.onBeforeRender = data => {}; + this.onPostRender = (data, updated) => {}; + } + + /** + * onBeforeRender: function(data) + * Function called before rendering with data to be rendered + */ + withOnBeforeRender(onBeforeRender) { + assertType(onBeforeRender, Type.FUNCTION); + + this.onBeforeRender = onBeforeRender; + return this; + } + + /** + * onPostRender: function(data, updated) + * Function called after rendering with data rendered and element/object updated. + * updated may be null. + */ + withOnPostRender(onPostRender) { + assertType(onPostRender, Type.FUNCTION); + + this.onPostRender = onPostRender; + return this; + } + + build() { + return new RenderTarget(this); + } +} + +class RenderTarget { + constructor(options) { + assertInstance(options, RenderTargetOptions); + + this._onBeforeRender = options.onBeforeRender; + this._onPostRender = options.onPostRender; + + // Wrap pre- and post-render functions with this.render + // For subclasses, `this.render` will refer to the subclass's implementation + const renderFunction = this.render.bind(this); + this.render = async data => { + this._onBeforeRender(data); + const updated = await renderFunction(data); + this._onPostRender(data, updated); + } + } + + /** + * async render(data) => DOMNode + * Renders data (response object from the Oracle) for the target. Must be + * implemented by a subclass. Return value may be null. + */ + async render(data) { + throw new Error('RenderTarget.render must be implemented by subclass'); + } +} + +export { + RenderTarget, + RenderTargetOptions +}; diff --git a/static/js/theme-map/Renderer/Renderer.js b/static/js/theme-map/Renderer/Renderer.js new file mode 100644 index 000000000..2a9cac716 --- /dev/null +++ b/static/js/theme-map/Renderer/Renderer.js @@ -0,0 +1,71 @@ +import { assertInstance } from '../Util/Assertions.js'; +import { RenderTarget } from './RenderTarget.js'; + +class RendererOptions { + constructor() { + this.renderTargets = new Set(); + } + + /** + * renderTargets: Set[RenderTarget] + * All render targets are rendered when the Renderer receives data through render() + */ + withRenderTarget(renderTarget) { + assertInstance(renderTarget, RenderTarget); + + this.renderTargets.add(renderTarget); + return this; + } + + build() { + return new Renderer(this); + } +} + +class Renderer { + constructor(options) { + assertInstance(options, RendererOptions); + + this._renderTargets = options.renderTargets; + } + + /** + * deregister(renderTarget) => bool + * Removes renderTarget (an instance of RenderTarget) from renderTargets to + * no longer be updated when new data is received + * Returns true if renderTarget was successfully removed, false otherwise + */ + deregister(renderTarget) { + return this._renderTargets.delete(renderTarget); + } + + /** + * register(renderTarget) + * Adds renderTarget (an instance of RenderTarget) to renderTargets to be + * updated when new data is received + */ + register(renderTarget) { + assertInstance(renderTarget, RenderTarget); + + this._renderTargets.add(renderTarget); + } + + /** + * render(data) + * Renders each renderTarget in renderTargets with data (response object from the Oracle) + * If render returns a non-null value, calls onLoad() on it (assuming the value is a DOMNode) + */ + render(data) { + this._renderTargets.forEach(renderTarget => { + renderTarget.render(data) + .catch(err => { + console.error(`Failed to render ${renderTarget.constructor.name}: ${err}`); + }); + }); + } +} + +export { + Renderer, + RendererOptions +}; diff --git a/static/js/theme-map/SearchDebouncer.js b/static/js/theme-map/SearchDebouncer.js new file mode 100644 index 000000000..dcefb97f8 --- /dev/null +++ b/static/js/theme-map/SearchDebouncer.js @@ -0,0 +1,81 @@ +import { Coordinate } from './Geo/Coordinate.js'; + +/** + * Responsible for determining whether or not a new search should be ran based on the + * location of the most recent search, the current center of the map, and the zoom level. + */ +class SearchDebouncer { + constructor () { + /** + * The threshold for allowing a new search based on the distance to the previous search. + * + * The unit is a percentage of the map width. For example, if the threshold is .50, + * a new search will not be allowed unless the currentMapCenter is further than 50% of the map + * width away from the previous search. + * + * @type {number} + */ + this.relativeDistanceThreshold = .125; + + /** + * The threshold for allowing a new search based on a change in zoom. + * + * With a zoom threshold of 1, a new search will be ran every time the zoom changes by 1 or greater. + * + * @type {number} + */ + this.zoomThreshold = 1; + } + + /** + * Determines if a search should be debounced based on the relative distance of the current map + * center to the previous map center. + * + * @param {Coordinate} mostRecentSearchMapCenter + * @param {Coordinate} currentMapCenter + * @param {number} currentZoom + * @returns {boolean} + */ + isWithinDistanceThreshold ({ mostRecentSearchMapCenter, currentMapCenter, currentZoom }) { + const distanceToLastSearch = currentMapCenter.distanceTo(mostRecentSearchMapCenter); + const relativeDistance = this._calculateRelativeDistance(distanceToLastSearch, currentZoom); + + + return relativeDistance <= this.relativeDistanceThreshold; + } + + /** + * Determines if a search should be debounced based on the difference between the map zoom during + * the most recent search and the current map zoom. + * + * @param {number} mostRecentSearchZoom + * @param {number} currentZoom + * @returns {boolean} + */ + isWithinZoomThreshold ({ mostRecentSearchZoom, currentZoom }) { + const zoomDifference = Math.abs(currentZoom - mostRecentSearchZoom); + + return zoomDifference <= this.zoomThreshold; + } + + /** + * Calculates a distance relative to the map zoom level. + * + * A relative distance of 100 is approximately equal to the width of the entire map. + * + * Each change in the zoom level changes the total map width by an order of 2, which is why this formula + * uses `Math.pow()`. + * + * @param {number} distance in miles + * @param {number} zoom + * @returns {number} + */ + _calculateRelativeDistance (distance, zoom) { + const adjustment = 0.835; // The adjustment ensures that distanceInPixels is accurate + const distanceInPixels = distance * Math.pow(2, zoom - 6) * adjustment; + const widthOfMap = window.innerWidth || 1; + return distanceInPixels / widthOfMap; + } +} + +export { SearchDebouncer }; \ No newline at end of file diff --git a/static/js/theme-map/ThemeMap.js b/static/js/theme-map/ThemeMap.js new file mode 100644 index 000000000..20c52cd2b --- /dev/null +++ b/static/js/theme-map/ThemeMap.js @@ -0,0 +1,443 @@ +import { Coordinate } from './Geo/Coordinate.js'; +import { MapOptions } from './Maps/Map.js'; +import { MapRenderTargetOptions } from './Renderer/MapRenderTarget.js'; +import { RendererOptions } from './Renderer/Renderer.js'; +import { PinProperties } from './Maps/PinProperties.js'; +import { PinClustererOptions } from './PinClusterer/PinClusterer.js'; +import { transformDataToUniversalData, transformDataToVerticalData } from './Util/transformers.js'; +import { getEncodedSvg } from './Util/helpers.js'; + +import { GoogleMaps } from './Maps/Providers/Google.js'; +import { MapboxMaps } from './Maps/Providers/Mapbox.js'; + +import ThemeMapConfig from './ThemeMapConfig.js' +import StorageKeys from '../constants/storage-keys.js'; + +/** + * The component to create and control the functionality of a map, + * including importing and initializing the map, assigning event + * listeners, and rendering the map on the page with results changes + */ +class ThemeMap extends ANSWERS.Component { + constructor(rawConfig, systemConfig) { + super(rawConfig, systemConfig); + + /** + * Configuration with default logic + * @type {ThemeMapConfig} + */ + this.config = new ThemeMapConfig(rawConfig); + + /** + * The map object + * @type {Map} + */ + this.map = null; + + /** + * The map renderer + * @type {Renderer} + */ + this.renderer = new RendererOptions().build(); + + /** + * Whether the map is render ready + * @type {boolean} + */ + this.renderReady = false; + + /** + * The initial data for the map, just in case it isn't renderable yet + * @type {Object} + */ + this.initialData = null; + + /** + * HTML element id for the hovered pin + * @type {string} + */ + this.hoveredPinId = null; + + /** + * HTML element id for the selected pin + * @type {string} + */ + this.selectedPinId = null; + + /** + * HTML element id for the selected cluster + * @type {string} + */ + this.selectedClusterPin = null; + + /* + * A list of listeners to remove when results are updated + * @type {StorageListener[]} + */ + this.resultsSpecificStorageListeners = []; + } + + onCreate () { + this.loadAndInitializeMap(); + + this.core.storage.registerListener({ + eventType: 'update', + storageKey: StorageKeys.VERTICAL_RESULTS, + callback: (data) => { + this.setState(data); + } + }); + } + + setState (data) { + if (data.searchState === 'search-loading') { + return; + } + + this._data = data; + this.onMount(); + } + + onMount () { + this._updateMap(this._data); + } + + /** + * Load the map provider scripts and initialize the map with the configuration options + */ + async loadAndInitializeMap () { + const mapProviderImpl = (this.config.mapProvider === 'google') ? GoogleMaps : MapboxMaps; + await mapProviderImpl.load(this.config.apiKey, { + client: this.config.clientId, + language: this.config.language, + }); + const map = new MapOptions() + .withDefaultCenter(this.config.defaultCenter) + .withDefaultZoom(this.config.defaultZoom) + .withWrapper(this._container) + .withProvider(mapProviderImpl) + .withProviderOptions(this.config.providerOptions || {}) + .withPadding(this.config.padding) + .build(); + this.map = map; + this.addMapInteractions(map); + } + + /** + * Update in the Answers SDK storage map properties used by other components + */ + updateMapPropertiesInStorage() { + this.core.storage.set(StorageKeys.LOCATOR_MAP_PROPERTIES, { + visibleCenter: this.map.getVisibleCenter(), + visibleRadius: this.map.getVisibleRadius() + }); + } + + /** + * Add map interactions like event listeners and rendering targets + * @param {Map} The map object + */ + addMapInteractions(map) { + this.map.idle().then(() => { + map.setPanHandler((prevousBounds, currentBounds, zoomTrigger) => { + this.updateMapPropertiesInStorage(); + this.config.panHandler(prevousBounds, currentBounds, zoomTrigger); + }); + map.setDragEndHandler(() => { + this.updateMapPropertiesInStorage(); + this.config.dragEndListener() + }); + map.setZoomChangedHandler((zoomTrigger) => { + this.config.zoomChangedListener(this.map.getZoom(), zoomTrigger); + }); + map.setZoomEndHandler((zoomTrigger) => { + this.updateMapPropertiesInStorage(); + this.config.zoomEndListener(this.map.getZoom(), zoomTrigger); + }); + map.setCanvasClickHandler(() => this.config.canvasClickListener()); + }); + + const mapRenderTargetOptions = new MapRenderTargetOptions() + .withMap(map) + .withOnPostRender((data, map) => this.config.onPostMapRender(data, map, mapRenderTarget.getPins())) + .withPinBuilder((pinOptions, entity, index) => this.buildPin(pinOptions, entity, index)) + + let pinClusterer; + if (this.config.enablePinClustering) { + pinClusterer = this.getClusterer(); + mapRenderTargetOptions.withPinClusterer(pinClusterer); + } + + const mapRenderTarget = mapRenderTargetOptions.build(); + + this.renderer.register(mapRenderTarget); + this.renderReady = true; + if (this.initialData) { + this.renderer.render(this.initialData); + } + + this.core.storage.registerListener({ + eventType: 'update', + storageKey: StorageKeys.LOCATOR_HOVERED_RESULT, + callback: id => { + if (id != this.hoveredPinId) { + const pins = mapRenderTarget.getPins(); + + if (this.hoveredPinId && pins[this.hoveredPinId]) { + pins[this.hoveredPinId].setStatus({ hovered: false }); + this.hoveredPinId = null; + } + + if (id && pins[id]) { + pins[id].setStatus({ hovered: true }); + this.hoveredPinId = id; + } + } + } + }); + + this.core.storage.registerListener({ + eventType: 'update', + storageKey: StorageKeys.LOCATOR_SELECTED_RESULT, + callback: id => { + if (id === this.selectedPinId) { + return; + } + + const pins = mapRenderTarget.getPins(); + + if (this.selectedPinId && pins[this.selectedPinId]) { + pins[this.selectedPinId].setStatus({ selected: false }); + this.selectedPinId = null; + } + + if (this.selectedClusterPin) { + this.selectedClusterPin.setStatus({ selected: false }); + this.selectedClusterPin = null; + } + + if (!id) { + return; + } + + if (!pins[id]) { + throw new Error(`A pin with the id ${id} could not be found on the map.`); + } + + if (this.config.enablePinClustering && pinClusterer) { + this.updateSelectedResultStateWithClusters(id, pins, pinClusterer.getClusters()); + } else { + this.updateSelectedResultStateWithoutClusters(id, pins); + } + + if (this.config.onPinSelect) { + this.config.onPinSelect(); + } + } + }); + } + + /** + * Update the pin and map state with information about the selected result. This result + * may be selected by either a pin click or a card click. + * + * @param {string} id + * @param {MapPin[]} pins + */ + updateSelectedResultStateWithoutClusters(id, pins) { + pins[id].setStatus({ selected: true }); + this.selectedPinId = id; + + if (!this.map.coordinateIsInVisibleBounds(pins[id].getCoordinate())) { + this.map.setCenterWithPadding(pins[id].getCoordinate(), true); + } + } + + /** + * Update the pin and map state with information about the selected result. This result + * may be selected by either a pin click or a card click. + * This will check if the selected result, identified by id, is in a cluster in clusters + * and enact changes according to the selected cluster. + * If not, fallback to the normal selected result behavior. + * + * @param {string} id + * @param {MapPin[]} pins + * @param {PinCluster[]} clusters + */ + updateSelectedResultStateWithClusters(id, pins, clusters) { + const filteredClusters = clusters.filter((cluster) => cluster.containsPin(id)); + + if (filteredClusters.length === 0) { + this.updateSelectedResultStateWithoutClusters(id, pins); + return; + } + + const selectedCluster = filteredClusters[0]; + const selectedClusterPin = selectedCluster.clusterPin; + + selectedClusterPin.setStatus({ selected: true }); + this.selectedPinId = id; + this.selectedClusterPin = selectedClusterPin; + + if (!this.map.coordinateIsInVisibleBounds(selectedCluster.clusterPin.getCoordinate())) { + this.map.setCenterWithPadding(selectedCluster.clusterPin.getCoordinate(), true); + } + } + + /** + * Get the Map pin clusterer object + */ + getClusterer () { + const clustererOptions = new PinClustererOptions() + .withClickHandler(() => { + this.updateMapPropertiesInStorage(); + this.config.pinClusterClickListener(); + }) + .withIconTemplate('default', (pinDetails) => { + return getEncodedSvg(this.config.pinClusterImages.getDefaultPin(pinDetails.pinCount)); + }) + .withIconTemplate('hovered', (pinDetails) => { + return getEncodedSvg(this.config.pinClusterImages.getHoveredPin(pinDetails.pinCount)); + }) + .withIconTemplate('selected', (pinDetails) => { + return getEncodedSvg(this.config.pinClusterImages.getSelectedPin(pinDetails.pinCount)); + }) + .withPropertiesForStatus(status => { + const properties = new PinProperties() + .setIcon(status.hovered || status.focused || status.selected ? 'hovered' : 'default') + .setWidth(28) + .setHeight(28) + .setAnchorX(this.config.pinClusterAnchors.anchorX) + .setAnchorY(this.config.pinClusterAnchors.anchorY); + + + return properties; + }) + .withMinClusterSize(this.config.minClusterSize) + .withClusterRadius(this.config.minClusterRadius) + .withClusterZoomAnimated(this.config.clusterZoomAnimated) + .withClusterZoomMax(this.config.clusterZoomMax); + return clustererOptions.build(); + } + + /** + * Builds a pin given pin options. + * @param {PinOptions} pinOptions The pin options builder + * @param {Object} entity The entity data to use in pin building + * @param {Number} index The index of the entity in the result list ordering + */ + buildPin(pinOptions, entity, index) { + const id = 'js-yl-' + entity.profile.meta.id; + const pin = pinOptions + .withId(id) + .withIcon( + 'default', + getEncodedSvg(this.config.pinImages.getDefaultPin(index, entity.profile))) + .withIcon( + 'hovered', + getEncodedSvg(this.config.pinImages.getHoveredPin(index, entity.profile))) + .withIcon( + 'selected', + getEncodedSvg(this.config.pinImages.getSelectedPin(index, entity.profile))) + .withHideOffscreen(false) + .withCoordinate(new Coordinate(entity.profile.yextDisplayCoordinate)) + .withPropertiesForStatus(status => { + const properties = new PinProperties() + .setIcon(status.selected ? 'selected' : ((status.hovered || status.focused) ? 'hovered' : 'default')) + .setSRText(index) + .setZIndex(status.selected ? 1 : ((status.hovered || status.focused) ? 2 : 0)) + .setAnchorX(this.config.pinAnchors.anchorX) + .setAnchorY(this.config.pinAnchors.anchorY); + + properties.setWidth(24); + properties.setHeight(28); + + if (status.selected) { + properties.setWidth(24); + properties.setHeight(34); + } + + return properties; + }) + .build(); + + const cardFocusUpdateListener = { + eventType: 'update', + storageKey: StorageKeys.LOCATOR_CARD_FOCUS, + callback: (data) => { + const cardIndex = data.index; + if (cardIndex + 1 === index) { + this.core.storage.set(StorageKeys.LOCATOR_SELECTED_RESULT, id); + } + } + }; + this.resultsSpecificStorageListeners.push(cardFocusUpdateListener); + this.core.storage.registerListener(cardFocusUpdateListener); + pin.setClickHandler(() => this.config.pinFocusListener(index, id)); + pin.setFocusHandler(() => this.config.pinFocusListener(index, id)); + pin.setHoverHandler(hovered => this.core.storage.set( + StorageKeys.LOCATOR_HOVERED_RESULT, + hovered ? id : null + )); + return pin; + } + + /** + * Update the map with the new data + * + * @param data The vertical results data + */ + _updateMap (data) { + this.resultsSpecificStorageListeners.forEach((listener) => { + this.core.storage.removeListener(listener); + }); + this.resultsSpecificStorageListeners = []; + + const verticalData = transformDataToVerticalData(data); + const universalData = transformDataToUniversalData(data); + let entityData = verticalData.length ? verticalData : universalData; + + const numConcurrentSearchThisAreaCalls = + this.core.storage.get(StorageKeys.LOCATOR_NUM_CONCURRENT_SEARCH_THIS_AREA_CALLS) || 0; + + if (numConcurrentSearchThisAreaCalls > 0) { + this.core.storage.set( + StorageKeys.LOCATOR_NUM_CONCURRENT_SEARCH_THIS_AREA_CALLS, + numConcurrentSearchThisAreaCalls - 1 + ); + } + + let fitCoordinates = numConcurrentSearchThisAreaCalls <= 0; + + const isNoResults = data.resultsContext === 'no-results'; + if (isNoResults && !this.config.displayAllResultsOnNoResults) { + entityData = []; + fitCoordinates = false; + } + + const renderData = { + response: { entities: entityData }, + fitCoordinates: fitCoordinates + }; + + if (this.renderReady) { + this.renderer.render(renderData); + } else { + this.initialData = renderData; + } + } + + static defaultTemplateName() { + return 'theme-components/theme-map'; + } + + static areDuplicateNamesAllowed() { + return false; + } + + static get type() { + return 'ThemeMap'; + } +} + +export { ThemeMap }; diff --git a/static/js/theme-map/ThemeMapConfig.js b/static/js/theme-map/ThemeMapConfig.js new file mode 100644 index 000000000..fe36953bd --- /dev/null +++ b/static/js/theme-map/ThemeMapConfig.js @@ -0,0 +1,253 @@ +import { Coordinate } from './Geo/Coordinate.js'; +import { PinImages } from './PinImages.js'; +import { ClusterPinImages } from './ClusterPinImages.js'; +import { getLanguageForProvider } from './Util/helpers.js'; +import { defaultCenterCoordinate } from './constants.js'; + +/** + * The configuration for the ThemeMap component. + */ +export default class ThemeMapConfig { + /** + * @param {Object} jsonConfig Configuration to parse + */ + constructor (jsonConfig) { + /** + * The provider for the map, normalized to lowercase + * @type {string} + */ + this.mapProvider = jsonConfig.mapProvider && jsonConfig.mapProvider.toLowerCase(); + + /** + * The API key for the map provider (if applicable) + * @type {string} + */ + this.apiKey = jsonConfig.apiKey; + + /** + * Controls the visual offset of each pin. + * @type {Object} + */ + this.pinAnchors = { + anchorX: 0.5, + anchorY: 0.5 + }; + + /** + * Controls the visual offset of each cluster pin. + * @type {Object} + */ + this.pinClusterAnchors = { + anchorX: 0.5, + anchorY: 0.5 + }; + + /** + * The client id for the map provider (if applicable) + * @type {string} + */ + this.clientId = jsonConfig.clientId; + + /** + * The language locale for the map. This is different from + * the specified locale because some map providers do not support + * certain locales and we want to fallback in a specific pattern + * when we come across a locale we do not support + * @type {string} + */ + this.language = getLanguageForProvider(jsonConfig.locale, this.mapProvider); + + /** + * The content wrapper for the floating content above the map + * @type {HTMLElement} + */ + this.contentWrapperEl = jsonConfig.contentWrapperEl; + + /** + * Map options to be passed directly to the Map Provider + * @type {Object} + */ + this.providerOptions = jsonConfig.providerOptions || {}; + + const defaultCenterFromConfig = jsonConfig.defaultCenter || this.providerOptions.center; + + /** + * The default center coordinate for the map, an object with {lat, lng} + * @type {Coordinate} + */ + this.defaultCenter = defaultCenterFromConfig + ? new Coordinate(defaultCenterFromConfig) + : defaultCenterCoordinate; + + /** + * The default zoom level for the map + * @type {number} + */ + this.defaultZoom = jsonConfig.defaultZoom + || this.providerOptions.zoom + || 14; + + /** + * The mobile breakpoint (inclusive max) in px + * @type {Number} + */ + this.mobileBreakpointMax = jsonConfig.mobileBreakpointMax || 991; + + /** + * The padding for the map within the viewable area + * @type {Object} + */ + this.padding = { + top: () => window.innerWidth <= this.mobileBreakpointMax ? 150 : 50, + bottom: () => 50, + right: () => 50, + left: () => this.getLeftVisibleBoundary(), + }; + + /** + * The pin options for the map, with information for each pin state (e.g. default, hovered) + * @type {Object} + */ + this.pinOptions = jsonConfig.pinOptions || {}; + + /** + * The pin images for the default Map Pin + * @type {PinImages} + */ + this.pinImages = new PinImages( + this.pinOptions.default, + this.pinOptions.hovered, + this.pinOptions.selected, + ); + + /** + * The cluster pin options for the map, with information for each pin state + * @type {Object} + */ + this.pinClusterOptions = jsonConfig.pinClusterOptions || jsonConfig.pinOptions; + + /** + * The pin images for the default Map Pin + * @type {ClusterPinImages} + */ + this.pinClusterImages = new ClusterPinImages( + this.pinClusterOptions.default || this.pinOptions.default, + this.pinClusterOptions.hovered || this.pinOptions.hovered, + this.pinClusterOptions.selected || this.pinOptions.selected, + ); + + /** + * Whether the map should cluster pins that are close to each other + * @type {boolean} + */ + this.enablePinClustering = jsonConfig.enablePinClustering; + + const noResultsConfig = jsonConfig.noResultsConfig || {}; + + /** + * Whether the map should display all results on no results + * @type {boolean} + */ + this.displayAllResultsOnNoResults = noResultsConfig.displayAllResults; + + /** + * Callback for when a non-cluster pin is selected + * @type {Function} + */ + this.onPinSelect = jsonConfig.onPinSelect || function () {}; + + /** + * Callback for when the map is rendered + * @type {Function} + */ + this.onPostMapRender = jsonConfig.onPostMapRender || function () {}; + + /** + * Callback for when a non-cluster pin is clicked + * @type {Function} + */ + this.pinClickListener = jsonConfig.pinClickListener || function () {}; + + /** + * Callback for when a non-cluster pin gains focus + * @type {Function} + */ + this.pinFocusListener = jsonConfig.pinFocusListener || function () {}; + + /** + * Callback for when a cluster pin is clicked + * @type {Function} + */ + this.pinClusterClickListener = jsonConfig.pinClusterClickListener || function () {}; + + /** + * Callback for when a map drag event has finished + * @type {Function} + */ + this.dragEndListener = jsonConfig.dragEndListener || function () {}; + + /** + * Callback for when a map pan event has finished + * @type {Function} + */ + this.panHandler = jsonConfig.panHandler || function () {}; + + /** + * Callback for when a map zoom event has fired + * @type {Function} + */ + this.zoomChangedListener = jsonConfig.zoomChangedListener || function () {}; + + /** + * Callback for when a map zoom event has finished + * @type {Function} + */ + this.zoomEndListener = jsonConfig.zoomEndListener || function () {}; + + /** + * Callback for when the map canvas is clicked + * A click does not include clicks to a pin or a map control + * A click is a mouseup and a mousedown with moving the mouse + * @type {Function} + */ + this.canvasClickListener = jsonConfig.canvasClickListener || function () {}; + + /** + * The minimum number of pins to be clustered + * @type {number} + */ + this.minClusterSize = 2; + + /** + * The max pixel distance from the center of a cluster to any pin in the cluster + * @type {number} + */ + this.minClusterRadius = 50; + + /** + * Whether to animate map zoom on cluster click + * @type {boolean} + */ + this.clusterZoomAnimated = true; + + /** + * Max zoom level for the map after clicking a cluster + * @type {number} + */ + this.clusterZoomMax = 20; + } + + /** + * Get the leftmost point on the map, such that pins will still be visible + * @return {Number} The boundary (in pixels) for the visible area of the map, from the left + * hand side of the viewport + */ + getLeftVisibleBoundary () { + if (window.innerWidth <= this.mobileBreakpointMax) { + return 50; + } + + const contentWrapperElWidth = this.contentWrapperEl ? this.contentWrapperEl.offsetWidth : 0; + return 50 + contentWrapperElWidth; + }; +} diff --git a/static/js/theme-map/Util/Accessibility.js b/static/js/theme-map/Util/Accessibility.js new file mode 100644 index 000000000..7c797ece1 --- /dev/null +++ b/static/js/theme-map/Util/Accessibility.js @@ -0,0 +1,42 @@ +const outlineStyle = '8px solid red'; +const whitelist = [ + '.c-map-with-pins' +]; +export class AccessibilityHelpers { + setAriaProp(element, ariaProp, ariaValue) { + element.setAttribute(`aria-${ariaProp}`, ariaValue); + } + + toggleAriaState(element, ariaProp) { + if (!element.hasAttribute(`aria-${ariaProp}`)) return; + const currAriaValue = element.getAttribute(`aria-${ariaProp}`); + const newAriaValue = !(currAriaValue == 'true'); + element.setAttribute(`aria-${ariaProp}`, newAriaValue); + } + + setTabIndex(target, tabIndex) { + let els = []; + if (typeof(target) === 'string') { + els = document.querySelectorAll(`${selector}`); + } else if (target instanceof HTMLElement) { + els = [target]; + } else if (target instanceof NodeList) { + els = target; + } + + for (const el of els) { + el.tabIndex = tabIndex; + } + } +} + +export const AccessibilityChecks = { + checkAltTags: function () { + const accessibilityStyleSheet = document.createElement('style'); + accessibilityStyleSheet.innerHTML = `img:not([alt]) { outline: ${outlineStyle}; }`; + for (let selector of whitelist) { + accessibilityStyleSheet.innerHTML += `${selector} img:not([alt]) { outline: none; }`; + } + document.head.appendChild(accessibilityStyleSheet); + } +} diff --git a/static/js/theme-map/Util/Assertions.js b/static/js/theme-map/Util/Assertions.js new file mode 100644 index 000000000..5a4490ac2 --- /dev/null +++ b/static/js/theme-map/Util/Assertions.js @@ -0,0 +1,41 @@ +const Type = { + UNDEFINED: 'undefined', + NULL: 'object', // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/typeof#null + BOOLEAN: 'boolean', + NUMBER: 'number', + BIGINT: 'bigint', + STRING: 'string', + SYMBOL: 'symbol', + FUNCTION: 'function', + OBJECT: 'object' +} + +function assertType(object, type) { + if (typeof type != 'string') { + throw new Error('Assertion error: \'type\' must be a string'); + } + + if (typeof object !== type) { + throw new Error(`Expected an object of type '${type}' but received '${typeof object}'`) + } +} + +function assertInstance(object, instanceClass) { + let isInstance; + + try { + isInstance = object instanceof instanceClass; + } catch(err) { + throw new Error('Assertion error: \'instanceClass\' is not a valid constructor'); + } + + if (!isInstance) { + throw new Error(`Expected an instance of '${instanceClass.name}' but received '${object.constructor.name}'`); + } +} + +export { + Type, + assertType, + assertInstance +} diff --git a/static/js/theme-map/Util/Browser.js b/static/js/theme-map/Util/Browser.js new file mode 100644 index 000000000..27ded4007 --- /dev/null +++ b/static/js/theme-map/Util/Browser.js @@ -0,0 +1,29 @@ +function onReady(cb) { + if (document.readyState === "complete" + || document.readyState === "loaded" + || document.readyState === "interactive") { + cb.bind(this)(); + } else { + document.addEventListener('DOMContentLoaded', cb.bind(this)); + } +} + + class UserAgent { + static fromWindow() { + return new this(window.navigator.userAgent); + } + + constructor(ua) { + this.userAgent = ua; + } + + isGooglePageSpeed() { + return this.userAgent.indexOf("Google Page Speed Insights") > -1 || + this.userAgent.indexOf("Chrome-Lighthouse") > -1; + } +} + +export { + onReady, + UserAgent +} diff --git a/static/js/theme-map/Util/Debug.js b/static/js/theme-map/Util/Debug.js new file mode 100644 index 000000000..b8a3c893e --- /dev/null +++ b/static/js/theme-map/Util/Debug.js @@ -0,0 +1,27 @@ +let param = 'xYextDebug'; + +export class Debug { + static hasQueryParam() { + if ('URL' in window && typeof URL === "function") { + let params = new URL(window.location.href).searchParams; + return params && params.get(param) == 'true'; + } + return false; + } + + static enable() { + document.documentElement.classList.add(param); + } + + static disable() { + document.documentElement.classList.remove(param); + } + + static isEnabled() { + let enabled = this.hasQueryParam(); + if (enabled) { + this.enable(); + } + return enabled; + } +} diff --git a/static/js/theme-map/Util/GeoSearchFormBinder.js b/static/js/theme-map/Util/GeoSearchFormBinder.js new file mode 100644 index 000000000..0860461ba --- /dev/null +++ b/static/js/theme-map/Util/GeoSearchFormBinder.js @@ -0,0 +1,89 @@ +import { HTML5Geolocation } from './Html5Geolocation.js'; +import { SpinnerModal } from '../SpinnerModal/SpinnerModal.js'; + +export class GeoSearchFormBinder { + + constructor(input, form, submitHandler, spinnerParent) { + this.input = input; + this.form = form; + this.useSpinner = spinnerParent != undefined; + this.running = false; + + // Google's example values + const geoLocationOptions = { + "timeout": 5 * 1000, + "maximumAge": 5 * 60 * 1000, + }; + + HTML5Geolocation.initClass(geoLocationOptions); + + if (typeof submitHandler === 'function') { + this.submitHandler = submitHandler; + } else { + console.warn('the submit handler should be a function, was: ', typeof submitHandler); + } + + if (this.useSpinner) { + this.spinner = new SpinnerModal(spinnerParent); + } + } + + fillPosition(position) { + if ('latitude' in position && 'longitude' in position) { + let query = `${position.latitude},${position.longitude}`; + this.input.name = 'qp'; + let q = document.createElement('input'); + q.name ='q'; + q.type = 'hidden'; + q.value = query; + this.form.appendChild(q); + if (this.submitHandler) { + this.submitHandler(); + return + } + // This will not get fired if you provide a submitHandler function. + // It's useful because browsers do not fire the 'submit' event when form + // submits are triggered via javascript. So if you need to do something + // with the form before it submits, pass a submitHandler!!!!!! + this.form.submit(); + } + } + + geolocateAndSearch() { + if (this.running) return; + this.running = true; + if (this.useSpinner) { + this.spinner.showSpinner(); + } + + this.form.classList.add('js-geolocating'); + document.body.classList.add('js-geolocating'); + + HTML5Geolocation.geolocate(this.geoLocationSuccess.bind(this), this.geoLocationFailure.bind(this)); + } + + geoLocationSuccess(position) { + this.running = false; + this.fillPosition(position); + } + + geoLocationFailure(error) { + this.running = false; + + if (error.code == error.PERMISSION_DENIED) { + console.warn(error.message); + } else { + alert('Sorry, we could not geolocate you at this time'); + } + + console.error(error); + + Array.from(document.getElementsByClassName('js-geolocating')).forEach(function(element) { + element.classList.remove('js-geolocating'); + }); + + if (this.useSpinner) { + this.spinner.hideSpinner(); + } + } +} diff --git a/static/js/theme-map/Util/Html5Geolocation.js b/static/js/theme-map/Util/Html5Geolocation.js new file mode 100644 index 000000000..51d8384a3 --- /dev/null +++ b/static/js/theme-map/Util/Html5Geolocation.js @@ -0,0 +1,47 @@ +export class HTML5Geolocation { + static initClass(options = {}) { + + this.inflight = false; + this.successes = []; + this.failures = []; + this.options = options + } + + static enabled() { + return ("geolocation" in navigator); + } + + static getCurrentLocation(success, failure) { + if (this.cached) { return success(this.cached); } + if (success != null) { this.successes.push(success); } + if (failure != null) { this.failures.push(failure); } + if (this.inflight) { return; } + this.inflight = true; + return this.geolocate((latitude, longitude) => { + this.successes.forEach(element => element(latitude, longitude)); + this.successes = []; + this.failures = []; + return this.inflight = false; + } + , error => { + this.failures.forEach(element => element(error)); + this.successes = []; + this.failures = []; + return this.inflight = false; + }); + } + static geolocate(success, failure) { + if (!this.enabled()) { + if (failure != null) { failure(new Error('geolocation is not enabled')); } + return; + } + + return navigator.geolocation.getCurrentPosition(position => { + this.cached = {latitude: position.coords.latitude, longitude: position.coords.longitude}; + if (success != null) { return success({latitude: position.coords.latitude, longitude: position.coords.longitude}); } + } + , function(error) { + if (failure != null) { return failure(error); } + }, this.options); + } +} diff --git a/static/js/theme-map/Util/Observer.js b/static/js/theme-map/Util/Observer.js new file mode 100644 index 000000000..2a57d44f8 --- /dev/null +++ b/static/js/theme-map/Util/Observer.js @@ -0,0 +1,19 @@ +export class Observer { + constructor (element = document.documentElement) { + this._el = element; + this._mutationObserver = undefined; + } + + add(callback, opts={attributes: true, childList: true}) { + if (this._mutationObserver) return; + const observer = new MutationObserver(callback); + observer.observe(this._el, opts); + this._mutationObserver = observer; + } + + remove() { + if (!this._mutationObserver) return; + this._mutationObserver.disconnect(); + this._mutationObserver = undefined; + } +} diff --git a/static/js/theme-map/Util/OptimizedResize.js b/static/js/theme-map/Util/OptimizedResize.js new file mode 100644 index 000000000..ecb02c2b1 --- /dev/null +++ b/static/js/theme-map/Util/OptimizedResize.js @@ -0,0 +1,31 @@ +import { Throttle } from './Throttle.js'; + +class OptimizedResize { + constructor(scope = window) { + this.eventTypeName = 'optimizedResize'; + this.throttle = new Throttle('resize', this.eventTypeName, scope); + this.init = false; + } + + on(cb) { + if (!this.init) { + this.init = true; + this.throttle.start(); + } + window.addEventListener(this.eventTypeName, cb); + } + + remove(cb) { + window.removeEventListener(this.eventTypeName, cb); + } + + // This will halt the triggering of ALL callbacks added with '.on()'. + // Only call this if you are sure there are no other functions/classes + // using this class. + kill() { + this.throttle.end(); + this.init = false; + } +} + +export const OptimizedResizeInstance = new OptimizedResize(); diff --git a/static/js/theme-map/Util/SmoothScroll.js b/static/js/theme-map/Util/SmoothScroll.js new file mode 100644 index 000000000..46517819d --- /dev/null +++ b/static/js/theme-map/Util/SmoothScroll.js @@ -0,0 +1,44 @@ +/** + * @typedef timingFunction + * @function + * @param {number} t A number within [0, 1] representing the percentage of time elapsed + * @returns {number} A number within [0, 1] representing the progress of the animation + */ + +/** + * @constant {timingFunction} Timing.LINEAR + */ +const Timing = { + LINEAR: t => t +} + +/** + * @param {HTMLElement} el The element to scroll + * @param {number} scrollDist The number of pixels to scroll vertically + * @param {number} duration The duration in miliseconds + * @param {Object} [options={}] + * @param {number} [intervalLength=10] Number of miliseconds between scroll position updates + * @param {function} [timing=Timing.LINEAR] Timing function used for scroll animation + */ +function smoothScroll(el, scrollDist, duration, { + intervalLength = 10, + timing = Timing.LINEAR +} = {}) { + const scrollTop = el.scrollTop; + let timeElapsed = 0; + + const interval = setInterval(() => { + timeElapsed += intervalLength; + el.scrollTop = scrollTop + scrollDist * timing(timeElapsed / duration); + + if (timeElapsed > duration) { + clearInterval(interval); + el.scrollTop = scrollTop + scrollDist; + } + }, intervalLength); +} + +export { + Timing, + smoothScroll +}; diff --git a/static/js/theme-map/Util/Throttle.js b/static/js/theme-map/Util/Throttle.js new file mode 100644 index 000000000..3bbbed5c2 --- /dev/null +++ b/static/js/theme-map/Util/Throttle.js @@ -0,0 +1,26 @@ +export class Throttle { + // adapted from: https://developer.mozilla.org/en-US/docs/Web/Events/resize + constructor(eventName, customName, scope) { + this.eventName = eventName; + this.customName = customName; + this.scope = scope; + this.running = false; + + this.listener = () => { + if (this.running) { return; } + this.running = true; + requestAnimationFrame(() => { + this.scope.dispatchEvent(new CustomEvent(this.customName)); + this.running = false; + }); + }; + } + + start() { + this.scope.addEventListener(this.eventName, this.listener); + } + + end() { + this.scope.removeEventListener(this.eventName, this.listener); + } +} diff --git a/static/js/theme-map/Util/WcagNewTab.js b/static/js/theme-map/Util/WcagNewTab.js new file mode 100644 index 000000000..d81212941 --- /dev/null +++ b/static/js/theme-map/Util/WcagNewTab.js @@ -0,0 +1,33 @@ +const blank = "_blank"; +const relnoopener = "noopener noreferrer"; + +class WCAGNewTab { + wcagify(newWindowAllLinks = false) { + for (let selector of document.querySelectorAll('a[href^="http"],a[target="_blank"]')){ + if (selector.target === blank || newWindowAllLinks) { + if (newWindowAllLinks && selector.target !== blank) { + selector.target = blank; + } + selector.rel = relnoopener + let spanToAppend = this.createTextNode(); + selector.appendChild(spanToAppend); + } + } + } + + createTextNode() { + const ariaSpan = document.createElement('span'); + const innerText = document.createTextNode('\u00A0Link Opens in New Tab'); + ariaSpan.classList.add('sr-only'); + ariaSpan.classList.add('wcag-new-tab-hover'); + ariaSpan.appendChild(innerText); + return ariaSpan; + } +} + +const Instance = new WCAGNewTab(); + +export { + WCAGNewTab, + Instance +} diff --git a/static/js/theme-map/Util/helpers.js b/static/js/theme-map/Util/helpers.js new file mode 100644 index 000000000..bdd8d5098 --- /dev/null +++ b/static/js/theme-map/Util/helpers.js @@ -0,0 +1,115 @@ +/** + * Gets the language locale according to specific fallback logic + * 1. The user-specified locale to the component + * 2. If invalid, try using only the first two characters + * 3. If still invalid, providers fallback to en + * + * @param {string} localeStr The user-defined locale string + * @param {string[]} supportedLocales The locales supported by the current map provider + * @return {string} The language locale for the map + */ +const getLanguageForProvider = (localeStr, supportedLocales) => { + if (localeStr.length == 2) { + return localeStr; + } + + if (localeStr.length > 2) { + if (supportedLocalesForProvider.includes(localeStr)) { + return localeStr; + } + return localeStr.substring(0, 2); + } + + return 'en'; +}; + +/** + * Returns a utf-8 encoding of an SVG + * + * @param {string} svg The SVG to encode + * @return {string} + */ +const getEncodedSvg = (svg) => { + return `data:image/svg+xml;charset=utf-8, ${encodeURIComponent(svg)}`; +} + +/** + * Returns whether or not targetEl is viewable within containerEl, considering + * its container's scroll position and the target's offset from the top + * + * @param {HTMLElement} targetEl The element that is meant to be viewable + * @param {HTMLElement} containerEl The wrapper element, should be some ancestor for targetEl + * @return {boolean} + */ +const isViewableWithinContainer = (targetEl, containerEl) => { + const containerElViewableTop = containerEl.scrollTop; + const containerElViewableBottom = containerEl.scrollTop + containerEl.offsetHeight; + const targetElTop = targetEl.offsetTop; + const targetElBottom = targetEl.offsetTop + targetEl.offsetHeight; + + const isScrolledIntoView = + targetElTop >= containerElViewableTop && + targetElTop <= containerElViewableBottom && + targetElBottom >= containerElViewableTop && + targetElBottom <= containerElViewableBottom; + return isScrolledIntoView; +}; + + +/** + * Normalize lng to the range [-180, 180]. If you give -181, for + * example, we wrap back to 179. If lng is 180, we return 180. Otherwise, we + * prefer to return -180 over 180 when wrapping, as they are the same coordinate. + * + * The idea is that we must mod by the range of the longitude values (360) to + * be span our entire range of values. In order to have our negative to + * positive wrapping work with modulus, the values we mod by need to be positive. + * + * 1. Add 180 to shift values to make sure -180 to 0 map to 0 to 180 + * and 0 to 180 map to 180 to 360 for example + * 2. Mod by 360 to make the range (-360, 360) + * 3. Add 360 and mod by 360 again to make the range positive [0, 360) + * 4. Subtract by 180 to make the range into the desired range [-180, 180) + * + * @param {Number} lng The longitude + * @returns {Number} The normalized longitude + */ +const getNormalizedLongitude = (lng) => { + if (lng === 180) { + return lng; + } + const range = 360; // 180 - (-180) + return ((lng + 180) % range + range) % range - 180; +} + +/** + * Returns a function, that, as long as it continues to be invoked, will not be triggered. + * The function will be called after it stops being called for `wait` milliseconds. + * + * Source: https://levelup.gitconnected.com/debounce-in-javascript-improve-your-applications-performance-5b01855e086 + * + * @param {Function} func The function to debounce + * @param {number} wait The number of milliseconds that need to pass without the function + * being called before the provided function will execute + */ +const debounce = (func, wait) => { + let timeout; + + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +}; + +export { + getLanguageForProvider, + getEncodedSvg, + getNormalizedLongitude, + isViewableWithinContainer, + debounce +} diff --git a/static/js/theme-map/Util/transformers.js b/static/js/theme-map/Util/transformers.js new file mode 100644 index 000000000..027d55b9a --- /dev/null +++ b/static/js/theme-map/Util/transformers.js @@ -0,0 +1,60 @@ +/** + * Transforms the data from the answers API to the live api format the Locator code expects + * @param {Object} data The results from the answers API + * @return {Object} The results formatted for the Locator code + */ +const transformDataToUniversalData = (data) => { + const universalData = (data.map ? (data.map.mapMarkers || []) : []).map(marker => ({ + profile: { + ...marker.item, + meta: { + accountId: '', + countryCode: marker.item.address.countryCode, + entityType: marker.item.type, + folderId: '', + id: marker.item.id, + labels: '', + language: '', + schemaTypes: '', + timestamp: '', + uid: '', + utcOffsets: '', + yextId: marker.item.id, + } + } + })); + return universalData; +}; + +/** + * Transforms the data from the answers API to the live api format the Locator code expects + * @param {Object} data The results from the answers API + * @return {Object} The results formatted for the Locator code + */ +const transformDataToVerticalData = (data) => { + const verticalData = (data.results || []).map(ent => ({ + profile: { + ...ent._raw, + meta: { + accountId: '', + countryCode: ent._raw.address.countryCode, + entityType: ent._raw.type, + folderId: '', + id: ent.id, + labels: '', + language: '', + schemaTypes: '', + timestamp: '', + uid: '', + utcOffsets: '', + yextId: ent.id, + }, + } + })); + return verticalData; +}; + +export { + transformDataToUniversalData, + transformDataToVerticalData +} diff --git a/static/js/theme-map/VerticalFullPageMapOrchestrator.js b/static/js/theme-map/VerticalFullPageMapOrchestrator.js new file mode 100644 index 000000000..39c83d2b1 --- /dev/null +++ b/static/js/theme-map/VerticalFullPageMapOrchestrator.js @@ -0,0 +1,679 @@ +import { Coordinate } from './Geo/Coordinate.js'; +import { smoothScroll } from './Util/SmoothScroll.js'; +import { getLanguageForProvider, isViewableWithinContainer } from './Util/helpers.js'; +import { SearchDebouncer } from './SearchDebouncer'; +import { defaultCenterCoordinate } from './constants.js'; + +import ZoomTriggers from './Maps/ZoomTriggers.js'; +import PanTriggers from './Maps/PanTriggers.js'; +import MobileStates from './MobileStates'; + +import StorageKeys from '../constants/storage-keys.js'; + +/** + * The component to control the interactions for an interative map. + * Interactions like clicking on a pin or dragging the map and + * searching an area is controlled here + */ +class VerticalFullPageMapOrchestrator extends ANSWERS.Component { + constructor(config, systemConfig) { + super(config, systemConfig); + + /** + * The container in the DOM for the interactive map + * @type {HTMLElement} + */ + this._mapContainerSelector = '#js-answersMap'; + + /** + * The page wrapper DOM element + * @type {HTMLElement} + */ + this._pageWrapperEl = document.querySelector('.YxtPage-wrapper'); + + /** + * The results wrapper DOM element + * @type {HTMLElement} + */ + this._resultsWrapperEl = this._container.querySelector('.js-locator-resultsWrapper'); + + /** + * The current Answers API vertical key + * @type {string} + */ + this.verticalKey = config.verticalKey; + + /** + * The vertical configuration + * @type {Object} + */ + this.verticalsConfig = config.verticalPages || []; + + /** + * Map options to be passed directly to the Map Provider + * @type {Object} + */ + this.providerOptions = config.providerOptions || {}; + + /** + * The default center coordinate for the map, an object with {lat, lng} + * @type {Coordinate} + */ + this.defaultCenter = this.providerOptions.center + ? new Coordinate(this.providerOptions.center) + : defaultCenterCoordinate; + + /** + * The default zoom level for the map + * @type {number} + */ + this.defaultZoom = this.providerOptions.zoom || 14; + + /** + * The current zoom level of the map + * @type {number} + */ + this.currentZoom = this.defaultZoom; + + /** + * The zoom level of the map during the most recent search + * @type {number} + */ + this.mostRecentSearchZoom = this.defaultZoom; + + /** + * The center of the map during the most recent search + * @type {Coordinate} + */ + this.mostRecentSearchLocation = this.defaultCenter; + + /** + * Whether the map should search on a map movement action like map drag + * @type {boolean} + */ + this.searchOnMapMove = !config.disableSearchOnMapMove; + + /** + * The configuration for the no results state + * @type {Object} + */ + this.noResultsConfig = config.noResults || {}; + + /** + * Whether the map should display all results on no results + * @type {boolean} + */ + this.displayAllResultsOnNoResults = this.noResultsConfig.displayAllResults; + + /** + * The mobile breakpoint (inclusive max) in px + * @type {Number} + */ + this.mobileBreakpointMax = 991; + + /** + * Provides information about whether or not the window is within the mobile breakpoint + * @ytype {MediaQueryList} + */ + this.mobileBreakpointMediaQuery = window.matchMedia(`(max-width: ${this.mobileBreakpointMax}px)`); + + /** + * The current view for mobile. + * + * Either MobileStates.LIST_VIEW or MobileStates.MAP_VIEW + */ + this._mobileView = MobileStates.LIST_VIEW; + + /** + * Determines whether or not another search should be ran + * @type {SearchDebouncer} + */ + this.searchDebouncer = new SearchDebouncer(); + + /** + * The detail card which apears on mobile after clicking a pin + * @type {Element} + */ + this._detailCard = null; + + /** + * The passthrough config for the Alternative Verticals component + * NOTE This component is added as a child to this component because Alternative Verticals + * in the SDK is not designed to be a standalone component. In this layout, it cannot be + * a child of the Vertical Results because we want it to show on the map view. So we make it + * a child of the larger component. + * @type {Object} + */ + this.alternativeVerticalsConfig = config.alternativeVerticalsConfig; + } + + onCreate () { + this.core.storage.registerListener({ + eventType: 'update', + storageKey: StorageKeys.VERTICAL_RESULTS, + callback: (data) => this.setState(data) + }); + + this.core.storage.registerListener({ + eventType: 'update', + storageKey: StorageKeys.QUERY, + callback: () => this.updateMostRecentSearchState() + }); + + const searchThisAreaToggleEls = this._container.querySelectorAll('.js-searchThisAreaToggle'); + searchThisAreaToggleEls.forEach((el) => { + el.addEventListener('click', (e) => { + this.searchOnMapMove = e.target.checked; + if (this.searchOnMapMove) { + this._container.classList.remove('VerticalFullPageMap--showSearchThisArea'); + } else { + this._container.classList.add('VerticalFullPageMap--showSearchThisArea'); + } + }); + }); + + const searchThisAreaButtonEl = this._container.querySelector('.js-searchThisAreaButton'); + searchThisAreaButtonEl.addEventListener('click', (e) => { + this.searchThisArea(); + }); + + this.setupCssForBreakpoints(); + this.addMapComponent(); + } + + /** + * Properly set CSS classes for mobile and desktop + */ + setupCssForBreakpoints () { + if (!this.isMobile()) { + this.updateCssForDesktop(); + } + + this.mobileBreakpointMediaQuery.addEventListener('change', () => { + if (this.isMobile()) { + this.updateCssForMobile(); + } else { + this.updateCssForDesktop(); + } + }, { passive: true }); + } + + /** + * @returns {boolean} + */ + isMobile () { + return this.mobileBreakpointMediaQuery.matches; + } + + updateCssForMobile () { + if (this._mobileView === MobileStates.LIST_VIEW) { + this.addCssClassesForState(MobileStates.LIST_VIEW); + this.removeCssClassesForState(MobileStates.MAP_VIEW); + this.removeCssClassesForState(MobileStates.DETAIL_SHOWN); + } else if (this._mobileView === MobileStates.MAP_VIEW) { + this.addCssClassesForState(MobileStates.MAP_VIEW); + this.removeCssClassesForState(MobileStates.LIST_VIEW); + } + } + + updateCssForDesktop () { + const statesToRemove = [MobileStates.LIST_VIEW, MobileStates.MAP_VIEW, MobileStates.DETAIL_SHOWN]; + statesToRemove.forEach(state => { + this.removeCssClassesForState(state); + }); + } + + setMobileMapView () { + this._mobileView = 'mapView'; + this.updateCssForMobile(); + } + + setMobileListView () { + this._mobileView = 'listView'; + this.updateCssForMobile(); + } + + /** + * @param {MobileStates} mobileState + */ + addCssClassesForState(mobileState) { + const classModifier = this.getModifierForState(mobileState); + this._container.classList.add(`VerticalFullPageMap--${classModifier}`); + this._pageWrapperEl.classList.add(`YxtPage-wrapper--${classModifier}`); + } + + /** + * @param {MobileStates} mobileState + */ + removeCssClassesForState(mobileState) { + const classModifier = this.getModifierForState(mobileState); + this._container.classList.remove(`VerticalFullPageMap--${classModifier}`); + this._pageWrapperEl.classList.remove(`YxtPage-wrapper--${classModifier}`); + } + + /** + * Returns a css class modifier for a given mobile state. + * + * @param {MobileStates} mobileState + * @returns {string} + */ + getModifierForState(mobileState) { + switch (mobileState) { + case MobileStates.LIST_VIEW: + return 'mobileListView' + case MobileStates.MAP_VIEW: + return 'mobileMapView' + case MobileStates.DETAIL_SHOWN: + return 'mobileDetailShown' + default: + throw new Error('Invalid mobile state'); + } + } + + addMapComponent () { + /** + * Sets up mobile view toggles and search bar listeners + * + * @param {Object} data The data (formatted in the Consulting LiveAPI format) of results + * @param {Map} map The map object + * @param {Object} pins Mapping from pin id to the pin object on the map + */ + const onPostMapRender = (data, map, pins) => { + this.setupMobileViewToggles(data, map, pins); + this.setupSearchBarListeners(); + }; + + /** + * Clicking a pin cluster searches the new area, if desired + */ + const pinClusterClickListener = () => this.searchOnMapMove && this.searchThisArea(); + + /** + * The listener called when the map pans + */ + const panHandler = (prevousBounds, currentBounds, panTrigger) => { + if (panTrigger === PanTriggers.API) { + return; + } + + this.handleMapCenterChange(); + } + + /** + * The listener called when the zoom changes + * + * @param {number} zoom The zoom during a zoom event + * @param {ZoomTriggers} zoomTrigger The intitiator of the zoom + */ + const zoomChangedListener = (zoom, zoomTrigger) => {}; + + /** + * User-initiated changes to the map zoom searches the new area, if desired + * Clicking on a cluster or fitting the bounds for results is not considered user-initiated + * + * @param {number} zoom The zoom after this event + * @param {ZoomTriggers} zoomTrigger The intitiator of the zoom + */ + const zoomEndListener = (zoom, zoomTrigger) => { + this.currentZoom = zoom; + + if (zoomTrigger !== ZoomTriggers.USER) { + return; + } + + this.handleMapZoomChange(); + }; + + ANSWERS.addComponent('ThemeMap', Object.assign({}, { + container: this._mapContainerSelector, + mapProvider: this._config.mapProvider, + apiKey: this._config.apiKey, + clientId: this._config.clientId, + locale: this._config.locale, + contentWrapperEl: this._container.querySelector('.js-locator-contentWrap'), + providerOptions: this._config.providerOptions, + defaultCenter: this.defaultCenter, + defaultZoom: this.defaultZoom, + mobileBreakpointMax: this.mobileBreakpointMax, + pinOptions: this._config.pin, + pinClusterOptions: this._config.pinCluster, + enablePinClustering: this._config.enablePinClustering, + noResultsConfig: this.noResultsConfig, + onPinSelect: this._config.onPinSelect, + onPostMapRender: onPostMapRender, + pinFocusListener: (index, id) => this.pinFocusListener(index, id), + pinClusterClickListener: pinClusterClickListener, + zoomChangedListener: zoomChangedListener, + zoomEndListener: zoomEndListener, + panHandler: panHandler, + canvasClickListener: () => this.deselectAllResults() + })); + } + + /** + * Search the area or show the search the area button according to configurable logic + */ + handleMapCenterChange () { + if (!this.searchOnMapMove) { + this._container.classList.add('VerticalFullPageMap--showSearchThisArea'); + return; + } + + if (!this.shouldSearchBeDebouncedBasedOnCenter()) { + this.searchThisArea(); + } + } + + /** + * Search the area or show the search the area button according to configurable logic + */ + handleMapZoomChange () { + if (!this.searchOnMapMove) { + this._container.classList.add('InteractiveMap--showSearchThisArea'); + return; + } + + if (!this.shouldSearchBeDebouncedBasedOnZoom()) { + this.searchThisArea(); + } + } + + /** + * Returns true if a search should be debounced based on the center of the current map + * and the center of the map during the most recent search + * + * @returns {boolean} + */ + shouldSearchBeDebouncedBasedOnCenter () { + return this.searchDebouncer.isWithinDistanceThreshold({ + mostRecentSearchMapCenter: this.mostRecentSearchLocation, + currentMapCenter: this.getCurrentMapCenter(), + currentZoom: this.currentZoom + }); + } + + /** + * Returns true if a search should be debounced based on the previous search zoom level and + * the current zoom level + * + * @returns {boolean} + */ + shouldSearchBeDebouncedBasedOnZoom () { + return this.searchDebouncer.isWithinZoomThreshold({ + mostRecentSearchZoom: this.mostRecentSearchZoom, + currentZoom: this.currentZoom + }); + } + + /** + * Sets the most recent search state to the current map state + */ + updateMostRecentSearchState () { + this.mostRecentSearchZoom = this.currentZoom; + this.mostRecentSearchLocation = this.getCurrentMapCenter(); + } + + /** + * Returns the current center of the map + * + * @returns {Coordinate} + */ + getCurrentMapCenter () { + const mapProperties = this.core.storage.get(StorageKeys.LOCATOR_MAP_PROPERTIES); + + if (!mapProperties) { + return this.defaultCenter; + } + + const center = mapProperties.visibleCenter; + const lat = center.latitude; + const lng = center.longitude; + + return new Coordinate(lat, lng); + } + + /** + * Deselect all results by updating CSS classes, removing the detail card if present, and + * updating global storage. + */ + deselectAllResults () { + this.removeCssClassesForState(MobileStates.DETAIL_SHOWN); + + document.querySelectorAll('.yxt-Card--pinFocused').forEach((el) => { + el.classList.remove('yxt-Card--pinFocused'); + }); + + this._detailCard && this._detailCard.remove(); + + this.core.storage.set(StorageKeys.LOCATOR_SELECTED_RESULT, null); + } + + /** + * The callback when a result pin on the map is clicked or tabbed onto + * @param {Number} index The index of the pin in the current result list order + * @param {string} cardId The unique id for the pin entity, usually of the form `js-yl-${meta.id}` + */ + pinFocusListener (index, cardId) { + this.core.storage.set(StorageKeys.LOCATOR_SELECTED_RESULT, cardId); + const selector = `.yxt-Card[data-opts='{ "_index": ${index - 1} }']`; + const card = document.querySelector(selector); + + document.querySelectorAll('.yxt-Card--pinFocused').forEach((el) => { + el.classList.remove('yxt-Card--pinFocused'); + }); + + card.classList.add('yxt-Card--pinFocused'); + + if (this.isMobile()) { + document.querySelectorAll('.yxt-Card--isVisibleOnMobileMap').forEach((el) => el.remove()); + const isDetailCardOpened = document.querySelectorAll('.yxt-Card--isVisibleOnMobileMap').length; + + this._detailCard = card.cloneNode(true); + this._detailCard.classList.add('yxt-Card--isVisibleOnMobileMap'); + this._container.appendChild(this._detailCard); + + if (!isDetailCardOpened) { + window.requestAnimationFrame(() => { + this._detailCard.style = 'height: 0;'; + window.requestAnimationFrame(() => { + this._detailCard.style = ''; + }); + }); + } + + const buttonSelector = '.js-HitchhikerLocationCard-closeCardButton'; + + this._detailCard.querySelectorAll(buttonSelector).forEach((el) => { + el.addEventListener('click', () => { + this.deselectAllResults(); + }); + }); + + this.addCssClassesForState(MobileStates.DETAIL_SHOWN); + } else { + this.scrollToResult(card); + } + } + + /** + * Determines whether or not the view toggles on mobile should be shown or not + * If it is shown, add click listener + * @param {Object} data The data (formatted in the Consulting LiveAPI format) of results + * @param {Map} map The map object + * @param {Object} pins Mapping from pin id to the pin object on the map + */ + setupMobileViewToggles (data, map, pins) { + const mobileToggles = this._container.querySelector('.js-locator-mobiletoggles'); + const listToggle = mobileToggles.querySelector('.js-locator-listToggle'); + + let showToggles = false; + + if (data.response && data.response.entities && data.response.entities.length) { + showToggles = true; + } + this.removeCssClassesForState(MobileStates.DETAIL_SHOWN); + + if (showToggles) { + this._container.classList.add('VerticalFullPageMap--showMobileViewToggles'); + if (!listToggle.dataset.listened) { + listToggle.dataset.listened = 'true'; + listToggle.addEventListener('click', () => { + this.deselectAllResults(); + window.scrollTo(0, 0); + if (this._mobileView === MobileStates.LIST_VIEW) { + this.setMobileMapView(); + } else { + this.setMobileListView(); + } + }); + } + } else { + this._container.classList.remove('VerticalFullPageMap--showMobileViewToggles'); + } + } + + /** + * Register listeners so that any active pins are deselected when a user clicks + * or focuses on the searchbar. + */ + setupSearchBarListeners () { + const searchBarForm = this._container.querySelector('.yxt-SearchBar-form'); + searchBarForm && searchBarForm.addEventListener('click', () => { + this.deselectAllResults() + }); + const searchBarInput = this._container.querySelector('.yxt-SearchBar-input'); + searchBarInput && searchBarInput.addEventListener('focus', () => { + this.deselectAllResults(); + }); + const searchBarButton = this._container.querySelector('.yxt-SearchBar-button'); + searchBarButton && searchBarButton.addEventListener('focus', () => { + this.deselectAllResults(); + }) + } + + /** + * Conducts a new search on the locator for the given viewable bounds for the map. + * Note that the visible area is the viewport of the map, taking into account the map padding. + * Also note that the radius is from the center of the visible area to the corner of + * the visible area. + */ + searchThisArea() { + const numConcurrentSearchThisAreaCalls = + this.core.storage.get(StorageKeys.LOCATOR_NUM_CONCURRENT_SEARCH_THIS_AREA_CALLS); + const updatedNumSearchThisAreaCalls = numConcurrentSearchThisAreaCalls + 1 || 1; + this.core.storage.set( + StorageKeys.LOCATOR_NUM_CONCURRENT_SEARCH_THIS_AREA_CALLS, + updatedNumSearchThisAreaCalls + ); + + this.deselectAllResults(); + + this._container.classList.remove('VerticalFullPageMap--showSearchThisArea'); + + const mapProperties = this.core.storage.get(StorageKeys.LOCATOR_MAP_PROPERTIES); + const center = mapProperties.visibleCenter; + const radius = mapProperties.visibleRadius; + const lat = center.latitude; + const lng = center.longitude; + + const filterNode = ANSWERS.FilterNodeFactory.from({ + filter: { + 'builtin.location': { + '$near': { lat, lng, radius } + } + }, + remove: () => this.core.clearStaticFilterNode('SearchThisArea') + }); + this.core.setStaticFilterNodes('SearchThisArea', filterNode); + this.core.verticalSearch(this.verticalKey, { + setQueryParams: true, + resetPagination: true, + useFacets: true + }); + this.updateMostRecentSearchState(); + } + + /** + * Scroll the result list to show the given element + * @param {HTMLElement} targetEl The result card to scroll to + */ + scrollToResult(targetEl) { + const scrollContainer = this._resultsWrapperEl; + const scrollDistance = targetEl.offsetTop - scrollContainer.scrollTop; + + if (!isViewableWithinContainer(targetEl, scrollContainer)) { + smoothScroll(scrollContainer, scrollDistance, 400); + } + } + + setState(data) { + if (data.searchState === 'search-loading') { + return; + } + + this._data = data; + + if (data.resultsContext === 'no-results') { + this._isNoResults = true; + this._container.classList.add('VerticalFullPageMap--noResults'); + } else { + this._isNoResults = false; + this._container.classList.remove('VerticalFullPageMap--noResults'); + } + + this.onMount(); + } + + onMount () { + this._children.forEach(child => { + child.unMount(); + }); + this._children.forEach(c => c.remove()); + this._children = []; + + if (this._isNoResults) { + const altVerticalsData = this.core.storage.get(StorageKeys.ALTERNATIVE_VERTICALS); + this.addChild( + altVerticalsData, + 'AlternativeVerticals', + Object.assign({}, + { + container: '.js-answersNoResults', + verticalsConfig: this.verticalsConfig, + baseUniversalUrl: this.getBaseUniversalUrl(), + isShowingResults: this.displayAllResultsOnNoResults && this._data.results, + name: 'AlternativeVerticals--resultsHeader' + }, + this.alternativeVerticalsConfig + ) + ); + } + + this._children.forEach(child => { + child.mount(); + }); + } + + /** + * Get the base universal url + * @return {string} The universal url + */ + getBaseUniversalUrl () { + const universalConfig = this.verticalsConfig.find(config => !config.verticalKey) || {}; + return universalConfig.url; + } + + static defaultTemplateName() { + return 'theme-components/vertical-full-page-map'; + } + + static areDuplicateNamesAllowed() { + return false; + } + + static get type() { + return 'VerticalFullPageMapOrchestrator'; + } +} + +export { VerticalFullPageMapOrchestrator }; diff --git a/static/js/theme-map/constants.js b/static/js/theme-map/constants.js new file mode 100644 index 000000000..56515b08f --- /dev/null +++ b/static/js/theme-map/constants.js @@ -0,0 +1,8 @@ +import { Coordinate } from './Geo/Coordinate.js'; + +/** + * The default center coordinate, currently in Dearing, KS, in the middle + * of the United States + * @type {Coordinate} + */ +export const defaultCenterCoordinate = new Coordinate(37.0902, -95.7129); diff --git a/static/js/transform-facets.js b/static/js/transform-facets.js new file mode 100644 index 000000000..09b715c06 --- /dev/null +++ b/static/js/transform-facets.js @@ -0,0 +1,44 @@ +/** + * Transforms the Facets.fields component configuration into a transformFacets function + * which can be understood by the Answers Search UI. + * + * @param {DisplayableFacet[]} facets from answers-core + * @param {FilterOptionsConfig} config the config of the FilterOptionsConfig from answers-search-ui + * @returns {(DisplayableFacet | FilterOptionsConfig)[]} + */ +export default function transformFacets (facets, config) { + if(!config || !('fields' in config)) { + return facets; + } + + return facets.map(facet => { + const isConfigurationForFacet = facet.fieldId in config.fields; + if (!isConfigurationForFacet) { + return facet; + } + const facetConfig = config.fields[facet.fieldId]; + + let options = facet.options; + + if ('fieldLabels' in facetConfig) { + options = facet.options.map(option => { + const fieldLabels = facetConfig.fieldLabels; + + const displayName = (option.displayName in fieldLabels) + ? fieldLabels[option.displayName] + : option.displayName; + + return Object.assign({}, option, { displayName }); + }) + } + + const filterOptionsConfig = Object.entries(facetConfig).reduce((filterOptions, [option, value]) => { + if (option !== 'fieldLabels') { + filterOptions[option] = value; + } + return filterOptions; + }, {}); + + return Object.assign({}, facet, filterOptionsConfig, { options }); + }); +} \ No newline at end of file diff --git a/static/package-lock.json b/static/package-lock.json index 9de8317d4..b7e77f741 100644 --- a/static/package-lock.json +++ b/static/package-lock.json @@ -1,6 +1,6 @@ { "name": "answers-hitchhiker-theme", - "version": "1.19.0", + "version": "1.20.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1103,9 +1103,9 @@ "dev": true }, "@types/minimatch": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", - "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA==", "dev": true }, "@types/node": { @@ -2851,9 +2851,9 @@ "dev": true }, "fastq": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.10.0.tgz", - "integrity": "sha512-NL2Qc5L3iQEsyYzweq7qfgy5OtXCmGzGvhElGEd/SoFWEMOEczNh5s5ocaF01HDetxz+p8ecjNPA6cZxxIHmzA==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.11.0.tgz", + "integrity": "sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==", "dev": true, "requires": { "reusify": "^1.0.4" @@ -3142,9 +3142,9 @@ } }, "glob-parent": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.1.tgz", - "integrity": "sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "requires": { "is-glob": "^4.0.1" @@ -3163,9 +3163,9 @@ "dev": true }, "globby": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.2.tgz", - "integrity": "sha512-2ZThXDvvV8fYFRVIxnrMQBipZQDr7MxKAmQK1vujaj9/7eF0efG7BPUKJ7jP7G5SLF37xKDXvO4S/KKLj/Z0og==", + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.0.3.tgz", + "integrity": "sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg==", "dev": true, "requires": { "array-union": "^2.1.0", @@ -3391,9 +3391,9 @@ } }, "handlebars": { - "version": "4.7.6", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.6.tgz", - "integrity": "sha512-1f2BACcBfiwAfStCKZNrUCgqNZkGsAT7UM3kkYtXuLo0KnaVfjKOyf7PRzB6++aK9STyT1Pd2ZCPe3EGOXleXA==", + "version": "4.7.7", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", + "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", "dev": true, "requires": { "minimist": "^1.2.5", @@ -3589,18 +3589,18 @@ "dev": true }, "i18next": { - "version": "19.8.4", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-19.8.4.tgz", - "integrity": "sha512-FfVPNWv+felJObeZ6DSXZkj9QM1Ivvh7NcFCgA8XPtJWHz0iXVa9BUy+QY8EPrCLE+vWgDfV/sc96BgXVo6HAA==", + "version": "19.9.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-19.9.2.tgz", + "integrity": "sha512-0i6cuo6ER6usEOtKajUUDj92zlG+KArFia0857xxiEHAQcUwh/RtOQocui1LPJwunSYT574Pk64aNva1kwtxZg==", "dev": true, "requires": { "@babel/runtime": "^7.12.0" }, "dependencies": { "@babel/runtime": { - "version": "7.12.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz", - "integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==", + "version": "7.13.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.10.tgz", + "integrity": "sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==", "dev": true, "requires": { "regenerator-runtime": "^0.13.4" @@ -3988,9 +3988,9 @@ "dev": true }, "jambo": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/jambo/-/jambo-1.10.0.tgz", - "integrity": "sha512-eZXjzvVYVhSVIdgS5WPCeZLiaGbOXCmvAJgfuvwzDV+W2HpWdTfBLvFVPZuGaedcatpY2CKku1pUv38aRTYR6Q==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/jambo/-/jambo-1.10.3.tgz", + "integrity": "sha512-LrCJCFydM1NsB3uzxu+FgjLNYJy3n8zJY0H7hSAFH7AEZj0GAtdGOwxIJvSh9nK5F6Lno3GYp1jWYTFv5O8nxw==", "dev": true, "requires": { "@babel/core": "^7.9.6", @@ -4323,9 +4323,9 @@ } }, "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", "dev": true }, "lodash.clonedeep": { @@ -4442,13 +4442,13 @@ "dev": true }, "micromatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz", - "integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", + "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==", "dev": true, "requires": { "braces": "^3.0.1", - "picomatch": "^2.0.5" + "picomatch": "^2.2.3" } }, "mime-db": { @@ -5000,9 +5000,9 @@ "dev": true }, "picomatch": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", - "integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.3.tgz", + "integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg==", "dev": true }, "pify": { @@ -5263,9 +5263,9 @@ "dev": true }, "prompts": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.0.tgz", - "integrity": "sha512-awZAKrk3vN6CroQukBL+R9051a4R3zCZBlJm/HBfrSZ8iTpYix3VX1vU4mveiLpiwmOJT4wokTF9m6HUk4KqWQ==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.1.tgz", + "integrity": "sha512-EQyfIuO2hPDsX1L/blblV+H7I0knhgAd82cVneCwcdND9B8AuCDuRcBH6yIcG4dFzlOUqbazQqwGjx5xmsNLuQ==", "dev": true, "requires": { "kleur": "^3.0.3", @@ -5315,6 +5315,12 @@ "strict-uri-encode": "^1.0.0" } }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -5846,10 +5852,13 @@ } }, "run-parallel": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.10.tgz", - "integrity": "sha512-zb/1OuZ6flOlH6tQyMPUrE3x3Ulxjlo9WIVXR4yVYi4H9UXQaeIsPbLn2R3O3vQCnDKkAl2qHiuocKKX4Tz/Sw==", - "dev": true + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "requires": { + "queue-microtask": "^1.2.2" + } }, "safe-buffer": { "version": "5.1.2", @@ -6210,8 +6219,7 @@ "sprintf-js": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", - "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", - "dev": true + "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==" }, "sshpk": { "version": "1.16.1", @@ -6284,9 +6292,9 @@ "dev": true }, "string-width": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz", - "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz", + "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==", "dev": true, "requires": { "emoji-regex": "^8.0.0", @@ -6737,9 +6745,9 @@ } }, "typescript": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz", - "integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz", + "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==", "dev": true }, "typical": { @@ -6749,17 +6757,20 @@ "dev": true }, "uglify-js": { - "version": "3.12.5", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.12.5.tgz", - "integrity": "sha512-SgpgScL4T7Hj/w/GexjnBHi3Ien9WS1Rpfg5y91WXMj9SY997ZCQU76mH4TpLwwfmMvoOU8wiaRkIf6NaH3mtg==", + "version": "3.13.4", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.13.4.tgz", + "integrity": "sha512-kv7fCkIXyQIilD5/yQy8O+uagsYIOt5cZvs890W40/e/rvjMSzJw81o9Bg0tkURxzZBROtDQhW2LFjOGoK3RZw==", "dev": true, "optional": true }, "underscore.string": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.2.3.tgz", - "integrity": "sha1-gGmSYzZl1eX8tNsfs6hi62jp5to=", - "dev": true + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/underscore.string/-/underscore.string-3.3.5.tgz", + "integrity": "sha512-g+dpmgn+XBneLmXXo+sGlW5xQEt4ErkS3mgeN2GFbremYeMBSJKr9Wf2KJplQVaiPY/f7FN6atosWYNm9ovrYg==", + "requires": { + "sprintf-js": "^1.0.3", + "util-deprecate": "^1.0.2" + } }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", @@ -6843,8 +6854,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "util.promisify": { "version": "1.0.0", diff --git a/static/package.json b/static/package.json index d9be5d853..8b2c992cd 100644 --- a/static/package.json +++ b/static/package.json @@ -1,6 +1,6 @@ { "name": "answers-hitchhiker-theme", - "version": "1.19.0", + "version": "1.20.0", "description": "Toolchain for use with the HH Theme", "main": "Gruntfile.js", "scripts": { @@ -29,7 +29,7 @@ "grunt-webpack": "^4.0.0", "html-loader": "^1.1.0", "html-webpack-plugin": "^4.3.0", - "jambo": "^1.10.0", + "jambo": "^1.10.3", "jsdom": "^16.4.0", "mini-css-extract-plugin": "^0.11.0", "node-sass": "^4.13.1", @@ -37,7 +37,6 @@ "resolve-url-loader": "^3.1.1", "sass-loader": "^8.0.2", "simple-git": "^2.15.0", - "underscore.string": "3.2.3", "webpack": "^5.4.0", "webpack-cli": "^4.2.0" }, @@ -54,7 +53,8 @@ "libphonenumber-js": "^1.7.51", "lodash.clonedeep": "^4.5.0", "merge-options": "^2.0.0", - "remove-files-webpack-plugin": "^1.4.3" + "remove-files-webpack-plugin": "^1.4.3", + "underscore.string": "3.3.5" }, "engines": { "node": ">=10" diff --git a/static/scss/answers-variables.scss b/static/scss/answers-variables.scss index b439d1732..ceba653c1 100644 --- a/static/scss/answers-variables.scss +++ b/static/scss/answers-variables.scss @@ -26,6 +26,7 @@ $container-width: 1000px; --hh-product-tag-text-color: var(--yxt-color-brand-white); --hh-product-tag-background-color: var(--hh-color-gray-3); --hh-universal-grid-margin: 8px; + --hh-universal-grid-four-columns-width: calc(calc(100% - var(--hh-universal-grid-margin) * 8)/4); --hh-universal-grid-three-columns-width: calc(calc(100% - var(--hh-universal-grid-margin) * 6) / 3); --hh-universal-grid-two-columns-width: calc(calc(100% - var(--hh-universal-grid-margin) * 4) / 2); --hh-universal-section-title-text-color: var(--yxt-color-text-primary); @@ -51,6 +52,19 @@ $container-width: 1000px; --yxt-filter-options-options-max-height: none; --yxt-filters-and-sorts-font-size: var(--yxt-font-size-md); --yxt-alternative-verticals-emphasized-font-weight: var(--yxt-font-weight-semibold); + --yxt-text-snippet-highlight-color: #eef3fb; + --yxt-text-snippet-font-color: var(--yxt-color-text-primary); + --yxt-direct-answer-view-details-font-weight: var(--yxt-font-weight-normal); + + // interactive map variables + --yxt-maps-search-this-area-background-color: white; + --yxt-maps-search-this-area-text-color: var(--yxt-color-text-primary); + --yxt-maps-mobile-detail-card-height: 225px; + --yxt-maps-desktop-results-container-width: 410px; + --yxt-maps-mobile-results-header-height: 185px; + --yxt-maps-mobile-results-footer-height: 97px; + --yxt-maps-desktop-height: 820px; + --yxt-maps-mobile-height: 620px; } // breakpoint variables for use in media queries within the theme styling @@ -61,4 +75,4 @@ $breakpoint-mobile-sm-max: 575px; $breakpoint-mobile-sm-min: 576px; $breakpoint-collapsed-filters: $container-width + 279px; -$breakpoint-expanded-filters: $container-width + 280px; \ No newline at end of file +$breakpoint-expanded-filters: $container-width + 280px; diff --git a/static/scss/answers/_default.scss b/static/scss/answers/_default.scss index 5ebec7083..d4eeb674b 100644 --- a/static/scss/answers/_default.scss +++ b/static/scss/answers/_default.scss @@ -30,6 +30,7 @@ // Universal Section Template styling @import "universalsectiontemplates/standard"; +@import "universalsectiontemplates/grid-four-columns"; @import "universalsectiontemplates/grid-three-columns"; @import "universalsectiontemplates/grid-two-columns"; @@ -48,10 +49,10 @@ @import "cards/professional-location"; @import "cards/financial-professional-location"; @import "cards/product-prominentimage-clickable"; -@import "directanswercards/allfields-standard"; -// HH files -@import "../answers"; +// Direct Answer Card styling +@import "directanswercards/allfields-standard"; +@import "directanswercards/documentsearch-standard"; // Styling for CollapsibleFilters @import "collapsible-filters/collapsible-filters"; @@ -61,6 +62,12 @@ @import "collapsible-filters/filter-link"; @import "collapsible-filters/view-results-button"; +// Vertical map component styling +@import 'interactive-map/VerticalFullPageMap.scss'; + +// HH files +@import "../answers"; + // Custom styling outside of the Answers experience @import "../page.scss"; @import "../header.scss"; diff --git a/static/scss/answers/answers-variables-default.scss b/static/scss/answers/answers-variables-default.scss index 685f1d9fb..a92fe57d4 100644 --- a/static/scss/answers/answers-variables-default.scss +++ b/static/scss/answers/answers-variables-default.scss @@ -25,6 +25,7 @@ $container-width: 1000px; --hh-product-tag-text-color: var(--yxt-color-brand-white); --hh-product-tag-background-color: var(--hh-color-gray-3); --hh-universal-grid-margin: 8px; + --hh-universal-grid-four-columns-width: calc(calc(100% - var(--hh-universal-grid-margin) * 8)/4); --hh-universal-grid-three-columns-width: calc(calc(100% - var(--hh-universal-grid-margin) * 6) / 3); --hh-universal-grid-two-columns-width: calc(calc(100% - var(--hh-universal-grid-margin) * 4) / 2); --hh-universal-section-title-text-color: var(--yxt-color-text-primary); @@ -50,6 +51,19 @@ $container-width: 1000px; --yxt-filter-options-options-max-height: none; --yxt-filters-and-sorts-font-size: var(--yxt-font-size-md); --yxt-alternative-verticals-emphasized-font-weight: var(--yxt-font-weight-semibold); + --yxt-text-snippet-highlight-color: #eef3fb; + --yxt-text-snippet-font-color: var(--yxt-color-text-primary); + --yxt-direct-answer-view-details-font-weight: var(--yxt-font-weight-normal); + + // interactive map variables + --yxt-maps-search-this-area-background-color: white; + --yxt-maps-search-this-area-text-color: var(--yxt-color-text-primary); + --yxt-maps-mobile-detail-card-height: 225px; + --yxt-maps-desktop-results-container-width: 410px; + --yxt-maps-mobile-results-header-height: 185px; + --yxt-maps-mobile-results-footer-height: 97px; + --yxt-maps-desktop-height: 820px; + --yxt-maps-mobile-height: 620px; } // breakpoint variables for use in media queries within the theme styling @@ -60,4 +74,4 @@ $breakpoint-mobile-sm-max: 575px; $breakpoint-mobile-sm-min: 576px; $breakpoint-collapsed-filters: $container-width + 279px; -$breakpoint-expanded-filters: $container-width + 280px; \ No newline at end of file +$breakpoint-expanded-filters: $container-width + 280px; diff --git a/static/scss/answers/cards/financial-professional-location.scss b/static/scss/answers/cards/financial-professional-location.scss index dfe8f48e1..1867976aa 100644 --- a/static/scss/answers/cards/financial-professional-location.scss +++ b/static/scss/answers/cards/financial-professional-location.scss @@ -49,6 +49,10 @@ $financial-professional-location-ordinal-dimensions: 18px !default; height: auto; } + &-closeCardButton { + display: none; + } + &-ordinal { flex-shrink: 0; padding-top: 3px; diff --git a/static/scss/answers/cards/location-standard.scss b/static/scss/answers/cards/location-standard.scss index a98fa79ad..81a8e531b 100644 --- a/static/scss/answers/cards/location-standard.scss +++ b/static/scss/answers/cards/location-standard.scss @@ -63,6 +63,10 @@ $location-standard-ordinal-dimensions: calc(var(--hh-location-standard-base-spac width: 100%; height: 100%; + &-closeCardButton { + display: none; + } + &-ordinal { display: flex; align-items: center; diff --git a/static/scss/answers/cards/professional-location.scss b/static/scss/answers/cards/professional-location.scss index 3d253eed1..7d7d90230 100644 --- a/static/scss/answers/cards/professional-location.scss +++ b/static/scss/answers/cards/professional-location.scss @@ -49,6 +49,10 @@ $professional-location-ordinal-dimensions: 18px !default; height: auto; } + &-closeCardButton { + display: none; + } + &-ordinal { flex-shrink: 0; padding-top: 3px; diff --git a/static/scss/answers/collapsible-filters/collapsible-filters-templates.scss b/static/scss/answers/collapsible-filters/collapsible-filters-templates.scss index 42393c426..5550fdb4b 100644 --- a/static/scss/answers/collapsible-filters/collapsible-filters-templates.scss +++ b/static/scss/answers/collapsible-filters/collapsible-filters-templates.scss @@ -34,6 +34,13 @@ } } +.VerticalMap.YxtFooter.CollapsibleFilters-inactive { + display: none; + @include bpgte(sm) { + display: block; + } +} + .AnswersVerticalStandard.CollapsibleFilters, .AnswersVerticalGrid.CollapsibleFilters { @include CollapsibleFiltersOverrides; @@ -75,3 +82,10 @@ } } } + +.VerticalStandard.YxtFooter.CollapsibleFilters-inactive, +.VerticalGrid.YxtFooter.CollapsibleFilters-inactive { + @media (max-width: #{$breakpoint-collapsed-filters}) { + display: none; + } +} diff --git a/static/scss/answers/collapsible-filters/sdk-filter-overrides.scss b/static/scss/answers/collapsible-filters/sdk-filter-overrides.scss index b834b43c3..148739240 100644 --- a/static/scss/answers/collapsible-filters/sdk-filter-overrides.scss +++ b/static/scss/answers/collapsible-filters/sdk-filter-overrides.scss @@ -1,4 +1,14 @@ + // This variable does not change the filters width. It is simply used for calculation + $filters-width: 242px; + $container-width-at-collapsed-filters-breakpoint: $breakpoint-collapsed-filters - (2 * $filters-width); + @mixin CollapsibleFiltersOverrides { + .Answers-container { + @media (min-width: #{$breakpoint-expanded-filters}) { + width: $container-width-at-collapsed-filters-breakpoint / $breakpoint-collapsed-filters * 100%; + } + } + .yxt-FilterOptions { &-fieldSet { margin: 0; diff --git a/static/scss/answers/directanswercards/allfields-standard.scss b/static/scss/answers/directanswercards/allfields-standard.scss index 2e17d6f81..ba47b71fa 100644 --- a/static/scss/answers/directanswercards/allfields-standard.scss +++ b/static/scss/answers/directanswercards/allfields-standard.scss @@ -67,10 +67,18 @@ display: flex; flex-direction: row; justify-content: space-between; - padding: var(--yxt-base-spacing); background-color: var(--yxt-direct-answer-content-background-color); + + padding-top: calc(var(--yxt-base-spacing) * 0.7); + padding-bottom: var(--yxt-base-spacing); + padding-left: var(--yxt-base-spacing); + padding-right: var(--yxt-base-spacing); + border-left: var(--yxt-direct-answer-border); border-right: var(--yxt-direct-answer-border); + border-bottom: var(--yxt-direct-answer-border); + border-bottom-left-radius: var(--yxt-border-radius); + border-bottom-right-radius: var(--yxt-border-radius); } &-cta @@ -100,26 +108,21 @@ justify-content: flex-end; align-items: center; padding-left: var(--yxt-base-spacing); - padding-right: var(--yxt-base-spacing); + padding-right: 4px; padding-top: 8px; padding-bottom: 8px; - border: var(--yxt-direct-answer-border); - border-bottom-left-radius: var(--yxt-border-radius); - border-bottom-right-radius: var(--yxt-border-radius); } &-footerText { display: inline; - margin-right: var(--yxt-base-spacing); + margin-right: 8px; @include Text( $size: var(--yxt-direct-answer-footer-font-size), $line-height: var(--yxt-direct-answer-footer-line-height), $weight: var(--yxt-direct-answer-footer-font-weight), $color: var(--yxt-direct-answer-footer-color) ); - - font-style: italic; } &-thumbs @@ -145,11 +148,11 @@ display: inline; flex-shrink: 0; cursor: pointer; - font-size: 24px; + font-size: 18px; & + & { - margin-left: 10px; + margin-left: 8px; } } diff --git a/static/scss/answers/directanswercards/documentsearch-standard.scss b/static/scss/answers/directanswercards/documentsearch-standard.scss new file mode 100644 index 000000000..12b592986 --- /dev/null +++ b/static/scss/answers/directanswercards/documentsearch-standard.scss @@ -0,0 +1,161 @@ +.HitchhikerDocumentSearchStandard +{ + margin-top: calc(var(--yxt-base-spacing) * 1.5); + background-color: var(--yxt-direct-answer-content-background-color); + + mark + { + background-color: var(--yxt-text-snippet-highlight-color); + color: var(--yxt-text-snippet-font-color); + } + + @media (min-width: $breakpoint-mobile-min) { + margin-top: calc(var(--yxt-base-spacing) * 2.35); + } + + &-titleAndContent + { + border: var(--yxt-direct-answer-border); + border-radius: var(--yxt-border-radius); + } + + &-title + { + margin: 0; + + padding-top: var(--yxt-base-spacing); + padding-left: var(--yxt-base-spacing); + padding-right: var(--yxt-base-spacing); + + @include Text( + $size: var(--yxt-font-size-xlg), + $line-height: var(--yxt-direct-answer-title-line-height), + $weight: var(--yxt-direct-answer-title-font-weight), + $color: var(--yxt-color-brand-primary) + ); + } + + &-column + { + width: 100%; + } + + &-content + { + display: flex; + flex-direction: row; + justify-content: space-between; + + padding-top: calc(var(--yxt-base-spacing) * 0.7); + padding-bottom: var(--yxt-base-spacing); + padding-left: var(--yxt-base-spacing); + padding-right: var(--yxt-base-spacing); + + @include Text( + $size: var(--yxt-direct-answer-footer-font-size), + $line-height: var(--yxt-direct-answer-footer-line-height), + $weight: var(--yxt-direct-answer-footer-font-weight), + $color: var(--yxt-direct-answer-footer-color) + ); + } + + &-cta + { + text-transform: uppercase; + margin-top: calc(var(--yxt-base-spacing) / 2); + justify-content: center; + + @media (min-width: $breakpoint-mobile-max) + { + margin-top: 0; + margin-left: calc(var(--yxt-base-spacing) / 2); + margin-right: calc(var(--yxt-base-spacing) / 2); + justify-content: center; + } + + } + + &-footer + { + display: flex; + justify-content: flex-end; + align-items: center; + padding-left: var(--yxt-base-spacing); + padding-right: 4px; + padding-top: 8px; + padding-bottom: 8px; + } + + &-footerText + { + display: inline; + margin-right: 8px; + @include Text( + $size: var(--yxt-direct-answer-footer-font-size), + $line-height: var(--yxt-direct-answer-footer-line-height), + $weight: var(--yxt-direct-answer-footer-font-weight), + $color: var(--yxt-direct-answer-footer-color) + ); + } + + &-thumbs + { + display: flex; + margin: 0; + } + + &-thumbUpIcon + { + transform: rotate(180deg); + } + + &-thumbUpIcon, + &-thumbDownIcon + { + display: inline-flex; + color: var(--yxt-color-text-secondary); + } + + &-thumb + { + display: inline; + flex-shrink: 0; + cursor: pointer; + font-size: 18px; + + & + & + { + margin-left: 8px; + } + } + + &-feedback + { + @include Text( + $color: var(--yxt-color-text-neutral), + ); + background-color: var(--yxt-color-background-highlight); + display: none; + } + + &-viewMore + { + @include Text( + $size: var(--yxt-direct-answer-view-details-font-size), + $line-height: var(--yxt-direct-answer-view-details-line-height), + $weight: var(--yxt-direct-answer-view-details-font-weight), + ); + @include Link; + + display: inline-flex; + align-items: center; + } + + &-viewMoreWrapper + { + &:not(:first-child) + { + margin-top: calc(var(--yxt-base-spacing) / 2); + } + } +} \ No newline at end of file diff --git a/static/scss/answers/interactive-map/VerticalFullPageMap.scss b/static/scss/answers/interactive-map/VerticalFullPageMap.scss new file mode 100644 index 000000000..32c12a34d --- /dev/null +++ b/static/scss/answers/interactive-map/VerticalFullPageMap.scss @@ -0,0 +1,696 @@ +.VerticalFullPageMap +{ + $results-width-desktop: var(--yxt-maps-desktop-results-container-width); + $results-padding-top: 24px !default; + $results-padding-bottom: 24px !default; + + $input-height: 44px !default; + $detail-card-height: 30% !default; + + $desktop-break-point-min: md !default; + $mobile-break-point-max: sm !default; + + $border-default: 1px solid #666 !default; + + $locator-text-gray: #737373; + + position: relative; + display: flex; + flex-direction: column; + flex-grow: 1; + width: 100%; + height: var(--yxt-maps-mobile-height); + + @include bpgte($desktop-break-point-min) { + height: var(--yxt-maps-desktop-height); + } + + .Answers + { + &-content + { + display: flex; + flex-grow: 1; + flex-direction: column; + width: 100%; + height: 100%; + position: relative; + background-color: white; + + @include bpgte($desktop-break-point-min) + { + position: relative; + bottom: 0; + height: auto; + width: 100%; + } + + @include bplte($mobile-break-point-max) + { + flex-direction: column-reverse; + } + } + + &-contentWrap + { + background-color: transparent; + max-height: 100%; + width: 100%; + position: absolute; + z-index: 3; + display: flex; + flex-direction: column; + + // IE11 only styling to fix content height problem + @media (-ms-high-contrast:none) + { + height: 100%; + } + + @include bpgte($desktop-break-point-min) + { + width: $results-width-desktop; + max-height: calc(100% - #{$results-padding-top} - #{$results-padding-bottom}); + margin-top: 16px; + margin-left: 16px; + margin-right: 16px; + margin-bottom: 32px; + border-radius: 4px; + box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25); + background-color: var(--hh-color-gray-2); + + // IE11 only styling to fix content height problem + @media (-ms-high-contrast:none) + { + height: calc(100% - #{$results-padding-top} - #{$results-padding-bottom}); + } + } + + @include bplte($mobile-break-point-max) + { + pointer-events: none; + position: static; + height: 100%; + } + } + + &-resultsWrapper + { + display: none; + flex-direction: column; + flex-grow: 1; + background-color: white; + padding: 0; + overflow-y: scroll; + + @include bpgte($desktop-break-point-min) { + display: flex; + border-radius: 4px; + overflow-y: auto; + } + + @include bplte($mobile-break-point-max) + { + transition: top 0.3s ease-out; + position: relative; + top: 100%; + pointer-events: all; + height: auto; + + &--moving + { + transition: none; + } + } + } + + &-resultsContainer, + &-results + { + display: flex; + flex-direction: column; + flex-grow: 1; + flex-shrink: 0; + } + + &-searchWrapper + { + padding: 16px; + display: flex; + flex-shrink: 0; + width: 100%; + background-color: transparent; + + @include bpgte($desktop-break-point-min) { + position: static; + } + + @include bplte($mobile-break-point-max) + { + pointer-events: all; + padding: 16px; + } + } + + &-form + { + display: flex; + flex-direction: column; + position: relative; + width: 100%; + } + + &-nav + { + @include bplte($mobile-break-point-max) + { + padding-left: 0; + padding-right: 0; + } + } + + &-mapWrapper { + display: block; + position: absolute; + width: 100%; + height: 100%; + right: 0; + top: 0; + + @include bplte($mobile-break-point-max) { + overflow: auto; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + } + + &-map + { + display: block; + width: 100%; + height: 100%; + min-height: 0; + + @include bplte($mobile-break-point-max) + { + top: 0; + transition: height 0.3s ease-out; + height: calc(100% - var(--yxt-maps-mobile-results-header-height) - var(--yxt-maps-mobile-results-footer-height)); + } + } + + &-mobileToggles + { + display: none; + position: absolute; + bottom: 72px; + right: 16px; + z-index: 4; + background-color: #eaeaea; + color: black; + cursor: pointer; + box-shadow: 1px 1px 2px rgba(0,0,0,0.25); + border-radius: var(--yxt-searchbar-form-border-radius); + } + + &-mobileToggle + { + flex: 1; + padding: 16px; + } + + &-viewMapText, + &-viewListText { + text-transform: uppercase; + letter-spacing: 1px; + align-items: center; + + svg { + margin-right: 8px; + height: 20px; + width: 20px; + } + } + + &-viewListText { + display: flex; + } + + &-viewMapText + { + display: none; + } + + &-centerTop { + display: flex; + margin-top: 11px; + color: black; + z-index: 1; + + @include bpgte($desktop-break-point-min) { + position: fixed; + margin-top: 0; + top: 16px; + left: calc(50% + calc(var(--yxt-maps-desktop-results-container-width) / 2)); + } + } + + &-searchThisAreaWrapper { + display: none; + position: relative; + background-color: var(--yxt-maps-search-this-area-background-color); + color: var(--yxt-maps-search-this-area-text-color); + margin-left: auto; + margin-right: auto; + padding: 10px 16px; + border-radius: var(--yxt-border-radius); + box-shadow: 1px 1px 2px rgba(0,0,0,0.25); + pointer-events: all; + + @include bpgte($desktop-break-point-min) { + left: -50%; + } + } + + &-searchThisAreaWrapper:focus-within { + background-color: #ececec; + } + + &-searchThisAreaToggleWrapper { + padding: 8px 16px; + font-size: 14px; + display: flex; + align-items: center; + border-top: var(--yxt-border-default); + color: $locator-text-gray; + + &--desktop { + display: none; + + @include bpgte(md) { + display: flex; + } + } + } + + &-searchThisAreaToggleWrapper:focus-within { + outline: 2px solid var(--yxt-color-brand-hover); + } + + &-searchThisAreaToggle { + opacity: 0; + height: 0; + width: 0; + margin: 0; + } + + &-searchThisAreaToggle:checked + .Answers-searchThisAreaToggleLabel:after { + content: ""; + } + + &-searchThisAreaToggleLabel { + position: relative; + cursor: pointer; + margin-left: 22px; + font-size: var(--yxt-font-size-md); + + &:before { + content: ""; + position: absolute; + bottom: 50%; + left: -22px; + height: 16px; + width: 16px; + transform: translateY(50%); + border: var(--yxt-border-default); + border-radius: 2px; + } + + &:after { + content: none; + position: absolute; + top: calc(50% - 3px); + left: -20px; + width: 4px; + height: 12px; + transform: rotate(45deg) translateY(-50%); + border-left-color: var(--yxt-filter-options-checkmark-color); + border-bottom: .0625rem solid #0c5ecb; + border-bottom-color: var(--yxt-filter-options-checkmark-color); + border-right: .0625rem solid #0c5ecb; + border-right-color: var(--yxt-filter-options-checkmark-color); + border-top-color: var(--yxt-filter-options-checkmark-color); + } + } + + &-resultsHeaderTop { + display: flex; + justify-content: space-between; + padding-left: 16px; + } + + &-stickyBottom { + bottom: 0; + background-color: var(--hh-color-gray-2); + position: sticky; + } + + &-mapFooter { + background-color: var(--hh-color-gray-2); + display: none; + position: absolute; + bottom: 0; + flex-direction: column; + width: 100%; + } + + &-search { + @include bplte($mobile-break-point-max) { + padding-left: 0; + padding-right: 0; + } + } + + &-pagination .yxt-Pagination { + padding: 8px; + } + + &-filtersWrapper { + overflow: auto; + + @include bplte($mobile-break-point-max) { + height: 100%; + pointer-events: all; + background-color: white; + } + } + + &-locationBias { + &--mobileMap { + display: none; + } + + .yxt-LocationBias { + border-top: var(--yxt-border-default); + padding-left: 16px; + padding-right: 16px; + color: $locator-text-gray; + + @include bplte($mobile-break-point-max) { + height: 60px; + display: flex; + justify-content: center; + align-items: center; + } + + &-container { + text-align: left; + } + } + + .yxt-locationBias-updateLoc:focus { + outline: 2px solid var(--yxt-color-brand-hover); + } + } + + &-resultsHeader { + display: block; + padding-left: 0; + + @include bplte($mobile-break-point-max) { + position: sticky; + top: 0; + z-index: 2; + pointer-events: all; + } + } + } + + // TODO make this the default + &.VerticalFullPageMap--mobileListView + { + .Answers + { + &-resultsWrapper + { + top: 0; + display: flex; + } + + &-viewMapText + { + display: flex; + } + + &-viewListText + { + display: none; + } + + &-centerTop + { + @include bplte($mobile-break-point-max) { + display: none; + } + } + } + } + + &.VerticalFullPageMap--mobileMapView + { + &:not(.CollapsibleFilters--expanded) .Answers-map { + top: var(--yxt-maps-mobile-results-header-height); + } + + .Answers-mapFooter { + display: flex; + } + + .Answers-locationBias { + &--main { + display: none; + } + + &--mobileMap { + display: inline-block; + } + } + } + + &.VerticalFullPageMap--mobileDetailShown + { + .Answers + { + &-map + { + position: absolute; + top: var(--yxt-maps-mobile-results-header-height); + height: calc(100% - var(--yxt-maps-mobile-results-header-height) - var(--yxt-maps-mobile-detail-card-height)); + } + + &-searchThisWrapper { + display: none; + } + + &-mobileToggles { + z-index: 0; + } + } + } + + &.VerticalFullPageMap--showMobileViewToggles { + .Answers-mobileToggles { + @include bplte($mobile-break-point-max) { + display: flex; + } + } + } + + // TODO move to js + &.VerticalFullPageMap--noResults { + .Answers-searchThisAreaWrapper { + display: none; + } + + .Answers-searchThisAreaToggleWrapper { + display: none; + } + } + + // TODO move to js + &.VerticalFullPageMap--showSearchThisArea { + .Answers-searchThisAreaWrapper { + display: flex; + } + } + + .gmnoprint + { + @include bplte($mobile-break-point-max) + { + display: none; + } + } + + .HitchhikerProfessionalLocation, + .HitchhikerFinancialProfessionalLocation { + &-contentWrapper { + flex-direction: column; + } + + &-ctasWrapper { + margin-top: calc(var(--yxt-base-spacing) / 2); + } + } + + .HitchhikerLocationCard { + position: relative; + + &-ctasWrapper { + margin-left: 0; + } + + &-info { + line-height: var(--yxt-line-height-sm); + } + + &-content { + flex-wrap: wrap; + line-height: var(--yxt-line-height-sm); + } + + &-closeCardButtonImageWrapper { + height: 16px; + width: 16px; + margin-bottom: 8px; + } + + &-ordinal { + display: none; + } + + &-closeCardButtonWrapper { + position: relative; + top: 1px; + margin-left: auto; + } + + &-closeCardButton { + display: none; + width: 100%; + font-size: 16px; + font-weight: bold; + justify-content: flex-end; + } + } + + .yxt-Card--isVisibleOnMobileMap { + transition: height 0.3s ease-out; + } + + .yxt-Card--pinFocused { + @include bpgte($desktop-break-point-min) { + .HitchhikerLocationCard { + background-color: #ececec; + } + } + + @include bplte($mobile-break-point-max) { + position: fixed; + bottom: 0; + left: 0; + width: 100%; + height: var(--yxt-maps-mobile-detail-card-height); + background-color: #fcfcfc; + + .HitchhikerLocationCard { + overflow-y: scroll; + + &-closeCardButton { + display: flex; + } + } + } + } + + .yxt-VerticalResultsCount { + color: $locator-text-gray; + } + + &.CollapsibleFilters .Answers-resultsHeader { + background-color: var(--hh-color-gray-2); + } + + &.CollapsibleFilters .Answers-viewResultsButton { + padding: 8px; + + @include bpgte($desktop-break-point-min) { + width: 100%; + position: absolute; + } + } + + &.CollapsibleFilters .Answers-filtersWrapper { + @include bplte($mobile-break-point-max) { + margin: 0; + padding: 16px; + } + } + + &.CollapsibleFilters { + &.CollapsibleFilters--expanded { + .Answers-mobileToggles { + display: none; + } + + .Answers-stickyBottom { + display: none; + } + } + + .Answers-viewResultsButton { + @include bplte(sm) { + width: 100%; + } + } + + .CollapsibleFilters-unstuck { + @include bplte(sm) { + position: fixed; + } + } + } +} + +.YxtPage-wrapper--mobileMapView { + min-height: var(--yxt-maps-mobile-height); + + .YxtPage-content { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + } +} + +.YxtPage-wrapper--mobileDetailShown { + .Answers-footer { + display: none; + } + + .YxtFooter { + display: none; + } +} + +.YxtPage-wrapper--mobileListView { + .Answers-footer { + margin-top: 0; + padding-top: calc(24px + var(--yxt-base-spacing)); + position: relative; + } +} diff --git a/static/scss/answers/theme.scss b/static/scss/answers/theme.scss index 05bb52432..ce32c5297 100644 --- a/static/scss/answers/theme.scss +++ b/static/scss/answers/theme.scss @@ -1,8 +1,5 @@ // answers theme goes here - // This variable does not change the filters width. It is simply used for calculation -$filters-width: 242px; -$container-width-at-collapsed-filters-breakpoint: $breakpoint-collapsed-filters - (2 * $filters-width); .Answers { @@ -19,10 +16,6 @@ $container-width-at-collapsed-filters-breakpoint: $breakpoint-collapsed-filters margin-right: auto; max-width: var(--hh-answers-container-width); width: 100%; - - @media (min-width: #{$breakpoint-expanded-filters}) { - width: $container-width-at-collapsed-filters-breakpoint / $breakpoint-collapsed-filters * 100%; - } } &-search, @@ -180,4 +173,11 @@ $container-width-at-collapsed-filters-breakpoint: $breakpoint-collapsed-filters .yxt-SortOptions-optionLabel { font-size: var(--yxt-filters-and-sorts-font-size); } + + .yxt-Card-child { + mark { + background-color: unset; + font-weight: var(--yxt-font-weight-medium); + } + } } diff --git a/static/scss/answers/universalsectiontemplates/common.scss b/static/scss/answers/universalsectiontemplates/common.scss index 94b4252ab..09a0edcf7 100644 --- a/static/scss/answers/universalsectiontemplates/common.scss +++ b/static/scss/answers/universalsectiontemplates/common.scss @@ -207,19 +207,6 @@ flex-basis: 100%; flex-grow: 1; - &, - &-placeholder { - min-width: var(--yxt-cards-min-width); - border: var(--yxt-border-default); - margin-right: var(--yxt-cards-margin); - } - - &-placeholder { - visibility: hidden; - border-top: none; - border-bottom: none; - } - &-child { flex-grow: 1; min-height: 1px; diff --git a/static/scss/answers/universalsectiontemplates/grid-four-columns.scss b/static/scss/answers/universalsectiontemplates/grid-four-columns.scss new file mode 100644 index 000000000..1cb2b741c --- /dev/null +++ b/static/scss/answers/universalsectiontemplates/grid-four-columns.scss @@ -0,0 +1,26 @@ +/** @define HitchhikerResultsGridFourColumns */ + +@import 'common'; + +.HitchhikerResultsGridFourColumns +{ + @include universal-standard; + + &-items + { + @include column-grid-items; + } + + &-Card + { + &--universal + { + @media (min-width: $breakpoint-mobile-min) { + margin: var(--hh-universal-grid-margin); + flex-basis: var(--hh-universal-grid-four-columns-width); + max-width: var(--hh-universal-grid-four-columns-width); + border: var(--yxt-border-default); + } + } + } +} diff --git a/static/webpack-config.js b/static/webpack-config.js index 55c111a91..a00741764 100644 --- a/static/webpack-config.js +++ b/static/webpack-config.js @@ -5,6 +5,28 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const HtmlPlugin = require('html-webpack-plugin'); const RemovePlugin = require('remove-files-webpack-plugin'); +const javascriptModuleRule = { + test: /\.js$/, + exclude: [ + /node_modules\// + ], + loader: 'babel-loader', + options: { + presets: [ + '@babel/preset-env', + ], + plugins: [ + ['@babel/plugin-transform-runtime', { + 'corejs': 3 + }], + '@babel/syntax-dynamic-import', + '@babel/plugin-transform-arrow-functions', + '@babel/plugin-proposal-object-rest-spread', + '@babel/plugin-transform-object-assign', + ] + } +}; + module.exports = function () { const isDevelopment = 'IS_DEVELOPMENT_PREVIEW' in process.env ? process.env.IS_DEVELOPMENT_PREVIEW === 'true': @@ -53,106 +75,113 @@ module.exports = function () { mode = 'production'; } - return { - mode, - performance: { - maxAssetSize: 1536000, - maxEntrypointSize: 1024000 - }, - target: ['web', 'es5'], - entry: { - 'bundle': `./${jamboConfig.dirs.output}/static/entry.js`, - 'iframe': `./${jamboConfig.dirs.output}/static/js/iframe.js`, - 'answers': `./${jamboConfig.dirs.output}/static/js/iframe.js`, - 'overlay-button': `./${jamboConfig.dirs.output}/static/js/overlay/button-frame/entry.js`, - 'overlay': `./${jamboConfig.dirs.output}/static/js/overlay/parent-frame/yxtanswersoverlay.js`, - 'iframe-prod': `./${jamboConfig.dirs.output}/static/js/iframe-prod.js`, - 'iframe-staging': `./${jamboConfig.dirs.output}/static/js/iframe-staging.js`, - }, - resolve: { - alias: { - static: path.resolve(__dirname, jamboConfig.dirs.output, 'static'), - } - }, - output: { - filename: '[name].js', - path: path.resolve(__dirname, jamboConfig.dirs.output), - library: 'HitchhikerJS', - libraryTarget: 'window', - publicPath: '' - }, - plugins, - module: { - rules: [ - { - test: /\.js$/, - exclude: [ - /node_modules\// - ], - loader: 'babel-loader', - options: { - presets: [ - '@babel/preset-env', - ], - plugins: [ - ['@babel/plugin-transform-runtime', { - 'corejs': 3 - }], - '@babel/syntax-dynamic-import', - '@babel/plugin-transform-arrow-functions', - '@babel/plugin-proposal-object-rest-spread', - '@babel/plugin-transform-object-assign', - ] - } + return [ + { + mode, + devtool: 'source-map', + target: ['web', 'es5'], + entry: { + 'locator-bundle': `./${jamboConfig.dirs.output}/static/js/locator-bundle.js` + }, + resolve: { + alias: { + static: path.resolve(__dirname, jamboConfig.dirs.output, 'static'), + } + }, + output: { + filename: '[name].js', + path: path.resolve(__dirname, jamboConfig.dirs.output), + library: { + name: 'VerticalFullPageMap', + type: 'window' }, - { - test: /\.scss$/, - use: [ - MiniCssExtractPlugin.loader, - 'css-loader', - 'resolve-url-loader', - { - loader: 'sass-loader', - options: { - sourceMap: true, + publicPath: '' + }, + module: { + rules: [javascriptModuleRule], + }, + }, + { + mode, + performance: { + maxAssetSize: 1536000, + maxEntrypointSize: 1024000 + }, + target: ['web', 'es5'], + entry: { + 'bundle': `./${jamboConfig.dirs.output}/static/entry.js`, + 'iframe': `./${jamboConfig.dirs.output}/static/js/iframe.js`, + 'answers': `./${jamboConfig.dirs.output}/static/js/iframe.js`, + 'overlay-button': `./${jamboConfig.dirs.output}/static/js/overlay/button-frame/entry.js`, + 'overlay': `./${jamboConfig.dirs.output}/static/js/overlay/parent-frame/yxtanswersoverlay.js`, + 'iframe-prod': `./${jamboConfig.dirs.output}/static/js/iframe-prod.js`, + 'iframe-staging': `./${jamboConfig.dirs.output}/static/js/iframe-staging.js`, + }, + resolve: { + alias: { + static: path.resolve(__dirname, jamboConfig.dirs.output, 'static'), + } + }, + output: { + filename: '[name].js', + path: path.resolve(__dirname, jamboConfig.dirs.output), + library: 'HitchhikerJS', + libraryTarget: 'window', + publicPath: '' + }, + plugins, + module: { + rules: [ + javascriptModuleRule, + { + test: /\.scss$/, + use: [ + MiniCssExtractPlugin.loader, + 'css-loader', + 'resolve-url-loader', + { + loader: 'sass-loader', + options: { + sourceMap: true, + } } + ], + }, + { + test: /\.(png|ico|gif|jpe?g|svg|webp|eot|otf|ttf|woff2?)$/, + loader: 'file-loader', + options: { + name: '[name].[contenthash].[ext]' } - ], - }, - { - test: /\.(png|ico|gif|jpe?g|svg|webp|eot|otf|ttf|woff2?)$/, - loader: 'file-loader', - options: { - name: '[name].[contenthash].[ext]' - } - }, - { - test: /\.html$/i, - use: [ - { - loader: path.resolve(__dirname, `./${jamboConfig.dirs.output}/static/webpack/html-asset-loader.js`), - }, - { - loader: 'html-loader', - options: { - minimize: { - removeAttributeQuotes: false, - collapseWhitespace: true, - conservativeCollapse: true, - keepClosingSlash: true, - minifyCSS: false, - minifyJS: false, - removeComments: true, - removeScriptTypeAttributes: true, - removeStyleLinkTypeAttributes: true, - useShortDoctype: true - }, - attributes: false + }, + { + test: /\.html$/i, + use: [ + { + loader: path.resolve(__dirname, `./${jamboConfig.dirs.output}/static/webpack/html-asset-loader.js`), + }, + { + loader: 'html-loader', + options: { + minimize: { + removeAttributeQuotes: false, + collapseWhitespace: true, + conservativeCollapse: true, + keepClosingSlash: true, + minifyCSS: false, + minifyJS: false, + removeComments: true, + removeScriptTypeAttributes: true, + removeStyleLinkTypeAttributes: true, + useShortDoctype: true + }, + attributes: false + } } - } - ] - } - ], - }, - } + ] + } + ], + }, + } + ] }; diff --git a/templates/universal-standard/page-config.json b/templates/universal-standard/page-config.json index 6a61c4bc4..970d233ff 100644 --- a/templates/universal-standard/page-config.json +++ b/templates/universal-standard/page-config.json @@ -11,7 +11,14 @@ }, **/ "DirectAnswer": { - "defaultCard": "allfields-standard" + "types": { + "FEATURED_SNIPPET": { + "cardType": "documentsearch-standard" + }, + "FIELD_VALUE": { + "cardType": "allfields-standard" + } + } }, "SearchBar": { "placeholderText": "Search" // The placeholder text in the answers search bar diff --git a/templates/universal-standard/script/map-pin.hbs b/templates/universal-standard/script/map-pin.hbs index 2bf925670..0ff478714 100644 --- a/templates/universal-standard/script/map-pin.hbs +++ b/templates/universal-standard/script/map-pin.hbs @@ -3,9 +3,9 @@ label: marker.label.toString() || '', height: {{#if pin.height}}{{pin.height}}{{else}}26{{/if}}, width: {{#if pin.width}}{{pin.width}}{{else}}22{{/if}}, - backgroundColor: '{{#if pin.backgroundColor}}{{pin.backgroundColor}}{{else}}#5387d7{{/if}}', - labelColor: '{{#if pin.labelColor}}{{pin.labelColor}}{{else}}white{{/if}}', - strokeColor: '{{#if pin.strokeColor}}{{pin.strokeColor}}{{else}}black{{/if}}', + backgroundColor: '{{#if pin.backgroundColor}}{{pin.backgroundColor}}{{else if pin.default.backgroundColor}}{{pin.default.backgroundColor}}{{else}}#5387d7{{/if}}', + labelColor: '{{#if pin.labelColor}}{{pin.labelColor}}{{else if pin.default.labelColor}}{{pin.default.labelColor}}{{else}}white{{/if}}', + strokeColor: '{{#if pin.strokeColor}}{{pin.strokeColor}}{{else if pin.default.strokeColor}}{{pin.default.strokeColor}}{{else}}black{{/if}}', } const svg = diff --git a/templates/universal-standard/script/navigation.hbs b/templates/universal-standard/script/navigation.hbs index bd3042792..348fe0cd5 100644 --- a/templates/universal-standard/script/navigation.hbs +++ b/templates/universal-standard/script/navigation.hbs @@ -11,7 +11,7 @@ verticalPages: [ {{#with (lookup verticalsToConfig verticalKey)}} {{#if isFirst}}isFirst: {{isFirst}},{{/if}} {{#if icon}}icon: "{{{icon}}}",{{/if}} - label: "{{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}", + label: {{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}, url: {{#if url}} "{{{relativePathHandler url=url relativePath=@root.relativePath}}}", @@ -51,30 +51,11 @@ verticalPages: [ --}} {{#*inline 'verticalLabel'}} {{~#if overridedLabel ~}} - {{{overridedLabel}}} - {{~ else if - (lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName') - ~}} - {{{lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName'}}} - {{~ else if verticalKey ~}} - {{{verticalKey}}} + "{{{overridedLabel}}}" {{~ else ~}} - {{{fallback}}} + HitchhikerJS.getInjectedProp( + "{{{@root.global_config.experienceKey}}}", + ["verticals", "{{{verticalKey}}}", "displayName"]) + || "{{{verticalKey}}}" || "{{{fallback}}}" {{~/if ~}} {{/inline}} diff --git a/templates/universal-standard/script/universalresults.hbs b/templates/universal-standard/script/universalresults.hbs index 5c80d48e0..58bb7a281 100644 --- a/templates/universal-standard/script/universalresults.hbs +++ b/templates/universal-standard/script/universalresults.hbs @@ -66,7 +66,7 @@ ANSWERS.addComponent("UniversalResults", Object.assign({}, { cardType: "{{{cardType}}}" }, {{/if}} - sectionTitle: {{#if sectionTitle}}"{{{sectionTitle}}}"{{else}}"{{> verticalLabel overridedLabel=label verticalKey=verticalKey fallback=verticalKey}}"{{/if}}, + sectionTitle: {{#if sectionTitle}}"{{{sectionTitle}}}"{{else}}{{> verticalLabel overridedLabel=label verticalKey=verticalKey}}{{/if}}, {{#if icon}} sectionTitleIconName: "{{{icon}}}", {{/if}} @@ -103,35 +103,15 @@ ANSWERS.addComponent("UniversalResults", Object.assign({}, { Prints the vertical label according to specific logic Assumes @root has environment variables and global_config @param overridedLabel The hardcoded label from configuration in repo, meant to supercede defaults - @param verticalKey The current vertical key, if it exists - @param fallback The fallback for the label if all else doesn't exist + @param verticalKey The current vertical key, if it exists. Will fallback to this value. --}} {{#*inline 'verticalLabel'}} {{~#if overridedLabel ~}} - {{{overridedLabel}}} - {{~ else if - (lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName') - ~}} - {{{lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName'}}} - {{~ else if verticalKey ~}} - {{{verticalKey}}} + "{{{overridedLabel}}}" {{~ else ~}} - {{{fallback}}} + HitchhikerJS.getInjectedProp( + "{{{@root.global_config.experienceKey}}}", + ["verticals", "{{{verticalKey}}}", "displayName"]) + || "{{{verticalKey}}}" {{~/if ~}} {{/inline}} diff --git a/templates/vertical-full-page-map/collapsible-filters/markup/filterlink.hbs b/templates/vertical-full-page-map/collapsible-filters/markup/filterlink.hbs new file mode 100644 index 000000000..1648253f1 --- /dev/null +++ b/templates/vertical-full-page-map/collapsible-filters/markup/filterlink.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/vertical-full-page-map/collapsible-filters/markup/viewresultsbutton.hbs b/templates/vertical-full-page-map/collapsible-filters/markup/viewresultsbutton.hbs new file mode 100644 index 000000000..b52ee1b9a --- /dev/null +++ b/templates/vertical-full-page-map/collapsible-filters/markup/viewresultsbutton.hbs @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/templates/vertical-full-page-map/collapsible-filters/page-setup.js b/templates/vertical-full-page-map/collapsible-filters/page-setup.js new file mode 100644 index 000000000..e767898bd --- /dev/null +++ b/templates/vertical-full-page-map/collapsible-filters/page-setup.js @@ -0,0 +1,28 @@ +// For signaling collapsible filters specific behavior in components. +const IS_COLLAPSIBLE_FILTERS = true; + +// Register the CollapsibleFiltersInteractions class, and instantiate an instance +// of it, to be called within component config. +const collapsibleFiltersInteractions = new CollapsibleFilters.Interactions({ + filterEls: document.querySelectorAll('.js-answersFiltersWrapper'), + resultEls: document.querySelectorAll('.js-answersResults,.js-answersFooter'), + disableScrollToTopOnToggle: true +}); +window.collapsibleFiltersInteractions = collapsibleFiltersInteractions; + +// When a search is made with the searchbar, collapse the filters. +collapsibleFiltersInteractions.registerCollapseFiltersOnSearchbarSearch(); + +// Make the view results button sticky +collapsibleFiltersInteractions.stickifyViewResultsButton(); + +// Register an instance of CollapsibleFilters.FacetsDecorator, +// to decorate the Facets component with +const facetsDecorator = new CollapsibleFilters.FacetsDecorator(); + +// Register the theme components used in CollapsibleFilters, and +// add them to the page with ANSWERS.addComponent() +{{> theme-components/collapsible-filters/view-results-button/component}} +{{> templates/vertical-full-page-map/collapsible-filters/script/viewresultsbutton }} +{{> theme-components/collapsible-filters/filter-link/component}} +{{> templates/vertical-full-page-map/collapsible-filters/script/filterlink }} diff --git a/templates/vertical-full-page-map/collapsible-filters/script/filterlink.js b/templates/vertical-full-page-map/collapsible-filters/script/filterlink.js new file mode 100644 index 000000000..f04667012 --- /dev/null +++ b/templates/vertical-full-page-map/collapsible-filters/script/filterlink.js @@ -0,0 +1,15 @@ +ANSWERS.addComponent('FilterLink', { + container: '#js-answersFilterLink', + onClickResetFilters: function() { + CollapsibleFilters.Helpers.resetAllFilters(); + CollapsibleFilters.Helpers.verticalSearch({ useFacets: true }); + }, + onClickChangeFilters: () => { + collapsibleFiltersInteractions.expandFilters(); + }, + onClickClearSearch: () => { + CollapsibleFilters.Helpers.clearSearch(); + collapsibleFiltersInteractions.focusSearchBar(); + }, + ...{{{ json componentSettings.FilterLink }}} +}); \ No newline at end of file diff --git a/templates/vertical-full-page-map/collapsible-filters/script/viewresultsbutton.js b/templates/vertical-full-page-map/collapsible-filters/script/viewresultsbutton.js new file mode 100644 index 000000000..c5a964c1b --- /dev/null +++ b/templates/vertical-full-page-map/collapsible-filters/script/viewresultsbutton.js @@ -0,0 +1,11 @@ +ANSWERS.addComponent('ViewResultsButton', { + container: '#js-answersViewResultsButton', + onClick: function () { + collapsibleFiltersInteractions.collapseFilters(); + this.setState(this.getState()); + }, + onCreate: function () { + collapsibleFiltersInteractions.markAsInactive(this._container); + }, + ...{{{ json componentSettings.ViewResultsButton }}} +}); \ No newline at end of file diff --git a/templates/vertical-full-page-map/markup/alternativeresults.hbs b/templates/vertical-full-page-map/markup/alternativeresults.hbs new file mode 100644 index 000000000..99737189c --- /dev/null +++ b/templates/vertical-full-page-map/markup/alternativeresults.hbs @@ -0,0 +1 @@ +
diff --git a/templates/vertical-full-page-map/markup/appliedfilters.hbs b/templates/vertical-full-page-map/markup/appliedfilters.hbs new file mode 100644 index 000000000..45d6c20b5 --- /dev/null +++ b/templates/vertical-full-page-map/markup/appliedfilters.hbs @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/templates/vertical-full-page-map/markup/facets.hbs b/templates/vertical-full-page-map/markup/facets.hbs new file mode 100644 index 000000000..dea947748 --- /dev/null +++ b/templates/vertical-full-page-map/markup/facets.hbs @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/templates/vertical-full-page-map/markup/filterbox.hbs b/templates/vertical-full-page-map/markup/filterbox.hbs new file mode 100644 index 000000000..c4de7520d --- /dev/null +++ b/templates/vertical-full-page-map/markup/filterbox.hbs @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/templates/vertical-full-page-map/markup/locationbias.hbs b/templates/vertical-full-page-map/markup/locationbias.hbs new file mode 100644 index 000000000..d73284f08 --- /dev/null +++ b/templates/vertical-full-page-map/markup/locationbias.hbs @@ -0,0 +1,3 @@ +
+
diff --git a/templates/vertical-full-page-map/markup/map.hbs b/templates/vertical-full-page-map/markup/map.hbs new file mode 100644 index 000000000..4192b3d06 --- /dev/null +++ b/templates/vertical-full-page-map/markup/map.hbs @@ -0,0 +1 @@ +
diff --git a/templates/vertical-full-page-map/markup/mobilelisttoggles.hbs b/templates/vertical-full-page-map/markup/mobilelisttoggles.hbs new file mode 100644 index 000000000..bf8bfe916 --- /dev/null +++ b/templates/vertical-full-page-map/markup/mobilelisttoggles.hbs @@ -0,0 +1,44 @@ +
+ +
+ +{{#*inline 'mapIcon'}} + + + Elements/Icons/Map + + + + + + + + + + +{{/inline}} + +{{#*inline 'listIcon'}} + + + Elements/Icons/List + + + + + + + + + + +{{/inline}} diff --git a/templates/vertical-full-page-map/markup/navigation.hbs b/templates/vertical-full-page-map/markup/navigation.hbs new file mode 100644 index 000000000..1b04f675d --- /dev/null +++ b/templates/vertical-full-page-map/markup/navigation.hbs @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/templates/vertical-full-page-map/markup/pagination.hbs b/templates/vertical-full-page-map/markup/pagination.hbs new file mode 100644 index 000000000..2f1935c3a --- /dev/null +++ b/templates/vertical-full-page-map/markup/pagination.hbs @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/templates/vertical-full-page-map/markup/qasubmission.hbs b/templates/vertical-full-page-map/markup/qasubmission.hbs new file mode 100644 index 000000000..4493cfb7d --- /dev/null +++ b/templates/vertical-full-page-map/markup/qasubmission.hbs @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/templates/vertical-full-page-map/markup/searchbar.hbs b/templates/vertical-full-page-map/markup/searchbar.hbs new file mode 100644 index 000000000..9bd9ac070 --- /dev/null +++ b/templates/vertical-full-page-map/markup/searchbar.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/vertical-full-page-map/markup/searchthisareabutton.hbs b/templates/vertical-full-page-map/markup/searchthisareabutton.hbs new file mode 100644 index 000000000..7ca618319 --- /dev/null +++ b/templates/vertical-full-page-map/markup/searchthisareabutton.hbs @@ -0,0 +1,5 @@ +
+ +
diff --git a/templates/vertical-full-page-map/markup/searchthisareatoggle.hbs b/templates/vertical-full-page-map/markup/searchthisareatoggle.hbs new file mode 100644 index 000000000..4a49d3bed --- /dev/null +++ b/templates/vertical-full-page-map/markup/searchthisareatoggle.hbs @@ -0,0 +1,12 @@ +
+ + +
diff --git a/templates/vertical-full-page-map/markup/sortoptions.hbs b/templates/vertical-full-page-map/markup/sortoptions.hbs new file mode 100644 index 000000000..7bfeaa804 --- /dev/null +++ b/templates/vertical-full-page-map/markup/sortoptions.hbs @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/templates/vertical-full-page-map/markup/spellcheck.hbs b/templates/vertical-full-page-map/markup/spellcheck.hbs new file mode 100644 index 000000000..afb50153f --- /dev/null +++ b/templates/vertical-full-page-map/markup/spellcheck.hbs @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/templates/vertical-full-page-map/markup/verticalresults.hbs b/templates/vertical-full-page-map/markup/verticalresults.hbs new file mode 100644 index 000000000..bbb1e3b6f --- /dev/null +++ b/templates/vertical-full-page-map/markup/verticalresults.hbs @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/templates/vertical-full-page-map/markup/verticalresultscount.hbs b/templates/vertical-full-page-map/markup/verticalresultscount.hbs new file mode 100644 index 000000000..004729536 --- /dev/null +++ b/templates/vertical-full-page-map/markup/verticalresultscount.hbs @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/templates/vertical-full-page-map/page-config.json b/templates/vertical-full-page-map/page-config.json new file mode 100644 index 000000000..a3982750c --- /dev/null +++ b/templates/vertical-full-page-map/page-config.json @@ -0,0 +1,88 @@ +{ + "verticalKey": "", // The vertical key from your search configuration + "pageTitle": "Location Search", // !!!REPLACE THIS VALUE!!! The contents of the title tag and meta open graph tag for title + // "metaDescription": "", // The meta tag for open graph description + // "canonicalUrl": "", // The link tag for canonical URL as well as the meta tag for open graph url + // "keywords": "", // The meta tag for keywords + "pageSettings": { + "search": { + "verticalKey": "", // The vertical key from your search configuration + "defaultInitialSearch": "" // Enter a default search term + } + }, + "componentSettings": { + /** + "QASubmission": { + "entityId": "", // Set the ID of the entity to use for Q&A submissions, must be of entity type "Organization" + "privacyPolicyUrl": "" // The fully qualified URL to the privacy policy + }, + **/ + /** + "Facets": { + "expand": false, // Allow the user to expand and collapse the facets + "showMore": false, // Display a link to see more facet options within a facet + "searchOnChange": true // Will automatically run a search as facets are selected or unselected. Set to false to only trigger updates with an Apply button. + // Additional options are available in the documentation + }, + **/ + "FilterLink": { + "changeFiltersText": "filter results", // Text that displays by default + "resetFiltersText": "reset filters", // Text when filters are applied + "clearSearchText": "clear search" // Text when there are no results, conducts an empty search + }, + "AppliedFilters": { + "removable": true + }, + "VerticalResults": { + "noResults": { + "displayAllResults": false // Optional, whether to display all results in the vertical when no results are found. + }, + "hideResultsHeader": true + }, + "SearchBar": { + "placeholderText": "Search for locations", // The placeholder text in the answers search bar + "allowEmptySearch": true // Allows users to submit an empty search in the searchbar + }, + "Pagination": { + "noResults": { + "visible": false + } + } + }, + // Configuration used to define the look and feel of the vertical, both on this page and, by default, + // on the universal page. + "verticalsToConfig": { + "": { // The vertical key from your search configuration + // "label": "", // The name of the vertical in the section header and the navigation bar + // "verticalLimit": 15, // The result count limit for vertical search + // "universalLimit": 5, // The result count limit for universal search + "cardType": "location-standard", // The name of the card to use - e.g. accordion, location, customcard + "icon": "pin", // The icon to use on the card for this vertical + "mapConfig": { + //"enablePinClustering": true, // Cluster pins on the map that are close together. Defaults false + "mapProvider": "MapBox", // The name of the provider (e.g. Mapbox, Google) + "noResults": { + "displayAllResults": false, // Set to FALSE to hide results on the map when a search returns no results + }, + "pin": { + "default": { // The pin in its normal state + "backgroundColor": "#5387d7", // Enter a hex value or color for the pin background + "strokeColor": "#2a446b", + "labelColor": "white" + }, + "hovered": { // The pin when it is hovered + "backgroundColor": "#2f4d71", + "strokeColor": "#172638", + "labelColor": "white" + }, + "selected": { // The pin when it is selected by mouse click or through a card click + "backgroundColor": "#2f4d71", + "strokeColor": "#172638", + "labelColor": "white" + } + } + }, + "universalSectionTemplate": "standard" + } + } +} diff --git a/templates/vertical-full-page-map/page-setup.js b/templates/vertical-full-page-map/page-setup.js new file mode 100644 index 000000000..28bc1482e --- /dev/null +++ b/templates/vertical-full-page-map/page-setup.js @@ -0,0 +1,11 @@ +function loadFullPageMap() { + {{> theme-components/theme-map/script}} + {{> theme-components/vertical-full-page-map/script}} +} + +if (window.locatorBundleLoaded) { + loadFullPageMap(); +} else { + const locatorBundleScript = document.querySelector('script#js-answersLocatorBundleScript'); + locatorBundleScript.onload = loadFullPageMap; +} diff --git a/templates/vertical-full-page-map/page.html.hbs b/templates/vertical-full-page-map/page.html.hbs new file mode 100644 index 000000000..a3933f747 --- /dev/null +++ b/templates/vertical-full-page-map/page.html.hbs @@ -0,0 +1,82 @@ +{{#> layouts/html pageWrapperCss="YxtPage-wrapper--mobileListView" }} + {{#> script/core }} + {{> cards/all }} + {{!-- {{> templates/vertical-full-page-map/collapsible-filters/page-setup }} --}} + {{> templates/vertical-full-page-map/page-setup }} + {{> templates/vertical-full-page-map/script/searchbar }} + {{> templates/vertical-full-page-map/script/spellcheck }} + {{> templates/vertical-full-page-map/script/navigation }} + {{> templates/vertical-full-page-map/script/verticalresultscount }} + {{> templates/vertical-full-page-map/script/appliedfilters }} + {{!-- {{> templates/vertical-full-page-map/script/sortoptions }} --}} + {{!-- {{> templates/vertical-full-page-map/script/facets }} --}} + {{!-- {{> templates/vertical-full-page-map/script/filterbox }} --}} + {{> templates/vertical-full-page-map/script/verticalresults }} + {{> templates/vertical-full-page-map/script/pagination }} + {{!-- {{> templates/vertical-full-page-map/script/qasubmission }} --}} + {{> templates/vertical-full-page-map/script/locationbias modifier="main" }} + {{> templates/vertical-full-page-map/script/locationbias modifier="mobileMap" }} + {{/script/core }} + +
+
+
+
+
+
+ {{> templates/vertical-full-page-map/markup/searchbar }} + {{> templates/vertical-full-page-map/markup/navigation }} +
+
+
+ {{> templates/vertical-full-page-map/markup/verticalresultscount }} + {{> templates/vertical-full-page-map/markup/appliedfilters }} + {{!-- {{> templates/vertical-full-page-map/collapsible-filters/markup/filterlink }} --}} + {{!-- {{> templates/vertical-full-page-map/collapsible-filters/markup/viewresultsbutton }} --}} +
+
+ {{> templates/vertical-full-page-map/markup/alternativeresults }} +
+
+ + {{!--
--}} + {{!-- {{> templates/vertical-full-page-map/markup/sortoptions }} --}} + {{!-- {{> templates/vertical-full-page-map/markup/facets }} --}} + {{!-- {{> templates/vertical-full-page-map/markup/filterbox }} --}} + {{!--
--}} +
+
+
+
+ {{> templates/vertical-full-page-map/markup/spellcheck }} + {{> templates/vertical-full-page-map/markup/verticalresults }} + {{> templates/vertical-full-page-map/markup/pagination }} + {{!-- {{> templates/vertical-full-page-map/markup/qasubmission }} --}} +
+
+
+
+
+ {{> templates/vertical-full-page-map/markup/searchthisareabutton }} +
+
+ {{> templates/vertical-full-page-map/markup/searchthisareatoggle modifier="desktop"}} + {{> templates/vertical-full-page-map/markup/locationbias modifier="main"}} +
+
+
+ {{> templates/vertical-full-page-map/markup/map }} +
+ {{> templates/vertical-full-page-map/markup/searchthisareatoggle modifier="mobileMap"}} + {{> templates/vertical-full-page-map/markup/locationbias modifier="mobileMap"}} +
+
+ {{> templates/vertical-full-page-map/markup/mobilelisttoggles }} +
+
+ +{{/layouts/html }} + diff --git a/templates/vertical-full-page-map/script/appliedfilters.hbs b/templates/vertical-full-page-map/script/appliedfilters.hbs new file mode 100644 index 000000000..b1875e649 --- /dev/null +++ b/templates/vertical-full-page-map/script/appliedfilters.hbs @@ -0,0 +1,7 @@ +ANSWERS.addComponent('AppliedFilters', { + container: '#js-answersAppliedFilters', + {{#if verticalKey}} + verticalKey: '{{{verticalKey}}}', + {{/if}} + ...{{{ json componentSettings.AppliedFilters }}} +}); \ No newline at end of file diff --git a/templates/vertical-full-page-map/script/facets.hbs b/templates/vertical-full-page-map/script/facets.hbs new file mode 100644 index 000000000..2c7305c6d --- /dev/null +++ b/templates/vertical-full-page-map/script/facets.hbs @@ -0,0 +1,15 @@ +ANSWERS.addComponent('Facets', { + container: '#js-answersFacets', + {{#if verticalKey}} + verticalKey: "{{{verticalKey}}}", + {{/if}} + onMount: function() { + if (typeof IS_COLLAPSIBLE_FILTERS !== 'undefined') { + facetsDecorator.onMount(this); + } + }, + {{#if componentSettings.Facets.fields}} + transformFacets: HitchhikerJS.transformFacets, + {{/if}} + ...{{{ json componentSettings.Facets }}}, +}); diff --git a/templates/vertical-full-page-map/script/filterbox.hbs b/templates/vertical-full-page-map/script/filterbox.hbs new file mode 100644 index 000000000..a0f72be29 --- /dev/null +++ b/templates/vertical-full-page-map/script/filterbox.hbs @@ -0,0 +1,6 @@ +ANSWERS.addComponent("FilterBox", Object.assign({}, { + container: "#js-answersFilterBox", + {{#if verticalKey}} + verticalKey: "{{{verticalKey}}}", + {{/if}} +}, {{{ json componentSettings.FilterBox }}})); diff --git a/templates/vertical-full-page-map/script/locationbias.hbs b/templates/vertical-full-page-map/script/locationbias.hbs new file mode 100644 index 000000000..34689ae6a --- /dev/null +++ b/templates/vertical-full-page-map/script/locationbias.hbs @@ -0,0 +1,7 @@ +ANSWERS.addComponent("LocationBias", Object.assign({}, { + container: "#js-answersLocationBias{{#if modifier}}--{{modifier}}{{/if}}", + updateLocationEl: "#js-answersLocationBias{{#if modifier}}--{{modifier}}{{/if}} .js-locationBias-update-location", + {{#if verticalKey}} + verticalKey: "{{{verticalKey}}}" + {{/if}} +}, {{{ json componentSettings.LocationBias }}})); diff --git a/templates/vertical-full-page-map/script/navigation.hbs b/templates/vertical-full-page-map/script/navigation.hbs new file mode 100644 index 000000000..1af27da3e --- /dev/null +++ b/templates/vertical-full-page-map/script/navigation.hbs @@ -0,0 +1,62 @@ +ANSWERS.addComponent("Navigation", Object.assign({}, { +container: "#js-answersNavigation", +verticalPages: [ +{{#each verticalConfigs}} +{{#if (any verticalKey (lookup verticalsToConfig 'Universal'))}} + { + verticalKey: "{{{verticalKey}}}", + {{#each ../excludedVerticals}}{{#ifeq this ../verticalKey}}hideInNavigation: true,{{/ifeq}}{{/each}} + {{#ifeq ../verticalKey verticalKey}}isActive: true,{{/ifeq}} + {{#if verticalKey}} + {{#with (lookup verticalsToConfig verticalKey)}} + {{#if isFirst}}isFirst: {{isFirst}},{{/if}} + {{#if icon}}icon: "{{{icon}}}",{{/if}} + label: {{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}, + url: + {{#if url}} + "{{{relativePathHandler url=url relativePath=@root.relativePath}}}", + {{else if ../url}} + "{{{relativePathHandler url=../url relativePath=@root.relativePath}}}", + {{else}} + "{{{@key}}}.html", + {{/if}} + {{/with}} + {{else}} + {{#with (lookup verticalsToConfig "Universal")}} + {{#if isFirst}}isFirst: {{isFirst}},{{/if}} + {{#if icon}}icon: "{{{icon}}}",{{/if}} + label: {{#if label}}"{{{label}}}"{{else}}"{{{@key}}}"{{/if}}, + url: + {{#if url}} + "{{{relativePathHandler url=url relativePath=@root.relativePath}}}", + {{else if ../url}} + "{{{relativePathHandler url=../url relativePath=@root.relativePath}}}", + {{else}} + "{{{@key}}}.html", + {{/if}} + {{/with}} + {{/if}} + }{{#unless @last}},{{/unless}} +{{/if}} +{{/each}} +] +}, {{{ json componentSettings.Navigation }}})); + +{{!-- + Prints the vertical label according to specific logic + Assumes @root has environment variables and global_config + @param overridedLabel The hardcoded label from configuration in repo, meant to supercede defaults + @param verticalKey The current vertical key, if it exists + @param fallback The fallback for the label if all else doesn't exist +--}} +{{#*inline 'verticalLabel'}} + {{~#if overridedLabel ~}} + "{{{overridedLabel}}}" + {{~ else ~}} + HitchhikerJS.getInjectedProp( + "{{{@root.global_config.experienceKey}}}", + ["verticals", "{{{verticalKey}}}", "displayName"]) + {{~#if verticalKey ~}} || "{{{verticalKey}}}" {{~/if ~}} + {{~#if fallback ~}} || "{{{fallback}}}" {{~/if ~}} + {{~/if ~}} +{{/inline}} diff --git a/templates/vertical-full-page-map/script/pagination.hbs b/templates/vertical-full-page-map/script/pagination.hbs new file mode 100644 index 000000000..2998fc231 --- /dev/null +++ b/templates/vertical-full-page-map/script/pagination.hbs @@ -0,0 +1,19 @@ +ANSWERS.addComponent("Pagination", Object.assign({}, { + container: "#js-answersPagination", + onPaginate: (newPageNumber, oldPageNumber, totalPages) => { + if (window.parentIFrame) { + const paginateMessage = { action: 'paginate' }; + window.parentIFrame.sendMessage(JSON.stringify(paginateMessage)); + } else { + document.documentElement.scrollTop = 0; + document.body.scrollTop = 0; // Safari + } + const resultsColumn = document.querySelector(".js-locator-resultsWrapper"); + if (resultsColumn) { + resultsColumn.scrollTop = 0; + } + }, + {{#if verticalKey}} + verticalKey: "{{{verticalKey}}}", + {{/if}} +}, {{{ json componentSettings.Pagination }}})); diff --git a/templates/vertical-full-page-map/script/qasubmission.hbs b/templates/vertical-full-page-map/script/qasubmission.hbs new file mode 100644 index 000000000..fdd3628c4 --- /dev/null +++ b/templates/vertical-full-page-map/script/qasubmission.hbs @@ -0,0 +1,3 @@ +ANSWERS.addComponent("QASubmission", Object.assign({}, { + container: "#js-answersQASubmission", +}, {{{ json componentSettings.QASubmission }}})); \ No newline at end of file diff --git a/templates/vertical-full-page-map/script/searchbar.hbs b/templates/vertical-full-page-map/script/searchbar.hbs new file mode 100644 index 000000000..97e49dad4 --- /dev/null +++ b/templates/vertical-full-page-map/script/searchbar.hbs @@ -0,0 +1,30 @@ +const overlayConfig = { + customHooks: { + onConductSearch: function (query) { + window.Overlay.grow(); + }, + onClearSearch: function () { + window.Overlay.shrink(); + } + }, + autocomplete: { + onOpen: function() { + const overlaySuggestionsEl = document.querySelector('.js-Answers-overlaySuggestions'); + overlaySuggestionsEl && overlaySuggestionsEl.classList.add('hidden'); + }, + onClose: function() { + const overlaySuggestionsEl = document.querySelector('.js-Answers-overlaySuggestions'); + overlaySuggestionsEl && overlaySuggestionsEl.classList.remove('hidden'); + }, + shouldHideOnEmptySearch: true + } +}; +const shouldAddOverlayConfig = window.isOverlay && document.querySelector('.js-Answers-overlaySuggestions'); + +ANSWERS.addComponent("SearchBar", Object.assign({}, { + container: ".js-answersSearch", + {{#if verticalKey}} + verticalKey: "{{{verticalKey}}}", + {{/if}} + ...(shouldAddOverlayConfig ? overlayConfig : {}), +}, {{{ json componentSettings.SearchBar }}})); \ No newline at end of file diff --git a/templates/vertical-full-page-map/script/sortoptions.hbs b/templates/vertical-full-page-map/script/sortoptions.hbs new file mode 100644 index 000000000..0f3f07a88 --- /dev/null +++ b/templates/vertical-full-page-map/script/sortoptions.hbs @@ -0,0 +1,6 @@ +ANSWERS.addComponent("SortOptions", Object.assign({}, { + container: "#js-answersSortOptions", + {{#if verticalKey}} + verticalKey: "{{{verticalKey}}}", + {{/if}} +}, {{{ json componentSettings.SortOptions }}})); \ No newline at end of file diff --git a/templates/vertical-full-page-map/script/spellcheck.hbs b/templates/vertical-full-page-map/script/spellcheck.hbs new file mode 100644 index 000000000..8c1ac95b6 --- /dev/null +++ b/templates/vertical-full-page-map/script/spellcheck.hbs @@ -0,0 +1,3 @@ +ANSWERS.addComponent("SpellCheck", Object.assign({}, { + container: "#js-answersSpellCheck", +}, {{{ json componentSettings.SpellCheck }}})); \ No newline at end of file diff --git a/templates/vertical-full-page-map/script/verticalresults.hbs b/templates/vertical-full-page-map/script/verticalresults.hbs new file mode 100644 index 000000000..3972d3232 --- /dev/null +++ b/templates/vertical-full-page-map/script/verticalresults.hbs @@ -0,0 +1,77 @@ +ANSWERS.addComponent("VerticalResults", Object.assign({}, { + container: "#js-answersVerticalResults", + {{#if verticalKey}} + verticalKey: "{{{verticalKey}}}", + modifier: "{{{verticalKey}}}", + {{/if}} + verticalPages: [ + {{#each verticalConfigs}} + {{#if verticalKey}} + { + verticalKey: "{{{verticalKey}}}", + {{#each ../excludedVerticals}}{{#ifeq this ../verticalKey}}hideInNavigation: true,{{/ifeq}}{{/each}} + {{#ifeq ../verticalKey verticalKey}}isActive: true,{{/ifeq}} + {{#with (lookup verticalsToConfig verticalKey)}} + {{#if isFirst}}isFirst: {{isFirst}},{{/if}} + {{#if icon}}icon: "{{{icon}}}",{{/if}} + {{#if iconUrl}}iconUrl: "{{{relativePathHandler url=iconUrl relativePath=@root.relativePath}}}",{{/if}} + label: {{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}, + url: + {{#if url}} + "{{{relativePathHandler url=url relativePath=@root.relativePath}}}", + {{else if ../url}} + "{{{relativePathHandler url=../url relativePath=@root.relativePath}}}", + {{else}} + "{{{@key}}}.html", + {{/if}} + {{/with}} + }{{#unless @last}},{{/unless}} + {{else}} + { + {{#with (lookup verticalsToConfig "Universal")}} + {{#if isFirst}}isFirst: {{isFirst}},{{/if}} + {{#if icon}}icon: "{{{icon}}}",{{/if}} + label: {{#if label}}"{{{label}}}"{{else}}"{{{@key}}}"{{/if}}, + url: + {{#if url}} + "{{{relativePathHandler url=url relativePath=@root.relativePath}}}", + {{else if ../url}} + "{{{relativePathHandler url=../url relativePath=@root.relativePath}}}", + {{else}} + "{{{@key}}}.html", + {{/if}} + {{/with}} + }{{#unless @last}},{{/unless}} + {{/if}} + {{/each}} + ], + {{#with (lookup verticalsToConfig verticalKey)}} + card: { + {{#if cardType}}cardType: "{{{cardType}}}",{{/if}} + }, + {{/with}} +}, {{{ json componentSettings.VerticalResults }}}, { + noResults: Object.assign({}, + { template: "" }, + {{{ json componentSettings.VerticalResults.noResults }}} + ) +})); + +{{!-- + Prints the vertical label according to specific logic + Assumes @root has environment variables and global_config + @param overridedLabel The hardcoded label from configuration in repo, meant to supercede defaults + @param verticalKey The current vertical key, if it exists + @param fallback The fallback for the label if all else doesn't exist +--}} +{{#*inline 'verticalLabel'}} + {{~#if overridedLabel ~}} + "{{{overridedLabel}}}" + {{~ else ~}} + HitchhikerJS.getInjectedProp( + "{{{@root.global_config.experienceKey}}}", + ["verticals", "{{{verticalKey}}}", "displayName"]) + {{~#if verticalKey ~}} || "{{{verticalKey}}}" {{~/if ~}} + {{~#if fallback ~}} || "{{{fallback}}}" {{~/if ~}} + {{~/if ~}} +{{/inline}} diff --git a/templates/vertical-full-page-map/script/verticalresultscount.hbs b/templates/vertical-full-page-map/script/verticalresultscount.hbs new file mode 100644 index 000000000..a79fda533 --- /dev/null +++ b/templates/vertical-full-page-map/script/verticalresultscount.hbs @@ -0,0 +1,4 @@ +ANSWERS.addComponent('VerticalResultsCount', { + container: '#js-answersVerticalResultsCount', + ...{{{ json componentSettings.VerticalResultsCount }}} +}); \ No newline at end of file diff --git a/templates/vertical-grid/collapsible-filters/page-setup.js b/templates/vertical-grid/collapsible-filters/page-setup.js index f2b9c5033..4b6e6e45a 100644 --- a/templates/vertical-grid/collapsible-filters/page-setup.js +++ b/templates/vertical-grid/collapsible-filters/page-setup.js @@ -1,16 +1,12 @@ // For signaling collapsible filters specific behavior in components. const IS_COLLAPSIBLE_FILTERS = true; -// The SDK does not support Facets on load, however the Facets -// component interacts with persistent storage in a way that suggests -// that it does. This is a temporary fix until the SDK is patched. -CollapsibleFilters.Helpers.clearFacetsPersistentStorage(); - // Register the CollapsibleFiltersInteractions class, and instantiate an instance // of it, to be called within component config. const collapsibleFiltersInteractions = new CollapsibleFilters.Interactions({ filterEls: document.querySelectorAll('.js-answersFiltersWrapper'), - resultEls: document.querySelectorAll('.js-answersResults,.js-answersFooter') + resultEls: document.querySelectorAll('.js-answersResults,.js-answersFooter,.js-yxtFooter'), + templateName: 'VerticalGrid' }); // When a search is made with the searchbar, collapse the filters. @@ -19,6 +15,9 @@ collapsibleFiltersInteractions.registerCollapseFiltersOnSearchbarSearch(); // Make the view results button sticky collapsibleFiltersInteractions.stickifyViewResultsButton(true); +// Setup the Footer so that it can properly interact with CFilters +collapsibleFiltersInteractions.setupFooter(); + // Register an instance of CollapsibleFilters.FacetsDecorator, // to decorate the Facets component with const facetsDecorator = new CollapsibleFilters.FacetsDecorator(); diff --git a/templates/vertical-grid/page-config.json b/templates/vertical-grid/page-config.json index 6f86fd1c5..2ad521992 100644 --- a/templates/vertical-grid/page-config.json +++ b/templates/vertical-grid/page-config.json @@ -5,12 +5,10 @@ // "canonicalUrl": "", // The link tag for canonical URL as well as the meta tag for open graph url // "keywords": "", // The meta tag for keywords "pageSettings": { - /** "search": { "verticalKey": "", // The vertical key from your search configuration "defaultInitialSearch": "" // Enter a default search term } - **/ }, "componentSettings": { /** diff --git a/templates/vertical-grid/script/facets.hbs b/templates/vertical-grid/script/facets.hbs index 447e41e08..2c7305c6d 100644 --- a/templates/vertical-grid/script/facets.hbs +++ b/templates/vertical-grid/script/facets.hbs @@ -8,5 +8,8 @@ ANSWERS.addComponent('Facets', { facetsDecorator.onMount(this); } }, + {{#if componentSettings.Facets.fields}} + transformFacets: HitchhikerJS.transformFacets, + {{/if}} ...{{{ json componentSettings.Facets }}}, }); diff --git a/templates/vertical-grid/script/navigation.hbs b/templates/vertical-grid/script/navigation.hbs index bd3042792..7f3397ba1 100644 --- a/templates/vertical-grid/script/navigation.hbs +++ b/templates/vertical-grid/script/navigation.hbs @@ -11,7 +11,7 @@ verticalPages: [ {{#with (lookup verticalsToConfig verticalKey)}} {{#if isFirst}}isFirst: {{isFirst}},{{/if}} {{#if icon}}icon: "{{{icon}}}",{{/if}} - label: "{{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}", + label: {{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}, url: {{#if url}} "{{{relativePathHandler url=url relativePath=@root.relativePath}}}", @@ -51,30 +51,12 @@ verticalPages: [ --}} {{#*inline 'verticalLabel'}} {{~#if overridedLabel ~}} - {{{overridedLabel}}} - {{~ else if - (lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName') - ~}} - {{{lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName'}}} - {{~ else if verticalKey ~}} - {{{verticalKey}}} + "{{{overridedLabel}}}" {{~ else ~}} - {{{fallback}}} + HitchhikerJS.getInjectedProp( + "{{{@root.global_config.experienceKey}}}", + ["verticals", "{{{verticalKey}}}", "displayName"]) + {{~#if verticalKey ~}} || "{{{verticalKey}}}" {{~/if ~}} + {{~#if fallback ~}} || "{{{fallback}}}" {{~/if ~}} {{~/if ~}} {{/inline}} diff --git a/templates/vertical-grid/script/verticalresults.hbs b/templates/vertical-grid/script/verticalresults.hbs index 2f1e50e28..413f471b0 100644 --- a/templates/vertical-grid/script/verticalresults.hbs +++ b/templates/vertical-grid/script/verticalresults.hbs @@ -15,7 +15,7 @@ ANSWERS.addComponent("VerticalResults", Object.assign({}, { {{#if isFirst}}isFirst: {{isFirst}},{{/if}} {{#if icon}}icon: "{{{icon}}}",{{/if}} {{#if iconUrl}}iconUrl: "{{{relativePathHandler url=iconUrl relativePath=@root.relativePath}}}",{{/if}} - label: "{{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}", + label: {{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}, url: {{#if url}} "{{{relativePathHandler url=url relativePath=@root.relativePath}}}", @@ -61,30 +61,12 @@ ANSWERS.addComponent("VerticalResults", Object.assign({}, { --}} {{#*inline 'verticalLabel'}} {{~#if overridedLabel ~}} - {{{overridedLabel}}} - {{~ else if - (lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName') - ~}} - {{{lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName'}}} - {{~ else if verticalKey ~}} - {{{verticalKey}}} + "{{{overridedLabel}}}" {{~ else ~}} - {{{fallback}}} + HitchhikerJS.getInjectedProp( + "{{{@root.global_config.experienceKey}}}", + ["verticals", "{{{verticalKey}}}", "displayName"]) + {{~#if verticalKey ~}} || "{{{verticalKey}}}" {{~/if ~}} + {{~#if fallback ~}} || "{{{fallback}}}" {{~/if ~}} {{~/if ~}} {{/inline}} diff --git a/templates/vertical-map/collapsible-filters/page-setup.js b/templates/vertical-map/collapsible-filters/page-setup.js index cd1d9995e..30fe036ed 100644 --- a/templates/vertical-map/collapsible-filters/page-setup.js +++ b/templates/vertical-map/collapsible-filters/page-setup.js @@ -1,16 +1,12 @@ // For signaling collapsible filters specific behavior in components. const IS_COLLAPSIBLE_FILTERS = true; -// The SDK does not support Facets on load, however the Facets -// component interacts with persistent storage in a way that suggests -// that it does. This is a temporary fix until the SDK is patched. -CollapsibleFilters.Helpers.clearFacetsPersistentStorage(); - // Register the CollapsibleFiltersInteractions class, and instantiate an instance // of it, to be called within component config. const collapsibleFiltersInteractions = new CollapsibleFilters.Interactions({ filterEls: document.querySelectorAll('.js-answersFiltersWrapper'), - resultEls: document.querySelectorAll('.js-answersResults,.js-answersFooter') + resultEls: document.querySelectorAll('.js-answersResults,.js-answersFooter,.js-yxtFooter'), + templateName: 'VerticalMap' }); // When a search is made with the searchbar, collapse the filters. @@ -19,6 +15,9 @@ collapsibleFiltersInteractions.registerCollapseFiltersOnSearchbarSearch(); // Make the view results button sticky collapsibleFiltersInteractions.stickifyViewResultsButton(); +// Setup the Footer so that it can properly interact with CFilters +collapsibleFiltersInteractions.setupFooter(); + // Register an instance of CollapsibleFilters.FacetsDecorator, // to decorate the Facets component with const facetsDecorator = new CollapsibleFilters.FacetsDecorator(); diff --git a/templates/vertical-map/page-config.json b/templates/vertical-map/page-config.json index 033f6f98a..8311f434f 100644 --- a/templates/vertical-map/page-config.json +++ b/templates/vertical-map/page-config.json @@ -5,12 +5,10 @@ // "canonicalUrl": "", // The link tag for canonical URL as well as the meta tag for open graph url // "keywords": "", // The meta tag for keywords "pageSettings": { - /** "search": { "verticalKey": "", // The vertical key from your search configuration "defaultInitialSearch": "" // Enter a default search term } - **/ }, "componentSettings": { /** diff --git a/templates/vertical-map/script/facets.hbs b/templates/vertical-map/script/facets.hbs index 447e41e08..2c7305c6d 100644 --- a/templates/vertical-map/script/facets.hbs +++ b/templates/vertical-map/script/facets.hbs @@ -8,5 +8,8 @@ ANSWERS.addComponent('Facets', { facetsDecorator.onMount(this); } }, + {{#if componentSettings.Facets.fields}} + transformFacets: HitchhikerJS.transformFacets, + {{/if}} ...{{{ json componentSettings.Facets }}}, }); diff --git a/templates/vertical-map/script/navigation.hbs b/templates/vertical-map/script/navigation.hbs index c0bf5a1c1..1af27da3e 100644 --- a/templates/vertical-map/script/navigation.hbs +++ b/templates/vertical-map/script/navigation.hbs @@ -11,7 +11,7 @@ verticalPages: [ {{#with (lookup verticalsToConfig verticalKey)}} {{#if isFirst}}isFirst: {{isFirst}},{{/if}} {{#if icon}}icon: "{{{icon}}}",{{/if}} - label: "{{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}", + label: {{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}, url: {{#if url}} "{{{relativePathHandler url=url relativePath=@root.relativePath}}}", @@ -51,30 +51,12 @@ verticalPages: [ --}} {{#*inline 'verticalLabel'}} {{~#if overridedLabel ~}} - {{{overridedLabel}}} - {{~ else if - (lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName') - ~}} - {{{lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName'}}} - {{~ else if verticalKey ~}} - {{{verticalKey}}} + "{{{overridedLabel}}}" {{~ else ~}} - {{{fallback}}} + HitchhikerJS.getInjectedProp( + "{{{@root.global_config.experienceKey}}}", + ["verticals", "{{{verticalKey}}}", "displayName"]) + {{~#if verticalKey ~}} || "{{{verticalKey}}}" {{~/if ~}} + {{~#if fallback ~}} || "{{{fallback}}}" {{~/if ~}} {{~/if ~}} {{/inline}} diff --git a/templates/vertical-map/script/verticalresults.hbs b/templates/vertical-map/script/verticalresults.hbs index 88bc04a5b..604b836c4 100644 --- a/templates/vertical-map/script/verticalresults.hbs +++ b/templates/vertical-map/script/verticalresults.hbs @@ -15,7 +15,7 @@ ANSWERS.addComponent("VerticalResults", Object.assign({}, { {{#if isFirst}}isFirst: {{isFirst}},{{/if}} {{#if icon}}icon: "{{{icon}}}",{{/if}} {{#if iconUrl}}iconUrl: "{{{relativePathHandler url=iconUrl relativePath=@root.relativePath}}}",{{/if}} - label: "{{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}", + label: {{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}, url: {{#if url}} "{{{relativePathHandler url=url relativePath=@root.relativePath}}}", @@ -61,30 +61,12 @@ ANSWERS.addComponent("VerticalResults", Object.assign({}, { --}} {{#*inline 'verticalLabel'}} {{~#if overridedLabel ~}} - {{{overridedLabel}}} - {{~ else if - (lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName') - ~}} - {{{lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName'}}} - {{~ else if verticalKey ~}} - {{{verticalKey}}} + "{{{overridedLabel}}}" {{~ else ~}} - {{{fallback}}} + HitchhikerJS.getInjectedProp( + "{{{@root.global_config.experienceKey}}}", + ["verticals", "{{{verticalKey}}}", "displayName"]) + {{~#if verticalKey ~}} || "{{{verticalKey}}}" {{~/if ~}} + {{~#if fallback ~}} || "{{{fallback}}}" {{~/if ~}} {{~/if ~}} {{/inline}} diff --git a/templates/vertical-standard/collapsible-filters/page-setup.js b/templates/vertical-standard/collapsible-filters/page-setup.js index 165019107..422cfe22f 100644 --- a/templates/vertical-standard/collapsible-filters/page-setup.js +++ b/templates/vertical-standard/collapsible-filters/page-setup.js @@ -1,16 +1,12 @@ // For signaling collapsible filters specific behavior in components. const IS_COLLAPSIBLE_FILTERS = true; -// The SDK does not support Facets on load, however the Facets -// component interacts with persistent storage in a way that suggests -// that it does. This is a temporary fix until the SDK is patched. -CollapsibleFilters.Helpers.clearFacetsPersistentStorage(); - // Register the CollapsibleFiltersInteractions class, and instantiate an instance // of it, to be called within component config. const collapsibleFiltersInteractions = new CollapsibleFilters.Interactions({ filterEls: document.querySelectorAll('.js-answersFiltersWrapper'), - resultEls: document.querySelectorAll('.js-answersResults,.js-answersFooter') + resultEls: document.querySelectorAll('.js-answersResults,.js-answersFooter,.js-yxtFooter'), + templateName: 'VerticalStandard' }); // When a search is made with the searchbar, collapse the filters. @@ -19,6 +15,9 @@ collapsibleFiltersInteractions.registerCollapseFiltersOnSearchbarSearch(); // Make the view results button sticky collapsibleFiltersInteractions.stickifyViewResultsButton(true); +// Setup the Footer so that it can properly interact with CFilters +collapsibleFiltersInteractions.setupFooter(); + // Register an instance of CollapsibleFilters.FacetsDecorator, // to decorate the Facets component with const facetsDecorator = new CollapsibleFilters.FacetsDecorator(); diff --git a/templates/vertical-standard/page-config.json b/templates/vertical-standard/page-config.json index 7305da510..c4d6d57cc 100644 --- a/templates/vertical-standard/page-config.json +++ b/templates/vertical-standard/page-config.json @@ -5,12 +5,10 @@ // "canonicalUrl": "", // The link tag for canonical URL as well as the meta tag for open graph url // "keywords": "", // The meta tag for keywords "pageSettings": { - /** "search": { "verticalKey": "", // The vertical key from your search configuration "defaultInitialSearch": "" // Enter a default search term } - **/ }, "componentSettings": { /** diff --git a/templates/vertical-standard/script/facets.hbs b/templates/vertical-standard/script/facets.hbs index 0370e6836..f681ee63b 100644 --- a/templates/vertical-standard/script/facets.hbs +++ b/templates/vertical-standard/script/facets.hbs @@ -8,5 +8,8 @@ ANSWERS.addComponent("Facets", { facetsDecorator.onMount(this); } }, + {{#if componentSettings.Facets.fields}} + transformFacets: HitchhikerJS.transformFacets, + {{/if}} ...{{{ json componentSettings.Facets }}}, }); diff --git a/templates/vertical-standard/script/navigation.hbs b/templates/vertical-standard/script/navigation.hbs index bd3042792..7f3397ba1 100644 --- a/templates/vertical-standard/script/navigation.hbs +++ b/templates/vertical-standard/script/navigation.hbs @@ -11,7 +11,7 @@ verticalPages: [ {{#with (lookup verticalsToConfig verticalKey)}} {{#if isFirst}}isFirst: {{isFirst}},{{/if}} {{#if icon}}icon: "{{{icon}}}",{{/if}} - label: "{{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}", + label: {{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}, url: {{#if url}} "{{{relativePathHandler url=url relativePath=@root.relativePath}}}", @@ -51,30 +51,12 @@ verticalPages: [ --}} {{#*inline 'verticalLabel'}} {{~#if overridedLabel ~}} - {{{overridedLabel}}} - {{~ else if - (lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName') - ~}} - {{{lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName'}}} - {{~ else if verticalKey ~}} - {{{verticalKey}}} + "{{{overridedLabel}}}" {{~ else ~}} - {{{fallback}}} + HitchhikerJS.getInjectedProp( + "{{{@root.global_config.experienceKey}}}", + ["verticals", "{{{verticalKey}}}", "displayName"]) + {{~#if verticalKey ~}} || "{{{verticalKey}}}" {{~/if ~}} + {{~#if fallback ~}} || "{{{fallback}}}" {{~/if ~}} {{~/if ~}} {{/inline}} diff --git a/templates/vertical-standard/script/verticalresults.hbs b/templates/vertical-standard/script/verticalresults.hbs index 2f1e50e28..413f471b0 100644 --- a/templates/vertical-standard/script/verticalresults.hbs +++ b/templates/vertical-standard/script/verticalresults.hbs @@ -15,7 +15,7 @@ ANSWERS.addComponent("VerticalResults", Object.assign({}, { {{#if isFirst}}isFirst: {{isFirst}},{{/if}} {{#if icon}}icon: "{{{icon}}}",{{/if}} {{#if iconUrl}}iconUrl: "{{{relativePathHandler url=iconUrl relativePath=@root.relativePath}}}",{{/if}} - label: "{{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}", + label: {{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}, url: {{#if url}} "{{{relativePathHandler url=url relativePath=@root.relativePath}}}", @@ -61,30 +61,12 @@ ANSWERS.addComponent("VerticalResults", Object.assign({}, { --}} {{#*inline 'verticalLabel'}} {{~#if overridedLabel ~}} - {{{overridedLabel}}} - {{~ else if - (lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName') - ~}} - {{{lookup - (lookup - (lookup - (lookup - @root.env.JAMBO_INJECTED_DATA.answers.experiences - @root.global_config.experienceKey) - 'verticals') - verticalKey) - 'displayName'}}} - {{~ else if verticalKey ~}} - {{{verticalKey}}} + "{{{overridedLabel}}}" {{~ else ~}} - {{{fallback}}} + HitchhikerJS.getInjectedProp( + "{{{@root.global_config.experienceKey}}}", + ["verticals", "{{{verticalKey}}}", "displayName"]) + {{~#if verticalKey ~}} || "{{{verticalKey}}}" {{~/if ~}} + {{~#if fallback ~}} || "{{{fallback}}}" {{~/if ~}} {{~/if ~}} {{/inline}} diff --git a/test-site/config/events.json b/test-site/config/events.json deleted file mode 100644 index 9cb13222a..000000000 --- a/test-site/config/events.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "verticalKey": "events", // The vertical key from your search configuration - "pageTitle": "Events", // !!!REPLACE THIS VALUE!!! The contents of the title tag and meta open graph tag for title - // "metaDescription": "", // The meta tag for open graph description - // "canonicalUrl": "", // The link tag for canonical URL as well as the meta tag for open graph url - // "keywords": "", // The meta tag for keywords - "pageSettings": { - /** - "search": { - "verticalKey": "", // The vertical key from your search configuration - "defaultInitialSearch": "" // Enter a default search term - } - **/ - }, - "componentSettings": { - /** - "QASubmission": { - "entityId": "", // Set the ID of the entity to use for Q&A submissions, must be of entity type "Organization" - "privacyPolicyUrl": "" // The fully qualified URL to the privacy policy - }, - **/ - /** - "Facets": { - "expand": false, // Allow the user to expand and collapse the facets - "showMore": false, // Display a link to see more facet options within a facet - "searchOnChange": true // Will automatically run a search as facets are selected or unselected. Set to false to only trigger updates with an Apply button. - // Additional options are available in the documentation - }, - **/ - /** - "FilterLink": { - "changeFiltersText": "sorts and filters", // Text that displays by default - "resetFiltersText": "reset filters", // Text when filters are applied - "clearSearchText": "clear search" // Text when there are no results, conducts an empty search - }, - **/ - "AppliedFilters": { - "removable": true - }, - "VerticalResults": { - "noResults": { - "displayAllResults": true // Optional, whether to display all results in the vertical when no results are found. - }, - "hideResultsHeader": true - }, - "SearchBar": { - "placeholderText": "Search", // The placeholder text in the answers search bar - "allowEmptySearch": true // Allows users to submit an empty search in the searchbar - }, - "Pagination": { - "noResults": { - "visible": true - } - } - }, - // Configuration used to define the look and feel of the vertical, both on this page and, by default, - // on the universal page. - "verticalsToConfig": { - "events": { // The vertical key from your search configuration - "label": "Events", // The name of the vertical in the section header and the navigation bar - // "verticalLimit": 15, // The result count limit for vertical search - // "universalLimit": 5, // The result count limit for universal search - "cardType": "event-standard", // The name of the card to use - e.g. accordion, location, customcard - "icon": "star", // The icon to use on the card for this vertical - "universalSectionTemplate": "standard" - } - } -} \ No newline at end of file diff --git a/test-site/config/index.json b/test-site/config/index.json index 48e7e995d..416ccf4c9 100644 --- a/test-site/config/index.json +++ b/test-site/config/index.json @@ -11,7 +11,14 @@ }, **/ "DirectAnswer": { - "defaultCard": "allfields-standard" + "types": { + "FEATURED_SNIPPET": { + "cardType": "documentsearch-standard" + }, + "FIELD_VALUE": { + "cardType": "allfields-standard" + } + } }, "SearchBar": { "placeholderText": "Search" // The placeholder text in the answers search bar diff --git a/test-site/config/locations.json b/test-site/config/locations.json index 981500b0e..2a3d6ef10 100644 --- a/test-site/config/locations.json +++ b/test-site/config/locations.json @@ -19,14 +19,14 @@ "privacyPolicyUrl": "" // The fully qualified URL to the privacy policy }, **/ - /** + "Facets": { "expand": false, // Allow the user to expand and collapse the facets "showMore": false, // Display a link to see more facet options within a facet "searchOnChange": true // Will automatically run a search as facets are selected or unselected. Set to false to only trigger updates with an Apply button. // Additional options are available in the documentation }, - **/ + /** "FilterLink": { "changeFiltersText": "sorts and filters", // Text that displays by default diff --git a/test-site/config/people.json b/test-site/config/people.json index 9a1cdcc25..7b928044a 100644 --- a/test-site/config/people.json +++ b/test-site/config/people.json @@ -23,7 +23,21 @@ "Facets": { "expand": false, // Allow the user to expand and collapse the facets "showMore": false, // Display a link to see more facet options within a facet - "searchOnChange": true // Will automatically run a search as facets are selected or unselected. Set to false to only trigger updates with an Apply button. + "searchOnChange": true, // Will automatically run a search as facets are selected or unselected. Set to false to only trigger updates with an Apply button. + "fields": { + "c_puppyPreference": { + "searchable": true, + "fieldLabels": { + "Frodo": "FRODO !!!", + "Marty": "MARTY !!!" + } + }, + "c_employeeDepartment": { + "fieldLabels": { + "Strategy": "STRATEGY !!!" + } + } + } // Additional options are available in the documentation }, diff --git a/test-site/jambo.json b/test-site/jambo.json index 02bf0db41..568147e58 100644 --- a/test-site/jambo.json +++ b/test-site/jambo.json @@ -4,7 +4,9 @@ "config": "config", "output": "public", "pages": "pages", - "partials": [], + "partials": [ + "script/on-ready.js" + ], "preservedFiles": [] }, "defaultTheme": "answers-hitchhiker-theme" diff --git a/test-site/pages/events.html.hbs b/test-site/pages/events.html.hbs deleted file mode 100644 index 20adc78b6..000000000 --- a/test-site/pages/events.html.hbs +++ /dev/null @@ -1,54 +0,0 @@ -{{#> layouts/html }} - {{#> script/core }} - {{> cards/all }} - {{!-- {{> templates/vertical-standard/collapsible-filters/page-setup }} --}} - {{> templates/vertical-standard/script/appliedfilters }} - {{> templates/vertical-standard/script/verticalresultscount }} - {{> templates/vertical-standard/script/searchbar }} - {{> templates/vertical-standard/script/spellcheck }} - {{> templates/vertical-standard/script/navigation }} - {{> templates/vertical-standard/script/verticalresults }} - {{> templates/vertical-standard/script/pagination }} - {{> templates/vertical-standard/script/locationbias }} - {{!-- {{> templates/vertical-standard/script/sortoptions }} --}} - {{!-- {{> templates/vertical-standard/script/facets }} --}} - {{!-- {{> templates/vertical-standard/script/filterbox }} --}} - {{!-- {{> templates/vertical-standard/script/qasubmission }} --}} - {{/script/core }} -
-
-
- {{> templates/vertical-standard/markup/searchbar }} - {{> templates/vertical-standard/markup/navigation }} -
-
- - - {{!-- {{> layouts/overlay-suggestions }} --}} -
-
- {{> templates/vertical-standard/markup/verticalresultscount }} - {{> templates/vertical-standard/markup/appliedfilters }} - {{!-- {{> templates/vertical-standard/collapsible-filters/markup/filterlink }} --}} - {{!-- {{> templates/vertical-standard/collapsible-filters/markup/viewresultsbutton }} --}} -
- - {{!--
--}} - {{!-- {{> templates/vertical-standard/markup/sortoptions }} --}} - {{!-- {{> templates/vertical-standard/markup/filterbox }} --}} - {{!-- {{> templates/vertical-standard/markup/facets }} --}} - {{!--
--}} -
- {{> templates/vertical-standard/markup/spellcheck }} - {{> templates/vertical-standard/markup/verticalresults }} - {{> templates/vertical-standard/markup/pagination }} - {{!-- {{> templates/vertical-standard/markup/qasubmission }} --}} -
-
- -
-{{/layouts/html }} diff --git a/test-site/pages/locations.html.hbs b/test-site/pages/locations.html.hbs index 3960dd080..64a6c9ed5 100644 --- a/test-site/pages/locations.html.hbs +++ b/test-site/pages/locations.html.hbs @@ -8,7 +8,7 @@ {{> templates/vertical-map/script/verticalresultscount }} {{> templates/vertical-map/script/appliedfilters }} {{> templates/vertical-map/script/sortoptions }} - {{!-- {{> templates/vertical-map/script/facets }} --}} + {{> templates/vertical-map/script/facets }} {{!-- {{> templates/vertical-map/script/filterbox }} --}} {{> templates/vertical-map/script/verticalresults }} {{> templates/vertical-map/script/pagination }} @@ -34,7 +34,7 @@
{{> templates/vertical-map/markup/sortoptions }} - {{!-- {{> templates/vertical-map/markup/facets }} --}} + {{> templates/vertical-map/markup/facets }} {{!-- {{> templates/vertical-map/markup/filterbox }} --}}
diff --git a/test-site/script/on-ready.js b/test-site/script/on-ready.js new file mode 100644 index 000000000..82c0fe501 --- /dev/null +++ b/test-site/script/on-ready.js @@ -0,0 +1 @@ +ANSWERS.setGeolocation(38.8955, -77.0699) \ No newline at end of file diff --git a/test-site/scripts/build.sh b/test-site/scripts/build.sh index f1d6f762c..e8db5a954 100755 --- a/test-site/scripts/build.sh +++ b/test-site/scripts/build.sh @@ -6,4 +6,13 @@ set_working_dir_to_test_site () { } set_working_dir_to_test_site -npx jambo build && grunt webpack \ No newline at end of file + +# TODO (SLAP-1066): Make this a full integration test. All vertical pages should be built with the +# `vertical` command. We should use the `card` command as well to create some custom cards. + +# Create the vertical page for events +npx jambo vertical --name events --verticalKey events --template vertical-standard --cardName event-standard +sed -i '' -e 's/\/\/ "label": ""/"label": "Events"/g' config/events.json +sed -i '' -e 's/"pageTitle": "Search"/"pageTitle": "Events"/g' config/events.json + +npx jambo build && grunt webpack diff --git a/tests/hbshelpers/chainedLookup.js b/tests/hbshelpers/chainedLookup.js new file mode 100644 index 000000000..15be2f479 --- /dev/null +++ b/tests/hbshelpers/chainedLookup.js @@ -0,0 +1,33 @@ +import chainedLookup from '../../hbshelpers/chainedLookup'; + +it('performs a chained lookup', () => { + const context = { + a: { + b: { + c: 123 + } + } + }; + expect(chainedLookup(context, 'a', 'b', 'c', {})).toEqual(123); +}); + +it('short circuits when key does not exist', () => { + const context = { + a: { + b: { + c: 123 + } + } + }; + expect(chainedLookup(context, 'a', '123', 'asdf', {})).toEqual(undefined); +}); + + +it('short circuits when intermediate key points to non-object', () => { + const context = { + a: { + b: 123 + } + }; + expect(chainedLookup(context, 'a', 'b', 'c', 'd', {})).toEqual(undefined); +}); diff --git a/tests/static/js/formatters-internal/generate-cta-field-type-link.js b/tests/static/js/formatters-internal/generate-cta-field-type-link.js new file mode 100644 index 000000000..3de5a33ab --- /dev/null +++ b/tests/static/js/formatters-internal/generate-cta-field-type-link.js @@ -0,0 +1,28 @@ +import Formatters from '../../../../static/js/formatters'; +const { generateCTAFieldTypeLink } = Formatters; + +describe('generateCtaFieldTypeLinks can handle translated link types', () => { + it('understands teléfono as Phone', () => { + const cta = { + linkType: 'teléfono', + link: 'slap' + } + expect(generateCTAFieldTypeLink(cta)).toEqual('tel:slap'); + }); + + it('understands Eメール as Email', () => { + const cta = { + linkType: 'Eメール', + link: 'slap' + } + expect(generateCTAFieldTypeLink(cta)).toEqual('mailto:slap'); + }); + + it('will pass the link through if linkType other than Email or Phone', () => { + const cta = { + linkType: 'URL', + link: 'slap' + } + expect(generateCTAFieldTypeLink(cta)).toEqual('slap'); + }); +}); \ No newline at end of file diff --git a/tests/static/js/formatters.js b/tests/static/js/formatters.js index 3046f65ae..601a46cb3 100644 --- a/tests/static/js/formatters.js +++ b/tests/static/js/formatters.js @@ -54,31 +54,31 @@ describe('Formatters', () => { describe('price', () => { const priceField = { - value: '100', + value: '100.99', currencyCode: 'USD-US Dollar' }; it('Formats a price in USD', () => { const price = Formatters.price(priceField, 'en'); - expect(price).toEqual('$100.00'); + expect(price).toEqual('$100.99'); }); it('Formats a price in USD with no provided locale', () => { const price = Formatters.price(priceField); - expect(price).toEqual('$100.00'); + expect(price).toEqual('$100.99'); }); it('Formats a price in USD with a non-en locale', () => { const price = Formatters.price(priceField, 'fr'); - expect(price).toEqual('100,00 $US'); + expect(price).toEqual('100,99 $US'); }); it('Formats a price in EUR', () => { priceField.currencyCode = 'EUR-Euro'; const price = Formatters.price(priceField); - expect(price).toEqual('€100.00'); + expect(price).toEqual('€100.99'); }); it('Formats a price in EUR with a non-en locale', () => { priceField.currencyCode = 'EUR-Euro'; const price = Formatters.price(priceField, 'fr'); - expect(price).toEqual('100,00 €'); + expect(price).toEqual('100,99 €'); }); it('Returns value when no price or currency code', () => { @@ -107,4 +107,50 @@ describe('Formatters', () => { expect(consoleWarn).toHaveBeenCalled(); }); }); + + describe('highlightField', () => { + it('Behaves correctly when there are no matchedSubstrings', () => { + const plainText = 'No more straws'; + const actual = Formatters.highlightField(plainText, []); + + expect(actual).toEqual(plainText); + }); + + it('Highlights single substring correctly', () => { + const plainText = 'No more straws'; + const matchedSubstrings = [ + { + "offset": 8, + "length": 6 + } + ]; + const actual = Formatters.highlightField(plainText, matchedSubstrings); + + const expected = 'No more straws' + expect(actual).toEqual(expected); + }); + + it('Highlights multiple substrings correctly', () => { + const plainText = 'How does mask wearing prevent COVID-19'; + const matchedSubstrings = [ + { + "offset": 9, + "length": 4 + }, + { + "offset": 30, + "length": 8 + }, + { + "offset": 14, + "length": 7 + } + ]; + const actual = Formatters.highlightField(plainText, matchedSubstrings); + + const expected = + 'How does mask wearing prevent COVID-19'; + expect(actual).toEqual(expected); + }); + }); }); diff --git a/tests/static/js/get-injected-prop.js b/tests/static/js/get-injected-prop.js new file mode 100644 index 000000000..fe2f46c50 --- /dev/null +++ b/tests/static/js/get-injected-prop.js @@ -0,0 +1,87 @@ +import { getInjectedProp } from '../../../static/js/get-injected-prop'; +import { isStaging } from '../../../static/js/is-staging'; +jest.mock('../../../static/js/is-staging.js'); + +let topLevelConfig, productionConfig, stagingConfig; +beforeEach(() => { + stagingConfig = { + apiKey: 'staging-api-key', + verticals: { + mockVertical: { + displayName: 'staging-display-name', + source: 'KNOWLEDGE_MANAGER' + } + } + }; + productionConfig = { + apiKey: 'production-api-key', + verticals: { + mockVertical: { + displayName: 'production-display-name', + source: 'KNOWLEDGE_MANAGER' + } + } + }; + topLevelConfig = { + apiKey: 'top-level-api-key', + verticals: { + mockVertical: { + displayName: 'top-level-display-name', + source: 'KNOWLEDGE_MANAGER' + } + } + }; + mockInjectedExperienceConfig({ + ...topLevelConfig, + configByLabel: { + STAGING: stagingConfig, + PRODUCTION: productionConfig + } + }); +}); + +describe('getInjectedProp()', () => { + it('returns undefined when no matching experience key found', () => { + expect(getInjectedProp('unknownKey')).toEqual(undefined); + }); + + it('will use configByLabel.PRODUCTION when isStaging() returns false', () => { + isStaging.mockImplementationOnce(() => false); + const injectedDisplayName = getInjectedProp('mockExperience', ['verticals', 'mockVertical', 'displayName']); + expect(injectedDisplayName).toEqual('production-display-name'); + }); + + it('will use configByLabel.STAGING when isStaging() returns true', () => { + isStaging.mockImplementationOnce(() => true); + const injectedDisplayName = getInjectedProp('mockExperience', ['verticals', 'mockVertical', 'displayName']); + expect(injectedDisplayName).toEqual('staging-display-name'); + }); + + it('will default to top level config when configByLabel is not found', () => { + mockInjectedExperienceConfig(topLevelConfig); + const injectedDisplayName = getInjectedProp('mockExperience', ['verticals', 'mockVertical', 'displayName']); + expect(injectedDisplayName).toEqual('top-level-display-name'); + }); + + it('will return undefined when no JAMBO_INJECTED_DATA is set', () => { + process.env.JAMBO_INJECTED_DATA = null; + const injectedDisplayName = getInjectedProp('mockExperience', ['verticals', 'mockVertical', 'displayName']); + expect(injectedDisplayName).toEqual(undefined); + }); + + it('will return undefined if a value in the propPath is not found', () => { + const injectedDisplayName = getInjectedProp('mockExperience', ['verticals', 'mockVertical', 'a', 'b', 'c']); + expect(injectedDisplayName).toEqual(undefined); + }); +}); + +function mockInjectedExperienceConfig(experienceConfig) { + const mockJamboInjectedData = { + answers: { + experiences: { + mockExperience: experienceConfig + } + } + }; + process.env.JAMBO_INJECTED_DATA = JSON.stringify(mockJamboInjectedData) +} \ No newline at end of file diff --git a/tests/static/js/is-highlighted.js b/tests/static/js/is-highlighted.js new file mode 100644 index 000000000..ce5082b3c --- /dev/null +++ b/tests/static/js/is-highlighted.js @@ -0,0 +1,23 @@ +import { isHighlighted } from 'static/js/is-highlighted.js'; + +describe('isHighlighted', () => { + const highlightedFields = { + field1: { + value: 'Some value', + matchedSubstrings: [ + { + offset: 0, + length: 4 + } + ] + } + }; + + it('returns true when field is in highlightedFields', () => { + expect(isHighlighted('field1', highlightedFields)).toBeTruthy(); + }); + + it('returns false when field is not in highlightedFields', () => { + expect(isHighlighted('field2', highlightedFields)).toBeFalsy(); + }); +}); diff --git a/tests/static/js/theme-map/Util/helpers.js b/tests/static/js/theme-map/Util/helpers.js new file mode 100644 index 000000000..e45250ef7 --- /dev/null +++ b/tests/static/js/theme-map/Util/helpers.js @@ -0,0 +1,54 @@ +import { getNormalizedLongitude } from 'static/js/theme-map/Util/helpers.js'; + +describe('getNormalizedLongitude', () => { + describe('it works within normal longitude bounds', () => { + it('works with 0', () => { + expect(getNormalizedLongitude(0)).toEqual(0); + }); + + it('works with the boundaries', () => { + expect(getNormalizedLongitude(179)).toEqual(179); + expect(getNormalizedLongitude(180)).toEqual(180); + expect(getNormalizedLongitude(-179)).toEqual(-179); + expect(getNormalizedLongitude(-180)).toEqual(-180); + }); + + it('works with numbers between boundaries', () => { + expect(getNormalizedLongitude(1)).toEqual(1); + expect(getNormalizedLongitude(-1)).toEqual(-1); + expect(getNormalizedLongitude(60)).toEqual(60); + expect(getNormalizedLongitude(-60)).toEqual(-60); + }); + }); + + describe('it works outside of normal longitude bounds', () => { + it('works with the boundaries', () => { + expect(getNormalizedLongitude(180.5)).toEqual(-179.5); + expect(getNormalizedLongitude(181)).toEqual(-179); + + expect(getNormalizedLongitude(-180.5)).toEqual(179.5); + expect(getNormalizedLongitude(-181)).toEqual(179); + }); + + it('works with large numbers', () => { + expect(getNormalizedLongitude(780.5)).toEqual(60.5); + expect(getNormalizedLongitude(-780.5)).toEqual(-60.5); + expect(getNormalizedLongitude(190)).toEqual(-170); + expect(getNormalizedLongitude(-190)).toEqual(170); + }); + + it('works with multiples of the boundaries', () => { + expect(getNormalizedLongitude(270)).toEqual(-90); + expect(getNormalizedLongitude(360)).toEqual(0); + expect(getNormalizedLongitude(450)).toEqual(90); + expect(getNormalizedLongitude(540)).toEqual(-180); + expect(getNormalizedLongitude(720)).toEqual(0); + + expect(getNormalizedLongitude(-270)).toEqual(90); + expect(getNormalizedLongitude(-360)).toEqual(0); + expect(getNormalizedLongitude(-450)).toEqual(-90); + expect(getNormalizedLongitude(-540)).toEqual(-180); + expect(getNormalizedLongitude(-720)).toEqual(0); + }); + }); +}); diff --git a/tests/static/js/transform-facets.js b/tests/static/js/transform-facets.js new file mode 100644 index 000000000..c498f9344 --- /dev/null +++ b/tests/static/js/transform-facets.js @@ -0,0 +1,122 @@ +import transformFacets from '../../../static/js/transform-facets'; + +const defaultOption = { + displayName: 'Breakfast', + count: 3, + matcher: '$eq', + selected: false, + value: 'breakfast' +} + +const defaultFacets = [{ + fieldId: 'c_mealType', + displayName: 'Meal type', + options: [defaultOption] +}] + +it('can specify both filterOptionsConfig values and fieldLabels', () => { + const facetsConfig = { + fields: { + c_mealType: { + searchable: true, + placeholderText: 'Search...', + fieldLabels: { + Breakfast: 'BREAKFAST!!!' + } + } + } + } + const expectedTransformedFacets = [{ + displayName: "Meal type", + fieldId: "c_mealType", + searchable: true, + placeholderText: 'Search...', + options: [{ + ...defaultOption, + displayName: "BREAKFAST!!!", + }] + }]; + const actualTransformedFacets = transformFacets(defaultFacets, facetsConfig); + expect(actualTransformedFacets).toEqual(expectedTransformedFacets); +}); + +it('can specify filterOptionsConfig values', () => { + const facetsConfig = { + fields: { + c_mealType: { + searchable: true, + placeholderText: 'Search', + showMoreLimit: 5 + } + } + } + const expectedTransformedFacets = [{ + displayName: "Meal type", + fieldId: "c_mealType", + options: [defaultOption], + placeholderText: "Search", + searchable: true, + showMoreLimit: 5 + }]; + const actualTransformedFacets = transformFacets(defaultFacets, facetsConfig); + expect(actualTransformedFacets).toEqual(expectedTransformedFacets); +}); + +it('fieldLabels updates option displayNames', () => { + const facets = [{ + fieldId: 'c_mealType', + displayName: 'Meal type', + options: [{ + ...defaultOption, + displayName: 'Breakfast', + value: 'breakfast' + }, { + ...defaultOption, + displayName: 'Lunch', + value: 'lunch' + }, { + ...defaultOption, + displayName: 'Dinner', + value: 'dinner' + }] + }] + const facetsConfig = { + fields: { + c_mealType: { + fieldLabels: { + Breakfast: 'BREAKFAST!!!', + Lunch: 'LUNCH!!!' + } + } + } + } + const expectedTransformedFacets = [{ + displayName: "Meal type", + fieldId: "c_mealType", + options: [{ + ...defaultOption, + displayName: "BREAKFAST!!!", + value: 'breakfast' + }, { + ...defaultOption, + displayName: 'LUNCH!!!', + value: 'lunch' + }, { + ...defaultOption, + displayName: 'Dinner', + value: 'dinner' + }] + }]; + const actualTransformedFacets = transformFacets(facets, facetsConfig); + expect(actualTransformedFacets).toEqual(expectedTransformedFacets); +}); + +it('facets do not change if fields is not specified in in the config', () => { + const facets = [{ + displayName: "Meal type", + fieldId: "c_mealType", + options: [defaultOption] + }]; + const actualTransformedFacets = transformFacets(facets, {}); + expect(actualTransformedFacets).toEqual(facets); +}); \ No newline at end of file diff --git a/tests/templates/script/navigation.js b/tests/templates/script/navigation.js index 4259f966c..2978f731b 100644 --- a/tests/templates/script/navigation.js +++ b/tests/templates/script/navigation.js @@ -6,13 +6,12 @@ const pageTemplates = [ 'universal-standard', 'vertical-grid', 'vertical-standard', - 'vertical-map' + 'vertical-map', + 'vertical-full-page-map' ]; for (const pageTemplate of pageTemplates) { - const templatePath = path.resolve(__dirname, `../../../templates/${pageTemplate}/script/navigation.hbs`); - const navigationConfigTemplate = fs.readFileSync(templatePath, 'utf-8'); - const compiledTemplate = hbs.compile(navigationConfigTemplate); + const compiledTemplate = getCompiledNavigationTemplate(pageTemplate); describe(`uses relativePath correctly (${pageTemplate})`, () => { describe('with url set in a vertical page\'s verticalsToConfig', () => { @@ -29,13 +28,7 @@ for (const pageTemplate of pageTemplates) { } } }; - const output = compiledTemplate(templateData); - const ANSWERS = { - addComponent: jest.fn() - }; - eval(output); - const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; - const verticalPage = componentConfig.verticalPages[0]; + const verticalPage = evalComponentConfig(compiledTemplate, templateData).verticalPages[0]; expect(verticalPage.url).toEqual('../../vtc.html'); }); @@ -52,13 +45,7 @@ for (const pageTemplate of pageTemplates) { } } }; - const output = compiledTemplate(templateData); - const ANSWERS = { - addComponent: jest.fn() - }; - eval(output); - const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; - const verticalPage = componentConfig.verticalPages[0]; + const verticalPage = evalComponentConfig(compiledTemplate, templateData).verticalPages[0]; expect(verticalPage.url).toEqual('../../top-level.html'); }); @@ -74,14 +61,107 @@ for (const pageTemplate of pageTemplates) { } } }; - const output = compiledTemplate(templateData); - const ANSWERS = { - addComponent: jest.fn() - }; - eval(output); - const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; - const verticalPage = componentConfig.verticalPages[0]; + const verticalPage = evalComponentConfig(compiledTemplate, templateData).verticalPages[0]; expect(verticalPage.url).toEqual('people.html'); }); }); + + describe(`handles verticalLabel correctly (${pageTemplate})`, () => { + it('allows users to override the label', () => { + const templateData = { + verticalConfigs: { + people: { + verticalKey: 'peopleKey', + verticalsToConfig: { + peopleKey: { + label: 'overridden label!' + } + } + } + } + }; + const verticalPage = evalComponentConfig(compiledTemplate, templateData, jest.fn()).verticalPages[0]; + expect(verticalPage.label).toEqual('overridden label!'); + }); + + it('will use HitchhikerJS.getInjectedProp by default', () => { + const templateData = { + global_config: { + experienceKey: 'mockExperienceKey', + }, + verticalConfigs: { + people: { + verticalKey: 'peopleKey', + verticalsToConfig: { + peopleKey: {} + } + } + } + } + const mockGetInjectedProp = jest.fn(() => 'injected vertical label'); + const verticalPage = evalComponentConfig(compiledTemplate, templateData, mockGetInjectedProp).verticalPages[0]; + expect(mockGetInjectedProp).toHaveBeenLastCalledWith('mockExperienceKey', ['verticals', 'peopleKey', 'displayName']); + expect(verticalPage.label).toEqual('injected vertical label'); + }); + + it('will default to verticalKey if no injected vertical label', () => { + const templateData = { + global_config: { + experienceKey: 'mockExperienceKey', + }, + verticalConfigs: { + people: { + verticalKey: 'peopleKey', + verticalsToConfig: { + peopleKey: {} + } + } + } + } + const mockGetInjectedProp = jest.fn(() => undefined); + const verticalPage = evalComponentConfig(compiledTemplate, templateData, mockGetInjectedProp).verticalPages[0]; + expect(mockGetInjectedProp).toHaveBeenLastCalledWith('mockExperienceKey', ['verticals', 'peopleKey', 'displayName']); + expect(verticalPage.label).toEqual('peopleKey'); + }); + + it('will use label for universal pages', () => { + const templateData = { + global_config: { + experienceKey: 'mockExperienceKey', + }, + verticalConfigs: { + index: { + verticalsToConfig: { + Universal: { + label: 'universal label' + } + } + } + } + } + const mockGetInjectedProp = jest.fn(() => undefined); + const verticalPage = evalComponentConfig(compiledTemplate, templateData, mockGetInjectedProp).verticalPages[0]; + expect(mockGetInjectedProp).toHaveBeenCalledTimes(0); + expect(verticalPage.label).toEqual('universal label'); + }); + }); +} + +function evalComponentConfig(compiledTemplate, templateData, mockGetInjectedProp) { + const output = compiledTemplate(templateData); + const ANSWERS = { + addComponent: jest.fn() + }; + const HitchhikerJS = { + getInjectedProp: mockGetInjectedProp || jest.fn() + }; + eval(output); + const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; + return componentConfig; +} + +function getCompiledNavigationTemplate(pageTemplate) { + const templatePath = path.resolve(__dirname, `../../../templates/${pageTemplate}/script/navigation.hbs`); + const navigationConfigTemplate = fs.readFileSync(templatePath, 'utf-8'); + return hbs.compile(navigationConfigTemplate); } \ No newline at end of file diff --git a/tests/templates/script/universalresults.js b/tests/templates/script/universalresults.js index e730f6317..1baba668f 100644 --- a/tests/templates/script/universalresults.js +++ b/tests/templates/script/universalresults.js @@ -2,11 +2,7 @@ const fs = require('fs'); const path = require('path'); const hbs = require('../../test-utils/hbs'); -const universalResultsPath = - path.resolve(__dirname, '../../../templates/universal-standard/script/universalresults.hbs'); -const universalResultsTemplate = fs.readFileSync(universalResultsPath, 'utf-8'); -const compiledTemplate = hbs.compile(universalResultsTemplate); - +const compiledTemplate = getCompiledUniversalResultsTemplate(); hbs.registerHelper('read', () => { return 'mock universal section template' }); @@ -15,6 +11,9 @@ describe('uses relativePath correctly', () => { describe('with url set in a vertical page\'s verticalsToConfig', () => { const templateData = { relativePath: '../..', + global_config: { + experienceKey: 'mockExperienceKey', + }, verticalConfigs: { people: { verticalKey: 'people', @@ -28,14 +27,8 @@ describe('uses relativePath correctly', () => { } } }; - const output = compiledTemplate(templateData); - const ANSWERS = { - addComponent: jest.fn() - }; - eval(output); - const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; - const peopleConfig = componentConfig.config.people; - + const peopleConfig = evalComponentConfig(templateData).config.people; + it('verticalPages and url', () => { expect(peopleConfig.verticalPages).toEqual([{ verticalKey: 'people', @@ -52,6 +45,9 @@ describe('uses relativePath correctly', () => { it('with url set in a vertical page\'s top level config', () => { const templateData = { relativePath: '../..', + global_config: { + experienceKey: 'mockExperienceKey', + }, verticalConfigs: { people: { url: 'page-path.html', @@ -64,14 +60,7 @@ describe('uses relativePath correctly', () => { } } }; - const output = compiledTemplate(templateData); - const ANSWERS = { - addComponent: jest.fn() - }; - eval(output); - const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; - const peopleConfig = componentConfig.config.people; - + const peopleConfig = evalComponentConfig(templateData).config.people; expect(peopleConfig.verticalPages).toEqual([{ verticalKey: 'people', pageUrl: '../../page-path.html', @@ -82,6 +71,9 @@ describe('uses relativePath correctly', () => { it('will default url to {{pageName}}.html', () => { const templateData = { relativePath: '../..', + global_config: { + experienceKey: 'mockExperienceKey', + }, verticalConfigs: { people: { verticalKey: 'people', @@ -93,14 +85,7 @@ describe('uses relativePath correctly', () => { } } }; - const output = compiledTemplate(templateData); - const ANSWERS = { - addComponent: jest.fn() - }; - eval(output); - const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; - const peopleConfig = componentConfig.config.people; - + const peopleConfig = evalComponentConfig(templateData).config.people; expect(peopleConfig.verticalPages).toEqual([{ verticalKey: 'people', url: 'people.html', @@ -108,3 +93,90 @@ describe('uses relativePath correctly', () => { expect(peopleConfig.url).toEqual('people.html'); }); }); + +describe('handles verticalLabel correctly', () => { + it('lets you override verticalLabel', () => { + const templateData = { + global_config: { + experienceKey: 'mockExperienceKey', + }, + verticalConfigs: { + people: { + verticalKey: 'people', + verticalsToConfig: { + people: { + label: 'overridden!', + viewAllText: 'test view all' + } + } + } + } + } + const peopleConfig = evalComponentConfig(templateData).config.people; + expect(peopleConfig.sectionTitle).toEqual('overridden!'); + }); + + it('will use HitchhikerJS.getInjectedProp by default', () => { + const templateData = { + global_config: { + experienceKey: 'mockExperienceKey', + }, + verticalConfigs: { + people: { + verticalKey: 'people', + verticalsToConfig: { + people: { + viewAllText: 'test view all' + } + } + } + } + } + const mockGetInjectedProp = jest.fn(() => 'injected vertical label'); + const peopleConfig = evalComponentConfig(templateData, mockGetInjectedProp).config.people; + expect(mockGetInjectedProp).toHaveBeenLastCalledWith('mockExperienceKey', ['verticals', 'people', 'displayName']); + expect(peopleConfig.sectionTitle).toEqual('injected vertical label'); + }); + + it('will use verticalKey if no injected verticalLabel', () => { + const templateData = { + global_config: { + experienceKey: 'mockExperienceKey', + }, + verticalConfigs: { + people: { + verticalKey: 'peopleKey', + verticalsToConfig: { + peopleKey: { + viewAllText: 'test view all' + } + } + } + } + } + const mockGetInjectedProp = jest.fn(() => null); + const peopleConfig = evalComponentConfig(templateData, mockGetInjectedProp).config.peopleKey; + expect(mockGetInjectedProp).toHaveBeenLastCalledWith('mockExperienceKey', ['verticals', 'peopleKey', 'displayName']); + expect(peopleConfig.sectionTitle).toEqual('peopleKey'); + }); +}); + +function evalComponentConfig(templateData, mockGetInjectedProp) { + const output = compiledTemplate(templateData); + const ANSWERS = { + addComponent: jest.fn() + }; + const HitchhikerJS = { + getInjectedProp: mockGetInjectedProp || jest.fn() + }; + eval(output); + const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; + return componentConfig; +} + +function getCompiledUniversalResultsTemplate() { + const universalResultsPath = + path.resolve(__dirname, '../../../templates/universal-standard/script/universalresults.hbs'); + const universalResultsTemplate = fs.readFileSync(universalResultsPath, 'utf-8'); + return hbs.compile(universalResultsTemplate); +} \ No newline at end of file diff --git a/tests/templates/script/verticalresults.js b/tests/templates/script/verticalresults.js index 0c07e75a8..8548a1334 100644 --- a/tests/templates/script/verticalresults.js +++ b/tests/templates/script/verticalresults.js @@ -5,13 +5,12 @@ const hbs = require('../../test-utils/hbs'); const pageTemplates = [ 'vertical-grid', 'vertical-standard', - 'vertical-map' + 'vertical-map', + 'vertical-full-page-map' ]; for (const pageTemplate of pageTemplates) { - const templatePath = path.resolve(__dirname, `../../../templates/${pageTemplate}/script/verticalresults.hbs`); - const verticalResultsConfigTemplate = fs.readFileSync(templatePath, 'utf-8'); - const compiledTemplate = hbs.compile(verticalResultsConfigTemplate); + const compiledTemplate = getCompiledVerticalResultsTemplate(pageTemplate); describe(`uses relativePath correctly (${pageTemplate})`, () => { describe('for vertical pages with url at the top level page config', () => { @@ -29,13 +28,7 @@ for (const pageTemplate of pageTemplates) { } } }; - const output = compiledTemplate(templateData); - const ANSWERS = { - addComponent: jest.fn() - }; - eval(output); - const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; - const verticalPage = componentConfig.verticalPages[0]; + const verticalPage = evalComponentConfig(compiledTemplate, templateData).verticalPages[0]; it('iconUrl correctly uses relativePath', () => { expect(verticalPage.iconUrl).toEqual('../../static/assets/icon.gif'); @@ -60,13 +53,7 @@ for (const pageTemplate of pageTemplates) { } } }; - const output = compiledTemplate(templateData); - const ANSWERS = { - addComponent: jest.fn() - }; - eval(output); - const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; - const verticalPage = componentConfig.verticalPages[0]; + const verticalPage = evalComponentConfig(compiledTemplate, templateData).verticalPages[0]; expect(verticalPage.url).toEqual('../../vtc.html'); }); @@ -82,13 +69,7 @@ for (const pageTemplate of pageTemplates) { } } }; - const output = compiledTemplate(templateData); - const ANSWERS = { - addComponent: jest.fn() - }; - eval(output); - const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; - const verticalPage = componentConfig.verticalPages[0]; + const verticalPage = evalComponentConfig(compiledTemplate, templateData).verticalPages[0]; expect(verticalPage.url).toEqual('people.html'); }); @@ -105,13 +86,7 @@ for (const pageTemplate of pageTemplates) { } } }; - const output = compiledTemplate(templateData); - const ANSWERS = { - addComponent: jest.fn() - }; - eval(output); - const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; - const verticalPage = componentConfig.verticalPages[0]; + const verticalPage = evalComponentConfig(compiledTemplate, templateData).verticalPages[0]; expect(verticalPage.url).toEqual('../../index-page.html'); }); @@ -128,13 +103,7 @@ for (const pageTemplate of pageTemplates) { } } }; - const output = compiledTemplate(templateData); - const ANSWERS = { - addComponent: jest.fn() - }; - eval(output); - const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; - const verticalPage = componentConfig.verticalPages[0]; + const verticalPage = evalComponentConfig(compiledTemplate, templateData).verticalPages[0]; expect(verticalPage.url).toEqual('../../index-page-vtc.html'); }); @@ -149,15 +118,108 @@ for (const pageTemplate of pageTemplates) { } } }; - const output = compiledTemplate(templateData); - const ANSWERS = { - addComponent: jest.fn() - }; - eval(output); - const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; - const verticalPage = componentConfig.verticalPages[0]; + const verticalPage = evalComponentConfig(compiledTemplate, templateData).verticalPages[0]; expect(verticalPage.url).toEqual('index.html'); }); }); }); + + describe(`handles verticalLabel correctly (${pageTemplate})`, () => { + it('allows users to override the label', () => { + const templateData = { + verticalConfigs: { + people: { + verticalKey: 'peopleKey', + verticalsToConfig: { + peopleKey: { + label: 'overridden label!' + } + } + } + } + }; + const verticalPage = evalComponentConfig(compiledTemplate, templateData, jest.fn()).verticalPages[0]; + expect(verticalPage.label).toEqual('overridden label!'); + }); + + it('will use HitchhikerJS.getInjectedProp by default', () => { + const templateData = { + global_config: { + experienceKey: 'mockExperienceKey', + }, + verticalConfigs: { + people: { + verticalKey: 'peopleKey', + verticalsToConfig: { + peopleKey: {} + } + } + } + } + const mockGetInjectedProp = jest.fn(() => 'injected vertical label'); + const verticalPage = evalComponentConfig(compiledTemplate, templateData, mockGetInjectedProp).verticalPages[0]; + expect(mockGetInjectedProp).toHaveBeenLastCalledWith('mockExperienceKey', ['verticals', 'peopleKey', 'displayName']); + expect(verticalPage.label).toEqual('injected vertical label'); + }); + + it('will default to verticalKey if no injected vertical label', () => { + const templateData = { + global_config: { + experienceKey: 'mockExperienceKey', + }, + verticalConfigs: { + people: { + verticalKey: 'peopleKey', + verticalsToConfig: { + peopleKey: {} + } + } + } + } + const mockGetInjectedProp = jest.fn(() => undefined); + const verticalPage = evalComponentConfig(compiledTemplate, templateData, mockGetInjectedProp).verticalPages[0]; + expect(mockGetInjectedProp).toHaveBeenLastCalledWith('mockExperienceKey', ['verticals', 'peopleKey', 'displayName']); + expect(verticalPage.label).toEqual('peopleKey'); + }); + + it('will use label for universal pages', () => { + const templateData = { + global_config: { + experienceKey: 'mockExperienceKey', + }, + verticalConfigs: { + index: { + verticalsToConfig: { + Universal: { + label: 'universal label' + } + } + } + } + } + const mockGetInjectedProp = jest.fn(() => undefined); + const verticalPage = evalComponentConfig(compiledTemplate, templateData, mockGetInjectedProp).verticalPages[0]; + expect(mockGetInjectedProp).toHaveBeenCalledTimes(0); + expect(verticalPage.label).toEqual('universal label'); + }); + }); +} + +function evalComponentConfig(compiledTemplate, templateData, mockGetInjectedProp) { + const output = compiledTemplate(templateData); + const ANSWERS = { + addComponent: jest.fn() + }; + const HitchhikerJS = { + getInjectedProp: mockGetInjectedProp || jest.fn() + }; + eval(output); + const componentConfig = ANSWERS.addComponent.mock.calls[0][1]; + return componentConfig; +} + +function getCompiledVerticalResultsTemplate(pageTemplate) { + const templatePath = path.resolve(__dirname, `../../../templates/${pageTemplate}/script/verticalresults.hbs`); + const verticalResultsConfigTemplate = fs.readFileSync(templatePath, 'utf-8'); + return hbs.compile(verticalResultsConfigTemplate); } \ No newline at end of file diff --git a/tests/test-utils/hbs.js b/tests/test-utils/hbs.js index 9d1323f39..b035046ff 100644 --- a/tests/test-utils/hbs.js +++ b/tests/test-utils/hbs.js @@ -21,8 +21,8 @@ function registerCustomHbsHelpers(hbs, pathToCustomHelpers) { try { hbs.registerHelper(helperName, require(filePath)); } catch (err) { - throw new UserError( - `Could not register handlebars helper from file ${path}`, err.stack); + throw new Error( + `Could not register handlebars helper from file ${filePath}`, err.stack); } }); return hbs; diff --git a/theme-components/collapsible-filters/filter-link/component.js b/theme-components/collapsible-filters/filter-link/component.js index e7b6f11d5..57897b765 100644 --- a/theme-components/collapsible-filters/filter-link/component.js +++ b/theme-components/collapsible-filters/filter-link/component.js @@ -15,18 +15,22 @@ const DEFAULT_FILTER_LINK_CONFIG = { class FilterLink extends ANSWERS.Component { constructor(config, systemConfig = {}) { super({ ...DEFAULT_FILTER_LINK_CONFIG, ...config }, systemConfig); - this.core.globalStorage.on('update', 'vertical-results', data => { - if (data.searchState === 'search-complete') { - this.setState(); + this.core.storage.registerListener({ + eventType: 'update', + storageKey: 'vertical-results', + callback: data => { + if (data.searchState === 'search-complete') { + this.setState(); + } } - }); + }) this.onClickClearSearch = this._config.onClickClearSearch.bind(this); this.onClickResetFilters = this._config.onClickResetFilters.bind(this); this.onClickChangeFilters = this._config.onClickChangeFilters.bind(this); } setState(data = {}) { - const verticalResults = this.core.globalStorage.getState('vertical-results') || {}; + const verticalResults = this.core.storage.get('vertical-results') || {}; return super.setState({ ...this.getState(), ...data, diff --git a/theme-components/collapsible-filters/view-results-button/component.js b/theme-components/collapsible-filters/view-results-button/component.js index 5b8f89e8b..8f0ca5f4a 100644 --- a/theme-components/collapsible-filters/view-results-button/component.js +++ b/theme-components/collapsible-filters/view-results-button/component.js @@ -10,9 +10,13 @@ const DEFAULT_VIEW_RESULTS_BUTTON_CONFIG = { class ViewResultsButton extends ANSWERS.Component { constructor(config = {}, systemConfig = {}) { super({ ...DEFAULT_VIEW_RESULTS_BUTTON_CONFIG, ...config }, systemConfig); - this.core.globalStorage.on('update', 'vertical-results', data => { - if (data.searchState === 'search-complete') { - this.setState(data); + this.core.storage.registerListener({ + eventType: 'update', + storageKey: 'vertical-results', + callback: data => { + if (data.searchState === 'search-complete') { + this.setState(data); + } } }); } @@ -29,7 +33,7 @@ class ViewResultsButton extends ANSWERS.Component { ...this.getState(), ...data, isNoResults: data.resultsContext === 'no-results', - verticalKey: this.core.globalStorage.getState('search-config').verticalKey + verticalKey: this.core.storage.get('search-config').verticalKey }); } diff --git a/theme-components/theme-map/script.js b/theme-components/theme-map/script.js new file mode 100644 index 000000000..e4de57d83 --- /dev/null +++ b/theme-components/theme-map/script.js @@ -0,0 +1,2 @@ +ANSWERS.registerTemplate('theme-components/theme-map', ''); +ANSWERS.registerComponentType(VerticalFullPageMap.ThemeMap); diff --git a/theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs b/theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs new file mode 100644 index 000000000..df57e822b --- /dev/null +++ b/theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs @@ -0,0 +1,52 @@ +
+
+ {{translate phrase='No results found in [[currentVerticalLabel]].' currentVerticalLabel=currentVerticalLabel escapeHTML=false}} + {{#if isShowingResults}} + {{translate phrase='Showing all [[currentVerticalLabel]] instead.' currentVerticalLabel=currentVerticalLabel escapeHTML=false}} + {{/if}} +
+ {{#if (all verticalSuggestions.length query)}} +
+
+ {{translate phrase='The following search category yielded results for "[[query]]":' pluralForm='The following search categories yielded results for "[[query]]":' count=verticalSuggestions.length query=query escapeHTML=false}} +
+ + {{#if universalUrl}} +
+ {{translate phrase='Alternatively, you can view results across all search categories.' universalUrl=universalUrl escapeHTML=false}} +
+ {{/if}} +
+ {{/if}} +
diff --git a/theme-components/vertical-full-page-map/script.js b/theme-components/vertical-full-page-map/script.js new file mode 100644 index 000000000..5c3353d19 --- /dev/null +++ b/theme-components/vertical-full-page-map/script.js @@ -0,0 +1,84 @@ +ANSWERS.registerTemplate('theme-components/vertical-full-page-map', ''); +ANSWERS.registerComponentType(VerticalFullPageMap.VerticalFullPageMapOrchestrator); +ANSWERS.addComponent('VerticalFullPageMapOrchestrator', Object.assign({}, +{ + container: '.js-answersVerticalFullPageMap', + {{#unless (chainedLookup verticalsToConfig verticalKey 'mapConfig' 'clientId')}} + apiKey: HitchhikerJS.getDefaultMapApiKey( + {{#if componentSettings.Map.mapProvider}} + "{{componentSettings.Map.mapProvider}}" + {{else}} + {{#with (lookup verticalsToConfig verticalKey)}} + {{#if mapConfig}} + "{{mapConfig.mapProvider}}" + {{/if}} + {{/with}} + {{/if}} + ), + {{/unless}} + pageSettings: {{{ json pageSettings }}}, + onPinSelect: () => { + window.collapsibleFiltersInteractions && window.collapsibleFiltersInteractions.collapseFilters(); + }, + locale: "{{global_config.locale}}", + verticalKey: "{{{verticalKey}}}", + verticalPages: [ + {{#each verticalConfigs}} + {{#if verticalKey}} + { + verticalKey: "{{{verticalKey}}}", + {{#each ../excludedVerticals}}{{#ifeq this ../verticalKey}}hideInNavigation: true,{{/ifeq}}{{/each}} + {{#ifeq ../verticalKey verticalKey}}isActive: true,{{/ifeq}} + {{#with (lookup verticalsToConfig verticalKey)}} + {{#if isFirst}}isFirst: {{isFirst}},{{/if}} + {{#if icon}}icon: "{{{icon}}}",{{/if}} + {{#if iconUrl}}iconUrl: "{{#unless (isNonRelativeUrl iconUrl)}}{{relativePath}}/{{/unless}}{{{iconUrl}}}",{{/if}} + label: {{> verticalLabel overridedLabel=label verticalKey=../verticalKey fallback=@key}}, + url: "{{#if url}}{{{url}}}{{else if ../url}}{{../../relativePath}}/{{{../url}}}{{else}}{{{@key}}}.html{{/if}}", + {{/with}} + }{{#unless @last}},{{/unless}} + {{else}} + { + {{#with (lookup verticalsToConfig "Universal")}} + {{#if isFirst}}isFirst: {{isFirst}},{{/if}} + {{#if icon}}icon: "{{{icon}}}",{{/if}} + label: {{#if label}}"{{{label}}}"{{else}}"{{{@key}}}"{{/if}}, + url: "{{#if url}}{{{url}}}{{else if ../url}}{{../../relativePath}}/{{{../url}}}{{else}}{{{@key}}}.html{{/if}}", + {{/with}} + }{{#unless @last}},{{/unless}} + {{/if}} + {{/each}} + ], + alternativeVerticalsConfig: Object.assign({}, + { + template: {{{ stringifyPartial (read 'theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals') }}} + }, + {{{ json componentSettings.AlternativeVerticals }}}, + ), +}, + {{#with (lookup verticalsToConfig verticalKey)}} + {{#if mapConfig}} + {{{json mapConfig}}}, + {{/if}} + {{/with}} + {{{ json componentSettings.VerticalFullPageMap }}}, +)); + +{{!-- + Prints the vertical label according to specific logic + Assumes @root has environment variables and global_config + @param overridedLabel The hardcoded label from configuration in repo, meant to supercede defaults + @param verticalKey The current vertical key, if it exists + @param fallback The fallback for the label if all else doesn't exist +--}} +{{#*inline 'verticalLabel'}} + {{~#if overridedLabel ~}} + "{{{overridedLabel}}}" + {{~ else ~}} + HitchhikerJS.getInjectedProp( + "{{{@root.global_config.experienceKey}}}", + ["verticals", "{{{verticalKey}}}", "displayName"]) + {{~#if verticalKey ~}} || "{{{verticalKey}}}" {{~/if ~}} + {{~#if fallback ~}} || "{{{fallback}}}" {{~/if ~}} + {{~/if ~}} +{{/inline}} diff --git a/translations/de.po b/translations/de.po index a8d1711b7..31db24424 100755 --- a/translations/de.po +++ b/translations/de.po @@ -113,4 +113,67 @@ msgstr "Sortieren und Filter" msgid "View 1 Result" msgid_plural "View [[count]] Results" msgstr[0] "1 Ergebnis anzeigen" -msgstr[1] "[[count]] Ergebnisse anzeigen" \ No newline at end of file +msgstr[1] "[[count]] Ergebnisse anzeigen" + +#: cards/multilang-financial-professional-location/template.hbs:195 +#: cards/multilang-location-standard/template.hbs:206 +#: cards/multilang-professional-location/template.hbs:192 +msgctxt "Close is a verb" +msgid "Close Card" +msgstr "Karte schließen" + +#: templates/vertical-full-page-map/markup/searchthisareatoggle.hbs:10 +msgctxt "A button that conducts a search in the current map area" +msgid "Search When Map Moves" +msgstr "Suchergebnisse beim Verschieben der Karte aktualisieren" + +#: templates/vertical-full-page-map/markup/searchthisareabutton.hbs:3 +msgctxt "A toggle for automatically searching when the map moves" +msgid "Search This Area" +msgstr "In diesem Bereich suchen" + +#: templates/vertical-full-page-map/markup/mobilelisttoggles.hbs:5 +msgctxt "The label of a toggle for displaying a list of results" +msgid "List" +msgstr "Liste" + +#: templates/vertical-full-page-map/markup/mobilelisttoggles.hbs:9 +msgctxt "The label of a toggle for viewing a visual map" +msgid "Map" +msgstr "Karte" + +#: templates/vertical-interactive-map/page.html.hbs:23 +msgid "Main location search" +msgstr "Hauptstandortsuche" + +#: templates/vertical-interactive-map/page.html.hbs:64 +msgid "Map" +msgstr "Plan" + +#: templates/vertical-interactive-map/page.html.hbs:59 +msgid "Map controls" +msgstr "Kartensteuerungselemente" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:3 +msgid "No results found in [[currentVerticalLabel]]." +msgstr "Keine Ergebnisse gefunden in [[currentVerticalLabel]]." + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:5 +msgid "Showing all [[currentVerticalLabel]] instead." +msgstr "Stattdessen werden alle [[currentVerticalLabel]] gezeigt." + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:11 +msgid "The following search category yielded results for \"[[query]]\":" +msgid_plural "The following search categories yielded results for \"[[query]]\":" +msgstr[0] "Die folgenden Kategorie liefert Suchergebnisse für \"[[query]]\":" +msgstr[1] "Die folgenden Kategorien liefern Suchergebnisse für \"[[query]]\":" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:32 +msgid "([[resultsCount]] result)" +msgid_plural "([[resultsCount]] results)" +msgstr[0] "[[resultsCount]] Ergebnis" +msgstr[1] "[[resultsCount]] Ergebnisse" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:47 +msgid "Alternatively, you can view results across all search categories." +msgstr "Sehen Sie alternativ Ergebnisse aus allen Kategorien." diff --git a/translations/es.po b/translations/es.po index 8ef4e09bc..a659dd869 100755 --- a/translations/es.po +++ b/translations/es.po @@ -113,4 +113,67 @@ msgstr "ordenaciones y filtros" msgid "View 1 Result" msgid_plural "View [[count]] Results" msgstr[0] "Ver 1 resultado" -msgstr[1] "Ver [[count]] resultados" \ No newline at end of file +msgstr[1] "Ver [[count]] resultados" + +#: cards/multilang-financial-professional-location/template.hbs:195 +#: cards/multilang-location-standard/template.hbs:206 +#: cards/multilang-professional-location/template.hbs:192 +msgctxt "Close is a verb" +msgid "Close Card" +msgstr "Cerrar tarjeta" + +#: templates/vertical-full-page-map/markup/searchthisareatoggle.hbs:10 +msgctxt "A button that conducts a search in the current map area" +msgid "Search When Map Moves" +msgstr "Buscar cuando el mapa se mueve" + +#: templates/vertical-full-page-map/markup/searchthisareabutton.hbs:3 +msgctxt "A toggle for automatically searching when the map moves" +msgid "Search This Area" +msgstr "Buscar en esta zona" + +#: templates/vertical-full-page-map/markup/mobilelisttoggles.hbs:5 +msgctxt "The label of a toggle for displaying a list of results" +msgid "List" +msgstr "Lista" + +#: templates/vertical-full-page-map/markup/mobilelisttoggles.hbs:9 +msgctxt "The label of a toggle for viewing a visual map" +msgid "Map" +msgstr "Mapa" + +#: templates/vertical-interactive-map/page.html.hbs:23 +msgid "Main location search" +msgstr "Búsqueda principal del local" + +#: templates/vertical-interactive-map/page.html.hbs:64 +msgid "Map" +msgstr "Mapa" + +#: templates/vertical-interactive-map/page.html.hbs:59 +msgid "Map controls" +msgstr "Controles del mapa" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:3 +msgid "No results found in [[currentVerticalLabel]]." +msgstr "Ningún resultado disponible en [[currentVerticalLabel]]." + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:5 +msgid "Showing all [[currentVerticalLabel]] instead." +msgstr "Mostrando en vez que [[currentVerticalLabel]]." + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:11 +msgid "The following search category yielded results for \"[[query]]\":" +msgid_plural "The following search categories yielded results for \"[[query]]\":" +msgstr[0] "La siguiente búsqueda ha generado categoría resultados para \"[[query]]\":" +msgstr[1] "La siguiente búsqueda ha generado categorias resultados para \"[[query]]\":" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:32 +msgid "([[resultsCount]] result)" +msgid_plural "([[resultsCount]] results)" +msgstr[0] "[[resultsCount]] resultado" +msgstr[1] "[[resultsCount]] resultados" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:47 +msgid "Alternatively, you can view results across all search categories." +msgstr "En su defecto, puede ver los resultados para todas las categorías de búsqueda." diff --git a/translations/fr.po b/translations/fr.po index c5cfb0fe9..35ec49937 100755 --- a/translations/fr.po +++ b/translations/fr.po @@ -114,3 +114,66 @@ msgid "View 1 Result" msgid_plural "View [[count]] Results" msgstr[0] "Afficher 1 résultat" msgstr[1] "Afficher [[count]] résultats" + +#: cards/multilang-financial-professional-location/template.hbs:195 +#: cards/multilang-location-standard/template.hbs:206 +#: cards/multilang-professional-location/template.hbs:192 +msgctxt "Close is a verb" +msgid "Close Card" +msgstr "Fermer la carte" + +#: templates/vertical-full-page-map/markup/searchthisareatoggle.hbs:10 +msgctxt "A button that conducts a search in the current map area" +msgid "Search When Map Moves" +msgstr "Rechercher quand la carte est déplacée" + +#: templates/vertical-full-page-map/markup/searchthisareabutton.hbs:3 +msgctxt "A toggle for automatically searching when the map moves" +msgid "Search This Area" +msgstr "Rechercher dans cette zone" + +#: templates/vertical-full-page-map/markup/mobilelisttoggles.hbs:5 +msgctxt "The label of a toggle for displaying a list of results" +msgid "List" +msgstr "Liste" + +#: templates/vertical-full-page-map/markup/mobilelisttoggles.hbs:9 +msgctxt "The label of a toggle for viewing a visual map" +msgid "Map" +msgstr "Carte" + +#: templates/vertical-interactive-map/page.html.hbs:23 +msgid "Main location search" +msgstr "Recherche d'établissements principale" + +#: templates/vertical-interactive-map/page.html.hbs:64 +msgid "Map" +msgstr "Carte" + +#: templates/vertical-interactive-map/page.html.hbs:59 +msgid "Map controls" +msgstr "Commandes de carte" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:3 +msgid "No results found in [[currentVerticalLabel]]." +msgstr "Aucun resultat in [[currentVerticalLabel]]." + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:5 +msgid "Showing all [[currentVerticalLabel]] instead." +msgstr "Voici tous/toutes les [[currentVerticalLabel]] à la place." + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:11 +msgid "The following search category yielded results for \"[[query]]\":" +msgid_plural "The following search categories yielded results for \"[[query]]\":" +msgstr[0] "La catégorie de recherche suivante a produit des résultats pour \"[[query]]\":" +msgstr[1] "Les catégories de recherche suivantes ont produit des résultats pour \"[[query]]\":" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:32 +msgid "([[resultsCount]] result)" +msgid_plural "([[resultsCount]] results)" +msgstr[0] "([[resultsCount]] résultat)" +msgstr[1] "([[resultsCount]] résultats)" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:47 +msgid "Alternatively, you can view results across all search categories." +msgstr "Autrement, vous pouvez voir les résultats à travers toutes les catégories." diff --git a/translations/it.po b/translations/it.po index 1d95363d9..3ae20614e 100755 --- a/translations/it.po +++ b/translations/it.po @@ -114,3 +114,66 @@ msgid "View 1 Result" msgid_plural "View [[count]] Results" msgstr[0] "Visualizza 1 risultato" msgstr[1] "Visualizza [[count]] risultati" + +#: cards/multilang-financial-professional-location/template.hbs:195 +#: cards/multilang-location-standard/template.hbs:206 +#: cards/multilang-professional-location/template.hbs:192 +msgctxt "Close is a verb" +msgid "Close Card" +msgstr "Chiudi scheda" + +#: templates/vertical-full-page-map/markup/searchthisareatoggle.hbs:10 +msgctxt "A button that conducts a search in the current map area" +msgid "Search When Map Moves" +msgstr "Cerca quando la mappa si sposta" + +#: templates/vertical-full-page-map/markup/searchthisareabutton.hbs:3 +msgctxt "A toggle for automatically searching when the map moves" +msgid "Search This Area" +msgstr "Cerca in questa area" + +#: templates/vertical-full-page-map/markup/mobilelisttoggles.hbs:5 +msgctxt "The label of a toggle for displaying a list of results" +msgid "List" +msgstr "Elenco" + +#: templates/vertical-full-page-map/markup/mobilelisttoggles.hbs:9 +msgctxt "The label of a toggle for viewing a visual map" +msgid "Map" +msgstr "Mappa" + +#: templates/vertical-interactive-map/page.html.hbs:23 +msgid "Main location search" +msgstr "Ricerca posizione principale" + +#: templates/vertical-interactive-map/page.html.hbs:64 +msgid "Map" +msgstr "Mappa" + +#: templates/vertical-interactive-map/page.html.hbs:59 +msgid "Map controls" +msgstr "Controlli mappa" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:3 +msgid "No results found in [[currentVerticalLabel]]." +msgstr "Non ci sono risultati disponibili in [[currentVerticalLabel]]." + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:5 +msgid "Alternatively, you can view results across all search categories." +msgstr "Altrimenti, può vedere i risultati su tutte le categorie di ricerca." + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:11 +msgid "The following search category yielded results for \"[[query]]\":" +msgid_plural "The following search categories yielded results for \"[[query]]\":" +msgstr[0] "La categoria di ricerca ha generato risultati per \"[[query]]\":" +msgstr[1] "La categorie di ricerca ha generato risultati per \"[[query]]\":" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:32 +msgid "([[resultsCount]] result)" +msgid_plural "([[resultsCount]] results)" +msgstr[0] "[[resultsCount]] risultato" +msgstr[1] "[[resultsCount]] risultati" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:47 +msgid "Alternatively, you can view results across all search categories." +msgstr "Altrimenti, può vedere i risultati su tutte le categorie di ricerca." diff --git a/translations/ja.po b/translations/ja.po index 75c5ae46b..c53ca1d1e 100755 --- a/translations/ja.po +++ b/translations/ja.po @@ -113,3 +113,64 @@ msgstr "並べ替えとフィルタ" msgid "View 1 Result" msgid_plural "View [[count]] Results" msgstr[0] "[[count]]件の結果を表示" + +#: cards/multilang-financial-professional-location/template.hbs:195 +#: cards/multilang-location-standard/template.hbs:206 +#: cards/multilang-professional-location/template.hbs:192 +msgctxt "Close is a verb" +msgid "Close Card" +msgstr "カードを閉じる" + +#: templates/vertical-full-page-map/markup/searchthisareatoggle.hbs:10 +msgctxt "A button that conducts a search in the current map area" +msgid "Search When Map Moves" +msgstr "マップが移動したときに検索" + +#: templates/vertical-full-page-map/markup/searchthisareabutton.hbs:3 +msgctxt "A toggle for automatically searching when the map moves" +msgid "Search This Area" +msgstr "このエリアを検索" + +#: templates/vertical-full-page-map/markup/mobilelisttoggles.hbs:5 +msgctxt "The label of a toggle for displaying a list of results" +msgid "List" +msgstr "リスト" + +#: templates/vertical-full-page-map/markup/mobilelisttoggles.hbs:9 +msgctxt "The label of a toggle for viewing a visual map" +msgid "Map" +msgstr "マップ" + +#: templates/vertical-interactive-map/page.html.hbs:23 +msgid "Main location search" +msgstr "主なロケーション検索" + +#: templates/vertical-interactive-map/page.html.hbs:64 +msgid "Map" +msgstr "マップ" + +#: templates/vertical-interactive-map/page.html.hbs:59 +msgid "Map controls" +msgstr "マップコントロール" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:3 +msgid "No results found in [[currentVerticalLabel]]." +msgstr "[[currentVerticalLabel]] で結果は見つかりませんでした。" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:5 +msgid "Showing all [[currentVerticalLabel]] instead." +msgstr "代わりにすべての[[currentVerticalLabel]]を表示しています。" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:11 +msgid "The following search category yielded results for \"[[query]]\":" +msgid_plural "The following search categories yielded results for \"[[query]]\":" +msgstr[0] "以下の検索カテゴリーで「[[query]]」の検索結果が見つかりました。" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:32 +msgid "([[resultsCount]] result)" +msgid_plural "([[resultsCount]] results)" +msgstr[0] "([[resultsCount]]件の結果)" + +#: theme-components/vertical-full-page-map-alternative-verticals/alternativeverticals.hbs:47 +msgid "Alternatively, you can view results across all search categories." +msgstr "代わりにすべての検索カテゴリーの結果を表示することもできます。" diff --git a/universalsectiontemplates/grid-four-columns.hbs b/universalsectiontemplates/grid-four-columns.hbs new file mode 100644 index 000000000..5114c72b3 --- /dev/null +++ b/universalsectiontemplates/grid-four-columns.hbs @@ -0,0 +1,68 @@ +{{#if isSearchComplete}} + {{#if showNoResults}} + {{> noResults}} + {{/if}} + {{#if resultsPresent}} +
+ {{> header}} + {{> map}} + {{> results}} + {{> viewMore}} +
+ {{/if}} +{{/if}} + +{{#*inline "noResults"}} + {{#if useLegacyNoResults}} + {{> results/noresults}} + {{/if}} +{{/inline}} + +{{#*inline "header"}} +
+ {{#if _config.icon}} + {{#if iconIsBuiltIn}} +
+ {{else}} +
+ {{/if}} + {{/if}} +
{{_config.title}}
+
+
+{{/inline}} + +{{#*inline "results"}} +
+ {{#each results}} +
+
+ {{/each}} +
+{{/inline}} + +{{#*inline "map"}} + {{#if _config.includeMap}} +
+
+ {{/if}} +{{/inline}} + +{{#*inline "viewMore"}} + {{#if _config.isUniversal}} + + {{/if}} +{{/inline}} diff --git a/universalsectiontemplates/grid-three-columns.hbs b/universalsectiontemplates/grid-three-columns.hbs index 82ee1c1de..d15a51906 100644 --- a/universalsectiontemplates/grid-three-columns.hbs +++ b/universalsectiontemplates/grid-three-columns.hbs @@ -21,10 +21,12 @@ {{#*inline "header"}}
- {{#if iconIsBuiltIn}} -
- {{else}} -
+ {{#if _config.icon}} + {{#if iconIsBuiltIn}} +
+ {{else}} +
+ {{/if}} {{/if}}
{{_config.title}}
@@ -39,9 +41,6 @@ data-opts='{ "_index": {{@index}} }'>
{{/each}} - {{#each placeholders}} - - {{/each}} {{/inline}} diff --git a/universalsectiontemplates/grid-two-columns.hbs b/universalsectiontemplates/grid-two-columns.hbs index ed4b4c0af..c0540dba8 100644 --- a/universalsectiontemplates/grid-two-columns.hbs +++ b/universalsectiontemplates/grid-two-columns.hbs @@ -21,10 +21,12 @@ {{#*inline "header"}}
- {{#if iconIsBuiltIn}} -
- {{else}} -
+ {{#if _config.icon}} + {{#if iconIsBuiltIn}} +
+ {{else}} +
+ {{/if}} {{/if}}
{{_config.title}}
@@ -39,9 +41,6 @@ data-opts='{ "_index": {{@index}} }'> {{/each}} - {{#each placeholders}} - - {{/each}} {{/inline}} @@ -67,4 +66,4 @@ {{/if}} -{{/inline}} +{{/inline}} \ No newline at end of file diff --git a/universalsectiontemplates/standard.hbs b/universalsectiontemplates/standard.hbs index 8a4f44952..93c10e8c2 100644 --- a/universalsectiontemplates/standard.hbs +++ b/universalsectiontemplates/standard.hbs @@ -21,10 +21,12 @@ {{#*inline "header"}}
- {{#if iconIsBuiltIn}} -
- {{else}} -
+ {{#if _config.icon}} + {{#if iconIsBuiltIn}} +
+ {{else}} +
+ {{/if}} {{/if}}
{{_config.title}}
@@ -39,9 +41,6 @@ data-opts='{ "_index": {{@index}} }'> {{/each}} - {{#each placeholders}} - - {{/each}} {{/inline}}