├── .babelrc ├── .dependabot └── config.yml ├── .eslintrc.json ├── .github ├── CODEOWNERS ├── run_headless_acceptance.sh ├── run_translation_verification.sh ├── testcafe.json ├── testcafe_search_bar.json └── workflows │ ├── acceptance.yml │ ├── acceptance_search_bar.yml │ ├── build.yml │ ├── build_and_deploy.yml │ ├── build_and_deploy_hold.yml │ ├── build_and_deploy_i18n.yml │ ├── build_and_deploy_search_bar.yml │ ├── build_i18n.yml │ ├── coverage.yml │ ├── deploy.yml │ ├── deploy_hold.yml │ ├── extract_versions.yml │ ├── format_branch_name.yml │ ├── main.yml │ ├── miscellaneous_tests.yml │ ├── percy_snapshots.yml │ ├── should_deploy_major_version.yml │ ├── sync_develop_and_main.yml │ ├── third_party_notices_check.yml │ ├── unit_test.yml │ ├── version-update.yml │ └── wcag_test.yml ├── .gitignore ├── .postcssrc.json ├── .size-limit.js ├── .stylelintrc.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── THIRD-PARTY-NOTICES ├── __mocks__ └── @yext │ └── search-core │ └── lib │ └── commonjs.js ├── conf ├── cloud-regions │ └── constants.js ├── gulp-tasks │ ├── bundle │ │ ├── bundle.js │ │ ├── bundletaskfactory.js │ │ └── minifytaskfactory.js │ ├── extracttranslations.gulpfile.js │ ├── library.gulpfile.js │ ├── template │ │ ├── bundletemplatestaskfactory.js │ │ ├── createcleanfiles.js │ │ ├── createprecompiletemplates.js │ │ ├── filenameutils.js │ │ ├── minifytemplatestaskfactory.js │ │ └── templatetype.js │ ├── templates.gulpfile.js │ └── utils │ │ ├── bundlename.js │ │ └── libversion.js ├── i18n │ ├── constants.js │ ├── extract │ │ ├── hbsplaceholderparser.js │ │ ├── jsplaceholderparser.js │ │ └── translationextractor.js │ ├── localebuildutils.js │ ├── localfileparser.js │ ├── models │ │ └── translationplaceholder.js │ ├── runtimecallgeneratorutils.js │ ├── translatecallparser.js │ ├── translatehelpervisitor.js │ ├── translationplaceholderutils.js │ ├── translationresolver.js │ ├── translations │ │ ├── ar.po │ │ ├── de.po │ │ ├── es.po │ │ ├── fr.po │ │ ├── hi.po │ │ ├── it.po │ │ ├── ja.po │ │ ├── ko.po │ │ ├── messages.pot │ │ ├── nl.po │ │ ├── pl.po │ │ ├── pt.po │ │ ├── ru.po │ │ ├── sv.po │ │ ├── zh-Hans.po │ │ └── zh-Hant.po │ └── translator.js ├── npm │ ├── README.md │ ├── generateentrypoints.js │ ├── revert_npm_readme.sh │ └── use_npm_readme.sh └── templates │ └── handlebarswrapper.txt ├── gulpfile.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── src ├── answers-search-bar.js ├── answers-umd.js ├── core │ ├── analytics │ │ ├── analyticsevent.js │ │ ├── analyticsreporter.js │ │ ├── createimpressionevent.js │ │ ├── noopanalyticsreporter.js │ │ └── visibilityanalyticshandler.js │ ├── constants.js │ ├── core.js │ ├── errors │ │ ├── consoleerrorreporter.js │ │ ├── errorreporter.js │ │ └── errors.js │ ├── eventemitter │ │ └── eventemitter.js │ ├── filters │ │ ├── combinedfilternode.js │ │ ├── filtercombinators.js │ │ ├── filtermetadata.js │ │ ├── filternode.js │ │ ├── filternodefactory.js │ │ ├── filterregistry.js │ │ ├── filtertype.js │ │ ├── matcher.js │ │ └── simplefilternode.js │ ├── http │ │ ├── apirequest.js │ │ └── httprequester.js │ ├── i18n │ │ └── translationprocessor.js │ ├── index.js │ ├── models │ │ ├── alternativevertical.js │ │ ├── alternativeverticals.js │ │ ├── appliedhighlightedfields.js │ │ ├── appliedqueryfilter.js │ │ ├── autocompletedata.js │ │ ├── directanswer.js │ │ ├── dynamicfilters.js │ │ ├── facet.js │ │ ├── filter.js │ │ ├── generativedirectanswer.js │ │ ├── highlightedfields.js │ │ ├── highlightedvalue.js │ │ ├── locationbias.js │ │ ├── navigation.js │ │ ├── querytriggers.js │ │ ├── questionsubmission.js │ │ ├── result.js │ │ ├── searchconfig.js │ │ ├── searcher.js │ │ ├── section.js │ │ ├── spellcheck.js │ │ ├── universalresults.js │ │ ├── verticalpagesconfig.js │ │ └── verticalresults.js │ ├── search │ │ ├── autocompleteresponsetransformer.js │ │ ├── searchdatatransformer.js │ │ └── searchoptionsfactory.js │ ├── services │ │ ├── analyticsreporterservice.js │ │ └── errorreporterservice.js │ ├── speechrecognition │ │ ├── locales.js │ │ └── support.js │ ├── statelisteners │ │ ├── queryupdatelistener.js │ │ └── resultsupdatelistener.js │ ├── storage │ │ ├── moduledata.js │ │ ├── resultscontext.js │ │ ├── searchstates.js │ │ ├── storage.js │ │ ├── storageindexes.js │ │ ├── storagekeys.js │ │ └── storagelistener.js │ └── utils │ │ ├── apicontext.js │ │ ├── arrayutils.js │ │ ├── configutils.js │ │ ├── filternodeutils.js │ │ ├── i18nutils.js │ │ ├── mergeAdditionalHttpHeaders.js │ │ ├── objectutils.js │ │ ├── resultsutils.js │ │ ├── richtextformatter.js │ │ ├── strings.js │ │ ├── urlutils.js │ │ ├── useragent.js │ │ └── uuid.js └── ui │ ├── alert.js │ ├── components │ ├── cards │ │ ├── accordioncardcomponent.js │ │ ├── cardcomponent.js │ │ ├── consts.js │ │ ├── legacycardcomponent.js │ │ └── standardcardcomponent.js │ ├── component.js │ ├── componentmanager.js │ ├── componenttypes.js │ ├── ctas │ │ ├── ctacollectioncomponent.js │ │ └── ctacomponent.js │ ├── filters │ │ ├── daterangefiltercomponent.js │ │ ├── facetscomponent.js │ │ ├── filterboxcomponent.js │ │ ├── filteroptionscomponent.js │ │ ├── geolocationcomponent.js │ │ ├── rangefiltercomponent.js │ │ └── sortoptionscomponent.js │ ├── icons │ │ └── iconcomponent.js │ ├── map │ │ ├── mapcomponent.js │ │ └── providers │ │ │ ├── googlemapprovider.js │ │ │ ├── mapboxmapprovider.js │ │ │ └── mapprovider.js │ ├── navigation │ │ └── navigationcomponent.js │ ├── questions │ │ └── questionsubmissioncomponent.js │ ├── registry.js │ ├── results │ │ ├── accordionresultscomponent.js │ │ ├── alternativeverticalscomponent.js │ │ ├── appliedfilterscomponent.js │ │ ├── directanswercomponent.js │ │ ├── generativedirectanswercomponent.js │ │ ├── paginationcomponent.js │ │ ├── resultsheadercomponent.js │ │ ├── universalresultscomponent.js │ │ ├── verticalresultscomponent.js │ │ └── verticalresultscountcomponent.js │ ├── search-bar-only-registry.js │ ├── search │ │ ├── autocompletecomponent.js │ │ ├── filtersearchcomponent.js │ │ ├── locationbiascomponent.js │ │ ├── searchcomponent.js │ │ └── spellcheckcomponent.js │ └── state.js │ ├── controllers │ └── searchbariconcontroller.js │ ├── dom │ ├── dom.js │ └── searchparams.js │ ├── i18n │ └── translationflagger.js │ ├── icons │ ├── briefcase.js │ ├── calendar.js │ ├── callout.js │ ├── chevron.js │ ├── close.js │ ├── directions.js │ ├── document.js │ ├── elements.js │ ├── email.js │ ├── gear.js │ ├── icon.js │ ├── index.js │ ├── info.js │ ├── kabob.js │ ├── light_bulb.js │ ├── link.js │ ├── magnifying_glass.js │ ├── mic.js │ ├── office.js │ ├── pantheon.js │ ├── person.js │ ├── phone.js │ ├── pin.js │ ├── receipt.js │ ├── star.js │ ├── support.js │ ├── svg │ │ ├── voice_search_dots.svg │ │ └── voice_search_mic.svg │ ├── tag.js │ ├── thumb.js │ ├── voice_search_dots.js │ ├── voice_search_mic.js │ ├── window.js │ ├── yext.js │ ├── yext_animated_forward.js │ └── yext_animated_reverse.js │ ├── index.js │ ├── rendering │ ├── const.js │ ├── defaulttemplatesloader.js │ ├── handlebarsrenderer.js │ └── renderer.js │ ├── sass │ ├── _alert.scss │ ├── _base.scss │ ├── _colors.scss │ ├── _fonts.scss │ ├── _layout.scss │ ├── _mixins.scss │ ├── _util.scss │ ├── answers-search-bar.scss │ ├── answers.scss │ └── modules │ │ ├── _AccordionCard.scss │ │ ├── _AccordionResult.scss │ │ ├── _AlternativeVerticals.scss │ │ ├── _AppliedFilters.scss │ │ ├── _AutoComplete.scss │ │ ├── _CTA.scss │ │ ├── _Card.scss │ │ ├── _DirectAnswer.scss │ │ ├── _Facets.scss │ │ ├── _FilterBox.scss │ │ ├── _FilterOptions.scss │ │ ├── _GenerativeDirectAnswer.scss │ │ ├── _Icon.scss │ │ ├── _LegacyCard.scss │ │ ├── _LocationBias.scss │ │ ├── _Nav.scss │ │ ├── _NoResults.scss │ │ ├── _Pagination.scss │ │ ├── _QuestionSubmission.scss │ │ ├── _Results.scss │ │ ├── _ResultsHeader.scss │ │ ├── _SearchBar.scss │ │ ├── _SortOptions.scss │ │ ├── _SpellCheck.scss │ │ ├── _StandardCard.scss │ │ └── _VerticalResultsCount.scss │ ├── speechrecognition │ ├── listeningiconstylist.js │ ├── miciconstylist.js │ ├── screenreadertextcontroller.js │ ├── speechrecognizer.js │ └── voicesearchcontroller.js │ ├── statemachine │ ├── defaulticonstate.js │ ├── loadingiconstate.js │ ├── searchiconstate.js │ └── statemachine.js │ ├── templates │ ├── cards │ │ ├── accordion.hbs │ │ ├── card.hbs │ │ ├── legacy.hbs │ │ └── standard.hbs │ ├── controls │ │ ├── date.hbs │ │ ├── filteroptions.hbs │ │ ├── geolocation.hbs │ │ ├── range.hbs │ │ └── sortoptions.hbs │ ├── ctas │ │ ├── cta.hbs │ │ └── ctacollection.hbs │ ├── filters │ │ ├── facets.hbs │ │ └── filterbox.hbs │ ├── icons │ │ ├── builtInIcon.hbs │ │ ├── icon.hbs │ │ ├── iconPartial.hbs │ │ ├── searchBarIcon.hbs │ │ └── voiceSearchIcon.hbs │ ├── navigation │ │ └── navigation.hbs │ ├── questions │ │ └── questionsubmission.hbs │ ├── results │ │ ├── alternativeverticals.hbs │ │ ├── appliedfilters.hbs │ │ ├── directanswer.hbs │ │ ├── generativedirectanswer.hbs │ │ ├── map.hbs │ │ ├── noresults.hbs │ │ ├── pagination.hbs │ │ ├── resultsaccordion.hbs │ │ ├── resultsheader.hbs │ │ ├── resultssectionheader.hbs │ │ ├── universalresults.hbs │ │ ├── verticalresults.hbs │ │ └── verticalresultscount.hbs │ └── search │ │ ├── autocomplete.hbs │ │ ├── filtersearch.hbs │ │ ├── locationbias.hbs │ │ ├── search.hbs │ │ └── spellcheck.hbs │ └── tools │ ├── filterutils.js │ ├── searchparamsparser.js │ ├── stringmatching.js │ ├── taborder.js │ └── urlutils.js └── tests ├── acceptance ├── acceptancesuites │ ├── experiencelinkssuite.js │ ├── facetsonload.js │ ├── facetssuite.js │ ├── filterboxsuite.js │ ├── filtersearchsuite.js │ ├── performancemarkssuite.js │ ├── searchbaronlysuite.js │ ├── sortoptionssuite.js │ ├── universalinitialsearch.js │ ├── universalsuite.js │ ├── verticalinitialsearch.js │ ├── verticalsuite.js │ └── xss.js ├── blocks │ ├── facetscomponent.js │ ├── filterboxcomponent.js │ ├── filteroptionscomponent.js │ ├── filtersearchcomponent.js │ ├── paginationcomponent.js │ ├── searchcomponent.js │ ├── spellcheckcomponent.js │ ├── universalresultscomponent.js │ └── verticalresultscomponent.js ├── constants.js ├── fixtures │ ├── html │ │ ├── facets.html │ │ ├── facetsonload.html │ │ ├── filterbox.html │ │ ├── no-unsafe-eval.html │ │ ├── searchbaronly.html │ │ ├── universal.html │ │ ├── universalinitialsearch.html │ │ └── vertical.html │ └── responses │ │ ├── cors.js │ │ ├── filtersearch │ │ └── search.js │ │ ├── universal │ │ ├── autocomplete.js │ │ ├── search.js │ │ └── virginiaRes.js │ │ └── vertical │ │ ├── KM │ │ ├── generateKMResponse.js │ │ ├── nyData.js │ │ ├── ukData.js │ │ └── vaData.js │ │ ├── autocomplete.js │ │ ├── people │ │ ├── allRes25MileRadius.js │ │ ├── allResNoFacets.js │ │ ├── allResOneFacet.js │ │ ├── allResOneStaticFilter.js │ │ ├── allResThreeFacets.js │ │ ├── allResTwoFacets.js │ │ ├── allResTwoStaticFilters.js │ │ ├── amaniData.js │ │ ├── generatePeopleResponse.js │ │ └── tomData.js │ │ ├── search.js │ │ └── sharedData.js ├── pagenavigator.js ├── pageobjects │ ├── facetspage.js │ ├── searchbaronlypage.js │ ├── universalpage.js │ └── verticalpage.js ├── percy │ └── snapshots.js ├── requestUtils.js ├── searchrequestlogger.js ├── utils.js └── wcag │ ├── index.js │ ├── utils.js │ └── wcagreporter.js ├── answers-search-bar.js ├── answers-umd.js ├── conf ├── i18n │ ├── extract │ │ └── translationextractor.js │ ├── runtimecallgeneratorutils.js │ ├── translatecallparser.js │ ├── translatehelpervisitor.js │ ├── translationplaceholderutils.js │ ├── translationresolver.js │ └── translator.js └── npm │ └── generateentrypoints.js ├── core ├── analytics │ └── analyticsreporter.js ├── core.js ├── filters │ ├── combinedfilternode.js │ ├── filternodefactory.js │ ├── filterregistry.js │ └── simplefilternode.js ├── http │ └── httprequester.js ├── i18n │ └── translationprocessor.js ├── models │ ├── appliedhighlightedfields.js │ ├── appliedqueryfilter.js │ ├── directanswer.js │ ├── filter.js │ ├── generativedirectanswer.js │ ├── highlightedvalue.js │ ├── locationbias.js │ ├── navigation.js │ ├── result.js │ ├── spellcheck.js │ ├── universalresults.js │ └── verticalresults.js ├── search │ ├── autocompleteresponsetransformer.js │ ├── searchdatatransformer.js │ └── searchoptionsfactory.js ├── speechrecognition │ └── locales.js ├── statelisteners │ ├── queryupdatelistener.js │ └── resultsupdatelistener.js ├── storage │ └── storage.js └── utils │ ├── apicontext.js │ ├── configutils.js │ ├── i18nutils.js │ ├── mergeAdditionalHttpHeaders.js │ ├── richtextformatter.js │ └── urlutils.js ├── fixtures ├── conf │ └── npm │ │ └── inputFile.json ├── core │ ├── coreuniversalresponse.json │ └── sdkuniversalresults.json ├── expectedjavascript.pot ├── expectedtemplate.pot ├── rawjavascript.js ├── rawtemplate.hbs ├── responseWithNoResults.json ├── responseWithResults.json └── translations │ ├── fr-FR.po │ ├── fr.po │ └── lt-LT.po ├── setup ├── enzymeadapter.js ├── initanswers.js ├── managermocker.js ├── mockcomponentmanager.js ├── mockwindow.js └── setup.js └── ui ├── components ├── component.js ├── controllers │ └── searchbariconcontroller.js ├── filters │ ├── daterangecomponent.js │ ├── facetscomponent.js │ ├── filterboxcomponent.js │ ├── filteroptionscomponent.js │ ├── filtersearchcomponent.js │ ├── geolocationcomponent.js │ ├── rangefiltercomponent.js │ └── sortoptionscomponent.js ├── map │ ├── mapcomponent.js │ └── providers │ │ ├── googlemapprovider.js │ │ ├── mapboxmapprovider.js │ │ └── mapprovider.js ├── navigation │ └── navigationcomponent.js ├── questions │ └── questionsubmissioncomponent.js ├── results │ ├── alternativeverticalscomponent.js │ ├── appliedfilterscomponent.js │ ├── directanswercomponent.js │ ├── generativedirectanswercomponent.js │ ├── paginationcomponent.js │ ├── resultsheadercomponent.js │ ├── universalresultscomponent.js │ ├── verticalresultscomponent.js │ └── verticalresultscountcomponent.js └── search │ ├── autocompletecomponent.js │ ├── searchcomponent.js │ └── spellcheckcomponent.js ├── dom └── searchparams.js ├── rendering └── handlebarsrenderer.js └── tools ├── filterutils.js ├── taborder.js └── urlutils.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } -------------------------------------------------------------------------------- /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | update_configs: 3 | - package_manager: "javascript" 4 | directory: "/" 5 | update_schedule: "monthly" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["semistandard"], 3 | "ignorePatterns": ["dist"], 4 | "rules": { 5 | "arrow-spacing": "error", 6 | "quotes": ["error", "single"], 7 | "max-len": ["error", { 8 | "code": 110, 9 | "ignorePattern": "^import\\s.+\\sfrom\\s.+;$", 10 | "ignoreUrls": true, 11 | "ignoreTemplateLiterals": true, 12 | "ignoreStrings": true, 13 | "ignoreRegExpLiterals": true, 14 | "ignoreTrailingComments": true 15 | }] 16 | }, 17 | "globals": { 18 | "beforeEach": true, 19 | "afterEach": true, 20 | "beforeAll": true, 21 | "describe": true, 22 | "it": true, 23 | "expect": true, 24 | "jest": true, 25 | "DOMParser": true, 26 | "ytag": true, 27 | "test": true, 28 | "fixture": true, 29 | "CustomEvent": true, 30 | "ANSWERS": true, 31 | "SpeechRecognition": true, 32 | "webkitSpeechRecognition": true 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @yext/watson 2 | -------------------------------------------------------------------------------- /.github/run_headless_acceptance.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ $GITHUB_REF_NAME == release/* 4 | || $GITHUB_REF_NAME == hotfix/* 5 | || $GITHUB_REF_NAME == master 6 | || $GITHUB_REF_NAME == support/* ]] 7 | then 8 | npx testcafe -c 3 "chrome:headless,firefox:headless" --config-file ./.github/testcafe.json -q 9 | else 10 | npx testcafe -c 3 "chrome:headless" --config-file ./.github/testcafe.json -q 11 | fi 12 | -------------------------------------------------------------------------------- /.github/run_translation_verification.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Verifiy that the messages.pot file in the repo is up to date 4 | # If there are any git diffs after translations are extracted, the checked-in file is out of date 5 | npm run extract-translations 6 | git diff --exit-code conf/i18n/translations/messages.pot > /dev/null # send stdout to /dev/null to reduce clutter in the CI output 7 | diff_exit_code=$? 8 | 9 | if test $diff_exit_code -eq 1 10 | then 11 | echo "Extracted translations are out of date. Run 'npm run extract-translations' and commit the updated pot file." 12 | exit 1 13 | else 14 | echo "The messages.pot translation file is up to date." 15 | fi 16 | 17 | # Verify that translations are present for all languages 18 | cd conf/i18n/translations 19 | 20 | exit_code=0 21 | if [[ $GITHUB_REF_NAME == release/* 22 | || $GITHUB_REF_NAME == hotfix/* 23 | || $GITHUB_REF_NAME == master 24 | || $GITHUB_REF_NAME == support/* ]] 25 | then 26 | for po_file in *.po 27 | do 28 | msgcmp $po_file messages.pot || exit_code=1 29 | done 30 | else 31 | echo "Skipping the verification that all translations are present" 32 | fi 33 | 34 | exit $exit_code 35 | -------------------------------------------------------------------------------- /.github/testcafe.json: -------------------------------------------------------------------------------- 1 | { 2 | "src": ["tests/acceptance/acceptancesuites/*.js", "!tests/acceptance/acceptancesuites/searchbaronlysuite.js"], 3 | "appCommand": "npx serve -l tcp://0.0.0.0:9999", 4 | "appInitDelay": 4000 5 | } -------------------------------------------------------------------------------- /.github/testcafe_search_bar.json: -------------------------------------------------------------------------------- 1 | { 2 | "src": ["tests/acceptance/acceptancesuites/searchbaronlysuite.js"], 3 | "appCommand": "npx serve -l tcp://0.0.0.0:9999", 4 | "appInitDelay": 4000 5 | } -------------------------------------------------------------------------------- /.github/workflows/acceptance.yml: -------------------------------------------------------------------------------- 1 | name: Run acceptance tests 2 | 3 | on: workflow_call 4 | 5 | jobs: 6 | headless_acceptance: 7 | name: Headless Acceptance 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Use Node.js 20 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | cache: 'npm' 16 | - run: npm ci 17 | - name: Download build-output-US artifact 18 | uses: actions/download-artifact@v4 19 | with: 20 | name: build-output-US 21 | path: dist/ 22 | - run: ./.github/run_headless_acceptance.sh 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/acceptance_search_bar.yml: -------------------------------------------------------------------------------- 1 | name: Run acceptance tests 2 | 3 | on: workflow_call 4 | jobs: 5 | headless_acceptance: 6 | name: Headless Acceptance 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Use Node.js 20 11 | uses: actions/setup-node@v4 12 | with: 13 | node-version: 20 14 | cache: 'npm' 15 | - run: npm ci 16 | - name: Download build-output-US artifact 17 | uses: actions/download-artifact@v4 18 | with: 19 | name: build-output-US 20 | path: dist/ 21 | - run: npx testcafe -c 3 "chrome:headless,firefox:headless" --config-file ./.github/testcafe_search_bar.json -q 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Answers assets 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | build_script: 7 | required: false 8 | default: build 9 | type: string 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | - name: Use Node.js 20 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | cache: 'npm' 24 | - run: npm ci 25 | - run: npm run "$BUILD_SCRIPT" 26 | env: 27 | BUILD_SCRIPT: ${{ inputs.build_script }} 28 | - name: Create build-output-US artifact 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: build-output-US 32 | path: dist/ 33 | -------------------------------------------------------------------------------- /.github/workflows/build_and_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - develop 7 | - master 8 | - hotfix/** 9 | - feature/**-i18n 10 | - release/** 11 | 12 | jobs: 13 | call_build: 14 | uses: ./.github/workflows/build.yml 15 | 16 | call_unit_test: 17 | uses: ./.github/workflows/unit_test.yml 18 | needs: call_build 19 | 20 | call_misc_tests: 21 | uses: ./.github/workflows/miscellaneous_tests.yml 22 | 23 | call_format_branch_name: 24 | uses: ./.github/workflows/format_branch_name.yml 25 | 26 | call_acceptance: 27 | uses: ./.github/workflows/acceptance.yml 28 | needs: call_build 29 | 30 | call_deploy: 31 | needs: 32 | - call_unit_test 33 | - call_format_branch_name 34 | - call_acceptance 35 | - call_misc_tests 36 | uses: ./.github/workflows/deploy.yml 37 | with: 38 | directory: dev/${{ needs.call_format_branch_name.outputs.formatted_branch }} 39 | secrets: 40 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 41 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 42 | 43 | concurrency: 44 | group: ci-build-and-deploy-${{ github.ref }}-1 45 | cancel-in-progress: true 46 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run our tests, generate an lcov code coverage file, 2 | # and send that coverage to Coveralls 3 | 4 | name: Code Coverage 5 | 6 | on: 7 | push: 8 | branches-ignore: dev/* 9 | pull_request: 10 | 11 | jobs: 12 | call_coveralls: 13 | uses: yext/slapshot-reusable-workflows/.github/workflows/coverage.yml@v1 14 | with: 15 | test_script: npx gulp templates && npx jest --coverage 16 | comparison_branch: master 17 | secrets: 18 | caller_github_token: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy assets to AWS S3 and GCP Cloud Storage 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | bucket: 7 | required: false 8 | type: string 9 | default: answers 10 | directory: 11 | required: true 12 | type: string 13 | cache-control: 14 | required: false 15 | type: string 16 | default: no-cache 17 | secrets: 18 | AWS_ACCESS_KEY_ID: 19 | required: true 20 | AWS_SECRET_ACCESS_KEY: 21 | required: true 22 | 23 | jobs: 24 | deploy-aws: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Download build-output-US artifact 29 | uses: actions/download-artifact@v4 30 | with: 31 | name: build-output-US 32 | path: dist/ 33 | - name: Configure AWS Credentials 34 | uses: aws-actions/configure-aws-credentials@v4 35 | with: 36 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 37 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 38 | aws-region: us-east-1 39 | - name: Deploy to S3 40 | run: | 41 | aws s3 cp ./dist/ s3://assets.sitescdn.net/"$BUCKET"/"$DIRECTORY" \ 42 | --acl public-read \ 43 | --recursive \ 44 | --cache-control "CACHE_CONTROL" 45 | env: 46 | BUCKET: ${{ inputs.bucket }} 47 | DIRECTORY: ${{ inputs.directory }} 48 | CACHE_CONTROL: ${{ inputs.cache-control }} 49 | -------------------------------------------------------------------------------- /.github/workflows/extract_versions.yml: -------------------------------------------------------------------------------- 1 | name: Extract versions 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | ignore_prefix: 7 | required: false 8 | default: '' 9 | type: string 10 | outputs: 11 | major_version: 12 | value: ${{ jobs.extract_versions.outputs.major_version }} 13 | minor_version: 14 | value: ${{ jobs.extract_versions.outputs.minor_version }} 15 | full_version: 16 | value: ${{ jobs.extract_versions.outputs.full_version }} 17 | 18 | jobs: 19 | extract_versions: 20 | runs-on: ubuntu-latest 21 | outputs: 22 | minor_version: ${{ steps.vars.outputs.minor_version }} 23 | major_version: ${{ steps.vars.outputs.major_version }} 24 | full_version: ${{ steps.vars.outputs.full_version }} 25 | steps: 26 | - name: extract major and minor version substrings 27 | id: vars 28 | run: | 29 | MAJOR_VERSION="$(echo "${GITHUB_REF_NAME##"$IGNORE_PREFIX"}" | cut -d '.' -f 1)" 30 | echo "Major version: $MAJOR_VERSION" 31 | echo major_version=${MAJOR_VERSION} >> $GITHUB_OUTPUT 32 | MINOR_VERSION="$(echo "${GITHUB_REF_NAME##"$IGNORE_PREFIX"}" | cut -d '.' -f 1,2)" 33 | echo "Minor version: $MINOR_VERSION" 34 | echo minor_version=${MINOR_VERSION} >> $GITHUB_OUTPUT 35 | FULL_VERSION="${GITHUB_REF_NAME##"$IGNORE_PREFIX"}" 36 | echo "Full version: $FULL_VERSION" 37 | echo full_version=${FULL_VERSION} >> $GITHUB_OUTPUT 38 | env: 39 | IGNORE_PREFIX: ${{ inputs.ignore_prefix }}} 40 | -------------------------------------------------------------------------------- /.github/workflows/format_branch_name.yml: -------------------------------------------------------------------------------- 1 | name: Format branch name to use as part of s3 directory path 2 | 3 | on: 4 | workflow_call: 5 | outputs: 6 | formatted_branch: 7 | description: "formatted branch name" 8 | value: ${{ jobs.format_branch_name.outputs.formatted_branch }} 9 | 10 | jobs: 11 | format_branch_name: 12 | runs-on: ubuntu-latest 13 | outputs: 14 | formatted_branch: ${{ steps.vars.outputs.formatted_branch }} 15 | steps: 16 | - name: Format branch name # replace '/' with '-' 17 | id: vars 18 | run: | 19 | FORMATTED_BRANCH="$(echo ${GITHUB_REF_NAME} | sed "s/\//-/g")" 20 | echo $FORMATTED_BRANCH 21 | echo formatted_branch=${FORMATTED_BRANCH} >> $GITHUB_OUTPUT 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Semgrep - Team Vaccine 2 | on: 3 | workflow_dispatch: {} 4 | pull_request: {} 5 | push: 6 | branches: 7 | - main 8 | - master 9 | paths: 10 | - .github/workflows/semgrep.yml 11 | schedule: 12 | # random HH:MM to avoid a load spike on GitHub Actions at 00:00 13 | - cron: '20 1 * * *' 14 | jobs: 15 | semgrep: 16 | name: semgrep/ci 17 | runs-on: ubuntu-latest 18 | env: 19 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN_VACCINE }} 20 | container: 21 | image: returntocorp/semgrep 22 | if: (github.actor != 'dependabot[bot]') 23 | steps: 24 | - uses: actions/checkout@v4 25 | - run: semgrep ci 26 | -------------------------------------------------------------------------------- /.github/workflows/miscellaneous_tests.yml: -------------------------------------------------------------------------------- 1 | name: Run miscellaneous tests 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | translation_test: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Use Node.js 20 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | cache: 'npm' 17 | - run: npm ci 18 | - run: sudo apt-get install -qq gettext 19 | - run: ./.github/run_translation_verification.sh 20 | -------------------------------------------------------------------------------- /.github/workflows/percy_snapshots.yml: -------------------------------------------------------------------------------- 1 | name: Percy Snapshots 2 | 3 | on: 4 | push: 5 | branches-ignore: dev/* 6 | pull_request: 7 | 8 | jobs: 9 | call_percy_snapshots: 10 | uses: yext/slapshot-reusable-workflows/.github/workflows/percy_snapshots.yml@v1 11 | with: 12 | snapshots_script_path: tests/acceptance/percy/snapshots.js 13 | fetch_depth: 0 14 | secrets: 15 | PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }} 16 | -------------------------------------------------------------------------------- /.github/workflows/should_deploy_major_version.yml: -------------------------------------------------------------------------------- 1 | name: Should deploy major version 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | ignore_prefix: 7 | required: false 8 | default: '' 9 | type: string 10 | outputs: 11 | should_deploy_major_version: 12 | value: ${{ jobs.should_deploy_major_version.outputs.should_deploy_major_version }} 13 | 14 | jobs: 15 | should_deploy_major_version: 16 | runs-on: ubuntu-latest 17 | outputs: 18 | should_deploy_major_version: ${{ steps.vars.outputs.should_deploy_major_version }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | - name: allow for major version deployment if the next minor version from current tag does not exist 24 | id: vars 25 | run: | 26 | MINOR_VERSION=$(echo "${GITHUB_REF_NAME##"$IGNORE_PREFIX"}" | cut -d '.' -f 2) 27 | MAJOR_VERSION=$(echo "${GITHUB_REF_NAME##"$IGNORE_PREFIX"}" | cut -d '.' -f 1) 28 | NEXT_MINOR_VERSION=$(( $MINOR_VERSION + 1 )) 29 | TAGS_FOR_NEXT_MINOR=$(git tag --list ""$IGNORE_PREFIX"$MAJOR_VERSION.$NEXT_MINOR_VERSION.*") 30 | if [ -z "$TAGS_FOR_NEXT_MINOR" ] 31 | then 32 | echo 'Major version should be deployed.' 33 | echo should_deploy_major_version=true >> $GITHUB_OUTPUT 34 | else 35 | echo 'Major version should not be deployed.' 36 | fi 37 | env: 38 | IGNORE_PREFIX: ${{ inputs.ignore_prefix }}} 39 | -------------------------------------------------------------------------------- /.github/workflows/sync_develop_and_main.yml: -------------------------------------------------------------------------------- 1 | name: Create PR from main to develop 2 | 3 | on: 4 | push: 5 | branches: [main, master] 6 | 7 | jobs: 8 | call_sync_develop_and_main: 9 | uses: yext/slapshot-reusable-workflows/.github/workflows/sync_develop_and_main.yml@v1 10 | secrets: 11 | caller_github_token: ${{ secrets.GITHUB_TOKEN }} 12 | -------------------------------------------------------------------------------- /.github/workflows/third_party_notices_check.yml: -------------------------------------------------------------------------------- 1 | name: Check Third Party Notices File 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | call_notices_check: 7 | uses: yext/slapshot-reusable-workflows/.github/workflows/third_party_notices_check.yml@v1 8 | secrets: 9 | REPO_SCOPED_TOKEN: ${{ secrets.BOT_REPO_SCOPED_TOKEN }} 10 | -------------------------------------------------------------------------------- /.github/workflows/unit_test.yml: -------------------------------------------------------------------------------- 1 | name: Run unit tests 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | unit_tests: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Use Node.js 20 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | cache: 'npm' 17 | - run: npm ci 18 | - name: Download build-output-US artifact 19 | uses: actions/download-artifact@v4 20 | with: 21 | name: build-output-US 22 | path: dist/ 23 | - run: npm run test 24 | -------------------------------------------------------------------------------- /.github/workflows/version-update.yml: -------------------------------------------------------------------------------- 1 | name: Update package version for release & hotfix branches 2 | 3 | on: 4 | push: 5 | branches: [release/*, hotfix/*] 6 | 7 | jobs: 8 | call_version_update: 9 | uses: yext/slapshot-reusable-workflows/.github/workflows/version_update.yml@v1 10 | secrets: 11 | caller_github_token: ${{ secrets.GITHUB_TOKEN }} 12 | -------------------------------------------------------------------------------- /.github/workflows/wcag_test.yml: -------------------------------------------------------------------------------- 1 | name: WCAG tests 2 | 3 | on: 4 | pull_request: 5 | branches: [develop, hotfix/*, release/*, support/*] 6 | 7 | jobs: 8 | call_wcag_test: 9 | uses: yext/slapshot-reusable-workflows/.github/workflows/wcag_test.yml@v1 10 | with: 11 | fetch_depth: 0 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | **/.DS_Store 4 | .idea/ -------------------------------------------------------------------------------- /.postcssrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "postcss-pxtorem": { 4 | "rootValue": 16, 5 | "propList": ["*"], 6 | "mediaQuery": true 7 | }, 8 | "autoprefixer": { 9 | "overrideBrowserslist": ["last 2 versions"], 10 | "grid": true 11 | }, 12 | "cssnano": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.size-limit.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | path: 'dist/answers.min.js', 4 | limit: '200 KB' 5 | }, 6 | { 7 | path: 'dist/answers-modern.min.js', 8 | limit: '170 KB' 9 | }, 10 | { 11 | path: 'dist/answerstemplates.compiled.min.js', 12 | limit: '100 KB' 13 | } 14 | ]; 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | - [Reporting a Bug](#reporting-a-bug) 4 | - [Testing](#testing) 5 | - [Submitting Changes](#submitting-changes) 6 | 7 | # Reporting a Bug 8 | 9 | - Confirm the bug was not already reported by searching for existing 10 | [issues](https://github.com/yext/answers/issues). 11 | 12 | - If there's no existing issue, [create a new 13 | one](https://github.com/yext/answers/issues/new). Be sure to include a clear 14 | description and a code sample or executable test case that demonstrates the 15 | issue. 16 | 17 | # Testing 18 | 19 | Answers has a testing suite under the tests/ directory. Please include tests for 20 | all new code. 21 | 22 | - Build the bundles with `npm run build` 23 | 24 | - Run the existing test suite with `npm run test` 25 | 26 | - Fix any fixable linter errors with `npm run fix` 27 | 28 | - Add new tests to the tests/ directory mirroring the src/ directory 29 | 30 | # Submitting Changes 31 | 32 | All enhancement and bug fix pull requests should have an associated issue. 33 | Feature work is tracked outside of GitHub and may not have associated issues. If 34 | you'd like to submit an enhancement or bug fix PR, please ensure a relevant 35 | issue exists or create one. 36 | - Send a pull request with a clear list of what you've done with the base set to the desired version. 37 | - Include tests for the changes and link to all relevant issues. 38 | - If changes are requested, respond to all comments (either by making the change and resolving, or discussing the change) 39 | - Once all comments have been addressed, re-request a review from the reviewer. The PR will not be looked at until re-requested 40 | - Once the changes are approved, `Squash & Merge` onto the version branch. 41 | 42 | Your changes will then be included in that version of Answers when released. 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The Answers Search UI files listed in this repository are licensed under the below license.  All other features and products are subject to separate agreements 2 | and certain functionality requires paid subscriptions to Yext products. 3 | 4 | BSD 3-Clause License 5 | 6 | Copyright (c) 2022, Yext 7 | All rights reserved. 8 | 9 | Redistribution and use in source and binary forms, with or without 10 | modification, are permitted provided that the following conditions are met: 11 | 12 | 1. Redistributions of source code must retain the above copyright notice, this 13 | list of conditions and the following disclaimer. 14 | 15 | 2. Redistributions in binary form must reproduce the above copyright notice, 16 | this list of conditions and the following disclaimer in the documentation 17 | and/or other materials provided with the distribution. 18 | 19 | 3. Neither the name of the copyright holder nor the names of its 20 | contributors may be used to endorse or promote products derived from 21 | this software without specific prior written permission. 22 | 23 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 24 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 25 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 26 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 27 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 28 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 29 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 30 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 31 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 32 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 33 | -------------------------------------------------------------------------------- /__mocks__/@yext/search-core/lib/commonjs.js: -------------------------------------------------------------------------------- 1 | const provideCore = function () { 2 | return { 3 | universalSearch: jest.fn(() => { 4 | return Promise.resolve({ 5 | queryId: '123', 6 | verticalResults: [] 7 | }); 8 | }), 9 | verticalSearch: jest.fn(() => { 10 | return Promise.resolve({}); 11 | }), 12 | universalAutocomplete: jest.fn(() => { 13 | return Promise.resolve({}); 14 | }), 15 | verticalAutocomplete: jest.fn(() => { 16 | return Promise.resolve({}); 17 | }), 18 | filterSearch: jest.fn(() => { 19 | return Promise.resolve({}); 20 | }), 21 | submitQuestion: jest.fn(() => { 22 | return Promise.resolve({}); 23 | }) 24 | }; 25 | }; 26 | 27 | module.exports = { 28 | provideCore 29 | }; 30 | -------------------------------------------------------------------------------- /conf/cloud-regions/constants.js: -------------------------------------------------------------------------------- 1 | const { CloudRegion } = require('@yext/search-core'); 2 | 3 | exports.DEFAULT_CLOUD_REGION = CloudRegion.US; 4 | -------------------------------------------------------------------------------- /conf/gulp-tasks/bundle/minifytaskfactory.js: -------------------------------------------------------------------------------- 1 | const getBundleName = require('../utils/bundlename'); 2 | 3 | const { src, dest } = require('gulp'); 4 | const rename = require('gulp-rename'); 5 | const uglify = require('gulp-uglify-es').default; 6 | const sourcemaps = require('gulp-sourcemaps'); 7 | 8 | /** 9 | * A factory class that provides Gulp tasks to minify the SDK bundles. 10 | */ 11 | class MinifyTaskFactory { 12 | constructor (locale) { 13 | this._locale = locale; 14 | } 15 | 16 | /** 17 | * Provides a Gulp task to minify an SDK bundle of the specified type. 18 | * 19 | * @param {BundleType} bundleType The type of SDK bundle to minify. 20 | * @returns {Function} Gulp task for minifying the requested SDK bundle. 21 | */ 22 | minify (bundleType) { 23 | const bundleName = getBundleName(bundleType, this._locale); 24 | const minifyFunction = (callback) => this._minifyBundle(callback, bundleName); 25 | Object.defineProperty(minifyFunction, 'name', { value: `minify ${bundleName}` }); 26 | return minifyFunction; 27 | } 28 | 29 | /** 30 | * The Gulp task for minifying a version of the SDK bundle. 31 | * 32 | * @param {function} callback 33 | * @param {string} bundleName The name of the JS bundle 34 | * @returns {stream.Writable} A {@link Writable} stream containing the minified 35 | * SDK bundle. 36 | */ 37 | _minifyBundle (callback, bundleName) { 38 | return src(`./dist/${bundleName}.js`) 39 | .pipe(sourcemaps.init()) 40 | .pipe(rename(`${bundleName}.min.js`)) 41 | .pipe(uglify({ 42 | mangle: { reserved: ['ANSWERS'] } 43 | })) 44 | .pipe(sourcemaps.write('./')) 45 | .pipe(dest('dist')) 46 | .on('end', callback); 47 | } 48 | } 49 | 50 | module.exports = MinifyTaskFactory; 51 | -------------------------------------------------------------------------------- /conf/gulp-tasks/extracttranslations.gulpfile.js: -------------------------------------------------------------------------------- 1 | const { src } = require('gulp'); 2 | const { Writable } = require('stream'); 3 | const path = require('path'); 4 | const TranslationExtractor = require('../i18n/extract/translationextractor'); 5 | 6 | module.exports = function extractTranslations () { 7 | const extractor = new TranslationExtractor(); 8 | 9 | /** 10 | * Extracts messages from a given source file into the extractor. 11 | * 12 | * @param {vinyl.File} file 13 | * @param {string} encoding 14 | * @param {Function} next 15 | */ 16 | function extractMessagesFromFile (file, encoding, next) { 17 | const contents = file.contents.toString(); 18 | const pathFromBase = path.relative('./', file.path); 19 | extractor.extract(contents, pathFromBase); 20 | next(); 21 | } 22 | 23 | const processSourceFiles = new Writable({ 24 | objectMode: true, 25 | write: extractMessagesFromFile 26 | }); 27 | 28 | return src(['./src/ui/templates/**/*.hbs', './src/**/*.js']) 29 | .pipe(processSourceFiles) 30 | .on('finish', () => { 31 | extractor.savePotFile('./conf/i18n/translations/messages.pot'); 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /conf/gulp-tasks/template/createcleanfiles.js: -------------------------------------------------------------------------------- 1 | const { getPrecompiledFileName, addLocalePrefix } = require('./filenameutils'); 2 | 3 | const del = require('del'); 4 | 5 | /** 6 | * Creates a cleanFiles task for the specified locale, which will 7 | * clear away any intermediary files in the template build chain. 8 | * Also customizes the task's name for display in the command line. 9 | * 10 | * @param {string} locale 11 | * @returns {Function} 12 | */ 13 | function createCleanFilesTask (locale) { 14 | const cleanFilesTask = callback => _cleanFiles(callback, locale); 15 | const taskName = addLocalePrefix('cleanFiles', locale); 16 | Object.defineProperty(cleanFilesTask, 'name', { 17 | value: taskName 18 | }); 19 | return cleanFilesTask; 20 | } 21 | 22 | /** 23 | * The cleanFiles task for a specified locale. 24 | * 25 | * @param {Function} callback 26 | * @param {string} locale 27 | */ 28 | function _cleanFiles (callback, locale) { 29 | const precompiledFile = getPrecompiledFileName(locale); 30 | del.sync([`./dist/${precompiledFile}`]); 31 | callback(); 32 | } 33 | 34 | module.exports = createCleanFilesTask; 35 | -------------------------------------------------------------------------------- /conf/gulp-tasks/template/filenameutils.js: -------------------------------------------------------------------------------- 1 | const TemplateType = require('./templatetype'); 2 | 3 | const fileNames = { 4 | [TemplateType.UMD]: 'answerstemplates.compiled.min.js', 5 | [TemplateType.IIFE]: 'answerstemplates-iife.compiled.min.js' 6 | }; 7 | 8 | /** 9 | * Prefixes the given fileName with the locale. 10 | * 11 | * @param {string} fileName 12 | * @param {string} locale 13 | * @returns {string} 14 | */ 15 | function addLocalePrefix (fileName, locale = 'en') { 16 | const prefix = (locale === 'en') ? '' : `${locale}-`; 17 | return prefix + fileName; 18 | } 19 | exports.addLocalePrefix = addLocalePrefix; 20 | 21 | /** 22 | * Gets the output fileName for the specified templateType and locale. 23 | * 24 | * @param {TemplateType} templateType 25 | * @param {string} locale 26 | * @returns {string} 27 | */ 28 | exports.getFileName = function (templateType, locale) { 29 | const fileName = fileNames[templateType]; 30 | return addLocalePrefix(fileName, locale); 31 | }; 32 | 33 | /** 34 | * Gets the precompiled templates fileName for the given locale. 35 | * 36 | * @param {string} locale 37 | * @returns {string} 38 | */ 39 | exports.getPrecompiledFileName = function (locale) { 40 | const precompiledFileName = 'answerstemplates.precompiled.min.js'; 41 | return addLocalePrefix(precompiledFileName, locale); 42 | }; 43 | -------------------------------------------------------------------------------- /conf/gulp-tasks/template/templatetype.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The different types of templates the SDK supports. 3 | */ 4 | const TemplateType = { 5 | UMD: 'templates-UMD', 6 | IIFE: 'templates-IIFE' 7 | }; 8 | 9 | module.exports = TemplateType; 10 | -------------------------------------------------------------------------------- /conf/gulp-tasks/utils/bundlename.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Gets the name for the bundle based on the type and locale. 3 | * 4 | * @param {BundleType|string} bundleType 5 | * @param {string} locale 6 | * @returns {string} 7 | */ 8 | function getBundleName (bundleType, locale) { 9 | const localePrefix = locale && locale !== 'en' ? `${locale}-` : ''; 10 | return `${localePrefix}${bundleType}`; 11 | } 12 | 13 | module.exports = getBundleName; 14 | -------------------------------------------------------------------------------- /conf/gulp-tasks/utils/libversion.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('child_process'); 2 | 3 | /** 4 | * Attempts to compute the correct library version for an SDK asset. 5 | * 6 | * @returns {string} The SDK asset's library version. 7 | */ 8 | function getLibVersion () { 9 | try { 10 | const insideWorkTree = 11 | execSync('git rev-parse --is-inside-work-tree 2>/dev/null') 12 | .toString().trim(); 13 | if (insideWorkTree === 'true') { 14 | return require('child_process') 15 | .execSync('git describe --tags') 16 | .toString().trim(); 17 | } 18 | } catch (e) { 19 | console.error('Error getting lib version'); 20 | throw e; 21 | } 22 | } 23 | 24 | module.exports = getLibVersion; 25 | -------------------------------------------------------------------------------- /conf/i18n/extract/hbsplaceholderparser.js: -------------------------------------------------------------------------------- 1 | const Handlebars = require('handlebars'); 2 | 3 | const { fromMustacheStatementNode } = require('../translationplaceholderutils'); 4 | 5 | class HbsPlaceholderParser { 6 | constructor () { 7 | this._translateHelpers = ['translate']; 8 | } 9 | 10 | /** 11 | * Parses {@link TranslationPlaceholder}s from a given hbs template. 12 | * 13 | * @param {string} template 14 | * @param {string} filepath 15 | * @returns {Array} 16 | */ 17 | parse (template, filepath) { 18 | const tree = Handlebars.parseWithoutProcessing(template); 19 | const visitor = new Handlebars.Visitor(); 20 | const placeholderAccumulator = []; 21 | visitor.MustacheStatement = statement => { 22 | const placeholder = this._parseFromMustacheNode(statement, filepath); 23 | if (placeholder) { 24 | placeholderAccumulator.push(placeholder); 25 | } 26 | }; 27 | visitor.accept(tree); 28 | return placeholderAccumulator; 29 | } 30 | 31 | /** 32 | * Parses a {@link TranslationPlaceholder} from a single MustacheStatement. 33 | * 34 | * @param {hbs.AST.MustacheStatement} statement 35 | * @param {string} filepath 36 | * @returns {TranslationPlaceholder} 37 | */ 38 | _parseFromMustacheNode (statement, filepath) { 39 | const isTranslationHelper = this._translateHelpers.includes(statement.path.original); 40 | if (!isTranslationHelper) { 41 | return; 42 | } 43 | const placeholder = fromMustacheStatementNode(statement, filepath); 44 | return placeholder; 45 | } 46 | } 47 | 48 | module.exports = HbsPlaceholderParser; 49 | -------------------------------------------------------------------------------- /conf/i18n/extract/jsplaceholderparser.js: -------------------------------------------------------------------------------- 1 | const TranslateCallParser = require('../translatecallparser'); 2 | const { TRANSLATION_FLAGGER_REGEX } = require('../constants'); 3 | 4 | class JsPlaceholderParser { 5 | /** 6 | * Parses {@link TranslationPlaceholder}s from javascript code. 7 | * 8 | * @param {string} code 9 | * @param {string} filepath 10 | * @returns {Array} 11 | */ 12 | parse (code, filepath) { 13 | const matches = [...code.matchAll(TRANSLATION_FLAGGER_REGEX)]; 14 | return matches.map(match => this._parseJsCall(match, filepath)); 15 | } 16 | 17 | /** 18 | * Parses a {@link TranslationPlacehodler} from a regex match. 19 | * 20 | * @param {Array} match the regex match 21 | * @param {string} filepath 22 | * @returns {TranslationPlaceholder} 23 | */ 24 | _parseJsCall (match, filepath) { 25 | const { index, input } = match; 26 | const translateCall = match[0]; 27 | const lineNumber = input.substring(0, index).match(/\n/g).length + 1; 28 | const placeholder = new TranslateCallParser().parse(translateCall, lineNumber, filepath); 29 | return placeholder; 30 | } 31 | } 32 | 33 | module.exports = JsPlaceholderParser; 34 | -------------------------------------------------------------------------------- /conf/i18n/localebuildutils.js: -------------------------------------------------------------------------------- 1 | const { LANGUAGES_TO_LOCALES, ALL_LANGUAGES } = require('./constants'); 2 | const fs = require('fs'); 3 | 4 | /** 5 | * Iterate through SDK language build files and create symlinks for each locale defined 6 | * in LANGUAGES_TO_LOCALES. If the symlink already exists, delete it before creating a 7 | * new one. 8 | * 9 | * For example, if assetNames includes 'answers.js' and LANGUAGES_TO_LOCALES includes 10 | * 'fr_CA' and 'fr_FR' for the language 'fr', symlinks named 'fr_CA-answers.js' and 11 | * 'fr_FR-answers.js' will be created which point to 'fr-answers.js'. Builds for each 12 | * language must be created before this function is run. 13 | * 14 | * @param {Array} assetNames File names used for iteration and file generation 15 | * @param {string[]} languages a list of languages that requires copying assets for locales 16 | */ 17 | function copyAssetsForLocales (assetNames, languages = ALL_LANGUAGES) { 18 | languages.forEach((language) => { 19 | assetNames.forEach((assetName) => { 20 | const languageBundleName = language !== 'en' 21 | ? `${language}-${assetName}` 22 | : `${assetName}`; 23 | LANGUAGES_TO_LOCALES[language].forEach((locale) => { 24 | const localeBundleName = `${locale}-${assetName}`; 25 | if (fs.existsSync(`./dist/${localeBundleName}`)) { 26 | fs.unlinkSync(`./dist/${localeBundleName}`); 27 | } 28 | fs.symlinkSync(`./${languageBundleName}`, `./dist/${localeBundleName}`); 29 | }); 30 | }); 31 | }); 32 | } 33 | 34 | exports.copyAssetsForLocales = copyAssetsForLocales; 35 | -------------------------------------------------------------------------------- /conf/i18n/localfileparser.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { readFileSync, existsSync } = require('fs'); 3 | const { gettextToI18next } = require('i18next-conv'); 4 | 5 | /** 6 | * This class parses translations from a local .PO file. The i18next-conv 7 | * library is used to put the translations in i18next format. 8 | */ 9 | class LocalFileParser { 10 | /** 11 | * Creates a new instance of {@link LocalFileParser}. 12 | * 13 | * @param {string} translationsDir The local directory containing .PO files. 14 | * @param {Object} options Used to optionally configure the i18next-conv 15 | * library. 16 | */ 17 | constructor (translationsDir, options) { 18 | this._translationsDir = translationsDir; 19 | this._options = options; 20 | } 21 | 22 | /** 23 | * Extracts a locale's translations from the local filesystem. If no such 24 | * translations exist, an empty object is returned. 25 | * 26 | * @param {string} locale The desired locale. 27 | * @returns {Promise} A Promise containing the parsed translations in 28 | * i18next format. 29 | */ 30 | async fetch (locale) { 31 | const translationFile = path.join(this._translationsDir, `${locale}.po`); 32 | 33 | if (existsSync(translationFile)) { 34 | const localeTranslations = 35 | gettextToI18next(locale, readFileSync(translationFile), this._options); 36 | return localeTranslations.then(data => JSON.parse(data)); 37 | } 38 | 39 | return {}; 40 | } 41 | } 42 | module.exports = LocalFileParser; 43 | -------------------------------------------------------------------------------- /conf/npm/README.md: -------------------------------------------------------------------------------- 1 | This is a javascript library that helps you build search experiences on top of 2 | the Yext Answers product. The library provides many out of the box components. 3 | A full list of the components and how to use them can be found here: 4 | https://github.com/yext/answers-search-ui 5 | -------------------------------------------------------------------------------- /conf/npm/revert_npm_readme.sh: -------------------------------------------------------------------------------- 1 | # This is run after a publish using `npm publish` 2 | # We want to undo the changes done by use_npm_readme.sh 3 | 4 | mv ./README.tmp ./README.md 5 | -------------------------------------------------------------------------------- /conf/npm/use_npm_readme.sh: -------------------------------------------------------------------------------- 1 | # This is run before a publish using `npm publish` 2 | # We want to switch out the current (GitHub) README 3 | # with the short README we have for NPM. 4 | 5 | mv ./README.md ./README.tmp 6 | cp ./conf/npm/README.md ./README.md 7 | -------------------------------------------------------------------------------- /conf/templates/handlebarswrapper.txt: -------------------------------------------------------------------------------- 1 | <%= data.importStatements %> 2 | 3 | const ignoreHelpers = ['each']; 4 | let parseHelper = function(helpers) { 5 | 6 | for (let name in helpers) { 7 | if (typeof helpers[name] !== 'function') { 8 | parseHelper(helpers[name]); 9 | continue; 10 | } 11 | 12 | if (ignoreHelpers.indexOf(name) > -1) { 13 | continue; 14 | } 15 | Handlebars.registerHelper(name, helpers[name]); 16 | } 17 | } 18 | 19 | <%= data.handlebarsHelpers %> 20 | 21 | var context = context || {}; 22 | 23 | context['_hb'] = Handlebars; 24 | 25 | <%= data.contents %>; 26 | 27 | const autoinit = function() { 28 | if (window.ANSWERS && window.ANSWERS.templates) { 29 | ANSWERS.templates.register(context); 30 | } 31 | }() 32 | 33 | export { context as default }; 34 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const { parallel } = require('gulp'); 2 | 3 | const templates = require('./conf/gulp-tasks/templates.gulpfile.js'); 4 | const library = require('./conf/gulp-tasks/library.gulpfile.js'); 5 | const extractTranslations = require('./conf/gulp-tasks/extracttranslations.gulpfile.js'); 6 | 7 | const languageEnv = process.env.LANGUAGE; 8 | let languages; 9 | if (languageEnv && typeof languageEnv === 'string') { 10 | languages = languageEnv.split(','); 11 | } 12 | 13 | const cloudRegionEnv = process.env.CLOUD_REGION; 14 | let cloudRegion; 15 | if (cloudRegionEnv && typeof cloudRegionEnv === 'string') { 16 | cloudRegion = cloudRegionEnv; 17 | } 18 | 19 | exports.default = exports.build = parallel( 20 | templates.default, 21 | library.default 22 | ); 23 | exports.dev = parallel( 24 | templates.dev, 25 | library.dev 26 | ); 27 | exports.unminifiedLegacy = parallel( 28 | templates.unminifiedLegacy, 29 | library.unminifiedLegacy 30 | ); 31 | exports.buildLanguages = parallel( 32 | templates.buildLanguages, 33 | library.buildLanguages 34 | ); 35 | exports.buildLocales = parallel( 36 | library.buildLocales.bind(null, languages, cloudRegion), 37 | templates.buildLocales.bind(null, languages) 38 | ); 39 | 40 | exports.extractTranslations = extractTranslations; 41 | exports.templates = templates.default; 42 | 43 | exports.buildSearchBarOnlyAssets = parallel( 44 | library.buildSearchBarOnlyAssets.bind(null, languages, cloudRegion), 45 | templates.buildSearchBarOnlyAssets.bind(null, languages) 46 | ); 47 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "typeAcquisition": { 3 | "include": ["jest"] 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/core/analytics/analyticsevent.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Model for the analytics event type 3 | */ 4 | export default class AnalyticsEvent { 5 | constructor (type, label) { 6 | /** 7 | * The type of event to report 8 | * @type {string} 9 | */ 10 | this.eventType = type.toUpperCase(); 11 | 12 | /** 13 | * An optional label to be provided for the event 14 | * @type {string} 15 | */ 16 | if (label) { 17 | this.label = label; 18 | } 19 | } 20 | 21 | /** 22 | * Adds the provided options to the event 23 | * @param {object} options Additional options for the event 24 | */ 25 | addOptions (options) { 26 | Object.assign(this, options); 27 | return this; 28 | } 29 | 30 | /** 31 | * Return the event in the api format, typically for reporting to the api 32 | */ 33 | toApiEvent () { 34 | return Object.assign({}, this); 35 | } 36 | 37 | /** 38 | * Creating an analytics event from raw data. 39 | * @param {Object} data 40 | */ 41 | static fromData (data) { 42 | const { type, label, ...eventOptions } = data; 43 | const analyticsEvent = new AnalyticsEvent(type, label); 44 | analyticsEvent.addOptions(eventOptions); 45 | return analyticsEvent; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/core/analytics/createimpressionevent.js: -------------------------------------------------------------------------------- 1 | import Searcher from '../models/searcher'; 2 | import AnalyticsEvent from './analyticsevent'; 3 | 4 | /** 5 | * Creates an SEARCH_BAR_IMPRESSION analytics event 6 | * @param {Object} options 7 | * @param {string} options.verticalKey Optional, indicates the vertical associated with the impression 8 | * @param {boolean} options.standAlone Indicates whether or not the impression came 9 | * from the standalone search bar 10 | * @returns AnalyticsEvent 11 | */ 12 | export default function createImpressionEvent ({ 13 | verticalKey = undefined, 14 | standAlone = false 15 | }) { 16 | return AnalyticsEvent.fromData({ 17 | type: 'SEARCH_BAR_IMPRESSION', 18 | searcher: verticalKey ? Searcher.VERTICAL : Searcher.UNIVERSAL, 19 | ...verticalKey && { verticalConfigId: verticalKey }, 20 | standAlone 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/core/analytics/noopanalyticsreporter.js: -------------------------------------------------------------------------------- 1 | /** @typedef {import('../services/analyticsreporterservice').default} AnalyticsReporterService */ 2 | 3 | /** 4 | * @implements {AnalyticsReporterService} 5 | */ 6 | export default class NoopAnalyticsReporter { 7 | /** @inheritdoc */ 8 | report (event) { 9 | return true; 10 | } 11 | 12 | /** @inheritdoc */ 13 | setConversionTrackingEnabled (isEnabled) {} 14 | } 15 | -------------------------------------------------------------------------------- /src/core/constants.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | /** The current lib version, reported with errors and analytics, injected by the build process */ 4 | export const LIB_VERSION = '@@LIB_VERSION'; 5 | 6 | /** The current locale, injected by the build process */ 7 | export const LOCALE = '@@LOCALE'; 8 | 9 | /** The speech recognition locales supported by Microsoft Edge */ 10 | export const SPEECH_RECOGNITION_LOCALES_SUPPORTED_BY_EDGE = '@@SPEECH_RECOGNITION_LOCALES_SUPPORTED_BY_EDGE'; 11 | 12 | /** The cloud region being used, injected by the build process */ 13 | export const CLOUD_REGION = '@@CLOUD_REGION'; 14 | /** The identifier for all cloud providers */ 15 | export const GLOBAL_MULTI = 'multi'; 16 | 17 | /** The identifier for using GCP */ 18 | export const GLOBAL_GCP = 'gcp'; 19 | 20 | /** The identifier of the production environment */ 21 | export const PRODUCTION = 'production'; 22 | 23 | /** The identifier of the sandbox environment */ 24 | export const SANDBOX = 'sandbox'; 25 | 26 | /** The default url for compiled component templates */ 27 | export const COMPILED_TEMPLATES_URL = `https://assets.sitescdn.net/answers/${LIB_VERSION}/answerstemplates.compiled.min.js`; 28 | 29 | /** The default query source reported with analytics */ 30 | export const QUERY_SOURCE = 'STANDARD'; 31 | 32 | export const ENDPOINTS = { 33 | UNIVERSAL_SEARCH: '/v2/accounts/me/answers/query', 34 | VERTICAL_SEARCH: '/v2/accounts/me/answers/vertical/query', 35 | QUESTION_SUBMISSION: '/v2/accounts/me/createQuestion', 36 | UNIVERSAL_AUTOCOMPLETE: '/v2/accounts/me/answers/autocomplete', 37 | VERTICAL_AUTOCOMPLETE: '/v2/accounts/me/answers/vertical/autocomplete', 38 | FILTER_SEARCH: '/v2/accounts/me/answers/filtersearch' 39 | }; 40 | -------------------------------------------------------------------------------- /src/core/errors/consoleerrorreporter.js: -------------------------------------------------------------------------------- 1 | /** @typedef {import('../services/errorreporterservice').default} ErrorReporterService */ 2 | 3 | /** 4 | * @implements {ErrorReporterService} 5 | */ 6 | export default class ConsoleErrorReporter { 7 | /** @inheritdoc */ 8 | report (err) { 9 | console.error(err.toString()); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/core/filters/filtercombinators.js: -------------------------------------------------------------------------------- 1 | /** @module FilterCombinators */ 2 | 3 | /** 4 | * FilterCombinators are enums for valid ways to combine {@link Filter}s. 5 | */ 6 | const FilterCombinators = { 7 | AND: '$and', 8 | OR: '$or' 9 | }; 10 | 11 | export default FilterCombinators; 12 | -------------------------------------------------------------------------------- /src/core/filters/filtermetadata.js: -------------------------------------------------------------------------------- 1 | /** @module FilterMetadata */ 2 | 3 | import FilterType from './filtertype'; 4 | 5 | /** 6 | * FilterMetadata is a container for additional display data for a {@link Filter}. 7 | */ 8 | export default class FilterMetadata { 9 | constructor (metadata = {}) { 10 | const { fieldName, displayValue, filterType } = metadata; 11 | 12 | /** 13 | * The display name for the field being filtered on. 14 | * @type {string} 15 | */ 16 | this.fieldName = fieldName; 17 | 18 | /** 19 | * The display value for the values being filtered on. 20 | * Even if there are multiple values within the data of a filter, 21 | * there should only be one display value for the whole filter. 22 | * @type {string} 23 | */ 24 | this.displayValue = displayValue; 25 | 26 | /** 27 | * What type of filter this is. 28 | * @type {FilterType} 29 | */ 30 | this.filterType = filterType || FilterType.STATIC; 31 | Object.freeze(this); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/core/filters/filternode.js: -------------------------------------------------------------------------------- 1 | /** @module FilterNode */ 2 | 3 | /** 4 | * A FilterNode represents a single node in a filter tree. 5 | * Each filter node has an associated filter, containing the filter 6 | * data to send in a request, any additional filter metadata for display, 7 | * and any children nodes. 8 | * 9 | * Implemented by {@link SimpleFilterNode} and {@link CombinedFilterNode}. 10 | */ 11 | export default class FilterNode { 12 | /** 13 | * Returns this node's filter. 14 | * @returns {Filter} 15 | */ 16 | getFilter () {} 17 | 18 | /** 19 | * Returns the metadata for this node's filter. 20 | * @returns {FilterMetadata} 21 | */ 22 | getMetadata () {} 23 | 24 | /** 25 | * Returns the children of this node. 26 | * @returns {Array} 27 | */ 28 | getChildren () {} 29 | 30 | /** 31 | * Recursively get all of the leaf SimpleFilterNodes. 32 | * @returns {Array} 33 | */ 34 | getSimpleDescendants () {} 35 | 36 | /** 37 | * Remove this FilterNode from the FilterRegistry. 38 | */ 39 | remove () {} 40 | } 41 | -------------------------------------------------------------------------------- /src/core/filters/filtertype.js: -------------------------------------------------------------------------------- 1 | /** @module FilterTypes */ 2 | 3 | /** 4 | * FilterType is an ENUM for the different types of filters in the SDK. 5 | * @enum {string} 6 | */ 7 | const FilterType = { 8 | STATIC: 'filter-type-static', 9 | FACET: 'filter-type-facet', 10 | RADIUS: 'filter-type-radius', 11 | NLP: 'filter-type-nlp' 12 | }; 13 | 14 | export default FilterType; 15 | -------------------------------------------------------------------------------- /src/core/filters/matcher.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A Matcher is a filtering operation for {@link Filter}s. 3 | */ 4 | const Matcher = { 5 | Equals: '$eq', 6 | NotEquals: '!$eq', 7 | LessThan: '$lt', 8 | LessThanOrEqualTo: '$le', 9 | GreaterThan: '$gt', 10 | GreaterThanOrEqualTo: '$ge', 11 | Near: '$near' 12 | }; 13 | export default Matcher; 14 | -------------------------------------------------------------------------------- /src/core/index.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | export { default as AnalyticsReporter } from './analytics/analyticsreporter'; 4 | export { default as NoopAnalyticsReporter } from './analytics/noopanalyticsreporter'; 5 | export { default as ModuleData } from './storage/moduledata'; 6 | export { default as Storage } from './storage/storage'; 7 | -------------------------------------------------------------------------------- /src/core/models/alternativevertical.js: -------------------------------------------------------------------------------- 1 | import { AnswersConfigError } from '../errors/errors'; 2 | 3 | /** 4 | * The AlternativeVertical is a model that is used to power the search 5 | * suggestions info box. It's initialized through the configuration provided 6 | * to the component. 7 | */ 8 | export default class AlternativeVertical { 9 | constructor (config) { 10 | /** 11 | * The name of the vertical that is exposed for the link 12 | * @type {string} 13 | */ 14 | this.label = config.label; 15 | if (typeof this.label !== 'string') { 16 | throw new AnswersConfigError( 17 | 'label is a required configuration option for verticalPage.', 18 | 'AlternativeVertical' 19 | ); 20 | } 21 | 22 | /** 23 | * The complete URL, including the params 24 | * @type {string} 25 | */ 26 | this.url = config.url; 27 | if (typeof this.url !== 'string') { 28 | throw new AnswersConfigError( 29 | 'url is a required configuration option for verticalPage.', 30 | 'AlternativeVertical' 31 | ); 32 | } 33 | 34 | /** 35 | * name of an icon from the default icon set 36 | * @type {string} 37 | */ 38 | this.iconName = config.iconName; 39 | 40 | /** 41 | * URL of an icon 42 | * @type {string} 43 | */ 44 | this.iconUrl = config.iconUrl; 45 | 46 | /** 47 | * Whether the vertical has an icon 48 | * @type {string} 49 | */ 50 | this.hasIcon = this.iconName || this.iconUrl; 51 | 52 | /** 53 | * The number of results to display next to each alternative 54 | * vertical 55 | * @type {number} 56 | */ 57 | this.resultsCount = config.resultsCount; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/core/models/alternativeverticals.js: -------------------------------------------------------------------------------- 1 | /** @module AlternativeVerticals */ 2 | 3 | import VerticalResults from './verticalresults'; 4 | 5 | export default class AlternativeVerticals { 6 | constructor (data) { 7 | /** 8 | * Alternative verticals that have results for the current query 9 | * @type {Section} 10 | */ 11 | this.alternativeVerticals = data || []; 12 | } 13 | 14 | /** 15 | * Create alternative verticals from server data 16 | * 17 | * @param {Object[]} alternativeVerticals 18 | * @param {Object} formatters applied to the result fields 19 | */ 20 | static fromCore (alternativeVerticals, formatters) { 21 | if (!alternativeVerticals || alternativeVerticals.length === 0) { 22 | return new AlternativeVerticals(); 23 | } 24 | 25 | return new AlternativeVerticals(alternativeVerticals.map(alternativeVertical => { 26 | return VerticalResults.fromCore(alternativeVertical, {}, formatters); 27 | })); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/core/models/appliedqueryfilter.js: -------------------------------------------------------------------------------- 1 | import Filter from './filter'; 2 | 3 | /** 4 | * A model that represents a filter that the backend applied to a search 5 | */ 6 | export default class AppliedQueryFilter { 7 | constructor (appliedQueryFilter = {}) { 8 | this.key = appliedQueryFilter.key; 9 | this.value = appliedQueryFilter.value; 10 | this.filter = appliedQueryFilter.filter; 11 | this.fieldId = appliedQueryFilter.fieldId; 12 | } 13 | 14 | /** 15 | * Constructs an SDK AppliedQueryFilter from an search-core AppliedQueryFilter 16 | * 17 | * @param {AppliedQueryFilter} appliedFilter from search-core 18 | * @returns {@link AppliedQueryFilter} 19 | */ 20 | static fromCore (appliedFilter) { 21 | if (!appliedFilter) { 22 | return new AppliedQueryFilter(); 23 | } 24 | 25 | return new AppliedQueryFilter({ 26 | key: appliedFilter.displayKey, 27 | value: appliedFilter.displayValue, 28 | filter: Filter.fromCoreSimpleFilter(appliedFilter.filter), 29 | fieldId: appliedFilter.filter.fieldId 30 | }); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/core/models/autocompletedata.js: -------------------------------------------------------------------------------- 1 | /** @module AutoCompleteData */ 2 | 3 | export default class AutoCompleteData { 4 | constructor (data = {}) { 5 | this.sections = data.sections || []; 6 | this.queryId = data.queryId || ''; 7 | this.inputIntents = data.inputIntents || []; 8 | this.businessId = data.businessId || ''; 9 | Object.freeze(this); 10 | } 11 | } 12 | 13 | export class AutoCompleteResult { 14 | constructor (data = {}) { 15 | this.filter = data.filter || {}; 16 | this.key = data.key || ''; 17 | this.matchedSubstrings = data.matchedSubstrings || []; 18 | this.value = data.value || ''; 19 | this.shortValue = data.shortValue || this.value; 20 | this.intents = data.queryIntents || []; 21 | Object.freeze(this); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/core/models/dynamicfilters.js: -------------------------------------------------------------------------------- 1 | /** @module DynamicFilters */ 2 | 3 | import ResultsContext from '../storage/resultscontext'; 4 | 5 | /** 6 | * Model representing a set of dynamic filters 7 | */ 8 | export default class DynamicFilters { 9 | constructor (data) { 10 | /** 11 | * The list of facets this model holds 12 | * @type {DisplayableFacet[]} from search-core 13 | */ 14 | this.filters = data.filters || []; 15 | 16 | /** 17 | * The {@link ResultsContext} of the facets. 18 | * @type {ResultsContext} 19 | */ 20 | this.resultsContext = data.resultsContext; 21 | Object.freeze(this); 22 | } 23 | 24 | /** 25 | * Organize 'facets' from the search-core into dynamic filters 26 | * @param {DisplayableFacet[]} facets from search-core 27 | * @param {ResultsContext} resultsContext 28 | * @returns {DynamicFilters} 29 | */ 30 | static fromCore (facets = [], resultsContext = ResultsContext.NORMAL) { 31 | return new DynamicFilters({ 32 | filters: facets, 33 | resultsContext: resultsContext 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/core/models/facet.js: -------------------------------------------------------------------------------- 1 | /** @module Facet */ 2 | 3 | /** 4 | * Model representing a facet filter with the format of 5 | * { 6 | * "field_name": [ Filters... ], 7 | * ... 8 | * } 9 | * 10 | * @see {@link Filter} 11 | */ 12 | export default class Facet { 13 | constructor (data = {}) { 14 | Object.assign(this, data); 15 | Object.freeze(this); 16 | } 17 | 18 | /** 19 | * Create a facet filter from a list of Filters 20 | * @param {Array} availableFieldIds array of expected field ids 21 | * @param {...Filter} filters The filters to use in this facet 22 | * @returns {Facet} 23 | */ 24 | static fromFilters (availableFieldIds, ...filters) { 25 | const groups = {}; 26 | availableFieldIds.forEach(fieldId => { 27 | groups[fieldId] = []; 28 | }); 29 | const flatFilters = filters.flatMap(f => f.$or || f); 30 | flatFilters.forEach(f => { 31 | const key = f.getFilterKey(); 32 | if (!groups[key]) { 33 | groups[key] = []; 34 | } 35 | groups[key].push(f); 36 | }); 37 | 38 | return new Facet(groups); 39 | } 40 | 41 | /** 42 | * Transforms an search-core DisplayableFacet array into a Facet array 43 | * 44 | * @param {DisplayableFacet[]} coreFacets from search-core 45 | * @returns {Facet[]} 46 | */ 47 | static fromCore (coreFacets = []) { 48 | const facets = coreFacets.map(f => ({ 49 | label: f.displayName, 50 | fieldId: f.fieldId, 51 | options: f.options.map(o => ({ 52 | label: o.displayName, 53 | countLabel: o.count, 54 | selected: o.selected, 55 | filter: { 56 | [f.fieldId]: { 57 | [o.matcher]: o.value 58 | } 59 | } 60 | })) 61 | })).map(f => new Facet(f)); 62 | return facets; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/core/models/highlightedfields.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents highlighted fields without the highlighting applied 3 | */ 4 | export default class HighlightedFields { 5 | /** 6 | * Constructs a highlighted fields object which consists of fields mapping to HighlightedValues 7 | * 8 | * @param {import('@yext/search-core').HighlightedFields} highlightedFields 9 | * 10 | * Example object: 11 | * 12 | * { 13 | * description: { 14 | * value: 'likes apple pie and green apples', 15 | * matchedSubstrings: [ 16 | * { offset: 6, length: 5 }, 17 | * { offset: 26, length: 5 } 18 | * ] 19 | * }, 20 | * c_favoriteFruits: [ 21 | * { 22 | * apples: [ 23 | * { 24 | * value: 'Granny Smith', 25 | * matchedSubstrings: [] 26 | * }, 27 | * { 28 | * value: 'Upton Pyne Apple', 29 | * matchedSubstrings: [{ offset: 11, length: 5}] 30 | * } 31 | * ], 32 | * pears: [ 33 | * { 34 | * value: 'Callery Pear', 35 | * matchedSubstrings: [] 36 | * } 37 | * ] 38 | * } 39 | * ] 40 | * } 41 | */ 42 | constructor (highlightedFields = {}) { 43 | Object.assign(this, highlightedFields); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/core/models/navigation.js: -------------------------------------------------------------------------------- 1 | /** @module Navigation */ 2 | 3 | export default class Navigation { 4 | constructor (tabOrder) { 5 | this.tabOrder = tabOrder || []; 6 | Object.freeze(this); 7 | } 8 | 9 | static from (modules) { 10 | const nav = []; 11 | if (!modules || !Array.isArray(modules)) { 12 | return nav; 13 | } 14 | for (let i = 0; i < modules.length; i++) { 15 | nav.push(modules[i].verticalConfigId); 16 | } 17 | return new Navigation(nav); 18 | } 19 | 20 | /** 21 | * Constructs a Navigation model from an search-core VerticalResults array 22 | * 23 | * @param {VerticalResults[]} verticalResults 24 | */ 25 | static fromCore (verticalResults) { 26 | const verticalKeys = verticalResults.map(result => result.verticalKey); 27 | return new Navigation(verticalKeys); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/core/models/querytriggers.js: -------------------------------------------------------------------------------- 1 | /** @module QueryTriggers */ 2 | 3 | /** 4 | * QueryTriggers is an ENUM of the possible triggers for a 5 | * query update. 6 | * 7 | * @enum {string} 8 | */ 9 | const QueryTriggers = { 10 | INITIALIZE: 'initialize', 11 | QUERY_PARAMETER: 'query-parameter', 12 | SUGGEST: 'suggest', 13 | FILTER_COMPONENT: 'filter-component', 14 | PAGINATION: 'pagination', 15 | SEARCH_BAR: 'search-bar', 16 | VOICE_SEARCH: 'voice-search' 17 | }; 18 | export default QueryTriggers; 19 | -------------------------------------------------------------------------------- /src/core/models/searchconfig.js: -------------------------------------------------------------------------------- 1 | import { AnswersConfigError } from '../errors/errors'; 2 | 3 | /** @module SearchConfig */ 4 | 5 | export default class SearchConfig { 6 | constructor (config = {}) { 7 | /** 8 | * The max results per search. 9 | * Also defines the number of results per page, if pagination is enabled 10 | * @type {number} 11 | */ 12 | this.limitForVertical = config.limit || 20; 13 | 14 | /** 15 | * The max results per vertical for universal search. 16 | * @type {Object} 17 | */ 18 | this.universalLimit = config.universalLimit; 19 | 20 | /** 21 | * The vertical key to use for all searches 22 | * @type {string} 23 | */ 24 | this.verticalKey = config.verticalKey || null; 25 | 26 | /** 27 | * A default search to use on initialization when the user hasn't provided a query 28 | * @type {string} 29 | */ 30 | this.defaultInitialSearch = config.defaultInitialSearch; 31 | 32 | this.validate(); 33 | Object.freeze(this); 34 | } 35 | 36 | validate () { 37 | if (typeof this.limitForVertical !== 'number' || this.limitForVertical < 1 || this.limitForVertical > 50) { 38 | throw new AnswersConfigError('Search Limit must be between 1 and 50, inclusive', 'SearchConfig'); 39 | } 40 | 41 | if (typeof this.universalLimit === 'object' && this.universalLimit !== null) { 42 | Object.values(this.universalLimit).forEach((value) => { 43 | if (typeof value !== 'number' || value < 1 || value > 50) { 44 | throw new AnswersConfigError('Universal limits must be between 1 and 50, inclusive', 'SearchConfig'); 45 | } 46 | }); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/core/models/searcher.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Searcher is an ENUM of the possible search experiences. This 3 | * is use as part of analytic events payload. 4 | */ 5 | const Searcher = { 6 | UNIVERSAL: 'UNIVERSAL', 7 | VERTICAL: 'VERTICAL' 8 | }; 9 | export default Searcher; 10 | -------------------------------------------------------------------------------- /src/core/models/section.js: -------------------------------------------------------------------------------- 1 | /** @module Section */ 2 | 3 | import SearchStates from '../storage/searchstates'; 4 | 5 | export default class Section { 6 | constructor (data = {}, url = null, resultsContext = undefined) { 7 | this.searchState = SearchStates.SEARCH_COMPLETE; 8 | this.verticalConfigId = data.verticalConfigId || null; 9 | this.resultsCount = data.resultsCount || 0; 10 | this.encodedState = data.encodedState || ''; 11 | this.appliedQueryFilters = data.appliedQueryFilters; 12 | this.facets = data.facets || null; 13 | this.results = data.results; 14 | this.map = Section.parseMap(data.results); 15 | this.verticalURL = url || null; 16 | this.resultsContext = resultsContext; 17 | this.searchCoreDocument = data.searchCoreDocument || null; 18 | } 19 | 20 | static parseMap (results) { 21 | if (!results) { 22 | return {}; 23 | } 24 | 25 | const mapMarkers = []; 26 | 27 | let centerCoordinates = {}; 28 | 29 | for (let resultIndex = 0; resultIndex < results.length; resultIndex++) { 30 | const result = results[resultIndex]._raw; 31 | if (result && result.yextDisplayCoordinate) { 32 | if (!centerCoordinates.latitude) { 33 | centerCoordinates = { 34 | latitude: result.yextDisplayCoordinate.latitude, 35 | longitude: result.yextDisplayCoordinate.longitude 36 | }; 37 | } 38 | mapMarkers.push({ 39 | item: result, 40 | label: resultIndex + 1, 41 | latitude: result.yextDisplayCoordinate.latitude, 42 | longitude: result.yextDisplayCoordinate.longitude 43 | }); 44 | } 45 | } 46 | 47 | return { 48 | mapCenter: centerCoordinates, 49 | mapMarkers: mapMarkers 50 | }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/core/models/spellcheck.js: -------------------------------------------------------------------------------- 1 | /** @module SpellCheck */ 2 | 3 | /** 4 | * SpellCheck is the core state model 5 | * to power the SpellCheck component 6 | */ 7 | export default class SpellCheck { 8 | constructor (data) { 9 | /** 10 | * The original query 11 | * @type {string} 12 | */ 13 | this.query = data.query || null; 14 | 15 | /** 16 | * The corrected query 17 | * @type {string} 18 | */ 19 | this.correctedQuery = data.correctedQuery || null; 20 | 21 | /** 22 | * The spell check type 23 | * @type {string} 24 | */ 25 | this.type = data.type || null; 26 | 27 | /** 28 | * Should show spell check or not 29 | * @type {boolean} 30 | */ 31 | this.shouldShow = this.correctedQuery !== null; 32 | } 33 | 34 | /** 35 | * Create a spell check model from the provided data 36 | * 37 | * @param {Object} response The spell check response 38 | */ 39 | static fromCore (response) { 40 | if (!response) { 41 | return {}; 42 | } 43 | 44 | return new SpellCheck({ 45 | query: response.originalQuery, 46 | correctedQuery: { 47 | value: response.correctedQuery 48 | }, 49 | type: response.type 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/core/models/universalresults.js: -------------------------------------------------------------------------------- 1 | /** @module UniversalResults */ 2 | 3 | import SearchStates from '../storage/searchstates'; 4 | import VerticalResults from './verticalresults'; 5 | 6 | export default class UniversalResults { 7 | constructor (data) { 8 | this.queryId = data.queryId || null; 9 | this.sections = data.sections || []; 10 | 11 | /** 12 | * The current state of the search, used to render different templates before, during, 13 | * and after loading 14 | * @type {SearchState} 15 | */ 16 | this.searchState = data.searchState || SearchStates.SEARCH_COMPLETE; 17 | } 18 | 19 | /** 20 | * Construct a UniversalResults object representing loading results 21 | * @return {UniversalResults} 22 | */ 23 | static searchLoading () { 24 | return new UniversalResults({ searchState: SearchStates.SEARCH_LOADING }); 25 | } 26 | 27 | /** 28 | * Constructs an SDK UniversalResults model from an search-core UniversalSearchResponse 29 | * 30 | * @param {UniversalSearchResponse} response from search-core 31 | * @param {Object} urls keyed by vertical key 32 | * @param {Object} formatters applied to the result fields 33 | * @returns {@link UniversalResults} 34 | */ 35 | static fromCore (response, urls, formatters) { 36 | if (!response) { 37 | return new UniversalResults(); 38 | } 39 | 40 | return new UniversalResults({ 41 | queryId: response.queryId, 42 | sections: response.verticalResults.map(verticalResults => { 43 | return VerticalResults.fromCore(verticalResults, urls, formatters); 44 | }) 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/core/search/searchoptionsfactory.js: -------------------------------------------------------------------------------- 1 | import QueryTriggers from '../models/querytriggers'; 2 | 3 | /** 4 | * SearchOptionsFactory is responsible for determining what search options to use 5 | * for a given QUERY_TRIGGER. 6 | */ 7 | export default class SearchOptionsFactory { 8 | /** 9 | * Given a QUERY_TRIGGER, return the search options for the given trigger. 10 | * 11 | * @returns {Object} 12 | */ 13 | create (queryTrigger) { 14 | switch (queryTrigger) { 15 | case QueryTriggers.FILTER_COMPONENT: 16 | return { 17 | setQueryParams: true, 18 | resetPagination: true, 19 | useFacets: true 20 | }; 21 | case QueryTriggers.PAGINATION: 22 | return { 23 | setQueryParams: true, 24 | resetPagination: false, 25 | useFacets: true, 26 | sendQueryId: true 27 | }; 28 | case QueryTriggers.QUERY_PARAMETER: 29 | case QueryTriggers.INITIALIZE: 30 | return { 31 | setQueryParams: true, 32 | resetPagination: false, 33 | useFacets: true 34 | }; 35 | case QueryTriggers.SUGGEST: 36 | return { 37 | setQueryParams: true, 38 | resetPagination: true, 39 | useFacets: true 40 | }; 41 | default: 42 | return { 43 | setQueryParams: true, 44 | resetPagination: true 45 | }; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/core/services/analyticsreporterservice.js: -------------------------------------------------------------------------------- 1 | /** @typedef {import('../analytics/analyticsevent').default} AnalyticsEvent */ 2 | 3 | /** 4 | * AnslyticsReporterService exposes an interface for reporting analytics events 5 | * to a backend 6 | * 7 | * @interface 8 | */ 9 | export default class AnalyticsReporterService { 10 | /** 11 | * Report an analytics event 12 | * @param {AnalyticsEvent} event 13 | * @returns {boolean} whether the event was successfully reported 14 | */ 15 | report (event) {} 16 | 17 | /** 18 | * Enable or disable conversion tracking 19 | * @param {boolean} isEnabled 20 | */ 21 | setConversionTrackingEnabled (isEnabled) {} 22 | } 23 | -------------------------------------------------------------------------------- /src/core/services/errorreporterservice.js: -------------------------------------------------------------------------------- 1 | /** @typedef {import('../errors/errors').AnswersBaseError} AnswersBaseError */ 2 | 3 | /** 4 | * ErrorReporterService exposes an interface for reporting errors to the console 5 | * and to a backend 6 | * 7 | * @interface 8 | */ 9 | export default class ErrorReporterService { 10 | /** 11 | * Reports an error to backend servers for logging 12 | * @param {AnswersBaseError} err The error to be reported 13 | */ 14 | report (err) {} // eslint-disable-line 15 | } 16 | -------------------------------------------------------------------------------- /src/core/speechrecognition/locales.js: -------------------------------------------------------------------------------- 1 | import { SPEECH_RECOGNITION_LOCALES_SUPPORTED_BY_EDGE } from '../constants'; 2 | import { parseLocale } from '../utils/i18nutils'; 3 | 4 | /** 5 | * Transforms the given locale to a locale Microsoft Edge can understand. 6 | * This means changing the language/locale separating underscore to a dash, 7 | * and defaulting the locale to the 2 character language code if it is not 8 | * supported by Edge. 9 | * 10 | * @param {string} locale 11 | * @returns {string} 12 | */ 13 | export function transformSpeechRecognitionLocaleForEdge (rawLocale) { 14 | const { language, modifier, region } = parseLocale(rawLocale); 15 | if (!modifier && !region) { 16 | return language; 17 | } 18 | const locale = formatLocaleForEdge(language, modifier, region); 19 | const isCompatibleWithEdge = SPEECH_RECOGNITION_LOCALES_SUPPORTED_BY_EDGE.includes(locale); 20 | if (isCompatibleWithEdge) { 21 | return locale; 22 | } 23 | if (modifier) { 24 | return formatLocaleForEdge(language, modifier); 25 | } 26 | return language; 27 | } 28 | 29 | /** 30 | * Formats a locale code given its constituent parts for Edge (which does not accept underscores). 31 | * Edge does not care about capitalization, but converting to full lowercase allows for easier lookup 32 | * within the SPEECH_RECOGNITION_LOCALES_SUPPORTED_BY_EDGE array. 33 | * 34 | * @param {string} language zh in zh-Hans_CH 35 | * @param {string?} modifier Hans in zh-Hans_CH 36 | * @param {string?} region CH in zh-Hans_CH 37 | * @returns 38 | */ 39 | function formatLocaleForEdge (language, modifier, region) { 40 | let result = language; 41 | if (modifier) { 42 | result += '-' + modifier; 43 | } 44 | if (region) { 45 | result += '-' + region; 46 | } 47 | return result.toLowerCase(); 48 | } 49 | -------------------------------------------------------------------------------- /src/core/speechrecognition/support.js: -------------------------------------------------------------------------------- 1 | import Bowser from 'bowser'; 2 | 3 | /** 4 | * Whether the SpeechRecognition API is supported by the current browser. 5 | * 6 | * Currently all languages in the SDK (en, es, fr, de, it, ja) 7 | * have SpeechRecognition support in browsers that support SpeechRecognition. 8 | * However, because browser specific SpeechRecognition documentation is poor to nonexistent, 9 | * new languages/locales will need to be manually tested for SpeechRecognition support. 10 | * 11 | * @returns {boolean} 12 | */ 13 | export function speechRecognitionIsSupported () { 14 | if (!(window.webkitSpeechRecognition && navigator.mediaDevices)) { 15 | return false; 16 | } 17 | 18 | const browserData = Bowser.parse(navigator.userAgent); 19 | if (browserData.platform.type === 'desktop') { 20 | return true; 21 | } 22 | 23 | const os = browserData.os.name; 24 | const browser = browserData.browser.name; 25 | 26 | return (browser === 'Safari' && os === 'iOS') || 27 | (browser === 'Chrome' && os === 'Android') || 28 | (browser === 'Samsung Internet for Android' && os === 'Android'); 29 | } 30 | -------------------------------------------------------------------------------- /src/core/storage/resultscontext.js: -------------------------------------------------------------------------------- 1 | /** @module ResultsContext */ 2 | 3 | /** 4 | * ResultsContext is an ENUM that provides context 5 | * for the results that we are storing from server 6 | * data 7 | * @enum {string} 8 | */ 9 | export default { 10 | NORMAL: 'normal', 11 | NO_RESULTS: 'no-results' 12 | }; 13 | -------------------------------------------------------------------------------- /src/core/storage/searchstates.js: -------------------------------------------------------------------------------- 1 | /** @module SearchStates */ 2 | 3 | /** 4 | * @typedef {string} SearchState 5 | */ 6 | 7 | /** 8 | * SearchStates is an ENUM for the various stages of searching, 9 | * used to show different templates 10 | * @enum {string} 11 | */ 12 | const SearchStates = { 13 | PRE_SEARCH: 'pre-search', 14 | SEARCH_LOADING: 'search-loading', 15 | SEARCH_COMPLETE: 'search-complete' 16 | }; 17 | export default SearchStates; 18 | -------------------------------------------------------------------------------- /src/core/storage/storageindexes.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | /** 4 | * StorageIndexes is an ENUM are considered the root context 5 | * for how data is stored and scoped in the storage. 6 | * 7 | * @enum {string} 8 | */ 9 | export default { 10 | /** 11 | * The global index that should contain all application 12 | * specific globals for the application (e.g. search params) 13 | */ 14 | GLOBAL: 'global', 15 | 16 | /** 17 | * The Navigation index contains all data to power the navigation component. 18 | * Sometimes other components might depend directly on this as well, but 19 | * we've opted to try to store some of that data in the global index instead. 20 | */ 21 | NAVIGATION: 'navigation', 22 | 23 | /** 24 | * The Universal Results index for all data related to search results 25 | * for universal search 26 | */ 27 | UNIVERSAL_RESULTS: 'universal-results', 28 | 29 | /** 30 | * The Vertical Results index for all data related to search results 31 | * for vertical search 32 | */ 33 | VERTICAL_RESULTS: 'vertical-results', 34 | 35 | /** 36 | * The Autocomplete index contains state to power the auto complete component 37 | * This data is powered by network requests for both vertical and universal. 38 | */ 39 | AUTOCOMPLETE: 'autocomplete', 40 | 41 | /** 42 | * The direct answer index contains all the data to power the Direct Answer component 43 | * Typically this index is powered from universal results, in the response to a search query 44 | */ 45 | DIRECT_ANSWER: 'direct-answer', 46 | 47 | /** 48 | * The Filter index is the global source of truth for all filters on a page. 49 | * It should contain all the latest state that is used for search. 50 | */ 51 | FILTER: 'filter' 52 | }; 53 | -------------------------------------------------------------------------------- /src/core/storage/storagekeys.js: -------------------------------------------------------------------------------- 1 | /** @module StorageKeys */ 2 | 3 | /** 4 | * StorageKeys is an ENUM are considered the root context 5 | * for how data is stored and scoped in the storage. 6 | * 7 | * @enum {string} 8 | */ 9 | const StorageKeys = { 10 | NAVIGATION: 'navigation', 11 | UNIVERSAL_RESULTS: 'universal-results', 12 | VERTICAL_RESULTS: 'vertical-results', 13 | ALTERNATIVE_VERTICALS: 'alternative-verticals', 14 | AUTOCOMPLETE: 'autocomplete', 15 | DIRECT_ANSWER: 'direct-answer', 16 | GENERATIVE_DIRECT_ANSWER: 'generative-direct-answer', 17 | FILTER: 'filter', // DEPRECATED 18 | PERSISTED_FILTER: 'filters', 19 | STATIC_FILTER_NODES: 'static-filter-nodes', 20 | LOCATION_RADIUS_FILTER_NODE: 'location-radius-filter-node', 21 | PERSISTED_LOCATION_RADIUS: 'locationRadius', 22 | QUERY: 'query', 23 | QUERY_ID: 'query-id', 24 | FACET_FILTER_NODES: 'facet-filter-nodes', 25 | PERSISTED_FACETS: 'facetFilters', 26 | DYNAMIC_FILTERS: 'dynamic-filters', 27 | GEOLOCATION: 'geolocation', 28 | QUESTION_SUBMISSION: 'question-submission', 29 | SEARCH_CONFIG: 'search-config', 30 | SEARCH_OFFSET: 'search-offset', 31 | SPELL_CHECK: 'spell-check', 32 | SKIP_SPELL_CHECK: 'skipSpellCheck', 33 | LOCATION_BIAS: 'location-bias', 34 | SESSIONS_OPT_IN: 'sessions-opt-in', 35 | VERTICAL_PAGES_CONFIG: 'vertical-pages-config', 36 | LOCALE: 'locale', 37 | SORT_BYS: 'sortBys', 38 | NO_RESULTS_CONFIG: 'no-results-config', 39 | RESULTS_HEADER: 'results-header', // DEPRECATED 40 | API_CONTEXT: 'context', 41 | REFERRER_PAGE_URL: 'referrerPageUrl', 42 | QUERY_TRIGGER: 'queryTrigger', 43 | FACETS_LOADED: 'facets-loaded', 44 | QUERY_SOURCE: 'query-source', 45 | HISTORY_POP_STATE: 'history-pop-state', 46 | SEARCH_ID: 'search-id' 47 | }; 48 | export default StorageKeys; 49 | -------------------------------------------------------------------------------- /src/core/storage/storagelistener.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The storage listener is a listener on changes to the SDK storage 3 | */ 4 | export default class StorageListener { 5 | /** 6 | * @param {string} eventType The type of event to listen for e.g. 'update' 7 | * @param {string} storageKey The key to listen for e.g. 'Pagination' 8 | * @param {Function} callback The callback to call when the event is emitted on the storage key 9 | */ 10 | constructor (eventType, storageKey, callback) { 11 | this.eventType = eventType; 12 | this.storageKey = storageKey; 13 | this.callback = callback; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/core/utils/apicontext.js: -------------------------------------------------------------------------------- 1 | export function isValidContext (context) { 2 | // should be both valid JSON and a map 3 | let parsed; 4 | try { 5 | parsed = JSON.parse(context); 6 | } catch (e) { 7 | return false; 8 | } 9 | 10 | if (!parsed) { 11 | return false; 12 | } 13 | 14 | return typeof parsed === 'object' && !Array.isArray(parsed); 15 | } 16 | -------------------------------------------------------------------------------- /src/core/utils/arrayutils.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Groups an array into an object using a given key and value function, and an initial object 4 | * to add to. By default the key and value functions will not perform any transformations 5 | * on the array elements. 6 | * @param {Array} arr array to be grouped 7 | * @param {Function} keyFunc function that evaluates what key to give an array element. 8 | * @param {Function} valueFunc function that evaluates what value to give an array element. 9 | * @param {Object} intitial the initial object to add to, defaulting to {} 10 | * @returns {Object} 11 | */ 12 | export function groupArray (arr, keyFunc, valueFunc, initial) { 13 | keyFunc = keyFunc || (key => key); 14 | valueFunc = valueFunc || (value => value); 15 | return arr.reduce((groups, element, idx) => { 16 | const key = keyFunc(element, idx); 17 | const value = valueFunc(element, idx); 18 | if (!groups[key]) { 19 | groups[key] = [value]; 20 | } else { 21 | groups[key].push(value); 22 | } 23 | return groups; 24 | }, initial || {}); 25 | } 26 | -------------------------------------------------------------------------------- /src/core/utils/configutils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Used to parse config options, defaulting to different synonyms and 3 | * finally a default value. Option names with periods will be parsed 4 | * as multiple child object accessors, i.e. trying to access 'first.second.option' 5 | * will first look for config['first']['second']['option']. 6 | * 7 | * This is mostly needed for boolean config values, since boolean operators, 8 | * which we commonly use for defaulting config options, do not work properly 9 | * in those cases. 10 | * @param {Object} config 11 | * @param {Array} 12 | * @param {any} defaultValue 13 | */ 14 | export function defaultConfigOption (config, synonyms, defaultValue) { 15 | for (const name of synonyms) { 16 | const accessors = name.split('.'); 17 | let parentConfig = config; 18 | let skip = false; 19 | for (const childConfigAccessor of accessors.slice(0, -1)) { 20 | if (!(childConfigAccessor in parentConfig)) { 21 | skip = true; 22 | break; 23 | } 24 | parentConfig = parentConfig[childConfigAccessor]; 25 | } 26 | const configName = accessors[accessors.length - 1]; 27 | if (!skip && configName in parentConfig) { 28 | return parentConfig[configName]; 29 | } 30 | } 31 | return defaultValue; 32 | } 33 | -------------------------------------------------------------------------------- /src/core/utils/filternodeutils.js: -------------------------------------------------------------------------------- 1 | import FilterNodeFactory from '../filters/filternodefactory'; 2 | import Filter from '../models/filter'; 3 | import FilterMetadata from '../filters/filtermetadata'; 4 | 5 | /** 6 | * Converts an array of {@link AppliedQueryFilter}s into equivalent {@link SimpleFilterNode}s. 7 | * @param {Array} nlpFilters 8 | * @returns {Array} 9 | */ 10 | export function convertNlpFiltersToFilterNodes (nlpFilters) { 11 | return nlpFilters.map(nlpFilter => FilterNodeFactory.from({ 12 | filter: Filter.from(nlpFilter.filter), 13 | metadata: new FilterMetadata({ 14 | fieldName: nlpFilter.key, 15 | displayValue: nlpFilter.value 16 | }) 17 | })); 18 | } 19 | 20 | /** 21 | * Flattens an array of {@link FilterNode}s into an array 22 | * of their constituent leaf {@link SimpleFilterNode}s. 23 | * @param {Array} filterNodes 24 | * @returns {Array} 25 | */ 26 | export function flattenFilterNodes (filterNodes) { 27 | return filterNodes.flatMap(fn => fn.getSimpleDescendants()); 28 | } 29 | 30 | /** 31 | * Returns the given array of {@link FilterNode}s, 32 | * removing FilterNodes that are empty or have a field id listed as a hidden. 33 | * @param {Array} filterNodes 34 | * @param {Array} hiddenFields 35 | * @returns {Array} 36 | */ 37 | export function pruneFilterNodes (filterNodes, hiddenFields) { 38 | return filterNodes 39 | .filter(fn => { 40 | const { fieldName, displayValue } = fn.getMetadata(); 41 | if (!fieldName || !displayValue) { 42 | return false; 43 | } 44 | const fieldId = fn.getFilter().getFilterKey(); 45 | return !hiddenFields.includes(fieldId); 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/core/utils/objectutils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Nest a value inside an object whose structure is defined by an array of keys 3 | * 4 | * Example: if `value` is 'Hello, world!', and `keys` is ['a', 'b'], 5 | * the function will return the object: 6 | * 7 | * { 8 | * a: { 9 | * b: 'Hello, world!' 10 | * } 11 | * } 12 | * 13 | * @param {*} value 14 | * @param {string[]} keys 15 | * @returns {Object} 16 | */ 17 | export function nestValue (value, keys) { 18 | return keys.reduceRight((acc, key) => { 19 | return { [key]: acc }; 20 | }, value); 21 | } 22 | -------------------------------------------------------------------------------- /src/core/utils/resultsutils.js: -------------------------------------------------------------------------------- 1 | import SearchStates from '../storage/searchstates'; 2 | 3 | /** 4 | * Returns a CSS class for the input searchState 5 | * @param {SearchState} searchState 6 | * @returns {string} 7 | */ 8 | export function getContainerClass (searchState) { 9 | switch (searchState) { 10 | case SearchStates.PRE_SEARCH: 11 | return 'yxt-Results--preSearch'; 12 | case SearchStates.SEARCH_LOADING: 13 | return 'yxt-Results--searchLoading'; 14 | case SearchStates.SEARCH_COMPLETE: 15 | return 'yxt-Results--searchComplete'; 16 | default: 17 | console.trace(`encountered an unknown search state: ${searchState}`); 18 | return ''; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/core/utils/strings.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Truncates strings to 250 characters, attempting to preserve whole words 3 | * @param str {string} the string to truncate 4 | * @param limit {Number} the maximum character length to return 5 | * @param trailing {string} a trailing string to denote truncation, e.g. '...' 6 | * @param sep {string} the word separator 7 | * @returns {string} 8 | */ 9 | export function truncate (str, limit = 250, trailing = '...', sep = ' ') { 10 | if (!str || str.length <= limit) { 11 | return str; 12 | } 13 | 14 | // TODO (bmcginnis): split punctuation too so we don't end up with "foo,..." 15 | const words = str.split(sep); 16 | const max = limit - trailing.length; 17 | let truncated = ''; 18 | 19 | for (let i = 0; i < words.length; i++) { 20 | const word = words[i]; 21 | if (truncated.length + word.length > max || 22 | (i !== 0 && truncated.length + word.length + sep.length > max)) { 23 | truncated += trailing; 24 | break; 25 | } 26 | 27 | truncated += i === 0 ? word : sep + word; 28 | } 29 | 30 | return truncated; 31 | } 32 | -------------------------------------------------------------------------------- /src/core/utils/useragent.js: -------------------------------------------------------------------------------- 1 | import Bowser from 'bowser'; 2 | 3 | /** 4 | * Returns whether the current browser is Microsoft Edge. 5 | * 6 | * @returns {boolean} 7 | */ 8 | export function isMicrosoftEdge () { 9 | const browserData = Bowser.parse(navigator.userAgent); 10 | 11 | return browserData.browser.name === 'Microsoft Edge'; 12 | } 13 | 14 | /** 15 | * Returns whether the current browser is Safari. 16 | * 17 | * @returns {boolean} 18 | */ 19 | export function isSafari () { 20 | const browserData = Bowser.parse(navigator.userAgent); 21 | 22 | return browserData.browser.name === 'Safari'; 23 | } 24 | 25 | /** 26 | * Returns whether the current browser is Internet Explorer. 27 | * 28 | * @returns {boolean} 29 | */ 30 | export function isIE () { 31 | const browserData = Bowser.parse(navigator.userAgent); 32 | 33 | return browserData.browser.name === 'Internet Explorer'; 34 | } 35 | -------------------------------------------------------------------------------- /src/core/utils/uuid.js: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | /** 4 | * Generates a uuid. 5 | * 6 | * @returns {String} 7 | */ 8 | export function generateUUID () { 9 | return uuidv4(); 10 | } 11 | -------------------------------------------------------------------------------- /src/ui/alert.js: -------------------------------------------------------------------------------- 1 | import swal from 'sweetalert'; 2 | import DOM from './dom/dom'; 3 | 4 | const defaultAlertOptions = { 5 | closeOnClickOutside: false, 6 | closeOnEsc: false 7 | }; 8 | 9 | export default function alert (message, options = null) { 10 | const opts = options || defaultAlertOptions; 11 | swal(message, opts).then(() => { 12 | DOM.query('.swal-overlay').remove(); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /src/ui/components/cards/consts.js: -------------------------------------------------------------------------------- 1 | export const cardTemplates = { 2 | Standard: 'cards/standard', 3 | Accordion: 'cards/accordion', 4 | Legacy: 'cards/legacy' 5 | }; 6 | 7 | export const cardTypes = { 8 | Standard: 'StandardCard', 9 | Accordion: 'AccordionCard', 10 | Legacy: 'LegacyCard' 11 | }; 12 | -------------------------------------------------------------------------------- /src/ui/components/componenttypes.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | /** 4 | * An enum listing the different Component types supported in the SDK 5 | * TODO: add all component types 6 | * @type {Object.} 7 | */ 8 | export default { 9 | FILTER_BOX: 'FilterBox', 10 | FILTER_OPTIONS: 'FilterOptions', 11 | RANGE_FILTER: 'RangeFilter', 12 | DATE_RANGE_FILTER: 'DateRangeFilter', 13 | FACETS: 'Facets', 14 | GEOLOCATION_FILTER: 'GeoLocationFilter', 15 | SORT_OPTIONS: 'SortOptions', 16 | FILTER_SEARCH: 'FilterSearch' 17 | }; 18 | -------------------------------------------------------------------------------- /src/ui/components/search-bar-only-registry.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | import Component from './component'; 4 | 5 | import SearchComponent from './search/searchcomponent'; 6 | import AutoCompleteComponent from './search/autocompletecomponent'; 7 | 8 | import IconComponent from './icons/iconcomponent.js'; 9 | 10 | const COMPONENT_CLASS_LIST = [ 11 | // Core Component 12 | Component, 13 | 14 | // Search Components 15 | SearchComponent, 16 | AutoCompleteComponent, 17 | 18 | // Helper Components 19 | IconComponent 20 | ]; 21 | 22 | /** 23 | * This component registry is a map that contains all component classes pertaining 24 | * to the stand-alone SearchBar integration. Each component class has a unique type, which 25 | * is used as the key for the registry. 26 | * 27 | * @type {Object.} 28 | */ 29 | export const SEARCH_BAR_COMPONENTS_REGISTRY = COMPONENT_CLASS_LIST.reduce((registry, clazz) => { 30 | registry[clazz.type] = clazz; 31 | return registry; 32 | }, {}); 33 | -------------------------------------------------------------------------------- /src/ui/i18n/translationflagger.js: -------------------------------------------------------------------------------- 1 | /** 2 | * TranslationFlagger is a class used to flag Translation calls. The usages of this class 3 | * are handled and removed during SDK bundling. 4 | */ 5 | export default class TranslationFlagger { 6 | /** 7 | * Any calls of this method will be removed during a preprocessing step during SDK 8 | * bundling. 9 | * 10 | * To support cases where someone may want to bundle without using our 11 | * bundling tasks, this function attempts to return the same-language interpolated 12 | * and pluralized value based on the information given. 13 | * 14 | * @param {string} phrase 15 | * @param {string} pluralForm 16 | * @param {string | number} count 17 | * @param {string} context 18 | * @param {Object} interpolationValues 19 | * @returns {string} 20 | */ 21 | static flag ({ phrase, pluralForm, count, context, interpolationValues }) { 22 | const isPlural = count && count > 1 && pluralForm; 23 | const declensionOfPhrase = isPlural ? pluralForm : phrase; 24 | if (!interpolationValues) { 25 | return declensionOfPhrase; 26 | } 27 | 28 | let interpolatedPhrase = declensionOfPhrase; 29 | for (const [key, value] of Object.entries(interpolationValues)) { 30 | interpolatedPhrase = interpolatedPhrase.replace(`[[${key}]]`, value); 31 | } 32 | return interpolatedPhrase; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/ui/icons/briefcase.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'briefcase', 4 | path: 'M20 7h-4V5c0-1.11-.89-2-2-2h-4c-1.11 0-2 .89-2 2v2H4c-1.11 0-1.99.89-1.99 2L2 20c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V9c0-1.11-.89-2-2-2zm-6 0h-4V5h4v2z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/calendar.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'calendar', 4 | path: 'M18.111 13.2H12v6h6.111v-6zM16.89 0v2.4H7.11V0H4.667v2.4H3.444c-1.356 0-2.432 1.08-2.432 2.4L1 21.6C1 22.92 2.088 24 3.444 24h17.112C21.9 24 23 22.92 23 21.6V4.8c0-1.32-1.1-2.4-2.444-2.4h-1.223V0H16.89zm3.667 21.6H3.444V8.4h17.112v13.2z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/callout.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'callout', 4 | path: 'M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/chevron.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'chevron', 4 | viewBox: '0 0 7 9', 5 | complexContents: '' 6 | }); 7 | -------------------------------------------------------------------------------- /src/ui/icons/close.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'close', 4 | viewBox: '0 1 24 24', 5 | complexContents: ` 6 | 9 | ` 10 | }); 11 | -------------------------------------------------------------------------------- /src/ui/icons/directions.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'directions', 4 | path: 'M23.649 11.154L12.846.35a1.195 1.195 0 00-1.692 0L.35 11.154a1.195 1.195 0 000 1.692L11.154 23.65a1.195 1.195 0 001.692 0L23.65 12.846c.468-.456.468-1.212 0-1.692zm-9.254 3.853v-3.001H9.593v3.6h-2.4v-4.8c0-.66.54-1.2 1.2-1.2h6.002V6.604l4.2 4.2-4.2 4.202z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/document.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'document', 4 | path: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-1 9H9V9h10v2zm-4 4H9v-2h6v2zm4-8H9V5h10v2z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/elements.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'elements', 4 | path: 'M13,15 L13,17 L21,17 L21,19 L13,19 L13,21 L11,21 L11,15 L13,15 Z M9,17 L9,19 L3,19 L3,17 L9,17 Z M9,15 L7,15 L7,13 L3,13 L3,11 L7,11 L7,9 L9,9 L9,15 Z M21,11 L21,13 L11,13 L11,11 L21,11 Z M17,3 L17,5 L21,5 L21,7 L17,7 L17,9 L15,9 L15,3 L17,3 Z M13,5 L13,7 L3,7 L3,5 L13,5 Z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/email.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'email', 4 | path: 'M12,-3.55271368e-15 C8.81712,-3.55271368e-15 5.7648,1.26468 3.5148,3.5148 C1.2648,5.76492 3.55271368e-15,8.81736 3.55271368e-15,12 C3.55271368e-15,15.18264 1.26468,18.2352 3.5148,20.4852 C5.76492,22.7352 8.81736,24 12,24 C15.18264,24 18.2352,22.73532 20.4852,20.4852 C22.7352,18.23508 24,15.18264 24,12 C24,8.81736 22.73532,5.7648 20.4852,3.5148 C18.23508,1.2648 15.18264,-3.55271368e-15 12,-3.55271368e-15 Z M17.28,7.92 L12,11.87064 L6.72,7.92 L17.28,7.92 Z M18,15.64776 C18,15.7743216 17.9446872,15.894312 17.85,15.976824 C17.7543744,16.059324 17.6278128,16.096824 17.503128,16.0799496 L6.479928,16.0799496 C6.352428,16.0940122 6.224928,16.0499496 6.13212,15.961824 C6.0402456,15.8727624 5.9914944,15.7471368 5.9999328,15.618696 L5.9999328,9.047736 L5.9999328,8.441184 L7.9536768,9.90744 L11.6398368,12.67224 C11.839524,12.8681784 12.1601568,12.8681784 12.3598368,12.67224 L17.8939968,8.51736 L17.9849352,8.44986 L17.9858726,8.45079768 C17.9914978,8.48548488 17.9952478,8.52111048 17.9971226,8.55579768 L17.9971226,15.6386777 L18,15.64776 Z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/gear.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'gear', 4 | path: 'M12 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm7-7H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5a2 2 0 00-2-2zm-1.75 9c0 .23-.02.46-.05.68l1.48 1.16c.13.11.17.3.08.45l-1.4 2.42c-.09.15-.27.21-.43.15l-1.74-.7c-.36.28-.76.51-1.18.69l-.26 1.85c-.03.17-.18.3-.35.3h-2.8c-.17 0-.32-.13-.35-.29l-.26-1.85c-.43-.18-.82-.41-1.18-.69l-1.74.7c-.16.06-.34 0-.43-.15l-1.4-2.42a.353.353 0 01.08-.45l1.48-1.16c-.03-.23-.05-.46-.05-.69 0-.23.02-.46.05-.68l-1.48-1.16a.353.353 0 01-.08-.45l1.4-2.42c.09-.15.27-.21.43-.15l1.74.7c.36-.28.76-.51 1.18-.69l.26-1.85c.03-.17.18-.3.35-.3h2.8c.17 0 .32.13.35.29l.26 1.85c.43.18.82.41 1.18.69l1.74-.7c.16-.06.34 0 .43.15l1.4 2.42c.09.15.05.34-.08.45l-1.48 1.16c.03.23.05.46.05.69z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/icon.js: -------------------------------------------------------------------------------- 1 | export default class SVGIcon { 2 | /** 3 | * @param config 4 | * @param config.name 5 | * @param config.path 6 | * @param config.complexContents 7 | * @param config.viewBox 8 | * @constructor 9 | */ 10 | constructor (config) { 11 | /** 12 | * the name of the icon 13 | */ 14 | this.name = config.name; 15 | /** 16 | * an svg path definition 17 | */ 18 | this.path = config.path; 19 | /** 20 | * if not using a path, a the markup for a complex SVG 21 | */ 22 | this.complexContents = config.complexContents; 23 | /** 24 | * the view box definition, defaults to 24x24 25 | * @type {string} 26 | */ 27 | this.viewBox = config.viewBox || '0 0 24 24'; 28 | /** 29 | * actual contents used 30 | */ 31 | this.contents = this.pathDefinition(); 32 | } 33 | 34 | pathDefinition () { 35 | if (this.complexContents) { 36 | return this.complexContents; 37 | } 38 | 39 | return ``; 40 | } 41 | 42 | parseContents (complexContentsParams) { 43 | let contents = this.contents; 44 | if (typeof contents === 'function') { 45 | contents = contents(complexContentsParams); 46 | } 47 | return `${contents}`; 48 | } 49 | 50 | /** 51 | * returns the svg markup 52 | */ 53 | markup () { 54 | if (typeof this.contents === 'function') { 55 | return complexContentsParams => this.parseContents(complexContentsParams); 56 | } 57 | return this.parseContents(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/ui/icons/info.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'info', 4 | path: 'M12 8.4A1.2 1.2 0 1012 6a1.2 1.2 0 000 2.4zM12 0c6.624 0 12 5.376 12 12s-5.376 12-12 12S0 18.624 0 12 5.376 0 12 0zm0 18c.66 0 1.2-.54 1.2-1.2V12c0-.66-.54-1.2-1.2-1.2-.66 0-1.2.54-1.2 1.2v4.8c0 .66.54 1.2 1.2 1.2z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/kabob.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'kabob', 4 | viewBox: '0 0 3 11', 5 | complexContents: '' 6 | }); 7 | -------------------------------------------------------------------------------- /src/ui/icons/light_bulb.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'light_bulb', 4 | viewBox: '0 0 32 35', 5 | path: 'M11.585 31.056l8.38-.493v-.986l-8.38.493zM11.585 33.028L15.775 35l4.19-1.972V31.55l-8.38.493v.986zm6.926-.407l-2.736 1.29-2.13-1.004 4.866-.286zM15.775 7.394c-4.63 0-8.38 3.205-8.38 8.38 0 5.177 4.19 6.902 4.19 12.818v.493l8.38-.493c0-5.916 4.19-8.188 4.19-12.817a8.38 8.38 0 00-8.38-8.38zm5.617 13.48c-1.025 1.837-2.174 3.892-2.381 6.786l-6.44.38c-.129-3.01-1.29-5.021-2.32-6.808-.493-.8-.928-1.636-1.299-2.5h13.556c-.325.708-.704 1.403-1.116 2.142zm1.479-3.128H8.627a7.793 7.793 0 01-.247-1.971c0-4.353 3.042-7.395 7.395-7.395a7.394 7.394 0 017.394 7.395 6.739 6.739 0 01-.3 1.971h.002zM26.62 15.282h4.93v1h-4.93zM23.094 7.756l2.091-2.091.698.697-2.092 2.092zM15.282 0h1v4.93h-1zM5.666 6.362l.697-.697 2.091 2.091-.697.697zM0 15.282h4.93v1H0z' 6 | }); 7 | -------------------------------------------------------------------------------- /src/ui/icons/link.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'link', 4 | path: 'M2.28 12A3.723 3.723 0 016 8.28h4.8V6H6c-3.312 0-6 2.688-6 6s2.688 6 6 6h4.8v-2.28H6A3.723 3.723 0 012.28 12zm4.92 1.2h9.6v-2.4H7.2v2.4zM18 6h-4.8v2.28H18A3.723 3.723 0 0121.72 12 3.723 3.723 0 0118 15.72h-4.8V18H18c3.312 0 6-2.688 6-6s-2.688-6-6-6z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/magnifying_glass.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'magnifying_glass', 4 | path: 'M16.124 13.051a5.154 5.154 0 110-10.308 5.154 5.154 0 010 10.308M16.114 0a7.886 7.886 0 00-6.46 12.407L0 22.06 1.94 24l9.653-9.653A7.886 7.886 0 1016.113 0' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/mic.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'mic', 4 | path: 'M12 15c1.66 0 2.99-1.34 2.99-3L15 6c0-1.66-1.34-3-3-3S9 4.34 9 6v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 15 6.7 12H5c0 3.41 2.72 6.23 6 6.72V22h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/office.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'office', 4 | path: 'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/person.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'person', 4 | viewBox: '0 0 18 18', 5 | path: 'M9 9c2.486 0 4.5-2.014 4.5-4.5S11.486 0 9 0a4.499 4.499 0 00-4.5 4.5C4.5 6.986 6.514 9 9 9zm0 2.25c-3.004 0-9 1.508-9 4.5v1.125C0 17.494.506 18 1.125 18h15.75c.619 0 1.125-.506 1.125-1.125V15.75c0-2.992-5.996-4.5-9-4.5z' 6 | }); 7 | -------------------------------------------------------------------------------- /src/ui/icons/phone.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'phone', 4 | path: 'M4.827 10.387a20.198 20.198 0 008.786 8.786l2.934-2.933c.36-.36.893-.48 1.36-.32a15.21 15.21 0 004.76.76c.733 0 1.333.6 1.333 1.333v4.654C24 23.4 23.4 24 22.667 24 10.147 24 0 13.853 0 1.333 0 .6.6 0 1.333 0H6c.733 0 1.333.6 1.333 1.333 0 1.667.267 3.267.76 4.76.147.467.04.987-.333 1.36l-2.933 2.934z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/pin.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'pin', 4 | viewBox: '5 0 9 18', 5 | path: 'm9.375 0c-3.52446429 0-6.375 2.817-6.375 6.3 0 4.725 6.375 11.7 6.375 11.7s6.375-6.975 6.375-11.7c0-3.483-2.8505357-6.3-6.375-6.3zm.00000018 8.55000007c-1.25678576 0-2.27678579-1.008-2.27678579-2.25s1.02000003-2.25 2.27678579-2.25c1.25678572 0 2.27678582 1.008 2.27678582 2.25s-1.0200001 2.25-2.27678582 2.25z' 6 | }); 7 | -------------------------------------------------------------------------------- /src/ui/icons/receipt.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'receipt', 4 | path: 'M14.606 9.5c-.671-.515-1.591-.833-2.606-.833 1.015 0 1.935.318 2.606.833zm-7.985 0H1.655A1.66 1.66 0 010 7.833V3.667C0 2.747.741 2 1.655 2h20.69A1.66 1.66 0 0124 3.667v4.166A1.66 1.66 0 0122.345 9.5h-4.966V22H6.621V9.5h2.773H6.62zm10.758-1.667h4.966V3.667H1.655v4.166h4.966v-2.5h10.758v2.5z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/star.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'star', 4 | viewBox: '0 0 18 18', 5 | path: 'M8.991 0C4.023 0 0 4.032 0 9s4.023 9 8.991 9C13.968 18 18 13.968 18 9s-4.032-9-9.009-9zm3.816 14.4L9 12.105 5.193 14.4l1.008-4.329-3.357-2.907 4.428-.378L9 2.7l1.728 4.077 4.428.378-3.357 2.907z' 6 | }); 7 | -------------------------------------------------------------------------------- /src/ui/icons/support.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'support', 4 | path: 'M12,0 C5.376,0 0,5.376 0,12 C0,18.624 5.376,24 12,24 C18.624,24 24,18.624 24,12 C24,5.376 18.624,0 12,0 Z M13,19 L11,19 L11,17 L13,17 L13,19 Z M15.07,11.25 L14.17,12.17 C13.45,12.9 13,13.5 13,15 L11,15 L11,14.5 C11,13.4 11.45,12.4 12.17,11.67 L13.41,10.41 C13.78,10.05 14,9.55 14,9 C14,7.9 13.1,7 12,7 C10.9,7 10,7.9 10,9 L8,9 C8,6.79 9.79,5 12,5 C14.21,5 16,6.79 16,9 C16,9.88 15.64,10.68 15.07,11.25 Z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/tag.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'tag', 4 | viewBox: '0 0 18 18', 5 | path: 'M17.469 8.622l-8.1-8.1A1.789 1.789 0 008.1 0H1.8C.81 0 0 .81 0 1.8v6.3c0 .495.198.945.531 1.278l8.1 8.1c.324.324.774.522 1.269.522a1.76 1.76 0 001.269-.531l6.3-6.3A1.76 1.76 0 0018 9.9c0-.495-.207-.954-.531-1.278zM3.15 4.5c-.747 0-1.35-.603-1.35-1.35 0-.747.603-1.35 1.35-1.35.747 0 1.35.603 1.35 1.35 0 .747-.603 1.35-1.35 1.35z' 6 | }); 7 | -------------------------------------------------------------------------------- /src/ui/icons/thumb.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'thumb', 4 | viewBox: '0 0 24 22', 5 | path: 'M15.273 1H5.455c-.906 0-1.68.55-2.008 1.342L.153 10.097A2.19 2.19 0 000 10.9v2.2c0 1.21.982 2.2 2.182 2.2h6.883L8.03 20.327l-.033.352c0 .451.186.869.48 1.166L9.633 23l7.178-7.249a2.16 2.16 0 00.644-1.551v-11c0-1.21-.982-2.2-2.182-2.2zm0 13.2l-4.735 4.774L11.75 13.1H2.182v-2.2l3.273-7.7h9.818v11zM19.636 1H24v13.2h-4.364V1z' 6 | }); 7 | -------------------------------------------------------------------------------- /src/ui/icons/voice_search_dots.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | import VoiceSearchDotsSVG from './svg/voice_search_dots.svg'; 3 | export default new SVGIcon({ 4 | name: 'voice_search_dots', 5 | viewBox: '0 0 33 27', 6 | complexContents: VoiceSearchDotsSVG 7 | }); 8 | -------------------------------------------------------------------------------- /src/ui/icons/voice_search_mic.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | import VoiceSearchMicSVG from './svg/voice_search_mic.svg'; 3 | export default new SVGIcon({ 4 | name: 'voice_search_mic', 5 | viewBox: '0 0 33 27', 6 | complexContents: VoiceSearchMicSVG 7 | }); 8 | -------------------------------------------------------------------------------- /src/ui/icons/window.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'window', 4 | path: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' 5 | }); 6 | -------------------------------------------------------------------------------- /src/ui/icons/yext.js: -------------------------------------------------------------------------------- 1 | import SVGIcon from './icon.js'; 2 | export default new SVGIcon({ 3 | name: 'yext', 4 | viewBox: '0 0 30 30', 5 | path: 'M25.517 28.142v.095h-.204v.905h-.066v-.905h-.197v-.095h.467zm.667 0h.066v1h-.066v-.825l-.24.595h-.013l-.24-.595v.825h-.066v-1h.066l.247.61.246-.61zM15 28.8c7.622 0 13.8-6.178 13.8-13.8 0-7.622-6.178-13.8-13.8-13.8C7.378 1.2 1.2 7.378 1.2 15c0 7.622 6.178 13.8 13.8 13.8zM15 0c8.284 0 15 6.716 15 15 0 8.284-6.716 15-15 15-8.284 0-15-6.716-15-15C0 6.716 6.716 0 15 0zm.45 16.65v-1.2h6.6v1.2h-2.7v5.4h-1.2v-5.4h-2.7zm-1.599-1.35l.849.849-2.601 2.601 2.601 2.601-.849.849-2.601-2.601L8.649 22.2l-.849-.849 2.601-2.601L7.8 16.149l.849-.849 2.601 2.601 2.601-2.601zM18.675 9a2.175 2.175 0 00-1.847 3.323l2.995-2.995A2.163 2.163 0 0018.675 9zm0 5.55a3.375 3.375 0 112.833-5.209l-3.789 3.788a2.175 2.175 0 003.13-1.954h1.201a3.375 3.375 0 01-3.375 3.375zm-7.425-3.734L13.78 7.8l.92.771-2.85 3.397v2.582h-1.2v-2.582L7.8 8.57l.92-.771 2.53 3.016z' 6 | }); 7 | -------------------------------------------------------------------------------- /src/ui/index.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | export { default as DOM } from './dom/dom'; 4 | export { default as SearchParams } from './dom/searchparams'; 5 | 6 | export { Renderers } from './rendering/const'; 7 | export { default as DefaultTemplatesLoader } from './rendering/defaulttemplatesloader'; 8 | -------------------------------------------------------------------------------- /src/ui/rendering/const.js: -------------------------------------------------------------------------------- 1 | /** @module */ 2 | 3 | import Renderer from './renderer'; 4 | import HandlebarsRenderer from './handlebarsrenderer'; 5 | 6 | // In the future, this will contain all different types of renderers 7 | // E.g. Mustache, SOY, HandleBars, React, etc. 8 | export const Renderers = { 9 | SOY: Renderer, 10 | Handlebars: HandlebarsRenderer 11 | }; 12 | -------------------------------------------------------------------------------- /src/ui/rendering/renderer.js: -------------------------------------------------------------------------------- 1 | /** @module Renderer */ 2 | 3 | /** 4 | * Renderer is an abstract class that all Renderers should extend and implement 5 | */ 6 | export default class Renderer { 7 | /** 8 | * render is a core method for all renderers. 9 | * All implementations should override this class 10 | * @param {string} template 11 | * @param {object} data 12 | */ 13 | render (template, data) { 14 | return template; 15 | } 16 | 17 | registerHelper (name, cb) { 18 | 19 | } 20 | 21 | registerTemplate (templateName, template) { 22 | 23 | } 24 | 25 | registerPartial (partialName, partial) { 26 | 27 | } 28 | 29 | compile (template) { 30 | 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/ui/sass/_alert.scss: -------------------------------------------------------------------------------- 1 | .swal-overlay { 2 | .swal-modal { 3 | animation: none; 4 | } 5 | 6 | .swal-text { 7 | @include Text($color: black, $size: var(--yxt-font-size-md-lg)); 8 | } 9 | 10 | .swal-button { 11 | @include Text($color: white, $size: var(--yxt-font-size-md-lg)); 12 | @include Button(); 13 | 14 | &:not(:disabled):focus { 15 | border: none; 16 | background: var(--yxt-color-brand-hover); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/ui/sass/_base.scss: -------------------------------------------------------------------------------- 1 | .yxt-Answers-component { 2 | *:focus 3 | { 4 | outline: none; 5 | } 6 | 7 | input[type="checkbox"]:focus 8 | { 9 | outline: black solid 1px; 10 | } 11 | 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | -------------------------------------------------------------------------------- /src/ui/sass/_colors.scss: -------------------------------------------------------------------------------- 1 | $color-background-highlight: #FAFAFA !default; 2 | $color-background-dark: #A8A8A8 !default; 3 | $color-brand-primary: #0F70F0 !default; 4 | $color-brand-hover: #0C5ECB !default; 5 | $color-brand-white: #FFF !default; 6 | 7 | $color-text-primary: #212121 !default; 8 | $color-text-secondary: #757575 !default; 9 | $color-text-neutral: #616161 !default; 10 | 11 | $color-link-primary: var(--yxt-color-brand-primary) !default; 12 | 13 | $color-borders: #DCDCDC !default; 14 | 15 | $color-error: #940000 !default; 16 | 17 | :root { 18 | --yxt-color-background-highlight: #{$color-background-highlight}; 19 | --yxt-color-background-dark: #{$color-background-dark}; 20 | --yxt-color-brand-primary: #{$color-brand-primary}; 21 | --yxt-color-brand-hover: #{$color-brand-hover}; 22 | --yxt-color-brand-white: #{$color-brand-white}; 23 | 24 | --yxt-color-text-primary: #{$color-text-primary}; 25 | --yxt-color-text-secondary: #{$color-text-secondary}; 26 | --yxt-color-text-neutral: #{$color-text-neutral}; 27 | 28 | --yxt-color-link-primary: #{$color-link-primary}; 29 | 30 | --yxt-color-borders: #{$color-borders}; 31 | 32 | --yxt-color-error: #{$color-error}; 33 | } 34 | -------------------------------------------------------------------------------- /src/ui/sass/_layout.scss: -------------------------------------------------------------------------------- 1 | $base-spacing-sm: 12px !default; 2 | $base-spacing: 16px !default; 3 | 4 | $module-footer-height: 24px !default; 5 | $module-container-height: 20px !default; 6 | 7 | $border-default: 1px solid var(--yxt-color-borders) !default; 8 | $border-legacy: 1px solid #E9E9E9 !default; 9 | $yxt-border-hover: 1px solid var(--yxt-color-brand-hover) !default; 10 | 11 | $breakpoint-mobile-max: 767px !default; 12 | $breakpoint-mobile-min: 768px !default; 13 | 14 | $breakpoint-mobile-sm-max: 575px !default; 15 | $breakpoint-mobile-sm-min: 576px !default; 16 | $z-index-nav-more-modal: 2 !default; 17 | 18 | $button-focus-border-size: 3px !default; 19 | 20 | $cards-min-width: 210px !default; 21 | $container-desktop-base: 400px !default; 22 | 23 | :root { 24 | --yxt-base-spacing-sm: #{$base-spacing-sm}; 25 | --yxt-base-spacing: #{$base-spacing}; 26 | --yxt-module-footer-height: #{$module-footer-height}; 27 | --yxt-module-container-height: #{$module-container-height}; 28 | --yxt-border-default: #{$border-default}; 29 | --yxt-border-hover: #{$yxt-border-hover}; 30 | --yxt-border-legacy: #{$border-legacy}; 31 | --yxt-z-index-nav-more-modal: #{$z-index-nav-more-modal}; 32 | --yxt-button-focus-border-size: #{$button-focus-border-size}; 33 | --yxt-cards-min-width: #{$cards-min-width}; 34 | --yxt-container-desktop-base: #{$container-desktop-base}; 35 | } 36 | -------------------------------------------------------------------------------- /src/ui/sass/_util.scss: -------------------------------------------------------------------------------- 1 | // Only display content to screen readers. A la Bootstrap 4. 2 | // See: http://a11yproject.com/posts/how-to-hide-content/ 3 | 4 | @mixin sr-only { 5 | position: absolute; 6 | width: 1px; 7 | height: 1px; 8 | padding: 0; 9 | margin: -1px; 10 | overflow: hidden; 11 | clip: rect(0, 0, 0, 0); 12 | border: 0; 13 | } 14 | 15 | // Use in conjunction with .sr-only to only display content when it's focused. 16 | // Useful for "Skip to main content" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1 17 | // Credit: HTML5 Boilerplate 18 | 19 | @mixin sr-only-focusable { 20 | &:active, 21 | &:focus { 22 | position: static; 23 | width: auto; 24 | height: auto; 25 | margin: 0; 26 | overflow: visible; 27 | clip: auto; 28 | } 29 | } 30 | 31 | .sr-only { 32 | @include sr-only(); 33 | } 34 | 35 | .sr-only-focusable { 36 | @include sr-only-focusable(); 37 | } 38 | -------------------------------------------------------------------------------- /src/ui/sass/answers-search-bar.scss: -------------------------------------------------------------------------------- 1 | // Variables and mixins 2 | @import "util"; 3 | @import "colors"; 4 | @import "fonts"; 5 | @import "layout"; 6 | @import "mixins"; 7 | 8 | // General styling 9 | @import "base"; 10 | 11 | // Component Styling 12 | @import "modules/SearchBar"; 13 | @import "modules/Icon"; 14 | @import "modules/AutoComplete"; 15 | -------------------------------------------------------------------------------- /src/ui/sass/answers.scss: -------------------------------------------------------------------------------- 1 | // Variables and mixins 2 | @import "util"; 3 | @import "colors"; 4 | @import "fonts"; 5 | @import "layout"; 6 | @import "mixins"; 7 | 8 | // General styling 9 | @import "base"; 10 | 11 | // Component Styling 12 | @import "modules/SearchBar"; 13 | @import "modules/Icon"; 14 | @import "modules/Nav"; 15 | @import "modules/DirectAnswer"; 16 | @import "modules/GenerativeDirectAnswer"; 17 | @import "modules/Results"; 18 | @import "modules/NoResults"; 19 | @import "modules/AlternativeVerticals"; 20 | @import "modules/AutoComplete"; 21 | @import "modules/AccordionResult"; 22 | @import "modules/SpellCheck"; 23 | @import "modules/Pagination"; 24 | @import "modules/LocationBias"; 25 | @import "modules/FilterOptions"; 26 | @import "modules/Facets"; 27 | @import "modules/FilterBox"; 28 | @import "modules/QuestionSubmission"; 29 | @import "modules/SortOptions"; 30 | @import "modules/StandardCard"; 31 | @import "modules/LegacyCard"; 32 | @import "modules/CTA"; 33 | @import "modules/Card"; 34 | @import "modules/AccordionCard"; 35 | @import "modules/ResultsHeader"; 36 | @import "modules/VerticalResultsCount"; 37 | @import "modules/AppliedFilters"; 38 | 39 | // Alert Styling 40 | @import "alert"; 41 | -------------------------------------------------------------------------------- /src/ui/sass/modules/_AppliedFilters.scss: -------------------------------------------------------------------------------- 1 | /** @define AppliedFilter */ 2 | 3 | .yxt-AppliedFilters 4 | { 5 | border-top: 0; 6 | display: flex; 7 | flex-wrap: wrap; 8 | align-items: center; 9 | 10 | &-filterLabel, &-filterValue, &-filterSeparator { 11 | margin-right: calc(var(--yxt-base-spacing) / 4); 12 | display: flex; 13 | } 14 | 15 | &-filterLabel, 16 | &-filterValueText, 17 | &-filterValueComma, 18 | &-filterSeparator 19 | { 20 | @include Text( 21 | $size: var(--yxt-font-size-md), 22 | $line-height: var(--yxt-line-height-md), 23 | $color: var(--yxt-color-text-secondary), 24 | ); 25 | } 26 | 27 | 28 | &-filterValueText, 29 | &-filterValueComma { 30 | font-style: italic; 31 | } 32 | 33 | &-filterSeparator 34 | { 35 | color: var(--yxt-color-text-secondary); 36 | } 37 | 38 | &-removableFilterTag 39 | { 40 | background-color: var(--yxt-color-borders); 41 | border-radius: 2px; 42 | border-width: 0; 43 | margin-bottom: 4px; 44 | padding-left: 5px; 45 | padding-right: 4px; 46 | margin-right: calc(var(--yxt-base-spacing) / 2); 47 | white-space: nowrap; 48 | @include Text( 49 | $size: var(--yxt-font-size-sm), 50 | $color: var(--yxt-color-text-neutral), 51 | $line-height: 20px, 52 | $style: italic, 53 | ); 54 | 55 | &:hover, 56 | &:focus 57 | { 58 | color: var(--yxt-color-brand-white); 59 | background-color: var(--yxt-color-text-secondary); 60 | cursor: pointer; 61 | } 62 | } 63 | 64 | &-removableFilterX 65 | { 66 | font-style: normal; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/ui/sass/modules/_Facets.scss: -------------------------------------------------------------------------------- 1 | /** @define Facets */ 2 | 3 | .yxt-Facets { 4 | &-container { 5 | box-sizing: border-box; 6 | padding: 25px; 7 | width: 300px; 8 | @media (max-width: $breakpoint-mobile-max) { 9 | width: 100%; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/sass/modules/_FilterBox.scss: -------------------------------------------------------------------------------- 1 | /** @define FilterBox */ 2 | 3 | .yxt-FilterBox { 4 | &-container { 5 | box-sizing: border-box; 6 | width: 100%; 7 | } 8 | 9 | &-titleContainer { 10 | display: flex; 11 | align-items: center; 12 | margin: 0; 13 | margin-bottom: 4px; 14 | 15 | svg { 16 | width: 18px; 17 | height: 18px; 18 | } 19 | } 20 | 21 | &-title { 22 | @include Text( 23 | $size: var(--yxt-font-size-md-lg), 24 | $line-height: var(--yxt-line-height-lg), 25 | $weight: var(--yxt-font-weight-semibold)); 26 | 27 | text-transform: uppercase; 28 | margin-left: 8px; 29 | } 30 | 31 | &-filter + &-filter { 32 | border-top: 1px solid var(--yxt-color-borders); 33 | } 34 | 35 | &-apply { 36 | @include Text( 37 | $size: var(--yxt-font-size-md), 38 | $weight: var(--yxt-font-weight-semibold), 39 | $color: white); 40 | 41 | @include Button(); 42 | 43 | width: 90px; 44 | height: 40px; 45 | } 46 | 47 | &-reset { 48 | @include Text( 49 | $size: var(--yxt-font-size-md), 50 | $weight: var(--yxt-font-weight-semibold), 51 | $color: var(--yxt-color-brand-primary)); 52 | 53 | @include TextButton( 54 | $padding: 5px 10px 5px 0px 55 | ); 56 | 57 | text-decoration: underline; 58 | 59 | &:not(:disabled):hover { 60 | text-decoration: none; 61 | } 62 | 63 | letter-spacing: 0.5px; 64 | } 65 | 66 | &-apply + &-reset { 67 | padding-left: 5px; 68 | margin-left: 10px; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/ui/sass/modules/_NoResults.scss: -------------------------------------------------------------------------------- 1 | /** @define NoResults */ 2 | 3 | $noresults-font-size: var(--yxt-font-size-md) !default; 4 | $noresults-line-height: var(--yxt-line-height-md) !default; 5 | $noresults-font-weight: var(--yxt-font-weight-normal) !default; 6 | $noresults-query-font-weight: var(--yxt-font-weight-semibold) !default; 7 | 8 | :root { 9 | --yxt-noresults-font-size: #{$noresults-font-size}; 10 | --yxt-noresults-line-height: #{$noresults-line-height}; 11 | --yxt-noresults-font-weight: #{$noresults-font-weight}; 12 | --yxt-noresults-query-font-weight: #{$noresults-query-font-weight}; 13 | } 14 | 15 | .yxt-NoResults 16 | { 17 | &-wrapper 18 | { 19 | font-size: var(--yxt-noresults-font-size); 20 | line-height: var(--yxt-noresults-line-height); 21 | font-weight: var(--yxt-noresults-font-weight); 22 | } 23 | 24 | &-query 25 | { 26 | font-weight: var(--yxt-noresults-query-font-weight); 27 | } 28 | 29 | &-returnLinkWrapper 30 | { 31 | margin-top: var(--yxt-base-spacing); 32 | } 33 | 34 | &-returnLink 35 | { 36 | @include Link-2(); 37 | } 38 | 39 | &-suggestions 40 | { 41 | margin-top: var(--yxt-base-spacing); 42 | } 43 | 44 | &-suggestionsHeader 45 | { 46 | font-style: italic; 47 | } 48 | 49 | &-suggestionsList 50 | { 51 | margin-top: calc(var(--yxt-base-spacing) / 2); 52 | } 53 | 54 | &-suggestion 55 | { 56 | margin-left: calc(var(--yxt-base-spacing-sm) * 2); 57 | list-style-type: disc; 58 | list-style-position: inside; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/ui/sass/modules/_SpellCheck.scss: -------------------------------------------------------------------------------- 1 | /** @define SpellCheck */ 2 | 3 | $spellCheck-text-font-size: var(--yxt-font-size-md-lg) !default; 4 | $spellCheck-text-font-weight: var(--yxt-font-weight-normal) !default; 5 | $spellCheck-text-color: var(--yxt-color-text-primary) !default; 6 | $spellCheck-container-height: var(--yxt-module-container-height) !default; 7 | 8 | :root { 9 | --yxt-spellCheck-text-font-size: #{$spellCheck-text-font-size}; 10 | --yxt-spellCheck-text-font-weight: #{$spellCheck-text-font-weight}; 11 | --yxt-spellCheck-text-color: #{$spellCheck-text-color}; 12 | --yxt-spellCheck-container-height: #{$spellCheck-container-height}; 13 | } 14 | 15 | .yxt-SpellCheck 16 | { 17 | @media (max-width: $breakpoint-mobile-max) 18 | { 19 | margin-left: var(--yxt-base-spacing); 20 | } 21 | 22 | margin-top: calc(var(--yxt-base-spacing) * 2); 23 | padding-bottom: var(--yxt-base-spacing); 24 | display: block; 25 | 26 | .yxt-SpellCheck-helpText { 27 | font-size: var(--yxt-font-size-lg); 28 | } 29 | 30 | .yxt-SpellCheck-container { 31 | height: var(--yxt-spellCheck-container-height); 32 | } 33 | 34 | @include Text( 35 | $size: var(--yxt-spellCheck-text-font-size), 36 | $weight: var(--yxt-spellCheck-text-font-weight), 37 | $color: var(--yxt-spellCheck-text-color) 38 | ); 39 | 40 | strong 41 | { 42 | font-style: italic; 43 | font-weight: var(--yxt-font-weight-semibold); 44 | } 45 | 46 | a 47 | { 48 | @include Link-1(); 49 | text-decoration: none; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/ui/sass/modules/_VerticalResultsCount.scss: -------------------------------------------------------------------------------- 1 | /** @define VerticalResultsCount */ 2 | 3 | .yxt-VerticalResultsCount 4 | { 5 | white-space: nowrap; 6 | @include Text( 7 | $size: var(--yxt-font-size-md), 8 | $line-height: var(--yxt-line-height-md), 9 | $weight: var(--yxt-font-weight-bold), 10 | $color: var(--yxt-color-text-secondary) 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/speechrecognition/miciconstylist.js: -------------------------------------------------------------------------------- 1 | import DOM from '../dom/dom'; 2 | 3 | /** 4 | * Responsible for styling the voice search mic icon 5 | */ 6 | export default class MicIconStylist { 7 | constructor (searchBarContainer, customMicIconUrl) { 8 | this._voiceIconWrapper = DOM.query(searchBarContainer, '.yxt-SearchBar-voiceIconWrapper'); 9 | 10 | /** @type {SVGAnimateElement} */ 11 | this._micFadeInAnimation = DOM.query(searchBarContainer, '.js-yxt-SearchBar-micFadeIn'); 12 | this._micFadeOutAnimation = DOM.query(searchBarContainer, '.js-yxt-SearchBar-micFadeOut'); 13 | 14 | this._activeCustomIconClass = 'yxt-SearchBar-CustomMicIcon--active'; 15 | this._customMicIconUrl = customMicIconUrl; 16 | } 17 | 18 | /** 19 | * Applies styling for when the icon is active 20 | */ 21 | applyActiveStyling () { 22 | if (this._customMicIconUrl) { 23 | this._voiceIconWrapper.classList.add(this._activeCustomIconClass); 24 | } else { 25 | this._micFadeInAnimation.beginElement(); 26 | } 27 | } 28 | 29 | /** 30 | * Applies styling for when the icon is inactive 31 | */ 32 | applyInactiveStyling () { 33 | if (this._customMicIconUrl) { 34 | this._voiceIconWrapper.classList.remove(this._activeCustomIconClass); 35 | } else { 36 | this._micFadeOutAnimation.beginElement(); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/ui/speechrecognition/screenreadertextcontroller.js: -------------------------------------------------------------------------------- 1 | import DOM from '../dom/dom'; 2 | import TranslationFlagger from '../i18n/translationflagger'; 3 | 4 | /** 5 | * Responsible for updating the screen reader text for the voice search button 6 | */ 7 | export default class ScreenReaderTextController { 8 | constructor (searchBarContainer, startText, stopText) { 9 | /** 10 | * The screen reader text for the "Start Voice Search" button 11 | * @type {string} 12 | */ 13 | this._startVoiceSearchText = startText || TranslationFlagger.flag({ 14 | phrase: 'Start Voice Search', 15 | context: 'A button to begin listening for a voice search' 16 | }); 17 | 18 | /** 19 | * The screen reader text for the "Stop Voice Search" button 20 | * @type {string} 21 | */ 22 | this._stopVoiceSearchText = stopText || TranslationFlagger.flag({ 23 | phrase: 'Stop Voice Search', 24 | context: 'A button to stop listening for a voice search' 25 | }); 26 | 27 | this._voiceSearchElement = DOM.query(searchBarContainer, '.yxt-SearchBar-voiceSearch'); 28 | this._screenReaderTextElement = DOM.query(searchBarContainer, '.yxt-SearchBar-voiceSearchText'); 29 | } 30 | 31 | setStartListeningText () { 32 | this._screenReaderTextElement.innerText = this._startVoiceSearchText; 33 | } 34 | 35 | setStopListeningText () { 36 | this._screenReaderTextElement.innerText = this._stopVoiceSearchText; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/ui/statemachine/loadingiconstate.js: -------------------------------------------------------------------------------- 1 | import { State } from './statemachine'; 2 | import DOM from '../dom/dom'; 3 | 4 | /** 5 | * Defines behavior for search bar icon in loading state and during state transitions 6 | */ 7 | export default class LoadingIconState extends State { 8 | constructor (controller, stateId) { 9 | super(stateId); 10 | this._controller = controller; 11 | this._loadingIconElement = DOM.query(this._controller.searchBarContainer, '.js-yxt-SearchBar-LoadingIndicator'); 12 | this.inputEl = DOM.query(this._controller.searchBarContainer, this._controller.inputEl); 13 | } 14 | 15 | onEnter (prevState) { 16 | this._loadingIconElement.classList.remove('yxt-SearchBar-Icon--inactive'); 17 | } 18 | 19 | onExit (nextState) { 20 | this.inputEl.blur(); 21 | this._loadingIconElement.classList.add('yxt-SearchBar-Icon--inactive'); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/ui/statemachine/searchiconstate.js: -------------------------------------------------------------------------------- 1 | import { State } from './statemachine'; 2 | import DOM from '../dom/dom'; 3 | 4 | /** 5 | * Defines behavior for search bar icon in search state and during state transitions 6 | */ 7 | export default class SearchIconState extends State { 8 | constructor (controller, stateId, useCustomIcon) { 9 | super(stateId); 10 | this._controller = controller; 11 | this._useCustomIcon = useCustomIcon; 12 | if (useCustomIcon) { 13 | this._searchIconElement = DOM.query(this._controller.searchBarContainer, '.js-yxt-SearchBar-buttonImage'); 14 | } else { 15 | this._searchIconElement = DOM.query(this._controller.searchBarContainer, '.js-yxt-AnimatedForward'); 16 | this.svgWrapper = DOM.query(this._searchIconElement, '.Icon--yext_animated_forward'); 17 | } 18 | } 19 | 20 | canEnter (context) { 21 | return !this._controller.iconIsFrozen; 22 | } 23 | 24 | onEnter (prevState) { 25 | this._useCustomIcon ? this._handleTransitionToCustom() : this._handleTransitionToMagifyingGlass(); 26 | } 27 | 28 | _handleTransitionToMagifyingGlass () { 29 | this._searchIconElement.classList.remove('yxt-SearchBar-Icon--inactive'); 30 | this.svgWrapper.classList.remove('yxt-SearchBar-MagnifyingGlass--static'); 31 | } 32 | 33 | _handleTransitionToCustom () { 34 | this._searchIconElement.classList.remove('yxt-SearchBar-Icon--inactive'); 35 | } 36 | 37 | onExit (nextState) { 38 | this._searchIconElement.classList.add('yxt-SearchBar-Icon--inactive'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ui/templates/cards/accordion.hbs: -------------------------------------------------------------------------------- 1 |
3 | 4 | {{#if _config.details}} 5 | 17 | {{else}} 18 |

19 | {{{_config.title}}} 20 |

21 | {{/if}} 22 | 23 | {{#if _config.details}} 24 |
25 |
26 |
{{{_config.subtitle}}}
27 |
{{{_config.details}}}
28 | {{#if hasCTAs}} 29 |
30 | {{/if}} 31 |
32 |
33 | {{/if}} 34 |
-------------------------------------------------------------------------------- /src/ui/templates/cards/card.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | -------------------------------------------------------------------------------- /src/ui/templates/controls/date.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{title}} 3 | {{#if minLabel}} 4 | 5 | {{/if}} 6 | 12 | 13 | 14 | {{#if maxLabel}} 15 | 16 | {{/if}} 17 | 23 |
-------------------------------------------------------------------------------- /src/ui/templates/controls/geolocation.hbs: -------------------------------------------------------------------------------- 1 |
2 |

{{title}}

3 |
4 |
5 |
6 | 7 | 10 | 18 |
19 |
20 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /src/ui/templates/controls/range.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{title}} 3 | 4 | {{#if minLabel}} 5 | 6 | {{/if}} 7 | 15 | 16 | {{#if maxLabel}} 17 | 18 | {{/if}} 19 | 27 |
-------------------------------------------------------------------------------- /src/ui/templates/ctas/cta.hbs: -------------------------------------------------------------------------------- 1 | 6 | {{#if hasIcon}} 7 |
8 | {{#if _config.includeLegacyClasses}} 9 | {{> icons/iconPartial iconName=_config.icon iconUrl=_config.iconUrl classNames="yxt-Results-ctaIcon" }} 10 | {{else}} 11 | {{> icons/iconPartial iconName=_config.icon iconUrl=_config.iconUrl }} 12 | {{/if}} 13 |
14 | {{/if}} 15 |
16 | {{{_config.label}}} 17 |
18 |
19 | -------------------------------------------------------------------------------- /src/ui/templates/ctas/ctacollection.hbs: -------------------------------------------------------------------------------- 1 | {{#each callsToAction}} 2 | {{#if this}} 3 |
6 |
7 | {{/if}} 8 | {{/each}} 9 | -------------------------------------------------------------------------------- /src/ui/templates/filters/facets.hbs: -------------------------------------------------------------------------------- 1 | {{#unless isNoResults}} 2 |
3 | 4 |
5 | {{/unless}} -------------------------------------------------------------------------------- /src/ui/templates/filters/filterbox.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{#if title}} 3 |

4 |
5 | {{> icons/builtInIcon iconName='elements'}} 6 |
7 | {{title}} 8 |

9 | {{/if}} 10 | {{#each filterConfigs}} 11 |
12 |
13 | {{/each}} 14 | {{#if (or showApplyButton showReset)}} 15 |
16 | {{#if showApplyButton}} 17 | 21 | {{/if}} 22 | {{~#if showReset ~}} 23 | 26 | {{/if}} 27 |
28 | {{/if}} 29 |
30 | -------------------------------------------------------------------------------- /src/ui/templates/icons/builtInIcon.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ui/templates/icons/icon.hbs: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/ui/templates/icons/iconPartial.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ui/templates/icons/voiceSearchIcon.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{#if customMicIconUrl}} 4 | {{> icons/iconPartial iconUrl=customMicIconUrl}} 5 | {{else}} 6 | {{> icons/builtInIcon iconName='voice_search_mic'}} 7 | {{/if}} 8 |
9 |
10 | {{#if customListeningIconUrl}} 11 | {{> icons/iconPartial iconUrl=customListeningIconUrl}} 12 | {{else}} 13 | {{> icons/builtInIcon iconName='voice_search_dots'}} 14 | {{/if}} 15 |
16 | 17 |
18 | -------------------------------------------------------------------------------- /src/ui/templates/navigation/navigation.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ui/templates/results/appliedfilters.hbs: -------------------------------------------------------------------------------- 1 | {{#if appliedFiltersArray.length}} 2 |
3 | {{#each appliedFiltersArray}} 4 | {{#if ../_config.showFieldNames}} 5 |
6 | {{this.label}} 7 | : 8 |
9 | {{/if}} 10 | {{#each filterDataArray}} 11 | {{#if removable}} 12 | 17 | {{else}} 18 |
19 | {{displayValue}} 20 | {{#unless @last}},{{/unless}} 21 |
22 | {{/if}} 23 | {{/each}} 24 | {{#unless @last}} 25 |
{{../_config.delimiter}}
26 | {{/unless}} 27 | {{/each}} 28 |
29 | {{/if}} -------------------------------------------------------------------------------- /src/ui/templates/results/map.hbs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yext/answers-search-ui/05c3b5e2e21a36480a9b449abfe393ac5c618648/src/ui/templates/results/map.hbs -------------------------------------------------------------------------------- /src/ui/templates/results/resultssectionheader.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | {{> icons/iconPartial iconName=_config.sectionTitleIconName iconUrl=_config.sectionTitleIconUrl}} 5 |
6 |

7 | {{_config.sectionTitle}} 8 |

9 |
10 | {{#if verticalURL}} 11 | 15 | {{_config.viewAllText}} 16 | 17 | {{/if}} 18 |
19 | {{#if appliedQueryFilters}} 20 | 42 | {{/if}} 43 | -------------------------------------------------------------------------------- /src/ui/templates/results/universalresults.hbs: -------------------------------------------------------------------------------- 1 | {{#if isSearchComplete}} 2 | {{#if showNoResults}} 3 | {{> results/noresults}} 4 | {{else}} 5 |
6 |
9 |
10 |
11 | {{/if}} 12 | {{/if}} 13 | -------------------------------------------------------------------------------- /src/ui/templates/results/verticalresultscount.hbs: -------------------------------------------------------------------------------- 1 | {{#unless isHidden}} 2 |
3 | {{translate 4 | phrase= 5 | '[[start]] 6 | - 7 | [[end]] 8 | of 9 | [[resultsCount]]' 10 | context='Example: 1-10 of 50' 11 | start=pageStart 12 | end=pageEnd 13 | resultsCount=total 14 | }} 15 |
16 | {{/unless}} 17 | -------------------------------------------------------------------------------- /src/ui/templates/search/filtersearch.hbs: -------------------------------------------------------------------------------- 1 |

{{title}}

2 |
3 | 4 | 10 | 11 |
12 | {{translate 13 | phrase='When autocomplete results are available, use up and down arrows to review and enter to select.' 14 | }} 15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /src/ui/templates/search/locationbias.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | 5 | {{locationDisplayName}} 6 | {{#if accuracyText}}({{accuracyText}}){{/if}} {{#if allowUpdate}}| {{/if}} 7 |
8 |
-------------------------------------------------------------------------------- /src/ui/templates/search/spellcheck.hbs: -------------------------------------------------------------------------------- 1 | {{#if shouldShow}} 2 |
3 | 9 |
10 | {{/if}} -------------------------------------------------------------------------------- /src/ui/tools/searchparamsparser.js: -------------------------------------------------------------------------------- 1 | /** @module SearchParamsParser */ 2 | 3 | export default function buildSearchParameters (searchParameterConfigs) { 4 | const searchParameters = { 5 | sectioned: false, 6 | fields: [] 7 | }; 8 | if (searchParameterConfigs === undefined) { 9 | return searchParameters; 10 | } 11 | if (searchParameterConfigs.sectioned) { 12 | searchParameters.sectioned = searchParameterConfigs.sectioned; 13 | } 14 | searchParameters.fields = buildFields(searchParameterConfigs.fields); 15 | return searchParameters; 16 | } 17 | 18 | function buildFields (fieldConfigs) { 19 | if (fieldConfigs === undefined) { 20 | return []; 21 | } 22 | 23 | return fieldConfigs.map(fc => ({ fetchEntities: false, ...fc })); 24 | } 25 | -------------------------------------------------------------------------------- /src/ui/tools/urlutils.js: -------------------------------------------------------------------------------- 1 | import SearchParams from '../dom/searchparams'; 2 | 3 | /** 4 | * Construct a new redirect url with params saved in answers storage. 5 | * In the case of duplicate params, priorize user-specified params. 6 | * 7 | * @param {string} redirectUrl user-specified redirect url 8 | * @param {SearchParams} params url params saved in answers storage 9 | * @returns {string} new redirect url including params from answers storage 10 | */ 11 | export function constructRedirectUrl (redirectUrl, params) { 12 | const urlParser = document.createElement('a'); 13 | urlParser.href = redirectUrl; 14 | const redirectUrlParams = new SearchParams(urlParser.search); 15 | for (const [key, val] of params.entries()) { 16 | if (!redirectUrlParams.has(key)) { 17 | redirectUrlParams.set(key, val); 18 | } 19 | } 20 | urlParser.search = redirectUrlParams.toString(); 21 | return urlParser.href; 22 | } 23 | -------------------------------------------------------------------------------- /tests/acceptance/acceptancesuites/performancemarkssuite.js: -------------------------------------------------------------------------------- 1 | import { FACETS_PAGE } from '../constants'; 2 | import FacetsPage from '../pageobjects/facetspage'; 3 | import { MockedVerticalSearchRequest } from '../fixtures/responses/vertical/search'; 4 | import { MockedVerticalAutoCompleteRequest } from '../fixtures/responses/vertical/autocomplete'; 5 | import { waitForResults } from '../utils'; 6 | 7 | fixture`Performance marks on search` 8 | .requestHooks( 9 | MockedVerticalSearchRequest, 10 | MockedVerticalAutoCompleteRequest 11 | ) 12 | .page`${FACETS_PAGE}`; 13 | 14 | test('window.performance calls are marked for a normal search', async t => { 15 | const marksToCheck = [ 16 | 'yext.answers.initStart', 17 | 'yext.answers.statusStart', 18 | 'yext.answers.statusEnd', 19 | 'yext.answers.ponyfillStart', 20 | 'yext.answers.ponyfillEnd', 21 | 'yext.answers.verticalQueryStart', 22 | 'yext.answers.verticalQuerySent', 23 | 'yext.answers.verticalQueryResponseReceived', 24 | 'yext.answers.verticalQueryResponseRendered' 25 | ]; 26 | const searchComponent = FacetsPage.getSearchComponent(); 27 | await searchComponent.submitQuery(); 28 | await waitForResults(); 29 | 30 | // All performance marks should be called at least once with a search 31 | for (let i = 0; i < marksToCheck.length; i++) { 32 | const markName = marksToCheck[i]; 33 | const marksFoundWithName = await t.eval(() => { 34 | return JSON.stringify(window.performance.getEntriesByName(markName)); 35 | }, { dependencies: { markName } }); 36 | await t.expect(JSON.parse(marksFoundWithName.length)).gt(0); 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /tests/acceptance/acceptancesuites/searchbaronlysuite.js: -------------------------------------------------------------------------------- 1 | import { ClientFunction } from 'testcafe'; 2 | import { SEARCH_BAR_ONLY_PAGE } from '../constants'; 3 | import SearchBarOnlyPage from '../pageobjects/searchbaronlypage'; 4 | import { MockedUniversalAutoCompleteRequest } from '../fixtures/responses/universal/autocomplete'; 5 | 6 | /** 7 | * This file contains acceptance tests for a SearchBar-only page. 8 | * Note that before any tests are run, a local HTTP server is spun 9 | * up to serve the page and the dist directory of Answers. This server 10 | * is closed once all tests have completed. 11 | */ 12 | fixture`SearchBar-only page works as expected` 13 | .requestHooks(MockedUniversalAutoCompleteRequest) 14 | .page`${SEARCH_BAR_ONLY_PAGE}`; 15 | 16 | test('Basic search and redirect flow', async t => { 17 | const searchComponent = SearchBarOnlyPage.getSearchComponent(); 18 | 19 | await searchComponent.enterQuery('Tom'); 20 | await searchComponent.clearQuery(); 21 | 22 | await searchComponent.enterQuery('ama'); 23 | await searchComponent.getAutoComplete().selectOption('amani farooque phone number'); 24 | 25 | const getUrl = ClientFunction(() => window.location.href); 26 | const clientUrl = await getUrl(); 27 | const expectedUrl = 'https://theme.slapshot.pagescdn.com/?query=amani+farooque+phone+number'; 28 | await t.expect(clientUrl.startsWith(expectedUrl)).ok(); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/acceptance/acceptancesuites/sortoptionssuite.js: -------------------------------------------------------------------------------- 1 | import { FACETS_PAGE } from '../constants'; 2 | import FacetsPage from '../pageobjects/facetspage'; 3 | import { MockedVerticalSearchRequest } from '../fixtures/responses/vertical/search'; 4 | import { Selector } from 'testcafe'; 5 | import { browserRefreshPage } from '../utils'; 6 | import SearchRequestLogger from '../searchrequestlogger'; 7 | import { MockedVerticalAutoCompleteRequest } from '../fixtures/responses/vertical/autocomplete'; 8 | 9 | fixture`SortOptions suite` 10 | .requestHooks( 11 | MockedVerticalSearchRequest, 12 | MockedVerticalAutoCompleteRequest 13 | ) 14 | .page`${FACETS_PAGE}`; 15 | 16 | test('selecting a sort option and refreshing maintains that sort selection', async t => { 17 | const searchComponent = FacetsPage.getSearchComponent(); 18 | await searchComponent.submitQuery(); 19 | await SearchRequestLogger.waitOnSearchComplete(t); 20 | 21 | const thirdSortOption = await Selector('.yxt-SortOptions-optionSelector').nth(2); 22 | await t.click(thirdSortOption); 23 | await browserRefreshPage(); 24 | 25 | await t.expect(thirdSortOption.checked).ok(); 26 | }); 27 | 28 | fixture`W3C Accessibility standards are met` 29 | .requestHooks(MockedVerticalSearchRequest) 30 | .page`${FACETS_PAGE}`; 31 | 32 | test('Sort options focus state works', async t => { 33 | const searchComponent = FacetsPage.getSearchComponent(); 34 | await searchComponent.submitQuery(); 35 | 36 | const firstOption = await Selector('.yxt-SortOptions-optionSelector').nth(0); 37 | 38 | await t.click(firstOption); 39 | await t.expect(firstOption.focused).ok(); 40 | }); 41 | -------------------------------------------------------------------------------- /tests/acceptance/acceptancesuites/universalinitialsearch.js: -------------------------------------------------------------------------------- 1 | import { UNIVERSAL_INITIAL_SEARCH_PAGE } from '../constants'; 2 | import { Selector } from 'testcafe'; 3 | import { getCurrentUrlParams, waitForResults } from '../utils'; 4 | import StorageKeys from '../../../src/core/storage/storagekeys'; 5 | import { MockedUniversalSearchRequest } from '../fixtures/responses/universal/search'; 6 | import { MockedUniversalAutoCompleteRequest } from '../fixtures/responses/universal/autocomplete'; 7 | 8 | fixture`Universal page with default initial search` 9 | .requestHooks( 10 | MockedUniversalSearchRequest, 11 | MockedUniversalAutoCompleteRequest 12 | ) 13 | .page`${UNIVERSAL_INITIAL_SEARCH_PAGE}`; 14 | 15 | test('blank defaultInitialSearch will fire on universal if allowEmptySearch is true', async t => { 16 | await waitForResults(); 17 | await t.expect(Selector('.yxt-Results').exists).ok(); 18 | }); 19 | 20 | test('referrerPageUrl is added to the URL on default initial searches', async t => { 21 | await waitForResults(); 22 | const currentSearchParams = await getCurrentUrlParams(); 23 | const referrerPageUrl = currentSearchParams.has(StorageKeys.REFERRER_PAGE_URL); 24 | await t.expect(referrerPageUrl).ok(); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/acceptance/acceptancesuites/universalsuite.js: -------------------------------------------------------------------------------- 1 | import UniversalPage from '../pageobjects/universalpage'; 2 | import { UNIVERSAL_PAGE } from '../constants'; 3 | import { MockedUniversalAutoCompleteRequest } from '../fixtures/responses/universal/autocomplete'; 4 | import { MockedUniversalSearchRequest } from '../fixtures/responses/universal/search'; 5 | import SearchRequestLogger from '../searchrequestlogger'; 6 | 7 | /** 8 | * This file contains acceptance tests for a universal search page. 9 | * Note that before any tests are run, a local HTTP server is spun 10 | * up to serve the search page and the dist directory of Answers. 11 | * This server is closed once all tests have completed. 12 | */ 13 | 14 | fixture`Universal search page works as expected` 15 | .requestHooks( 16 | SearchRequestLogger.createUniversalSearchLogger(), 17 | MockedUniversalSearchRequest, 18 | MockedUniversalAutoCompleteRequest 19 | ) 20 | .page`${UNIVERSAL_PAGE}`; 21 | 22 | test('Basic universal flow', async t => { 23 | const searchComponent = UniversalPage.getSearchComponent(); 24 | await searchComponent.enterQuery('Tom'); 25 | await searchComponent.clearQuery(); 26 | 27 | await searchComponent.enterQuery('ama'); 28 | await searchComponent.getAutoComplete().selectOption('amani farooque phone number'); 29 | await SearchRequestLogger.waitOnSearchComplete(t); 30 | 31 | const sections = 32 | await UniversalPage.getUniversalResultsComponent().getSections(); 33 | await t.expect(sections.length).eql(2); 34 | 35 | const faqsSectionTitle = await sections[1].getTitle(); 36 | await t.expect(faqsSectionTitle.toUpperCase()).contains('FAQ'); 37 | }); 38 | -------------------------------------------------------------------------------- /tests/acceptance/acceptancesuites/verticalinitialsearch.js: -------------------------------------------------------------------------------- 1 | import { FILTERBOX_PAGE } from '../constants'; 2 | import { getCurrentUrlParams, waitForResults } from '../utils'; 3 | import StorageKeys from '../../../src/core/storage/storagekeys'; 4 | import { MockedVerticalSearchRequest } from '../fixtures/responses/vertical/search'; 5 | import { MockedVerticalAutoCompleteRequest } from '../fixtures/responses/vertical/autocomplete'; 6 | 7 | fixture`Vertical page with default initial search` 8 | .requestHooks(MockedVerticalSearchRequest, MockedVerticalAutoCompleteRequest) 9 | .page`${FILTERBOX_PAGE}`; 10 | 11 | test('referrerPageUrl is added to the URL on default initial searches', async t => { 12 | await waitForResults(); 13 | const currentSearchParams = await getCurrentUrlParams(); 14 | const referrerPageUrl = currentSearchParams.has(StorageKeys.REFERRER_PAGE_URL); 15 | await t.expect(referrerPageUrl).ok(); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/acceptance/blocks/facetscomponent.js: -------------------------------------------------------------------------------- 1 | import FilterBoxComponentBlock from './filterboxcomponent'; 2 | import { Selector } from 'testcafe'; 3 | 4 | /** 5 | * This class models user interactions with the {@link FacetsComponent}. 6 | */ 7 | export default class FacetsComponentBlock { 8 | constructor () { 9 | const filterBoxSelector = Selector('.js-yxt-Facets .yxt-FilterBox-container'); 10 | this._filterBox = new FilterBoxComponentBlock(filterBoxSelector); 11 | } 12 | 13 | /** 14 | * Return the child filter box component's block. 15 | */ 16 | getFilterBox () { 17 | return this._filterBox; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/acceptance/blocks/filterboxcomponent.js: -------------------------------------------------------------------------------- 1 | import { Selector, t } from 'testcafe'; 2 | import FilterOptionsComponentBlock from './filteroptionscomponent'; 3 | /** 4 | * This class models user interactions with the {@link FilterBoxComponent}. 5 | */ 6 | export default class FilterBoxComponentBlock { 7 | constructor (selector) { 8 | this._container = '.yxt-FilterBox-container'; 9 | this._selector = selector || Selector(this._container); 10 | } 11 | 12 | /** 13 | * Gets the child FilterOptions block with the given title. 14 | * @param {String} title 15 | */ 16 | async getFilterOptions (title) { 17 | const filterOptions = await this._selector.find('.yxt-FilterOptions-fieldSet').withText(title); 18 | return new FilterOptionsComponentBlock(filterOptions); 19 | } 20 | 21 | /** 22 | * Apply the filters in this filter box. 23 | */ 24 | async applyFilters () { 25 | const applyButton = await this._selector.find('.js-yext-filterbox-apply'); 26 | await t.click(applyButton); 27 | } 28 | 29 | /** 30 | * Reset the child filters. 31 | */ 32 | async reset () { 33 | const reset = await this._selector.find('.js-yxt-FilterBox-reset'); 34 | await t.click(reset); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/acceptance/blocks/filtersearchcomponent.js: -------------------------------------------------------------------------------- 1 | import { Selector, t } from 'testcafe'; 2 | 3 | /** 4 | * This class models user interactions with the {@link FilterSearchComponent}. 5 | */ 6 | export default class FilterSearchComponentBlock { 7 | constructor () { 8 | this._selector = Selector('.yext-search-container'); 9 | this._input = this._selector.find('.js-yext-query'); 10 | } 11 | 12 | /** 13 | * Selects the filter with the given display value in FilterSearch's autocomplete. 14 | * 15 | * @param {string} displayValue 16 | * @returns {Promise} 17 | */ 18 | async selectFilter (displayValue) { 19 | return t 20 | .click(this._input) 21 | .pressKey('ctrl+a') 22 | .pressKey('backspace') 23 | .typeText(this._input, displayValue) 24 | .pressKey('space') 25 | .wait(500) 26 | .click(this._selector.find('.js-yext-autocomplete-option').withExactText(displayValue)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/acceptance/blocks/paginationcomponent.js: -------------------------------------------------------------------------------- 1 | import { Selector, t } from 'testcafe'; 2 | 3 | /** 4 | * This class models user interactions with the {@link PaginationComponent}. 5 | */ 6 | export default class PaginationComponentBlock { 7 | constructor () { 8 | this._currentPage = Selector('#active-page'); 9 | this._links = Selector('.yxt-Pagination-link'); 10 | } 11 | 12 | /** 13 | * Find the next-page button and click it 14 | */ 15 | async clickNextButton () { 16 | await t.click(this._links.filter('.js-yxt-Pagination-next')); 17 | } 18 | 19 | /** 20 | * Find the current page label+number, if it exists 21 | */ 22 | getActivePageLabelAndNumberPromise () { 23 | return this._currentPage.textContent; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/acceptance/blocks/spellcheckcomponent.js: -------------------------------------------------------------------------------- 1 | import { Selector, t } from 'testcafe'; 2 | 3 | /** 4 | * This class models user interactions with the {@link SpellCheckComponent}. 5 | */ 6 | export default class SpellCheckComponentBlock { 7 | constructor () { 8 | this._spellCheck = Selector('.yxt-SpellCheck'); 9 | } 10 | 11 | /** 12 | * Clicks the suggested 13 | */ 14 | async clickLink () { 15 | await t.click(this._spellCheck.find('a')); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/acceptance/blocks/universalresultscomponent.js: -------------------------------------------------------------------------------- 1 | import { Selector } from 'testcafe'; 2 | import VerticalResultsComponentBlock from './verticalresultscomponent'; 3 | /** 4 | * This class models interactions with the {@link UniversalResultsComponent}. 5 | */ 6 | export default class UniversalResultsComponentBlock { 7 | /** 8 | * Returns the {@link VerticalResultsComponentBlock}s associated 9 | * with each vertical section. These are wrapped in a {@link Promise}. 10 | * 11 | * @returns {Promise} A {@link Promise} containing the various 12 | * {@link VerticalResultsComponentBlock}s. 13 | */ 14 | async getSections () { 15 | const selector = Selector('.yxt-Results'); 16 | const sectionCount = await selector.count; 17 | 18 | const sections = []; 19 | for (let i = 0; i < sectionCount; i++) { 20 | sections.push(new VerticalResultsComponentBlock(selector.nth(i))); 21 | } 22 | 23 | return sections; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/acceptance/blocks/verticalresultscomponent.js: -------------------------------------------------------------------------------- 1 | import { Selector } from 'testcafe'; 2 | 3 | /** 4 | * Models the user interaction with a {@link VerticalResultsComponent}. 5 | */ 6 | export default class VerticalResultsComponentBlock { 7 | /** 8 | * Creates a new instance of {@link VerticalResultsComponentBlock}, 9 | * based on the provided {@link Selector}. 10 | * 11 | * @param {Selector} selector The {@link Selector} corresponding to 12 | * this new instance. If one is not provided, 13 | * a default is used. 14 | */ 15 | constructor (selector = Selector('.yxt-Results')) { 16 | this._selector = selector; 17 | } 18 | 19 | /** 20 | * Returns the title of this vertical results section, wrapped in a 21 | * {@link Promise}. 22 | * 23 | * @returns {Promise} Title of the {@link VerticalResultsComponentBlock}, 24 | * wrapped in a {@link Promise}. 25 | */ 26 | async getTitle () { 27 | const title = await this._selector.find('.yxt-Results-title').innerText; 28 | return title; 29 | } 30 | 31 | async getResultsCountTotal () { 32 | const resultsCountTotal = Selector('.yxt-ResultsHeader-resultsCountTotal'); 33 | const countText = await resultsCountTotal.innerText; 34 | return Number.parseInt(countText); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/acceptance/constants.js: -------------------------------------------------------------------------------- 1 | export const VERTICAL_SEARCH_URL_REGEX = /v2\/accounts\/me\/search\/vertical\/query/; 2 | export const UNIVERSAL_SEARCH_URL_REGEX = /v2\/accounts\/me\/search\/query/; 3 | export const PORT = 9999; 4 | 5 | const PAGE_DIR = `http://localhost:${PORT}/tests/acceptance/fixtures/html`; 6 | 7 | export const UNIVERSAL_PAGE = `${PAGE_DIR}/universal`; 8 | export const VERTICAL_PAGE = `${PAGE_DIR}/vertical`; 9 | export const FACETS_PAGE = `${PAGE_DIR}/facets`; 10 | export const FACETS_ON_LOAD_PAGE = `${PAGE_DIR}/facetsonload`; 11 | export const FILTERBOX_PAGE = `${PAGE_DIR}/filterbox`; 12 | export const UNIVERSAL_INITIAL_SEARCH_PAGE = `${PAGE_DIR}/universalinitialsearch`; 13 | export const SEARCH_BAR_ONLY_PAGE = `${PAGE_DIR}/searchbaronly`; 14 | -------------------------------------------------------------------------------- /tests/acceptance/fixtures/html/no-unsafe-eval.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | 16 |
17 | 18 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /tests/acceptance/fixtures/html/searchbaronly.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |
11 | 12 |
13 |
14 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tests/acceptance/fixtures/responses/cors.js: -------------------------------------------------------------------------------- 1 | export const CORSHeaders = { 'access-control-allow-credentials': true, 'access-control-allow-origin': 'http://localhost:9999' }; 2 | -------------------------------------------------------------------------------- /tests/acceptance/fixtures/responses/universal/autocomplete.js: -------------------------------------------------------------------------------- 1 | import { RequestMock } from 'testcafe'; 2 | import { CORSHeaders } from '../cors'; 3 | 4 | function generateAutoCompleteResponse (prompt) { 5 | const mockedResponse = { 6 | meta: { 7 | uuid: '01802d71-9901-1b83-9d50-ff143088f1ab', 8 | errors: [] 9 | }, 10 | response: { 11 | input: { 12 | value: prompt, 13 | queryIntents: [] 14 | }, 15 | results: [] 16 | } 17 | }; 18 | 19 | if (prompt === '') { 20 | mockedResponse.response.results = [ 21 | { 22 | value: 'a Rose by any other name', 23 | matchedSubstrings: [], 24 | queryIntents: [], 25 | verticalKeys: [] 26 | }, 27 | { 28 | value: 'amani farooque phone number', 29 | matchedSubstrings: [], 30 | queryIntents: [], 31 | verticalKeys: [] 32 | } 33 | ]; 34 | } else if (prompt.startsWith('a')) { 35 | mockedResponse.response.results = [ 36 | { 37 | value: 'amani farooque phone number', 38 | matchedSubstrings: [], 39 | queryIntents: [], 40 | verticalKeys: [] 41 | } 42 | ]; 43 | } 44 | 45 | return mockedResponse; 46 | } 47 | 48 | export const MockedUniversalAutoCompleteRequest = RequestMock() 49 | .onRequestTo(async request => { 50 | const urlRegex = /.*\.com\/v2\/accounts\/me\/search\/autocomplete/; 51 | return urlRegex.test(request.url) && request.method === 'get'; 52 | }) 53 | .respond((req, res) => { 54 | const parsedUrl = new URL(req.url); 55 | res.body = JSON.stringify(generateAutoCompleteResponse(parsedUrl.searchParams.get('input'))); 56 | res.headers = CORSHeaders; 57 | res.statusCode = 200; 58 | }); 59 | -------------------------------------------------------------------------------- /tests/acceptance/fixtures/responses/vertical/autocomplete.js: -------------------------------------------------------------------------------- 1 | import { RequestMock } from 'testcafe'; 2 | import { CORSHeaders } from '../cors'; 3 | 4 | function generateAutoCompleteResponse (prompt) { 5 | const mockedResponse = { 6 | meta: { 7 | uuid: '01802d71-9901-1b83-9d50-ff143088f1ab', 8 | errors: [] 9 | }, 10 | response: { 11 | input: { 12 | value: prompt, 13 | queryIntents: [] 14 | }, 15 | results: [] 16 | } 17 | }; 18 | 19 | if (prompt === '') { 20 | mockedResponse.response.results = [ 21 | { 22 | value: 'a Rose by any other name', 23 | matchedSubstrings: [], 24 | queryIntents: [], 25 | verticalKeys: [] 26 | } 27 | ]; 28 | } else if (prompt.startsWith('vir')) { 29 | mockedResponse.response.results = [ 30 | { 31 | value: 'virginia', 32 | matchedSubstrings: [], 33 | queryIntents: [], 34 | verticalKeys: [] 35 | } 36 | ]; 37 | } 38 | 39 | return mockedResponse; 40 | } 41 | 42 | export const MockedVerticalAutoCompleteRequest = RequestMock() 43 | .onRequestTo(async request => { 44 | const urlRegex = /.*\.com\/v2\/accounts\/me\/search\/vertical\/autocomplete/; 45 | return urlRegex.test(request.url) && request.method === 'get'; 46 | }) 47 | .respond((req, res) => { 48 | const parsedUrl = new URL(req.url); 49 | res.body = JSON.stringify(generateAutoCompleteResponse(parsedUrl.searchParams.get('input'))); 50 | res.headers = CORSHeaders; 51 | res.statusCode = 200; 52 | }); 53 | -------------------------------------------------------------------------------- /tests/acceptance/fixtures/responses/vertical/search.js: -------------------------------------------------------------------------------- 1 | import { RequestMock } from 'testcafe'; 2 | import { CORSHeaders } from '../cors'; 3 | import { generateKMVerticalSearchResponse } from './KM/generateKMResponse'; 4 | import { generatePeopleVerticalSearchResponse } from './people/generatePeopleResponse'; 5 | import { basicResponseData } from './sharedData'; 6 | 7 | function generateVerticalSearchResponse (verticalKey, input, offset, filterParams) { 8 | switch (verticalKey) { 9 | case 'people': 10 | return generatePeopleVerticalSearchResponse(input, offset, filterParams); 11 | case 'KM': 12 | return generateKMVerticalSearchResponse(input, offset); 13 | default: 14 | return basicResponseData; 15 | } 16 | } 17 | 18 | export const MockedVerticalSearchRequest = RequestMock() 19 | .onRequestTo(async request => { 20 | const urlRegex = /^https:\/\/prod-cdn.us.yextapis.com\/v2\/accounts\/me\/search\/vertical\/query/; 21 | return urlRegex.test(request.url) && request.method === 'get'; 22 | }) 23 | .respond((req, res) => { 24 | const parsedUrl = new URL(req.url); 25 | 26 | const filterParams = { 27 | facetFilters: JSON.parse(parsedUrl.searchParams.get('facetFilters')), 28 | locationRadius: parseFloat(parsedUrl.searchParams.get('locationRadius')) || null, 29 | filters: JSON.parse(parsedUrl.searchParams.get('filters') || 'null') 30 | }; 31 | 32 | res.body = JSON.stringify(generateVerticalSearchResponse( 33 | parsedUrl.searchParams.get('verticalKey'), 34 | parsedUrl.searchParams.get('input'), 35 | parsedUrl.searchParams.get('offset'), 36 | filterParams 37 | )); 38 | res.headers = CORSHeaders; 39 | res.statusCode = 200; 40 | }); 41 | -------------------------------------------------------------------------------- /tests/acceptance/fixtures/responses/vertical/sharedData.js: -------------------------------------------------------------------------------- 1 | export const meta = { 2 | uuid: '018163fe-e697-6ffc-6346-d01618241911', 3 | errors: [] 4 | }; 5 | 6 | export const basicResponseData = { 7 | businessId: 3350634, 8 | queryId: '018163fe-e6b4-af13-36e8-91d629a343d4', 9 | results: [], 10 | resultsCount: 0, 11 | source: 'KNOWLEDGE_MANAGER', 12 | searchIntents: [] 13 | }; 14 | 15 | export const locationBias = { 16 | latitude: 38.890396, 17 | longitude: -77.084159, 18 | locationDisplayName: 'Arlington, Virginia, United States', 19 | accuracy: 'DEVICE' 20 | }; 21 | -------------------------------------------------------------------------------- /tests/acceptance/pagenavigator.js: -------------------------------------------------------------------------------- 1 | const { waitTillHTMLRendered, getQueryParamsString } = require('./wcag/utils'); 2 | 3 | /** 4 | * Responsible for navigating pages 5 | */ 6 | class PageNavigator { 7 | constructor (page, siteUrl) { 8 | this._page = page; 9 | this._siteUrl = siteUrl; 10 | } 11 | 12 | async goto (pageName, queryParams = {}) { 13 | const queryParamsString = getQueryParamsString(queryParams); 14 | const url = `${this._siteUrl}/${pageName}?${queryParamsString}`; 15 | await this._page.goto(url); 16 | await waitTillHTMLRendered(this._page); 17 | } 18 | 19 | /** 20 | * Click on an element of the current page 21 | * 22 | * @param {string} selector The CSS selector to click on 23 | */ 24 | async click (selector) { 25 | await this._page.click(selector); 26 | await waitTillHTMLRendered(this._page); 27 | } 28 | } 29 | 30 | module.exports = PageNavigator; 31 | -------------------------------------------------------------------------------- /tests/acceptance/pageobjects/searchbaronlypage.js: -------------------------------------------------------------------------------- 1 | import SearchComponentBlock from '../blocks/searchcomponent'; 2 | 3 | /** 4 | * A model of a SearchBar-only page, containing block representations 5 | * of the various {@link Component}s a user would interact with. 6 | */ 7 | class SearchBarOnlyPage { 8 | constructor () { 9 | this._searchComponent = new SearchComponentBlock(); 10 | } 11 | 12 | /** 13 | * Returns the {@link SearchComponentBlock} on the page. 14 | */ 15 | getSearchComponent () { 16 | return this._searchComponent; 17 | } 18 | } 19 | 20 | export default new SearchBarOnlyPage(); 21 | -------------------------------------------------------------------------------- /tests/acceptance/pageobjects/universalpage.js: -------------------------------------------------------------------------------- 1 | import SearchComponentBlock from '../blocks/searchcomponent'; 2 | import UniversalResultsComponentBlock from '../blocks/universalresultscomponent'; 3 | 4 | /** 5 | * A model of a universal search page, containing block representations 6 | * of the various {@link Component}s a user would interact with. 7 | */ 8 | class UniversalPage { 9 | constructor () { 10 | this._searchComponent = new SearchComponentBlock(); 11 | this._universalResultsComponent = new UniversalResultsComponentBlock(); 12 | } 13 | 14 | /** 15 | * Returns the {@link SearchComponentBlock} on the page. 16 | */ 17 | getSearchComponent () { 18 | return this._searchComponent; 19 | } 20 | 21 | /** 22 | * Returns the {@link UniversalResultsComponentBlock} on the page. 23 | */ 24 | getUniversalResultsComponent () { 25 | return this._universalResultsComponent; 26 | } 27 | } 28 | 29 | export default new UniversalPage(); 30 | -------------------------------------------------------------------------------- /tests/acceptance/pageobjects/verticalpage.js: -------------------------------------------------------------------------------- 1 | import SearchComponentBlock from '../blocks/searchcomponent'; 2 | import VerticalResultsComponentBlock from '../blocks/verticalresultscomponent'; 3 | import PaginationComponentBlock from '../blocks/paginationcomponent'; 4 | import SpellCheckComponentBlock from '../blocks/spellcheckcomponent'; 5 | 6 | /** 7 | * A model of a vertical search page, containing block representations 8 | * of the various {@link Component}s a user would interact with. 9 | */ 10 | class VerticalPage { 11 | constructor () { 12 | this._searchComponent = new SearchComponentBlock(); 13 | this._verticalResultsComponent = new VerticalResultsComponentBlock(); 14 | this._paginationComponent = new PaginationComponentBlock(); 15 | this._spellCheckComponent = new SpellCheckComponentBlock(); 16 | } 17 | 18 | /** 19 | * Returns the {@link SearchComponentBlock} on the page. 20 | */ 21 | getSearchComponent () { 22 | return this._searchComponent; 23 | } 24 | 25 | /** 26 | * Returns the {@link VerticalResultsComponentBlock} on the page. 27 | */ 28 | getVerticalResultsComponent () { 29 | return this._verticalResultsComponent; 30 | } 31 | 32 | /** 33 | * Returns the {@link PaginationComponentBlock} on the page. 34 | */ 35 | getPaginationComponent () { 36 | return this._paginationComponent; 37 | } 38 | 39 | /** 40 | * Returns the {@link SpellCheckComponentBlock} on the page. 41 | */ 42 | getSpellCheckComponent () { 43 | return this._spellCheckComponent; 44 | } 45 | } 46 | 47 | export default new VerticalPage(); 48 | -------------------------------------------------------------------------------- /tests/acceptance/percy/snapshots.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const percySnapshot = require('@percy/puppeteer'); 3 | const http = require('http'); 4 | const handler = require('serve-handler'); 5 | const PageNavigator = require('../pagenavigator'); 6 | 7 | (async () => { 8 | const server = await setupServer(); 9 | const browser = await puppeteer.launch(); 10 | const page = await browser.newPage(); 11 | const navigator = new PageNavigator(page, 'http://localhost:9999/tests/acceptance/fixtures/html'); 12 | 13 | async function snap (slug, name) { 14 | await navigator.goto(slug); 15 | await percySnapshot(page, name); 16 | } 17 | await snap('facets.html', 'facets page pre search'); 18 | await snap('no-unsafe-eval.html', 'no unsafe eval CSP'); 19 | 20 | await browser.close(); 21 | await server.close(); 22 | })(); 23 | 24 | /** 25 | * Initalizes the server to port 9999 26 | */ 27 | async function setupServer () { 28 | const server = http.createServer((request, response) => { 29 | return handler(request, response); 30 | }); 31 | server.listen(9999); 32 | return server; 33 | } 34 | -------------------------------------------------------------------------------- /tests/acceptance/utils.js: -------------------------------------------------------------------------------- 1 | import { ClientFunction, Selector } from 'testcafe'; 2 | 3 | /* global location */ 4 | 5 | export async function browserRefreshPage () { 6 | await ClientFunction(() => location.reload())(); 7 | await waitForResults(); 8 | } 9 | 10 | export async function browserBackButton () { 11 | await ClientFunction(() => window.history.back())(); 12 | await waitForResults(); 13 | } 14 | 15 | export async function browserForwardButton () { 16 | await ClientFunction(() => window.history.forward())(); 17 | await waitForResults(); 18 | } 19 | 20 | export async function getCurrentUrlParams () { 21 | const urlParams = await ClientFunction(() => window.location.search)(); 22 | return new URLSearchParams(urlParams); 23 | } 24 | 25 | export async function waitForResults () { 26 | const resultsSelector = await Selector('.yxt-Results'); 27 | await resultsSelector.with({ visibilityCheck: true })(); 28 | } 29 | -------------------------------------------------------------------------------- /tests/acceptance/wcag/utils.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Waits until the content of the page stops changing. 4 | * 5 | * This methods polls the html content length and waits until the value is the same for three 6 | * consecutive samples before returning. 7 | * 8 | * @param {import('puppeteer').Page} page 9 | */ 10 | module.exports.waitTillHTMLRendered = async function (page) { 11 | const pollingIntervalMsecs = 750; 12 | const minNumStableIntervals = 3; 13 | 14 | let previousHTMLSize = 0; 15 | let numStableIntervals = 0; 16 | let isHTMLStabilized = false; 17 | 18 | while (!isHTMLStabilized) { 19 | await page.waitForTimeout(pollingIntervalMsecs); 20 | const currentHTMLSize = (await page.content()).length; 21 | 22 | if (currentHTMLSize === previousHTMLSize) { 23 | numStableIntervals++; 24 | } else { 25 | numStableIntervals = 0; 26 | } 27 | 28 | isHTMLStabilized = (numStableIntervals >= minNumStableIntervals && currentHTMLSize > 0); 29 | previousHTMLSize = currentHTMLSize; 30 | } 31 | }; 32 | 33 | /** 34 | * Returns a string represenation of a query params object 35 | * 36 | * @example 37 | * The input of {query: 'test', url: 'localhost'} returns 'query=test&url=localhost' 38 | * 39 | * @param {Object} queryParams 40 | * @returns {string} 41 | */ 42 | module.exports.getQueryParamsString = function (queryParams) { 43 | return new URLSearchParams(queryParams).toString(); 44 | }; 45 | -------------------------------------------------------------------------------- /tests/acceptance/wcag/wcagreporter.js: -------------------------------------------------------------------------------- 1 | class WcagReporter { 2 | /** 3 | * @param {PageNavigator} pageNavigator 4 | * @param {AxePuppeteer} analyzer 5 | */ 6 | constructor (pageNavigator, analyzer) { 7 | this._pageNavigator = pageNavigator; 8 | this._analyzer = analyzer; 9 | this.results = []; 10 | } 11 | 12 | async analyze () { 13 | await this._analyzeUniversalPages(); 14 | await this._analyzeVerticalPages(); 15 | return this.results; 16 | } 17 | 18 | async _analyzeUniversalPages () { 19 | await this._pageNavigator.goto('universal'); 20 | this.results.push(await this._analyzer.analyze()); 21 | await this._pageNavigator.goto('universal', { query: 'virginia' }); 22 | this.results.push(await this._analyzer.analyze()); 23 | } 24 | 25 | async _analyzeVerticalPages () { 26 | await this._pageNavigator.goto('facets', { query: 'tom' }); 27 | this.results.push(await this._analyzer.analyze()); 28 | await this._pageNavigator.goto('filterbox', { query: 'sales' }); 29 | this.results.push(await this._analyzer.analyze()); 30 | await this._pageNavigator.goto('vertical'); 31 | this.results.push(await this._analyzer.analyze()); 32 | await this._pageNavigator.goto('vertical', { query: 'technology' }); 33 | this.results.push(await this._analyzer.analyze()); 34 | } 35 | } 36 | 37 | module.exports = WcagReporter; 38 | -------------------------------------------------------------------------------- /tests/answers-search-bar.js: -------------------------------------------------------------------------------- 1 | import ANSWERS from '../src/answers-search-bar'; 2 | import mockWindow from './setup/mockwindow'; 3 | import initAnswers from './setup/initanswers'; 4 | import Searcher from '../src/core/models/searcher'; 5 | 6 | jest.mock('../src/core/analytics/analyticsreporter'); 7 | jest.mock('../src/ui/rendering/handlebarsrenderer'); 8 | 9 | let windowSpy; 10 | 11 | describe('ANSWERS search bar instance integration testing', () => { 12 | beforeEach(() => { 13 | windowSpy = jest.spyOn(window, 'window', 'get'); 14 | }); 15 | 16 | afterEach(() => { 17 | windowSpy.mockRestore(); 18 | }); 19 | 20 | it('An ANSWERS impression event is reported during ANSWERS.init', async () => { 21 | mockWindow(windowSpy, { 22 | location: { 23 | search: '?query=test' 24 | } 25 | }); 26 | await initAnswers(ANSWERS); 27 | const expectedEvent = { 28 | eventType: 'SEARCH_BAR_IMPRESSION', 29 | searcher: Searcher.UNIVERSAL, 30 | standAlone: true 31 | }; 32 | expect(ANSWERS._analyticsReporterService.report).toHaveBeenCalledWith(expectedEvent, expect.anything()); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /tests/conf/i18n/extract/translationextractor.js: -------------------------------------------------------------------------------- 1 | const TranslationExtractor = require('../../../../conf/i18n/extract/translationextractor'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | 5 | describe('TranslationExtractor generates proper POT files', () => { 6 | function readFile (filename) { 7 | const fixturesDir = path.resolve(__dirname, '../../../fixtures'); 8 | return fs.readFileSync(path.join(fixturesDir, filename)).toString(); 9 | } 10 | 11 | let extractor; 12 | beforeEach(() => { 13 | extractor = new TranslationExtractor(); 14 | }); 15 | 16 | it('can extract from a javascript file', () => { 17 | const rawJavascript = readFile('rawjavascript.js'); 18 | const expectedJavascriptPot = readFile('expectedjavascript.pot'); 19 | 20 | extractor.extract(rawJavascript, 'rawjavascript.js'); 21 | const potString = extractor.getPotString(); 22 | 23 | expect(potString).toEqual(expectedJavascriptPot); 24 | }); 25 | 26 | it('can extract from a hbs file', () => { 27 | const rawTemplate = readFile('rawtemplate.hbs'); 28 | const expectedTemplatePot = readFile('expectedtemplate.pot'); 29 | 30 | extractor.extract(rawTemplate, 'rawtemplate.hbs'); 31 | const potString = extractor.getPotString(); 32 | 33 | expect(potString).toEqual(expectedTemplatePot); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/conf/i18n/runtimecallgeneratorutils.js: -------------------------------------------------------------------------------- 1 | const { generateProcessTranslationJsCall } = require('../../../conf/i18n/runtimecallgeneratorutils'); 2 | 3 | describe('generateProcessTranslationJsCall works as expected', () => { 4 | it('simple translation', () => { 5 | const translation = 'Bonjour'; 6 | const interpolationValues = {}; 7 | 8 | const actualJsCall = generateProcessTranslationJsCall(translation, interpolationValues); 9 | const expectedJsCall = 'ANSWERS.processTranslation(\'Bonjour\', {})'; 10 | 11 | expect(actualJsCall).toEqual(expectedJsCall); 12 | }); 13 | 14 | it('translation with plural form', () => { 15 | const translations = { 16 | 0: '[[count]] résultat pour [[verticalName]]', 17 | 1: '[[count]] résultats pour [[verticalName]]' 18 | }; 19 | const interpolationValues = { 20 | count: 'myCount', 21 | verticalName: 'verticalName' 22 | }; 23 | 24 | const actualJsCall = generateProcessTranslationJsCall(translations, interpolationValues, 'myCount'); 25 | const expectedJsCall = 'ANSWERS.processTranslation({0:\'[[count]] résultat pour [[verticalName]]\',1:\'[[count]] résultats pour [[verticalName]]\'}, {count:myCount,verticalName:verticalName}, myCount)'; 26 | 27 | expect(actualJsCall).toEqual(expectedJsCall); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/conf/i18n/translatecallparser.js: -------------------------------------------------------------------------------- 1 | const TranslateCallParser = require('../../../conf/i18n/translatecallparser'); 2 | 3 | describe('TranslateCallParser creates a TranslationPlaceholder from a TranslationFlagger', () => { 4 | const translateCallParser = new TranslateCallParser(); 5 | it('All params parse properly', () => { 6 | const translateCall = `TranslationFlagger.flag({ 7 | phrase: 'Simple phrase', 8 | pluralForm: 'Plural form', 9 | context: 'Context', 10 | count: 42, 11 | interpolationValues: { 12 | start: min, 13 | end: max 14 | } 15 | })`; 16 | const lineNumber = 1; 17 | const filePath = 'src/file.js'; 18 | const expectedResult = { 19 | _phrase: 'Simple phrase', 20 | _pluralForm: 'Plural form', 21 | _context: 'Context', 22 | _count: '42', 23 | _interpolationValues: { 24 | start: 'min', 25 | end: 'max' 26 | }, 27 | _lineNumber: lineNumber, 28 | _filepath: filePath 29 | }; 30 | const actualResult = translateCallParser.parse(translateCall, lineNumber, filePath); 31 | expect(actualResult).toMatchObject(expectedResult); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /tests/conf/i18n/translationplaceholderutils.js: -------------------------------------------------------------------------------- 1 | const Handlebars = require('handlebars'); 2 | const { fromMustacheStatementNode } = require('../../../conf/i18n/translationplaceholderutils'); 3 | const TranslationPlaceholder = require('../../../conf/i18n/models/translationplaceholder'); 4 | describe('Creating a translation placeholder from a HBS template', () => { 5 | it('fromMustacheStatementNode usage', () => { 6 | const template = `{{ 7 | translate 8 | phrase='result' 9 | pluralForm='results' 10 | count='resultCount' 11 | param1='param1' 12 | param2='param2' 13 | context='testing' 14 | }}`; 15 | 16 | const indexOfFirstMustacheStatement = 0; 17 | const mustacheStatement = Handlebars.parse(template).body[indexOfFirstMustacheStatement]; 18 | const filePath = '/path/to/file'; 19 | const actualTranslationPlaceholder = fromMustacheStatementNode(mustacheStatement, filePath); 20 | 21 | const expectedTranslationPlaceholder = new TranslationPlaceholder({ 22 | phrase: 'result', 23 | pluralForm: 'results', 24 | context: 'testing', 25 | count: 'resultCount', 26 | interpolationValues: { count: 'resultCount', param1: 'param1', param2: 'param2' }, 27 | lineNumber: 1, 28 | filepath: filePath 29 | }); 30 | expect(actualTranslationPlaceholder).toMatchObject(expectedTranslationPlaceholder); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/conf/npm/generateentrypoints.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | describe('generateentrypoints script works as expected', () => { 4 | const mockConstant = { 5 | ALL_LANGUAGES: ['en', 'es', 'fr'] 6 | }; 7 | jest.mock('../../../conf/i18n/constants', () => mockConstant); 8 | 9 | it('append exports field to input file', () => { 10 | const inputFile = './tests/conf/npm/test-generateentrypoints.json'; 11 | process.argv = ['', '', inputFile]; 12 | fs.writeFileSync(inputFile, '{}'); 13 | require('../../../conf/npm/generateentrypoints.js'); 14 | const output = fs.readFileSync(inputFile).toString(); 15 | const expectedOutput = fs.readFileSync('./tests/fixtures/conf/npm/inputFile.json').toString(); 16 | expect(output).toEqual(expectedOutput); 17 | fs.unlinkSync(inputFile); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tests/core/filters/combinedfilternode.js: -------------------------------------------------------------------------------- 1 | /* eslint camelcase: 0 */ 2 | import FilterNodeFactory from 'src/core/filters/filternodefactory'; 3 | import Filter from 'src/core/models/filter'; 4 | 5 | describe('CombinedFilterNode', () => { 6 | it('can parse CombinedFilterNodes into SimpleFilterNodes', () => { 7 | const node_f0_v0 = FilterNodeFactory.from({ 8 | filter: Filter.equal('field0', 'value0'), 9 | metadata: { 10 | fieldName: 'name0', 11 | displayValue: 'display0' 12 | } 13 | }); 14 | const node_f0_v1 = FilterNodeFactory.from({ 15 | filter: Filter.equal('field0', 'value1'), 16 | metadata: { 17 | fieldName: 'name0', 18 | displayValue: 'display1' 19 | } 20 | }); 21 | const node_f1_v0 = FilterNodeFactory.from({ 22 | filter: Filter.equal('field1', 'value0'), 23 | metadata: { 24 | fieldName: 'name1', 25 | displayValue: 'display0' 26 | } 27 | }); 28 | const node_f1_v1 = FilterNodeFactory.from({ 29 | filter: Filter.equal('field1', 'value1'), 30 | metadata: { 31 | fieldName: 'name1', 32 | displayValue: 'display1' 33 | } 34 | }); 35 | const combinedNode = FilterNodeFactory.and( 36 | FilterNodeFactory.and(node_f0_v0, node_f0_v1), 37 | FilterNodeFactory.or(node_f1_v0, node_f1_v1) 38 | ); 39 | const simpleFilterNodes = combinedNode.getSimpleDescendants(); 40 | expect(simpleFilterNodes).toHaveLength(4); 41 | expect(simpleFilterNodes).toContainEqual(node_f0_v0); 42 | expect(simpleFilterNodes).toContainEqual(node_f1_v0); 43 | expect(simpleFilterNodes).toContainEqual(node_f0_v1); 44 | expect(simpleFilterNodes).toContainEqual(node_f1_v1); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/core/models/appliedqueryfilter.js: -------------------------------------------------------------------------------- 1 | import AppliedQueryFilter from '../../../src/core/models/appliedqueryfilter'; 2 | 3 | it('constructs an applied query filter from an answers-core applied query filter', () => { 4 | const coreFilter = { 5 | displayKey: 'key1', 6 | displayValue: '23', 7 | filter: { 8 | fieldId: 'field1', 9 | matcher: '$eq', 10 | value: 'yext' 11 | } 12 | }; 13 | const expectedFilter = { 14 | key: 'key1', 15 | value: '23', 16 | filter: { 17 | field1: { 18 | $eq: 'yext' 19 | } 20 | }, 21 | fieldId: 'field1' 22 | }; 23 | const actualFilter = AppliedQueryFilter.fromCore(coreFilter); 24 | 25 | expect(actualFilter).toMatchObject(expectedFilter); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/core/models/directanswer.js: -------------------------------------------------------------------------------- 1 | import DirectAnswer from '../../../src/core/models/directanswer'; 2 | import Searcher from '../../../src/core/models/searcher'; 3 | 4 | it('constructs a direct answer from an answers-core direct answer', () => { 5 | const coreRelatedResult = { 6 | rawData: { 7 | food_type: 'fruit' 8 | }, 9 | id: 1, 10 | type: 'ce_fruit', 11 | link: 'yext.com' 12 | }; 13 | 14 | const coreDirectAnswer = { 15 | value: 'red', 16 | relatedResult: coreRelatedResult, 17 | verticalKey: 'foods', 18 | entityName: 'apple', 19 | fieldName: 'color', 20 | fieldApiName: 'c_color', 21 | fieldType: 'color' 22 | }; 23 | 24 | const expectedDirectAnswer = { 25 | answer: { 26 | entityName: 'apple', 27 | fieldName: 'color', 28 | fieldApiName: 'c_color', 29 | value: 'red', 30 | fieldType: 'color' 31 | }, 32 | relatedItem: { 33 | data: { 34 | fieldValues: { 35 | food_type: 'fruit' 36 | }, 37 | id: 1, 38 | type: 'ce_fruit', 39 | website: 'yext.com' 40 | }, 41 | verticalConfigId: 'foods' 42 | }, 43 | searcher: Searcher.UNIVERSAL 44 | }; 45 | 46 | const actualDirectAnswer = DirectAnswer.fromCore(coreDirectAnswer, null, Searcher.UNIVERSAL); 47 | 48 | expect(actualDirectAnswer).toMatchObject(expectedDirectAnswer); 49 | }); 50 | -------------------------------------------------------------------------------- /tests/core/models/locationbias.js: -------------------------------------------------------------------------------- 1 | import LocationBias from '../../../src/core/models/locationbias'; 2 | 3 | it('constructs a location bias model from an answers-core location bias', () => { 4 | const coreLocationBias = { 5 | latitude: 42, 6 | longitude: 42, 7 | displayName: 'Earth', 8 | method: 'DEVICE' 9 | }; 10 | 11 | const expectedLocationBias = { 12 | latitude: 42, 13 | longitude: 42, 14 | locationDisplayName: 'Earth', 15 | accuracy: 'DEVICE' 16 | }; 17 | 18 | const actualLocationBias = LocationBias.fromCore(coreLocationBias); 19 | 20 | expect(actualLocationBias).toMatchObject(expectedLocationBias); 21 | }); 22 | -------------------------------------------------------------------------------- /tests/core/models/navigation.js: -------------------------------------------------------------------------------- 1 | import Navigation from '../../../src/core/models/navigation'; 2 | 3 | it('constructs a navigation model from an answers-core vertical results array', () => { 4 | const verticalResultsArray = [{ verticalKey: 'faqs' }, { verticalKey: 'locations' }, { verticalKey: 'products' }]; 5 | 6 | const expectedNavigation = { 7 | tabOrder: [ 8 | 'faqs', 9 | 'locations', 10 | 'products' 11 | ] 12 | }; 13 | 14 | const actualNavigation = Navigation.fromCore(verticalResultsArray); 15 | 16 | expect(actualNavigation).toMatchObject(expectedNavigation); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/core/models/spellcheck.js: -------------------------------------------------------------------------------- 1 | import SpellCheck from '../../../src/core/models/spellcheck'; 2 | 3 | it('constructs a spellcheck from an answers-core spellcheck', () => { 4 | const coreSpellCheck = { 5 | originalQuery: 'where is the baank', 6 | correctedQuery: 'where is the bank', 7 | type: 'AUTOCORRECT' 8 | }; 9 | 10 | const expectedSpellCheck = { 11 | query: 'where is the baank', 12 | correctedQuery: { 13 | value: 'where is the bank' 14 | }, 15 | type: 'AUTOCORRECT', 16 | shouldShow: true 17 | }; 18 | 19 | const actualSpellCheck = SpellCheck.fromCore(coreSpellCheck); 20 | 21 | expect(actualSpellCheck).toMatchObject(expectedSpellCheck); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/core/models/universalresults.js: -------------------------------------------------------------------------------- 1 | import coreUniversalResponse from '../../fixtures/core/coreuniversalresponse.json'; 2 | import sdkUniversalResults from '../../fixtures/core/sdkuniversalresults.json'; 3 | import UniversalResults from '../../../src/core/models/universalresults'; 4 | 5 | it('constructs a universal results model from an answers-core universal results model', () => { 6 | const urls = { 7 | people: './people.html', 8 | faq: './faq.html' 9 | }; 10 | 11 | const actualUniversalResults = UniversalResults.fromCore(coreUniversalResponse, urls); 12 | 13 | expect(actualUniversalResults).toMatchObject(sdkUniversalResults); 14 | }); 15 | -------------------------------------------------------------------------------- /tests/core/models/verticalresults.js: -------------------------------------------------------------------------------- 1 | import VerticalResults from '../../../src/core/models/verticalresults'; 2 | 3 | describe('vertical results', () => { 4 | it('merges results together', () => { 5 | const data = { 6 | map: { 7 | mapCenter: 'test', 8 | mapMarkers: ['1', '2'] 9 | }, 10 | resultsCount: 4, 11 | results: ['1', '2'], 12 | searchState: 'test' 13 | }; 14 | 15 | const initialResults = new VerticalResults(data); 16 | const mergedResults = initialResults.append({ 17 | map: { 18 | mapCenter: 'test2', 19 | mapMarkers: ['3', '4'] 20 | }, 21 | resultsCount: 4, 22 | results: ['3', '4'], 23 | searchState: 'test2' 24 | }); 25 | 26 | expect(mergedResults.map.mapCenter).toBe('test'); 27 | expect(mergedResults.map.mapMarkers).toEqual(expect.arrayContaining(['1', '2', '3', '4'])); 28 | expect(mergedResults.results).toEqual(expect.arrayContaining(['1', '2', '3', '4'])); 29 | expect(mergedResults.searchState).toBe('test'); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /tests/core/speechrecognition/locales.js: -------------------------------------------------------------------------------- 1 | import { transformSpeechRecognitionLocaleForEdge } from '../../../src/core/speechrecognition/locales'; 2 | 3 | jest.mock('../../../src/core/constants', () => ({ 4 | SPEECH_RECOGNITION_LOCALES_SUPPORTED_BY_EDGE: 5 | require('../../../conf/i18n/constants').SPEECH_RECOGNITION_LOCALES_SUPPORTED_BY_EDGE 6 | })); 7 | 8 | it('works for plain languages', () => { 9 | expect(transformSpeechRecognitionLocaleForEdge('en')).toEqual('en'); 10 | expect(transformSpeechRecognitionLocaleForEdge('ZH_Hans')).toEqual('zh-hans'); 11 | }); 12 | 13 | it('will recognize supported locales that have dashes', () => { 14 | expect(transformSpeechRecognitionLocaleForEdge('en-US')).toEqual('en-us'); 15 | expect(transformSpeechRecognitionLocaleForEdge('en-GB')).toEqual('en-gb'); 16 | expect(transformSpeechRecognitionLocaleForEdge('zh-Hant-tw')).toEqual('zh-hant-tw'); 17 | }); 18 | 19 | it('defaults Edge incompatible locales to the language code', () => { 20 | expect(transformSpeechRecognitionLocaleForEdge('ja_FAKE')).toEqual('ja'); 21 | expect(transformSpeechRecognitionLocaleForEdge('en_AI')).toEqual('en'); 22 | expect(transformSpeechRecognitionLocaleForEdge('zH_hAns_fake')).toEqual('zh-hans'); 23 | expect(transformSpeechRecognitionLocaleForEdge('ZH-HANS-FAKE')).toEqual('zh-hans'); 24 | }); 25 | 26 | it('replaces underscores with dashes for supported locales', () => { 27 | expect(transformSpeechRecognitionLocaleForEdge('en_US')).toEqual('en-us'); 28 | expect(transformSpeechRecognitionLocaleForEdge('en_GB')).toEqual('en-gb'); 29 | }); 30 | 31 | it('canonicalizes case', () => { 32 | expect(transformSpeechRecognitionLocaleForEdge('en_uS')).toEqual('en-us'); 33 | expect(transformSpeechRecognitionLocaleForEdge('EN_AI')).toEqual('en'); 34 | expect(transformSpeechRecognitionLocaleForEdge('jA_AI')).toEqual('ja'); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/core/utils/apicontext.js: -------------------------------------------------------------------------------- 1 | import { isValidContext } from '../../../src/core/utils/apicontext'; 2 | 3 | describe('checking context is valid', () => { 4 | it('cannot be null', () => { 5 | const valid = isValidContext('null'); 6 | expect(valid).toEqual(false); 7 | }); 8 | 9 | it('cannot be undefined', () => { 10 | const valid = isValidContext('undefined'); 11 | expect(valid).toEqual(false); 12 | }); 13 | 14 | it('cannot be a string', () => { 15 | const valid = isValidContext('"string"'); 16 | expect(valid).toEqual(false); 17 | }); 18 | 19 | it('cannot be an empty string', () => { 20 | const valid = isValidContext('""'); 21 | expect(valid).toEqual(false); 22 | }); 23 | 24 | it('cannot be an array', () => { 25 | const valid = isValidContext('[{}, {}]'); 26 | expect(valid).toEqual(false); 27 | }); 28 | 29 | it('cannot be malformed', () => { 30 | const valid = isValidContext('variable'); 31 | expect(valid).toEqual(false); 32 | }); 33 | 34 | it('can be a regular object', () => { 35 | const valid = isValidContext('{"a":"tx","b":"hi"}'); 36 | expect(valid).toEqual(true); 37 | }); 38 | 39 | it('can be an empty object', () => { 40 | const valid = isValidContext('{}'); 41 | expect(valid).toEqual(true); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /tests/core/utils/configutils.js: -------------------------------------------------------------------------------- 1 | import { defaultConfigOption } from 'src/core/utils/configutils.js'; 2 | 3 | describe('defaultConfigOption helper method', () => { 4 | it('works for nested config', () => { 5 | const testConfig = { 6 | a: { 7 | b: { 8 | c: 1234 9 | } 10 | } 11 | }; 12 | expect(defaultConfigOption(testConfig, ['a.b.c'], 'default')).toEqual(1234); 13 | }); 14 | 15 | it('defaults when config option is not found', () => { 16 | const testConfig = { 17 | a: { 18 | b: { 19 | LOL: 1234 20 | } 21 | } 22 | }; 23 | expect(defaultConfigOption(testConfig, ['a.b.c'], 'default')).toEqual('default'); 24 | }); 25 | 26 | it('works for multiple config synonyms', () => { 27 | const testConfig = { 28 | a: { 29 | b: { 30 | LOL: 'yes' 31 | } 32 | }, 33 | LOL: 'not me' 34 | }; 35 | expect(defaultConfigOption(testConfig, ['a.b.c', 'a.b.LOL', 'LOL', 'default'], 'default')).toEqual('yes'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /tests/fixtures/conf/npm/inputFile.json: -------------------------------------------------------------------------------- 1 | { 2 | "exports": { 3 | "./css": "./dist/answers.css", 4 | "./rtl-css": "./dist/answers.rtl.css", 5 | ".": "./dist/answers-umd.js", 6 | "./modern": "./dist/answers-modern.js", 7 | "./modern.min": "./dist/answers-modern.min.js", 8 | "./template": "./dist/answerstemplates.compiled.min.js", 9 | "./es": "./dist/es-answers-umd.js", 10 | "./es/modern": "./dist/es-answers-modern.js", 11 | "./es/modern.min": "./dist/es-answers-modern.min.js", 12 | "./es/template": "./dist/es-answerstemplates.compiled.min.js", 13 | "./fr": "./dist/fr-answers-umd.js", 14 | "./fr/modern": "./dist/fr-answers-modern.js", 15 | "./fr/modern.min": "./dist/fr-answers-modern.min.js", 16 | "./fr/template": "./dist/fr-answerstemplates.compiled.min.js" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/fixtures/expectedjavascript.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=UTF-8\n" 4 | 5 | #: rawjavascript.js:18 6 | msgid "([[resultsCount]] result)" 7 | msgid_plural "([[resultsCount]] results)" 8 | msgstr[0] "" 9 | msgstr[1] "" 10 | 11 | #: rawjavascript.js:11 12 | msgid "Go to page [[maxPage]] of results" 13 | msgstr "" 14 | 15 | #: rawjavascript.js:7 16 | msgid "We are unable to determine your location" 17 | msgstr "" 18 | 19 | #: rawjavascript.js:32 20 | msgctxt "Alt-text" 21 | msgid "[[resultsCount]] autocomplete option found" 22 | msgid_plural "[[resultsCount]] autocomplete options found" 23 | msgstr[0] "" 24 | msgstr[1] "" 25 | 26 | #: rawjavascript.js:27 27 | msgctxt "Labels an input field" 28 | msgid "What are you interested in?" 29 | msgstr "" 30 | -------------------------------------------------------------------------------- /tests/fixtures/expectedtemplate.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=UTF-8\n" 4 | 5 | #: rawtemplate.hbs:3 6 | msgid "([[resultsCount]] result)" 7 | msgid_plural "([[resultsCount]] results)" 8 | msgstr[0] "" 9 | msgstr[1] "" 10 | 11 | #: rawtemplate.hbs:2 12 | msgid "Go to page [[maxPage]] of results" 13 | msgstr "" 14 | 15 | #: rawtemplate.hbs:1 16 | msgid "Go to the next page of results" 17 | msgstr "" 18 | 19 | #: rawtemplate.hbs:5 20 | msgctxt "Alt-text" 21 | msgid "[[resultsCount]] autocomplete option found" 22 | msgid_plural "[[resultsCount]] autocomplete options found" 23 | msgstr[0] "" 24 | msgstr[1] "" 25 | 26 | #: rawtemplate.hbs:4 27 | msgctxt "Change is a verb. Example: change filtering logic" 28 | msgid "change" 29 | msgstr "" 30 | -------------------------------------------------------------------------------- /tests/fixtures/rawjavascript.js: -------------------------------------------------------------------------------- 1 | const TranslationFlagger = require('../../src/ui/i18n/translationflagger'); 2 | 3 | // Interpolation values 4 | const maxPage = 3; 5 | const resultsCount = 3; 6 | 7 | TranslationFlagger.flag({ 8 | phrase: 'We are unable to determine your location' 9 | }); 10 | 11 | TranslationFlagger.flag({ 12 | phrase: 'Go to page [[maxPage]] of results', 13 | interpolationValues: { 14 | maxPage: maxPage 15 | } 16 | }); 17 | 18 | TranslationFlagger.flag({ 19 | phrase: '([[resultsCount]] result)', 20 | pluralForm: '([[resultsCount]] results)', 21 | count: resultsCount, 22 | interpolationValues: { 23 | resultsCount: resultsCount 24 | } 25 | }); 26 | 27 | TranslationFlagger.flag({ 28 | phrase: 'What are you interested in?', 29 | context: 'Labels an input field' 30 | }); 31 | 32 | TranslationFlagger.flag({ 33 | phrase: '[[resultsCount]] autocomplete option found', 34 | pluralForm: '[[resultsCount]] autocomplete options found', 35 | count: resultsCount, 36 | resultsCount: resultsCount, 37 | context: 'Alt-text' 38 | }); 39 | -------------------------------------------------------------------------------- /tests/fixtures/rawtemplate.hbs: -------------------------------------------------------------------------------- 1 | {{translate phrase='Go to the next page of results' }} 2 | {{translate phrase='Go to page [[maxPage]] of results' maxPage=maxPage }} 3 | {{translate phrase='([[resultsCount]] result)' pluralForm='([[resultsCount]] results)' count=resultsCount resultsCount=resultsCount }} 4 | {{translate phrase='change' context='Change is a verb. Example: change filtering logic' }} 5 | {{translate 6 | phrase='[[resultsCount]] autocomplete option found' 7 | pluralForm='[[resultsCount]] autocomplete options found' 8 | resultsCount=resultsCount 9 | count=resultsCount 10 | context='Alt-text' 11 | }} 12 | -------------------------------------------------------------------------------- /tests/fixtures/translations/fr.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: i18next-conv\n" 4 | "mime-version: 1.0\n" 5 | "Content-Type: text/plain; charset=utf-8\n" 6 | "Content-Transfer-Encoding: 8bit\n" 7 | "Plural-Forms: nplurals=2; plural=(n > 1)\n" 8 | "POT-Creation-Date: 2020-07-21T21:08:49.169Z\n" 9 | "PO-Revision-Date: 2020-07-21T21:08:49.169Z\n" 10 | "Language: fr\n" 11 | 12 | msgid "Breakfast" 13 | msgstr "Petit Déjeuner" 14 | 15 | msgid "Alternatively, you canview results across all search categories" 16 | msgstr "Sinon vous pouvezafficher les résultats dans toutes les catégories de recherche" 17 | 18 | msgid "The dog's bone" 19 | msgstr "L'os du chien" -------------------------------------------------------------------------------- /tests/fixtures/translations/lt-LT.po: -------------------------------------------------------------------------------- 1 | #: js/yext/pages/common/sitefields.js:36 2 | msgid "1 location selected" 3 | msgid_plural "[[count]] locations selected" 4 | msgstr[0] "Pasirinkta [[count]] tinklalapis" 5 | msgstr[1] "Pasirinkta [[count]] tinklalapiai" 6 | msgstr[2] "Pasirinkta [[count]] tinklalapių" 7 | 8 | #: js/yext/reviewsstorm/reviews.js:672 9 | msgid "Unable to email review" 10 | msgid_plural "Unable to email reviews" 11 | msgstr[0] "Nepavyksta nusiųsti apžvalgos el. paštu" 12 | msgstr[1] "Nepavyksta nusiųsti apžvalgų el. paštu" 13 | msgstr[2] "Nepavyksta nusiųsti apžvalgų el. paštu" 14 | -------------------------------------------------------------------------------- /tests/setup/initanswers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Initializes ANSWERS for jest testing with some functionality disabled 3 | * @param ANSWERS - The answers instance to initialize 4 | * @param config Config to add during initialization 5 | */ 6 | export default async function initAnswers (ANSWERS, config = {}) { 7 | // Don't load the templates during testing 8 | ANSWERS._loadTemplates = () => Promise.resolve(); 9 | await ANSWERS.init({ 10 | apiKey: 'test', 11 | experienceKey: 'test', 12 | // Don't load this polyfill during testing 13 | disableCssVariablesPonyfill: 'true', 14 | businessId: '123', 15 | ...config 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /tests/setup/managermocker.js: -------------------------------------------------------------------------------- 1 | /** @module ManagerMocker */ 2 | 3 | import MockComponentManager from './mockcomponentmanager'; 4 | import Storage from '../../src/core/storage/storage'; 5 | import StorageKeys from '../../src/core/storage/storagekeys'; 6 | 7 | /** 8 | * Generates a MockComponentManager with templates from the passed in template paths. 9 | * TODO(oshi): better module/method names 10 | */ 11 | export default function mockManager (mockedCore) { 12 | const storage = new Storage().init(); 13 | const core = { 14 | storage, 15 | triggerSearch: jest.fn(), 16 | setQuery: jest.fn(query => core.storage.setWithPersist(StorageKeys.QUERY, query)), 17 | ...mockedCore 18 | }; 19 | core.setQueryUpdateListener = (queryUpdateListener) => { 20 | core.queryUpdateListener = queryUpdateListener; 21 | }; 22 | core.setResultsUpdateListener = (resultsUpdateListener) => { 23 | core.resultsUpdateListener = resultsUpdateListener; 24 | }; 25 | const COMPONENT_MANAGER = new MockComponentManager(core); 26 | 27 | const mockAnalyticsReporter = { 28 | report: jest.fn(() => Promise.resolve()) 29 | }; 30 | COMPONENT_MANAGER.setAnalyticsReporter(mockAnalyticsReporter); 31 | return COMPONENT_MANAGER; 32 | } 33 | -------------------------------------------------------------------------------- /tests/setup/mockwindow.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Mocks the window for jest testing while putting the provided data on the window 3 | * @param {jest.SpyInstance} windowSpy A jest spy instance tracking calls to the window's get function 4 | * @param {Object} data Data added to the mocked window 5 | */ 6 | export default function mockWindow (windowSpy, data = {}) { 7 | windowSpy.mockImplementation(() => ({ 8 | performance: { 9 | mark: jest.fn() 10 | }, 11 | sessionStorage: { 12 | getItem: jest.fn(), 13 | setItem: jest.fn() 14 | }, 15 | addEventListener: jest.fn(), 16 | history: { 17 | replaceState: jest.fn() 18 | }, 19 | location: { 20 | search: jest.fn() 21 | }, 22 | ...data 23 | })); 24 | } 25 | -------------------------------------------------------------------------------- /tests/setup/setup.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import AnswersAdapter from './enzymeadapter'; 3 | import regeneratorRuntime from 'regenerator-runtime/runtime'; 4 | global.regeneratorRuntime = regeneratorRuntime; 5 | configure({ adapter: new AnswersAdapter() }); 6 | -------------------------------------------------------------------------------- /tests/ui/components/filters/filtersearchcomponent.js: -------------------------------------------------------------------------------- 1 | import mockManager from '../../../setup/managermocker'; 2 | import FilterNodeFactory from 'src/core/filters/filternodefactory'; 3 | 4 | describe('FilterSearch', () => { 5 | const COMPONENT_MANAGER = mockManager({ 6 | setStaticFilterNodes: () => {} 7 | }); 8 | 9 | it('builds FilterNodes correctly', () => { 10 | const title = 'abcdefg'; 11 | const config = { title }; 12 | const filterSearch = COMPONENT_MANAGER.create('FilterSearch', config); 13 | const query = 'Herndon, Virginia, USA, EARTH, MILKY WAY, UNIVERSE 7'; 14 | const filter = { 'builtin.location': { $eq: 'P-place.9519240260937770' } }; 15 | const filterNode = filterSearch._buildFilterNode(query, filter); 16 | const expectedFilterNode = FilterNodeFactory.from({ 17 | filter: filter, 18 | metadata: { 19 | fieldName: title, 20 | displayValue: `${query}` 21 | } 22 | }); 23 | expect(filterNode.filter).toEqual(expectedFilterNode.filter); 24 | expect(filterNode.metadata).toEqual(expectedFilterNode.metadata); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/ui/components/map/mapcomponent.js: -------------------------------------------------------------------------------- 1 | import MapComponent from '../../../../src/ui/components/map/mapcomponent'; 2 | 3 | describe('map component config', () => { 4 | let defaultConfig; 5 | const core = { 6 | storage: { 7 | get: () => {} 8 | } 9 | }; 10 | const systemConfig = { core }; 11 | beforeEach(() => { 12 | defaultConfig = { 13 | mapProvider: 'google' 14 | }; 15 | }); 16 | 17 | describe('noResults.visible has priority over displayAllResults and showEmptyMap', () => { 18 | it('visible: true, displayAllResults: false, showEmptyMap: false', () => { 19 | const config = { 20 | showEmptyMap: false, 21 | noResults: { 22 | visible: true, 23 | displayAllResults: false 24 | }, 25 | ...defaultConfig 26 | }; 27 | const component = new MapComponent(config, systemConfig); 28 | const { visible } = component._noResults; 29 | expect(visible).toBeTruthy(); 30 | }); 31 | 32 | it('visible: false, displayAllResults: true, showEmptyMap: true', () => { 33 | const config = { 34 | showEmptyMap: true, 35 | noResults: { 36 | visible: false, 37 | displayAllResults: true 38 | }, 39 | ...defaultConfig 40 | }; 41 | const component = new MapComponent(config, systemConfig); 42 | const { visible } = component._noResults; 43 | expect(visible).toBeFalsy(); 44 | }); 45 | }); 46 | 47 | it('does not break if given default config', () => { 48 | const component = new MapComponent(defaultConfig, systemConfig); 49 | const { displayAllResults, visible, template } = component._noResults; 50 | expect(displayAllResults).toBeFalsy(); 51 | expect(visible).toBeFalsy(); 52 | expect(template).toEqual(''); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /tests/ui/components/results/appliedfilterscomponent.js: -------------------------------------------------------------------------------- 1 | import { mount } from 'enzyme'; 2 | import mockManager from '../../../setup/managermocker'; 3 | import DOM from '../../../../src/ui/dom/dom'; 4 | import StorageKeys from '../../../../src/core/storage/storagekeys'; 5 | import SearchStates from '../../../../src/core/storage/searchstates'; 6 | import AppliedFiltersComponent from '../../../../src/ui/components/results/appliedfilterscomponent'; 7 | 8 | DOM.setup(document, new DOMParser()); 9 | let COMPONENT_MANAGER, defaultConfig; 10 | beforeEach(() => { 11 | COMPONENT_MANAGER = mockManager({ 12 | filterRegistry: { 13 | getAllFilterNodes: () => [] 14 | } 15 | }); 16 | const bodyEl = DOM.query('body'); 17 | DOM.empty(bodyEl); 18 | DOM.append(bodyEl, DOM.createEl('div', { id: 'test-component' })); 19 | 20 | defaultConfig = { 21 | container: '#test-component', 22 | verticalKey: 'people' 23 | }; 24 | }); 25 | 26 | describe('AppliedFilters component', () => { 27 | it('listens to updates to VERTICAL_RESULTS in storage', () => { 28 | const storage = COMPONENT_MANAGER.core.storage; 29 | const component = COMPONENT_MANAGER.create(AppliedFiltersComponent.type, defaultConfig); 30 | const wrapper = mount(component); 31 | expect(wrapper.exists('.yxt-AppliedFilters')).toBeFalsy(); 32 | storage.set(StorageKeys.VERTICAL_RESULTS, { 33 | searchState: SearchStates.SEARCH_COMPLETE, 34 | appliedQueryFilters: [ 35 | { 36 | fieldId: 'c_employeeDepartment', 37 | key: 'Employee Department', 38 | value: 'Technology' 39 | } 40 | ] 41 | }); 42 | wrapper.update(); 43 | expect(wrapper.exists('.yxt-AppliedFilters')).toBeTruthy(); 44 | }); 45 | }); 46 | --------------------------------------------------------------------------------