diff --git a/.eslintrc-code-compatibility.js b/.eslintrc-code-compatibility.js index 7f19396f34..92080c6c42 100644 --- a/.eslintrc-code-compatibility.js +++ b/.eslintrc-code-compatibility.js @@ -1,6 +1,6 @@ module.exports = { root: true, - extends: ['plugin:compat/recommended', 'plugin:ecmalist/recommended'], + extends: ['plugin:compat/recommended', 'plugin:ecmalist/recommended', 'plugin:react-hooks/recommended'], settings: { es: { aggressive: true } }, env: { browser: true, mocha: true }, parser: '@babel/eslint-parser', diff --git a/.eslintrc.js b/.eslintrc.js index 5aabca06b8..6e2a699d38 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,24 +10,40 @@ module.exports = { requireConfigFile: false, }, rules: { - // allow reassigning param - 'no-param-reassign': [2, { props: false }], - 'linebreak-style': ['error', 'unix'], + 'chai-friendly/no-unused-expressions': 2, 'import/extensions': ['error', { js: 'always' }], + 'import/no-cycle': 0, + 'linebreak-style': ['error', 'unix'], + 'no-await-in-loop': 0, + 'no-param-reassign': [2, { props: false }], + 'no-restricted-syntax': [ + 'error', + { + selector: 'ForInStatement', + message: 'for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.', + }, + { + selector: 'LabeledStatement', + message: 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.', + }, + { + selector: 'WithStatement', + message: '`with` is disallowed in strict mode because it makes code impossible to predict and optimize.', + }, + ], + 'no-return-assign': ['error', 'except-parens'], + 'no-unused-expressions': 0, 'object-curly-newline': ['error', { ObjectExpression: { multiline: true, minProperties: 6 }, ObjectPattern: { multiline: true, minProperties: 6 }, ImportDeclaration: { multiline: true, minProperties: 6 }, ExportDeclaration: { multiline: true, minProperties: 6 }, }], - 'no-unused-expressions': 0, - 'chai-friendly/no-unused-expressions': 2, - }, overrides: [ { files: ['test/**/*.js'], - rules: { 'no-console': 'off' }, + rules: { 'no-console': 0 }, }, ], ignorePatterns: [ diff --git a/.github/PULL_REQUEST_TEMPLATE/gnav.md b/.github/PULL_REQUEST_TEMPLATE/gnav.md new file mode 100644 index 0000000000..07056bb7a3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/gnav.md @@ -0,0 +1,26 @@ +## Description + +## Related Issue +Resolves: [MWPW-111111](https://jira.corp.adobe.com/browse/MWPW-111111) + +## Testing instructions + +## Screenshots (if appropriate): + + +## Test URLs +**Acrobat:** +- Before: https://www.stage.adobe.com/acrobat/online/sign-pdf.html?martech=off +- After: https://www.stage.adobe.com/acrobat/online/sign-pdf.html?martech=off&milolibs=--milo-- + +**BACOM:** +- Before: https://business.stage.adobe.com/fr/customer-success-stories.html?martech=off +- After: https://business.stage.adobe.com/fr/customer-success-stories.html?martech=off&milolibs=--milo-- + +**CC:** +- Before: https://main--cc--adobecom.hlx.live/?martech=off +- After: https://main--cc--adobecom.hlx.live/?martech=off&milolibs=--milo-- + +**Milo:** +- Before: https://main--milo--adobecom.hlx.page/ch_de/drafts/ramuntea/gnav-refactor?martech=off +- After: https://--milo--.hlx.page/ch_de/drafts/ramuntea/gnav-refactor?martech=off \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3e5e291697..a038de1534 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,7 @@ * Add your -* Specicic +* Specific * Features or fixes Resolves: [MWPW-NUMBER](https://jira.corp.adobe.com/browse/MWPW-NUMBER) diff --git a/.github/workflows/code-compatibility.yaml b/.github/workflows/code-compatibility.yaml index 2dff0eaa9f..177164ac14 100644 --- a/.github/workflows/code-compatibility.yaml +++ b/.github/workflows/code-compatibility.yaml @@ -5,8 +5,6 @@ on: branches: - main pull_request: - branches: - - main jobs: check: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 301f44ce65..6397ef8e95 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -15,8 +15,6 @@ on: push: branches: [ "main" ] pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] schedule: - cron: '18 6 * * 5' diff --git a/.github/workflows/dispatch.yml b/.github/workflows/dispatch.yml new file mode 100644 index 0000000000..78a396b37a --- /dev/null +++ b/.github/workflows/dispatch.yml @@ -0,0 +1,33 @@ +name: Dispatch workflows + +on: + push: + branches: + - main + +jobs: + dispatch-dc: + name: Dispatch DC workflow + if: github.repository == 'adobecom/milo' + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v3 + - uses: dorny/paths-filter@v2 + id: changes + with: + filters: | + src: + - 'libs/**' + - if: steps.changes.outputs.src == 'true' + name: Trigger DC Workflow + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.DC_PAT }} + script: | + github.rest.actions.createWorkflowDispatch({ + owner: 'adobecom', + repo: 'dc', + workflow_id: 'test-milo.yml', + ref: 'main', + }) diff --git a/.github/workflows/email-release.yaml b/.github/workflows/email-release.yaml index ea2bb95ee5..767a33be59 100644 --- a/.github/workflows/email-release.yaml +++ b/.github/workflows/email-release.yaml @@ -1,13 +1,15 @@ name: Milo Bot Release Email on: - pull_request: - types: [closed] + pull_request_target: + types: + - closed branches: - main jobs: action: + if: github.event.pull_request.merged == true runs-on: ubuntu-latest steps: @@ -16,7 +18,8 @@ jobs: - name: Use email bot uses: adobecom/milo-email-bot@main env: - TO_EMAIL: ${{ secrets.TO_EMAIL }} + TO_EMAIL_NEW_FEATURE: ${{ secrets.TO_EMAIL_NEW_FEATURE }} + TO_EMAIL_HIGH_IMPACT: ${{ secrets.TO_EMAIL_HIGH_IMPACT }} FROM_EMAIL: 'bot@em2344.milo.pink' FROM_NAME: 'Milo Bot' SG_KEY: ${{ secrets.SG_KEY }} diff --git a/.github/workflows/enforce-labels.yml b/.github/workflows/enforce-labels.yml index 9e5f0b64e7..c9531bc236 100644 --- a/.github/workflows/enforce-labels.yml +++ b/.github/workflows/enforce-labels.yml @@ -2,13 +2,13 @@ name: Enforce PR labels on: pull_request: - types: [labeled, unlabeled, opened, edited, synchronize] + types: [labeled, unlabeled, opened, synchronize] jobs: enforce-label: runs-on: ubuntu-latest steps: - uses: yogevbd/enforce-label-action@2.1.0 with: - REQUIRED_LABELS_ANY: "verified,trivial" - REQUIRED_LABELS_ANY_DESCRIPTION: "PR must be labeled with 'verified-fixed' or 'trivial'" + REQUIRED_LABELS_ANY: "verified,trivial,needs-verification" + REQUIRED_LABELS_ANY_DESCRIPTION: "PR must be labeled with 'trivial', 'needs-verification', or 'verified'" BANNED_LABELS: "do-not-merge,duplicate,invalid,wontfix" diff --git a/.github/workflows/run-lint.yaml b/.github/workflows/run-lint.yaml new file mode 100644 index 0000000000..3aab1f8ebb --- /dev/null +++ b/.github/workflows/run-lint.yaml @@ -0,0 +1,27 @@ +name: Lint +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.js' + +jobs: + run-lint: + name: Running eslint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: 18 + + - name: Install dependencies + run: npm ci + + - name: Run eslint on changed files + uses: tj-actions/eslint-changed-files@v20 + with: + config_path: ".eslintrc.js" + # ignore_path: "/path/to/.eslintignore" + # extra_args: "--max-warnings=0" diff --git a/.github/workflows/run-nala.yml b/.github/workflows/run-nala.yml index 6ade1a00f7..2117468a80 100644 --- a/.github/workflows/run-nala.yml +++ b/.github/workflows/run-nala.yml @@ -19,5 +19,10 @@ jobs: labels: ${{ join(github.event.pull_request.labels.*.name, ' ') }} branch: ${{ github.event.pull_request.head.ref }} repoName: ${{ github.repository }} + prUrl: ${{ github.event.pull_request.head.repo.html_url }} + prOrg: ${{ github.event.pull_request.head.repo.owner.login }} + prRepo: ${{ github.event.pull_request.head.repo.name }} + prBranch: ${{ github.event.pull_request.head.ref }} + prBaseBranch: ${{ github.event.pull_request.base.ref }} IMS_EMAIL: ${{ secrets.IMS_EMAIL }} IMS_PASS: ${{ secrets.IMS_PASS }} diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 983e8964cb..1319736fdc 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -4,9 +4,7 @@ on: branches: - main pull_request: - types: [opened, synchronize, reopened, edited] - branches: - - main + types: [opened, synchronize, reopened] jobs: run-tests: name: Running tests diff --git a/.stylelintrc.json b/.stylelintrc.json index caa6c58932..e8e6466238 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,3 +1,16 @@ { - "extends": ["stylelint-config-standard", "stylelint-config-prettier"] -} \ No newline at end of file + "extends": [ + "stylelint-config-standard", + "stylelint-config-prettier" + ], + "rules": { + "unit-no-unknown": [ + true, + { + "ignoreUnits": [ + "dvh" + ] + } + ] + } +} diff --git a/404.html b/404.html index 55fb3e457e..05a64d8e8a 100644 --- a/404.html +++ b/404.html @@ -3,6 +3,7 @@ 404 + diff --git a/CODEOWNERS b/CODEOWNERS index 712a1d1693..920c846853 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -3,33 +3,48 @@ # See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners # Milo Core (alphabetical order) +/fstab.yaml @adobecom/admins /libs/features/ @adobecom/admins +/libs/features/seotech/ @hparra +/libs/features/title-append/ @hparra +/libs/martech/ @adobecom/admins +/libs/scripts/ @adobecom/admins /libs/utils/ @adobecom/admins -# Milo Tools (alphabetical order) - # Milo Blocks (alphabetical order) /libs/blocks/accordion/ @fullcolorcoder @ryanmparrish /libs/blocks/aside/ @Sartxi +/libs/blocks/caas/ @chrischrischris +/libs/blocks/caas-config/ @chrischrischris /libs/blocks/carousel/ @rgclayton /libs/blocks/chart/ @meganthecoder @Brandon32 @JasonHowellSlavin @sanjayms01 +/libs/blocks/commerce/ @Axelcureno @VKniaz /libs/blocks/faas/ @seanchoi-dev /libs/blocks/faas-config/ @seanchoi-dev +/libs/blocks/fragment-personalization/ @chrischrischris /libs/blocks/global-footer/ @overmyheadandbody @mokimo @narcis-radu /libs/blocks/global-navigation/ @overmyheadandbody @mokimo @narcis-radu /libs/blocks/how-to/ @fullcolorcoder @ryanmparrish /libs/blocks/icon-block/ @elan-tbx /libs/blocks/local-nav/ @seanchoi-dev -/libs/blocks/marketo/ @Brandon32 +/libs/blocks/marketo/ @Brandon32 +/libs/blocks/marketo-config/ @Brandon32 /libs/blocks/marquee/ @ryanmparrish @Sartxi @auniverseaway /libs/blocks/media/ @ryanmparrish -/libs/blocks/merch/ @3ch023 @honstar @VKniaz @npeltier +/libs/blocks/merch/ @3ch023 @honstar @VKniaz @npeltier @Axelcureno +/libs/blocks/merch-card/ @VKniaz @Axelcureno @ryanmparrish +/libs/blocks/ost/ @Axelcureno @3ch023 @honstar @VKniaz @vladen @yesil @npeltier /libs/blocks/pdf-vewer/ @sanjayms01 +/libs/blocks/quiz/ @colloyd @sabyamon @fullcolorcoder @JackySun9 @elaineskpt @echampio-at-adobe +/libs/blocks/quiz-results/ @colloyd @sabyamon @fullcolorcoder @JackySun9 @elaineskpt @echampio-at-adobe /libs/blocks/quote/ @ryanmparrish /libs/blocks/table-of-contents/ @Brandon32 /libs/blocks/tabs/ @ryanmparrish +/libs/blocks/tag-selector/ @meganthecoder /libs/blocks/text/ @ryanmparrish /libs/blocks/video-metadata/ @hparra /libs/blocks/z-pattern/ @ryanmparrish - +# Milo Tools (alphabetical order) +/tools/caas-import/ @chrischrischris +/tools/send-to-caas/ @chrischrischris diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bcf422586..4102f3aa33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,74 +1,58 @@ -# Contributing to Project Helix +# Contributing to Milo -This project (like almost all of Project Helix) is an Open Development project and welcomes contributions from everyone who finds it useful or lacking. +This project is an Open Development project and welcomes contributions from everyone who finds it useful or lacking. ## Code Of Conduct -This project adheres to the Adobe [code of conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to cstaub at adobe dot com. +This project adheres to the Adobe [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to cstaub at adobe dot com. -## Contributor License Agreement +## Contributor License Agreement (CLA) All third-party contributions to this project must be accompanied by a signed contributor license. This gives Adobe permission to redistribute your contributions as part of the project. [Sign our CLA](http://opensource.adobe.com/cla.html)! You only need to submit an Adobe CLA one time, so if you have submitted one previously, you are good to go! -## Things to Keep in Mind +## How to Contribute -This project uses a **commit then review** process, which means that for approved maintainers, changes can be merged immediately, but will be reviewed by others. +First check if there is an existing issue in GitHub Issues (public) or JIRA (private). +Also check if there are other pull requests that might overlap or conflict with your intended contribution. -For other contributors, a maintainer of the project has to approve the pull request. +Fork the repository, make some changes on a branch on your fork, and create a pull request from your branch against `main`. -# Before You Contribute +Ensure that your PR follows the [pull request template](.github/pull_request_template.md): -* Check that there is an existing issue in GitHub issues -* Check if there are other pull requests that might overlap or conflict with your intended contribution +* description contains Issue or Ticket +* description _always_ contains at least one Milo-specific testing URL + * `https://--milo--.hlx.page/?martech=off` -# How to Contribute +Ensure your PR passes all checks: -1. Fork the repository -2. Make some changes on a branch on your fork -3. Create a pull request from your branch +* prerequesite labels are applied + * `trivial` or `needs-verification` +* unit tests pass +* helix-psi-check pass +* 100% test coverage (patch) +* etc. -In your pull request, outline: - -* What the changes intend -* How they change the existing code -* If (and what) they breaks -* Start the pull request with the GitHub issue ID, e.g. #123 - -Lastly, please follow the [pull request template](.github/pull_request_template.md) when submitting a pull request! - -Each commit message that is not part of a pull request: - -* Should contain the issue ID like `#123` -* Can contain the tag `[trivial]` for trivial changes that don't relate to an issue +Tips: +* Run `npm run lint` if your editor is not already doing so +* Please check that unit test pass! + * In the case of an occasional flakey test, please rerun the job +* Rebase and rerun checks to ensure your PR is up-to-date +* Use the `do not merge` label to prevent maintainers from merging your approved PR +Also see [Submitting PRs](https://github.com/adobecom/milo/wiki/Submitting-PRs). ## Coding Styleguides -We enforce a coding styleguide using `eslint`. As part of your build, run `npm run lint` to check if your code is conforming to the style guide. We do the same for every PR in our CI, so PRs will get rejected if they don't follow the style guide. +We enforce a coding styleguide using `eslint`. As part of your build, run `npm run lint` to check if your code is conforming to the style guide. You can fix some of the issues automatically by running `npx eslint . --fix`. -## Commit Message Format - -This project uses a structured commit changelog format that should be used for every commit. Use `npm run commit` instead of your usual `git commit` to generate commit messages using a wizard. - -```bash -# either add all changed files -$ git add -A -# or selectively add files -$ git add package.json -# then commit using the wizard -$ npm run commit -``` - -# How Contributions get Reviewed +## How Contributions get Reviewed One of the maintainers will look at the pull request within one week. Feedback on the pull request will be given in writing, in GitHub. +Not having a green check will result in indeterminate review delays. -# Release Management - -The project's committers will release to the [Adobe organization on npmjs.org](https://www.npmjs.com/org/adobe). -Please contact the [Adobe Open Source Advisory Board](https://git.corp.adobe.com/OpenSourceAdvisoryBoard/discuss/issues) to get access to the npmjs organization. +## Release Management -The release process is fully automated using `semantic-release`, increasing the version numbers, etc. based on the contents of the commit messages found. +Milo is a hot repo, meaning all changes on main are immediately available in production. diff --git a/README.md b/README.md index ce0c9307a0..80dd975815 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ You can use any text editor or IDE of your choice, but milo is highly optimized If you want to see how your local milo changes impact a consuming site you will need to work on a different port. ``` -npm run libs +npm run libs ``` Milo will run at: ``` diff --git a/build/htm-preact-debug.js b/build/htm-preact-debug.js index dddb229f20..73a78b812a 100644 --- a/build/htm-preact-debug.js +++ b/build/htm-preact-debug.js @@ -1,9 +1,13 @@ import 'preact/debug'; import { h, Component, createContext, createRef, render } from 'preact'; import { signal } from '@preact/signals'; -import { useState, useReducer, useEffect, useLayoutEffect, useRef, useImperativeHandle, useMemo, useCallback, useContext, useDebugValue, useErrorBoundary, useId } from 'preact/hooks'; +import { + useState, useReducer, useEffect, useLayoutEffect, useRef, useImperativeHandle, useMemo, useCallback, useContext, useDebugValue, useErrorBoundary, useId, +} from 'preact/hooks'; import htm from 'htm'; const html = htm.bind(h); -export { h, html, signal, render, Component, createContext, createRef, useState, useReducer, useEffect, useLayoutEffect, useRef, useImperativeHandle, useMemo, useCallback, useContext, useDebugValue, useErrorBoundary, useId }; +export { + h, html, signal, render, Component, createContext, createRef, useState, useReducer, useEffect, useLayoutEffect, useRef, useImperativeHandle, useMemo, useCallback, useContext, useDebugValue, useErrorBoundary, useId, +}; diff --git a/build/htm-preact.js b/build/htm-preact.js index ced722cb28..92578ead5d 100644 --- a/build/htm-preact.js +++ b/build/htm-preact.js @@ -2,9 +2,13 @@ import { h, Component, createContext, createRef, render } from 'preact'; import { signal } from '@preact/signals'; -import { useState, useReducer, useEffect, useLayoutEffect, useRef, useImperativeHandle, useMemo, useCallback, useContext, useDebugValue, useErrorBoundary, useId } from 'preact/hooks'; +import { + useState, useReducer, useEffect, useLayoutEffect, useRef, useImperativeHandle, useMemo, useCallback, useContext, useDebugValue, useErrorBoundary, useId, +} from 'preact/hooks'; import htm from 'htm'; const html = htm.bind(h); -export { h, html, signal, render, Component, createContext, createRef, useState, useReducer, useEffect, useLayoutEffect, useRef, useImperativeHandle, useMemo, useCallback, useContext, useDebugValue, useErrorBoundary, useId }; +export { + h, html, signal, render, Component, createContext, createRef, useState, useReducer, useEffect, useLayoutEffect, useRef, useImperativeHandle, useMemo, useCallback, useContext, useDebugValue, useErrorBoundary, useId, +}; diff --git a/experiments/test001/blocks/quote/quote.css b/experiments/test001/blocks/quote/quote.css new file mode 100644 index 0000000000..5ded4dfea2 --- /dev/null +++ b/experiments/test001/blocks/quote/quote.css @@ -0,0 +1,4 @@ +.quote { + background-color: #ff1693; + text-align: center; +} diff --git a/experiments/test001/blocks/quote/quote.js b/experiments/test001/blocks/quote/quote.js new file mode 100644 index 0000000000..586e2ccecf --- /dev/null +++ b/experiments/test001/blocks/quote/quote.js @@ -0,0 +1,3 @@ +export default function init(el) { + el.innerHTML = '

You\'re Logged Out: No Quote For You!!!!

'; +} diff --git a/libs/blocks/accordion/accordion.css b/libs/blocks/accordion/accordion.css index a371daad81..8fe5584e64 100644 --- a/libs/blocks/accordion/accordion.css +++ b/libs/blocks/accordion/accordion.css @@ -41,6 +41,10 @@ dl.accordion { color: var(--color-black); } +.accordion dt .accordion-heading { + margin: 0; +} + .accordion-icon { position: absolute; right: var(--spacing-xs); @@ -140,3 +144,70 @@ dl.accordion { .dark .accordion dt button:hover .accordion-icon::after { background: var(--color-gray-100); } + +/* Editorial Variation */ +.accordion-media { + display: none; +} + +.accordion-media > div { + position: relative; + display: none; + animation-duration: 1s; + animation-name: fade-in; +} + + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.accordion-media > div.expanded, +.accordion-media > div.expanded > img { + display: inline; + position: relative; + height: 525px; + max-height: 525px; + max-width: 700px; + width: auto; +} + +div.media-p { + width: 268px; + padding: 0; +} + +@media screen and (min-width: 1200px) { + .editorial { + display: flex; + gap: 54px; + align-items: center; + justify-content: center; + } + + .editorial .accordion { + width: 50%; + display: inline-block; + margin: 0; + } + + .media-p { + display: none; + } + + .accordion-media { + width: 700px; + height: 525px; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + margin: 0; + } +} diff --git a/libs/blocks/accordion/accordion.js b/libs/blocks/accordion/accordion.js index 32c2bb8ce6..e8d4972d2d 100644 --- a/libs/blocks/accordion/accordion.js +++ b/libs/blocks/accordion/accordion.js @@ -1,7 +1,9 @@ import { createTag } from '../../utils/utils.js'; -import { decorateBlockAnalytics, decorateLinkAnalytics } from '../../martech/attributes.js'; +import { decorateButtons } from '../../utils/decorate.js'; +import { processTrackingLabels } from '../../martech/analytics.js'; const faq = { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: [] }; +const mediaCollection = {}; function setSEO(questions) { faq.mainEntity.push(questions.map(({ name, text }) => ( @@ -10,39 +12,99 @@ function setSEO(questions) { document.head.append(script); } -function handleClick(el, dd) { +function toggleMedia(con, trig, status) { + if (status === 'open') { + trig.setAttribute('hidden', ''); + trig.setAttribute('aria-expanded', 'false'); + con.setAttribute('hidden', ''); + con.setAttribute('aria-expanded', 'false'); + } else { + trig.setAttribute('aria-expanded', 'true'); + trig.removeAttribute('hidden'); + con.setAttribute('aria-expanded', 'true'); + con.removeAttribute('hidden'); + } +} + +function displayMedia(displayArea, el, dd, i, expanded) { + const id = el.getAttribute('aria-controls').split('-')[1]; + [...mediaCollection[id]].forEach( + (mediaCollectionItem, idx, total) => { + mediaCollectionItem.classList.remove('expanded'); + + total.forEach((element, index) => { + const trigger = document.querySelector(`#accordion-${id}-trigger-${index + 1}`); + const content = document.querySelector(`#accordion-${id}-content-${index + 1}`); + toggleMedia(content, trigger, 'open'); + }); + toggleMedia(dd, el); + displayArea.childNodes[i - 1].classList.add('expanded'); + + if (expanded) { + toggleMedia(dd, el, 'open'); + displayArea.childNodes[i - 1]?.classList.remove('expanded'); + } + }, + ); +} + +function handleClick(el, dd, num) { const expanded = el.getAttribute('aria-expanded') === 'true'; + const analyticsValue = el.getAttribute('daa-ll'); if (expanded) { el.setAttribute('aria-expanded', 'false'); + el.setAttribute('daa-ll', analyticsValue.replace(/close-/, 'open-')); dd.setAttribute('hidden', ''); } else { el.setAttribute('aria-expanded', 'true'); + el.setAttribute('daa-ll', analyticsValue.replace(/open-/, 'close-')); dd.removeAttribute('hidden'); } + + const closestEditorial = el.closest('.editorial'); + if (closestEditorial) displayMedia(closestEditorial.querySelector('.accordion-media'), el, dd, num, expanded); +} + +function defalutOpen(accordion) { + handleClick(accordion.querySelector('.accordion-trigger'), accordion.querySelector('dd'), 1, 0); } -function createItem(accordion, id, heading, num) { +function createItem(accordion, id, heading, num, edit) { const triggerId = `accordion-${id}-trigger-${num}`; const panelId = `accordion-${id}-content-${num}`; const icon = createTag('span', { class: 'accordion-icon' }); + const hTag = heading.querySelector('h1, h2, h3, h4, h5, h6'); + const analyticsString = `open-${num}|${processTrackingLabels(heading.textContent)}`; const button = createTag('button', { type: 'button', id: triggerId, class: 'accordion-trigger', 'aria-expanded': 'false', 'aria-controls': panelId, + 'daa-ll': analyticsString, }, heading.textContent); button.append(icon); - const panel = heading.nextElementSibling.firstElementChild; - const para = panel.querySelector('p'); - const text = para ? para.textContent : panel.textContent; + const panel = heading.nextElementSibling?.firstElementChild; - const dt = createTag('dt', { role: 'heading', 'aria-level': 3 }, button); + const para = panel?.querySelector('p'); + const text = para ? para.textContent : panel?.textContent; + const dtAttrs = hTag ? {} : { role: 'heading', 'aria-level': 3 }; + const dtHtml = hTag ? createTag(hTag.tagName, { class: 'accordion-heading' }, button) : button; + const dt = createTag('dt', dtAttrs, dtHtml); const dd = createTag('dd', { role: 'region', 'aria-labelledby': triggerId, id: panelId, hidden: true }, panel); + const dm = createTag('div', { class: 'media-p' }); - button.addEventListener('click', (e) => { handleClick(e.target, dd); }); + if (edit) { + const ogMedia = mediaCollection[id][num - 1]; + const mediaCopy = ogMedia.cloneNode(true); + dm.append(mediaCopy); + dd.prepend(dm); + } + + button.addEventListener('click', (e) => { handleClick(e.target, dd, num, id); }); accordion.append(dt, dd); + return { name: heading.textContent, text }; } @@ -51,12 +113,31 @@ function getUniqueId(el) { return [...accordions].indexOf(el) + 1; } +function populateMedia(accordion, id, num, collection) { + mediaCollection[id] = collection; + accordion.append(mediaCollection[id][num]); +} + export default function init(el) { const id = getUniqueId(el); const accordion = createTag('dl', { class: 'accordion', id: `accordion-${id}`, role: 'presentation' }); + const accordionMedia = createTag('div', { class: 'accordion-media', id: `accordion-media-${id}` }); const isSeo = el.classList.contains('seo'); + const isEditorial = el.classList.contains('editorial'); + decorateButtons(el); + + if (isEditorial) { + const editorialMedia = el.querySelectorAll(':scope > div:nth-child(3n)'); + [...editorialMedia].map( + (media, idx, collection) => populateMedia(accordionMedia, id, idx, collection), + ); + } + const headings = el.querySelectorAll(':scope > div:nth-child(odd)'); - const items = [...headings].map((heading, idx) => createItem(accordion, id, heading, idx + 1)); + const items = [...headings].map( + (heading, idx) => createItem(accordion, id, heading, idx + 1, isEditorial, accordionMedia), + ); + if (isSeo) { setSEO(items); } el.innerHTML = ''; el.className = `accordion-container ${el.className}`; @@ -64,7 +145,9 @@ export default function init(el) { const maxWidthClass = Array.from(el.classList).find((style) => style.startsWith('max-width-')); el.classList.add('con-block', maxWidthClass || 'max-width-10-desktop'); accordion.classList.add('foreground'); - decorateBlockAnalytics(el); - decorateLinkAnalytics(accordion, headings); el.append(accordion); + if (isEditorial) { + el.append(accordionMedia); + defalutOpen(el); + } } diff --git a/libs/blocks/action-item/action-item.css b/libs/blocks/action-item/action-item.css new file mode 100644 index 0000000000..37630f4f37 --- /dev/null +++ b/libs/blocks/action-item/action-item.css @@ -0,0 +1,157 @@ +.action-item { + display: flex; +} + +.action-item img { + display: block; + max-height: 240px; +} + +.action-item.inline { + display: inline-block; + width: auto; +} + +.action-item.float-button .main-image picture::after { + content: ''; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + background: linear-gradient(180deg, rgba(0 0 0 / 0%) 40%, rgba(0 0 0 / 100%) 100%); +} + +.action-item.rounded img, +.action-item.rounded .main-image picture:not(.floated-icon), +.action-item.rounded.float-button .main-image picture::after { + border-radius: 4px; + overflow: hidden; +} + +.action-item.rounded-l img, +.action-item.rounded-l .main-image picture:not(.floated-icon), +.action-item.rounded-l.float-button .main-image picture::after { + border-radius: 16px; + overflow: hidden; +} + +.action-item.align-bottom { + align-self: baseline; +} + +.action-item.align-center { + align-self: center; +} + +.action-item.center { + text-align: center; + margin: 0 auto; +} + +.action-item.center img { + margin: 0 auto; +} + +.action-item.small { + font-size: var(--type-body-xs-size); +} + +.action-item.small img { + min-height: var(--spacing-xl); +} + +.action-item.medium { + font-size: var(--type-body-s-size); +} + +.action-item.medium img { + min-height: var(--spacing-xxl); +} + +.action-item.large { + font-size: var(--type-body-m-size); +} + +.action-item.large img { + min-height: var(--spacing-xxxl); +} + +.action-item.image-size-small img { + max-height: var(--spacing-xl); +} + +.action-item.image-size-medium img { + max-height: var(--spacing-xxl); +} + +.action-item.image-size-large img { + max-height: var(--spacing-xxxl); +} + +.action-item.float-icon .floated-icon img { + width: 24px; + min-height: 24px; + align-self: center; +} + +.action-item .main-image { + flex-grow: 1; + position: relative; +} + +.action-item.zoom .main-image picture:not(.floated-icon) { + display: block; +} + +.action-item.float-button .main-image picture img { + min-width: 100%; +} + +.action-item.zoom .main-image picture:not(.floated-icon) img { + object-fit: cover; + will-change: transform; + transition: transform .2s ease; +} + +.action-item:not(.zoom) a:focus-visible picture:not(.floated-icon) img, +.action-item.zoom a:focus-visible picture:not(.floated-icon) { + outline-color: -webkit-focus-ring-color; + outline-style: auto; +} + +.action-item:not(.float-button) a { + width: 100%; +} + +.action-item a:not(.con-button):focus-visible { + outline: none; + text-decoration: underline; +} + +.action-item.zoom .main-image picture:not(.floated-icon) img:hover { + transform: scale(1.05); +} + +.action-item.float-icon .floated-icon { + display: flex; + position: absolute; + bottom: -9px; + right: -19px; + background-color: black; + border-radius: 50%; + height: 40px; + width: 40px; +} + +.action-item.float-button .main-image { + position: relative; +} + +.action-item.float-button .con-button { + position: absolute; + z-index: 1; + left: 50%; + bottom: 0; + transform: translate(-50%, -50%); +} diff --git a/libs/blocks/action-item/action-item.js b/libs/blocks/action-item/action-item.js new file mode 100644 index 0000000000..9406cdb663 --- /dev/null +++ b/libs/blocks/action-item/action-item.js @@ -0,0 +1,44 @@ +import { decorateButtons } from '../../utils/decorate.js'; +import { createTag } from '../../utils/utils.js'; + +function floatIcon(picture, icon) { + icon.classList.add('floated-icon'); + picture.appendChild(icon); +} + +function floatButton(picture, content) { + decorateButtons(content); + const btn = content.querySelector('.con-button'); + picture.classList.add('dark'); + if (btn) picture.appendChild(btn); +} + +function getLinkAttrs(link) { + const anchor = link.querySelector('a'); + let attrs = {}; + if (anchor) { + attrs = Object.fromEntries([...anchor.attributes].map((attr) => [attr.name, attr.value])); + } + return attrs; +} + +function getContent(el, variants, link) { + const pictures = el.querySelectorAll('picture'); + const picture = pictures[0]?.parentElement; + picture?.classList.add('main-image'); + const wrapLink = link && !variants.contains('float-button'); + const tag = wrapLink ? 'a' : 'div'; + const attrs = wrapLink ? getLinkAttrs(link) : {}; + if (variants.contains('float-icon') && pictures.length > 1) floatIcon(picture, pictures[1]); + if (variants.contains('float-button') && link) floatButton(picture, link); + if (variants.contains('static-links')) attrs.class = 'static'; + const content = createTag(tag, attrs, picture.closest('div')); + return content; +} + +export default function init(el) { + const elems = el.querySelectorAll(':scope > div'); + const link = elems.length > 1 ? elems[elems.length - 1] : null; + const content = getContent(elems[0], el.classList, link); + el.replaceChildren(content); +} diff --git a/libs/blocks/action-scroller/action-scroller.css b/libs/blocks/action-scroller/action-scroller.css new file mode 100644 index 0000000000..87d4b93359 --- /dev/null +++ b/libs/blocks/action-scroller/action-scroller.css @@ -0,0 +1,142 @@ +.action-scroller { + --action-scroller-mobile-padding: 50px; + --action-scroller-button-color: white; + --action-scroller-button-size: 32px; + --action-scroller-button-border-color: #646364; + --action-scroller-button-border-color-hover: #333; + --action-scroller-column-width: calc(var(--action-scroller-item-width) * 1px); + + display: block; + position: relative; +} + +.action-scroller .scroller { + display: grid; + grid-template-columns: repeat(var(--action-scroller-columns), 1fr); + grid-auto-columns: minmax(var(--action-scroller-column-width), 1fr); + grid-auto-flow: column; + gap: var(--spacing-m); + padding: 0 var(--action-scroller-mobile-padding); + overflow-x: auto; + -ms-overflow-style: none; + scrollbar-width: none; + scroll-behavior: smooth; + + --mask-width: 60px; + --mask-image-content: linear-gradient(to right, + transparent, + black var(--mask-width), + black calc(100% - var(--mask-width)), + transparent); + --mask-size-content: 100% 100%; + --mask-image-scrollbar: linear-gradient(black, black); + --mask-size-scrollbar: 0 100%; + + /* stylelint-disable property-no-vendor-prefix */ + -webkit-mask-image: var(--mask-image-content), var(--mask-image-scrollbar); + -webkit-mask-size: var(--mask-size-content), var(--mask-size-scrollbar); + -webkit-mask-position: 0 0, 100% 0; + -webkit-mask-repeat: no-repeat, no-repeat; + /* stylelint-enable property-no-vendor-prefix */ + + mask-image: var(--mask-image-content), var(--mask-image-scrollbar); + mask-size: var(--mask-size-content), var(--mask-size-scrollbar); + mask-position: 0 0, 100% 0; + mask-repeat: no-repeat, no-repeat; +} + + +.action-scroller .scroller::-webkit-scrollbar { + display: none; +} + +.action-scroller .scroller .action-item { + width: var(--action-scroller-column-width); +} + +.action-scroller .nav-grad { + position: absolute; + height: 100%; + width: 54px; + top: 0; +} + +.action-scroller .nav-grad.next { + right: 0; +} + +.action-scroller .nav-grad.previous { + left: 0; +} + +.action-scroller .nav-grad[hide-btn='true'] { + display: none; +} + +.action-scroller .previous-button, +.action-scroller .next-button { + display: flex; + position: absolute; + align-items: center; + justify-content: center; + cursor: pointer; + outline: none; + border-radius: 100%; + background-color: var(--action-scroller-button-color); + border: 1px solid var(--action-scroller-button-border-color); + height: var(--action-scroller-button-size); + width: var(--action-scroller-button-size); + top: calc(50% - var(--action-scroller-button-size) / 2); + z-index: 2; +} + +.action-scroller .next-button { + right: 10px; +} + +.action-scroller .previous-button { + left: 10px; +} + +.action-scroller.grid-align-end .previous-button, +.action-scroller.grid-align-end .next-button { + top: calc(55% - var(--action-scroller-button-size) / 2); +} + +.action-scroller .nav-grad .next-button:focus, +.action-scroller .nav-grad .previous-button:focus { + outline-color: -webkit-focus-ring-color; + outline-style: auto; +} + +.action-scroller .nav-button:hover, +.action-scroller .nav-button:focus, +.action-scroller .nav-button:active { + background-color: var(--action-scroller-button-color); + border-color: var(--action-scroller-button-border-color-hover); + outline: none; +} + +.action-scroller .nav-button img { + height: 16px; + width: 10px; + margin-left: 2px; +} + +.action-scroller .previous-button img { + margin-right: 3px; + transform: rotate(180deg); +} + +.action-scroller .nav-button:hover img, +.action-scroller .nav-button:focus img, +.action-scroller .nav-button:active img { + filter: brightness(0) saturate(100%); +} + +@media screen and (min-width: 1200px) { + .action-scroller { + width: var(--grid-container-width); + margin: 0 auto; + } +} diff --git a/libs/blocks/action-scroller/action-scroller.js b/libs/blocks/action-scroller/action-scroller.js new file mode 100644 index 0000000000..5ff1e69189 --- /dev/null +++ b/libs/blocks/action-scroller/action-scroller.js @@ -0,0 +1,90 @@ +import { createTag, getConfig } from '../../utils/utils.js'; + +const { miloLibs, codeRoot } = getConfig(); +const base = miloLibs || codeRoot; + +const [NAV, ALIGN] = ['navigation', 'grid-align']; +const defaultItemWidth = 106; +const defaultGridGap = 32; + +const PREVBUTTON = ``; +const NEXTBUTTON = ``; + +const getBlockProps = (el) => [...el.childNodes].reduce((attr, row) => { + if (row.children) { + const [key, value] = row.children; + if (key && value) { + attr[key.textContent.trim().toLowerCase()] = value.textContent + .trim() + .toLowerCase(); + } + } + return attr; +}, {}); + +function setBlockProps(el, columns) { + const attrs = getBlockProps(el); + const itemWidth = attrs['item width'] ?? defaultItemWidth; + const overrides = attrs.style + ? attrs.style + .split(', ') + .map((style) => style.replaceAll(' ', '-')) + .join(' ') + : ''; + const gridAlign = [...el.classList].filter((cls) => cls.toLowerCase().includes(ALIGN)) + ?? 'grid-align-start'; + el.style.setProperty('--action-scroller-columns', columns); + el.style.setProperty('--action-scroller-item-width', itemWidth); + return `scroller ${gridAlign} ${overrides}`; +} + +function handleScroll(el, btn) { + const itemWidth = el.parentElement?.style?.getPropertyValue('--action-scroller-item-width') + ?? defaultItemWidth; + const gapStyle = window + .getComputedStyle(el, null) + .getPropertyValue('column-gap'); + const gridGap = gapStyle + ? parseInt(gapStyle.replace('px', ''), 10) + : defaultGridGap; + const scrollDistance = parseInt(itemWidth, 10) + gridGap; + el.scrollLeft = btn[1].includes('next-button') + ? el.scrollLeft + scrollDistance + : el.scrollLeft - scrollDistance; +} + +function handleBtnState( + { scrollLeft, scrollWidth, clientWidth }, + [prev, next], +) { + prev.setAttribute('hide-btn', scrollLeft === 0); + next.setAttribute( + 'hide-btn', + Math.ceil(scrollLeft) === Math.ceil(scrollWidth - clientWidth), + ); +} + +function handleNavigation(el) { + const prev = createTag('div', { class: 'nav-grad previous' }, PREVBUTTON); + const next = createTag('div', { class: 'nav-grad next' }, NEXTBUTTON); + const buttons = [prev, next]; + buttons.forEach((btn) => { + const button = btn.childNodes[0]; + button.addEventListener('click', () => handleScroll(el, button.classList)); + }); + return buttons; +} + +export default function init(el) { + const hasNav = el.classList.contains(NAV); + const actions = el.parentElement.querySelectorAll('.action-item'); + const style = setBlockProps(el, actions.length); + const items = createTag('div', { class: style }, null); + const buttons = hasNav ? handleNavigation(items) : []; + items.append(...actions); + el.replaceChildren(items, ...buttons); + if (hasNav) { + items.addEventListener('scroll', () => handleBtnState(items, buttons)); + setTimeout(() => handleBtnState(items, buttons), 200); + } +} diff --git a/libs/blocks/article-feed/article-feed.js b/libs/blocks/article-feed/article-feed.js index 936ec7b853..761c49aea7 100644 --- a/libs/blocks/article-feed/article-feed.js +++ b/libs/blocks/article-feed/article-feed.js @@ -6,8 +6,11 @@ import { buildArticleCard, } from './article-helpers.js'; -import { createTag, getConfig } from '../../utils/utils.js'; +import { createTag, getConfig, createIntersectionObserver } from '../../utils/utils.js'; import { replaceKey } from '../../features/placeholders.js'; +import { updateLinkWithLangRoot } from '../../utils/helpers.js'; + +const ROOT_MARGIN = 50; const replacePlaceholder = async (key) => replaceKey(key, getConfig()); @@ -16,6 +19,7 @@ const blogIndex = { byPath: {}, offset: 0, complete: false, + config: {}, }; /** @@ -69,10 +73,16 @@ export function readBlockConfig(block) { */ export async function fetchBlogArticleIndex() { const pageSize = 500; + const { feed } = blogIndex.config; + const queryParams = `?limit=${pageSize}&offset=${blogIndex.offset}`; + const defaultPath = updateLinkWithLangRoot(`${getConfig().locale.contentRoot}/query-index.json`); + const indexPath = feed + ? `${feed}${queryParams}` + : `${defaultPath}${queryParams}`; if (blogIndex.complete) return (blogIndex); - return fetch(`${getConfig().locale.contentRoot}/query-index.json?limit=${pageSize}&offset=${blogIndex.offset}`) + return fetch(indexPath) .then((response) => response.json()) .then((json) => { const complete = (json.limit + json.offset) === json.total; @@ -178,18 +188,18 @@ function buildSelectedFilter(name) { return a; } -function clearFilter(e, block, config) { +function clearFilter(e, block) { const { target } = e; const checked = document .querySelector(`input[name='${target.textContent}']`); if (checked) { checked.checked = false; } - delete config.selectedProducts; - delete config.selectedIndustries; + delete blogIndex.config.selectedProducts; + delete blogIndex.config.selectedIndustries; // eslint-disable-next-line no-use-before-define - applyCurrentFilters(block, config); + applyCurrentFilters(block); } -function applyCurrentFilters(block, config, close) { +function applyCurrentFilters(block, close) { const filters = {}; document.querySelectorAll('.filter-options').forEach((filter) => { const type = filter.getAttribute('data-type'); @@ -199,10 +209,10 @@ function applyCurrentFilters(block, config, close) { const boxType = box.parentElement.parentElement.getAttribute('data-type'); const capBoxType = boxType.charAt(0).toUpperCase() + boxType.slice(1); subfilters.push(box.name); - if (config[`selected${capBoxType}`]) { - config[`selected${capBoxType}`] += `, ${box.name}`; + if (blogIndex.config[`selected${capBoxType}`]) { + blogIndex.config[`selected${capBoxType}`] += `, ${box.name}`; } else { - config[`selected${capBoxType}`] = box.name; + blogIndex.config[`selected${capBoxType}`] = box.name; } } }); @@ -224,7 +234,7 @@ function applyCurrentFilters(block, config, close) { filters[filter].forEach((f) => { const selectedFilter = buildSelectedFilter(f); selectedFilter.addEventListener('click', (e) => { - clearFilter(e, block, config); + clearFilter(e, block); }); selectedFilters.append(selectedFilter); }); @@ -236,11 +246,11 @@ function applyCurrentFilters(block, config, close) { if (block) { block.innerHTML = ''; // eslint-disable-next-line no-use-before-define - decorateArticleFeed(block, config); + decorateArticleFeed(block); } } -function clearFilters(e, block, config) { +function clearFilters(e, block) { const type = e.target.classList[e.target.classList.length - 1]; let target = document; if (type === 'reset') { @@ -251,9 +261,9 @@ function clearFilters(e, block, config) { const checked = dropdown.querySelectorAll('input:checked'); checked.forEach((box) => { box.checked = false; }); }); - delete config.selectedProducts; - delete config.selectedIndustries; - applyCurrentFilters(block, config); + delete blogIndex.config.selectedProducts; + delete blogIndex.config.selectedIndustries; + applyCurrentFilters(block); } function buildFilterOption(itemName, type) { @@ -307,7 +317,7 @@ async function buildFilter(type, tax, block, config) { options.classList.add('filter-options'); options.setAttribute('data-type', type); const category = tax.getCategory(tax[`${type.toUpperCase()}`]); - // console.log(category); + category.forEach((topic) => { const item = tax.get(topic, tax[`${type.toUpperCase()}`]); if (item.level === 1) { @@ -348,13 +358,13 @@ async function buildFilter(type, tax, block, config) { const isInList = (list, val) => list && list.map((t) => t.toLowerCase()).includes(val); -async function filterArticles(config, feed, limit, offset) { +async function filterArticles(feed, limit, offset) { /* filter posts by category, tag and author */ const FILTER_NAMES = ['tags', 'topics', 'selectedProducts', 'selectedIndustries', 'author', 'category', 'exclude']; - const filters = Object.keys(config).reduce((prev, key) => { + const filters = Object.keys(blogIndex.config).reduce((prev, key) => { if (FILTER_NAMES.includes(key)) { - prev[key] = config[key].split(',').map((e) => e.toLowerCase().trim()); + prev[key] = blogIndex.config[key].split(',').map((e) => e.toLowerCase().trim()); } return prev; @@ -362,7 +372,6 @@ async function filterArticles(config, feed, limit, offset) { while ((feed.data.length < limit + offset) && (!feed.complete)) { const beforeLoading = new Date(); - // eslint-disable-next-line no-await-in-loop const index = await fetchBlogArticleIndex(); const indexChunk = index.data.slice(feed.cursor); @@ -411,7 +420,6 @@ async function filterArticles(config, feed, limit, offset) { async function decorateArticleFeed( articleFeedEl, - config, offset = 0, feed = { data: [], complete: false, cursor: 0 }, limit = 12, @@ -431,13 +439,13 @@ async function decorateArticleFeed( articleCards.append(container); const pageEnd = offset + limit; - await filterArticles(config, feed, limit, offset); + await filterArticles(feed, limit, offset); const articles = feed.data; if (articles.length) { // results were found container.remove(); - } else if (config.selectedProducts || config.selectedIndustries) { + } else if (blogIndex.config.selectedProducts || blogIndex.config.selectedIndustries) { // no user filtered results were found spinner.remove(); const noMatches = document.createElement('p'); @@ -468,13 +476,13 @@ async function decorateArticleFeed( loadMore.addEventListener('click', (event) => { event.preventDefault(); loadMore.remove(); - decorateArticleFeed(articleFeedEl, config, pageEnd, feed); + decorateArticleFeed(articleFeedEl, pageEnd, feed); }); } articleFeedEl.classList.add('appear'); } -async function decorateFeedFilter(articleFeedEl, config) { +async function decorateFeedFilter(articleFeedEl) { const taxonomy = getTaxonomyModule(); const parent = document.querySelector('.article-feed'); @@ -489,8 +497,8 @@ async function decorateFeedFilter(articleFeedEl, config) { filterText.classList.add('filter-text'); filterText.textContent = await replacePlaceholder('filters'); - const productsDropdown = await buildFilter('products', taxonomy, articleFeedEl, config); - const industriesDropdown = await buildFilter('industries', taxonomy, articleFeedEl, config); + const productsDropdown = await buildFilter('products', taxonomy, articleFeedEl, blogIndex.config); + const industriesDropdown = await buildFilter('industries', taxonomy, articleFeedEl, blogIndex.config); filterWrapper.append(filterText, productsDropdown, industriesDropdown); filterContainer.append(filterWrapper); @@ -513,7 +521,7 @@ async function decorateFeedFilter(articleFeedEl, config) { clearBtn.textContent = await replacePlaceholder('clear-all'); clearBtn.addEventListener( 'click', - (e) => clearFilters(e, articleFeedEl, config), + (e) => clearFilters(e, articleFeedEl), ); selectedWrapper.append(selectedText, selectedCategories, clearBtn); @@ -521,14 +529,20 @@ async function decorateFeedFilter(articleFeedEl, config) { parent.parentElement.insertBefore(selectedContainer, parent); } -const clearBlock = (block) => { block.innerHTML = ''; }; +export default async function init(el) { + const initArticleFeed = async () => { + blogIndex.config = readBlockConfig(el); + el.innerHTML = ''; + await loadTaxonomy(); + if (blogIndex.config.filters) { + decorateFeedFilter(el); + } + decorateArticleFeed(el); + }; -export default async function init(articleFeed) { - const config = readBlockConfig(articleFeed); - clearBlock(articleFeed); - await loadTaxonomy(); - if (config.filters) { - decorateFeedFilter(articleFeed, config); - } - decorateArticleFeed(articleFeed, config); + createIntersectionObserver({ + el, + options: { rootMargin: `${ROOT_MARGIN}px` }, + callback: initArticleFeed, + }); } diff --git a/libs/blocks/article-feed/article-helpers.js b/libs/blocks/article-feed/article-helpers.js index 0c1130d6ff..e70c760236 100644 --- a/libs/blocks/article-feed/article-helpers.js +++ b/libs/blocks/article-feed/article-helpers.js @@ -1,5 +1,6 @@ import { getConfig } from '../../utils/utils.js'; import * as taxonomyLibrary from '../../scripts/taxonomy.js'; +import { updateLinkWithLangRoot } from '../../utils/helpers.js'; /* * @@ -114,7 +115,9 @@ export function getTaxonomyModule() { } export async function loadTaxonomy() { - taxonomyModule = await taxonomyLibrary.default(getConfig(), '/topics'); + const config = getConfig(); + const taxonomyRoot = config.taxonomyRoot || '/topics'; + taxonomyModule = await taxonomyLibrary.default(config, taxonomyRoot); if (taxonomyModule) { // taxonomy loaded, post loading adjustments // fix the links which have been created before the taxonomy has been loaded @@ -262,7 +265,7 @@ export function getArticleTaxonomy(article) { export function getLinkForTopic(topic, path) { const titleSubs = { 'Transformation digitale': 'Transformation numérique' }; - const catLink = [getTaxonomyModule()?.get(topic)].map((tax) => tax?.link ?? '#'); + const catLink = updateLinkWithLangRoot([getTaxonomyModule()?.get(topic)].map((tax) => tax?.link ?? '#')); if (catLink === '#') { // eslint-disable-next-line no-console diff --git a/libs/blocks/article-header/adobe-logo.svg b/libs/blocks/article-header/adobe-logo.svg index 1e8f27e5f0..18eac9f6ee 100644 --- a/libs/blocks/article-header/adobe-logo.svg +++ b/libs/blocks/article-header/adobe-logo.svg @@ -1,2 +1,2 @@ - + diff --git a/libs/blocks/article-header/article-header.js b/libs/blocks/article-header/article-header.js index c6ca3cbc7e..bba4a482a9 100644 --- a/libs/blocks/article-header/article-header.js +++ b/libs/blocks/article-header/article-header.js @@ -2,7 +2,7 @@ import { createTag, getMetadata, getConfig } from '../../utils/utils.js'; import { copyToClipboard } from '../../utils/tools.js'; import { loadTaxonomy, getLinkForTopic, getTaxonomyModule } from '../article-feed/article-helpers.js'; import { replaceKey } from '../../features/placeholders.js'; -import { fetchIcons } from '../../features/icons.js'; +import { fetchIcons } from '../../features/icons/icons.js'; import { buildFigure } from '../figure/figure.js'; async function validateAuthorUrl(url) { diff --git a/libs/blocks/aside/aside.css b/libs/blocks/aside/aside.css index 03e0a181d8..0a82ceb6d6 100644 --- a/libs/blocks/aside/aside.css +++ b/libs/blocks/aside/aside.css @@ -16,64 +16,48 @@ margin: 0; } -.aside.small { - min-height: 420px; -} - -.aside.medium { - min-height: 560px; -} - -.aside.large { - min-height: 700px; -} - .aside.split picture { display: flex; } -.aside .background { - overflow: hidden; - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; -} - -.aside .background>div { - height: 100%; -} - .aside .split-image img, -.aside .background img { +.aside .split-image video { object-fit: cover; min-height: 700px; width: 100%; height: 100%; } +.aside.notification .background img { + min-height: unset; +} + .aside .foreground.container { display: flex; position: relative; align-items: center; justify-content: center; flex-direction: column; - gap: var(--spacing-xl); + gap: var(--spacing-l); } -.aside:not(.notification):not(.inline) .foreground.container> :first-child { +.aside:not(.notification):not(.inline) .foreground> :first-child { padding: var(--spacing-xxxl) 0 0; } -.aside:not(.notification):not(.inline) .foreground.container> :last-child { +.aside:not(.notification):not(.inline) .foreground> :last-child { margin-bottom: var(--spacing-xxxl); } .aside .foreground.container .text { display: flex; flex-wrap: wrap; - align-content: center; +} + +.aside.split .foreground.container .text { + margin: 0; + max-width: var(--grid-container-width); + padding: var(--spacing-xxxl) 0 var(--spacing-l) 0; } .aside.notification .foreground.container .text { @@ -82,49 +66,76 @@ padding-bottom: 0; } -.aside.notification .foreground.container .text a { - white-space: nowrap; +.aside .foreground.container .image { + position: relative; } -.aside:not(.notification) .foreground.container .text > * { - width: 100%; +.aside.simple .foreground.container .text { + margin-bottom: 80px; } -.aside .foreground.container .text .heading-xl { - margin: 0; - margin-bottom: var(--spacing-xs); +.aside .foreground.container>div { + flex-grow: 1; + flex-basis: 0; + min-width: 0; } -.aside .foreground.container .text .body-m, -.aside .foreground.container .text .body-s { - margin-bottom: var(--spacing-s); +.aside.notification .foreground.container>div { + flex-basis: 100%; } -.aside .foreground.container .text .action-area { - margin-bottom: 0; +.aside .foreground.container .image, +.aside .foreground.container div:has(.milo-video) { + display: flex; } -.aside .foreground.container .text .action-area > a { - margin-right: var(--spacing-s); +.aside.inline .foreground.container .image, +.aside.inline .foreground.container div:has(.milo-video), +.aside.inline .foreground.container .text { + padding: 0; } -.aside .foreground.container .text .action-area > a:last-child { - margin-right: 0; +.aside.split.large .foreground.container .text { + padding: var(--spacing-l) 0 var(--spacing-xxxl) 0; } -.aside .foreground.container > div { - flex-grow: 1; - flex-basis: 0; - min-width: 0; +.aside.split .icon-stack-area li, +.aside.split .icon-stack-area li a { + display: flex; + align-items: center; + gap: 12px; + font-weight: bold; } -.aside.notification .foreground.container > div { - flex-basis: 100%; +.aside.notification .foreground.container .text a { + white-space: nowrap; } -.aside .foreground.container .image, -.aside .foreground.container div:has(.milo-video) { +.aside:not(.notification) .foreground.container .text>* { + width: 100%; +} + +.aside .foreground.container .text .heading-xl { + margin: 0 0 var(--spacing-xs); +} + +.aside .foreground.container .text .body-m, +.aside .foreground.container .text .body-s { + margin-bottom: var(--spacing-s); +} + +.aside .foreground.container .text .supplemental-text { + padding-top: var(--spacing-s); + margin-bottom: 0; +} + +.aside .foreground.container .text .action-area, +.aside.promobar .promo-text .action-area { + margin-bottom: 0; display: flex; + gap: var(--spacing-s); + flex-wrap: wrap; + align-items: center; } .aside .foreground.container .icon-area { @@ -137,23 +148,56 @@ color: transparent; } -.aside.split .split-image img { +.aside.split .split-image img, +.aside.split .split-image video { position: relative; min-height: 270px; } +.aside.split .split-image video { + display: block; +} + +.aside.split .mobile-square img, +.aside.split .mobile-square video { + aspect-ratio: var(--aspect-ratio-square); +} + +.aside.split .mobile-wide img, +.aside.split .mobile-wide video { + aspect-ratio: var(--aspect-ratio-wide); +} + +.aside.split .mobile-standard img, +.aside.split .mobile-standard video { + aspect-ratio: var(--aspect-ratio-standard); +} + +.aside.split .format img, +.aside.split .format video { + height: auto; +} + +.aside.split .icon-stack-area li img { + width: var(--icon-size-m); + height: auto; +} + +.aside.split .format picture, +.aside.split .format video { + display: flex; + height: 100%; + align-items: center; +} + .aside .foreground.container .icon-area img { max-width: 234px; - height: 56px; + height: var(--icon-size-l); width: auto; object-fit: cover; object-position: left top; } -.aside.simple .foreground.container .text { - margin-bottom: 80px; -} - .aside.simple .foreground.container .image { display: none; } @@ -173,18 +217,6 @@ z-index: 1; } -.aside.split .foreground.container .text { - padding: var(--spacing-l) 0; - max-width: var(--grid-container-width); - margin: 0 auto; -} - -.aside.inline .foreground.container .image, -.aside.inline .foreground.container div:has(.milo-video), -.aside.inline .foreground.container .text { - padding: 0; -} - .aside.split .background { position: relative; } @@ -198,6 +230,10 @@ display: none; } +.aside.split .icon-stack-area li picture { + flex-shrink: 0; +} + .aside.notification .foreground.container img { display: block; } @@ -209,7 +245,8 @@ display: flex; } -.aside.split .foreground.container .split-image img { +.aside.split .foreground.container .split-image img, +.aside.split .foreground.container .split-image video { object-fit: cover; height: 270px; } @@ -221,7 +258,8 @@ .aside.inline .foreground.container { width: 100%; min-height: 0; - margin: 0 var(--spacing-m); + margin: var(--spacing-m); + padding: 0; gap: var(--spacing-m); } @@ -255,7 +293,7 @@ font-weight: normal; } -.aside.notification .foreground.container .text .action-area > a { +.aside.notification .foreground.container .text .action-area>a { margin-right: 0; } @@ -311,6 +349,12 @@ max-width: 1000px; } +.aside.center:not(.notification) .foreground.container .text { + margin: 80px 0; + text-align: center; + padding: 0; +} + .aside.notification.center.small .foreground.container .text { text-align: left; } @@ -361,24 +405,147 @@ justify-content: center; } +.aside.notification.center .foreground.container .action-area { + justify-content: center; +} + .aside.notification.center.small .foreground.container .text, -.aside.notification.center.small .foreground.container .text > * { +.aside.notification.center.small .foreground.container .text>* { justify-content: flex-start; } +.aside.split .icon-stack-area { + display: flex; + flex-flow: row wrap; + flex-direction: column; + gap: 12px; + margin: -8px 0 var(--spacing-s); + width: 100%; + padding: 0; + list-style-type: none; +} + +.aside.center:not(.notification) .foreground.container { + padding: 0; +} + +.aside.center:not(.notification) .foreground.container .icon-area { + max-width: 100%; +} + +.aside.center:not(.notification) .foreground.container .text .action-area { + justify-content: center; +} + +.aside:not(.notification) .foreground.container .text .detail-m { + margin-bottom: var(--spacing-xs); +} + +.aside.split .image.format { + display: flex; +} + +.aside.split.bio .foreground.container .text .icon-area { + display: none; +} + +.aside.center:not(.notification) .foreground.container .text .icon-area { + height: var(--icon-size-xxl); +} + +.aside.center:not(.notification) .foreground.container .text .icon-area img { + height: var(--icon-size-xxl); + max-width: 300px; + width: auto; +} + +.aside.promobar .foreground.container > :first-child { + padding: var(--spacing-xs) 0; +} + +.aside.promobar .foreground.container { + min-height: 0; + display: flex; + flex-direction: column; + padding: 0; + margin: 0; +} + +.aside.promobar .foreground.container .icon-area { + margin-bottom: 0; + height: var(--icon-size-m); +} + +.aside.promobar .foreground.container .icon-area img { + height: var(--icon-size-m); +} + +.aside.promobar .promo-text.desktop-up, +.aside.promobar .promo-text.tablet-up { + display: none; +} + +.aside.promobar .promo-text.mobile-up { + display: flex; +} + +.aside.promobar .promo-text { + display: flex; + flex-flow: row nowrap; + gap: var(--spacing-xs); + justify-content: space-between; + width: 100%; + padding: var(--spacing-xs) 0; +} + +.aside.promobar .promo-text .content-area { + display: flex; + flex-flow: row nowrap; + gap: var(--spacing-xs); + align-items: center; +} + +.aside.promobar .promo-text .action-area { + flex-wrap: nowrap; + gap: var(--spacing-xs); +} + +@media screen and (max-width: 600px) { + .aside.no-media:not(.notification) .foreground.container { + gap: 0; + } + + .aside.split.no-media:not(.notification) .foreground.container .text { + padding: var(--spacing-xxxl) 0; + } +} + @media screen and (min-width: 600px) { + .aside.small { + min-height: 420px; + } + + .aside.medium { + min-height: 560px; + } + + .aside.large { + min-height: 700px; + } + .aside .foreground.container { align-items: center; flex-direction: row; margin: 0 auto; padding: var(--spacing-m) 0; + gap: var(--spacing-xl); } .aside:not(.notification):not(.inline) .foreground.container> :first-child { padding: 0; } - .aside:not(.notification):not(.inline) .foreground.container> :last-child { + .aside:not(.notification):not(.inline):not(.center) .foreground.container> :last-child { margin-bottom: 0; } @@ -425,7 +592,18 @@ bottom: 0; } - .aside.split-right .split-image img { + .aside.split .tablet-wide img, + .aside.split .tablet-wide video { + aspect-ratio: var(--aspect-ratio-wide); + } + + .aside.split .tablet-standard img, + .aside.split .tablet-standard video { + aspect-ratio: var(--aspect-ratio-standard); + } + + .aside.split-right .split-image img, + .aside.split-right .split-image video { left: 0; } @@ -433,7 +611,9 @@ flex-direction: column; } - .aside.split .split-image img { + .aside .split-image .modal-img-link, + .aside.split .split-image img, + .aside.split .split-image video { width: 60.5vw; max-width: 56%; position: absolute; @@ -442,12 +622,38 @@ object-position: center top; } - .aside.split.half .split-image img { + .aside .split-image .modal-img-link, + .aside.split.half .split-image img, + .aside.split.half .split-image video { width: 50vw; max-width: 1396px; object-position: left top; } + .aside.split.split-left .split-image img, + .aside.split.split-left .split-image video { + right: 0; + left: auto; + } + + .aside.split.split-right .split-image img, + .aside.split.split-right .split-image video { + left: 0; + right: auto; + } + + [dir="rtl"] .aside.split.split-right .split-image img, + [dir="rtl"] .aside.split.split-right .split-image video { + right: 0; + left: auto; + } + + [dir="rtl"] .aside.split.split-left .split-image img, + [dir="rtl"] .aside.split.split-left .split-image video { + left: 0; + right: auto; + } + .aside.split .foreground.container { flex-direction: row; justify-content: stretch; @@ -546,10 +752,46 @@ margin-top: 0; } + .aside.split.bio .foreground.container .text .icon-area { + display: block; + } + .aside.notification.small .foreground.container .text .icon-area { width: 40px; height: 40px; } + + .aside.center:not(.notification) .foreground.container .text .icon-area img { + max-width: 234px; + } + + .aside.medium.split.bio .foreground.container .text .icon-area, + .aside.large.split.bio .foreground.container .text .icon-area { + height: var(--icon-size-xxl); + margin-bottom: var(--spacing-xs); + } + + .aside.medium.split.bio .foreground.container .text .icon-area img, + .aside.large.split.bio .foreground.container .text .icon-area img { + width: var(--icon-size-xxl); + height: var(--icon-size-xxl); + border-radius: 50%; + } + + .aside.promobar .promo-text .content-area .text-area { + display: flex; + flex-flow: column nowrap; + gap: var(--spacing-xxs); + } + + .aside.promobar .promo-text.mobile-up, + .aside.promobar .promo-text.desktop-up { + display: none; + } + + .aside.promobar .promo-text.tablet-up { + display: flex; + } } @media screen and (min-width: 1200px) { @@ -573,8 +815,7 @@ } .aside .foreground.container .text .heading-xl { - margin: 0; - margin-bottom: var(--spacing-xs); + margin: 0 0 var(--spacing-xs); } .aside.inline .foreground.container .text { @@ -596,10 +837,21 @@ object-fit: cover; } - .aside.split .split-image img { + .aside.split .split-image img, + .aside.split .split-image video { max-width: 1396px; } + .aside.split .desktop-wide img, + .aside.split .desktop-wide video { + aspect-ratio: var(--aspect-ratio-wide); + } + + .aside.split .desktop-standard img, + .aside.split .desktop-standard video { + aspect-ratio: var(--aspect-ratio-standard); + } + .aside.split.half .foreground.container .text { max-width: 500px; } @@ -614,6 +866,28 @@ flex: 0 0 calc(36% - var(--spacing-s)); } + .aside.split.icon-stack .foreground.container .text, + .aside.split.bio .foreground.container .text { + padding: var(--spacing-xxl) 0; + } + + .aside.split .icon-stack-area li { + max-width: calc(50% - 6px); + min-width: calc(50% - 6px); + } + + .aside.split .icon-stack-area { + flex-direction: row; + } + + .aside.center:not(.notification) .foreground.container .text .icon-area img { + max-width: 400px; + } + + .aside.center:not(.notification) .foreground.container .text { + max-width: 50%; + } + .aside.notification .foreground.container { min-height: 0; } @@ -664,4 +938,34 @@ .aside.notification.medium .foreground.container .text+.image { margin-right: 0; } -} \ No newline at end of file + + .aside.promobar .foreground.container .icon-area { + height: var(--icon-size-xxl); + } + + .aside.promobar .foreground.container .icon-area img { + height: var(--icon-size-xxl); + } + + .aside.promobar .promo-text .content-area { + gap: var(--spacing-m); + } + + .aside.promobar .promo-text .content-area .text-area { + gap: var(--spacing-xs); + } + + .aside.promobar .promo-text .action-area { + gap: var(--spacing-s); + } + + .aside.promobar .promo-text.mobile-up, + .aside.promobar .promo-text.tablet-up { + display: none; + } + + .aside.promobar .promo-text.desktop-up { + display: flex; + gap: var(--spacing-m); + } +} diff --git a/libs/blocks/aside/aside.js b/libs/blocks/aside/aside.js index b985c53b64..ad94730d74 100644 --- a/libs/blocks/aside/aside.js +++ b/libs/blocks/aside/aside.js @@ -14,7 +14,7 @@ * Aside - v5.1 */ -import { decorateBlockBg, decorateBlockText } from '../../utils/decorate.js'; +import { decorateBlockText, decorateIconStack, applyHoverPlay, decorateBlockBg } from '../../utils/decorate.js'; import { createTag } from '../../utils/utils.js'; // standard/default aside uses same text sizes as the split @@ -32,6 +32,7 @@ const blockConfig = { [large]: ['l', 'm'], }, }; +const FORMAT_REGEX = /^format:/i; function getBlockData(el) { const variant = variants.find((variantClass) => el.classList.contains(variantClass)); @@ -43,39 +44,143 @@ function getBlockData(el) { function decorateStaticLinks(el) { if (!el.classList.contains('notification')) return; const textLinks = el.querySelectorAll('a:not([class])'); - textLinks.forEach((link) => { link.classList.add('static') }); + textLinks.forEach((link) => { link.classList.add('static'); }); +} + +function decorateMedia(el) { + if (!(el.classList.contains('medium') || el.classList.contains('large'))) return; + const allMedia = el.querySelectorAll('div > p video, div > p picture'); + [...allMedia].some((media) => { + const parentP = media.closest('p'); + const siblingP = parentP?.nextElementSibling; + if (!siblingP || siblingP.nodeName !== 'P') return false; + const siblingText = siblingP.textContent; + const hasFormats = FORMAT_REGEX.test(siblingText); + if (!hasFormats) return false; + const formats = siblingText.split(': ')[1]?.split(/\s+/); + if (formats) { + const formatClasses = []; + formatClasses.push('format'); + if (formats.length === 3) formatClasses.push(`desktop-${formats[2]}`); + if (formats.length >= 2) formatClasses.push(`tablet-${formats[1]}`); + formatClasses.push(`mobile-${formats[0]}`); + media.closest('div').classList.add(...formatClasses); + } + siblingP.remove(); + media.closest('div').insertBefore(media, parentP); + parentP.remove(); + return true; + }); +} + +function decorateVideo(container) { + const link = container.querySelector('a[href*=".mp4"]'); + if (!link) return; + const isNotLooped = link.hash?.includes('autoplay1'); + const attrs = `playsinline autoplay ${isNotLooped ? '' : 'loop'} muted`; + container.innerHTML = ``; + container.classList.add('has-video'); +} + +function addPromobar(sourceEl, parent) { + const newPromo = sourceEl.cloneNode(true); + parent.appendChild(newPromo); +} + +function checkViewportPromobar(foreground) { + const { children, childElementCount: childCount } = foreground; + if (childCount < 2) addPromobar(children[childCount - 1], foreground); + if (childCount < 3) addPromobar(children[childCount - 1], foreground); +} + +function combineTextBocks(textBlocks, iconArea, viewPort) { + const textStyle = viewPort === 'desktop-up' ? ['m', 'l'] : ['s', 's']; + const contentArea = createTag('p', { class: 'content-area' }); + const textArea = createTag('p', { class: 'text-area' }); + textBlocks[0].parentElement.prepend(contentArea); + textBlocks.forEach((textBlock) => { + textArea.appendChild(textBlock); + if (textBlock.nodeName === 'P') { + textBlock.classList.add(`body-${textStyle[1]}`); + } else { + textBlock.classList.add(`heading-${textStyle[0]}`); + } + }); + if (iconArea) { + iconArea.classList.add('icon-area'); + contentArea.appendChild(iconArea); + } + contentArea.appendChild(textArea); +} + +function decoratePromobar(el) { + const viewports = ['mobile-up', 'tablet-up', 'desktop-up']; + const foreground = el.querySelector('.foreground'); + if (foreground.childElementCount !== 3) checkViewportPromobar(foreground); + [...foreground.children].forEach((child, index) => { + child.className = viewports[index]; + child.classList.add('promo-text'); + const textBlocks = [...child.children]; + const iconArea = child.querySelector('picture')?.closest('p'); + const actionArea = child.querySelectorAll('em a, strong a, p > a strong'); + if (iconArea) textBlocks.shift(); + if (actionArea.length) textBlocks.pop(); + if (textBlocks.length) combineTextBocks(textBlocks, iconArea, viewports[index]); + }); + return foreground; } function decorateLayout(el) { const elems = el.querySelectorAll(':scope > div'); - if (elems.length > 1) decorateBlockBg(el, elems[0]); + if (elems.length > 1) { + decorateBlockBg(el, elems[0]); + [...elems[0].children].forEach((child) => decorateVideo(child)); + } const foreground = elems[elems.length - 1]; foreground.classList.add('foreground', 'container'); + if (el.classList.contains('promobar')) return decoratePromobar(el); + if (el.classList.contains('split')) decorateMedia(el); const text = foreground.querySelector('h1, h2, h3, h4, h5, h6, p')?.closest('div'); text?.classList.add('text'); const media = foreground.querySelector(':scope > div:not([class])'); - if (!el.classList.contains('notification')) media?.classList.add('image'); - const picture = text?.querySelector('picture'); + if (media && !el.classList.contains('notification')) { + media.classList.add('image'); + const video = media.querySelector('video'); + if (video) applyHoverPlay(video); + } + const picture = text?.querySelector('p picture'); const iconArea = picture ? (picture.closest('p') || createTag('p', null, picture)) : null; iconArea?.classList.add('icon-area'); const foregroundImage = foreground.querySelector(':scope > div:not(.text) img')?.closest('div'); - const bgImage = el.querySelector(':scope > div:not(.text) img')?.closest('div'); + const bgImage = el.querySelector(':scope > div:not(.text):not(.foreground) img')?.closest('div'); + const foregroundMedia = foreground.querySelector(':scope > div:not(.text) video')?.closest('div'); + const bgMedia = el.querySelector(':scope > div:not(.text):not(.foreground) video')?.closest('div'); const image = foregroundImage ?? bgImage; - if (image && !image.classList.contains('text')) { - const isSplit = el.classList.contains('split'); - image.classList.add(`${isSplit ? 'split-' : ''}image`); + const asideMedia = foregroundMedia ?? bgMedia ?? image; + const isSplit = el.classList.contains('split'); + const hasMedia = foregroundImage ?? foregroundMedia ?? (isSplit && asideMedia); + if (!hasMedia) el.classList.add('no-media'); + if (asideMedia && !asideMedia.classList.contains('text')) { + asideMedia.classList.add(`${isSplit ? 'split-' : ''}image`); if (isSplit) { - const position = Array.from(image.parentNode.children).indexOf(image); + const position = [...asideMedia.parentNode.children].indexOf(asideMedia); el.classList.add(`split${!position ? '-right' : '-left'}`); - foreground.parentElement.appendChild(image); + foreground.parentElement.appendChild(asideMedia); } } else if (!iconArea) { foreground?.classList.add('no-image'); } + if (el.classList.contains('split') + && (el.classList.contains('medium') || el.classList.contains('large'))) { + decorateIconStack(el); + } return foreground; } export default function init(el) { + el.classList.add('con-block'); const blockData = getBlockData(el); const blockText = decorateLayout(el); decorateBlockText(blockText, blockData); diff --git a/libs/blocks/bulk-publish/bulk-publish-utils.js b/libs/blocks/bulk-publish/bulk-publish-utils.js index d39bcb6760..8e8e40c162 100644 --- a/libs/blocks/bulk-publish/bulk-publish-utils.js +++ b/libs/blocks/bulk-publish/bulk-publish-utils.js @@ -1,9 +1,9 @@ -import { getLocalStorage, setLocalStorage, fetchWithTimeout } from '../utils/utils.js'; -import { loadScript } from '../../utils/utils.js'; import { getImsToken } from '../../../tools/utils/utils.js'; +import { loadScript } from '../../utils/utils.js'; +import { fetchWithTimeout, getLocalStorage, setLocalStorage } from '../utils/utils.js'; export const ADMIN_BASE_URL = 'https://admin.hlx.page'; -const THROTTLING_DELAY_MS = 100; +const THROTTLING_DELAY_MS = 300; export const BULK_CONFIG_FILE_PATH = '/tools/bulk-publish/config.json'; export const BULK_REPORT_FILE_PATH = '/tools/bulk-publish/report'; const BULK_AUTHORIZED_USERS = 'bulkAuthorizedUsers'; @@ -14,6 +14,7 @@ const BULK_STORED_URLS = 'bulkStoredUrls'; const BULK_STORED_RESULTS = 'bulkStoredResults'; const BULK_STORED_OPERATION = 'bulkStoredOperation'; const UNSUPPORTED_SITE_STATUS = 'unsupported domain'; +const UNSUPPORTED_ACTION_STATUS = 'unsupported action'; const DUPLICATE_STATUS = 'duplicate'; export const ANONYMOUS = 'anonymous'; @@ -98,41 +99,57 @@ const siteIsSupported = async (url) => { const { origin } = new URL(url); const sites = await getSupportedSites(); return sites.includes(origin); + /* c8 ignore next 3 */ } catch (e) { return false; } }; -export const getUrls = (element) => { - return element.current?.value.split('\n') - .filter((url) => url.length > 0) - .map((e) => e.trim()); -}; +export const getUrls = (element) => element.current?.value.split('\n') + .filter((url) => url.length > 0) + .map((e) => e.trim()); export const getActionName = (action, useGerund) => { let name; switch (action) { case null: case 'preview': - name = (!useGerund) ? 'Preview' : 'Previewing'; + name = (useGerund) ? 'Previewing' : 'Preview'; break; case 'publish': - name = (!useGerund) ? 'Publish' : 'Publishing'; + name = (useGerund) ? 'Publishing' : 'Publish'; + break; + case 'unpublish': + name = (useGerund) ? 'Unpublishing' : 'Unpublish'; + break; + case 'unpublish&delete': + name = (useGerund) ? 'Deleting' : 'Delete'; + break; + case 'index': + name = (useGerund) ? 'Indexing' : 'Index'; break; default: - name = (!useGerund) ? 'Preview & publish' : 'Previewing & publishing'; + name = (useGerund) ? 'Previewing & publishing' : 'Preview & publish'; } return name; }; const executeAction = async (action, url) => { - const operation = (action === 'preview') ? 'preview' : 'live'; + const allowedActions = ['preview', 'publish', 'delete', 'unpublish', 'index']; + if (!allowedActions.includes(action)) return UNSUPPORTED_ACTION_STATUS; + let operation = action; + if (action === 'delete') { + operation = 'preview'; + } else if (action === 'publish' || action === 'unpublish') { + operation = 'live'; + } const siteAllowed = await siteIsSupported(url); if (!siteAllowed) return UNSUPPORTED_SITE_STATUS; const { hostname, pathname } = new URL(url); const [branch, repo, owner] = hostname.split('.')[0].split('--'); const adminURL = `${ADMIN_BASE_URL}/${operation}/${owner}/${repo}/${branch}${pathname}`; - const resp = await fetchWithTimeout(adminURL, { method: 'POST' }); + const method = (action === 'delete' || action === 'unpublish') ? 'DELETE' : 'POST'; + const resp = await fetchWithTimeout(adminURL, { method }); return resp.status; }; @@ -170,15 +187,14 @@ export const executeActions = async (resume, setResult) => { if (isProcessed(url, results)) { status[action] = DUPLICATE_STATUS; } else { - // eslint-disable-next-line no-await-in-loop status[action] = await executeAction(action, url); - // eslint-disable-next-line no-await-in-loop await delay(THROTTLING_DELAY_MS); } } results.push({ url, status, + timestamp: new Date(), }); setResult([...results]); setLocalStorage(BULK_STORED_URL_IDX, i); @@ -193,14 +209,27 @@ export const executeActions = async (resume, setResult) => { export const getCompletion = (results) => { let previewTotal = 0; let publishTotal = 0; + let deleteTotal = 0; + let unpublishTotal = 0; let previewSuccess = 0; let publishSuccess = 0; + let deleteSuccess = 0; + let unpublishSuccess = 0; + let indexTotal = 0; + let indexSuccess = 0; + results.forEach((result) => { const { status } = result; if (status.preview) previewTotal += 1; if (status.publish) publishTotal += 1; + if (status.delete) deleteTotal += 1; + if (status.unpublish) unpublishTotal += 1; if (status.preview === 200) previewSuccess += 1; if (status.publish === 200) publishSuccess += 1; + if (status.delete === 204) deleteSuccess += 1; + if (status.unpublish === 204) unpublishSuccess += 1; + if (status.index) indexTotal += 1; + if (status.index === 200 || status.index === 202) indexSuccess += 1; }); return { preview: { @@ -211,6 +240,18 @@ export const getCompletion = (results) => { total: publishTotal, success: publishSuccess, }, + delete: { + total: deleteTotal, + success: deleteSuccess, + }, + unpublish: { + total: unpublishTotal, + success: unpublishSuccess, + }, + index: { + total: indexTotal, + success: indexSuccess, + }, }; }; @@ -221,6 +262,7 @@ export const getReport = async (results, action) => { try { const urlObj = new URL(result.url); origin = urlObj.origin; + /* c8 ignore next 3 */ } catch (e) { origin = result.url; } @@ -230,15 +272,18 @@ export const getReport = async (results, action) => { success: 0, }; } - if (action === 'preview&publish') { + if (action === 'preview&publish' || action === 'unpublish&delete') { origins[origin].total += 2; } else { origins[origin].total += 1; } - if (result.status.preview === 200) { + if (result.status.preview === 200 || result.status.delete === 204) { + origins[origin].success += 1; + } + if (result.status.publish === 200 || result.status.unpublish === 204) { origins[origin].success += 1; } - if (result.status.publish === 200) { + if (result.status.index === 200) { origins[origin].success += 1; } }); diff --git a/libs/blocks/bulk-publish/bulk-publish.css b/libs/blocks/bulk-publish/bulk-publish.css index e7245c8281..cde7fffb76 100644 --- a/libs/blocks/bulk-publish/bulk-publish.css +++ b/libs/blocks/bulk-publish/bulk-publish.css @@ -252,12 +252,14 @@ button:hover { } .bulk-status-preview, -.bulk-status-publish { +.bulk-status-publish, +.bulk-status-index { width: 13%; } .bulk-status-publish span, -.bulk-status-preview span { +.bulk-status-preview span, +.bulk-status-index span { display: block; } @@ -278,7 +280,8 @@ button:hover { } .bulk-status-publish.status-error .page-status, -.bulk-status-preview.status-error .page-status { +.bulk-status-preview.status-error .page-status, +.bulk-status-index.status-error .page-status { color: var(--error-color); font-weight: 700; line-height: normal; diff --git a/libs/blocks/bulk-publish/bulk-publish.js b/libs/blocks/bulk-publish/bulk-publish.js index 8fe5a6855c..1a3792767e 100644 --- a/libs/blocks/bulk-publish/bulk-publish.js +++ b/libs/blocks/bulk-publish/bulk-publish.js @@ -1,24 +1,24 @@ -import { html, render, useState, useRef } from '../../deps/htm-preact.js'; +import { html, render, useRef, useState } from '../../deps/htm-preact.js'; import { getMetadata } from '../../utils/utils.js'; import { ANONYMOUS, - signOut, - getStoredUrlInput, - getActionName, - getUrls, - userIsAuthorized, executeActions, + getActionName, getCompletion, + getStoredOperation, + getStoredUrlInput, + getUrls, + getUser, sendReport, signIn, - getUser, - getStoredOperation, - storeUrls, + signOut, storeOperation, + storeUrls, + userIsAuthorized, } from './bulk-publish-utils.js'; -// eslint-disable-next-line max-len const URLS_ENTRY_LIMIT = 1000; +let urlLimit; function User({ user }) { return html` @@ -36,7 +36,7 @@ function User({ user }) { function UrlInput({ urlsElt }) { return html` - Maximum number of URLS processed: ${URLS_ENTRY_LIMIT} + Maximum number of URLS processed: ${urlLimit} `; } @@ -47,6 +47,9 @@ function SelectBtn({ actionElt, onSelectChange, storedOperationName }) { + + + `; } @@ -59,8 +62,7 @@ function SubmitBtn({ submit }) { `; } -function prettyDate() { - const date = new Date(); +function prettyDate(date = new Date()) { const localeDate = date.toLocaleString(); const splitDate = localeDate.split(', '); return html` @@ -70,23 +72,47 @@ function prettyDate() { } function bulkPublishStatus(row) { - const status = row.status.publish !== 200 - ? `Error - Status: ${row.status.publish}` - : ''; - return row.status.publish !== 200 && row.status.publish !== undefined && html` + const success = row.status.publish === 200; + const status = success ? '' : `Error - Status: ${row.status.publish}`; + return !success && row.status.publish !== undefined && html` ${status} `; } function bulkPreviewStatus(row) { - const status = row.status.preview !== 200 - ? `Error - Status: ${row.status.preview}` - : ''; + const success = row.status.preview === 200; + const status = success ? '' : `Error - Status: ${row.status.preview}`; return row.status.preview !== 200 && row.status.preview !== undefined && html` ${status} `; } +function bulkDeleteStatus(row) { + const success = row.status.delete === 204; + const status = row.status.delete === 403 + ? 'Failed to Delete (Ensure the resource is deleted in SharePoint)' + : `Error - Status: ${row.status.delete}`; + return !success && row.status.delete !== undefined && html` + ${status} + `; +} + +function bulkUnpublishStatus(row) { + const success = row.status.unpublish === 204; + const status = row.status.unpublish === 403 + ? 'Failed to Unpublish (Ensure the resource is deleted in SharePoint)' + : `Error - Status: ${row.status.unpublish}`; + return !success && row.status.unpublish !== undefined && html` + ${status} + `; +} + +function bulkIndexStatus(row) { + const success = row.status.index === 200 || row.status.index === 202; + const status = success ? '' : `Error - Status: ${row.status.index}`; + return !success && row.status.index !== undefined && html`${status}`; +} + function StatusTitle({ bulkTriggered, submittedAction, urlNumber }) { const name = getActionName(submittedAction, true); const URLS = (urlNumber > 1) ? 'URLS' : 'URL'; @@ -100,36 +126,56 @@ function StatusTitle({ bulkTriggered, submittedAction, urlNumber }) { } function StatusRow({ row }) { - const timeStamp = prettyDate(); + const timeStamp = prettyDate(row?.timestamp); const errorStyle = 'status-error'; - const previewStatusError = row.status.preview === 200 ? '' : errorStyle; - const publishStatusError = row.status.publish === 200 ? '' : errorStyle; + const del = !!row.status.delete || !!row.status.unpublish; + const expectedStatus = del ? 204 : 200; + const previewStatus = del ? row.status.delete : row.status.preview; + const publishStatus = del ? row.status.unpublish : row.status.publish; + const preStatus = del ? bulkDeleteStatus : bulkPreviewStatus; + const pubStatus = del ? bulkUnpublishStatus : bulkPublishStatus; + + const previewStatusError = previewStatus === expectedStatus ? '' : errorStyle; + const publishStatusError = publishStatus === expectedStatus ? '' : errorStyle; + const indexSuccess = row.status.index === 200 || row.status.index === 202; + const indexStatusError = indexSuccess ? '' : errorStyle; return html` ${row.url} - ${row.status.preview === 200 && timeStamp} ${bulkPreviewStatus(row)} + ${previewStatus === expectedStatus && timeStamp} ${preStatus(row)} - ${row.status.publish === 200 && timeStamp} ${bulkPublishStatus(row)} + ${publishStatus === expectedStatus && timeStamp} ${pubStatus(row)} + + + ${indexSuccess && timeStamp} ${bulkIndexStatus(row)} `; } function StatusContent({ resultsElt, result, submittedAction }) { - const name = getActionName(submittedAction); + const name = getActionName(submittedAction).toLowerCase(); const displayClass = 'did-bulk'; - const bulkPreviewed = name.toLowerCase().includes('preview') ? displayClass : ''; - const bulkPublished = name.toLowerCase().includes('publish') ? displayClass : ''; + const bulkPreviewed = name.includes('preview') || name === 'delete' ? displayClass : ''; + const bulkPublished = name.includes('publish') || name === 'delete' ? displayClass : ''; + const bulkIndexed = name.includes('index') ? displayClass : ''; + + const del = name === 'delete' || name === 'unpublish'; + const headings = { + pre: del ? 'Deleted' : 'Previewed', + pub: del ? 'UnPublished' : 'Published', + }; return html` ${result && html` - - + + + ${result.reverse().map((row) => html`<${StatusRow} row=${row} />`)} `} @@ -154,12 +200,33 @@ function StatusCompletion({ completion }) {
  • Successful: ${completion.publish.success} / ${completion.publish.total}
  • `} + ${completion.delete.total > 0 && html` +
      +
    • Delete Job Complete: ${timeStamp}
    • +
    • Successful: ${completion.delete.success} / ${completion.delete.total}
    • +
    + `} + ${completion.unpublish.total > 0 && html` +
      +
    • Unpublish Job Complete: ${timeStamp}
    • +
    • Successful: ${completion.unpublish.success} / ${completion.unpublish.total}
    • +
    + `} + ${completion.index.total > 0 && html` +
      +
    • Index Job Complete: ${timeStamp}
    • +
    • Successful: ${completion.index.success} / ${completion.index.total}
    • +
    + `} `; } -function Status({ valid, urlNumber, bulkTriggered, submittedAction, result, resultsElt, completion }) { +// eslint-disable-next-line max-len +function Status({ + valid, urlNumber, bulkTriggered, submittedAction, result, resultsElt, completion, +}) { return valid && html`
    @@ -187,8 +254,8 @@ function ErrorMessage({ valid, authorized, urlNumber }) { message = 'You are not authorized to perform bulk operations'; } else if (urlNumber < 1) { message = 'There are no URLS to process. Add URLS to the text area to start bulk publishing.'; - } else if (urlNumber > URLS_ENTRY_LIMIT) { - message = `There are too many URLS. You entered ${urlNumber} URLS. The max allowed number is ${URLS_ENTRY_LIMIT}`; + } else if (urlNumber > urlLimit) { + message = `There are too many URLS. You entered ${urlNumber} URLS. The max allowed number is ${urlLimit}`; } return !!message && html`
    @@ -247,7 +314,7 @@ function BulkPublish({ user, storedOperation }) { const { urls } = getStoredOperation(); const urlNumberValue = urls.length; setUrlNumber(urlNumberValue); - if (urlNumberValue < 1 || urlNumberValue > URLS_ENTRY_LIMIT) { + if (urlNumberValue < 1 || urlNumberValue > urlLimit) { setValid(false); return; } @@ -331,6 +398,13 @@ function BulkPublish({ user, storedOperation }) { `; } +function initUrlLimit() { + if (urlLimit) return; + const { searchParams } = new URL(window.location.href); + const limit = searchParams.get('limit'); + urlLimit = limit ? Number(limit) || URLS_ENTRY_LIMIT : URLS_ENTRY_LIMIT; +} + export default async function init(el) { const imsSignIn = getMetadata('ims-sign-in'); if (imsSignIn === 'on' || imsSignIn === 'true') { @@ -338,6 +412,7 @@ export default async function init(el) { if (!signedIn) return; } + initUrlLimit(); const user = await getUser(); const storedOperation = getStoredOperation(); render(html`<${BulkPublish} user="${user}" storedOperation="${storedOperation}" />`, el); diff --git a/libs/blocks/caas-config/caas-config.css b/libs/blocks/caas-config/caas-config.css index 10203a9581..d52fcd3d42 100644 --- a/libs/blocks/caas-config/caas-config.css +++ b/libs/blocks/caas-config/caas-config.css @@ -36,3 +36,67 @@ dd.content .sort-options > .field { top: 12px; } +.multifield .multifield { + padding: 0 0 15px; + margin: 0 -15px 9px 0; + border-bottom: none; +} + +.multifield .multifield > .multifield-set { + border: solid 1px #999; + padding: 3px 6px; + border-radius: 4px; + background: #757575; +} + +.multifield.filtersCustom > .multifield-set:nth-child(2) { + margin-top: -30px; +} + +.multifield .multifield > .multifield-set:not(:nth-child(2)) { + border-top: none; +} + +.config-panel dd.content .multifield .multifield .field { + margin-bottom: 0; +} + +.multifield .multifield h3 { + font-size: 16px; + font-weight: 300; + margin: 0; +} + +.multifield .multifield > .multifield-set > .multifield-fields { + width: 190px; +} + +.multifield .multifield > .multifield-header > .multifield-add { + font-size: 16px; +} + +.config-panel button { + cursor: pointer; +} + +.accordion-item:hover dt.title span, +.accordion-item:hover dt.title button { + color: #fff; + cursor: pointer; +} + +/* sticky panel */ +.config-panel { + border-top: 8px solid black; + position: sticky; + top: 60px; + align-self: start; + overflow: scroll; + max-height: 95vh; + max-width: 360px; + margin-bottom: 20px; +} + +.config-panel::-webkit-scrollbar { + display: none; /* Safari and Chrome */ +} diff --git a/libs/blocks/caas-config/caas-config.js b/libs/blocks/caas-config/caas-config.js index 48a45fab2f..13a8b595c8 100644 --- a/libs/blocks/caas-config/caas-config.js +++ b/libs/blocks/caas-config/caas-config.js @@ -1,3 +1,4 @@ +/* eslint-disable react-hooks/exhaustive-deps */ /* global ClipboardItem */ import { createContext, @@ -42,7 +43,7 @@ const getHashConfig = () => { const encodedConfig = hash.startsWith('#') ? hash.substring(1) : hash; return parseEncodedConfig(encodedConfig); -} +}; const caasFilesLoaded = loadCaasFiles(); @@ -64,7 +65,7 @@ const defaultOptions = { 'double-wide': 'Double Width Card', product: 'Product Card', 'text-card': 'Text Card', - 'custom-card': 'Custom Card' + 'custom-card': 'Custom Card', }, collectionBtnStyle: { primary: 'Primary', @@ -80,8 +81,8 @@ const defaultOptions = { carousel: 'Carousel', }, ctaActions: { - '_blank': 'New Tab', - '_self': 'Same Tab', + _blank: 'New Tab', + _self: 'Same Tab', }, draftDb: { false: 'Live', @@ -97,8 +98,6 @@ const defaultOptions = { '14257-chimera.adobeioruntime.net/api/v1/web/chimera-0.0.1/collection', '14257-chimera-stage.adobeioruntime.net/api/v1/web/chimera-0.0.1/collection': '14257-chimera-stage.adobeioruntime.net/api/v1/web/chimera-0.0.1/collection', - '14257-chimera-dev.adobeioruntime.net/api/v1/web/chimera-0.0.1/collection': - '14257-chimera-dev.adobeioruntime.net/api/v1/web/chimera-0.0.1/collection', }, filterBuildPanel: { automatic: 'Automatic', @@ -191,13 +190,16 @@ const defaultOptions = { dark: 'Dark Theme', darkest: 'Darkest Theme', }, + detailsTextOption: { + default: 'Default', + modifiedDate: 'Modified Date', + }, }; -const getTagList = (root) => - Object.entries(root).reduce((options, [, tag]) => { - options[tag.tagID] = tag.title; - return options; - }, {}); +const getTagList = (root) => Object.entries(root).reduce((options, [, tag]) => { + options[tag.tagID] = tag.title; + return options; +}, {}); const getTagTree = (root) => { const options = Object.entries(root).reduce((opts, [, tag]) => { @@ -238,7 +240,9 @@ const Select = ({ label, options, prop, sort = false }) => { `; }; -const Input = ({ label, type = 'text', prop, defaultValue = '', title}) => { +const Input = ({ + label, type = 'text', prop, defaultValue = '', title, placeholder, +}) => { const context = useContext(ConfiguratorContext); const onInputChange = (val, e) => { @@ -261,6 +265,7 @@ const Input = ({ label, type = 'text', prop, defaultValue = '', title}) => { title=${title} onChange=${onInputChange} value=${context.state[prop]} + placeholder=${placeholder} /> `; }; @@ -315,8 +320,8 @@ const BasicsPanel = ({ tagsData }) => { <${Select} options=${languageTags} prop="language" label="Language" sort />`; return html` - <${Input} label="Collection Name (only displayed in author link)" prop="collectionName" type="text" /> - <${Input} label="Collection Title" prop="collectionTitle" type="text" title="Enter a title, {placeholder}, or leave empty "/> + <${Input} label="Collection Name" placeholder="Only used in the author link" prop="collectionName" type="text" /> + <${Input} label="Collection Title" prop="collectionTitle" type="text" title="Enter a title, {placeholder}, or leave empty "/> <${Select} options=${defaultOptions.titleHeadingLevel} prop="titleHeadingLevel" label="Collection Title Level" /> <${DropdownSelect} options=${defaultOptions.source} prop="source" label="Source" /> <${Input} label="Results Per Page" prop="resultsPerPage" type="number" /> @@ -339,6 +344,7 @@ const UiPanel = () => html` <${Select} label="Layout Type" prop="layoutType" options=${defaultOptions.layoutType} /> <${Select} label="Grid Gap (Gutter)" prop="gutter" options=${defaultOptions.gutter} /> <${Select} label="Theme" prop="theme" options=${defaultOptions.theme} /> + <${Select} label="Details Text" prop="detailsTextOption" options=${defaultOptions.detailsTextOption} /> <${Select} label="Collection Button Style" prop="collectionBtnStyle" @@ -358,11 +364,11 @@ const UiPanel = () => html` `; const TagsPanel = ({ tagsData }) => { + const context = useContext(ConfiguratorContext); if (!tagsData) return ''; const contentTypeTags = getTagList(tagsData['content-type'].tags); const allTags = getTagTree(tagsData); - const context = useContext(ConfiguratorContext); const onLogicTagChange = (prop) => (values) => { context.dispatch({ @@ -406,9 +412,11 @@ const TagsPanel = ({ tagsData }) => { `; }; -const CardsPanel = () => { +const CardsPanel = ({ tagsData }) => { const context = useContext(ConfiguratorContext); + const allTags = getTagTree(tagsData); + const onChange = (prop) => (values) => { context.dispatch({ type: 'SELECT_CHANGE', @@ -423,7 +431,7 @@ const CardsPanel = () => { className="featuredCards" values=${context.state.featuredCards} title="Featured Cards" - subTitle="Enter the UUID for cards to be featured" + subTitle="UUIDs for featured cards" > <${FormInput} name="contentId" onValidate=${isValidUuid} /> @@ -432,10 +440,21 @@ const CardsPanel = () => { className="excludedCards" values=${context.state.excludedCards} title="Excluded Cards" - subTitle="Enter the UUID for cards to be excluded" + subTitle="UUIDs for excluded cards" > <${FormInput} name="contentId" onValidate=${isValidUuid} /> + <${MultiField} + onChange=${onChange('hideCtaIds')} + className="hideCtaIds" + values=${context.state.hideCtaIds} + title="Hidden CTAs" + subTitle="UUIDs for cards no CTAs" + > + <${FormInput} name="contentId" onValidate${isValidUuid} /> + +
    + <${DropdownSelect} options=${allTags} prop="hideCtaTags" label="Tags that should hide CTAS" /> `; }; @@ -443,7 +462,7 @@ const BookmarksPanel = () => html` <${Input} label="Show bookmark icon on cards" prop="showBookmarksOnCards" type="checkbox" /> <${Input} label="Only show bookmarked cards" prop="onlyShowBookmarkedCards" type="checkbox" /> <${Input} - label="Show the Bookmarks Filter In The Card Collection" + label="Show Bookmarks Filter" prop="showBookmarksFilter" type="checkbox" /> @@ -503,23 +522,62 @@ const FilterPanel = ({ tagsData }) => { <${Select} label="Filter Location" prop="filterLocation" options=${defaultOptions.filterLocation} /> <${Select} label="Filter logic within each tag panel" prop="filterLogic" options=${defaultOptions.filterLogic} /> <${Select} label="Event Filter" prop="filterEvent" options=${defaultOptions.filterEvent} /> + <${Select} label="Automatic or Custom Panel" prop="filterBuildPanel" options=${defaultOptions.filterBuildPanel} /> + `; + + const FilterBuildPanel = html` + <${FilterOptions}> <${MultiField} onChange=${onChange('filters')} className="filters" values=${context.state.filters} - title="Filter Tags" + title="Automatic Filters" subTitle="" > - <${TagSelect} id="filterTag" options=${allTags} label="Main Tag" singleSelect /> + <${TagSelect} id="filterTag" options=${allTags} label="Main Tag" singleSelect /> <${FormInput} label="Opened on load" name="openedOnLoad" type="checkbox" /> <${FormInput} label="Icon Path" name="icon" /> <${TagSelect} id="excludeTags" options=${allTags} label="Tags to Exclude" /> `; + const FilterCustomBuildPanel = html` + <${FilterOptions}> + <${MultiField} + onChange=${onChange('filtersCustom')} + className="filtersCustom" + values=${context.state.filtersCustom} + title="Custom Filters" + addBtnTitle="New Group" + subTitle="" + > + <${FormInput} label="Group Name" name="group" /> + + + <${MultiField} + className="filtersCustomItems" + parentValues=${context.state.filtersCustom} + title="Filters" + subTitle="" + addBtnLabel="+" + addBtnTitle="New Filter" + name="filtersCustomItems" + > + <${FormInput} label="Filter label" name="filtersCustomLabel"/> + <${TagSelect} id="customFilterTag" options=${allTags} label="Filter Tag" singleSelect /> + + + + <${FormInput} label="Opened on load" name="openedOnLoad" type="checkbox" /> + + `; + return html` <${Input} label="Show Filters" prop="showFilters" type="checkbox" /> - ${state.showFilters && FilterOptions} + ${state.showFilters + && (state.filterBuildPanel === 'custom' + ? FilterCustomBuildPanel + : FilterBuildPanel)} `; }; @@ -560,15 +618,13 @@ const PaginationPanel = () => { `; }; -const TargetPanel = () => - html` +const TargetPanel = () => html` <${Input} label="Target Enabled" prop="targetEnabled" type="checkbox" /> <${Input} label="Last Viewed Session" prop="lastViewedSession" type="checkbox" /> <${Input} label="Target Activity" prop="targetActivity" type="text" /> `; -const AnalyticsPanel = () => - html`<${Input} label="Track Impression" prop="analyticsTrackImpression" type="checkbox" /> +const AnalyticsPanel = () => html`<${Input} label="Track Impression" prop="analyticsTrackImpression" type="checkbox" /> <${Input} label="Collection Name" prop="analyticsCollectionName" type="text" />`; const AdvancedPanel = () => { @@ -596,9 +652,10 @@ const AdvancedPanel = () => { return html` + <${Input} label="Preview Floodgate Cards" prop="fetchCardsFromFloodgateTree" type="checkbox" /> <${Input} label="Show IDs (only in the configurator)" prop="showIds" type="checkbox" /> <${Input} label="Do not lazyload" prop="doNotLazyLoad" type="checkbox" /> - <${Input} label="Collection Size (defaults to Total Cards To Show)" prop="collectionSize" type="text" /> + <${Input} label="Collection Size (Defaults: Total Cards)" prop="collectionSize" type="text" /> <${Select} label="CaaS Endpoint" prop="endpoint" options=${defaultOptions.endpoints} /> <${Input} label="Fallback Endpoint" @@ -639,10 +696,13 @@ const getInitialState = () => { // /* c8 ignore next 2 */ if (!state) { const lsState = localStorage.getItem(LS_KEY); - if (lsState) { + // For backwards compatibilty: Check that localStorage state exists + // and it contains the new filtersCustom attribute before using it + if (lsState?.includes('filtersCustom')) { try { state = JSON.parse(lsState); /* c8 ignore next */ + // eslint-disable-next-line no-empty } catch (e) {} } } @@ -656,6 +716,16 @@ const saveStateToLocalStorage = (state) => { localStorage.setItem(LS_KEY, JSON.stringify(state)); }; +/** + * Removes the JSON key "fetchCardsFromFloodgateTree" from the Copied URL to Caas. + * Caas Collection will determine if the content should be served from floodgate + * based on metadata.xslx logic in caas-libs + * @param {*} key jsonKey + * @param {*} value jsonValue + * @returns replacedJson + */ +const fgKeyReplacer = (key, value) => (key === 'fetchCardsFromFloodgateTree' ? undefined : value); + /* c8 ignore start */ const CopyBtn = () => { const { state } = useContext(ConfiguratorContext); @@ -672,8 +742,10 @@ const CopyBtn = () => { }; const getUrl = () => { - const url = window.location.href.split('#')[0]; - return `${url}#${utf8ToB64(JSON.stringify(state))}`; + const url = new URL(window.location.href); + url.search = ''; + url.hash = utf8ToB64(JSON.stringify(state, fgKeyReplacer)); + return url.href; }; const copyConfig = () => { @@ -726,8 +798,8 @@ const CopyBtn = () => { return html` +
    @@ -896,6 +976,7 @@ const init = async (el) => { }; export { + // eslint-disable-next-line no-restricted-exports init as default, cloneObj, getHashConfig, diff --git a/libs/blocks/caas-config/caas-tags.js b/libs/blocks/caas-config/caas-tags.js index faa2df63a0..bad96f324c 100644 --- a/libs/blocks/caas-config/caas-tags.js +++ b/libs/blocks/caas-config/caas-tags.js @@ -8118,7 +8118,7 @@ const caasTags = { id: 'caas:events', title: 'Events', }, - path: '/content/cq:tags/caas/events/eml', + path: '/content/cq:tags/caas/events/eml', tagID: 'caas:events/eml', name: 'eml', tagImage: '', diff --git a/libs/blocks/caas/caas.css b/libs/blocks/caas/caas.css index 245f60c453..9357326893 100644 --- a/libs/blocks/caas/caas.css +++ b/libs/blocks/caas/caas.css @@ -1,4 +1,4 @@ -a[href*='/tools/caas#'] { +a[href*='/tools/caas#'], a[href*='/tools/caas?'] { visibility: hidden !important; } @@ -11,34 +11,6 @@ main > .section > .content .consonant-Wrapper--1600MaxWidth .consonant-Wrapper-i width: 100%; } -/* temp fix for caas */ -[class$='Card-logo'] { - border: 1px solid transparent; - background-color: white; - border-bottom-right-radius: 4px; - border-top-right-radius: 4px; - min-height: 48px; - max-height: 48px; - bottom: 16px; - display: block; - font-size: 0; - left: 0; - line-height: 0; - padding: 8px 24px; - position: absolute; - z-index: 1; -} - -[class$='Card-logo'] img { - height: auto; - min-height: 32px; - max-height: 32px; - max-width: 90px; - object-fit: contain; - user-select: none; - width: auto; -} - /* Dexter Modal CSS - can be removed when caas team fixes MWPW-118588*/ .aem-Grid { display: block; @@ -208,7 +180,6 @@ main > .section > .content .consonant-Wrapper--1600MaxWidth .consonant-Wrapper-i } } - .dexter-Modal { display: none; opacity: 0; @@ -401,4 +372,202 @@ main > .section > .content .consonant-Wrapper--1600MaxWidth .consonant-Wrapper-i overflow-y: auto } } + /* End Dexter Modal */ + +/* CaaS Configurator Overrrides */ +.config-panel hr.divider { + margin: 15px 0 8px; + border-color: #666;} + +.config-panel dt.title span { + font-size: 18px; +} + +.config-panel dt.title.is-expanded span, +dt.title.is-expanded button { + font-weight: 300; +} + +.config-panel .multifield { + background-color: #404040; + border-bottom: 0; + padding: 0; + margin: 14px 0 0; +} + +.config-panel .multifield.filtersCustomItems { + margin: 0 0 20px 14px; + width: 250px; +} + +.config-panel .multifield > .multifield-header h3 { + font-size: 18px; + font-weight: 700; + margin: 2px 6px; +} + +.config-panel .multifield > .multifield-set{ + padding-bottom: 13px; +} + +.config-panel .multifield > .multifield-set:not(:nth-child(2)) { + border-top: 2px solid #5d8c0499; +} + +.config-panel .multifield > .multifield-set > .multifield-fields .fields { + margin: 0; +} + +.config-panel .accordion { + margin: 0 0 0 6px; +} + +.config-panel dd.content.is-expanded { + margin-top: 0; + max-height: 999999px; /* needed for transition animation */ +} + +.config-panel dd.content .field { + margin: 8px 0; +} + +.config-panel .accordion-item { + margin: 1px 0; +} + +.config-panel .accordion > .accordion-item { + margin: 1px 0; +} + +.config-panel .accordion > .accordion-item .title { + cursor: pointer; +} + +.config-panel .accordion dt.title { + gap: 0; +} + +.config-panel .accordion .tagselect { + margin: 0; +} + +.config-panel .accordion dd.content button { + background-color: #666; + cursor: pointer; +} + +.config-panel .accordion dd.content button.multifield-delete { + margin: 8px 16px; + background-color: #858585; +} + +.config-panel .accordion dd.content button.multifield-delete:hover { + background-color: #AAA; +} + +.config-panel .accordion dd.content .filtersCustomItems button.multifield-delete { + margin: 2px 16px; + background-color: #999; +} + +.config-panel .accordion dd.content .filtersCustomItems button.multifield-delete:hover { + background-color: #AAA; +} + +.config-panel .accordion dd.content button:hover { + background-color: #505050; +} + +.config-panel .accordion .multifield > .multifield-header h5 { + font-size: 15px; + margin: 0 6px; + font-weight: 300; + font-style: italic; + color: #aaa; +} + +.config-panel .accordion dd.content .content-container { + padding: 20px; +} + +.config-panel .accordion dd.content .field select, +.config-panel .accordion dd.content .field input { + font-size: 14px; +} + +.config-panel .accordion .tagselect label, +.config-panel .accordion dd.content .field label { + margin-left: 6px; +} + +.config-panel .accordion .multifield-fields .field { + padding-left: 8px; +} + +.config-panel .accordion .multifield input, +.multifield .tagselect-input { + border: solid 1px #666 !important; + margin-left: 8px; +} + +.multifield > .multifield-set > .multifield-delete { + margin: 8px 20px; +} + +.config-panel .accordion .field input, +.config-panel .accordion .field select, +.config-panel .accordion .tagselect-input { + border: #666 solid 1px !important; +} + +.config-panel .accordion .multifield > .multifield-header h5 { + max-width: 220px; +} + +.config-panel .accordion input[type=checkbox] { + align-self: baseline; + margin-top: 6px; +} + +/* collapsible panel */ +button.collapse-panel { + border: none; + font-size: 18px; + width: 26px; + height: 32px; + cursor: pointer; +} + +button.collapse-panel:hover { +background: #666; +color: #eee; +} + +.tool-content { + transition: all 400ms; + grid-template-columns: 340px 20px 1fr; + gap: 6px; +} + +.panel-collapsed .config-panel { + z-index: -1; +} + +.panel-collapsed .tool-content { + grid-template-columns: 20px 20px 1fr; +} + +.panel-collapsed dt.title span { + color: #333; + padding: 0 3px; +} + +.panel-collapsed button { + cursor:pointer; +} + +.tool-content > div:nth-child(2) { + padding-top: 10px; + border-top: solid 2px #000; +} diff --git a/libs/blocks/caas/caas.js b/libs/blocks/caas/caas.js index b0c7c0aeef..2014e3f153 100644 --- a/libs/blocks/caas/caas.js +++ b/libs/blocks/caas/caas.js @@ -1,7 +1,9 @@ -import { initCaas, loadCaasFiles, loadStrings } from './utils.js'; -import { parseEncodedConfig, createIntersectionObserver } from '../../utils/utils.js'; +import { initCaas, loadCaasFiles, loadStrings, fgHeaderValue } from './utils.js'; +import { parseEncodedConfig, createIntersectionObserver, getMetadata, getConfig, b64ToUtf8 } from '../../utils/utils.js'; const ROOT_MARGIN = 1000; +const P_CAAS_AIO = b64ToUtf8('MTQyNTctY2hpbWVyYS5hZG9iZWlvcnVudGltZS5uZXQvYXBpL3YxL3dlYi9jaGltZXJhLTAuMC4xL2NvbGxlY3Rpb24='); +const S_CAAS_AIO = b64ToUtf8('MTQyNTctY2hpbWVyYS1zdGFnZS5hZG9iZWlvcnVudGltZS5uZXQvYXBpL3YxL3dlYi9jaGltZXJhLTAuMC4xL2NvbGxlY3Rpb24='); const getCaasStrings = (placeholderUrl) => new Promise((resolve) => { if (placeholderUrl) { @@ -30,6 +32,28 @@ const loadCaas = async (a) => { a.insertAdjacentElement('afterend', block); a.remove(); + const floodGateColor = getMetadata('floodgatecolor') || ''; + if (floodGateColor === fgHeaderValue) { + state.fetchCardsFromFloodgateTree = true; + } + + const { env } = getConfig(); + const { host } = window.location; + let chimeraEndpoint = 'www.adobe.com/chimera-api/collection'; + + if (host.includes('stage.adobe') || env?.name === 'local') { + chimeraEndpoint = S_CAAS_AIO; + } else if (host.includes('.hlx.')) { + // If invoking URL is not an Acom URL, then switch to AIO + chimeraEndpoint = P_CAAS_AIO; + } + + if (host.includes('hlx.page') || env?.name === 'local') { + state.draftDb = true; + } + + state.endpoint = chimeraEndpoint; + initCaas(state, caasStrs, block); }; diff --git a/libs/blocks/caas/utils.js b/libs/blocks/caas/utils.js index c8200fd760..d8799499df 100644 --- a/libs/blocks/caas/utils.js +++ b/libs/blocks/caas/utils.js @@ -3,9 +3,12 @@ import { loadScript, loadStyle, getConfig as pageConfigHelper } from '../../util import { fetchWithTimeout } from '../utils/utils.js'; const URL_ENCODED_COMMA = '%2C'; +export const fgHeaderName = 'X-Adobe-Floodgate'; +export const fgHeaderValue = 'pink'; const pageConfig = pageConfigHelper(); const pageLocales = Object.keys(pageConfig.locales || {}); +const requestHeaders = []; export function getPageLocale(currentPath, locales = pageLocales) { const possibleLocale = currentPath.split('/')[1]; @@ -175,20 +178,24 @@ const getLocalTitle = (tag, country, lang) => tag[`title.${lang}_${country}`] || tag[`title.${lang}`] || tag.title; -const getFilterObj = ({ excludeTags, filterTag, icon, openedOnLoad }, tags, state) => { +const getFilterObj = ( + { excludeTags, filterTag, icon, openedOnLoad }, + tags, + state, + country, + lang, +) => { if (!filterTag?.[0]) return null; const tagId = filterTag[0]; const tag = findTagById(tagId, tags); if (!tag) return null; - const country = state.country.split('/')[1]; - const lang = state.language.split('/')[1]; const items = Object.values(tag.tags) .map((itemTag) => { - if (excludeTags.includes(itemTag.tagID)) return null; - const label = getLocalTitle(itemTag, country, lang); + if (excludeTags?.includes(itemTag.tagID)) return null; + const titleLabel = getLocalTitle(itemTag, country, lang); return { id: itemTag.tagID, - label: label.replace('&', '&'), + label: titleLabel.replace('&', '&'), }; }) .filter((i) => i !== null) @@ -208,31 +215,90 @@ const getFilterObj = ({ excludeTags, filterTag, icon, openedOnLoad }, tags, stat return filterObj; }; -const getFilterArray = async (state) => { - if (!state.showFilters || state.filters.length === 0) { +const getCustomFilterObj = ({ group, filtersCustomItems, openedOnLoad }, strs = {}) => { + if (!group) return null; + + const IN_BRACKETS_RE = /^{.*}$/; + + const items = filtersCustomItems.map((item) => ({ + id: item.customFilterTag[0], + label: item.filtersCustomLabel?.match(IN_BRACKETS_RE) + ? strs[item.filtersCustomLabel.replace(/{|}/g, '')] + : item.filtersCustomLabel || '', + })); + + const filterObj = { + id: group, + openedOnLoad: !!openedOnLoad, + items, + group: group?.match(IN_BRACKETS_RE) + ? strs[group.replace(/{|}/g, '')] + : group || '', + }; + + return filterObj; +}; + +const getFilterArray = async (state, country, lang, strs) => { + if ((!state.showFilters || state.filters.length === 0) && state.filtersCustom?.length === 0) { return []; } const { tags } = await getTags(state.tagsUrl); - const filters = state.filters - .map((filter) => getFilterObj(filter, tags, state)) - .filter((filter) => filter !== null); + const useCustomFilters = state.filterBuildPanel === 'custom'; + + let filters = []; + if (!useCustomFilters) { + filters = state.filters + .map((filter) => getFilterObj(filter, tags, state, country, lang)) + .filter((filter) => filter !== null); + } else { + filters = state.filtersCustom.length > 0 + ? state.filtersCustom.map((filter) => getCustomFilterObj(filter, strs)) + : []; + } + return filters; }; -const getCountryAndLang = ({ autoCountryLang, country, language }) => { +export function getCountryAndLang({ autoCountryLang, country, language }) { if (autoCountryLang) { - const htmlLang = document.documentElement.getAttribute('lang')?.toLowerCase() || 'en-us'; - const [lang, cntry] = htmlLang.split('-'); + const locale = pageConfigHelper()?.locale; return { - country: cntry, - language: lang, + country: locale.region?.toLowerCase() || 'us', + language: locale.ietf?.toLowerCase() || 'en-us', }; } return { country: country ? country.split('/').pop() : 'us', language: language ? language.split('/').pop() : 'en', }; +} + +/** + * Finds the matching tuple and returns its index. + * Looks for 'X-Adobe-Floodgate' in [['X-Adobe-Floodgate', 'pink'], ['a','b']] + * @param {*} fgHeader fgHeader + * @returns tupleIndex + */ +const findTupleIndex = (fgHeader) => { + const matchingTupleIndex = requestHeaders.findIndex((element) => element[0] === fgHeader); + return matchingTupleIndex; +}; + +/** + * Adds the floodgate header to the Config of ConsonantCardCollection + * headers: [['X-Adobe-Floodgate', 'pink'], ['OtherHeader', 'Value']] + * @param {*} state state + * @returns requestHeaders + */ +const addFloodgateHeader = (state) => { + // Delete FG header if already exists, before adding pink to avoid duplicates in requestHeaders + requestHeaders.splice(findTupleIndex(fgHeaderName, 1)); + if (state.fetchCardsFromFloodgateTree) { + requestHeaders.push([fgHeaderName, fgHeaderValue]); + } + return requestHeaders; }; export function arrayToObj(input = []) { @@ -249,11 +315,24 @@ export function arrayToObj(input = []) { return obj; } -export const getConfig = async (state, strs = {}) => { +const addMissingStateProps = (state) => { + // eslint-disable-next-line no-use-before-define + Object.entries(defaultState).forEach(([key, val]) => { + if (state[key] === undefined) { + state[key] = val; + } + }); + return state; +}; + +export const getConfig = async (originalState, strs = {}) => { + const state = addMissingStateProps(originalState); const originSelection = Array.isArray(state.source) ? state.source.join(',') : state.source; const { country, language } = getCountryAndLang(state); const featuredCards = state.featuredCards && state.featuredCards.reduce(getContentIdStr, ''); const excludedCards = state.excludedCards && state.excludedCards.reduce(getContentIdStr, ''); + const hideCtaIds = state.hideCtaIds ? state.hideCtaIds.reduce(getContentIdStr, '') : ''; + const hideCtaTags = state.hideCtaTags ? state.hideCtaTags : []; const targetActivity = state.targetEnabled && state.targetActivity ? `/${encodeURIComponent(state.targetActivity)}.json` : ''; const flatFile = targetActivity ? '&flatFile=false' : ''; @@ -262,6 +341,8 @@ export const getConfig = async (state, strs = {}) => { const complexQuery = buildComplexQuery(state.andLogicTags, state.orLogicTags); + const caasRequestHeaders = addFloodgateHeader(state); + const config = { collection: { mode: state.theme, @@ -293,7 +374,9 @@ export const getConfig = async (state, strs = {}) => { onErrorTitle: strs.onErrorTitle || 'Sorry there was a system error.', onErrorDescription: strs.onErrorDesc || 'Please try reloading the page or try coming back to the page another time.', + lastModified: strs.lastModified || 'Last modified {date}', }, + detailsTextOption: state.detailsTextOption, setCardBorders: state.setCardBorders, useOverlayLinks: state.useOverlayLinks, collectionButtonStyle: state.collectionBtnStyle, @@ -315,13 +398,15 @@ export const getConfig = async (state, strs = {}) => { ctaAction: state.ctaAction, additionalRequestParams: arrayToObj(state.additionalRequestParams), }, + hideCtaIds: hideCtaIds.split(URL_ENCODED_COMMA), + hideCtaTags, featuredCards: featuredCards.split(URL_ENCODED_COMMA), filterPanel: { enabled: state.showFilters, eventFilter: state.filterEvent, type: state.showFilters ? state.filterLocation : 'left', showEmptyFilters: state.filtersShowEmpty, - filters: await getFilterArray(state), + filters: await getFilterArray(state, country, language, strs), filterLogic: state.filterLogic, i18n: { leftPanel: { @@ -427,6 +512,7 @@ export const getConfig = async (state, strs = {}) => { lastViewedSession: state.lastViewedSession || '', }, customCard: ['card', `return \`${state.customCard}\``], + headers: caasRequestHeaders, }; return config; }; @@ -457,6 +543,7 @@ export const defaultState = { analyticsTrackImpression: false, andLogicTags: [], autoCountryLang: false, + fetchCardsFromFloodgateTree: false, bookmarkIconSelect: '', bookmarkIconUnselect: '', cardStyle: 'half-height', @@ -469,7 +556,7 @@ export const defaultState = { contentTypeTags: [], country: 'caas:country/us', customCard: '', - ctaAction: '_blank', + ctaAction: '_self', doNotLazyLoad: false, disableBanners: false, draftDb: false, @@ -480,11 +567,16 @@ export const defaultState = { fallbackEndpoint: '', featuredCards: [], filterEvent: '', + filterBuildPanel: 'automatic', filterLocation: 'left', filterLogic: 'or', filters: [], + filtersCustom: [], filtersShowEmpty: false, gutter: '4x', + headers: [], + hideCtaIds: [], + hideCtaTags: [], includeTags: [], language: 'caas:language/en', layoutType: '4up', @@ -525,6 +617,7 @@ export const defaultState = { targetActivity: '', targetEnabled: false, theme: 'lightest', + detailsTextOption: 'default', titleHeadingLevel: 'h3', totalCardsToShow: 10, useLightText: false, diff --git a/libs/blocks/card/card.js b/libs/blocks/card/card.js index 2ea4efa984..3ce13cc8fd 100644 --- a/libs/blocks/card/card.js +++ b/libs/blocks/card/card.js @@ -1,6 +1,6 @@ import { decorateButtons } from '../../utils/decorate.js'; -import { loadStyle, getConfig, createTag } from '../../utils/utils.js'; -import { getMetadata } from '../section-metadata/section-metadata.js'; +import { loadStyle, getConfig } from '../../utils/utils.js'; +import { addBackgroundImg, addWrapper, addFooter, addVideoBtn } from './cardUtils.js'; const HALF = 'OneHalfCard'; const HALF_HEIGHT = 'HalfHeightCard'; @@ -18,55 +18,6 @@ const getCardType = (styles) => { return cardTypes[authoredType] || HALF; }; -const getUpFromSectionMetadata = (section) => { - const sectionMetadata = section.querySelector('.section-metadata'); - if (!sectionMetadata) return null; - const metadata = getMetadata(sectionMetadata); - const styles = metadata.style?.text.split(', ').map((style) => style.replaceAll(' ', '-')); - return styles?.find((style) => style.includes('-up')); -}; - -const addWrapper = (el, section, cardType) => { - const gridCl = 'consonant-CardsGrid'; - const prevGrid = section.querySelector(`.consonant-Wrapper .${gridCl}`); - - if (prevGrid) return; - - let upClass = getUpFromSectionMetadata(section); - // Authored w/ a typed out number reference... 'two-up' vs. '2-up' - const list = ['two-up', 'three-up', 'four-up', 'five-up']; - const ixd = list.findIndex(i => i.includes(upClass)); - if (ixd > -1) { - upClass = `${ixd+2}-up`; - section.classList.remove(list[ixd]); - } - const up = upClass?.replace('-', '') || '3up'; - const gridClass = `${gridCl} ${gridCl}--${up} ${gridCl}--with4xGutter${cardType === DOUBLE_WIDE ? ` ${gridCl}--doubleWideCards` : ''}`; - const grid = createTag('div', { class: gridClass }); - const collection = createTag('div', { class: 'consonant-Wrapper-collection' }, grid); - const inner = createTag('div', { class: 'consonant-Wrapper-inner' }, collection); - const wrapper = createTag('div', { class: 'milo-card-wrapper consonant-Wrapper consonant-Wrapper--1200MaxWidth' }, inner); - const cards = section.querySelectorAll('.card'); - const prevSib = cards[0].previousElementSibling; - - grid.append(...cards); - - if (prevSib) { - prevSib.after(wrapper); - } else { - section.prepend(wrapper); - } -}; - -const addBackgroundImg = (picture, cardType, card) => { - const url = picture.querySelector('img').src; - const imageDiv = document.createElement('div'); - - imageDiv.style.backgroundImage = `url(${url})`; - imageDiv.classList.add(`consonant-${cardType}-img`); - card.append(imageDiv); -}; - const addInner = (el, cardType, card) => { const title = el.querySelector('h1, h2, h3, h4, h5, h6'); const text = Array.from(el.querySelectorAll('p'))?.find((p) => !p.querySelector('picture, a')); @@ -98,22 +49,6 @@ const addInner = (el, cardType, card) => { text?.classList.add(`consonant-${cardType}-text`); }; -const addFooter = (links, container, merch) => { - const linksArr = Array.from(links); - const linksLeng = linksArr.length; - const hrTag = merch ? '
    ' : ''; - let footer = `
    ${hrTag}
    `; - footer = linksArr.reduce( - (combined, link, index) => ( - `${combined}
    ${link.outerHTML}
    `), - footer, - ); - footer += '
    '; - - container.insertAdjacentHTML('beforeend', footer); - links[0]?.parentElement?.remove(); -}; - const init = (el) => { const { miloLibs, codeRoot } = getConfig(); const base = miloLibs || codeRoot; @@ -127,7 +62,7 @@ const init = (el) => { const cardType = getCardType(styles); const merch = styles.includes('merch') && cardType === HALF; const links = merch ? el.querySelector(':scope > div > div > p:last-of-type') - .querySelectorAll('a') : el.querySelectorAll('a'); + .querySelectorAll('a') : el.querySelectorAll('a:not(.consonant-play-btn)'); let card = el; addWrapper(el, section, cardType); @@ -150,6 +85,8 @@ const init = (el) => { if (picture && cardType !== PRODUCT) { addBackgroundImg(picture, cardType, card); + const playBtn = el.querySelector('a.consonant-play-btn'); + if (playBtn) addVideoBtn(playBtn, cardType, card); } picture?.parentElement.remove(); diff --git a/libs/blocks/card/cardUtils.js b/libs/blocks/card/cardUtils.js new file mode 100644 index 0000000000..76a726aa73 --- /dev/null +++ b/libs/blocks/card/cardUtils.js @@ -0,0 +1,81 @@ +/* eslint-disable import/prefer-default-export */ +import { createTag } from '../../utils/utils.js'; +import { getMetadata } from '../section-metadata/section-metadata.js'; + +const DOUBLE_WIDE = 'DoubleWideCard'; +const HALF_HEIGHT = 'HalfHeightCard'; + +export const addBackgroundImg = (picture, cardType, card) => { + const url = picture.querySelector('img').src; + card.append(createTag('div', { class: `consonant-${cardType}-img`, style: `background-image: url(${url})` })); +}; + +export const addVideoBtn = (link, cardType, card) => { + const cardImage = card.querySelector(`.consonant-${cardType}-img`); + const playBtn = createTag('div', { class: `consonant-${cardType}-videoIco` }); + if (cardType === HALF_HEIGHT) return cardImage.append(playBtn); + link.innerHTML = ''; + link.appendChild(playBtn); + link.classList.add('consonant-videoButton-wrapper'); + return cardImage.append(link); +}; + +const getUpFromSectionMetadata = (section) => { + const sectionMetadata = section.querySelector('.section-metadata'); + if (!sectionMetadata) return null; + const metadata = getMetadata(sectionMetadata); + const styles = metadata.style?.text.split(', ').map((style) => style.replaceAll(' ', '-')); + return styles?.find((style) => style.includes('-up')); +}; + +export const addFooter = (links, container, merch) => { + const linksArr = Array.from(links); + const linksLeng = linksArr.length; + const hrTag = merch ? '
    ' : ''; + let footer = `
    ${hrTag}
    `; + footer = linksArr.reduce( + (combined, link, index) => ( + `${combined}
    ${link.outerHTML}
    `), + footer, + ); + footer += '
    '; + + container.insertAdjacentHTML('beforeend', footer); + links.forEach((link) => { + const { parentElement } = link; + if (parentElement && document.body.contains(parentElement)) parentElement.remove(); + }); +}; + +export const addWrapper = (el, section, cardType) => { + const gridCl = 'consonant-CardsGrid'; + const prevGrid = section.querySelector(`.consonant-Wrapper .${gridCl}`); + + if (prevGrid) return; + const card = el.classList[0]; + let upClass = getUpFromSectionMetadata(section); + // Authored w/ a typed out number reference... 'two-up' vs. '2-up' + const list = ['two-up', 'three-up', 'four-up', 'five-up']; + const idx = list.findIndex((i) => i.includes(upClass)); + if (idx > -1) { + upClass = `${idx + 2}-up`; + const classToRemove = list[idx]; + new MutationObserver(() => { if (section.classList.contains(classToRemove)) section.classList.remove(classToRemove); }).observe(section, { attributes: true, attributeFilter: ['class'] }); + } + const up = upClass?.replace('-', '') || '3up'; + const gridClass = `${gridCl} ${gridCl}--${up} ${gridCl}--with4xGutter${cardType === DOUBLE_WIDE ? ` ${gridCl}--doubleWideCards` : ''}`; + const grid = createTag('div', { class: gridClass }); + const collection = createTag('div', { class: 'consonant-Wrapper-collection' }, grid); + const inner = createTag('div', { class: 'consonant-Wrapper-inner' }, collection); + const wrapper = createTag('div', { class: 'milo-card-wrapper consonant-Wrapper consonant-Wrapper--1200MaxWidth' }, inner); + const cards = section.querySelectorAll(`.${card}`); + const prevSib = cards[0].previousElementSibling; + + grid.append(...cards); + + if (prevSib) { + prevSib.after(wrapper); + } else { + section.prepend(wrapper); + } +}; diff --git a/libs/blocks/carousel/carousel.js b/libs/blocks/carousel/carousel.js index 3f79c08441..580eaeb21e 100644 --- a/libs/blocks/carousel/carousel.js +++ b/libs/blocks/carousel/carousel.js @@ -1,4 +1,4 @@ -import { createTag, getConfig } from '../../utils/utils.js'; +import { createTag, getConfig, MILO_EVENTS } from '../../utils/utils.js'; const { miloLibs, codeRoot } = getConfig(); const base = miloLibs || codeRoot; @@ -366,9 +366,9 @@ export default function init(el) { images.forEach((img) => { img.removeAttribute('loading'); }); - parentArea.removeEventListener('milo:deferred', handleDeferredImages, true); + parentArea.removeEventListener(MILO_EVENTS.DEFERRED, handleDeferredImages, true); } - parentArea.addEventListener('milo:deferred', handleDeferredImages, true); + parentArea.addEventListener(MILO_EVENTS.DEFERRED, handleDeferredImages, true); slides[0].classList.add('active'); handleChangingSlides(carouselElements); diff --git a/libs/blocks/chart/chart.js b/libs/blocks/chart/chart.js index 1a39705fbe..5301668cb2 100644 --- a/libs/blocks/chart/chart.js +++ b/libs/blocks/chart/chart.js @@ -454,7 +454,9 @@ const setDonutListeners = (chart, source, seriesData, units = []) => { chart.on('legendselectchanged', ({ selected }) => { mouseOutValue = handleDonutSelect(sourceData, selected, chart, units?.[0], title); }); }; -const initChart = ({ chartWrapper, chartType, data, series, size, ...rest }) => { +const initChart = ({ + chartWrapper, chartType, data, series, size, ...rest +}) => { const themeName = getTheme(size); const options = { chartType, processedData: data, series, size, ...rest }; const chartOptions = getChartOptions(options); diff --git a/libs/blocks/columns/columns.css b/libs/blocks/columns/columns.css index 48d533d87b..3ba334a1be 100644 --- a/libs/blocks/columns/columns.css +++ b/libs/blocks/columns/columns.css @@ -35,11 +35,11 @@ } /* Table */ -.columns.table { +.columns.columns-table { font-size: 14px; } -.columns.table > .row { +.columns.columns-table > .row { margin-bottom: 0; padding: var(--spacing-xs) 0; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); @@ -47,11 +47,11 @@ align-items: center; } -.columns.table > .row:hover { +.columns.columns-table > .row:hover { background-color: #0000000f; } -.columns.table > .row:first-child { +.columns.columns-table > .row:first-child { text-transform: uppercase; font-size: 11px; font-weight: bold; @@ -60,7 +60,7 @@ /* For desktop only */ @media (min-width:600px) { - .columns.table > .row { + .columns.columns-table > .row { padding: var(--spacing-xs); } } diff --git a/libs/blocks/columns/columns.js b/libs/blocks/columns/columns.js index 8de3e6fd20..c0c8d2e660 100644 --- a/libs/blocks/columns/columns.js +++ b/libs/blocks/columns/columns.js @@ -7,4 +7,8 @@ export default function init(el) { col.className = `col col-${cdx + 1}`; }); }); + if (el.classList.contains('table')) { + el.classList.add('columns-table'); + el.classList.remove('table'); + } } diff --git a/libs/blocks/commerce/commerce.css b/libs/blocks/commerce/commerce.css new file mode 100644 index 0000000000..5845795750 --- /dev/null +++ b/libs/blocks/commerce/commerce.css @@ -0,0 +1,119 @@ + +::-webkit-scrollbar { + background-color: #303030; + width: 5px; +} + +::-webkit-scrollbar-thumb { + background-color: #676767; + border-radius: 10px; +} + +.in-page { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; +} + +.commerce { + width: 360px; + height: 354px; + overflow: hidden; + background-color: #303030; + color: #fff; + font-size: 0.78rem; + margin: 0; + padding: 0; +} + +.offer-search-wrapper { + position: sticky; +} + +.offer-details-wrapper { + padding: 5px; + position: relative; +} + +.offer-details-wrapper .not-valid-url { + text-align: center; +} + +.offer-details { + margin: 0; + padding: 0 0px 0 10px; + list-style: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: pre; + overflow-y: scroll; + height: 310px; +} + +.offer-details .offer-detail { + overflow: hidden; + text-overflow: ellipsis; + white-space: break-spaces; + line-height: 1.5rem; +} + +.offer-details .offer-detail .offer-value { + font-weight: 700; +} + +.offer-details .con-button { + border: 2px solid #fff; + color: #fff; + margin: 5px 10px 10px; +} + +.offer-details a.con-button.placeholder-resolved { + float: right; +} + +.offer-details button.con-button { + float: left; +} + +.offer-search { + width: 100%; + display: block; + font-family: var(--body-font-family); + background: rgb(0 0 0 / 35%); + border: none; + margin: 0; + padding: 0 12px; + line-height: 36px; + font-weight: 700; + color: #FFF; + box-sizing: border-box; + transition: padding .2s ease-in-out; +} + +.offer-search-icon { + position: absolute; + content: ''; + width: 36px; + height: 36px; + left: -36px; + top: 0; + background: url('/libs/ui/img/spectrum-search.svg') center / 24px no-repeat; + transition: left .2s ease-in-out; +} + +.offer-search:hover + .offer-search-icon, .offer-search:focus + .offer-search-icon { + left: 6px; +} + +.offer-search:hover, .offer-search:focus { + padding: 0 42px; +} + +.offer-search::placeholder { + color: #FFF; +} + +.offer-search:focus::placeholder { + color: #676767; +} diff --git a/libs/blocks/commerce/commerce.js b/libs/blocks/commerce/commerce.js new file mode 100644 index 0000000000..12f233211b --- /dev/null +++ b/libs/blocks/commerce/commerce.js @@ -0,0 +1,131 @@ +import { debounce } from '../../utils/action.js'; +import { createTag } from '../../utils/utils.js'; +import { buildCta, initService } from '../merch/merch.js'; + +export const filterOfferDetails = (offerDetails) => { + const formattedOffer = {}; + + function formatPrice(price, format) { + const currency = format.match(/'([^']+)'/); + return `${currency[1]}${price}`; + } + + const { + offerType, + offerId, + productArrangementCode, + pricePoint, + customerSegment, + commitment, + term, + offerSelectorIds, + priceDetails, + } = offerDetails; + + formattedOffer.offerType = offerType; + formattedOffer.offerId = offerId; + formattedOffer.productArrangementCode = productArrangementCode; + formattedOffer.pricePoint = pricePoint; + formattedOffer.customerSegment = customerSegment; + formattedOffer.commitment = commitment; + formattedOffer.term = term; + formattedOffer.offerSelectorIds = offerSelectorIds; + formattedOffer.price = formatPrice(priceDetails.price, priceDetails.formatString); + return formattedOffer; +}; + +export function buildClearButton() { + const button = createTag('button', { type: 'button', class: 'con-button' }); + button.textContent = 'Clear'; + return button; +} + +export async function decorateOfferDetails(el, of, searchParams) { + function formatOfferDetailKeys(str) { + const details = str.split(/(?=[A-Z])/); + const allCapsDetail = details.map((detail) => detail.toUpperCase()); + const result = allCapsDetail.join(' '); + if (result === 'OFFER SELECTOR IDS') { + return 'OSI'; + } + return result; + } + const offerDetailsList = document.createElement('ul'); + offerDetailsList.className = 'offer-details'; + const offer = filterOfferDetails(of); + const promotionCode = searchParams.get('promo'); + offer.type = searchParams.get('type'); + if (offer.type === 'checkoutUrl') { + offer.cta = searchParams.get('text'); + } + if (promotionCode) { + offer.promo = promotionCode; + } + + Object.entries(offer).forEach(([key, value]) => { + const offerData = document.createElement('li'); + const offerKey = document.createElement('span'); + const offerValue = document.createElement('span'); + offerData.classList.add('offer-detail'); + offerKey.classList.add('offer-key'); + offerValue.classList.add('offer-value'); + offerKey.textContent = `${formatOfferDetailKeys(key)}: `; + offerValue.textContent = value; + offerData.appendChild(offerKey); + offerData.appendChild(offerValue); + offerDetailsList.appendChild(offerData); + }); + const checkoutLink = document.createElement('a'); + checkoutLink.textContent = 'Checkout link'; + const checkoutUrl = await buildCta(checkoutLink, searchParams); + checkoutUrl.target = '_blank'; + const clearButton = buildClearButton(); + + clearButton.addEventListener('click', () => { + const input = document.querySelector('.offer-search'); + input.value = ''; + offerDetailsList.textContent = ''; + }); + offerDetailsList.appendChild(clearButton); + offerDetailsList.appendChild(checkoutUrl); + el.append(offerDetailsList); +} + +export async function handleOfferSearch(event, el) { + el.textContent = ''; + const { searchParams } = new URL(event.target.value, window.location.href); + const osi = searchParams.get('osi'); + if (osi != null) { + const service = await initService(); + const [promise] = await service.resolveOfferSelectors({ offerSelectorIds: [osi] }); + const [offer] = await promise; + await decorateOfferDetails(el, offer, searchParams); + } else { + const notValidUrl = document.createElement('h4'); + notValidUrl.classList.add('not-valid-url'); + notValidUrl.textContent = 'Not a valid offer link'; + el.append(notValidUrl); + } +} + +export function decorateSearch(el) { + const search = createTag('input', { class: 'offer-search', placeholder: 'Enter offer URL to preview' }); + const icon = createTag('div', { class: 'offer-search-icon' }); + const searchWrapper = createTag('div', { class: 'offer-search-wrapper' }, [search, icon]); + const offerDetailsWrapper = createTag('div', { class: 'offer-details-wrapper' }); + el.append(searchWrapper); + el.append(offerDetailsWrapper); + search.addEventListener( + 'keyup', + debounce((event) => handleOfferSearch(event, offerDetailsWrapper), 500), + ); +} + +function detectContext() { + if (window.self === window.top) document.body.classList.add('in-page'); +} + +export default async function init(el) { + detectContext(); + decorateSearch(el); +} diff --git a/libs/blocks/faas-config/faas-config.js b/libs/blocks/faas-config/faas-config.js index e010b8e315..efe0fe4616 100644 --- a/libs/blocks/faas-config/faas-config.js +++ b/libs/blocks/faas-config/faas-config.js @@ -24,6 +24,10 @@ const sortObjects = (obj) => Object.entries(obj).sort((a, b) => { return x < y ? -1 : x > y ? 1 : 0; }); +function deepCopy(obj) { + return JSON.parse(JSON.stringify(obj)); +} + const getHashConfig = () => { const { hash } = window.location; if (!hash) return null; @@ -68,9 +72,11 @@ const getInitialState = () => { } return null; }; + const saveStateToLocalStorage = (state) => { localStorage.setItem(LS_KEY, JSON.stringify(state)); }; + const getObjFromAPI = async (apiPath) => { const resp = await fetch(`${faasHostUrl}${apiPath}`); if (resp.ok) { @@ -79,12 +85,15 @@ const getObjFromAPI = async (apiPath) => { } return false; }; + const reducer = (state, action) => { switch (action.type) { case 'SELECT_CHANGE': case 'INPUT_CHANGE': case 'MULTI_SELECT_CHANGE': return { ...state, [action.prop]: action.value }; + case 'RESET_STATE': + return deepCopy(defaultState); default: console.log('DEFAULT'); return state; @@ -131,7 +140,7 @@ const CopyBtn = () => { input.focus(); return; } - if (input.name == 'v' && !/^[A-Za-z0-9]*$/.test(input.value)) { + if (input.name === 'v' && !/^[A-Za-z0-9]*$/.test(input.value)) { inputValidation = false; setErrorMessage('Campagin ID allows only letters and numbers'); input.focus(); @@ -145,6 +154,16 @@ const CopyBtn = () => { return `${url}#${utf8ToB64(JSON.stringify(state))}`; }; + const dateStr = new Date().toLocaleString('us-EN', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: false, + }); + const copyConfig = () => { setConfigUrl(getUrl()); if (!navigator?.clipboard) { @@ -161,7 +180,7 @@ const CopyBtn = () => { const formTemplate = document.getElementById('id').options[document.getElementById('id').selectedIndex].text; const link = document.createElement('a'); link.href = getUrl(); - link.textContent = `Form as a Service - ${formTemplate}`; + link.textContent = `Form as a Service - ${formTemplate} - ${dateStr}`; const blob = new Blob([link.outerHTML], { type: 'text/html' }); // eslint-disable-next-line no-undef @@ -266,6 +285,7 @@ const RequiredPanel = () => { }; if (!Object.keys(langOptions).length) { + // eslint-disable-next-line react-hooks/rules-of-hooks useEffect(() => { getObjFromAPI('/faas/api/locale').then((data) => { data.forEach((l) => { @@ -409,6 +429,22 @@ const StylePanel = () => html` <${Select} label="Title Alignment" prop="title_align" options="${{ left: 'Left', center: 'Center', right: 'Right' }}" /> <${Select} label="Custom Theme" prop="style_customTheme" options="${{ none: 'None' }}" /> `; + +const AdvancedPanel = () => { + const { dispatch } = useContext(ConfiguratorContext); + const onClick = () => { + const firstPanel = document.querySelector('.accordion-item button[aria-label=Expand]'); + + localStorage.removeItem(LS_KEY); + dispatch({ type: 'RESET_STATE' }); + firstPanel.click(); + }; + + return html` + + `; +}; + const Configurator = ({ rootEl }) => { const [state, dispatch] = useReducer(reducer, getInitialState() || defaultState); const [isFaasLoaded, setIsFaasLoaded] = useState(false); @@ -446,7 +482,12 @@ const Configurator = ({ rootEl }) => { { title: 'Style', content: html`<${StylePanel} />`, + }, + { + title: 'Advanced', + content: html`<${AdvancedPanel} />`, }]; + return html` <${ConfiguratorContext.Provider} value=${{ state, dispatch }}>
    diff --git a/libs/blocks/faas/faas.css b/libs/blocks/faas/faas.css index 83471758f0..ab96f0d736 100644 --- a/libs/blocks/faas/faas.css +++ b/libs/blocks/faas/faas.css @@ -5,6 +5,10 @@ Common styles for faas form Dexter's */ +a[href*='/tools/faas#'], a[href*='/tools/faas?'] { + visibility: hidden !important; +} + .faas { box-sizing: border-box; max-width: 800px; @@ -42,7 +46,8 @@ Common styles for faas form Dexter's } .faas-form .submit input { - appearance: button; + -webkit-appearance: none; + appearance: none; background-color: #2680eb; border: 0; border-radius: 24px; @@ -97,14 +102,17 @@ input[type="checkbox"] + label { } .faas-form select { + -webkit-appearance: none; + appearance: none; background: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii01OCAyOSAzMiAzNCIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAtNTggMjkgMzIgMzQiIHhtbDpzcGFjZT0icHJlc2VydmUiPjxwYXRoIGQ9Im0tNTYgNDMgNi0xMiA2IDEyaC0xMnpNLTQ0IDQ5bC02IDEyLTYtMTJoMTJ6IiBmaWxsPSIjNTM1MzUzIi8+PC9zdmc+') no-repeat 128% 54%; background-color: inherit; background-position: 100% 50%; background-size: 1em 1em; height: 58px; + color: #000; } -.faas-form .error input:not([type=checkbox]):not([type=radio]):not([type=submit]), +.faas-form .error input:not([type=checkbox]):not([type=radio]):not([type=submit]), .faas-form .error select { box-shadow: inset 1px 1px 0 0 #d9534f, inset -1px -1px 0 0 #d9534f; } @@ -399,7 +407,7 @@ input[type="checkbox"] + label { .faas-form .row > label { margin-bottom: 10px; } - + .faas.column2 { padding: 48px; } @@ -408,7 +416,7 @@ input[type="checkbox"] + label { width: 100%; padding: 12px 0; } - + .faas.column2 .faas-form select + div, .faas.column2 .faas-form textarea + div, .faas.column2 .faas-form input:not([type=checkbox]):not([type=radio]):not([type=submit]):not([type=hidden]) + div { diff --git a/libs/blocks/faas/faas.js b/libs/blocks/faas/faas.js index 5a11f48e19..41e7f100f8 100644 --- a/libs/blocks/faas/faas.js +++ b/libs/blocks/faas/faas.js @@ -1,7 +1,9 @@ -import { parseEncodedConfig } from '../../utils/utils.js'; +import { parseEncodedConfig, createIntersectionObserver } from '../../utils/utils.js'; import { initFaas, loadFaasFiles } from './utils.js'; -export default async function init(a) { +const ROOT_MARGIN = 1000; + +const loadFaas = async (a) => { await loadFaasFiles(); const encodedConfig = a.href.split('#')[1]; const faas = initFaas(parseEncodedConfig(encodedConfig), a); @@ -10,4 +12,16 @@ export default async function init(a) { if (faas && faas.closest('.dialog-modal')) { faas.querySelector('.faas').classList.add('column2'); } +}; + +export default async function init(a) { + if (a.textContent.includes('no-lazy')) { + loadFaas(a); + } else { + createIntersectionObserver({ + el: a, + options: { rootMargin: `${ROOT_MARGIN}px` }, + callback: loadFaas, + }); + } } diff --git a/libs/blocks/faas/utils.js b/libs/blocks/faas/utils.js index 84300ac52d..94a9ef96f0 100644 --- a/libs/blocks/faas/utils.js +++ b/libs/blocks/faas/utils.js @@ -1,10 +1,11 @@ -/* eslint-disable no-undef */ +/* global $ */ import { loadStyle, loadScript, getConfig, createTag, + localizeLink, } from '../../utils/utils.js'; const { env, miloLibs, codeRoot } = getConfig(); @@ -29,7 +30,7 @@ export const getFaasHostSubDomain = (environment) => { }; const base = miloLibs || codeRoot; -export const faasHostUrl = `https://${getFaasHostSubDomain()}apps.enterprise.adobe.com` +export const faasHostUrl = `https://${getFaasHostSubDomain()}apps.enterprise.adobe.com`; const faasCurrentJS = `${faasHostUrl}/faas/service/jquery.faas-current.js`; export const loadFaasFiles = () => { loadStyle(`${base}/blocks/faas/faas.css`); @@ -236,19 +237,19 @@ const beforeSubmitCallback = () => { const email = document.querySelector('.FaaS-1 input'); const country = document.querySelector('.FaaS-14 select'); - fetch('https://us-central1-adobe---aa-university.cloudfunctions.net/register', { + fetch('https://us-central1-adobe---aa-university.cloudfunctions.net/register', { method: 'POST', body: JSON.stringify({ first_name: firstName.value, last_name: lastName.value, email: email.value, university: 'none', - country: country.value - }) + country: country.value, + }), }) - .catch((error) => { - console.error('AA Sandbox Error:', error); - }); + .catch((error) => { + console.error('AA Sandbox Error:', error); + }); } }; /* c8 ignore stop */ @@ -258,25 +259,13 @@ export const makeFaasConfig = (targetState) => { state = defaultState; return state; } - - let url = targetState.d; - let destinationURL = ''; - try { - // checking if URL is absolute. - new URL(url); - destinationURL = targetState.d; - } - catch (e) { - // in case of relative: - destinationURL = window.location.origin + targetState.d; - } const config = { multicampaignradiostyle: targetState.multicampaignradiostyle ?? false, hidePrepopulated: targetState.hidePrepopulated ?? false, id: targetState.id, l: targetState.l, - d: destinationURL, + d: localizeLink(targetState.d), as: targetState.as, ar: targetState.ar, pc: { @@ -301,8 +290,8 @@ export const makeFaasConfig = (targetState) => { 149: '', }, }, - e: { - afterYiiLoadedCallback, + e: { + afterYiiLoadedCallback, beforeSubmitCallback, }, style_backgroundTheme: targetState.style_backgroundTheme || 'white', @@ -329,7 +318,7 @@ export const makeFaasConfig = (targetState) => { if (targetState.q103) { Object.assign(config.q, { 103: { c: targetState.q103 } }); } - + return config; }; @@ -362,8 +351,12 @@ export const initFaas = (config, targetEl) => { const formEl = createTag('div', { class: 'faas-form-wrapper' }); if (state.complete) { if (state.js) { - Object.keys(state.js).forEach((key) => { - state[key] = state.js[key]; + Object.keys(state.js).forEach((key) => { + if (key === 'd') { + state[key] = localizeLink(state.js[key]); + } else { + state[key] = state.js[key]; + } }); delete state.js; } diff --git a/libs/blocks/featured-article/featured-article.css b/libs/blocks/featured-article/featured-article.css index 9a23a937a0..3609db5823 100644 --- a/libs/blocks/featured-article/featured-article.css +++ b/libs/blocks/featured-article/featured-article.css @@ -38,6 +38,10 @@ padding-right: 0; } +.featured-article-card-category { + min-height: 15px; +} + .featured-article-card-category, .featured-article-card-category a, .featured-article-card-date { @@ -53,7 +57,7 @@ .featured-article-card-body h3 { font-size: var(--type-heading-l-size); color: var(--text-color); - line-height: var(--type-heading-l-lh); + line-height: var(--type-heading-base-lh); margin: 0; margin-bottom: 1rem; overflow: hidden; diff --git a/libs/blocks/featured-article/featured-article.js b/libs/blocks/featured-article/featured-article.js index 9b93bb9dd4..e14731525a 100644 --- a/libs/blocks/featured-article/featured-article.js +++ b/libs/blocks/featured-article/featured-article.js @@ -1,5 +1,18 @@ import { getMetadata, createTag, getConfig } from '../../utils/utils.js'; -import fetchTaxonomy from '../../scripts/taxonomy.js'; + +async function createCategoryLink(el, category = 'News') { + const promises = [import('../../scripts/taxonomy.js'), import('../../utils/helpers.js')]; + Promise.all(promises).then(async ([taxonomyMod, helpersMod]) => { + const fetchTaxonomy = taxonomyMod.default; + const { updateLinkWithLangRoot } = helpersMod; + const config = getConfig(); + const taxonomyRoot = config.taxonomyRoot || '/topics'; + const taxonomy = await fetchTaxonomy(config, taxonomyRoot); + const categoryTaxonomy = taxonomy.get(category); + const categoryLink = createTag('a', { href: updateLinkWithLangRoot(categoryTaxonomy?.link) }, categoryTaxonomy?.name); + el.append(categoryLink); + }); +} export default async function init(el) { const a = el.querySelector('a'); @@ -18,16 +31,15 @@ export default async function init(el) { const html = await resp.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); - const category = getMetadata('article:tag', doc); - - // load taxonomy to get link of article "category" - const taxonomy = await fetchTaxonomy(getConfig(), '/topics'); - const categoryTaxonomy = taxonomy.get(category); - const pic = doc.body.querySelector('picture'); + const img = pic.querySelector('img'); + img.removeAttribute('loading'); const featuredImg = createTag('div', { class: 'featured-article-card-image' }, pic); - const categoryLink = createTag('a', { href: categoryTaxonomy.link }, categoryTaxonomy.name); - const categoryEl = createTag('div', { class: 'featured-article-card-category' }, categoryLink); + const categoryEl = createTag('div', { class: 'featured-article-card-category' }); + img.addEventListener('load', () => { + // Load category link after block has been displayed to speed up LCP + createCategoryLink(categoryEl, getMetadata('article:tag', doc)); + }); const text = doc.body.querySelector('h1, h2, h3').textContent; const title = createTag('h3', null, text); const body = createTag('div', { class: 'featured-article-card-body' }); diff --git a/libs/blocks/figure/figure.css b/libs/blocks/figure/figure.css index f0d136ca1d..28075d0fb4 100644 --- a/libs/blocks/figure/figure.css +++ b/libs/blocks/figure/figure.css @@ -13,6 +13,10 @@ max-height: 600px; } +.figure.full-height img { + max-height: unset; +} + .figure-list { display: flex; flex-direction: column; @@ -40,10 +44,6 @@ width: calc(100% / 2 - 16px); } - .figure-list-2 .figure:last-of-type { - margin-left: 32px; - } - .figure-list-3 .figure { margin: 0; width: calc(100% / 3 - 22px); @@ -53,6 +53,16 @@ margin-right: 32px; margin-left: 32px; } + + .figure-list-2 .figure:last-of-type { + margin-left: 32px; + } + + html[dir='rtl'] .figure-list-2 .figure:last-of-type { + margin-left: auto; + margin-right: 32px; + } + } @media (min-width: 700px) { diff --git a/libs/blocks/figure/figure.js b/libs/blocks/figure/figure.js index bf2be0c585..2674184e80 100644 --- a/libs/blocks/figure/figure.js +++ b/libs/blocks/figure/figure.js @@ -1,3 +1,5 @@ +import { applyHoverPlay, getVideoAttrs } from '../../utils/decorate.js'; + function buildCaption(pEl) { const figCaptionEl = document.createElement('figcaption'); pEl.classList.add('caption'); @@ -11,16 +13,34 @@ export function buildFigure(blockEl) { Array.from(blockEl.children).forEach((child) => { const clone = child.cloneNode(true); // picture, video, or embed link is NOT wrapped in P tag - if (clone.nodeName === 'PICTURE' || clone.nodeName === 'VIDEO' || clone.nodeName === 'A') { + if (clone.nodeName === 'PICTURE' || clone.nodeName === 'VIDEO' || clone.nodeName === 'A' + || (clone.nodeName === 'SPAN' && clone.classList.contains('modal-img-link'))) { figEl.prepend(clone); } else { // content wrapped in P tag(s) + const imageVideo = clone.querySelector('.modal-img-link'); + if (imageVideo) { + figEl.prepend(imageVideo); + } const picture = clone.querySelector('picture'); if (picture) { figEl.prepend(picture); } - const video = clone.querySelector('video'); + let video = clone.querySelector('video'); + const videoLink = clone.querySelector('a[href*=".mp4"]'); + if (videoLink) { + const { href, hash } = videoLink; + const attrs = getVideoAttrs(hash); + const videoElem = ``; + videoLink.insertAdjacentHTML('afterend', videoElem); + videoLink.remove(); + video = clone.querySelector('video'); + } if (video) { + video.removeAttribute('data-mouseevent'); + applyHoverPlay(video); figEl.prepend(video); } const caption = clone.querySelector('em'); diff --git a/libs/blocks/footer/footer.css b/libs/blocks/footer/footer.css index 76eea4f320..64c6d696be 100644 --- a/libs/blocks/footer/footer.css +++ b/libs/blocks/footer/footer.css @@ -98,6 +98,7 @@ footer a:not(:any-link) { content: '\2228'; position: relative; left: -10px; + right: -10px; transform: scaleX(1.5); font-size: 12px; } @@ -106,6 +107,7 @@ footer a:not(:any-link) { content: '\2227'; position: relative; left: -10px; + right: -10px; transform: scaleX(1.5); font-size: 12px; } @@ -167,6 +169,10 @@ footer a:not(:any-link) { margin-right: 18px; } +[dir="rtl"] .footer-social-icon:not(:first-of-type) { + margin-right: 18px; +} + footer .footer-social a { display: block; height: 20px; @@ -327,6 +333,11 @@ footer .footer-region-button.inline-dialog-active svg.icon-chevron-down { margin-right: 3px; } +[dir="rtl"] .footer-privacy-link img { + margin-right: 0; + margin-left: 3px; +} + .footer-privacy-link:not(:last-child)::after { content: '/'; margin-left: 6px; diff --git a/libs/blocks/fragment-personalization/fragment-personalization.css b/libs/blocks/fragment-personalization/fragment-personalization.css new file mode 100644 index 0000000000..4d20d4b509 --- /dev/null +++ b/libs/blocks/fragment-personalization/fragment-personalization.css @@ -0,0 +1,13 @@ +@import '../columns/columns.css'; + +.columns > .row { + border-bottom: 1px solid #808080; +} + +.columns > .row:not(:first-of-type):hover { + background-color: #808588; +} + +.columns > .row:first-of-type { + border-top: 1px solid #808080; +} diff --git a/libs/blocks/fragment-personalization/fragment-personalization.js b/libs/blocks/fragment-personalization/fragment-personalization.js new file mode 100644 index 0000000000..24f2b2176a --- /dev/null +++ b/libs/blocks/fragment-personalization/fragment-personalization.js @@ -0,0 +1,9 @@ +import columns from '../columns/columns.js'; + +// TODO: add support for displaying personalization in fragment previews + +export default function init(el) { + el.classList.add('columns', 'contained'); + columns(el); + el.insertAdjacentHTML('afterbegin', '

    Fragment Personalization (info only):

    '); +} diff --git a/libs/blocks/fragment/fragment.js b/libs/blocks/fragment/fragment.js index 9f14242b85..c509871db8 100644 --- a/libs/blocks/fragment/fragment.js +++ b/libs/blocks/fragment/fragment.js @@ -1,9 +1,12 @@ -import { createTag, loadArea, localizeLink } from '../../utils/utils.js'; +import { createTag, getConfig, loadArea, localizeLink } from '../../utils/utils.js'; import Tree from '../../utils/tree.js'; const fragMap = {}; -const removeHash = (url) => url?.endsWith('#_dnt') ? url : url?.split('#')[0]; +const removeHash = (url) => { + const urlNoHash = url.split('#')[0]; + return url.includes('#_dnt') ? `${urlNoHash}#_dnt` : urlNoHash; +}; const isCircularRef = (href) => [...Object.values(fragMap)] .some((tree) => { @@ -28,31 +31,74 @@ const updateFragMap = (fragment, a, href) => { } }; +const setManifestIdOnChildren = (sections, manifestId) => { + [...sections[0].children].forEach( + (child) => (child.dataset.manifestId = manifestId), + ); +}; + +const insertInlineFrag = (sections, a) => { + // Inline fragments only support one section, other sections are ignored + const fragChildren = [...sections[0].children]; + if (a.parentElement.nodeName === 'DIV' && !a.parentElement.attributes.length) { + a.parentElement.replaceWith(...fragChildren); + } else { + a.replaceWith(...fragChildren); + } +}; + export default async function init(a) { - const relHref = localizeLink(a.href); + const { expFragments } = getConfig(); + let relHref = localizeLink(a.href); + let inline = false; + if (expFragments?.[relHref]) { + a.href = expFragments[relHref]; + relHref = expFragments[relHref]; + } if (isCircularRef(relHref)) { window.lana?.log(`ERROR: Fragment Circular Reference loading ${a.href}`); return; } + if (a.href.includes('#_inline')) { + inline = true; + a.href = a.href.replace('#_inline', ''); + } const resp = await fetch(`${a.href}.plain.html`); - if (resp.ok) { - const html = await resp.text(); - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - const sections = doc.querySelectorAll('body > div'); - if (sections.length > 0) { - const fragment = createTag('div', { class: 'fragment', 'data-path': relHref }); - fragment.append(...sections); - updateFragMap(fragment, a, relHref); + if (!resp.ok) { + window.lana?.log(`Could not get fragment: ${a.href}.plain.html`); + return; + } + + const html = await resp.text(); + const doc = new DOMParser().parseFromString(html, 'text/html'); + const sections = doc.querySelectorAll('body > div'); + + if (!sections.length) { + window.lana?.log(`Could not make fragment: ${a.href}.plain.html`); + return; + } - a.parentElement.replaceChild(fragment, a); + const fragment = createTag('div', { class: 'fragment', 'data-path': relHref }); - await loadArea(fragment); + if (!inline) { + fragment.append(...sections); + } + + updateFragMap(fragment, a, relHref); + + if (a.dataset.manifestId) { + if (inline) { + setManifestIdOnChildren(sections, a.dataset.manifestId); } else { - window.lana?.log(`Could not make fragment: ${a.href}.plain.html`); + fragment.dataset.manifestId = a.dataset.manifestId; } + } + + if (inline) { + insertInlineFrag(sections, a); } else { - window.lana?.log(`Could not get fragment: ${a.href}.plain.html`); + a.parentElement.replaceChild(fragment, a); + await loadArea(fragment); } } diff --git a/libs/blocks/gist/gist.css b/libs/blocks/gist/gist.css new file mode 100644 index 0000000000..643701d737 --- /dev/null +++ b/libs/blocks/gist/gist.css @@ -0,0 +1,3 @@ +.gist.link-block { + display: none; +} diff --git a/libs/blocks/gist/gist.js b/libs/blocks/gist/gist.js new file mode 100644 index 0000000000..1b4f28a80d --- /dev/null +++ b/libs/blocks/gist/gist.js @@ -0,0 +1,29 @@ +import { loadStyle } from '../../utils/utils.js'; + +const jsonpGist = (url, callback) => { + // Setup a unique name that cane be called & destroyed + const callbackName = `jsonp_${Math.round(100000 * Math.random())}`; + + const script = document.createElement('script'); + script.src = `${url}?callback=${callbackName}`; + + // The function the script will call + window[callbackName] = (data) => { + delete window[callbackName]; + document.body.removeChild(script); + callback(data); + }; + + document.body.appendChild(script); +}; + +export default function init(el) { + const { href } = el; + const url = `${href}on`; + + jsonpGist(url, (data) => { + loadStyle(data.stylesheet); + el.insertAdjacentHTML('afterend', data.div); + el.remove(); + }); +} diff --git a/libs/blocks/global-footer/global-footer.css b/libs/blocks/global-footer/global-footer.css index 712814334e..0ffa9efb09 100644 --- a/libs/blocks/global-footer/global-footer.css +++ b/libs/blocks/global-footer/global-footer.css @@ -34,6 +34,10 @@ padding: 0; } +.feds-footer-icons { + display: none; +} + /* Menu */ .feds-footer-wrapper .feds-menu-headline { font-size: 14px; diff --git a/libs/blocks/global-footer/global-footer.js b/libs/blocks/global-footer/global-footer.js index 030aabb0dd..1757c5aaae 100644 --- a/libs/blocks/global-footer/global-footer.js +++ b/libs/blocks/global-footer/global-footer.js @@ -1,5 +1,4 @@ /* eslint-disable no-async-promise-executor */ -/* eslint-disable no-restricted-syntax */ import { decorateAutoBlock, getConfig, @@ -13,6 +12,7 @@ import { getExperienceName, loadDecorateMenu, getFedsPlaceholderConfig, + getAnalyticsValue, loadBaseStyles, yieldToMain, } from '../global-navigation/utilities/utilities.js'; @@ -31,7 +31,6 @@ class Footer { constructor(footerEl, contentUrl) { this.footerEl = footerEl; this.contentUrl = contentUrl; - this.isDesktop = window.matchMedia('(min-width: 900px)'); this.elements = {}; this.init(); @@ -70,6 +69,16 @@ class Footer { // TODO: log to LANA if Footer content could not be found if (!this.body) return; + // TODO: revisit region picker and social links decoration logic + const regionAnchor = this.body.querySelector('.region-selector a'); + if (regionAnchor?.href) { + regionAnchor.setAttribute('href', `${regionAnchor.getAttribute('href')}#_dnt#_dnb`); + } + const socialLinks = document.querySelectorAll('.social a'); + socialLinks.forEach((socialLink) => { + socialLink.setAttribute('href', `${socialLink.getAttribute('href')}#_dnb`); + }); + decorateLinks(this.body); // Order is important, decorateFooter makes use of elements // which have already been created in previous steps @@ -77,6 +86,7 @@ class Footer { loadBaseStyles, this.decorateGrid, this.decorateProducts, + this.loadIcons, this.decorateRegionPicker, this.decorateSocial, this.decoratePrivacy, @@ -88,9 +98,7 @@ class Footer { await task(); } - this.setHeadlineAttributes(); - this.addEventListeners(); - this.footerEl.setAttribute('daa-lh', `gnav|${getExperienceName()}|footer`); + this.footerEl.setAttribute('daa-lh', `gnav|${getExperienceName()}|footer|${document.body.dataset.mep}`); this.footerEl.append(this.elements.footer); }; @@ -101,7 +109,7 @@ class Footer { if (!html) return null; - const parsedHTML = await replaceText(html, getFedsPlaceholderConfig(), /{{(.*?)}}/g, 'feds'); + const parsedHTML = await replaceText(html, getFedsPlaceholderConfig(), undefined, 'feds'); try { return new DOMParser().parseFromString(parsedHTML, 'text/html').body; @@ -143,6 +151,13 @@ class Footer { return this.elements.footerMenu; }; + loadIcons = async () => { + const file = await fetch(`${base}/blocks/global-footer/icons.svg`); + const content = await file.text(); + const elem = toFragment``; + this.footerEl.append(elem); + }; + decorateProducts = async () => { this.elements.featuredProducts = ''; @@ -195,14 +210,17 @@ class Footer { aria-haspopup="true" role="button"> - + ${regionPickerTextElem} `; - this.elements.regionPicker = toFragment`
    + const regionPickerWrapperClass = 'feds-regionPicker-wrapper'; + this.elements.regionPicker = toFragment`
    ${regionPickerElem}
    `; + const isRegionPickerExpanded = () => regionPickerElem.getAttribute('aria-expanded') === 'true'; + // Note: the region picker currently works only with Milo modals/fragments; // in the future we'll need to update this for non-Milo consumers if (url.hash !== '') { @@ -211,7 +229,6 @@ class Footer { await loadBlock(regionPickerElem); // load modal logic and styles // 'decorateAutoBlock' logic replaces class name entirely, need to add it back regionPickerElem.classList.add(regionPickerClass); - const isRegionPickerExpanded = () => regionPickerElem.getAttribute('aria-expanded') === 'true'; regionPickerElem.addEventListener('click', () => { if (!isRegionPickerExpanded()) { regionPickerElem.setAttribute('aria-expanded', 'true'); @@ -235,6 +252,13 @@ class Footer { const isDialogActive = regionPickerElem.getAttribute('aria-expanded') === 'true'; regionPickerElem.setAttribute('aria-expanded', !isDialogActive); }); + // Close region picker dropdown on outside click + document.addEventListener('click', (e) => { + if (isRegionPickerExpanded() + && !e.target.closest(`.${regionPickerWrapperClass}`)) { + regionPickerElem.setAttribute('aria-expanded', false); + } + }); } return this.regionPicker; @@ -245,17 +269,21 @@ class Footer { const socialBlock = this.body.querySelector('.social'); if (!socialBlock) return this.elements.social; - const socialElem = toFragment`
      `; + const socialElem = toFragment`
        `; - CONFIG.socialPlatforms.forEach((platform) => { + CONFIG.socialPlatforms.forEach((platform, index) => { const link = socialBlock.querySelector(`a[href*="${platform}"]`); if (!link) return; - // Add '#_dnb' to the 'href' value, since certain social media platforms are also blocks const iconElem = toFragment`
      • - + - +
      • `; @@ -285,15 +313,18 @@ class Footer { // Add Ad Choices icon const adChoicesElem = privacyContent.querySelector('a[href*="#interest-based-ads"]'); adChoicesElem?.prepend(toFragment` - + `); - this.elements.legal = toFragment``; + this.elements.legal = toFragment``; while (privacyContent.children.length) { const privacySection = privacyContent.firstElementChild; privacySection.classList.add('feds-footer-privacySection'); - privacySection.querySelectorAll('a').forEach((link) => link.classList.add('feds-footer-privacyLink')); + privacySection.querySelectorAll('a').forEach((link, index) => { + link.classList.add('feds-footer-privacyLink'); + link.setAttribute('daa-ll', getAnalyticsValue(link.textContent, index + 1)); + }); this.elements.legal.append(privacySection); } @@ -313,36 +344,8 @@ class Footer {
        `; - decorateLinks(this.elements.footer); - return this.elements.footer; }; - - setHeadlineAttributes = () => { - if (!this.elements?.headlines) return; - - if (this.isDesktop.matches) { - this.elements.headlines.forEach((headline) => { - headline.setAttribute('role', 'heading'); - headline.removeAttribute('tabindex'); - headline.setAttribute('aria-level', 2); - headline.removeAttribute('aria-haspopup', true); - headline.removeAttribute('aria-expanded', false); - }); - } else { - this.elements.headlines.forEach((headline) => { - headline.setAttribute('role', 'button'); - headline.setAttribute('tabindex', 0); - headline.removeAttribute('aria-level'); - headline.setAttribute('aria-haspopup', true); - headline.setAttribute('aria-expanded', false); - }); - } - }; - - addEventListeners = () => { - this.isDesktop.addEventListener('change', this.setHeadlineAttributes); - }; } export default function init(block) { diff --git a/libs/blocks/global-footer/icons.svg b/libs/blocks/global-footer/icons.svg index 9eeb429cd2..d9156ef35b 100644 --- a/libs/blocks/global-footer/icons.svg +++ b/libs/blocks/global-footer/icons.svg @@ -1,51 +1,51 @@ - + - + - + - + - + - + - + - + - + - + - + - + diff --git a/libs/blocks/global-navigation/base.css b/libs/blocks/global-navigation/base.css index ff911a8610..2812eb6fca 100644 --- a/libs/blocks/global-navigation/base.css +++ b/libs/blocks/global-navigation/base.css @@ -34,7 +34,9 @@ display: flex; align-items: center; padding: 12px; + border: none; color: var(--feds-color-link--light); + font: inherit; white-space: nowrap; flex-shrink: 0; } @@ -48,11 +50,20 @@ column-gap: 15px; } +.feds-navLink--blue { + color: var(--feds-color-link--hover--light); +} + .feds-navLink--hoverCaret { position: relative; padding-right: 32px; } +.feds-navLink--hoverCaret:hover, +.feds-navLink--hoverCaret:focus { + color: var(--feds-color-link--light); +} + .feds-navLink--hoverCaret:after { position: absolute; right: 18px; @@ -68,6 +79,7 @@ transform: rotateZ(45deg); transition: transform 0.1s ease; content: ""; + box-sizing: content-box; } [dir = "rtl"] .feds-navLink--hoverCaret { @@ -93,22 +105,43 @@ padding: 0 12px; } + .feds-topnav--overflowing .feds-navLink, + .feds-topnav--overflowing .feds-navLink--hoverCaret, + [dir = "rtl"] .feds-topnav--overflowing .feds-navLink--hoverCaret { + padding: 0 8px; + } + .feds-navLink--hoverCaret { position: static; } - + + .feds-navLink--hoverCaret:hover, + .feds-navLink--hoverCaret:focus { + background-color: var(--feds-background-popup--light); + } + .feds-navLink--hoverCaret:after { position: static; margin-top: 0; margin-left: 5px; } + .feds-topnav--overflowing .feds-navLink--hoverCaret:after { + margin-left: 3px; + } + [dir = "rtl"] .feds-navLink--hoverCaret:after { margin-left: 0; /* Margin different than LTR due to transform origin effect */ margin-right: 7px; } + [dir = "rtl"] .feds-topnav--overflowing .feds-navLink--hoverCaret:after { + margin-left: 0; + /* Margin different than LTR due to transform origin effect */ + margin-right: 5px; + } + .feds-navLink-image { display: flex; } diff --git a/libs/blocks/global-navigation/features/appLauncher/appLauncher.js b/libs/blocks/global-navigation/features/appLauncher/appLauncher.js deleted file mode 100644 index e54e2ca1e7..0000000000 --- a/libs/blocks/global-navigation/features/appLauncher/appLauncher.js +++ /dev/null @@ -1,64 +0,0 @@ -import { createTag, localizeLink } from '../../../../utils/utils.js'; - -const WAFFLE_ICON = ''; - -function decorateAppsMenu(profileEl, appsDom, toggle) { - const appsNavItem = createTag('div', { class: 'gnav-navitem app-launcher has-menu', 'da-ll': 'App Launcher' }); - const appsUl = createTag('ul', { class: 'apps' }); - const appsDropDown = createTag('div', { id: 'navmenu-apps', class: 'app-menu gnav-navitem-menu' }, appsUl); - const appButton = createTag( - 'button', - { - class: 'gnav-applications-button', - 'aria-expanded': false, - 'aria-controls': 'navmenu-apps', - 'daa-ll': 'App Launcher', - 'daa-lh': 'header|Open', - }, - WAFFLE_ICON, - ); - - appsDom.forEach((li, idx) => { - const image = li.querySelector('picture'); - const anchor = li.querySelector('a'); - const title = anchor?.textContent; - const link = createTag('a', { - class: 'link-block', - href: anchor.href, - role: 'link', - 'aria-label': title, - 'daa-ll': `${title}-${idx + 1}`, - rel: 'noopener', - target: '_blank', - }); - - anchor.href = localizeLink(anchor.href); - li.replaceChildren(); - link.append(image, title); - li.appendChild(link); - appsUl.append(li); - }); - - appButton.addEventListener('click', () => { toggle(appsNavItem); }); - appsDropDown.append(appsUl); - appsNavItem.append(appButton, appsDropDown); - profileEl.after(appsNavItem); - return appsNavItem; -} - -async function appLauncher(profileEl, appLauncherBlock, toggle) { - const appsLink = appLauncherBlock.querySelector('a'); - appsLink.href = localizeLink(appsLink.href); - - const path = appsLink.href; - const resp = await fetch(`${path}.plain.html`); - if (!resp.ok) return null; - - const html = await resp.text(); - const parser = new DOMParser(); - const doc = parser.parseFromString(html, 'text/html'); - const appsDom = doc.querySelectorAll('body > div > ul > li'); - return decorateAppsMenu(profileEl, appsDom, toggle); -} - -export default { appLauncher }; diff --git a/libs/blocks/global-navigation/features/breadcrumbs/breadcrumbs.js b/libs/blocks/global-navigation/features/breadcrumbs/breadcrumbs.js new file mode 100644 index 0000000000..d3b3e43da5 --- /dev/null +++ b/libs/blocks/global-navigation/features/breadcrumbs/breadcrumbs.js @@ -0,0 +1,111 @@ +import { getMetadata, getConfig } from '../../../../utils/utils.js'; +import { toFragment, lanaLog } from '../../utilities/utilities.js'; + +const metadata = { + seo: 'breadcrumbs-seo', + seoLegacy: 'breadcrumb-seo', + fromFile: 'breadcrumbs-from-file', + showCurrent: 'breadcrumbs-show-current-page', + hiddenEntries: 'breadcrumbs-hidden-entries', + pageTitle: 'breadcrumbs-page-title', + base: 'breadcrumbs-base', + fromUrl: 'breadcrumbs-from-url', +}; + +const setBreadcrumbSEO = (breadcrumbs) => { + const seoDisabled = (getMetadata(metadata.seo) || getMetadata(metadata.seoLegacy)) === 'off'; + if (seoDisabled || !breadcrumbs) return; + const breadcrumbsSEO = { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [], + }; + breadcrumbs.querySelectorAll('ul > li').forEach((item, idx) => { + const link = item.querySelector('a'); + breadcrumbsSEO.itemListElement.push({ + '@type': 'ListItem', + position: idx + 1, + name: link ? link.innerText.trim() : item.innerText.trim(), + item: link?.href, + }); + }); + const script = toFragment``; + document.head.append(script); +}; + +const createBreadcrumbs = (element) => { + if (!element) return null; + const ul = element.querySelector('ul'); + const pageTitle = getMetadata(metadata.pageTitle); + if (pageTitle || getMetadata(metadata.showCurrent) === 'on') { + ul.append(toFragment` +
      • + ${pageTitle || document.title} +
      • + `); + } + + const hiddenEntries = getMetadata(metadata.hiddenEntries) + ?.toLowerCase() + .split(',') + .map((item) => item.trim()) || []; + + ul.querySelectorAll('li').forEach((li) => { + if (hiddenEntries.includes(li.innerText?.toLowerCase().trim())) li.remove(); + }); + + const breadcrumbs = toFragment` +
        + +
        + `; + ul.querySelector('li:last-of-type')?.setAttribute('aria-current', 'page'); + return breadcrumbs; +}; + +const createWithBase = async (el) => { + const element = el || toFragment`
          `; + const url = getMetadata(metadata.base); + if (!url) return null; + try { + const resp = await fetch(`${url}.plain.html`); + const text = await resp.text(); + const base = new DOMParser().parseFromString(text, 'text/html').body; + element.querySelector('ul')?.prepend(...base.querySelectorAll('li')); + return createBreadcrumbs(element); + } catch (e) { + lanaLog({ e, message: 'Breadcrumbs failed fetching base' }); + return null; + } +}; + +const fromUrl = () => { + if (getMetadata(metadata.fromUrl) !== 'on') return null; + const list = toFragment`
            `; + const paths = document.location.pathname + .replace((getConfig().locale?.prefix || ''), '') + .split('/') + .filter((n) => n); + + for (let i = 0; i < paths.length; i += 1) { + list.append(toFragment` +
          • + ${paths[i].replaceAll('-', ' ')} +
          • + `); + } + return createBreadcrumbs(toFragment`
            ${list}
            `); +}; + +export default async function init(el) { + try { + const breadcrumbsEl = await createWithBase(el) || createBreadcrumbs(el) || fromUrl(); + setBreadcrumbSEO(breadcrumbsEl); + return breadcrumbsEl; + } catch (e) { + lanaLog({ e, message: 'Breadcrumbs failed rendering' }); + return null; + } +} diff --git a/libs/blocks/global-navigation/features/profile/dropdown.css b/libs/blocks/global-navigation/features/profile/dropdown.css index 7e7e60d30a..3475affa70 100644 --- a/libs/blocks/global-navigation/features/profile/dropdown.css +++ b/libs/blocks/global-navigation/features/profile/dropdown.css @@ -19,6 +19,7 @@ line-height: 1; white-space: nowrap; z-index: 1; + transform: translate3d(0,0,0); /* Fix Safari issues w/ position: sticky */ } [dir = "rtl"] .feds-profile-menu { diff --git a/libs/blocks/global-navigation/features/profile/dropdown.js b/libs/blocks/global-navigation/features/profile/dropdown.js index 6c41fb5eec..59c5a6a30b 100644 --- a/libs/blocks/global-navigation/features/profile/dropdown.js +++ b/libs/blocks/global-navigation/features/profile/dropdown.js @@ -1,5 +1,5 @@ import { getConfig } from '../../../../utils/utils.js'; -import { toFragment, getFedsPlaceholderConfig, trigger, closeAllDropdowns } from '../../utilities/utilities.js'; +import { toFragment, getFedsPlaceholderConfig, trigger, closeAllDropdowns, logErrorFor } from '../../utilities/utilities.js'; import { replaceKeyArray } from '../../../../features/placeholders.js'; const getLanguage = (ietfLocale) => { @@ -54,7 +54,7 @@ class ProfileDropdown { this.sections = sections; this.openOnInit = openOnInit; this.localMenu = rawElem.querySelector('h5')?.parentElement; - this.init(); + logErrorFor(this.init.bind(this), 'ProfileDropdown.init()'); } async init() { @@ -78,8 +78,6 @@ class ProfileDropdown { this.placeholders.manageEnterprise, this.placeholders.profileAvatar, ], - // TODO: sanity checks if the user is logged in and mandatory properties are set. - // If not, add logs providing guidance for developers { displayName: this.profileData.displayName, email: this.profileData.email }, ] = await Promise.all([ replaceKeyArray( @@ -103,15 +101,15 @@ class ProfileDropdown { // historically we shrunk the font size and displayed the account name on two lines; // the email had some special logic as well; // for MVP, we took a simpler approach ("Some very long name, very l...") - this.avatarElem = toFragment`${this.placeholders.profileAvatar}`; return toFragment`
            - `; - // TODO consumers might want to execute their own logic before a sign out - // we might want to provide them a way to do so here signOutLink.addEventListener('click', (e) => { e.preventDefault(); + window.dispatchEvent(new Event('feds:signOut')); window.adobeIMS.signOut(); }); @@ -174,11 +171,11 @@ class ProfileDropdown { } addEventListeners() { - this.buttonElem.addEventListener('click', () => trigger({ element: this.buttonElem })); + this.buttonElem.addEventListener('click', (e) => trigger({ element: this.buttonElem, event: e })); this.buttonElem.addEventListener('keydown', (e) => e.code === 'Escape' && closeAllDropdowns()); this.dropdown.addEventListener('keydown', (e) => e.code === 'Escape' && closeAllDropdowns()); - this.avatarElem.addEventListener('click', (event) => { - event.preventDefault(); + this.avatarElem.addEventListener('click', (e) => { + e.preventDefault(); window.location.assign(this.avatarElem.dataset?.url); }); } diff --git a/libs/blocks/global-navigation/features/search/gnav-search.css b/libs/blocks/global-navigation/features/search/gnav-search.css index 0d99dd53fd..6d4d4f7f0e 100644 --- a/libs/blocks/global-navigation/features/search/gnav-search.css +++ b/libs/blocks/global-navigation/features/search/gnav-search.css @@ -28,6 +28,7 @@ border-radius: 3px; background-color: transparent; box-shadow: none; + margin: 0; } [dir = "rtl"] .feds-search-input { @@ -131,7 +132,8 @@ top: 100%; left: 0; right: 0; - border-top: 1px solid var(--feds-borderColor--light); + /* Relative to nav, not trigger, ensure top border is visible */ + margin-top: 1px; display: none; z-index: 1; } diff --git a/libs/blocks/global-navigation/features/search/gnav-search.js b/libs/blocks/global-navigation/features/search/gnav-search.js index 993084774d..224252e9c7 100644 --- a/libs/blocks/global-navigation/features/search/gnav-search.js +++ b/libs/blocks/global-navigation/features/search/gnav-search.js @@ -1,11 +1,15 @@ import { toFragment, getFedsPlaceholderConfig, + isDesktop, + setCurtainState, trigger, closeAllDropdowns, + logErrorFor, } from '../../utilities/utilities.js'; import { replaceKeyArray } from '../../../../features/placeholders.js'; import { getConfig } from '../../../../utils/utils.js'; +import { debounce } from '../../../../utils/action.js'; const CONFIG = { suggestions: { @@ -18,17 +22,6 @@ const CONFIG = { }, }; -function debounceCallback(callback, time = 150) { - if (typeof callback !== 'function') return undefined; - - let timeout = null; - - return (...args) => { - clearTimeout(timeout); - timeout = setTimeout(() => callback(...args), time); - }; -} - const { locale } = getConfig(); const [, country = 'US'] = locale.ietf.split('-'); @@ -37,13 +30,11 @@ class Search { this.icon = config.icon; this.trigger = config.trigger; this.parent = this.trigger.closest('.feds-nav-wrapper'); - this.curtain = config.curtain; - this.isDesktop = window.matchMedia('(min-width: 900px)'); const observer = new MutationObserver(() => { this.clearSearchForm(); }); observer.observe(this.trigger, { attributeFilter: ['aria-expanded'] }); - this.init(); + logErrorFor(this.init.bind(this), 'Search has failed loading'); } async init() { @@ -99,7 +90,7 @@ class Search { // Pressing ESC when input has value resets the results if (this.input.value.length) { this.clearSearchForm(); - } else if (this.isDesktop.matches) { + } else if (isDesktop.matches) { closeAllDropdowns(); this.trigger.focus(); } @@ -126,11 +117,9 @@ class Search { // Switching between a mobile and a desktop view // should close the search dropdown - this.isDesktop.addEventListener('change', () => { + isDesktop.addEventListener('change', () => { closeAllDropdowns(); }); - - // TODO: search menu should close on scroll, but this should happen from the general Menu logic } getSuggestions(query = this.query) { @@ -145,7 +134,7 @@ class Search { }); } - onSearchInput = debounceCallback(() => { + onSearchInput = debounce(() => { const query = this.getQuery(); if (!query.length) { @@ -185,7 +174,7 @@ class Search { this.parent.classList.remove(CONFIG.selectors.hasResults); } }); - }); + }, 150); getQuery() { const query = this.input.value.trim(); @@ -274,15 +263,17 @@ class Search { } focusInput() { - if (this.isDesktop.matches) { + if (isDesktop.matches) { this.input.focus(); } } toggleDropdown() { + if (!isDesktop.matches) return; + const hasBeenOpened = trigger({ element: this.trigger }); if (hasBeenOpened) { - this.curtain.classList.add('is-open'); + setCurtainState(true); this.focusInput(); } else { this.clearSearchForm(); diff --git a/libs/blocks/global-navigation/global-navigation.css b/libs/blocks/global-navigation/global-navigation.css index 6dc2039c51..c0cc5ee8fd 100644 --- a/libs/blocks/global-navigation/global-navigation.css +++ b/libs/blocks/global-navigation/global-navigation.css @@ -13,9 +13,9 @@ } /* Curtain */ -.global-navigation.is-open .feds-curtain, -.feds-curtain.is-open { +.feds-curtain { position: fixed; + display: none; inset: 0; background: rgb(0 0 0 / .5); -webkit-backdrop-filter: blur(1em); @@ -23,13 +23,19 @@ z-index: 1; } +.feds-curtain--open { + display: flex; +} + /* General */ header.global-navigation { position: sticky; top: 0; - z-index: 1; + z-index: 10; background-color: var(--feds-background-nav--light); visibility: visible; + box-sizing: content-box; + overflow-x: clip; } .feds-topnav-wrapper { @@ -66,7 +72,7 @@ header.global-navigation { right: 0; } -.global-navigation.is-open .feds-nav-wrapper { +.feds-nav-wrapper--expanded { display: flex; } @@ -76,30 +82,64 @@ header.global-navigation { overflow-y: auto; } -/* Brand block */ +/* Hamburger toggle */ +.feds-toggle { + width: 60px; + margin: 0; + padding: 0; + border: none; + background: transparent; + box-shadow: none; + color: #2d2d2d; + cursor: pointer; + font-size: 20px; + font-weight: 300; +} + +.feds-toggle:before { + content: "\2630"; +} + +.feds-toggle[aria-expanded = "true"]:before { + content: "\2715"; +} + +/* Brand and Logo blocks */ .feds-brand-container { display: flex; + flex-shrink: 0; } -.feds-brand { - display: flex; +.feds-brand, +.feds-logo { align-items: center; outline-offset: 2px; padding: 0 var(--feds-gutter); column-gap: 10px; } -.feds-brand-image { +.feds-brand { + display: flex; +} + +.feds-logo { + display: none; +} + +.feds-brand-image, +.feds-logo-image { width: 25px; flex-shrink: 0; } -.feds-brand-image > * { +.feds-brand-image picture, .feds-brand-image img, .feds-brand-image svg, +.feds-logo-image picture, .feds-logo-image img, .feds-logo-image svg { width: 100%; display: block; } -.feds-brand-label { +.feds-brand-label, +.feds-logo-label { flex-shrink: 0; font-weight: 700; font-size: 18px; @@ -135,6 +175,10 @@ header.global-navigation { white-space: nowrap; } +.feds-topnav--overflowing .feds-navItem { + font-size: 13px; +} + .feds-navItem--centered { padding: 12px; } @@ -143,6 +187,27 @@ header.global-navigation { border-bottom: 1px solid var(--feds-borderColor-link--light); } +/* Item with active dropdown */ +.feds-dropdown--active { + position: relative; +} + +.feds-dropdown--active::before { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 2px; + background: var(--feds-color-link--light); + content: ""; + z-index: 1; +} + +[dir = "rtl"] .feds-dropdown--active::before { + right: 0; + left: initial; +} + .feds-popup .feds-navLink:hover, .feds-popup .feds-navLink:focus { background-color: var(--feds-background-link--hover--light); @@ -169,10 +234,18 @@ header.global-navigation { justify-content: center; overflow: visible; white-space: nowrap; - transition: all 130ms ease-out; + transition-property: color, border-color, background-color; + transition-duration: 130ms; + transition-timing-function: ease-out; cursor: pointer; } +.feds-topnav--overflowing .feds-cta { + height: 30px; + padding: 0 10px; + font-size: 14px; +} + .feds-cta--primary { background-color: rgb(20, 115, 230); border-color: rgb(20, 115, 230); @@ -230,8 +303,9 @@ header.global-navigation { } .feds-breadcrumbs { - padding: 0 12px; + padding: 6px 12px; font-size: 12px; + overflow-y: auto; } .feds-breadcrumbs ul { @@ -243,11 +317,37 @@ header.global-navigation { .feds-breadcrumbs li { display: flex; align-items: center; + flex-shrink: 0; +} + +.feds-breadcrumbs li:last-child:not(:first-child) { + padding-right: 12px; +} + +[dir = "rtl"] .feds-breadcrumbs li:last-child:not(:first-child) { + padding-right: 0; + padding-left: 12px; +} + +/* Hide all breadcrumbs except the first and last two */ +.feds-breadcrumbs li:nth-last-child(n+3):not(:first-child) { + display: none; +} + +/* If first breadcrumb is not third to last, add ellipsis after it */ +.feds-breadcrumbs li:first-child:not(:nth-last-child(-n+3)):after { + content: '/\3000…'; + padding: 0 0 0 12px; +} + +[dir = "rtl"] .feds-breadcrumbs li:first-child:not(:nth-last-child(-n+3)):after { + padding: 0 12px 0 0; } .feds-breadcrumbs a, .feds-breadcrumbs [aria-current] { line-height: var(--feds-height-breadcrumbs); + text-transform: capitalize; } .feds-breadcrumbs a { @@ -291,6 +391,14 @@ header.global-navigation { border-radius: var(--feds-radius-utilityIcon); color: #4B4B4B; white-space: nowrap; + border: none; + font: inherit; + text-align: start; + width: 100%; +} + +.feds-signIn:hover { + color: var(--link-hover-color); } .feds-signIn-dropdown { @@ -298,14 +406,13 @@ header.global-navigation { display: none; right: 0; top: 100%; - border: 1px solid #e1e1e1; - border-radius: 0 0 4px 4px; - background: #fafafa; + background-color: var(--feds-background-popup--light); overflow: hidden; - box-shadow: 0 2px 6px -1px rgb(0 0 0 / 10%); + box-shadow: 0 3px 3px 0 rgb(0 0 0 / 20%); line-height: 1.4; white-space: nowrap; z-index: 1; + transform: translate3d(0,0,0); /* Fix Safari issues w/ position: sticky */ } .feds-signIn[aria-expanded = "true"] + .feds-signIn-dropdown { @@ -322,15 +429,30 @@ header.global-navigation { padding: 12px 0; } -.feds-signIn-dropdown li > a { +.feds-signIn-dropdown li > a, +.feds-signIn-dropdown .feds-signIn { display: block; color: var(--feds-color-link--light); padding: 6px 32px; } -.feds-signIn-dropdown li > a:hover { +.feds-signIn-dropdown li > a:hover, +.feds-signIn-dropdown .feds-signIn:hover { color: var(--feds-color-link--hover--light); - background-color: var(--feds-background-link--hover--light);; + background-color: var(--feds-background-link--hover--light); +} + +#feds-googleLogin { + position: absolute; + top: 100%; + right: 0; + z-index: 1; + transform: translate3d(0,0,0); /* Fix Safari issues w/ position: sticky */ +} + +[dir = "rtl"] #feds-googleLogin { + left: 0; + right: auto; } /* Desktop styles */ @@ -342,6 +464,11 @@ header.global-navigation { .feds-topnav-wrapper { border-bottom: 1px solid var(--feds-borderColor--light); + box-sizing: content-box; + } + + .feds-toggle { + display: none; } .feds-nav-wrapper { @@ -351,6 +478,7 @@ header.global-navigation { flex-grow: 1; height: unset; border-bottom: unset; + border-top: unset; justify-content: space-between; background-color: transparent; } @@ -366,6 +494,15 @@ header.global-navigation { display: flex; } + .feds-topnav--overflowing .feds-brand-label:nth-child(2) { + display: none; + } + + /* Item with active dropdown */ + .feds-dropdown--active::before { + content: none; + } + /* Popup */ .feds-popup { position: absolute; @@ -374,6 +511,7 @@ header.global-navigation { padding: 0; z-index: 1; box-shadow: 0 3px 3px 0 rgb(0 0 0 / 20%); + transform: translate3d(0,0,0); /* Fix Safari issues w/ position: sticky */ } [dir = "rtl"] .feds-popup { @@ -405,7 +543,11 @@ header.global-navigation { align-items: center; } - .feds-navItem--section { + .feds-topnav--overflowing .feds-navItem--centered { + padding: 0 8px; + } + + .feds-navItem--section:only-of-type { border-left: 1px solid var(--feds-borderColor--light); border-right: 1px solid var(--feds-borderColor--light); } @@ -414,6 +556,10 @@ header.global-navigation { padding: 0 20px; } + .feds-topnav--overflowing .feds-navItem--section > .feds-navLink { + padding: 0 12px; + } + .feds-navItem:not(:last-child) > .feds-navLink { border-bottom: none; } @@ -464,6 +610,7 @@ header.global-navigation { justify-content: center; border-bottom: unset; box-shadow: 0 3px 2px rgb(142 142 142 / 30%); + transform: translate3d(0,0,0); /* Fix Safari issues w/ position: sticky */ } .feds-breadcrumbs { @@ -473,6 +620,14 @@ header.global-navigation { box-sizing: border-box; } + .feds-breadcrumbs li:nth-last-child(n+3):not(:first-child) { + display: flex; + } + + .feds-breadcrumbs li:first-child:not(:nth-last-child(-n+3)):after { + content: none; + } + .feds-breadcrumbs a:hover { text-decoration: underline; } @@ -485,117 +640,13 @@ header.global-navigation { } } -/* TODO: Old styles, to be refactored */ -.gnav-logo { - display: none; -} - -header button.gnav-toggle { - width: 56px; - justify-self: start; - font-size: 20px; - padding: 0; - border: none; - background: none; -} - -header button.gnav-toggle::after { - content: "\2630"; - font-weight: 700; -} - -header.is-open button.gnav-toggle::after { - content: "\2715"; -} - -/* BEGIN MAINNAV */ -.last-link-blue ul li:last-of-type a { - color: #1473E6; -} -/* END MAINNAV */ - -/* APP LAUNCHER */ - -header .app-launcher button.gnav-applications-button { - overflow: hidden; - background: none; - border: none; - cursor: pointer; - padding: 0 6px 0 2px; - align-items: center; -} - -header .app-launcher button svg { - width: 18px; - height: 18px; - opacity: 0.5; -} - -header .app-launcher button:hover svg { - opacity: 1; -} - -header .app-launcher { - display: none; -} - -/* END APP LAUNCHER */ - -@media (min-width: 900px) { - /* APP LAUNCHER */ - header .app-launcher { - box-sizing: border-box; - display: flex; - justify-content: center; - position: relative; - padding: 0 12px 0 0; - } - - header .app-launcher .apps { - display: flex; - flex-flow: row wrap; - max-height: 80vh; - overflow-y: scroll; - } - - header .app-launcher .apps > li { - width: 33.3333%; - max-width: 33.3333%; - flex: 1 1 auto; - min-height: auto; - } - - header .app-launcher .apps li > a { +/* Desktop styles */ +@media (min-width: 1200px) { + .feds-logo { display: flex; - flex-flow: column; - white-space: unset; - text-align: center; - padding: 16px 0 13px; - min-height: 85px; - justify-content: space-evenly; } - header .app-launcher .apps li > a:hover { - background-color: #f4f4f4; - color: var(--color-black); - } - - header .app-launcher .apps li > a img { - width: 48px; - } - - /* END APP LAUNCHER */ - header button.gnav-toggle { + .feds-topnav--overflowing .feds-logo { display: none; } - - .gnav-logo { - display: flex; - width: 25px; - } - - .gnav-logo::before { - content: ""; - margin-left: var(--feds-gutter); - } } diff --git a/libs/blocks/global-navigation/global-navigation.js b/libs/blocks/global-navigation/global-navigation.js index d1a164f75a..1b41443e59 100644 --- a/libs/blocks/global-navigation/global-navigation.js +++ b/libs/blocks/global-navigation/global-navigation.js @@ -1,10 +1,9 @@ /* eslint-disable no-async-promise-executor */ -/* eslint-disable no-restricted-syntax */ import { getConfig, getMetadata, - loadScript, - localizeLink, + loadIms, + decorateLinks, } from '../../utils/utils.js'; import { toFragment, @@ -16,35 +15,40 @@ import { loadBlock, loadStyles, trigger, + setActiveDropdown, closeAllDropdowns, loadBaseStyles, yieldToMain, + isDesktop, + isTangentToViewport, + setCurtainState, selectors, + logErrorFor, + lanaLog, } from './utilities/utilities.js'; -import { replaceKey } from '../../features/placeholders.js'; +import { replaceKey, replaceKeyArray, replaceText } from '../../features/placeholders.js'; const CONFIG = { icons: { - company: '', + company: '', search: '', }, - selectors: { isOpen: 'is-open' }, delays: { mainNavDropdowns: 800, loadDelayed: 2000, keyboardNav: 8000, }, + features: [ + 'gnav-brand', + 'gnav-promo', + 'search', + 'profile', + 'app-launcher', + 'adobe-logo', + ], }; -function getBlockClasses(className) { - const trimDashes = (str) => str.replace(/(^\s*-)|(-\s*$)/g, ''); - const blockWithVariants = className.split('--'); - const name = trimDashes(blockWithVariants.shift()); - const variants = blockWithVariants.map((v) => trimDashes(v)); - return { name, variants }; -} - // signIn, decorateSignIn and decorateProfileTrigger can be removed if IMS takes over the profile const signIn = () => { if (typeof window.adobeIMS?.signIn !== 'function') return; @@ -58,29 +62,31 @@ const decorateSignIn = async ({ rawElem, decoratedElem }) => { let signInElem; if (!dropdownElem) { - signInElem = toFragment``; + signInElem = toFragment``; signInElem.addEventListener('click', (e) => { e.preventDefault(); signIn(); }); } else { - signInElem = toFragment``; + signInElem = toFragment``; - signInElem.addEventListener('click', () => trigger({ element: signInElem })); + signInElem.addEventListener('click', (e) => trigger({ element: signInElem, event: e })); signInElem.addEventListener('keydown', (e) => e.code === 'Escape' && closeAllDropdowns()); dropdownElem.addEventListener('keydown', (e) => e.code === 'Escape' && closeAllDropdowns()); dropdownElem.classList.add('feds-signIn-dropdown'); - // TODO we don't have a good way of adding config properties to links - const dropdownSignIn = dropdownElem.querySelector('[href="https://adobe.com?sign-in=true"]'); - - if (dropdownSignIn) { - dropdownSignIn.addEventListener('click', (e) => { + const dropdownSignInAnchor = dropdownElem.querySelector('[href$="?sign-in=true"]'); + if (dropdownSignInAnchor) { + const dropdownSignInButton = toFragment``; + dropdownSignInAnchor.replaceWith(dropdownSignInButton); + dropdownSignInButton.addEventListener('click', (e) => { e.preventDefault(); signIn(); }); + } else { + lanaLog({ message: 'Sign in link not found in dropdown.' }); } decoratedElem.append(dropdownElem); @@ -90,8 +96,8 @@ const decorateSignIn = async ({ rawElem, decoratedElem }) => { }; const decorateProfileTrigger = async ({ avatar }) => { - const label = await replaceKey( - 'profile-button', + const [label, profileAvatar] = await replaceKeyArray( + ['profile-button', 'profile-avatar'], getFedsPlaceholderConfig(), 'feds', ); @@ -105,7 +111,7 @@ const decorateProfileTrigger = async ({ avatar }) => { daa-ll="Account" aria-haspopup="true" > - + ${profileAvatar} `; @@ -121,12 +127,31 @@ const setupKeyboardNav = async () => { }); }; -// TODO - when clicking the navigation the dropdowns currently do not close. +const getBrandImage = (image) => { + // Return the default Adobe logo if an image is not available + if (!image) return CONFIG.icons.company; + + // Try to decorate image as PNG, JPG or JPEG + const imgText = image?.textContent || ''; + const [source, alt] = imgText.split('|'); + if (source.trim().length) { + const img = toFragment``; + if (alt) img.alt = alt.trim(); + return img; + } + + // Return the default Adobe logo if the image could not be decorated + return CONFIG.icons.company; +}; + const closeOnClickOutside = (e) => { - if ( - !e.target.closest(selectors.globalNav) - || e.target.closest(selectors.curtain) - ) { + if (!isDesktop.matches) return; + + const openElemSelector = `${selectors.globalNav} [aria-expanded = "true"]`; + const isClickedElemOpen = [...document.querySelectorAll(openElemSelector)] + .find((openItem) => openItem.parentElement.contains(e.target)); + + if (!isClickedElemOpen) { closeAllDropdowns(); } }; @@ -139,19 +164,16 @@ class Gnav { decoratedElem: toFragment`
            `, }, search: { config: { icon: CONFIG.icons.search } }, + breadcrumbs: { wrapper: '' }, }; this.el = el; this.body = body; - this.isDesktop = window.matchMedia('(min-width: 900px)'); + decorateLinks(this.body); this.elements = {}; - body.querySelectorAll('[class$="-"]').forEach((block) => { - const { name, variants } = getBlockClasses(block.className); - block.classList.add(name, ...variants); - }); } - init = async () => { + init = () => logErrorFor(async () => { this.elements.curtain = toFragment`
            `; // Order is important, decorateTopnavWrapper will render the nav @@ -161,8 +183,8 @@ class Gnav { this.decorateMainNav, this.decorateTopNav, this.decorateTopnavWrapper, - this.addChangeEventListener, - this.loadIMS, + this.ims, + this.addChangeEventListeners, ]; this.el.addEventListener('click', this.loadDelayed); this.el.addEventListener('keydown', setupKeyboardNav); @@ -174,10 +196,21 @@ class Gnav { } document.addEventListener('click', closeOnClickOutside); - }; + isDesktop.addEventListener('change', closeAllDropdowns); + }, 'Error in global navigation init'); + + ims = async () => loadIms() + .then(() => this.imsReady()) + .catch((e) => { + if (e?.message === 'IMS timeout') { + window.addEventListener('onImsLibInstance', () => this.imsReady()); + return; + } + lanaLog({ message: 'GNAV: Error with IMS', e }); + }); decorateTopNav = () => { - this.elements.mobileToggle = this.mobileToggle(); + this.elements.mobileToggle = this.decorateToggle(); this.elements.topnav = toFragment`
            diff --git a/test/blocks/global-navigation/keyboard/mocks/global-nav.html b/test/blocks/global-navigation/keyboard/mocks/global-nav.html index 99363264fa..5028fc9841 100644 --- a/test/blocks/global-navigation/keyboard/mocks/global-nav.html +++ b/test/blocks/global-navigation/keyboard/mocks/global-nav.html @@ -8,9 +8,10 @@
            -
            + - diff --git a/test/blocks/global-navigation/mocks/global-navigation-long.plain.js b/test/blocks/global-navigation/mocks/global-navigation-long.plain.js new file mode 100644 index 0000000000..b6d5697881 --- /dev/null +++ b/test/blocks/global-navigation/mocks/global-navigation-long.plain.js @@ -0,0 +1,294 @@ +// Uses the franklin structure without any customizations +export default ` +
            +
            +
            +
            +

            + Cloud Menu +

            +
            +
            +
            +
            +
            +

            w/ Promo

            + +
            +
            +
            +

            Business Resilience: Leading Through Change

            +

            + Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas + porttitor congue massa. +

            +

            Check it out

            +
            +
            +
            +
            + + + + + + +
            +
            +
            +
            +
            +

            2 Col

            +
            Column 1 heading
            + + +
            Column 2 heading
            + +

            + Events +

            +
            + +
            +

            + Primary +

            +
            +
            +

            + Secondary +

            +
            +
            +

            Random text

            +
            + + + + +
            + +
            +
            +
            +
            Local Menu Title
            +

            + Creativity +

            +

            + Digital Transformation +

            +

            + Trends & Research +

            +
            +
            + +
            +
            + +
            + +
            `; diff --git a/test/blocks/global-navigation/mocks/global-navigation-only-brand-no-image.plain.js b/test/blocks/global-navigation/mocks/global-navigation-only-brand-no-image.plain.js new file mode 100644 index 0000000000..25c6595e84 --- /dev/null +++ b/test/blocks/global-navigation/mocks/global-navigation-only-brand-no-image.plain.js @@ -0,0 +1,15 @@ +// Uses the franklin structure without any customizations +export default ` + +`; diff --git a/test/blocks/global-navigation/mocks/global-navigation-only-brand.plain.js b/test/blocks/global-navigation/mocks/global-navigation-only-brand.plain.js index 857063d04f..9258127827 100644 --- a/test/blocks/global-navigation/mocks/global-navigation-only-brand.plain.js +++ b/test/blocks/global-navigation/mocks/global-navigation-only-brand.plain.js @@ -4,7 +4,9 @@ export default `
            diff --git a/test/blocks/global-navigation/mocks/global-navigation-only-non-svg-brand.plain.js b/test/blocks/global-navigation/mocks/global-navigation-only-non-svg-brand.plain.js new file mode 100644 index 0000000000..c848a03a5c --- /dev/null +++ b/test/blocks/global-navigation/mocks/global-navigation-only-non-svg-brand.plain.js @@ -0,0 +1,15 @@ +// Uses the franklin structure without any customizations +export default ` + +`; diff --git a/test/blocks/global-navigation/mocks/global-navigation.plain.js b/test/blocks/global-navigation/mocks/global-navigation.plain.js index e417674e49..50cdfc9a67 100644 --- a/test/blocks/global-navigation/mocks/global-navigation.plain.js +++ b/test/blocks/global-navigation/mocks/global-navigation.plain.js @@ -4,9 +4,9 @@ export default `
            @@ -18,7 +18,7 @@ export default `
            @@ -111,7 +111,7 @@ export default `
            > - diff --git a/test/blocks/icon-block/icon-block.test.js b/test/blocks/icon-block/icon-block.test.js index 4608da7986..51dacfaae2 100644 --- a/test/blocks/icon-block/icon-block.test.js +++ b/test/blocks/icon-block/icon-block.test.js @@ -10,7 +10,7 @@ describe('icon blocks', () => { init(block); const isColumn = block.classList.contains('vertical') || block.classList.contains('centered'); describe(`icon block ${isColumn ? 'column' : 'full-width'}`, () => { - const children = block.querySelectorAll('.text'); + const children = block.querySelectorAll('.text-content'); if (children.length) { children.forEach((blk) => { it('has an icon', () => { @@ -20,5 +20,27 @@ describe('icon blocks', () => { }); } }); + if (block.classList.contains('inline')) { + describe('icon block inline has 2 columns', () => { + it('has 2 columns', () => { + const firstColumn = block.querySelector('.text-content .icon-area'); + const secondColumn = block.querySelector('.text-content .second-column'); + expect(firstColumn).to.exist; + expect(secondColumn).to.exist; + }); + }); + } + }); + describe('icon block inline heading', () => { + it('has xs heading', () => { + const block = document.querySelector('#xx-up'); + const heading = block.querySelector('.heading-xs'); + expect(heading).to.exist; + }); + it('no xs heading', () => { + const block = document.querySelector('#not-xx-up'); + const heading = block.querySelector('.heading-xs'); + expect(heading).to.not.exist; + }); }); }); diff --git a/test/blocks/icon-block/mocks/body.html b/test/blocks/icon-block/mocks/body.html index f620a283f3..a3057ab4af 100644 --- a/test/blocks/icon-block/mocks/body.html +++ b/test/blocks/icon-block/mocks/body.html @@ -56,7 +56,7 @@

            FULL-WIDTH MEDIUM

            -
            +

            @@ -92,7 +92,7 @@

            FULL-WIDTH MEDIUM INTRO

            -
            +

            @@ -495,3 +495,81 @@

            Heading S Bold

            +
            +
            +
            +
            +

            + + + + + + +

            +

            Heading XS Bold 3-up Lorem ipsum sit amet

            +

            Body S Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna.

            +

            Learn more Body XS Text link

            +
            +
            +
            +
            +
            +
            +

            + + + + + + +

            +

            Heading XS Bold 3-up Lorem ipsum sit amet

            +

            Body S Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna.

            +

            Learn more Body XS Text link

            +
            +
            +
            + +
            +
            +
            +
            +
            +

            + + + + + + +

            +

            Heading XS Bold 3-up Lorem ipsum sit amet

            +

            Body S Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna.

            +

            Learn more Body XS Text link

            +
            +
            +
            +
            +
            +
            +

            + + + + + + +

            +

            Heading XS Bold 3-up Lorem ipsum sit amet

            +

            Body S Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna.

            +

            Learn more Body XS Text link

            +
            +
            +
            +
            diff --git a/test/blocks/iframe/iframe.test.js b/test/blocks/iframe/iframe.test.js index a26d18bbde..2b0b0465e7 100644 --- a/test/blocks/iframe/iframe.test.js +++ b/test/blocks/iframe/iframe.test.js @@ -2,13 +2,13 @@ import { expect } from '@esm-bundle/chai'; import { setConfig } from '../../../libs/utils/utils.js'; const { default: init } = await import('../../../libs/blocks/iframe/iframe.js'); -const emptyHTML = `
            `; -const blockHTML = `
            +const emptyHTML = '
            '; +const blockHTML = ``; -const autoBlockHTML = `https://adobe-ideacloud.forgedx.com/adobe-adobe-magento/adobe-magento-hybrid/public/mx?SUID=6Bmhi16C730c3noGdPN385j4ZipffIAq`; +const autoBlockHTML = 'https://adobe-ideacloud.forgedx.com/adobe-adobe-magento/adobe-magento-hybrid/public/mx?SUID=6Bmhi16C730c3noGdPN385j4ZipffIAq'; describe('iframe', () => { it('does not render iframe when there are no links', async () => { @@ -29,9 +29,7 @@ describe('iframe', () => { }); it('renders iframe autoblock onto the page', async () => { - setConfig({ - autoBlocks: [{ iframe: 'https://adobe-ideacloud.forgedx.com' },], - }); + setConfig({ autoBlocks: [{ iframe: 'https://adobe-ideacloud.forgedx.com' }] }); document.body.innerHTML = autoBlockHTML; const el = document.querySelector('a'); @@ -39,4 +37,12 @@ describe('iframe', () => { expect(document.querySelector('iframe')).to.exist; }); + + it('passes additional classes to final iframe', async () => { + document.body.innerHTML = blockHTML; + const el = document.querySelector('.iframe'); + init(el); + + expect(document.querySelector('.additional')).to.exist; + }); }); diff --git a/test/blocks/library-config/library-config.test.js b/test/blocks/library-config/library-config.test.js new file mode 100644 index 0000000000..afb5d4e28b --- /dev/null +++ b/test/blocks/library-config/library-config.test.js @@ -0,0 +1,177 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; + +const { getContainers, getSearchTags, isMatching, getHtml } = await import('../../../libs/blocks/library-config/lists/blocks.js'); +const BLOCK_PAGE_URL = 'https://main--milo--adobecom.hlx.page/path/to/block/page'; + +function verifyContainer(container, elementsLength, hasLibraryMetadata) { + expect(container).to.exist; + expect(container.elements).to.exist; + expect(container.elements.length).to.equal(elementsLength); + if (hasLibraryMetadata) { + expect(container['library-metadata']).to.exist; + } +} + +describe('Library Config: text', () => { + let bodyHtml; + let expectedDocxHtml; + + before(async () => { + bodyHtml = await readFile({ path: './mocks/blocks/text/body.html' }); + expectedDocxHtml = await readFile({ path: './mocks/blocks/text/docx.html' }); + }); + + it('text', async () => { + document.body.innerHTML = bodyHtml; + // verify getContainers() + const containers = getContainers(document); + expect(containers).to.exist; + verifyContainer(containers[0], 1, true); + // verify getHtml() + const docxHtml = getHtml(containers[0], BLOCK_PAGE_URL); + expect(docxHtml).to.equal(expectedDocxHtml); + // verify getSearchTags() + const searchTags = getSearchTags(containers[0]); + expect(searchTags).to.equal('tb-2up-gr10 tb-3up-gr12 text'); + // verify isMatching() + expect(isMatching(containers[0], 'tb-2up-gr10')).to.be.true; + expect(isMatching(containers[0], 'non-existing')).to.be.false; + }); +}); + +describe('Library Config: chart', () => { + let bodyHtml; + let expectedDocxHtml; + + before(async () => { + bodyHtml = await readFile({ path: './mocks/blocks/chart/body.html' }); + expectedDocxHtml = await readFile({ path: './mocks/blocks/chart/docx.html' }); + }); + + it('chart', async () => { + document.body.innerHTML = bodyHtml; + // verify getContainers() + const containers = getContainers(document); + expect(containers).to.exist; + verifyContainer(containers[0], 1, true); + // verify getHtml() + const docxHtml = getHtml(containers[0], BLOCK_PAGE_URL); + expect(docxHtml).to.equal(expectedDocxHtml); + // verify getSearchTags() + const searchTags = getSearchTags(containers[0]); + expect(searchTags).to.equal('chart-0 chart (area, green, border)'); + // verify isMatching() + expect(isMatching(containers[0], 'chart-0')).to.be.true; + expect(isMatching(containers[0], 'non-existing')).to.be.false; + }); +}); + +describe('Library Config: marquee', () => { + let bodyHtml; + let expectedDocxHtml; + + before(async () => { + bodyHtml = await readFile({ path: './mocks/blocks/marquee/body.html' }); + expectedDocxHtml = await readFile({ path: './mocks/blocks/marquee/docx.html' }); + }); + + it('marquee', async () => { + document.body.innerHTML = bodyHtml; + // verify getContainers() + const containers = getContainers(document); + expect(containers).to.exist; + verifyContainer(containers[0], 1, true); + // verify getHtml() + const docxHtml = getHtml(containers[0], BLOCK_PAGE_URL); + expect(docxHtml).to.equal(expectedDocxHtml); + // verify getSearchTags() + const searchTags = getSearchTags(containers[0]); + expect(searchTags).to.equal('mq-std-md-lt mq-std-md-rt mq-std-md-lt-vid marquee-dark marquee'); + // verify isMatching() + expect(isMatching(containers[0], 'mq-std-md-lt')).to.be.true; + expect(isMatching(containers[0], 'non-existing')).to.be.false; + }); +}); + +describe('Library Config: containers', () => { + let noBlocksNoContainersHtml; + let singleBlocksHtml; + let containersHtml; + let mixedHtml; + let expectedSingleBlockHtml; + let expectedContainerHtml; + + before(async () => { + noBlocksNoContainersHtml = await readFile({ path: './mocks/blocks/container/body-no-blocks-no-containers.html' }); + singleBlocksHtml = await readFile({ path: './mocks/blocks/container/body-single-blocks.html' }); + containersHtml = await readFile({ path: './mocks/blocks/container/body-containers.html' }); + mixedHtml = await readFile({ path: './mocks/blocks/container/body-mixed.html' }); + expectedSingleBlockHtml = await readFile({ path: './mocks/blocks/container/docx-single-block.html' }); + expectedContainerHtml = await readFile({ path: './mocks/blocks/container/docx-container.html' }); + }); + + it('getContainers: page with no blocks, no containers', async () => { + document.body.innerHTML = noBlocksNoContainersHtml; + const containers = getContainers(document); + expect(containers).to.be.empty; + }); + + it('getContainers: page with single blocks', async () => { + document.body.innerHTML = singleBlocksHtml; + const containers = getContainers(document); + expect(containers).to.exist; + verifyContainer(containers[0], 1, false); + verifyContainer(containers[1], 1, true); + }); + + it('getContainers: page with containers', async () => { + document.body.innerHTML = containersHtml; + const containers = getContainers(document); + expect(containers).to.exist; + verifyContainer(containers[0], 8, false); + verifyContainer(containers[1], 11, true); + }); + + it('getContainers: page with mixed content', async () => { + document.body.innerHTML = mixedHtml; + const containers = getContainers(document); + expect(containers).to.exist; + verifyContainer(containers[0], 1, false); + verifyContainer(containers[1], 1, true); + verifyContainer(containers[2], 8, false); + verifyContainer(containers[3], 11, true); + }); + + it('getHtml', async () => { + document.body.innerHTML = mixedHtml; + const containers = getContainers(document); + const singleBlockHtml = getHtml(containers[0], BLOCK_PAGE_URL); + const containerHtml = getHtml(containers[3], BLOCK_PAGE_URL); + expect(singleBlockHtml).to.equal(expectedSingleBlockHtml); + expect(containerHtml).to.equal(expectedContainerHtml); + }); + + it('getSearchTags', async () => { + document.body.innerHTML = mixedHtml; + const containers = getContainers(document); + const singleBlockWithoutMetadataSearchTags = getSearchTags(containers[0]); + const singleBlockWithMetadataSearchTags = getSearchTags(containers[1]); + const containerWithH2TitleAndSearchTags = getSearchTags(containers[2]); + const containerWithBlockTitleAndSearchTags = getSearchTags(containers[4]); + expect(singleBlockWithoutMetadataSearchTags).to.equal('carousel (container0)'); + expect(singleBlockWithMetadataSearchTags).to.equal('tag1 carousel (container1)'); + expect(containerWithH2TitleAndSearchTags).to.equal('tag2 Carousel (container2)'); + expect(containerWithBlockTitleAndSearchTags).to.equal('tag4 carousel (lightbox4)'); + }); + + it('isMatching', async () => { + document.body.innerHTML = mixedHtml; + const containers = getContainers(document); + expect(isMatching(containers[0], 'tag1')).to.be.false; + expect(isMatching(containers[1], 'tag1')).to.be.true; + expect(isMatching(containers[2], 'tag2')).to.be.true; + expect(isMatching(containers[3], 'tag3')).to.be.true; + expect(isMatching(containers[4], 'tag4')).to.be.true; + }); +}); diff --git a/test/blocks/library-config/mocks/blocks/chart/body.html b/test/blocks/library-config/mocks/blocks/chart/body.html new file mode 100644 index 0000000000..20860c7350 --- /dev/null +++ b/test/blocks/library-config/mocks/blocks/chart/body.html @@ -0,0 +1,24 @@ +
            +
            +
            +
            +

            Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

            +
            +
            +
            +
            Revenue dollars: 2020 vs 2021 forecasted vs 2021 actuals.
            +
            + +
            +
            Footnote lorem ipsum dolor sit amet, consectetuer adipiscing elit.
            +
            +
            + +
            diff --git a/test/blocks/library-config/mocks/blocks/chart/docx.html b/test/blocks/library-config/mocks/blocks/chart/docx.html new file mode 100644 index 0000000000..096d052cac --- /dev/null +++ b/test/blocks/library-config/mocks/blocks/chart/docx.html @@ -0,0 +1,3 @@ +
            URLPreviewedPublished${headings.pre}${headings.pub}Indexed
            chart (area, green, border)
            +

            Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

            +
            Revenue dollars: 2020 vs 2021 forecasted vs 2021 actuals.
            https://main--milo--adobecom.hlx.page/docs/library/blocks/chart_data/areachart.json
            Footnote lorem ipsum dolor sit amet, consectetuer adipiscing elit.
            \ No newline at end of file diff --git a/test/blocks/library-config/mocks/blocks/container/body-containers.html b/test/blocks/library-config/mocks/blocks/container/body-containers.html new file mode 100644 index 0000000000..97cc156676 --- /dev/null +++ b/test/blocks/library-config/mocks/blocks/container/body-containers.html @@ -0,0 +1,169 @@ +
            + +

            Carousel contained to 1200px

            +
            +
            +
            +
            +
            + + +
            +
            +
            +
            +
            +

            Melon

            +

            Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

            +

            Learn about

            +
            +
            +
            + +
            +
            +
            +
            +
            +

            Avocado

            +

            Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

            +

            Learn about

            +
            +
            +
            + +
            +
            +
            +
            +
            +
            +
            + +

            Full width carousel with lightbox feature

            +
            +
            +
            +
            +
            + + +
            +
            +

            Avocado surprise

            +

            + + + + + + +

            + +
            +
            +

            Cabage surprise

            +

            + + + + + + +

            + +
            +
            +
            +
            +
            +
            +
            + +
            + diff --git a/test/blocks/library-config/mocks/blocks/container/body-mixed.html b/test/blocks/library-config/mocks/blocks/container/body-mixed.html new file mode 100644 index 0000000000..5ffaedd2c1 --- /dev/null +++ b/test/blocks/library-config/mocks/blocks/container/body-mixed.html @@ -0,0 +1,297 @@ +
            +

            Carousel (container)

            +

            Carousel 1 contained to 1200px

            +
            +
            +

            Carousel (container)

            +

            Carousel 2 contained to 1200px

            +
            +
            +
            +
            +
            +

            Melon

            +

            Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

            +

            Learn about

            +
            +
            +
            +
            +
            + +
            +
            + + +
            +
            +

            Carousel contained to 1200px

            + +
            +
            +
            +
            +
            + + +
            +
            +
            +
            +
            +

            Melon

            +

            Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

            +

            Learn about

            +
            +
            +
            + +
            +
            +
            +
            +
            +

            Avocado

            +

            Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

            +

            Learn about

            +
            +
            +
            + +
            +
            +
            +
            +
            + +
            +
            + +

            Full width carousel with lightbox feature

            +
            +
            +
            +
            +
            + + +
            +
            +

            Avocado surprise

            +

            + + + + + + +

            + +
            +
            +

            Cabage surprise

            +

            + + + + + + +

            + +
            +
            +
            +
            +
            +
            +
            + +
            +
            + +

            Full width carousel with lightbox feature

            +
            +
            +
            +
            +
            + + +
            +
            +

            Avocado surprise

            +

            + + + + + + +

            + +
            +
            +

            Cabage surprise

            +

            + + + + + + +

            + +
            +
            +
            +
            +
            +
            +
            + +
            + diff --git a/test/blocks/library-config/mocks/blocks/container/body-no-blocks-no-containers.html b/test/blocks/library-config/mocks/blocks/container/body-no-blocks-no-containers.html new file mode 100644 index 0000000000..4780f0d252 --- /dev/null +++ b/test/blocks/library-config/mocks/blocks/container/body-no-blocks-no-containers.html @@ -0,0 +1,19 @@ +
            + +

            Carousel 1 contained to 1200px

            +
            +
            + +

            Carousel 2 contained to 1200px

            +
            +
            +
            +
            +
            +

            Melon

            +

            Lorem ipsum dolor sit amet, consectetuer adipiscing elit.

            +

            Learn about

            +
            +
            +
            +
            diff --git a/test/blocks/library-config/mocks/blocks/container/body-single-blocks.html b/test/blocks/library-config/mocks/blocks/container/body-single-blocks.html new file mode 100644 index 0000000000..77e7492232 --- /dev/null +++ b/test/blocks/library-config/mocks/blocks/container/body-single-blocks.html @@ -0,0 +1,20 @@ +
            + +
            +
            + + +
            diff --git a/test/blocks/library-config/mocks/blocks/container/docx-container.html b/test/blocks/library-config/mocks/blocks/container/docx-container.html new file mode 100644 index 0000000000..d1311e15a7 --- /dev/null +++ b/test/blocks/library-config/mocks/blocks/container/docx-container.html @@ -0,0 +1,15 @@ +
            carousel (lightbox)
            Carousel lightbox
            section-metadata
            stylexxl spacing

            ---

            Avocado surprise

            + + + + + + +

            section-metadata
            carouselCarousel lightbox
            styleCenter

            ---

            Cabage surprise

            + + + + + + +

            section-metadata
            carouselCarousel lightbox
            styleCenter

            ---

            \ No newline at end of file diff --git a/test/blocks/library-config/mocks/blocks/container/docx-single-block.html b/test/blocks/library-config/mocks/blocks/container/docx-single-block.html new file mode 100644 index 0000000000..99f92a87e0 --- /dev/null +++ b/test/blocks/library-config/mocks/blocks/container/docx-single-block.html @@ -0,0 +1 @@ +
            carousel (container0)
            Carousel container
            \ No newline at end of file diff --git a/test/blocks/library-config/mocks/blocks/marquee/body.html b/test/blocks/library-config/mocks/blocks/marquee/body.html new file mode 100644 index 0000000000..2902737a70 --- /dev/null +++ b/test/blocks/library-config/mocks/blocks/marquee/body.html @@ -0,0 +1,44 @@ +
            +
            +
            +
            + + + + + + +
            +
            +
            +
            +

            + + + + + + +

            +

            This is my detail

            +

            Heading XL Marquee standard medium left

            +

            Body M Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.

            +

            Lorem ipsum Learn more Text link

            +
            +
            + + + + + + +
            +
            +
            + +
            diff --git a/test/blocks/library-config/mocks/blocks/marquee/docx.html b/test/blocks/library-config/mocks/blocks/marquee/docx.html new file mode 100644 index 0000000000..c2514138a5 --- /dev/null +++ b/test/blocks/library-config/mocks/blocks/marquee/docx.html @@ -0,0 +1,28 @@ +
            marquee
            + + + + + + +
            +

            + + + + + + +

            +

            This is my detail

            +

            Heading XL Marquee standard medium left

            +

            Body M Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.

            +

            Lorem ipsum Learn more Text link

            +
            + + + + + + +
            \ No newline at end of file diff --git a/test/blocks/library-config/mocks/blocks/text/body.html b/test/blocks/library-config/mocks/blocks/text/body.html new file mode 100644 index 0000000000..5a84cf3969 --- /dev/null +++ b/test/blocks/library-config/mocks/blocks/text/body.html @@ -0,0 +1,17 @@ +
            +
            +
            +
            +

            Text

            +

            Kick things off with hundreds of premium and free presets you can access with your Lightroom subscription.

            +

            Learn more Explore the premium collection

            +
            +
            +
            + +
            diff --git a/test/blocks/library-config/mocks/blocks/text/docx.html b/test/blocks/library-config/mocks/blocks/text/docx.html new file mode 100644 index 0000000000..71a0437c5a --- /dev/null +++ b/test/blocks/library-config/mocks/blocks/text/docx.html @@ -0,0 +1,5 @@ +
            text
            +

            Text

            +

            Kick things off with hundreds of premium and free presets you can access with your Lightroom subscription.

            +

            Learn more Explore the premium collection

            +
            \ No newline at end of file diff --git a/test/blocks/library-config/mocks/media_1.png b/test/blocks/library-config/mocks/media_1.png new file mode 100644 index 0000000000..4faad891dd Binary files /dev/null and b/test/blocks/library-config/mocks/media_1.png differ diff --git a/test/blocks/marketo-config/marketo-config.test.js b/test/blocks/marketo-config/marketo-config.test.js new file mode 100644 index 0000000000..149a627d28 --- /dev/null +++ b/test/blocks/marketo-config/marketo-config.test.js @@ -0,0 +1,160 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; +import { stub } from 'sinon'; +import { delay, waitForElement } from '../../helpers/waitfor.js'; +import init, { getDefaultStates, cleanPanelData, getConfigOptions } from '../../../libs/blocks/marketo-config/marketo-config.js'; +import { setConfig } from '../../../libs/utils/utils.js'; + +const innerHTML = await readFile({ path: './mocks/body.html' }); +const options = JSON.parse(await readFile({ path: './mocks/options.json' })); +const config = { codeRoot: '/libs' }; +const ogFetch = window.fetch; + +setConfig(config); + +describe('marketo-config', () => { + beforeEach(() => { + document.body.innerHTML = innerHTML; + localStorage.clear(); + }); + + afterEach(() => { + window.fetch = ogFetch; + }); + + it('cleans panel data correctly', () => { + const data = [ + { prop: 'PROP1', required: 'YES' }, + { prop: 'PROP2', required: 'no' }, + ]; + const cleanedData = cleanPanelData(data); + + expect(cleanedData).to.deep.equal([ + { prop: 'prop1', required: 'yes' }, + { prop: 'prop2', required: 'no' }, + ]); + }); + + it('gets config options correctly', () => { + const configOptions = getConfigOptions(options); + expect(configOptions['Sheet 1'][0].prop).to.exist; + }); + + it('retrieves default states correctly', () => { + const panelsData = getConfigOptions(options); + const defaults = getDefaultStates(panelsData); + + expect(defaults).to.deep.equal({ + prop1: 'option1', prop2: '', prop3: '', prop4: '', prop5: 'option1', prop6: '', prop7: 'option1', prop8: 'option1', + }); + }); + + it('shows error message', async () => { + const el = document.querySelector('.marketo-config'); + window.fetch = stub().returns( + new Promise((resolve) => { + resolve({ + ok: false, + status: 500, + }); + }), + ); + await init(el); + await delay(50); + + const content = await waitForElement('.error', { rootEl: el }); + expect(content.textContent).to.contain('Error'); + }); + + it('renders correctly', async () => { + const el = document.querySelector('.marketo-config'); + await init(el); + + const title = await waitForElement('.tool-title'); + expect(title.textContent).to.contain('Marketo Test Configurator'); + + const accordion = await waitForElement('.accordion'); + expect(accordion).to.exist; + + const marketo = await waitForElement('.marketo'); + expect(marketo).to.exist; + }); + + it('updates state and local storage', async () => { + let lsState = {}; + const el = document.querySelector('.marketo-config'); + await init(el); + + const accordion = await waitForElement('.accordion'); + const select = accordion.querySelector('select'); + + select.value = 'option2'; + select.dispatchEvent(new window.Event('change')); + await delay(50); + + lsState = JSON.parse(localStorage.getItem('marketo-test-ConfiguratorState')); + expect(lsState).to.deep.equal({ + prop1: 'option2', prop2: '', prop3: '', prop4: '', prop5: 'option1', prop6: '', prop7: 'option1', prop8: 'option1', + }); + + const input = accordion.querySelector('input'); + + input.value = 'input'; + input.dispatchEvent(new window.Event('change')); + await delay(50); + + lsState = JSON.parse(localStorage.getItem('marketo-test-ConfiguratorState')); + + expect(lsState).to.deep.equal({ + prop1: 'option2', prop2: '', prop3: 'input', prop4: '', prop5: 'option1', prop6: '', prop7: 'option1', prop8: 'option1', + }); + }); + + it('validate config and copy', async () => { + const el = document.querySelector('.marketo-config'); + await init(el); + const accordion = await waitForElement('.accordion'); + const copyBtn = await waitForElement('.copy-button'); + const select = accordion.querySelector('select#prop2'); + const input = accordion.querySelector('input#prop3'); + + select.value = 'option2'; + select.dispatchEvent(new window.Event('change')); + await delay(50); + + const copyButton = copyBtn.querySelector('.copy-config'); + copyButton.click(); + await delay(50); + + const message = copyBtn.querySelector('.message'); + expect(message.textContent).to.contain('Required fields must be filled'); + + input.value = 'input'; + input.dispatchEvent(new window.Event('change')); + await delay(50); + + copyButton.click(); + await delay(50); + + const copyContent = copyBtn.querySelector('.copy-content'); + expect(copyContent.textContent).to.contain('http'); + }); + + it('resets to default state', async () => { + const el = document.querySelector('.marketo-config'); + await init(el); + + const accordion = await waitForElement('.accordion'); + + const resetButton = accordion.querySelector('.resetToDefaultState'); + resetButton.click(); + + await delay(50); + + const lsState = JSON.parse(localStorage.getItem('marketo-test-ConfiguratorState')); + + const panelsData = getConfigOptions(options); + const defaults = getDefaultStates(panelsData); + expect(lsState).to.deep.equal(defaults); + }); +}); diff --git a/test/blocks/marketo-config/mocks/body.html b/test/blocks/marketo-config/mocks/body.html new file mode 100644 index 0000000000..1a8bd67999 --- /dev/null +++ b/test/blocks/marketo-config/mocks/body.html @@ -0,0 +1,10 @@ +
            +
            +
            +
            Marketo Test
            +
            +
            +
            +
            +
            +
            diff --git a/test/blocks/marketo-config/mocks/options.json b/test/blocks/marketo-config/mocks/options.json new file mode 100644 index 0000000000..6be8a20224 --- /dev/null +++ b/test/blocks/marketo-config/mocks/options.json @@ -0,0 +1,95 @@ +{ + "metadata": { + "total": 2, + "offset": 0, + "limit": 2, + "data": [ + { + "Sheet": "sheet1", + "Title": "Sheet 1" + }, + { + "Sheet": "sheet2", + "Title": "Sheet 2" + } + ] + }, + "sheet1": { + "total": 4, + "offset": 0, + "limit": 4, + "data": [ + { + "prop": "prop1", + "label": "Field with options and default", + "options": "option1:Label 1,option2:Label 2", + "default": "option1", + "required": "yes", + "description": "This field has options and a default value" + }, + { + "prop": "prop2", + "label": "Field with options and no default", + "options": ",option1:Label 1,option2:Label 2", + "required": "yes", + "description": "This field has options but no default value" + }, + { + "prop": "prop3", + "label": "Required field with no options and no default", + "options": "", + "required": "yes", + "description": "This is a required field with no options or default value" + }, + { + "prop": "prop4", + "label": "Non-required field with no options and no default", + "required": "no", + "description": "This is a non-required field with no options or default value" + } + ] + }, + "sheet2": { + "total": 4, + "offset": 0, + "limit": 4, + "data": [ + { + "prop": "prop5", + "label": "Field with options separated by commas", + "options": "option1,option2,option3", + "default": "option1", + "required": "yes", + "description": "This field has options separated by commas" + }, + { + "prop": "prop6", + "label": "Field with empty options", + "required": "no", + "description": "This field has no options" + }, + { + "prop": "prop7", + "label": "Required field with default", + "default": "option1", + "required": "yes", + "description": "This is a required field with a default value" + }, + { + "prop": "prop8", + "label": "Non-required field with default and one option", + "default": "option1", + "options": "option1", + "required": "no", + "description": "This is a non-required field with a default value" + } + ] + }, + ":version": 1, + ":names": [ + "metadata", + "sheet1", + "sheet2" + ], + ":type": "multi-sheet" +} diff --git a/test/blocks/marketo/marketo.test.html b/test/blocks/marketo/marketo.test.html new file mode 100644 index 0000000000..ef3f7c66c0 --- /dev/null +++ b/test/blocks/marketo/marketo.test.html @@ -0,0 +1,104 @@ + + + + + +
            +

            Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec cursus mi id tincidunt pretium. Praesent a porta ex. + Etiam eu metus urna. Etiam vulputate nibh nisi, sed gravida diam dictum id. Cras et justo metus. Morbi consectetur + diam eu mi ultricies, molestie efficitur quam posuere. Integer iaculis euismod pulvinar.

            +

            Fill out the form to view the report.

            +
            + +
            +
            Title
            +
            New Title
            +
            +
            +
            Description
            +
            New Description
            +
            +
            +
            Destination URL
            + +
            +
            +
            Co-Partner Names
            +
            Partner 1, Partner 2
            +
            +
            +
            SFDC Campaign ID
            +
            7011p00000046jUAAQ
            +
            +
            + +
            + + + diff --git a/test/blocks/marketo/marketo.test.js b/test/blocks/marketo/marketo.test.js index cdb11aba95..8ae2dd11f3 100644 --- a/test/blocks/marketo/marketo.test.js +++ b/test/blocks/marketo/marketo.test.js @@ -1,79 +1,80 @@ import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; -import { waitForElement } from '../../helpers/waitfor.js'; -import { setConfig } from '../../../libs/utils/utils.js'; -import init, { formValidate, formSuccess } from '../../../libs/blocks/marketo/marketo.js'; +import { delay } from '../../helpers/waitfor.js'; +import init, { setPreferences, decorateURL } from '../../../libs/blocks/marketo/marketo.js'; const innerHTML = await readFile({ path: './mocks/body.html' }); -const config = { - marketoBaseURL: '//engage.adobe.com', - marketoFormID: '1723', - marketoMunchkinID: '360-KCI-804', -}; -// There have been a lot of changes to the Marketo form coming from the Marketo instance -// that we do not have control over. -// This test is not reliable and is skipped for now. -describe.skip('marketo', () => { - it('hides form if no base url', async () => { - setConfig({}); +describe('marketo', () => { + beforeEach(() => { document.body.innerHTML = innerHTML; - const el = document.querySelector('.marketo'); - init(el); - - expect(el.style.display).to.equal('none'); }); - describe('marketo with correct config', () => { - before(() => { - setConfig(config); - document.body.innerHTML = innerHTML; - const el = document.querySelector('.marketo'); - init(el); - }); - - it('initializes', async () => { - const wrapper = await waitForElement('.marketo-form-wrapper'); - expect(wrapper).to.exist; + afterEach(() => { + window.mcz_marketoForm_pref = undefined; + }); - const title = document.querySelector('.marketo-title'); - expect(title).to.exist; - }); + it('hides form if no data url', async () => { + document.body.innerHTML = innerHTML; + const el = document.querySelector('.marketo'); + el.querySelector('a').remove(); - it('loads hidden fields', async function () { - this.timeout(3000); - const hiddenInput = await waitForElement('.marketo form input[name="hiddenField"]'); - expect(hiddenInput).to.exist; - }); + init(el); + await delay(10); + expect(el.style.display).to.equal('none'); + }); - it('shows form errors', async () => { - expect(window.MktoForms2).to.exist; - const form = window.MktoForms2.getForm(config.marketoFormID); - const formEl = await waitForElement(`#mktoForm_${config.marketoFormID}`); + it('sets preferences on the data layer', async () => { + const formData = { + 'first.key': 'value1', + 'second.key': 'value2', + }; - formValidate(form); + setPreferences(formData); - expect(formEl.classList.contains('show-warnings')).to.be.true; - }); + expect(window.mcz_marketoForm_pref).to.have.property('first'); + expect(window.mcz_marketoForm_pref.first).to.have.property('key'); + expect(window.mcz_marketoForm_pref.first.key).to.equal('value1'); + expect(window.mcz_marketoForm_pref).to.have.property('second'); + expect(window.mcz_marketoForm_pref.second).to.have.property('key'); + expect(window.mcz_marketoForm_pref.second.key).to.equal('value2'); + }); +}); - it('scrolls to top upon submitting with errors', async () => { - const button = await waitForElement('.marketo button'); - button.click(); +describe('marketo decorateURL', () => { + it('decorates absolute URL with local base URL', () => { + const baseURL = new URL('http://localhost:6456/marketo-block'); + const result = decorateURL('https://main--milo--adobecom.hlx.page/marketo-block/thank-you', baseURL); + expect(result.href).to.equal('http://localhost:6456/marketo-block/thank-you'); + }); - const firstField = document.querySelector('.mktoField'); - const bounding = firstField.getBoundingClientRect(); + it('decorates relative URL with absolute base URL', () => { + const baseURL = new URL('https://main--milo--adobecom.hlx.page/marketo-block'); + const result = decorateURL('/marketo-block/thank-you', baseURL); + expect(result.href).to.equal('https://main--milo--adobecom.hlx.page/marketo-block/thank-you'); + }); - expect(bounding.top >= 0 && bounding.bottom <= window.innerHeight).to.be.true; - }); + it('decorates absolute URL with matching base URL', () => { + const baseURL = new URL('https://main--milo--adobecom.hlx.page/marketo-block'); + const result = decorateURL('https://main--milo--adobecom.hlx.page/marketo-block/thank-you', baseURL); + expect(result.href).to.equal('https://main--milo--adobecom.hlx.page/marketo-block/thank-you'); + }); - it('submits successfully', async () => { - const redirectUrl = ''; + it('decorates absolute URL with .html base URL', () => { + const baseURL = new URL('https://business.adobe.com/marketo-block.html'); + const result = decorateURL('https://main--milo--adobecom.hlx.page/marketo-block/thank-you', baseURL); + expect(result.href).to.equal('https://business.adobe.com/marketo-block/thank-you.html'); + }); - expect(window.MktoForms2).to.exist; - const form = window.MktoForms2.getForm(config.marketoFormID); + it('keeps identical absolute URL with .html base URL', () => { + const baseURL = new URL('https://business.adobe.com/marketo-block.html'); + const result = decorateURL('https://business.adobe.com/marketo-block/thank-you.html', baseURL); + expect(result.href).to.equal('https://business.adobe.com/marketo-block/thank-you.html'); + }); - formSuccess(form, redirectUrl); - expect(window.mktoSubmitted).to.be.true; - }); + it('returns null when provided a malformed URL', () => { + const baseURL = new URL('https://business.adobe.com/marketo-block.html'); + const result = decorateURL('tps://business', baseURL); + expect(result).to.be.null; }); }); diff --git a/test/blocks/marketo/mocks/body.html b/test/blocks/marketo/mocks/body.html index 91adbe8ada..e08de1f68d 100644 --- a/test/blocks/marketo/mocks/body.html +++ b/test/blocks/marketo/mocks/body.html @@ -1,34 +1,39 @@
            -

            See what Adobe Experience Cloud can do for you.

            -

            Let us know who you are and what challenges we can help you solve or opportunities we can help you seize.

            +

            Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec cursus mi id tincidunt pretium. Praesent a porta ex. + Etiam eu metus urna. Etiam vulputate nibh nisi, sed gravida diam dictum id. Cras et justo metus. Morbi consectetur + diam eu mi ultricies, molestie efficitur quam posuere. Integer iaculis euismod pulvinar.

            +

            Fill out the form to view the report.

            -
            Title
            -
            Title
            +
            -
            Description
            -
            Description
            +
            Title
            +
            New Title
            -
            Form ID
            -
            +
            Description
            +
            New Description
            -
            Base URL
            -
            +
            Destination URL
            +
            -
            Munchkin ID
            -
            +
            Co-Partner Names
            +
            Partner 1, Partner 2
            -
            Destination URL
            -
            +
            SFDC Campaign ID
            +
            7011p00000046jUAAQ
            +
            +
            diff --git a/test/blocks/marketo/mocks/marketo-utils.js b/test/blocks/marketo/mocks/marketo-utils.js new file mode 100644 index 0000000000..28bb451b7a --- /dev/null +++ b/test/blocks/marketo/mocks/marketo-utils.js @@ -0,0 +1,63 @@ +import { stub } from 'sinon'; + +export const parseEncodedConfig = stub().returns({ + 'field visibility.phone': 'hidden', + 'field filters.job_role': 'all', + 'field filters.functional_area': 'all', + 'field filters.industry': 'all', + 'field filters.products': 'all', + 'field visibility.demo': 'visible', + 'program.copartnernames': '', + 'program.campaignids.external': '', + 'program.campaignids.retouch': '', + 'program.campaignids.onsite': '', + 'program.additional form_id': '', + 'form id': '1723', + 'marketo munckin': '360-KCI-804', + 'marketo host': 'engage.adobe.com', + 'form type': 'marketo_form', + 'form.subtype': 'whitepaper_form', + 'program.campaignids.sfdc': '7011p00000046jUAAQ', + 'program.poi': '', + title: 'New Title', + description: 'New Description', +}); + +export const loadScript = stub().returns(new Promise((resolve) => { + const forms2Mock = { + getFormElem: () => ({ get: () => document.querySelector('form') }), + onValidate: stub(), + onSuccess: stub(), + loadForm: stub(), + whenReady: stub().callsFake((fn) => fn(forms2Mock)), + }; + + window.MktoForms2 = forms2Mock; + resolve(); +})); + +export function createTag(tag, attributes, html) { + const el = document.createElement(tag); + if (html) { + if (html instanceof HTMLElement + || html instanceof SVGElement + || html instanceof DocumentFragment) { + el.append(html); + } else if (Array.isArray(html)) { + el.append(...html); + } else { + el.insertAdjacentHTML('beforeend', html); + } + } + if (attributes) { + Object.entries(attributes).forEach(([key, val]) => { + el.setAttribute(key, val); + }); + } + return el; +} + +export function createIntersectionObserver({ el, callback /* , once = true, options = {} */ }) { + // fire immediately + callback(el, { target: el }); +} diff --git a/test/blocks/marquee-anchors/marquee-anchors.test.js b/test/blocks/marquee-anchors/marquee-anchors.test.js new file mode 100644 index 0000000000..a149047d6d --- /dev/null +++ b/test/blocks/marquee-anchors/marquee-anchors.test.js @@ -0,0 +1,45 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; + +document.body.innerHTML = await readFile({ path: './mocks/body.html' }); +const { default: init } = await import('../../../libs/blocks/marquee-anchors/marquee-anchors.js'); + +describe('marquee-anchors', () => { + beforeEach(() => { + sinon.spy(console, 'log'); + }); + + afterEach(() => { + console.log.restore(); + }); + + const marquees = document.querySelectorAll('.marquee-anchors'); + marquees.forEach((marquee) => { + init(marquee); + }); + + it('has a copy area', () => { + const copy = marquees[0].querySelector('.copy'); + expect(copy).to.exist; + }); + + it('has a links area and anchor-link', () => { + const links = marquees[0].querySelector('.links'); + expect(links).to.exist; + const anchorLinks = marquees[0].querySelector('.links .anchor-link'); + expect(anchorLinks).to.exist; + }); + + it('adds the external class for external links', () => { + const links = marquees[1].querySelector('.external'); + expect(links).to.exist; + }); + + it('does not add the external class if the window has a hash or query praram', () => { + window.location.hash = 'a-string'; + + const links = marquees[0].querySelector('.external'); + expect(links).to.be.null; + }); +}); diff --git a/test/blocks/marquee-anchors/mocks/body.html b/test/blocks/marquee-anchors/mocks/body.html new file mode 100644 index 0000000000..e79f572b98 --- /dev/null +++ b/test/blocks/marquee-anchors/mocks/body.html @@ -0,0 +1,110 @@ +
            +
            +
            linear-gradient(#e66465, #9198e5)
            +
            +
            +
            +

            Heading M Bold 24/30

            +

            Heading XL Bold (36/45) Lorem ipsum (Image Background)

            +

            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.

            +

            Lorem ipsum Call to action

            +

            + + + +

            +
            +
            +
            +
            +

            This is the title of the section

            +
            +
            +
            +
            +

            Marquee Label

            +

            Actionable data that drives real-time personalization.

            +

            #marquee-block

            +
            +
            +
            +
            +

            How to

            +

            Easy analysis of connected data.

            +

            #how-to-block

            +
            +
            +
            +
            +

            Text

            +

            Actionable data that drives real-time personalization.

            +

            #text-block

            +
            +
            +
            +
            +

            Media

            +

            Easy analysis of connected data.

            +

            #media-block

            +
            +
            +
            +
            This is where the suffix goes What we offer
            +
            +
            + +
            +
            +
            #000000
            +
            +
            +
            +

            Heading M Bold 24/30

            +

            Heading XL Bold (36/45) Lorem ipsum (Image Background)

            +

            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.

            +

            Lorem ipsum Call to action

            +

            + + + +

            +
            +
            +
            +
            +

            This is the title of the section

            +

            Link to a place

            +
            +
            +
            +
            +

            Marquee Label

            +

            Actionable data that drives real-time personalization.

            +

            #marquee-block

            +
            +
            +
            +
            +

            How to

            +

            Easy analysis of connected data.

            +

            #how-to-block

            +
            +
            +
            +
            +

            Text

            +

            Actionable data that drives real-time personalization.

            +

            #text-block

            +
            +
            +
            +
            +

            Adobe.com

            +

            Actionable data that drives real-time personalization.

            +

            #Adobe.com

            +
            +
            +
            +
            This is where the suffix goes What we offer
            +
            +
            diff --git a/test/blocks/marquee/marquee.test.js b/test/blocks/marquee/marquee.test.js index 0f13ea7352..feaf23fe98 100644 --- a/test/blocks/marquee/marquee.test.js +++ b/test/blocks/marquee/marquee.test.js @@ -38,10 +38,15 @@ describe('marquee', () => { describe('supports media credits', () => { it('has a media credit with text content', () => { - const mediaCredit = marquees[8].querySelector('.media-credit .body-s'); + const mediaCredit = document.getElementById('media-credit-text').querySelector('.media-credit .body-s'); expect(mediaCredit).to.exist; expect(mediaCredit.textContent.trim()).to.have.lengthOf.above(0); }); + + it('has a media credit with element content', () => { + const mediaCredit = document.getElementById('media-credit-element').querySelector('.media-credit').firstElementChild; + expect(mediaCredit).to.exist; + }); }); describe('supports videos', () => { @@ -78,7 +83,7 @@ describe('marquee', () => { init(marquee); expect(marquee.querySelector('.icon-area-multiple')).to.exist; }); - + it('using svg', () => { const marquee = document.getElementById('using-svgs'); init(marquee); diff --git a/test/blocks/marquee/mocks/body.html b/test/blocks/marquee/mocks/body.html index 1d1c9a5697..fff41d20d7 100644 --- a/test/blocks/marquee/mocks/body.html +++ b/test/blocks/marquee/mocks/body.html @@ -184,7 +184,7 @@

            Marquee inline

            Marquee (split)

            small

            -
            +
            #000000
            @@ -204,7 +204,7 @@

            Marquee Split ½ dark

            Medium split medium light

            -
            +
            #fafafa
            @@ -219,7 +219,7 @@

            Marquee Split ½ light

            mock - Photo by First Name Last Name + Photo by First Name Last Name
            diff --git a/test/blocks/media/media.test.js b/test/blocks/media/media.test.js index d3c1503f3c..db2ba7ec6e 100644 --- a/test/blocks/media/media.test.js +++ b/test/blocks/media/media.test.js @@ -21,6 +21,12 @@ describe('media', () => { const iconArea = medias[0].querySelector('.icon-area'); expect(iconArea).to.exist; }); + it('has an icon area with blue button', () => { + const actionArea = medias[3].querySelector('.action-area'); + expect(actionArea).to.exist; + const blueButton = actionArea.querySelector('.con-button.blue'); + expect(blueButton).to.exist; + }); }); describe('dark media large', () => { it('has a heading-xl', () => { @@ -42,4 +48,32 @@ describe('media', () => { expect(isDark).to.equal(false); }); }); + describe('subcopy with links media', () => { + it('does have subcopy with links', () => { + const links = medias[4].querySelectorAll('h3.heading-xs ~ p.subcopy-link > a'); + expect(links.length).to.greaterThanOrEqual(2); + }); + }); + describe('media with qr-code', () => { + it('does have qr-code image', () => { + const qrCodeImg = medias[5].querySelector('img.qr-code-img'); + expect(qrCodeImg).to.exist; + }); + it('does have CTA for google-play', () => { + const googlePlayCta = medias[5].querySelector('a.google-play'); + expect(googlePlayCta).to.exist; + }); + it('does have CTA for app-store', () => { + const appStoreCta = medias[5].querySelector('a.app-store'); + expect(appStoreCta).to.exist; + }); + }); + describe('with bio variant', () => { + it('has a bio avatar and icon-stack area', () => { + const avatar = medias[6].querySelectorAll('.avatar'); + const iconStack = medias[6].querySelectorAll('.icon-stack-area'); + expect(avatar).to.exist; + expect(iconStack).to.exist; + }); + }); }); diff --git a/test/blocks/media/mocks/body.html b/test/blocks/media/mocks/body.html index db587b8fc1..a85166c5d0 100644 --- a/test/blocks/media/mocks/body.html +++ b/test/blocks/media/mocks/body.html @@ -55,3 +55,90 @@

            Heading XL 36/45 Media Block Large

            +
            +
            +
            en textbubbla
            +
            +

            Använd Acrobat-verktyg kostnadsfritt

            +
              +
            • Logga in för att prova över 20 verktyg, bl.a. konvertera eller komprimera
            • +
            • Lägg in kommentarer, fyll i formulär och signera pdf-filer utan kostnad
            • +
            • Lagra filer online för åtkomst från vilken enhet som helst
            • +
            +

            + + Skapa ett kostnadsfritt konto + Logga in

            +
            +
            +
            +
            +
            +
            + + + + +
            +
            +

            Body S 16/24 Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.

            +

            Learn More

            +

            Watch tutorial H3

            +

            Subcopy Link 1

            +

            Subcopy link 2

            +
            +
            +
            +
            +
            +
            + + + + +
            +
            +

            Body S 16/24 Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat.

            +

            +

            Google play

            +

            Apple store

            +
            +
            +
            + +
            +
            +
            +

            + + + mock + +

            +

            Detail M 12/15

            +

            Heading M 24/30 Media

            +

            This has a variant ‘bio’ and an video content w/ #_hoverplay

            + +

            Learn More

            +
            + +
            +
            + diff --git a/test/blocks/merch-card/merch-card.test.js b/test/blocks/merch-card/merch-card.test.js new file mode 100644 index 0000000000..d49b9e8dbe --- /dev/null +++ b/test/blocks/merch-card/merch-card.test.js @@ -0,0 +1,168 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; + +const { default: init } = await import('../../../libs/blocks/merch-card/merch-card.js'); + +describe('Merch Card', () => { + it('Shows segment card', async () => { + document.body.innerHTML = await readFile({ path: './mocks/segment-card.html' }); + await init(document.querySelector('.merch-card')); + const inner = document.querySelector('.consonant-SegmentBlade-inner'); + const cardFooter = inner.querySelector('.consonant-CardFooter'); + const buttons = cardFooter.querySelectorAll('.con-button'); + + expect(document.querySelector('.consonant-ProductCard')).to.exist; + expect(inner.querySelector('.consonant-SegmentBlade-title')).to.exist; + expect(inner.querySelector('.consonant-SegmentBlade-description')).to.exist; + expect(cardFooter.querySelector('.con-button')).to.exist; + expect(buttons.length).to.be.equal(2); + expect(buttons[0].textContent).to.be.equal('Learn More'); + expect(buttons[1].textContent).to.be.equal('Save now'); + }); + + describe('Wrapper', async () => { + before(async () => { + document.body.innerHTML = await readFile({ path: './mocks/segment-card.html' }); + const merchCards = document.querySelectorAll('.segment'); + await init(merchCards[0]); + await init(merchCards[1]); + }); + + it('Has one per section', () => { + expect(document.querySelectorAll('.consonant-Wrapper').length).to.equal(1); + }); + + it('Is in correct position', async () => { + const wrapper = document.querySelector('.consonant-Wrapper'); + expect(wrapper.previousElementSibling).to.equal(document.querySelector('.before')); + expect(wrapper.nextElementSibling).to.equal(document.querySelector('.after')); + }); + }); + + it('Supports Special Offers card', async () => { + document.body.innerHTML = await readFile({ path: './mocks/special-offers.html' }); + await init(document.querySelector('.special-offers')); + const inner = document.querySelector('.consonant-SpecialOffers-inner'); + const cardFooter = inner.querySelector('.consonant-CardFooter'); + const ribbon = document.querySelector('.consonant-SpecialOffers-ribbon'); + const buttons = cardFooter.querySelectorAll('.con-button'); + + expect(document.querySelector('.consonant-ProductCard')).to.exist; + expect(inner.querySelector('.consonant-SpecialOffers-title')).to.exist; + expect(inner.querySelector('.consonant-SpecialOffers-description')).to.exist; + expect(document.querySelector('.consonant-SpecialOffers-iconWrapper')).to.exist; + expect(ribbon).to.exist; + expect(ribbon.style.backgroundColor).to.be.equal(''); + expect(ribbon.style.color).to.be.equal('rgb(0, 0, 0)'); + expect(ribbon.textContent).to.be.equal('LOREM IPSUM DOLOR'); + expect(ribbon.style.borderLeft).to.be.equal('1px solid rgb(237, 204, 45)'); + expect(ribbon.style.borderRight).to.be.equal('none'); + expect(ribbon.style.borderTop).to.be.equal('1px solid rgb(237, 204, 45)'); + expect(ribbon.style.borderBottom).to.be.equal('1px solid rgb(237, 204, 45)'); + expect(buttons.length).to.be.equal(2); + expect(buttons[0].textContent).to.be.equal('Learn More'); + expect(buttons[1].textContent).to.be.equal('Save now'); + }); + + describe('Plans Card', () => { + before(async () => { + document.body.innerHTML = await readFile({ path: './mocks/plans-card.html' }); + }); + + it('Supports Plans card', async () => { + document.body.innerHTML = await readFile({ path: './mocks/plans-card.html' }); + await init(document.querySelector('.plans.icons')); + const inner = document.querySelector('.consonant-PlansCard-inner'); + const cardFooter = inner.querySelector('.consonant-CardFooter'); + const ribbon = document.querySelector('.consonant-PlansCard-ribbon'); + const buttons = cardFooter.querySelectorAll('.con-button'); + const inactiveButton = cardFooter.querySelector('.button--inactive'); + const secureWrapper = cardFooter.querySelector('.secure-transaction-wrapper'); + const checkBoxContainer = cardFooter.querySelector('.checkbox-container'); + const plansCard = document.querySelector('.consonant-ProductCard'); + const iconsWrapper = document.querySelector('.consonant-PlansCard-iconWrapper'); + const icons = iconsWrapper.querySelectorAll('.consonant-MerchCard-ProductIcon'); + const list = document.querySelector('.consonant-PlansCard-list'); + const listItems = list.querySelectorAll('li'); + + expect(plansCard).to.exist; + expect(plansCard.style.border).to.be.equal('1px solid rgb(237, 204, 45)'); + expect(inner.querySelector('.consonant-PlansCard-title')).to.exist; + expect(inner.querySelector('.consonant-PlansCard-description')).to.exist; + expect(iconsWrapper).to.exist; + expect(icons.length).to.be.equal(2); + expect(ribbon).to.exist; + expect(ribbon.style.backgroundColor).to.be.equal('rgb(237, 204, 45)'); + expect(ribbon.style.color).to.be.equal('rgb(0, 0, 0)'); + expect(ribbon.textContent).to.be.equal('LOREM IPSUM DOLOR'); + expect(buttons.length).to.be.equal(2); + expect(buttons[0].textContent).to.be.equal('Learn More'); + expect(buttons[1].textContent).to.be.equal('Save now'); + expect(secureWrapper).to.exist; + expect(list).to.exist; + expect(list.classList.contains('consonant-PlansCard-list')).to.be.true; + expect(listItems.length).to.be.equal(2); + expect(listItems[0].textContent).to.be.equal('Maecenas porttitor congue massa'); + expect(listItems[1].textContent).to.be.equal('Nunc viverra imperdiet enim.'); + + expect(checkBoxContainer.querySelector('.checkMark')).to.exist; + expect(checkBoxContainer.querySelector('.checkbox-label').textContent).to.be.equal('Lorem ipsum dolor sit amet'); + expect(secureWrapper.querySelector('.secure-transaction-icon').classList).to.exist; + expect(secureWrapper.querySelector('.secure-transaction-label')).to.exist; + + expect(inactiveButton.classList.contains('button--inactive')).to.be.true; + checkBoxContainer.querySelector('.checkMark').click(); + expect(inactiveButton.classList.contains('button--inactive')).to.be.false; + checkBoxContainer.querySelector('.checkMark').click(); + expect(inactiveButton.classList.contains('button--inactive')).to.be.true; + }); + + it('should skip ribbon and altCta creation', async () => { + document.body.innerHTML = await readFile({ path: './mocks/plans-card.html' }); + await init(document.querySelector('.plans.icons.skip-ribbon.skip-altCta')); + const inner = document.querySelector('.consonant-PlansCard-inner'); + const cardFooter = inner.querySelector('.consonant-CardFooter'); + const ribbon = document.querySelector('.consonant-PlansCard-ribbon'); + const buttons = cardFooter.querySelectorAll('.con-button'); + const inactiveButton = cardFooter.querySelectorAll('.button--inactiive'); + const secureWrapper = cardFooter.querySelector('.secure-transaction-wrapper'); + const checkBoxContainer = cardFooter.querySelector('.checkbox-container'); + const plansCard = document.querySelector('.consonant-ProductCard'); + const iconsWrapper = document.querySelector('.consonant-PlansCard-iconWrapper'); + const icons = iconsWrapper.querySelectorAll('.consonant-MerchCard-ProductIcon'); + + expect(plansCard).to.exist; + console.log(plansCard.style.border); + expect(inner.querySelector('.consonant-PlansCard-title')).to.exist; + expect(inner.querySelector('.consonant-PlansCard-description')).to.exist; + expect(iconsWrapper).to.exist; + expect(icons.length).to.be.equal(2); + expect(ribbon).to.not.exist; + expect(buttons.length).to.be.equal(2); + expect(buttons[0].textContent).to.be.equal('Learn More'); + expect(buttons[1].textContent).to.be.equal('Save now'); + expect(inactiveButton).to.exist; + expect(secureWrapper).to.not.exist; + expect(checkBoxContainer).to.not.exist; + }); + + it('does not display undefined if no content', async () => { + const el = document.querySelector('.merch-card.empty'); + await init(el); + expect(el.outerHTML.includes('undefined')).to.be.false; + }); + }); + + describe('UAR Card', () => { + before(async () => { + document.body.innerHTML = await readFile({ path: './mocks/uar-card.html' }); + }); + it('handles decorated
            ', async () => { + const cards = document.querySelectorAll('.merch-card'); + cards.forEach((card) => { + init(card); + }); + expect(cards[0].classList.contains('has-divider')).to.be.true; + }); + }); +}); diff --git a/test/blocks/merch-card/mocks/plans-card.html b/test/blocks/merch-card/mocks/plans-card.html new file mode 100644 index 0000000000..490011c422 --- /dev/null +++ b/test/blocks/merch-card/mocks/plans-card.html @@ -0,0 +1,76 @@ +
            +
            +
            +
            +
            +
            +
            +
            +
            #EDCC2D, #000000
            +
            LOREM IPSUM DOLOR
            +
            +
            +
            + + + + + + + + + + + + +

            Lorem ipsum dolor sit amet

            +

            Lorem ipsum dolor

            +

            Maecenas porttitor congue massa. Fusce posuere, magna sed pulvinar ultricies, purus lectus malesuada libero, + sit amet commodo magna eros quis urna. Nunc viverra imperdiet enim.

            +

            Maecenas

            +
              +
            • Maecenas porttitor congue massa
            • +
            • Nunc viverra imperdiet enim.
            • +
            +

            See terms about lorem ipsum

            +

            Learn More Save now

            +
            +
            +
            +
            Lorem ipsum dolor sit amet
            + +
            +
            +
            +
            +
            +
            LOREM IPSUM DOLOR
            +
            +
            +
            + + + + + + + + + + + + +

            Lorem ipsum dolor sit amet

            +

            Lorem ipsum dolor

            +

            Maecenas porttitor congue massa. Fusce posuere, magna sed pulvinar ultricies, purus lectus malesuada libero, + sit amet commodo magna eros quis urna. Nunc viverra imperdiet enim.

            +

            See terms about lorem ipsum

            +

            Learn More Save now

            +
            +
            +
            +
            Lorem ipsum dolor sit amet
            +
            +
            +
            +
            diff --git a/test/blocks/merch-card/mocks/segment-card.html b/test/blocks/merch-card/mocks/segment-card.html new file mode 100644 index 0000000000..c816cac7f4 --- /dev/null +++ b/test/blocks/merch-card/mocks/segment-card.html @@ -0,0 +1,24 @@ +
            +
            +
            +
            +
            +

            Lorem ipsum dolor sit amet

            +

            Maecenas porttitor congue massa. Fusce posuere, magna sed pulvinar ultricies, purus lectus malesuada libero, sit amet commodo magna eros quis urna. Nunc viverra imperdiet enim.

            +

            See what's included | Learn more

            +

            Learn More Save now

            +
            +
            +
            +
            +
            +
            +

            Lorem ipsum dolor sit amet

            +

            Maecenas porttitor congue massa. Fusce posuere, magna sed pulvinar ultricies, purus lectus malesuada libero, sit amet commodo magna eros quis urna. Nunc viverra imperdiet enim.

            +

            See what's included | Learn more

            +

            Learn More Save now

            +
            +
            +
            +
            +
            diff --git a/test/blocks/merch-card/mocks/special-offers.html b/test/blocks/merch-card/mocks/special-offers.html new file mode 100644 index 0000000000..9c48a9aaae --- /dev/null +++ b/test/blocks/merch-card/mocks/special-offers.html @@ -0,0 +1,25 @@ +
            +
            +
            +
            #EDCC2D, #000000
            +
            LOREM IPSUM DOLOR
            +
            +
            +
            +

            + + + + + + +

            +

            INDIVIDUALS

            +

            Get 10% off Photoshop.

            +

            Create gorgeous images, rich graphics, and incredible art. Save 10% for the first year. Ends Mar 20.

            +

            See terms

            +

            Learn More Save now

            +
            +
            +
            +
            diff --git a/test/blocks/merch-card/mocks/uar-card.html b/test/blocks/merch-card/mocks/uar-card.html new file mode 100644 index 0000000000..5955045e1d --- /dev/null +++ b/test/blocks/merch-card/mocks/uar-card.html @@ -0,0 +1,49 @@ +
            + +

            UAR CARDS

            + +
            +
            +
            +

            https://main--milo--adobecom.hlx.page/drafts/rparrish/assets/merch/photoshop.svg

            +

            Photoshop

            +

            PRICE - PUF - Photoshop with 2TB PRICE - ABM - Photoshop with 2TB

            +

            HR in between price and link? Variant UAR - Create gorgeous images, rich graphics, and incredible art.

            +


            --- #00ffff

            +

            See terms

            +

            Learn More Save now

            +
            +
            +
            + +

            inline heading

            +
            +
            +
            +

            https://main--milo--adobecom.hlx.page/drafts/rparrish/assets/merch/indesign.svg

            +

            InDesign

            +

            PRICE - PUF - Photoshop with 2TB PRICE - ABM - Photoshop with 2TB

            +

            --- #FF3266

            +

            Variant: ‘inline headline’ is used to keep the title inline w/ the icon.

            +

            See terms

            +

            Learn More Save now

            +
            +
            +
            + +

            background opacity 70

            +
            +
            +
            +

            https://main--milo--adobecom.hlx.page/drafts/rparrish/assets/merch/xd.svg

            +

            XD

            +

            PRICE - PUF - Photoshop with 2TB PRICE - ABM - Photoshop with 2TB

            +

            HR between link and ctas? Variant: ‘background opacity 70’ is used to have a 70% white bg.

            +

            See terms

            +

            See terms

            +

            Learn More Save now

            +
            +
            +
            + +
            diff --git a/test/blocks/merch/merch.test.js b/test/blocks/merch/merch.test.js index ef4bd40fac..78616d1347 100644 --- a/test/blocks/merch/merch.test.js +++ b/test/blocks/merch/merch.test.js @@ -1,370 +1,297 @@ import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; -import sinon from 'sinon'; -import { createTag, setConfig } from '../../../libs/utils/utils.js'; -const config = setConfig({ codeRoot: '/libs', env: { name: 'local' } }); -const { default: merch, VERSION, getTacocatEnv, imsCountryPromise, runTacocat } = await import('../../../libs/blocks/merch/merch.js'); +import merch, { + buildCta, + getCheckoutContext, + priceLiteralsURL, +} from '../../../libs/blocks/merch/merch.js'; -document.head.innerHTML = await readFile({ path: './mocks/head.html' }); -document.body.innerHTML = await readFile({ path: './mocks/body.html' }); +import { mockFetch, unmockFetch } from './mocks/fetch.js'; +import { mockIms, unmockIms } from './mocks/ims.js'; +import { createTag, setConfig } from '../../../libs/utils/utils.js'; + +const config = { + codeRoot: '/libs', + commerce: { priceLiteralsURL }, + env: { name: 'prod' }, +}; + +/** + * utility function that tests Price spans against mock HTML + * + * @param {util} selector price span selector + * @param {*} expectedAttributes { : + * } + */ +const validatePriceSpan = async (selector, expectedAttributes) => { + const el = await merch(document.querySelector( + selector, + )); + const { nodeName, dataset } = await el.onceSettled(); + expect(nodeName).to.equal('SPAN'); + if (!expectedAttributes.template) { + expect(dataset.template).to.be.undefined; + } + Object.keys(expectedAttributes).forEach((key) => { + const value = expectedAttributes[key]; + expect(dataset[key], ` ${key} should equal ${value}`).to.equal(value); + }); +}; describe('Merch Block', () => { + after(async () => { + delete window.lana; + unmockFetch(); + unmockIms(); + }); + before(async () => { - Object.assign(window.tacocat, { - loadPromise: Promise.resolve(), - price: { optionProviders: [] }, - defaults: { - apiKey: 'wcms-commerce-ims-ro-user-milo', - baseUrl: 'https://wcs.stage.adobe.com', - landscape: null, - env: 'STAGE', - environment: 'STAGE', - country: 'US', - clientId: 'adobe_com', - language: 'en', - locale: 'en_US', - checkoutWorkflow: 'UCv3', - checkoutWorkflowStep: 'email', - }, - }); + window.lana = { log: () => { } }; + document.head.innerHTML = await readFile({ path: './mocks/head.html' }); + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + await mockIms('CH'); + await mockFetch(); + setConfig(config); }); - it('Doesnt decorate merch with bad content', async () => { + beforeEach(async () => { + const { init, Log } = await import('../../../libs/deps/commerce.js'); + await init(() => config); + Log.reset(); + Log.use(Log.Plugins.quietFilter); + }); + + it('does not decorate merch with bad content', async () => { let el = document.querySelector('.bad-content'); - let undef = await merch(el); - expect(undef).to.be.undefined; + expect(await merch(el)).to.be.undefined; el = document.querySelector('.merch.bad-content'); - undef = await merch(el); - expect(undef).to.be.undefined; + expect(await merch(el)).to.be.null; }); describe('Prices', () => { - it('merch link to price without term', async () => { - const el = document.querySelector('.merch.price.hide-term'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.be.undefined; - expect(dataset.displayRecurrence).to.equal('false'); + it('renders merch link to price without term (new)', async () => { + await validatePriceSpan('.merch.price.hide-term', { displayRecurrence: 'false' }); }); - it('merch link to price with term', async () => { - const el = document.querySelector('.merch.price.term'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.be.undefined; - expect(dataset.displayRecurrence).to.equal(); + it('renders merch link to price with term', async () => { + await validatePriceSpan('.merch.price.term', { displayRecurrence: undefined }); }); - it('merch link to price with term and seat', async () => { - const el = document.querySelector('.merch.price.seat'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.be.undefined; - expect(dataset.displayPerUnit).to.equal('true'); + it('renders merch link to price with term and seat', async () => { + await validatePriceSpan('.merch.price.seat', { displayPerUnit: 'true' }); }); - it('merch link to price with term and tax', async () => { - const el = document.querySelector('.merch.price.tax'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.be.undefined; - expect(dataset.displayTax).to.equal('true'); + it('renders merch link to price with term and tax', async () => { + await validatePriceSpan('.merch.price.tax', { displayTax: 'true' }); }); - it('merch link to price with term, seat and tax', async () => { - const el = document.querySelector('.merch.price.seat.tax'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.be.undefined; - expect(dataset.displayTax).to.equal('true'); + it('renders merch link to price with term, seat and tax', async () => { + await validatePriceSpan('.merch.price.seat.tax', { displayTax: 'true' }); }); - it('merch link to strikethrough price with term, seat and tax', async () => { - const el = document.querySelector('.merch.price.strikethrough'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.equal('strikethrough'); + it('renders merch link to strikethrough price with term, seat and tax', async () => { + await validatePriceSpan('.merch.price.strikethrough', { template: 'strikethrough' }); }); - it('merch link to optical price with term, seat and tax', async () => { - const el = document.querySelector('.merch.price.optical'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.equal('optical'); + it('renders merch link to optical price with term, seat and tax', async () => { + await validatePriceSpan('.merch.price.optical', { template: 'optical' }); + }); + + it('renders merch link to tax exclusive price with tax exclusive attribute', async () => { + await validatePriceSpan('.merch.price.tax-exclusive', { forceTaxExclusive: 'true' }); }); }); describe('Promo Prices', () => { - it('merch link to promo price with discount', async () => { - const el = document.querySelector('.merch.price.oldprice'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.be.undefined; - expect(dataset.promotionCode).to.equal(undefined); + it('renders merch link to promo price with discount', async () => { + await validatePriceSpan('.merch.price.oldprice', { promotionCode: undefined }); }); - it('merch link to promo price without discount', async () => { - const el = document.querySelector('.merch.strikethrough.oldprice'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.equal('strikethrough'); - expect(dataset.promotionCode).to.equal(undefined); + it('renders merch link to promo price without discount', async () => { + await validatePriceSpan('.merch.strikethrough.oldprice', { template: 'strikethrough', promotionCode: undefined }); }); - it('merch link to promo price with discount', async () => { - const el = document.querySelector('.merch.price.promo'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.be.undefined; - expect(dataset.promotionCode).to.equal('nicopromo'); + it('renders merch link to promo price with discount', async () => { + await validatePriceSpan('.merch.price.promo', { promotionCode: 'nicopromo' }); }); - it('merch link to full promo price', async () => { - const el = document.querySelector('.merch.price.promo'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.be.undefined; - expect(dataset.promotionCode).to.equal('nicopromo'); + it('renders merch link to full promo price', async () => { + await validatePriceSpan('.merch.price.promo', { promotionCode: 'nicopromo' }); }); }); describe('Promo Prices in a fragment', () => { - it('merch link to promo price with discount', async () => { - const el = document.querySelector('.fragment .merch.price.oldprice'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.be.undefined; - expect(dataset.promotionCode).to.equal(undefined); + it('renders merch link to promo price with discount', async () => { + await validatePriceSpan('.fragment .merch.price.oldprice', { promotionCode: undefined }); }); - it('merch link to promo price without discount', async () => { - const el = document.querySelector( - '.fragment .merch.strikethrough.oldprice', - ); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.equal('strikethrough'); - expect(dataset.promotionCode).to.equal(undefined); + it('renders merch link to promo price without discount', async () => { + await validatePriceSpan('.fragment .merch.strikethrough.oldprice', { template: 'strikethrough', promotionCode: undefined }); }); - it('merch link to promo price with discount', async () => { - const el = document.querySelector('.fragment .merch.price.promo'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.be.undefined; - expect(dataset.promotionCode).to.equal('nicopromo'); + it('renders merch link to promo price with discount', async () => { + await validatePriceSpan('.fragment .merch.price.promo', { promotionCode: 'nicopromo' }); }); - it('merch link to full promo price', async () => { - const el = document.querySelector('.fragment .merch.price.promo'); - const { nodeName, dataset } = await merch(el); - expect(nodeName).to.equal('SPAN'); - expect(dataset.template).to.be.undefined; - expect(dataset.promotionCode).to.equal('nicopromo'); + it('renders merch link to full promo price', async () => { + await validatePriceSpan('.fragment .merch.price.promo', { promotionCode: 'nicopromo' }); }); }); describe('CTAs', () => { - it('merch link to CTA, default values', async () => { - let el = document.querySelector('.merch.cta'); - el = await merch(el); - const { nodeName, textContent, dataset } = el; + it('renders merch link to CTA, default values', async () => { + const { Defaults } = await import('../../../libs/deps/commerce.js'); + const el = await merch(document.querySelector( + '.merch.cta', + )); + const { dataset, href, nodeName, textContent } = await el.onceSettled(); + const url = new URL(href); expect(nodeName).to.equal('A'); expect(textContent).to.equal('Buy Now'); expect(el.getAttribute('is')).to.equal('checkout-link'); expect(dataset.promotionCode).to.equal(undefined); - expect(dataset.checkoutWorkflow).to.equal(undefined); - expect(dataset.checkoutWorkflowStep).to.equal(undefined); - expect(dataset.checkoutClientId).to.equal(undefined); + expect(dataset.checkoutWorkflow).to.equal(Defaults.checkoutWorkflow); + expect(dataset.checkoutWorkflowStep).to.equal(Defaults.checkoutWorkflowStep); expect(dataset.checkoutMarketSegment).to.equal(undefined); - }); - - it('merch link to CTA, config values', async () => { - setConfig({ commerce: { checkoutClientId: 'dc' } }); - let el = document.querySelector('.merch.cta.config'); - el = await merch(el); - const { nodeName, textContent, dataset } = el; + expect(url.searchParams.get('cli')).to.equal(Defaults.checkoutClientId); + }); + + it('renders merch link to CTA, config values', async () => { + const { Defaults, init, reset } = await import('../../../libs/deps/commerce.js'); + reset(); + await init(() => ({ ...config, commerce: { checkoutClientId: 'dc' } })); + const el = await merch(document.querySelector( + '.merch.cta.config', + )); + const { dataset, href, nodeName, textContent } = await el.onceSettled(); + const url = new URL(href); expect(nodeName).to.equal('A'); expect(textContent).to.equal('Buy Now'); expect(el.getAttribute('is')).to.equal('checkout-link'); expect(dataset.promotionCode).to.equal(undefined); - expect(dataset.checkoutWorkflow).to.equal(undefined); - expect(dataset.checkoutWorkflowStep).to.equal(undefined); - expect(dataset.checkoutClientId).to.equal('dc'); + expect(dataset.checkoutWorkflow).to.equal(Defaults.checkoutWorkflow); + expect(dataset.checkoutWorkflowStep).to.equal(Defaults.checkoutWorkflowStep); expect(dataset.checkoutMarketSegment).to.equal(undefined); - - setConfig(config); + expect(url.searchParams.get('cli')).to.equal('dc'); }); - it('merch link to CTA, metadata values', async () => { - const metadata = createTag('meta', { name: 'checkout-type', content: 'UCv2' }); + it('renders merch link to CTA, metadata values', async () => { + const { CheckoutWorkflow, CheckoutWorkflowStep, Defaults, init, reset } = await import('../../../libs/deps/commerce.js'); + reset(); + const metadata = createTag('meta', { name: 'checkout-workflow', content: CheckoutWorkflow.V2 }); document.head.appendChild(metadata); - let el = document.querySelector('.merch.cta.metadata'); - el = await merch(el); - const { nodeName, textContent, dataset } = el; + await init(() => config); + const el = await merch(document.querySelector( + '.merch.cta.metadata', + )); + const { dataset, href, nodeName, textContent } = await el.onceSettled(); + const url = new URL(href); expect(nodeName).to.equal('A'); expect(textContent).to.equal('Buy Now'); expect(el.getAttribute('is')).to.equal('checkout-link'); expect(dataset.promotionCode).to.equal(undefined); - expect(dataset.checkoutWorkflow).to.equal('UCv2'); - expect(dataset.checkoutWorkflowStep).to.equal(undefined); - expect(dataset.checkoutClientId).to.equal(undefined); + expect(dataset.checkoutWorkflow).to.equal(CheckoutWorkflow.V2); + expect(dataset.checkoutWorkflowStep).to.equal(CheckoutWorkflowStep.CHECKOUT); expect(dataset.checkoutMarketSegment).to.equal(undefined); + expect(url.searchParams.get('cli')).to.equal(Defaults.checkoutClientId); document.head.removeChild(metadata); + await init(() => config, true); }); - it('merch link to cta with empty promo', async () => { - let el = document.querySelector('.merch.cta.nopromo'); - el = await merch(el); - const { nodeName, dataset } = el; + it('renders merch link to cta with empty promo', async () => { + const el = await merch(document.querySelector( + '.merch.cta.nopromo', + )); + const { nodeName, dataset } = await el.onceSettled(); expect(nodeName).to.equal('A'); expect(el.getAttribute('is')).to.equal('checkout-link'); expect(dataset.promotionCode).to.equal(undefined); }); - it('merch link to cta with empty promo in a fragment', async () => { - let el = document.querySelector('.fragment .merch.cta.nopromo'); - el = await merch(el); - const { nodeName, dataset } = el; + it('renders merch link to cta with empty promo in a fragment', async () => { + const el = await merch(document.querySelector( + '.fragment .merch.cta.nopromo', + )); + const { nodeName, dataset } = await el.onceSettled(); expect(nodeName).to.equal('A'); expect(el.getAttribute('is')).to.equal('checkout-link'); expect(dataset.promotionCode).to.equal(undefined); }); - it('merch link to promo cta with discount', async () => { - let el = document.querySelector('.merch.cta.promo'); - el = await merch(el); - const { nodeName, dataset } = el; + it('renders merch link to promo cta with discount', async () => { + const el = await merch(document.querySelector( + '.merch.cta.promo', + )); + const { nodeName, dataset } = await el.onceSettled(); expect(nodeName).to.equal('A'); expect(el.getAttribute('is')).to.equal('checkout-link'); expect(dataset.promotionCode).to.equal('nicopromo'); }); - it('merch link to promo cta with discount in a fragment', async () => { - let el = document.querySelector('.fragment .merch.cta.promo'); - el = await merch(el); - const { nodeName, dataset } = el; + it('renders merch link to promo cta with discount in a fragment', async () => { + const el = await merch(document.querySelector( + '.fragment .merch.cta.promo', + )); + const { nodeName, dataset } = await el.onceSettled(); expect(nodeName).to.equal('A'); expect(el.getAttribute('is')).to.equal('checkout-link'); expect(dataset.promotionCode).to.equal('nicopromo'); }); - it('merch link to UCv2 cta with link-level overrides.', async () => { - let el = document.querySelector('.merch.cta.link-overrides'); - el = await merch(el); - const { nodeName, dataset } = el; + it('renders merch link to UCv2 cta with link-level overrides', async () => { + const el = await merch(document.querySelector( + '.merch.cta.link-overrides', + )); + const { nodeName, dataset } = await el.onceSettled(); expect(nodeName).to.equal('A'); expect(el.getAttribute('is')).to.equal('checkout-link'); + // https://wiki.corp.adobe.com/pages/viewpage.action?spaceKey=BPS&title=UCv2+Link+Creation+Guide expect(dataset.checkoutWorkflow).to.equal('UCv2'); - expect(dataset.checkoutWorkflowStep).to.equal('checkout/email'); + expect(dataset.checkoutWorkflowStep).to.equal('checkout'); expect(dataset.checkoutMarketSegment).to.equal('EDU'); }); - it('should add ims country to checkout link', async () => { - window.tacocat.imsCountryPromise = Promise.resolve('CH'); - let el = document.querySelector('.merch.cta.ims'); - el = await merch(el); - const { dataset: { imsCountry } } = el; - expect(imsCountry).to.equal('CH'); - }); - - it('should esolve IMS country', async () => { - window.adobeIMS = { isSignedInUser: () => true, getProfile: () => Promise.resolve({ countryCode: 'CH' }) }; - const imsCountry = await imsCountryPromise(); - expect(imsCountry).to.equal('CH'); - }); - - it('should resolve ims country', async () => { - window.adobeIMS = { isSignedInUser: () => false }; - const imsCountry = await imsCountryPromise(); - expect(imsCountry).to.undefined; + it('adds ims country to checkout link', async () => { + const el = await merch(document.querySelector( + '.merch.cta.ims', + )); + const { dataset } = await el.onceSettled(); + expect(dataset.imsCountry).to.equal('CH'); }); - it('should render blue CTAs', async () => { - const els = document.querySelectorAll('.merch.cta.strong'); + it('renders blue CTAs', async () => { + const els = await Promise.all([...document.querySelectorAll( + '.merch.cta.strong', + )].map(merch)); expect(els.length).to.equal(2); - const cta1 = await merch(els[0]); - expect(cta1.classList.contains('blue')).to.be.true; - const cta2 = await merch(els[1]); - expect(cta2.classList.contains('blue')).to.be.true; + els.forEach((el) => { + expect(el.classList.contains('blue')).to.be.true; + }); }); - it('should render large CTA inside a marquee', async () => { - const el = document.querySelector('.merch.cta.inside-marquee'); - const cta = await merch(el); - expect(cta.classList.contains('button-l')).to.be.true; + it('renders large CTA inside a marquee', async () => { + const el = await merch(document.querySelector( + '.merch.cta.inside-marquee', + )); + const { classList } = await el.onceSettled(); + expect(classList.contains('button-l')).to.be.true; }); }); - describe('Tacocat config', () => { - it('falls back to en for unsupported languages', async () => { - const { literalScriptUrl, language } = getTacocatEnv('local', { ietf: 'xx-US' }); - expect(literalScriptUrl).to.equal( - 'https://www.stage.adobe.com/special/tacocat/literals/en.js', - ); - expect(language).to.equal('en'); - }); - - it('returns production values', async () => { - const { scriptUrl, literalScriptUrl, country, language } = getTacocatEnv( - 'prod', - { ietf: 'fr-CA' }, - ); - expect(scriptUrl).to.equal( - `https://www.adobe.com/special/tacocat/lib/${VERSION}/tacocat.js`, - ); - expect(literalScriptUrl).to.equal( - 'https://www.adobe.com/special/tacocat/literals/fr.js', - ); - expect(country).to.equal('CA'); - expect(language).to.equal('fr'); - }); - - it('returns geo mapping', async () => { - let { country, language } = getTacocatEnv('prod', { prefix: 'africa' }); - expect(country).to.equal('ZA'); - expect(language).to.equal('en'); - - ({ country, language } = getTacocatEnv('prod', { prefix: 'no' })); - expect(country).to.equal('NO'); - expect(language).to.equal('nb'); - - ({ country, language } = getTacocatEnv('prod', { prefix: 'no' })); - expect(country).to.equal('NO'); - expect(language).to.equal('nb'); - }); - - it('returns geo mapping', async () => { - let { country, language } = getTacocatEnv('prod', { prefix: 'africa' }); - expect(country).to.equal('ZA'); - expect(language).to.equal('en'); - - ({ country, language } = getTacocatEnv('prod', { ietf: 'en' })); - expect(country).to.equal('US'); - expect(language).to.equal('en'); - }); - - it('does not initialize the block when tacocat fails to load', async () => { - window.tacocat.loadPromise = Promise.reject(new Error('404')); - let el = document.querySelector('.merch.cta.notacocat'); - el = await merch(el); - expect(el).to.be.undefined; + describe('Function "getCheckoutContext"', () => { + it('returns null if context params do not have osi', async () => { + const el = document.createElement('a'); + const params = new URLSearchParams(); + expect(await getCheckoutContext(el, params)).to.be.null; }); }); - describe('Tacocat trigger', () => { - it('should trigger tacocat', async () => { - window.tacocat.tacocat = sinon.spy(); - window.tacocat.initLanaLogger = sinon.spy(); - runTacocat('PRODUCTION', 'US', 'en'); - - expect(window.tacocat.initLanaLogger.calledWith('merch-at-scale', 'PRODUCTION', { country: 'US' }, { consumer: 'milo' })).to.be.true; - expect(window.tacocat.tacocat.calledWith({ - env: 'PRODUCTION', - country: 'US', - language: 'en', - })).to.be.true; + describe('Function "buildCta"', () => { + it('returns null if context params do not have osi', async () => { + const el = document.createElement('a'); + const params = new URLSearchParams(); + expect(await buildCta(el, params)).to.be.null; }); }); }); diff --git a/test/blocks/merch/mocks/body.html b/test/blocks/merch/mocks/body.html index 5df214de88..02229bb355 100644 --- a/test/blocks/merch/mocks/body.html +++ b/test/blocks/merch/mocks/body.html @@ -1,115 +1,108 @@
            +

            Prices and CTAs

            +

            Bad merch content: Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps

            +

            Regular prices

            Display term: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps - CTA Buy Now + href="/tools/ost?osi=01&type=price">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps - CTA Buy Now

            Hide term: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps - + href="/tools/ost?osi=03&type=price&term=false">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps +

            +

            Force tax to be exclusive: Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps

            -

            Display term and seat texts: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps +

            Display term and seat texts: Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps

            Display term and tax texts: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps + href="/tools/ost?osi=05&type=price&tax=true">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps

            Display term, seat and tax texts: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps + href="/tools/ost?osi=06&type=price&seat=true&tax=true">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps

            +

            Strikethrough price

            Display term, seat and tax texts: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps + href="/tools/ost?osi=07&type=strikethrough&seat=true&tax=true">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps

            +

            Optical price

            Display term, seat and tax texts: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps + href="/tools/ost?osi=08&type=optical&seat=true&tax=true">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps

            +

            Promo prices

            Price without discount: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps - CTA Buy Now + href="/tools/ost?osi=09&type=price&promo=">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps - CTA Buy Now

            Strikethrough price without discount: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps + href="/tools/ost?osi=11&type=strikethrough&promo=">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps

            Price with discount: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps - CTA Buy Now + href="/tools/ost?osi=12&type=price&promo=nicopromo&old=false">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps - CTA Buy Now

            Full promo price: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps + href="/tools/ost?osi=14&type=price&promo=nicopromo">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps

            +

            CTAs

            CTA with config default: CTA Buy Now

            -

            CTA with metadata default:

            + href="/tools/ost?osi=15&type=checkoutUrl">CTA Buy Now +

            +

            CTA with metadata default: +

            CTA with link-level overrides: CTA Buy Now

            + href="/tools/ost?osi=17&type=checkoutUrl&workflow=UCv2&workflowStep=checkout_email&marketSegment=EDU">CTA Buy Now +

            -

            - Large CTA inside a marquee: CTA Buy Now +

            Large CTA inside a marquee: CTA Buy Now

            -

            - CTA with blue background: CTA Buy Now +

            CTA with blue background: CTA Buy Now

            -

            - CTA with blue background: CTA Buy Now +

            CTA with blue background: CTA Buy Now

            Promo prices inside a fragment

            Price without discount: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps - CTA Buy Now + href="/tools/ost?osi=21&type=price&promo=">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps CTA Buy Now

            Strikethrough price without discount: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps + href="/tools/ost?osi=23&type=strikethrough&promo=">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps

            Price with discount: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps - CTA Buy Now + href="/tools/ost?osi=24&type=price&old=false">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps CTA Buy Now

            Full promo price: Price - - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps + href="/tools/ost?osi=26&type=price">Price - 632B3ADD940A7FBB7864AA5AD19B8D28 - All Apps

            CTA with IMS country code: CTA Buy Now + href="/tools/ost?osi=27&type=checkoutUrl">CTA Buy Now

            Uninitialized CTA: CTA Buy Now + href="/tools/ost?osi=28&type=checkoutUrl">CTA Buy Now

            +
            diff --git a/test/blocks/merch/mocks/fetch.js b/test/blocks/merch/mocks/fetch.js new file mode 100644 index 0000000000..dacf0b1cda --- /dev/null +++ b/test/blocks/merch/mocks/fetch.js @@ -0,0 +1,47 @@ +import { readFile } from '@web/test-runner-commands'; +import sinon from 'sinon'; + +import { priceLiteralsURL } from '../../../../libs/blocks/merch/merch.js'; + +export async function mockFetch() { + // this path allows to import this mock from tests for other blocks (e.g. commerce) + const literals = JSON.parse(await readFile({ path: '../merch/mocks/literals.json' })); + const offers = JSON.parse(await readFile({ path: '../merch/mocks/offers.json' })); + + const { fetch } = window; + sinon.stub(window, 'fetch').callsFake((...args) => { + const { href, pathname, searchParams } = new URL(String(args[0])); + // literals mock + if (href === priceLiteralsURL) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(literals), + }); + } + // wcs mock + if (pathname.endsWith('/web_commerce_artifact')) { + const osis = searchParams.get('offer_selector_ids').split(','); + return Promise.resolve({ + status: 200, + statusText: '', + ok: true, + json: () => Promise.resolve({ + resolvedOffers: osis.map((osi) => { + let index = Number.parseInt(osi, 10); + if (Number.isNaN(index) || !Number.isFinite(index) || index < 0) index = 0; + return { + ...offers[index % offers.length], + offerSelectorIds: [osi], + }; + }), + }), + }); + } + // fallback to original fetch, should not happen! + return fetch.apply(window, args); + }); +} + +export function unmockFetch() { + window.fetch.restore?.(); +} diff --git a/test/blocks/merch/mocks/head.html b/test/blocks/merch/mocks/head.html index e5656ea688..5e94638d6a 100644 --- a/test/blocks/merch/mocks/head.html +++ b/test/blocks/merch/mocks/head.html @@ -7,6 +7,4 @@ } - - diff --git a/test/blocks/merch/mocks/ims.js b/test/blocks/merch/mocks/ims.js new file mode 100644 index 0000000000..8324cdc240 --- /dev/null +++ b/test/blocks/merch/mocks/ims.js @@ -0,0 +1,10 @@ +export async function mockIms(countryCode) { + window.adobeIMS = { + isSignedInUser: () => !!countryCode, + getProfile: () => Promise.resolve({ countryCode }), + }; +} + +export function unmockIms() { + delete window.adobeIMS; +} diff --git a/test/blocks/merch/mocks/literals.json b/test/blocks/merch/mocks/literals.json new file mode 100644 index 0000000000..c29f836e35 --- /dev/null +++ b/test/blocks/merch/mocks/literals.json @@ -0,0 +1,16 @@ +{ + "data": [{ + "lang": "en", + "recurrenceLabel": "{recurrenceTerm, select, MONTH {/mo} YEAR {/yr} other {}}", + "recurrenceAriaLabel": "{recurrenceTerm, select, MONTH {per month} YEAR {per year} other {}}", + "perUnitLabel": "{perUnit, select, LICENSE {per license} other {}}", + "perUnitAriaLabel": "{perUnit, select, LICENSE {per license} other {}}", + "freeLabel": "Free", + "freeAriaLabel": "Free", + "taxExclusiveLabel": "{taxTerm, select, GST {excl. GST} VAT {excl. VAT} TAX {excl. tax} IVA {excl. IVA} SST {excl. SST} KDV {excl. KDV} other {}}", + "taxInclusiveLabel": "{taxTerm, select, GST {incl. GST} VAT {incl. VAT} TAX {incl. tax} IVA {incl. IVA} SST {incl. SST} KDV {incl. KDV} other {}}", + "alternativePriceAriaLabel": "Alternatively at {alternativePrice}", + "strikethroughAriaLabel": "Regularly at {strikethroughPrice}" + }], + ":type": "sheet" +} diff --git a/test/blocks/merch/mocks/offers.json b/test/blocks/merch/mocks/offers.json new file mode 100644 index 0000000000..0a0a8ea717 --- /dev/null +++ b/test/blocks/merch/mocks/offers.json @@ -0,0 +1,103 @@ +[ + { + "offerId": "632B3ADD940A7FBB7864AA5AD19B8D28", + "startDate": "2015-12-04T17:38:19.000Z", + "endDate": "2100-04-13T21:20:00.000Z", + "priceDetails": { + "price": 43.99, + "priceWithoutDiscount": 54.99, + "priceWithoutTax": 43.99, + "priceWithoutDiscountAndTax": 54.99, + "usePrecision": true, + "formatString": "'US$'#,##0.00", + "taxDisplay": "TAX_EXCLUSIVE", + "taxTerm": "TAX" + }, + "analytics": "{\"offerId\":\"632B3ADD940A7FBB7864AA5AD19B8D28\",\"label\":\"ccsn_direct_individual\",\"price\":\"43.99\",\"amountWithoutTax\":\"43.99\",\"commitmentType\":\"YEAR\",\"billingFrequency\":\"MONTHLY\",\"currencyCode\":\"USD\"}", + "productArrangementCode": "ccsn_direct_individual", + "buyingProgram": "RETAIL", + "commitment": "YEAR", + "term": "MONTHLY", + "customerSegment": "INDIVIDUAL", + "marketSegments": ["COM"], + "salesChannel": "DIRECT", + "offerType": "BASE", + "pricePoint": "REGULAR", + "language": "MULT", + "merchant": "ADOBE" + }, + { + "offerId": "30404A88D89A328584307175B8B27616", + "startDate": "2015-11-17T20:52:25.000Z", + "priceDetails": { + "price": 20.99, + "priceWithoutTax": 20.99, + "usePrecision": true, + "formatString": "'US$'#,##0.00", + "taxDisplay": "TAX_EXCLUSIVE", + "taxTerm": "TAX" + }, + "analytics": "{\"offerId\":\"30404A88D89A328584307175B8B27616\",\"label\":\"phsp_direct_individual\",\"price\":\"20.99\",\"amountWithoutTax\":\"20.99\",\"commitmentType\":\"YEAR\",\"billingFrequency\":\"MONTHLY\",\"currencyCode\":\"USD\"}", + "productArrangementCode": "phsp_direct_individual", + "buyingProgram": "RETAIL", + "commitment": "YEAR", + "term": "MONTHLY", + "customerSegment": "INDIVIDUAL", + "marketSegments": ["COM"], + "salesChannel": "DIRECT", + "offerType": "BASE", + "pricePoint": "REGULAR", + "language": "MULT", + "merchant": "ADOBE" + }, + { + "offerId": "BB6F26A4FBD69FFD455EFD7FF3D0CBB0", + "startDate": "2015-11-17T20:09:20.000Z", + "endDate": "2023-12-20T07:58:00.000Z", + "priceDetails": { + "price": 15.99, + "priceWithoutDiscount": 20.99, + "priceWithoutTax": 15.99, + "priceWithoutDiscountAndTax": 20.99, + "usePrecision": true, + "formatString": "'US$'#,##0.00", + "taxDisplay": "TAX_EXCLUSIVE", + "taxTerm": "TAX" + }, + "analytics": "{\"offerId\":\"BB6F26A4FBD69FFD455EFD7FF3D0CBB0\",\"label\":\"phsp_direct_individual\",\"price\":\"15.99\",\"amountWithoutTax\":\"15.99\",\"commitmentType\":\"YEAR\",\"billingFrequency\":\"MONTHLY\",\"currencyCode\":\"USD\"}", + "productArrangementCode": "phsp_direct_individual", + "buyingProgram": "RETAIL", + "commitment": "YEAR", + "term": "MONTHLY", + "customerSegment": "INDIVIDUAL", + "marketSegments": ["COM"], + "salesChannel": "DIRECT", + "offerType": "PROMOTION", + "pricePoint": "PROMO_BLACK_FRIDAY", + "language": "MULT", + "merchant": "ADOBE" + }, + { + "offerId": "57A0ADB680032709167EFF5B8544C3D4", + "startDate": "2020-06-01T13:00:00.000Z", + "priceDetails": { + "price": 358.8, + "priceWithoutTax": 358.8, + "usePrecision": true, + "formatString": "'US$'#,##0.00", + "taxDisplay": "TAX_EXCLUSIVE", + "taxTerm": "TAX" + }, + "analytics": "{\"offerId\":\"57A0ADB680032709167EFF5B8544C3D4\",\"label\":\"perpetual_acrobat_standard_2020_perpetual_individual\",\"price\":\"358.8\",\"amountWithoutTax\":\"358.8\",\"commitmentType\":\"PERPETUAL\",\"currencyCode\":\"USD\"}", + "productArrangementCode": "perpetual_acrobat_standard_2020_perpetual_individual", + "buyingProgram": "RETAIL", + "commitment": "PERPETUAL", + "customerSegment": "INDIVIDUAL", + "marketSegments": ["COM"], + "salesChannel": "DIRECT", + "offerType": "BASE", + "pricePoint": "FULL", + "language": "EN", + "merchant": "ADOBE" + } +] diff --git a/test/blocks/modals/mocks/body.html b/test/blocks/modals/mocks/body.html index 2539742f05..cc0d9f3aab 100644 --- a/test/blocks/modals/mocks/body.html +++ b/test/blocks/modals/mocks/body.html @@ -5,6 +5,13 @@ .modal-curtain.is-open { background: rgb(50 50 50 / 80%); } + @media (max-width: 1200px) { + .dialog-modal.commerce-frame { + width: 100%; + max-width: 100%; + max-height: 100%; + } +} { it('Doesnt load modals on page load with no hash', async () => { @@ -153,4 +153,37 @@ describe('Modals', () => { window.location.hash = ''; await waitForRemoval('#title'); }); + + it('checks if dialog modal has the 100% screen width when screen with is less than 1200', async () => { + await setViewport({ width: 600, height: 100 }); + window.location.hash = '#milo'; + await waitForElement('#milo'); + await getModal({ id: 'animate', path: '/cc-shared/fragments/trial-modals/animate', isHash: true }); + sendViewportDimensionsOnRequest({ data: 'viewportWidth', source: window }); + const dialogmodal = document.getElementsByClassName('dialog-modal')[0]; + dialogmodal.classList.add('commerce-frame'); + expect(window.innerWidth).to.equal(dialogmodal.offsetWidth); + }); + + it('checks if dialog modal is less than screen size if it does not have commerce frame class and screen size is less than 1200', async () => { + const dialogmodal = document.getElementsByClassName('dialog-modal')[0]; + dialogmodal.classList.remove('commerce-frame'); + expect(window.innerWidth).not.equal(dialogmodal.offsetWidth); + }); + + it('does not error for a modal with a non-querySelector compliant hash', async () => { + window.location.hash = '#milo=&'; + + const hashChangeTriggered = new Promise((resolve) => { + window.addEventListener('hashchange', function onHashChange() { + window.removeEventListener('hashchange', onHashChange); + resolve(); + }); + }); + + window.location.hash = ''; + + // Test passing, means there was no error thrown + await hashChangeTriggered; + }); }); diff --git a/test/blocks/ost/mocks/ost-utils.js b/test/blocks/ost/mocks/ost-utils.js new file mode 100644 index 0000000000..b836d797ef --- /dev/null +++ b/test/blocks/ost/mocks/ost-utils.js @@ -0,0 +1,104 @@ +import sinon from 'sinon'; + +const ogFetch = window.fetch; +const ogUrl = window.location.href; + +const getConfig = () => ({ + env: { name: 'local' }, + locales: { + '': { ietf: 'en-US', tk: 'hah7vzn.css' }, + ch_de: { ietf: 'de-CH', tk: 'vin7zsi.css' }, + }, +}); + +const getLocale = (locales, pathname) => locales[pathname.split('/', 2)[1]?.toLowerCase()] || locales['']; + +function getMetadata(name, doc = document) { + const attr = name && name.includes(':') ? 'property' : 'name'; + const meta = doc.head.querySelector(`meta[${attr}="${name}"]`); + return meta && meta.content; +} + +const loadScript = () => Promise.resolve(); + +const loadStyle = () => Promise.resolve(); + +const mockRes = ({ payload, status = 200 } = {}) => new Promise((resolve) => { + resolve({ + status, + statusText: '', + ok: status === 200, + json: () => payload, + text: () => payload, + }); +}); + +function mockOstDeps({ failStatus = false, failMetadata = false, mockToken } = {}) { + const options = { + country: 'CH', + language: 'de', + workflow: 'UCv2', + }; + + const params = { + ref: 'main', + repo: 'milo', + owner: 'adobecom', + host: 'milo.adobe.com', + project: 'Milo', + referrer: 'https://adobe.sharepoint.com/:w:/r/sites/adobecom/_layouts/15/Doc.aspx?sourcedoc=%7B341A5A28-4B2F-4BC0-B7D4-6467E22B275C%7D&file=index.docx&action=default&mobileredirect=true', + token: mockToken ? 'aos-access-token' : undefined, + }; + + window.fetch = sinon.stub() + .onFirstCall() + .callsFake( + () => ( + failStatus + ? mockRes({ status: 500 }) + : mockRes({ payload: { preview: { url: `https://hlx.page/${options.country}_${options.language}/drafts/page` } } }) + ), + ) + .onSecondCall() + .callsFake( + () => ( + failMetadata + ? mockRes({ status: 500 }) + : mockRes({ payload: `` }) + ), + ); + + window.adobeIMS = { + isSignedInUser: sinon.stub().returns(false), + signIn: sinon.stub(), + }; + window.ost = { openOfferSelectorTool: sinon.spy() }; + window.tacocat = { + initLanaLogger: sinon.spy(), + tacocat: sinon.spy(), + }; + + const url = new URL(window.location.href); + Object.entries(params).forEach(([key, value]) => { + if (value) url.searchParams.set(key, value); + }); + window.history.replaceState({}, '', url); + + document.body.innerHTML = '
            '; + + return { options, params }; +} + +function unmockOstDeps() { + document.body.innerHTML = ''; + delete window.ost; + delete window.tacocat; + delete window.adobeid; + delete window.adobeIMS; + window.fetch = ogFetch; + window.history.replaceState({}, '', ogUrl); +} + +export { + getConfig, getLocale, getMetadata, loadScript, loadStyle, mockOstDeps, mockRes, unmockOstDeps, +}; diff --git a/test/blocks/ost/mocks/wcs-artifacts-mock.json b/test/blocks/ost/mocks/wcs-artifacts-mock.json index d27906b636..232092f8c5 100644 --- a/test/blocks/ost/mocks/wcs-artifacts-mock.json +++ b/test/blocks/ost/mocks/wcs-artifacts-mock.json @@ -1,5 +1,5 @@ { - "stockOffer": { + "perpM2M": { "offer_id": "aeb0bf53517d46e89a1b039f859cf573", "commitment": "PERPETUAL", "name": "Stock", diff --git a/test/blocks/ost/ost.test.html b/test/blocks/ost/ost.test.html new file mode 100644 index 0000000000..4ac5d3e3ce --- /dev/null +++ b/test/blocks/ost/ost.test.html @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/test/blocks/ost/ost.test.html.js b/test/blocks/ost/ost.test.html.js new file mode 100644 index 0000000000..8004e60575 --- /dev/null +++ b/test/blocks/ost/ost.test.html.js @@ -0,0 +1,147 @@ +import { expect } from '@esm-bundle/chai'; + +import { mockOstDeps, unmockOstDeps } from './mocks/ost-utils.js'; + +afterEach(() => { + unmockOstDeps(); +}); + +describe('loadOstEnv', async () => { + it('fetches and returns page status and metadata', async () => { + const { + options: { country, language, workflow }, + params, + } = mockOstDeps({ mockToken: true }); + + const { + AOS_API_KEY, + CHECKOUT_CLIENT_ID, + WCS_ENV, + WCS_API_KEY, + loadOstEnv, + } = await import('../../../libs/blocks/ost/ost.js'); + + expect(await loadOstEnv()).to.include({ + aosAccessToken: params.token, + aosApiKey: AOS_API_KEY, + checkoutClientId: CHECKOUT_CLIENT_ID, + workflow, + country, + environment: WCS_ENV, + language, + wcsApiKey: WCS_API_KEY, + }); + }); + + it('tolerates page metadata request fail', async () => { + const { options: { country, language } } = mockOstDeps({ failMetadata: true }); + + const { + AOS_API_KEY, + CHECKOUT_CLIENT_ID, + WCS_ENV, + WCS_API_KEY, + loadOstEnv, + } = await import('../../../libs/blocks/ost/ost.js'); + + expect(await loadOstEnv()).to.include({ + aosAccessToken: null, + aosApiKey: AOS_API_KEY, + checkoutClientId: CHECKOUT_CLIENT_ID, + country, + environment: WCS_ENV, + language, + wcsApiKey: WCS_API_KEY, + }); + }); + + it('tolerates page status request fail', async () => { + mockOstDeps({ failStatus: true }); + + const { + AOS_API_KEY, + CHECKOUT_CLIENT_ID, + WCS_ENV, + WCS_API_KEY, + loadOstEnv, + } = await import('../../../libs/blocks/ost/ost.js'); + + expect(await loadOstEnv()).to.include({ + aosAccessToken: null, + aosApiKey: AOS_API_KEY, + checkoutClientId: CHECKOUT_CLIENT_ID, + country: 'US', + environment: WCS_ENV, + language: 'en', + wcsApiKey: WCS_API_KEY, + }); + }); +}); + +describe('init', () => { + it('opens OST without waiting for IMS if query string includes token', async () => { + const { + options: { country, language, workflow }, + params: { token }, + } = mockOstDeps({ mockToken: true }); + + const { + AOS_API_KEY, + CHECKOUT_CLIENT_ID, + WCS_ENV, + WCS_API_KEY, + default: init, + } = await import('../../../libs/blocks/ost/ost.js'); + await init(document.body.firstChild); + + expect(window.ost.openOfferSelectorTool.called).to.be.true; + expect(window.ost.openOfferSelectorTool.getCall(0).args[0]).to.include({ + aosAccessToken: token, + aosApiKey: AOS_API_KEY, + checkoutClientId: CHECKOUT_CLIENT_ID, + country, + environment: WCS_ENV, + language, + wcsApiKey: WCS_API_KEY, + workflow, + }); + }); + + it('waits for IMS callback to open OST if query string does not include token', async () => { + const { options: { country, language, workflow } } = mockOstDeps({ mockToken: false }); + + const token = 'test-token'; + const { + AOS_API_KEY, + CHECKOUT_CLIENT_ID, + WCS_ENV, + WCS_API_KEY, + default: init, + } = await import('../../../libs/blocks/ost/ost.js'); + await init(document.body.firstChild); + + expect(window.ost.openOfferSelectorTool.called).to.be.false; + window.adobeid.onAccessToken({ token }); + + expect(window.ost.openOfferSelectorTool.called).to.be.true; + expect(window.ost.openOfferSelectorTool.getCall(0).args[0]).to.include({ + aosAccessToken: token, + aosApiKey: AOS_API_KEY, + checkoutClientId: CHECKOUT_CLIENT_ID, + workflow, + country, + environment: WCS_ENV, + language, + wcsApiKey: WCS_API_KEY, + }); + }); + + it('forces IMS sign-in for anonymous user when IMS is ready', async () => { + mockOstDeps({ failStatus: true }); + + const { default: init } = await import('../../../libs/blocks/ost/ost.js'); + await init(document.body.firstChild); + window.adobeid.onReady(); + expect(window.adobeIMS.signIn.called).to.be.true; + }); +}); diff --git a/test/blocks/ost/ost.test.js b/test/blocks/ost/ost.test.js index a1d050eeed..598bcb0497 100644 --- a/test/blocks/ost/ost.test.js +++ b/test/blocks/ost/ost.test.js @@ -1,75 +1,101 @@ import { expect } from '@esm-bundle/chai'; import { readFile } from '@web/test-runner-commands'; -import { createLinkMarkup } from '../../../libs/blocks/ost/ost.js'; -const data = await readFile({ path: './mocks/wcs-artifacts-mock.json' }); -const { stockOffer } = JSON.parse(data); +const { CheckoutWorkflow, CheckoutWorkflowStep } = await import('../../../libs/deps/commerce.js'); +const { DEFAULT_CTA_TEXT, createLinkMarkup } = await import('../../../libs/blocks/ost/ost.js'); +const data = await readFile({ path: './mocks/wcs-artifacts-mock.json' }); +const { perpM2M } = JSON.parse(data); +const defaults = { + checkoutWorkflow: 'UCv3', + checkoutWorkflowStep: 'email', +}; const osi = 'cea462e983f649bca2293325c9894bdd'; -const offerId = 'aeb0bf53517d46e89a1b039f859cf573'; -const offerType = 'M2M'; -const placeholderOptions = { - workflow: 'UCv3', - workflowStep: 'email', - displayRecurrence: false, // term - displayPerUnit: true, // seat - displayTax: true, // tax - isPerpetual: true, +const promo = 'test-promo'; +const texts = { + buy: DEFAULT_CTA_TEXT, + try: 'free-trial', +}; +const types = { + checkoutUrl: 'checkoutUrl', + price: 'price', + opticalPrice: 'opticalPrice', }; -describe('test createLinkMarkup', () => { - const WINDOW_LOCATION = 'https://main--milo--adobecom.hlx.page'; - const location = { - protocol: 'https:', - host: 'main--milo--adobecom.hlx.page', - }; - - it('create a default "cta" link', async () => { - const EXPECTED_CTA_TEXT = 'CTA {{buy-now}}'; - const EXPECTED_CTA_URL = `${WINDOW_LOCATION}/tools/ost?osi=${osi}&offerId=${offerId}&type=checkoutUrl&perp=true&text=buy-now`; - const type = 'checkoutUrl'; - const link = createLinkMarkup( - osi, - type, - stockOffer, - placeholderOptions, - location, - ); - expect(EXPECTED_CTA_TEXT).to.equal(link.text); - expect(EXPECTED_CTA_URL).to.equal(link.href); +function assertLink(link, offer, params, text = texts.buy) { + const { searchParams } = new URL(link.href); + Object.entries(params).forEach(([key, value]) => { + expect(searchParams.get(key)).to.equal(String(value)); }); + if (params.type === types.checkoutUrl) { + expect(searchParams.get('text')).to.equal(text); + expect(link.text).to.equal(`CTA {{${text}}}`); + } else { + expect(link.text).to.equal(`PRICE - ${offer.planType} - ${offer.name}`); + } +} - it('create a "cta" link with overwrites', async () => { - placeholderOptions.workflowStep = 'email_checkout'; - placeholderOptions.workflow = 'UCv2'; - const EXPECTED_CTA_TEXT = 'CTA {{buy-now}}'; - const EXPECTED_CTA_URL = `${WINDOW_LOCATION}/tools/ost?osi=${osi}&offerId=${offerId}&type=checkoutUrl&perp=true&text=buy-now&checkoutType=UCv2&workflowStep=email%2Fcheckout`; +function createLink(params = {}) { + return createLinkMarkup(defaults)( + params.osi ?? osi, + params.type, + perpM2M, + params, + params.promo, + ); +} - const type = 'checkoutUrl'; - const link = createLinkMarkup( - osi, - type, - stockOffer, - placeholderOptions, - location, - ); - expect(EXPECTED_CTA_TEXT).to.equal(link.text); - expect(EXPECTED_CTA_URL).to.equal(link.href); +describe('function "createLinkMarkup"', () => { + describe('creates "cta" link', () => { + const type = types.checkoutUrl; + + it('with default params', async () => { + const link = createLink({ type }); + assertLink(link, perpM2M, { osi, type }); + }); + + it('with promo and custom text', async () => { + const ctaText = texts.try; + const link = createLink({ ctaText, promo, type }); + assertLink(link, perpM2M, { osi, promo, type }, ctaText); + }); + + it('to UCv2 workflow', async () => { + const workflow = CheckoutWorkflow.V2; + const workflowStep = CheckoutWorkflowStep.CHECKOUT_EMAIL; + const link = createLink({ type, workflow, workflowStep }); + assertLink(link, perpM2M, { osi, type, workflow, workflowStep }); + }); }); - it('create a "price" link', async () => { - const EXPECTED_PRICE_TEXT = `{{PRICE - ${offerType} - Stock}}`; - const EXPECTED_PRICE_URL = `${WINDOW_LOCATION}/tools/ost?osi=${osi}&offerId=${offerId}&type=price&perp=true&term=false&seat=true&tax=true`; + describe('creates "price" link', () => { + const type = types.price; + + it('with default params', async () => { + const link = createLink({ type }); + assertLink(link, perpM2M, { osi, type }); + }); - const type = 'price'; - const link = createLinkMarkup( - osi, - type, - stockOffer, - placeholderOptions, - location, - ); - expect(EXPECTED_PRICE_TEXT).to.be.equal(link.text); - expect(EXPECTED_PRICE_URL).to.be.equal(link.href); + it('with custom options', async () => { + const displayRecurrence = true; + const displayPerUnit = true; + const displayTax = true; + const forceTaxExclusive = true; + const link = createLink({ + displayRecurrence, + displayPerUnit, + displayTax, + forceTaxExclusive, + type, + }); + assertLink(link, perpM2M, { + term: displayRecurrence, + seat: displayPerUnit, + tax: displayTax, + exclusive: forceTaxExclusive, + osi, + type, + }); + }); }); }); diff --git a/test/blocks/ost/textOption.test.js b/test/blocks/ost/textOption.test.js new file mode 100644 index 0000000000..a89bd9de3a --- /dev/null +++ b/test/blocks/ost/textOption.test.js @@ -0,0 +1,32 @@ +import { expect } from '@esm-bundle/chai'; +import ctaTextOption from '../../../libs/blocks/ost/ctaTextOption.js'; + +describe('test ctaTextOption', () => { + it('get default text', async () => { + const EXPECTED_DEFAULT_TEXT = 'buy-now'; + const defaultText = ctaTextOption.getDefaultText(); + expect(EXPECTED_DEFAULT_TEXT).to.equal(defaultText); + }); + + it('get texts', async () => { + const EXPECTED_TEXTS = [{ id: 'buy-now', name: 'Buy now' }, + { id: 'free-trial', name: 'Free trial' }, + { id: 'start-free-trial', name: 'Start free trial' }, + { id: 'get-started', name: 'Get started' }, + { id: 'choose-a-plan', name: 'Choose a plan' }, + { id: 'learn-more', name: 'Learn more' }, + { id: 'change-plan-team-plans', name: 'Change Plan Team Plans' }, + { id: 'upgrade', name: 'Upgrade' }, + { id: 'change-plan-team-payment', name: 'Change Plan Team Payment' }, + { id: 'take-the-quiz', name: 'Take the quiz' }, + { id: 'see-more', name: 'See more' }]; + const texts = ctaTextOption.getTexts(); + expect(EXPECTED_TEXTS).to.deep.equal(texts); + }); + + it('get selected text', async () => { + const EXPECTED_SELECTED_TEXT = 'buy-now'; + const selectedText = ctaTextOption.getSelectedText(new Map().set('text', 'buy-now')); + expect(EXPECTED_SELECTED_TEXT).to.equal(selectedText); + }); +}); diff --git a/test/blocks/pdf-viewer/mocks/body.html b/test/blocks/pdf-viewer/mocks/body.html index 749985612e..964a56b28c 100644 --- a/test/blocks/pdf-viewer/mocks/body.html +++ b/test/blocks/pdf-viewer/mocks/body.html @@ -1,2 +1,2 @@
            - + diff --git a/test/blocks/quiz-results/mocks/body.html b/test/blocks/quiz-results/mocks/body.html new file mode 100644 index 0000000000..835fa65706 --- /dev/null +++ b/test/blocks/quiz-results/mocks/body.html @@ -0,0 +1,34 @@ +
            +
            +
            +
            quiz-url
            +
            http://this-is-a-fake-redirect-url
            +
            +
            +
            +
            +
            nested-fragments
            +
            marquee-product
            +
            +
            +
            style
            +
            m-spacing
            +
            +
            +
            +
            +
            nested-fragments
            +
            marquee-product
            +
            +
            +
            style
            +
            m-spacing
            +
            +
            +
            +
            +
            quiz-url
            +
            http://this-is-a-fake-redirect-url
            +
            +
            +
            diff --git a/test/blocks/quiz-results/mocks/fragments/basic-frag.plain.html b/test/blocks/quiz-results/mocks/fragments/basic-frag.plain.html new file mode 100644 index 0000000000..f8f929ce70 --- /dev/null +++ b/test/blocks/quiz-results/mocks/fragments/basic-frag.plain.html @@ -0,0 +1,3 @@ +
            +

            This is a basic fragment

            +
            diff --git a/test/blocks/quiz-results/mocks/fragments/nested-frag.plain.html b/test/blocks/quiz-results/mocks/fragments/nested-frag.plain.html new file mode 100644 index 0000000000..1b2958499c --- /dev/null +++ b/test/blocks/quiz-results/mocks/fragments/nested-frag.plain.html @@ -0,0 +1,3 @@ +
            +

            This is a nested fragment

            +
            diff --git a/test/blocks/quiz-results/mocks/quiz-results.mock-data.js b/test/blocks/quiz-results/mocks/quiz-results.mock-data.js new file mode 100644 index 0000000000..a1189aac41 --- /dev/null +++ b/test/blocks/quiz-results/mocks/quiz-results.mock-data.js @@ -0,0 +1,35 @@ +const resultsMock = { + mockOne: { + badBasicFragments: [ + '/test/blocks/quiz-results/mocks/fragments/basic-frag', + ], + nestedFragments: { + 'marquee-product': [ + '/test/blocks/quiz-results/mocks/fragments/nested-frag', + ], + }, + }, + mockTwo: { + basicFragments: [ + '/test/blocks/quiz-results/mocks/fragments/basic-frag', + ], + nestedFragments: { + 'marquee-product': [ + '/test/blocks/quiz-results/mocks/fragments/nested-frag', + ], + }, + }, + mockThree: { + basicFragments: [ + '/test/blocks/quiz-results/mocks/fragments/basic-frag', + ], + nestedFragments: { + 'marquee-product': [ + '/test/blocks/quiz-results/mocks/fragments/nested-frag', + ], + }, + pageloadHash: 'test analytics value', + }, +}; + +export default resultsMock; diff --git a/test/blocks/quiz-results/quiz-results.test.js b/test/blocks/quiz-results/quiz-results.test.js new file mode 100644 index 0000000000..89c24006ee --- /dev/null +++ b/test/blocks/quiz-results/quiz-results.test.js @@ -0,0 +1,66 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; +import { stub } from 'sinon'; +import { delay } from '../../helpers/waitfor.js'; + +window.lana = { log: stub() }; +localStorage.clear(); + +document.body.innerHTML = await readFile({ path: './mocks/body.html' }); +const { default: init, LOADING_ERROR } = await import('../../../libs/blocks/quiz-results/quiz-results.js'); +const { default: mockData } = await import('./mocks/quiz-results.mock-data.js'); + +describe('Quiz Results', () => { + it('Doesnt load data without local storage', async () => { + const el = document.body.querySelector('.basic'); + localStorage.clear(); + + await init(el, 'quiz-results', 'quiz-result-test'); + + expect(window.lana.log.args[0][0]).to.equal(`${LOADING_ERROR} local storage missing`); + }); + it('Doesnt load data without basicFragments in local storage', async () => { + const el = document.body.querySelector('.basic-one'); + localStorage.setItem('quiz-result-test', JSON.stringify(mockData.mockOne)); + + await init(el, 'quiz-results', 'quiz-result-test'); + + expect(window.lana.log.args[1][0]).to.equal(`${LOADING_ERROR} Basic fragments are missing`); + }); + it('Loads basic fragments', async () => { + const el = document.body.querySelector('.basic-one'); + localStorage.setItem('quiz-result-test', JSON.stringify(mockData.mockTwo)); + + await init(el, 'quiz-results', 'quiz-result-test'); + + await delay(700); + expect(el.querySelector('h1')).to.be.exist; + }); + it('Loads nested fragments', async () => { + const el = document.body.querySelector('.nested-one'); + localStorage.setItem('quiz-result-test', JSON.stringify(mockData.mockTwo)); + await init(el, 'quiz-results', 'quiz-result-test'); + + await delay(700); + expect(el.querySelector('h2')).to.be.exist; + }); + it('Sets style values', async () => { + const el = document.body.querySelector('.nested-two'); + localStorage.setItem('quiz-result-test', JSON.stringify(mockData.mockTwo)); + + await init(el, 'quiz-results', 'quiz-result-test'); + + await delay(700); + expect(el.classList.contains('section')).to.be.true; + expect(el.classList.contains('m-spacing')).to.be.true; + }); + it('Sets analytics customHash', async () => { + const el = document.body.querySelector('.basic-two'); + localStorage.setItem('quiz-result-test', JSON.stringify(mockData.mockThree)); + + await init(el, 'quiz-results', 'quiz-result-test'); + + /* eslint-disable no-underscore-dangle */ + expect(window.alloy_all.data._adobe_corpnew.digitalData.page.pageInfo.customHash).to.equal('test analytics value'); + }); +}); diff --git a/test/blocks/quiz/mocks/index.html b/test/blocks/quiz/mocks/index.html new file mode 100644 index 0000000000..9e64c7af0c --- /dev/null +++ b/test/blocks/quiz/mocks/index.html @@ -0,0 +1,25 @@ +
            +
            + +
            +
            css
            +
            custom css
            +
            +
            +
            storagePath
            +
            cc-quiz
            +
            +
            +
            analytics-type
            +
            cc:app-reco
            +
            +
            +
            analytics-quiz
            +
            UARv3
            +
            +
            +
            +
            diff --git a/test/blocks/quiz/mocks/mock-states.js b/test/blocks/quiz/mocks/mock-states.js new file mode 100644 index 0000000000..bfceef1863 --- /dev/null +++ b/test/blocks/quiz/mocks/mock-states.js @@ -0,0 +1,116 @@ +export const userSelection = [ + { + selectedQuestion: { + questions: 'q-category', + 'max-selections': '3', + 'min-selections': '1', + }, + selectedCards: { + photo: true, + video: true, + }, + }, + { + selectedQuestion: { + questions: 'q-rather', + 'max-selections': '1', + 'min-selections': '1', + }, + selectedCards: { custom: true }, + }, + { + selectedQuestion: { + questions: 'q-photo', + 'max-selections': '1', + 'min-selections': '1', + }, + selectedCards: { organize: true }, + }, + { + selectedQuestion: { + questions: 'q-video', + 'max-selections': '1', + 'min-selections': '1', + }, + selectedCards: { social: true }, + }, + { + selectedQuestion: { + questions: 'q-customer', + 'max-selections': '1', + 'min-selections': '1', + }, + selectedCards: { individual: true }, + }, +]; + +export const answers = [ + ['q-category', ['photo', 'video']], + ['q-rather', ['custom']], + ['q-photo', ['organize']], + ['q-video', ['social']], + ['q-customer', ['individual']], +]; + +export const resultRules = [ + { + result: '(ai,ai-edu,ai-bus,ai-ind,au-edu,au-bus,au-ind,an-edu,an-bus,an-ind,ae-edu,ae-bus,ae-ind,lr-edu,lr-bus,lr-ind,id,pr-edu,pr-bus,pr-ind,ps-bus,ps-edu,ps-ind,ac,pdf)&(ai,ai-edu,ai-bus,ai-ind,au-edu,au-bus,au-ind,an-edu,an-bus,an-ind,ae-edu,ae-bus,ae-ind,lr-edu,lr-bus,lr-ind,id,pr-edu,pr-bus,pr-ind,ps-bus,ps-edu,ps-ind,ac,pdf)', + 'umbrella-result': 'cc', + url: '/path/to/result', + 'basic-fragments': 'marquee, card-list', + 'nested-fragments-primary': '', + 'nested-fragments-secondary': 'marquee-product, commerce-card', + }, + { + result: '(ai,ai-edu,ai-bus,ai-ind,au-edu,au-bus,au-ind,an-edu,an-bus,an-ind,ae-edu,ae-bus,ae-ind,lr-edu,lr-bus,lr-ind,id,pr-edu,pr-bus,pr-ind,ps-bus,ps-edu,ps-ind,ac,pdf)', + 'umbrella-result': '', + url: '/path/to/result', + 'basic-fragments': 'marquee, card-list', + 'nested-fragments-primary': 'check-bullet,marquee-plan', + 'nested-fragments-secondary': 'commerce-card', + }, + { + result: '(3d,ai,ai-edu,ai-bus,ai-ind,au-edu,au-bus,au-ind,an-edu,an-bus,an-ind,ae-edu,ae-bus,ae-ind,lr-edu,lr-bus,lr-ind,id,pr-edu,pr-bus,pr-ind,ps-bus,ps-edu,ps-ind,ac,pdf)&(3d,ai,ai-edu,ai-bus,ai-ind,au-edu,au-bus,au-ind,an-edu,an-bus,an-ind,ae-edu,ae-bus,ae-ind,lr-edu,lr-bus,lr-ind,id,pr-edu,pr-bus,pr-ind,ps-bus,ps-edu,ps-ind,ac,pdf)', + 'umbrella-result': '3d-umbrella', + url: '/path/to/result', + 'basic-fragments': 'marquee, card-list', + 'nested-fragments-primary': '', + 'nested-fragments-secondary': '', + }, + { + result: 'default', + 'umbrella-result': '', + url: '/path/to/result', + 'basic-fragments': 'marquee, card-list', + 'nested-fragments-primary': '', + 'nested-fragments-secondary': 'commerce-card', + }, + { + result: 'express', + 'umbrella-result': '', + url: '/path/to/result', + 'basic-fragments': 'marquee, card-list', + 'nested-fragments-primary': '', + 'nested-fragments-secondary': 'commerce-card', + }, +]; + +export const resultData = { + primary: [ + 'lr-ind', + 'pr-ind', + ], + secondary: [ + 'ps-ind', + 'au-ind', + ], + matchedResults: [ + { + result: '(ai,ai-edu,ai-bus,ai-ind,au-edu,au-bus,au-ind,an-edu,an-bus,an-ind,ae-edu,ae-bus,ae-ind,lr-edu,lr-bus,lr-ind,id,pr-edu,pr-bus,pr-ind,ps-bus,ps-edu,ps-ind,ac,pdf)&(ai,ai-edu,ai-bus,ai-ind,au-edu,au-bus,au-ind,an-edu,an-bus,an-ind,ae-edu,ae-bus,ae-ind,lr-edu,lr-bus,lr-ind,id,pr-edu,pr-bus,pr-ind,ps-bus,ps-edu,ps-ind,ac,pdf)', + 'umbrella-result': 'cc', + url: '/path/to/result', + 'basic-fragments': 'marquee, card-list', + 'nested-fragments': 'marquee-product, commerce-card', + }, + ], +}; diff --git a/test/blocks/quiz/mocks/questions.json b/test/blocks/quiz/mocks/questions.json new file mode 100644 index 0000000000..b328e8b5d3 --- /dev/null +++ b/test/blocks/quiz/mocks/questions.json @@ -0,0 +1,289 @@ +{ + "questions": { + "total": 9, + "offset": 0, + "limit": 9, + "data": [ + { + "questions": "q-category", + "max-selections": "3", + "min-selections": "1" + }, + { + "questions": "q-rather", + "max-selections": "1", + "min-selections": "1" + }, + { + "questions": "q-video", + "max-selections": "1", + "min-selections": "1" + }, + { + "questions": "q-photo", + "max-selections": "1", + "min-selections": "1" + }, + { + "questions": "q-pdf", + "max-selections": "1", + "min-selections": "1" + }, + { + "questions": "q-customer", + "max-selections": "1", + "min-selections": "1" + }, + { + "questions": "q-3d", + "max-selections": "1", + "min-selections": "1" + }, + { + "questions": "q-design", + "max-selections": "1", + "min-selections": "1" + }, + { + "questions": "q-illustration", + "max-selections": "1", + "min-selections": "1" + } + ] + }, + "q-rather": { + "total": 2, + "offset": 0, + "limit": 2, + "data": [ + { + "options": "template", + "next": "RESET,q-customer", + "data-analytics-title": "ccx" + }, + { + "options": "custom", + "next": "RESULT", + "data-analytics-title": "Flagship" + } + ] + }, + "q-customer": { + "total": 3, + "offset": 0, + "limit": 3, + "data": [ + { + "options": "educational", + "next": "RESULT" + }, + { + "options": "business", + "next": "RESULT" + }, + { + "options": "individual", + "next": "RESULT" + } + ] + }, + "q-design": { + "total": 3, + "offset": 0, + "limit": 3, + "data": [ + { + "options": "layouts", + "next": "q-customer" + }, + { + "options": "images", + "next": "q-customer" + }, + { + "options": "graphics", + "next": "q-customer" + } + ] + }, + "q-3d": { + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "options": "stage", + "next": "q-customer" + }, + { + "options": "texture", + "next": "q-customer" + }, + { + "options": "materials", + "next": "q-customer" + }, + { + "options": "model", + "next": "q-customer" + }, + { + "options": "assets", + "next": "q-customer" + } + ] + }, + "q-category": { + "total": 6, + "offset": 0, + "limit": 6, + "data": [ + { + "options": "photo", + "next": "q-rather,q-photo" + }, + { + "options": "video", + "next": "q-rather,q-video" + }, + { + "options": "design", + "next": "q-rather,q-design" + }, + { + "options": "illustration", + "next": "q-rather,q-illustration" + }, + { + "options": "3d", + "next": "NOT(q-rather),q-3d" + }, + { + "options": "pdf", + "next": "q-rather,q-pdf" + } + ] + }, + "q-photo": { + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "options": "organize", + "next": "q-customer" + }, + { + "options": "batch", + "next": "q-customer" + }, + { + "options": "edit", + "next": "q-customer" + }, + { + "options": "color", + "next": "q-customer" + }, + { + "options": "blend", + "next": "q-customer" + } + ] + }, + "q-illustration": { + "total": 4, + "offset": 0, + "limit": 4, + "data": [ + { + "options": "raster", + "next": "q-customer" + }, + { + "options": "vector", + "next": "q-customer" + }, + { + "options": "crisp", + "next": "q-customer" + }, + { + "options": "images", + "next": "q-customer" + } + ] + }, + "q-video": { + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "options": "social", + "next": "q-customer" + }, + { + "options": "pro", + "next": "q-customer" + }, + { + "options": "movement", + "next": "q-customer" + }, + { + "options": "animate", + "next": "q-customer" + }, + { + "options": "sound", + "next": "q-customer" + } + ] + }, + "q-pdf": { + "total": 6, + "offset": 0, + "limit": 6, + "data": [ + { + "options": "create", + "next": "q-customer" + }, + { + "options": "edit", + "next": "q-customer" + }, + { + "options": "share", + "next": "q-customer" + }, + { + "options": "protect", + "next": "q-customer" + }, + { + "options": "sign", + "next": "q-customer" + }, + { + "options": "track", + "next": "q-customer" + } + ] + }, + ":version": 3, + ":names": [ + "questions", + "q-rather", + "q-customer", + "q-design", + "q-3d", + "q-category", + "q-photo", + "q-illustration", + "q-video", + "q-pdf" + ], + ":type": "multi-sheet" +} diff --git a/test/blocks/quiz/mocks/quiz.html b/test/blocks/quiz/mocks/quiz.html new file mode 100644 index 0000000000..169a44844d --- /dev/null +++ b/test/blocks/quiz/mocks/quiz.html @@ -0,0 +1,176 @@ + +
            +
            +
            + +
            +
            css
            +
            custom css
            +
            +
            +
            storagePath
            +
            cc-quiz
            +
            +
            +
            analytics-type
            +
            cc:app-reco
            +
            +
            +
            analytics-quiz
            +
            UARv3
            +
            +
            +
            +
            +
            +
            +
            +
            +
            + + + + + + +
            +
            +
            +
            +

            What do you want to do today?

            +

            Pick up to three.

            +
            +
            +
            +
            +
            +
            +
            +
            +
            Video
            +
            +

            Photography

            +

            Edit or organize my photos

            +
            +
            +
            +
            +
            +
            +
            +
            Video
            +
            +

            Video

            +

            Create and edit video or audio

            +
            +
            +
            +
            +
            +
            +
            +
            Video
            +
            +

            Graphic design

            +

            Design layouts or websites

            +
            +
            +
            +
            +
            +
            +
            +
            Video
            +
            +

            Illustration

            +

            Paint, draw, or create illustrations

            +
            +
            +
            +
            +
            +
            +
            +
            Video
            +
            +

            PDFs

            +

            Create, edit, or sign PDFs

            +
            +
            +
            +
            +
            +
            +
            +
            Video
            +
            +

            3D/AR

            +

            Model, texture, and render 3D assets and scenes

            +
            +
            +
            +
            +
            +
            +
            +
            + +
            +
            +
            +
            +
            +
            + +
            +
            css
            +
            custom css
            +
            +
            +
            storagePath
            +
            cc-quiz
            +
            +
            +
            analytics-type
            +
            cc:app-reco
            +
            +
            +
            analytics-quiz
            +
            UARv3
            +
            +
            +
            +
            +
            + diff --git a/test/blocks/quiz/mocks/result-resources.json b/test/blocks/quiz/mocks/result-resources.json new file mode 100644 index 0000000000..cc6929189c --- /dev/null +++ b/test/blocks/quiz/mocks/result-resources.json @@ -0,0 +1,207 @@ +{ + "total": 25, + "offset": 0, + "limit": 25, + "data": [ + { + "product": "ps-edu", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "ps-bus", + "marquee": "/path/to/marquee", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "ps-ind", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "lr-edu", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "lr-bus", + "marquee": "/path/to/marquee", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "lr-ind", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "pr-edu", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "pr-bus", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "pr-ind", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "ae", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "an", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "/fragments/card/animate", + "marquee-product": "", + "page": "" + }, + { + "product": "au", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "id", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "ai", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "acrobat-pro", + "marquee": "/fragments/marquee/acrobat", + "card-list": "", + "commerce-card": "/fragments/card/acrobat", + "marquee-product": "", + "page": "" + }, + { + "product": "substance-3d-collection", + "marquee": "/fragments/marquee/subtance-3d", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "substance-3d-stager", + "marquee": "/fragments/marquee/subtance-3d-stager", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "substance-3d-painter", + "marquee": "/fragments/marquee/subtance-3d-painter", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "substance-3d-sampler", + "marquee": "/fragments/marquee/subtance-3d-sampler", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "substance-3d-modeler", + "marquee": "/fragments/marquee/subtance-3d-modeler", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "substance-3d-designer", + "marquee": "/fragments/marquee/subtance-3d-designer", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "express", + "marquee": "/path/to/marquee", + "card-list": "", + "commerce-card": "path-to-commerce-card", + "marquee-product": "", + "page": "" + }, + { + "product": "cc", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "ac", + "marquee": "", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "page": "" + }, + { + "product": "3d-umbrella", + "marquee": "", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "page": "" + } + ] +} diff --git a/test/blocks/quiz/mocks/results.json b/test/blocks/quiz/mocks/results.json new file mode 100644 index 0000000000..46ed09b352 --- /dev/null +++ b/test/blocks/quiz/mocks/results.json @@ -0,0 +1,907 @@ +{ + "result": { + "total": 49, + "offset": 0, + "limit": 49, + "data": [ + { + "q-category": "photo", + "q-photo": "organize", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "educational", + "result-primary": "lr-edu", + "result-secondary": "ps-edu" + }, + { + "q-category": "photo", + "q-photo": "organize", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "business", + "result-primary": "lr-bus", + "result-secondary": "ps-bus" + }, + { + "q-category": "photo", + "q-photo": "organize", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "individual", + "result-primary": "lr-ind", + "result-secondary": "ps-ind" + }, + { + "q-category": "photo", + "q-photo": "batch", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "educational", + "result-primary": "lr-edu", + "result-secondary": "lr-edu" + }, + { + "q-category": "photo", + "q-photo": "batch", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "business", + "result-primary": "lr-bus", + "result-secondary": "lr-bus" + }, + { + "q-category": "photo", + "q-photo": "batch", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "individual", + "result-primary": "lr-ind", + "result-secondary": "lr-ind" + }, + { + "q-category": "photo", + "q-photo": "edit", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "educational", + "result-primary": "ps-edu", + "result-secondary": "lr-edu" + }, + { + "q-category": "photo", + "q-photo": "edit", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "business", + "result-primary": "ps-bus", + "result-secondary": "lr-bus" + }, + { + "q-category": "photo", + "q-photo": "edit", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "individual", + "result-primary": "ps-ind", + "result-secondary": "lr-ind" + }, + { + "q-category": "photo", + "q-photo": "color", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "educational", + "result-primary": "lr-edu", + "result-secondary": "ps-edu" + }, + { + "q-category": "photo", + "q-photo": "color", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "custom", + "result-primary": "lr-bus", + "result-secondary": "ps-bus" + }, + { + "q-category": "photo", + "q-photo": "color", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "individual", + "result-primary": "lr-ind", + "result-secondary": "ps-ind" + }, + { + "q-category": "photo", + "q-photo": "blend", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "educational", + "result-primary": "ps-edu", + "result-secondary": "lr-edu" + }, + { + "q-category": "photo", + "q-photo": "blend", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "business", + "result-primary": "ps-bus", + "result-secondary": "lr-bus" + }, + { + "q-category": "photo", + "q-photo": "blend", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "individual", + "result-primary": "ps-ind", + "result-secondary": "lr-ind" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "social", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "educational", + "result-primary": "pr-edu", + "result-secondary": "ps-edu,au-edu" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "social", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "business", + "result-primary": "pr-bus", + "result-secondary": "ps-bus,au-bus" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "social", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "individual", + "result-primary": "pr-ind", + "result-secondary": "ps-ind,au-ind" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "pro", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "educational", + "result-primary": "pr-edu", + "result-secondary": "ps-edu,au-edu" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "pro", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "business", + "result-primary": "business", + "result-secondary": "ps-bus,au-bus" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "pro", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "individual", + "result-primary": "pr-ind", + "result-secondary": "ps-ind,au-ind" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "movement", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "educational", + "result-primary": "ae-edu", + "result-secondary": "ps-edu,ai-edu,pr-edu" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "movement", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "business", + "result-primary": "ae-bus", + "result-secondary": "ps-bus,ai-bus,pr-bus" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "movement", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "individual", + "result-primary": "ae-ind", + "result-secondary": "ps-ind,ai-ind,pr-ind" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "animate", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "educational", + "result-primary": "an-edu", + "result-secondary": "ps-edu,ai-edu,pr-edu" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "animate", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "business", + "result-primary": "an-bus", + "result-secondary": "ps-bus,ai-bus,pr-bus" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "animate", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "individual", + "result-primary": "an-ind", + "result-secondary": "ps-ind,ai-ind,pr-ind" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "sound", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "educational", + "result-primary": "au-edu", + "result-secondary": "ps-edu,pr-edu,ai-edu" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "sound", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "business", + "result-primary": "au-bus", + "result-secondary": "ps-bus,pr-bus,ai-bus" + }, + { + "q-category": "video", + "q-photo": "", + "q-video": "sound", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "individual", + "result-primary": "au-ind", + "result-secondary": "ps-ind,pr-ind,ai-ind" + }, + { + "q-category": "design", + "q-photo": "", + "q-video": "", + "q-design": "layouts", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "", + "result-primary": "id", + "result-secondary": "ps,ai,pr" + }, + { + "q-category": "design", + "q-photo": "", + "q-video": "", + "q-design": "images", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "", + "result-primary": "ps", + "result-secondary": "ai,id,pr" + }, + { + "q-category": "design", + "q-photo": "", + "q-video": "", + "q-design": "graphics", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "", + "result-primary": "ai", + "result-secondary": "ps,id,pr" + }, + { + "q-category": "illustration", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "raster", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "", + "result-primary": "ps", + "result-secondary": "ai,id,pr" + }, + { + "q-category": "illustration", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "vector", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "", + "result-primary": "ai", + "result-secondary": "ps,id,pr" + }, + { + "q-category": "illustration", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "crisp", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "", + "result-primary": "ai", + "result-secondary": "ps,id,pr" + }, + { + "q-category": "illustration", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "images", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "", + "result-primary": "ps", + "result-secondary": "ai,id,pr" + }, + { + "q-category": "pdf", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "create", + "q-3d": "", + "q-rather": "custom", + "q-customer": "", + "result-primary": "acrobat-pro", + "result-secondary": "ps,pr,ai" + }, + { + "q-category": "pdf", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "edit", + "q-3d": "", + "q-rather": "custom", + "q-customer": "", + "result-primary": "acrobat-pro", + "result-secondary": "ps,pr,ai" + }, + { + "q-category": "pdf", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "share", + "q-3d": "", + "q-rather": "custom", + "q-customer": "", + "result-primary": "acrobat-pro", + "result-secondary": "ps,pr,ai" + }, + { + "q-category": "pdf", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "protect", + "q-3d": "", + "q-rather": "custom", + "q-customer": "", + "result-primary": "acrobat-pro", + "result-secondary": "ps,pr,ai" + }, + { + "q-category": "pdf", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "sign", + "q-3d": "", + "q-rather": "custom", + "q-customer": "", + "result-primary": "acrobat-pro", + "result-secondary": "ps,pr,ai" + }, + { + "q-category": "pdf", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "custom", + "q-customer": "", + "result-primary": "acrobat-pro", + "result-secondary": "ps,pr,ai" + }, + { + "q-category": "3d", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "stage", + "q-rather": "custom", + "q-customer": "", + "result-primary": "substance-3d-stager", + "result-secondary": "substance-3d-collection" + }, + { + "q-category": "3d", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "texture", + "q-rather": "custom", + "q-customer": "", + "result-primary": "substance-3d-painter", + "result-secondary": "substance-3d-collection" + }, + { + "q-category": "3d", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "materials", + "q-rather": "custom", + "q-customer": "", + "result-primary": "substance-3d-sampler", + "result-secondary": "substance-3d-collection" + }, + { + "q-category": "3d", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "model", + "q-rather": "custom", + "q-customer": "", + "result-primary": "substance-3d-modeler", + "result-secondary": "substance-3d-collection" + }, + { + "q-category": "3d", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "assets", + "q-rather": "custom", + "q-customer": "", + "result-primary": "substance-3d-designer", + "result-secondary": "substance-3d-collection" + }, + { + "q-category": "", + "q-photo": "", + "q-video": "", + "q-design": "", + "q-illustration": "", + "q-pdf": "", + "q-3d": "", + "q-rather": "template", + "q-customer": "", + "result-primary": "express", + "result-secondary": "" + } + ] + }, + "result-fragments": { + "total": 25, + "offset": 0, + "limit": 25, + "data": [ + { + "product": "ps-edu", + "marquee": "path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "/path/to/commerce-card", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "ps-bus", + "marquee": "path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "/path/to/commerce-card", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "ps-ind", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "/path/to/commerce-card", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "lr-edu", + "marquee": "/path/to/marquee", + "card-list": "", + "commerce-card": "/path/to/commerce-card", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "lr-bus", + "marquee": "/path/to/marquee", + "card-list": "", + "commerce-card": "/path/to/commerce-card", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "lr-ind", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "/path/to/commerce-card", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "pr-edu", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "/path/to/commerce-card", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "pr-bus", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "/path/to/commerce-card", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "pr-ind", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "/path/to/commerce-card", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "ae", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "/path/to/commerce-card", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "an-ind", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "/fragments/card/animate", + "marquee-product": "", + "check-bullet": "path/to/bullet1,path/to/bullet2" + }, + { + "product": "au", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "/path/to/commerce-card", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "id", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "/path/to/commerce-card", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "ai", + "marquee": "/path/to/marquee", + "card-list": "/path/to/cardlist", + "commerce-card": "/path/to/commerce-card", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "acrobat-pro", + "marquee": "/fragments/marquee/acrobat", + "card-list": "", + "commerce-card": "/fragments/card/acrobat", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "substance-3d-collection", + "marquee": "/fragments/marquee/subtance-3d", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "substance-3d-stager", + "marquee": "/fragments/marquee/subtance-3d-stager", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "substance-3d-painter", + "marquee": "/fragments/marquee/subtance-3d-painter", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "substance-3d-sampler", + "marquee": "/fragments/marquee/subtance-3d-sampler", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "substance-3d-modeler", + "marquee": "/fragments/marquee/subtance-3d-modeler", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "substance-3d-designer", + "marquee": "/fragments/marquee/subtance-3d-designer", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "express", + "marquee": "/path/to/marquee", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "cc", + "marquee": "/path/to/marquee", + "card-list": "q", + "commerce-card": "", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "ac", + "marquee": "", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "check-bullet": "" + }, + { + "product": "3d-umbrella", + "marquee": "", + "card-list": "", + "commerce-card": "", + "marquee-product": "", + "check-bullet": "" + } + ] + }, + "result-destination": { + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "result": "(ai,ai-edu,ai-bus,ai-ind,au-edu,au-bus,au-ind,an-edu,an-bus,an-ind,ae-edu,ae-bus,ae-ind,lr-edu,lr-bus,lr-ind,id,pr-edu,pr-bus,pr-ind,ps-bus,ps-edu,ps-ind,ac,pdf)&(ai,ai-edu,ai-bus,ai-ind,au-edu,au-bus,au-ind,an-edu,an-bus,an-ind,ae-edu,ae-bus,ae-ind,lr-edu,lr-bus,lr-ind,id,pr-edu,pr-bus,pr-ind,ps-bus,ps-edu,ps-ind,ac,pdf)", + "umbrella-result": "cc", + "url": "/path/to/result", + "basic-fragments": "marquee, card-list", + "nested-fragments-primary": "", + "nested-fragments-secondary": "marquee-product, commerce-card" + }, + { + "result": "(ai,ai-edu,ai-bus,ai-ind,au-edu,au-bus,au-ind,an-edu,an-bus,an-ind,ae-edu,ae-bus,ae-ind,lr-edu,lr-bus,lr-ind,id,pr-edu,pr-bus,pr-ind,ps-bus,ps-edu,ps-ind,ac,pdf)", + "umbrella-result": "", + "url": "/path/to/result", + "basic-fragments": "marquee, card-list", + "nested-fragments-primary": "check-bullet,marquee-plan", + "nested-fragments-secondary": "commerce-card" + }, + { + "result": "(3d,ai,ai-edu,ai-bus,ai-ind,au-edu,au-bus,au-ind,an-edu,an-bus,an-ind,ae-edu,ae-bus,ae-ind,lr-edu,lr-bus,lr-ind,id,pr-edu,pr-bus,pr-ind,ps-bus,ps-edu,ps-ind,ac,pdf)&(3d,ai,ai-edu,ai-bus,ai-ind,au-edu,au-bus,au-ind,an-edu,an-bus,an-ind,ae-edu,ae-bus,ae-ind,lr-edu,lr-bus,lr-ind,id,pr-edu,pr-bus,pr-ind,ps-bus,ps-edu,ps-ind,ac,pdf)", + "umbrella-result": "3d-umbrella", + "url": "/path/to/result", + "basic-fragments": "marquee, card-list", + "nested-fragments-primary": "", + "nested-fragments-secondary": "" + }, + { + "result": "default", + "umbrella-result": "", + "url": "/path/to/result", + "basic-fragments": "marquee, card-list", + "nested-fragments-primary": "", + "nested-fragments-secondary": "commerce-card" + }, + { + "result": "express", + "umbrella-result": "", + "url": "/path/to/result", + "basic-fragments": "marquee, card-list", + "nested-fragments-primary": "", + "nested-fragments-secondary": "commerce-card" + } + ] + }, + ":version": 3, + ":names": [ + "result", + "result-fragments", + "result-destination" + ], + ":type": "multi-sheet" +} diff --git a/test/blocks/quiz/mocks/strings.json b/test/blocks/quiz/mocks/strings.json new file mode 100644 index 0000000000..27f8cb7684 --- /dev/null +++ b/test/blocks/quiz/mocks/strings.json @@ -0,0 +1,431 @@ +{ + "questions": { + "total": 9, + "offset": 0, + "limit": 9, + "data": [ + { + "q": "q-category", + "heading": "What do you want to do today?", + "sub-head": "Pick up to three.", + "btn": "Next", + "background": "https://cc-prod.scene7.com/is/image/CCProdAuthor/DSK-Q1%20BKGD%202X?$pjpeg$&jpegSize=300&wid=1920", + "footerFragment": "" + }, + { + "q": "q-photo", + "heading": "What do you want to do with photos?", + "sub-head": "Pick one.", + "btn": "Next", + "background": "https://cc-prod.scene7.com/is/image/CCProdAuthor/DSK-Q2-Photo%20BKGD%202X?$pjpeg$&jpegSize=300&wid=1920", + "footerFragment": "" + }, + { + "q": "q-video", + "heading": "What do you want to do with video?", + "sub-head": "Pick one.", + "btn": "Next", + "background": "https://cc-prod.scene7.com/is/image/CCProdAuthor/DSK-Q2-Photo%20BKGD%202X?$pjpeg$&jpegSize=300&wid=1920", + "footerFragment": "" + }, + { + "q": "q-design", + "heading": "What do you want to do with design?", + "sub-head": "Pick one.", + "btn": "Next", + "background": "https://cc-prod.scene7.com/is/image/CCProdAuthor/DSK-Q2-Design%20BKGD%202X?$pjpeg$&jpegSize=300&wid=1920", + "footerFragment": "" + }, + { + "q": "q-illustration", + "heading": "What do you want to do with illustration?", + "sub-head": "Pick one.", + "btn": "Next", + "background": "https://cc-prod.scene7.com/is/image/CCProdAuthor/DSK-Q2-Illustration%20BKGD%202X?$pjpeg$&jpegSize=300&wid=1920", + "footerFragment": "" + }, + { + "q": "q-pdf", + "heading": "What do you want to do with PDFs?", + "sub-head": "Pick one.", + "btn": "Next", + "background": "https://cc-prod.scene7.com/is/image/CCProdAuthor/DSK-Q2-Acrobat%20BKGD%202X?$pjpeg$&jpegSize=300&wid=1920", + "footerFragment": "" + }, + { + "q": "q-3d", + "heading": "What do you want to do with 3D/AR?", + "sub-head": "Pick one.", + "btn": "Next", + "background": "https://cc-prod.scene7.com/is/image/CCProdAuthor/DSK-3D-AR%20BKGD?$pjpeg$&jpegSize=300&wid=1920", + "footerFragment": "" + }, + { + "q": "q-rather", + "heading": "For your projects, would you rather:", + "sub-head": "Pick one.", + "btn": "Next", + "background": "https://cc-prod.scene7.com/is/image/CCProdAuthor/DSK-Q3-Learn%20BKGD%202X?$pjpeg$&jpegSize=300&wid=1920", + "footerFragment": "" + }, + { + "q": "q-customer", + "heading": "What else are you looking for today?", + "sub-head": "Pick one.", + "btn": "Get your results", + "background": "https://cc-prod.scene7.com/is/image/CCProdAuthor/DSK-Q4%20BKGD%202X?$pjpeg$&jpegSize=300&wid=1920", + "footerFragment": "" + } + ] + }, + "q-video": { + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "options": "social", + "title": "", + "text": "Create, edit, and share on social", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-video/1-PR-CreateEditShare.png" + }, + { + "options": "pro", + "title": "", + "text": "Make pro-level edits for high-quality results", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-video/2-PR-ProLevelEdits.png" + }, + { + "options": "movement", + "title": "", + "text": "Create graphics and transitions that move", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-video/3-AE-TitlesAndTransitions.png" + }, + { + "options": "animate", + "title": "", + "text": "Make animations for cartoons or games", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-video/4-AN-Animations.png" + }, + { + "options": "sound", + "title": "", + "text": "Edit, mix, and add sound effects", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-video/5-AU-SoundEffects.png" + } + ] + }, + "q-illustration": { + "total": 4, + "offset": 0, + "limit": 4, + "data": [ + { + "options": "raster", + "title": "", + "text": "Paint, draw, or doodle like on paper", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-illustration/1-PS-PaintDraw.png" + }, + { + "options": "vector", + "title": "", + "text": "Make illustrations that work at any size", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-illustration/2-Ai-WorkAtAnySize.png" + }, + { + "options": "crisp", + "title": "", + "text": "Draw crisp lines and smooth curves", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-illustration/3-Ai-CrispLines.png" + }, + { + "options": "images", + "title": "", + "text": "Blend multiple images into something new", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-illustration/4-PS-BlendImages.png" + } + ] + }, + "q-design": { + "total": 3, + "offset": 0, + "limit": 3, + "data": [ + { + "options": "layouts", + "title": "", + "text": "Create layouts for magazines, books, or posters", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-design/1-%20Id-Layouts.png" + }, + { + "options": "images", + "title": "", + "text": "Combine multiple images into new designs", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-design/2-PS-CombineImages.png" + }, + { + "options": "graphics", + "title": "", + "text": "Create graphics and designs that work at any size", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-design/3-Ai-CreateGraphics.png" + } + ] + }, + "q-customer": { + "total": 3, + "offset": 0, + "limit": 3, + "data": [ + { + "options": "educational", + "title": "", + "text": "A student or teacher discount", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q4/2-StudentTeacher.png" + }, + { + "options": "business", + "title": "", + "text": "Licenses and business features for teams", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q4/3-Work.png" + }, + { + "options": "individual", + "title": "", + "text": "Neither apply", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q4/1-Individual.png" + } + ] + }, + "q-photo": { + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "options": "organize", + "title": "", + "text": "Get them sorted and organized", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-photo/1-LR-StoreAndOrganize.png" + }, + { + "options": "batch", + "title": "", + "text": "Edit lots of photos quickly", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-photo/2-LR-ApplyFilters.png" + }, + { + "options": "edit", + "title": "", + "text": "Edit and finesse the smallest details", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-photo/3-PS-RemoveObjects.png" + }, + { + "options": "color", + "title": "", + "text": "Correct color and lighting like a pro", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-photo/4-PS-MakeDetailedColor.png" + }, + { + "options": "blend", + "title": "", + "text": "Blend multiple shots into something new", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-photo/5-PS-BlendImages.png" + } + ] + }, + "q-category": { + "total": 6, + "offset": 0, + "limit": 6, + "data": [ + { + "options": "photo", + "title": "Photography", + "text": "Edit or organize my photos", + "image": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/1-Photo%20ICON.svg", + "cover": "" + }, + { + "options": "video", + "title": "Video", + "text": "Create and edit video or audio", + "image": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/2-Video%20ICON.svg", + "cover": "" + }, + { + "options": "design", + "title": "Graphic design", + "text": "Design layouts or websites", + "image": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/3-Design%20ICON.svg", + "cover": "" + }, + { + "options": "illustration", + "title": "Illustration", + "text": "Paint, draw, or create illustrations", + "image": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/4-Illustration%20ICON.svg", + "cover": "" + }, + { + "options": "pdf", + "title": "PDFs", + "text": "Create, edit, or sign PDFs", + "image": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/5-PDF%20ICON.svg", + "cover": "" + }, + { + "options": "3d", + "title": "3D/AR", + "text": "Model, texture, and render 3D assets and scenes", + "image": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/6-3D-AR%20ICON.svg", + "cover": "" + } + ] + }, + "q-pdf": { + "total": 6, + "offset": 0, + "limit": 6, + "data": [ + { + "options": "create", + "title": "", + "text": "Create and export PDFs to Office", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-pdf/1-Ac-CreateExport.png" + }, + { + "options": "edit", + "title": "", + "text": "Edit text and images in PDFs", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-pdf/2-Ac-EditText.png" + }, + { + "options": "share", + "title": "", + "text": "Share PDFs with anyone", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-pdf/3-Ac-Share.png" + }, + { + "options": "protect", + "title": "", + "text": "Protect and secure PDFs", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-pdf/4-Ac-Protect.png" + }, + { + "options": "sign", + "title": "", + "text": "Sign PDFs wherever you are", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-pdf/5-Ac-Sign.png" + }, + { + "options": "track", + "title": "", + "text": "Track signatures and progress", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q2-pdf/6-Ac-TrackSignatures.png" + } + ] + }, + "q-rather": { + "total": 2, + "offset": 0, + "limit": 2, + "data": [ + { + "options": "template", + "title": "", + "text": "Edit quickly and customize templates", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/1-Templates.png" + }, + { + "options": "custom", + "title": "", + "text": "Take the time to control every detail", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/2-CustomDesigns.png" + } + ] + }, + "q-3d": { + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "options": "stage", + "title": "", + "text": "Assemble, stage, and render 3D scenes", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q3/3-Stager@1x.png" + }, + { + "options": "texture", + "title": "", + "text": "Texture 3D assets in real time", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q3/2-Painter@1x.png" + }, + { + "options": "materials", + "title": "", + "text": "Create 3D materials from real-life images", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q3/5-Sampler@1x.png" + }, + { + "options": "model", + "title": "", + "text": "Create 3D models with digital clay", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q3/1-Modeler@1x.png" + }, + { + "options": "assets", + "title": "", + "text": "Design 3D assets and materials", + "image": "", + "cover": "https://www.adobe.com/content/dam/cc/Images/app-recommender/multi-select/quiz-question-card-thumbnails/q3/4-Designer@1x.png" + } + ] + }, + ":version": 3, + ":names": [ + "questions", + "q-video", + "q-illustration", + "q-design", + "q-customer", + "q-photo", + "q-category", + "q-pdf", + "q-rather", + "q-3d" + ], + ":type": "multi-sheet" +} diff --git a/test/blocks/quiz/quiz.test.js b/test/blocks/quiz/quiz.test.js new file mode 100644 index 0000000000..1fd5ee8cea --- /dev/null +++ b/test/blocks/quiz/quiz.test.js @@ -0,0 +1,77 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { setConfig } from '../../../libs/utils/utils.js'; + +const { initConfigPathGlob, getQuizData } = await import('../../../libs/blocks/quiz/utils.js'); +const { default: init } = await import('../../../libs/blocks/quiz/quiz.js'); +const locales = { '': { ietf: 'en-US', tk: 'hah7vzn.css' } }; +const conf = { locales }; +const QUIZ_BASE_PATH = 'https://mockdata/path/to/quiz'; + +setConfig(conf); + +let fetch; let quiz; let mockQuestionsData; let mockDataStrings; + +async function mockQuizResourceCall(mockFilePath, resourceName) { + const responseData = await readFile({ path: mockFilePath }); + fetch.withArgs(`${QUIZ_BASE_PATH}${resourceName}`).resolves({ ok: true, json: () => JSON.parse(responseData) }); +} + +describe('Quiz', () => { + beforeEach(async () => { + fetch = sinon.stub(window, 'fetch'); + document.body.innerHTML = await readFile({ path: './mocks/index.html' }); + quiz = document.querySelector('.quiz'); + initConfigPathGlob(quiz); + await mockQuizResourceCall('./mocks/questions.json', 'questions.json'); + await mockQuizResourceCall('./mocks/strings.json', 'strings.json'); + await mockQuizResourceCall('./mocks/results.json', 'results.json'); + const [questions, dataStrings] = await getQuizData(quiz); + mockQuestionsData = questions; + mockDataStrings = dataStrings; + const initQuestion = { + questionData: { + questions: 'q-category', + 'max-selections': '3', + 'min-selections': '1', + }, + }; + const el = document.querySelector('.quiz'); + await init(el, true, mockQuestionsData, mockDataStrings, initQuestion); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should render the initial "Loading" html when data is not loaded', async () => { + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(document.querySelector('.quiz-option')).to.exist; + }); + + it('should update the button when an option is selected', async () => { + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 100)); + const quizOption = document.querySelector('.quiz-option'); + quizOption.click(); + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(document.querySelector('.quiz-button').hasAttribute('disabled')).to.be.false; + }); + + it('should update the state when the next button is clicked', async () => { + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 100)); + const quizOption = document.querySelector('.quiz-option'); + quizOption.click(); + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 100)); + const quizButton = document.querySelector('.quiz-button'); + quizButton.click(); + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(document.querySelector('.quiz-step-container').children[1].classList.contains('current')).to.be.true; + }); +}); diff --git a/test/blocks/quiz/utils.test.js b/test/blocks/quiz/utils.test.js new file mode 100644 index 0000000000..437d2f5927 --- /dev/null +++ b/test/blocks/quiz/utils.test.js @@ -0,0 +1,201 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { setConfig } from '../../../libs/utils/utils.js'; +import { userSelection, answers, resultRules, resultData } from './mocks/mock-states.js'; + +const { + initConfigPathGlob, getQuizData, handleNext, + getAnalyticsDataForBtn, structuredFragments, nestedFragments, + getAnalyticsDataForLocalStorage, parseResultData, + getRedirectUrl, transformToFlowData, storeResultInLocalStorage, findMatchForSelections, + findAndStoreResultData, +} = await import('../../../libs/blocks/quiz/utils.js'); + +const locales = { '': { ietf: 'en-US', tk: 'hah7vzn.css' } }; +const conf = { locales }; +const QUIZ_BASE_PATH = 'https://mockdata/path/to/quiz'; + +setConfig(conf); + +let fetch; let quiz; let mockQuestionsData; let mockDataStrings; + +async function mockQuizResourceCall(mockFilePath, resourceType) { + const responseData = await readFile({ path: mockFilePath }); + fetch.withArgs(`${QUIZ_BASE_PATH}${resourceType}.json`).resolves({ ok: true, json: () => JSON.parse(responseData) }); +} + +describe('Quiz', () => { + before(async () => { + fetch = sinon.stub(window, 'fetch'); + document.body.innerHTML = await readFile({ path: './mocks/index.html' }); + quiz = document.querySelector('.quiz'); + initConfigPathGlob(quiz); + await mockQuizResourceCall('./mocks/questions.json', 'questions'); + await mockQuizResourceCall('./mocks/strings.json', 'strings'); + await mockQuizResourceCall('./mocks/results.json', 'results'); + const [questions, dataStrings] = await getQuizData(quiz); + mockQuestionsData = questions; + mockDataStrings = dataStrings; + }); + + after(() => { + sinon.restore(); + }); + + it('Checking config values from the quiz block', async () => { + const { configPath, quizKey, analyticsType, analyticsQuiz } = initConfigPathGlob(quiz); + expect(configPath).to.be.a('function'); + expect(quizKey).to.be.a.string; + expect(analyticsType).to.be.a.string; + expect(analyticsQuiz).to.be.a.string; + }); + + it('Checking quiz data', async () => { + expect(mockQuestionsData).to.be.an('object'); + expect(mockDataStrings).to.be.an('object'); + }); + + it('Checking general next button functionality', async () => { + const selectedQuestion = { 'max-selections': '3', 'min-selections': '1', questions: 'q-category' }; + const userInputSelections = { photo: true }; + + const { nextQuizViews } = handleNext( + mockQuestionsData, + selectedQuestion, + userInputSelections, + [], + ); + expect(nextQuizViews).to.be.an('array').of.length(2); + expect(nextQuizViews).to.include('q-photo'); + expect(nextQuizViews).to.include('q-rather'); + }); + + it('Checking next button functionality when selection has a (NOT)', async () => { + const selectedQuestion = { 'max-selections': '3', 'min-selections': '1', questions: 'q-category' }; + const userInputSelections = { photo: true, '3d': true }; + const { nextQuizViews } = handleNext( + mockQuestionsData, + selectedQuestion, + userInputSelections, + [], + ); + expect(nextQuizViews).to.be.an('array').of.length(2); + expect(nextQuizViews).does.not.include('q-rather'); + expect(nextQuizViews).that.includes('q-photo'); + }); + + it('Checking next button functionality when selection has a (RESET)', async () => { + const selectedQuestion = { questions: 'q-rather', 'max-selections': '1', 'min-selections': '1' }; + const userInputSelections = { template: true }; + const userFlow = ['q-photo']; + const { nextQuizViews } = handleNext( + mockQuestionsData, + selectedQuestion, + userInputSelections, + userFlow, + ); + expect(nextQuizViews).to.be.an('array').of.length(1); + expect(nextQuizViews).does.not.include('q-photo'); + expect(nextQuizViews).that.includes('q-customer'); + }); + + it('Checking next button analytics data', async () => { + const analyticsDataForBtn = getAnalyticsDataForBtn(null, {}); + expect(analyticsDataForBtn).to.equal(''); + + const selectedQuestion = { 'max-selections': '3', 'min-selections': '1', questions: 'q-category' }; + const selectedCards = { photo: true }; + const analyticsDataForBtnQCat = getAnalyticsDataForBtn(selectedQuestion, selectedCards); + expect(analyticsDataForBtnQCat).to.equal('Filters|cc:app-reco|q-category/photo'); + }); + + it('Checking analytics data for local storage', async () => { + const analyticsDataForBtnQCat = getAnalyticsDataForLocalStorage(answers); + expect(analyticsDataForBtnQCat).to.be.not.empty; + expect(analyticsDataForBtnQCat).to.equal('type=cc:app-reco&quiz=uarv3&selectedOptions=q-category/photo/video|q-rather/custom|q-photo/organize|q-video/social|q-customer/individual'); + }); + + it('Testing structured fragments', async () => { + const resultResources = await readFile({ path: './mocks/result-resources.json' }); + const primaryProducts = ['express']; + const structureFragsArray = ['marquee', 'card-list']; + const structuredFrags = structuredFragments(structureFragsArray, JSON.parse(resultResources), primaryProducts, ''); + expect(structuredFrags).to.be.an('array'); + expect(structuredFrags.length).to.be.equal(1); + }); + + it('Testing nested fragments', async () => { + const resultResources = await readFile({ path: './mocks/result-resources.json' }); + const nestedFragsPrimaryArray = ['check-bullet', 'marquee-plan']; + const nestedFragsSecondaryArray = ['commerce-card']; + const primaryProducts = ['lr-edu']; + const secondaryProducts = ['ps-edu']; + const umbrellaProduct = ''; + const nestedFrags = nestedFragments( + nestedFragsPrimaryArray, + nestedFragsSecondaryArray, + JSON.parse(resultResources), + primaryProducts, + secondaryProducts, + umbrellaProduct, + ); + expect(nestedFrags).to.be.an('object'); + expect(nestedFrags).to.include.keys('commerce-card'); + }); + + it('Testing redirect url', async () => { + const primaryProducts = ['express']; + const structuredFrags = getRedirectUrl('https://mockdata/path/to/quiz/uar-results', primaryProducts); + expect(structuredFrags).to.be.an('string'); + expect(structuredFrags).to.include('express'); + }); + + it('Testing result flow', async () => { + const { destinationPage, primaryProductCodes } = await findAndStoreResultData( + transformToFlowData(userSelection), + ); + expect(destinationPage).to.be.an('string'); + expect(primaryProductCodes).to.be.an('array').of.length(2); + expect(primaryProductCodes).to.include('lr-ind'); + expect(primaryProductCodes).to.include('pr-ind'); + }); + + it('Testing how the result data is parsed', async () => { + const resultObject = await parseResultData(answers); + expect(resultObject).to.be.an('object'); + expect(resultObject).to.have.ownProperty('filteredResults'); + }); + + it('Testing a direct product match and its recommendations', async () => { + const matchingSelections = { primary: ['express'], secondary: [] }; + const matches = findMatchForSelections(resultRules, matchingSelections); + const notMatched = findMatchForSelections(resultRules, { primary: ['whatever'], secondary: [] }); + expect(matches[0]).to.haveOwnProperty('umbrella-result').eq(''); + expect(matches).to.be.an('array').of.length(1); + expect(notMatched).to.be.an('array'); + expect(notMatched[0]).to.haveOwnProperty('result').eq('default'); + }); + + it('Testing transformToFlowData', async () => { + const flowData = transformToFlowData(userSelection); + expect(flowData).to.be.an('array').of.length(5); + }); + + it('Testing storeResultInLocalStorage', async () => { + const resultResources = await readFile({ path: './mocks/result-resources.json' }); + const primaryProducts = [ + 'lr-ind', + 'pr-ind', + ]; + const secondaryProductCodes = [ + 'ps-ind', + 'au-ind', + ]; + const resultToDelegate = storeResultInLocalStorage(answers, resultData, JSON.parse(resultResources), primaryProducts, secondaryProductCodes, 'cc'); + expect(resultToDelegate).to.be.an('object'); + expect(resultToDelegate).to.haveOwnProperty('umbrellaProduct').eq('cc'); + expect(resultToDelegate).to.haveOwnProperty('primaryProducts').include('lr-ind'); + expect(resultToDelegate).to.haveOwnProperty('secondaryProducts').include('ps-ind'); + }); +}); diff --git a/test/blocks/quote/mocks/body.html b/test/blocks/quote/mocks/body.html index 8f3b538dc0..76c7b5d74a 100644 --- a/test/blocks/quote/mocks/body.html +++ b/test/blocks/quote/mocks/body.html @@ -1,4 +1,4 @@ -
            +
            @@ -15,10 +15,78 @@
            -
            +
            +
            +
            “3D is a crucial part of how we explore the brand in a digital workflow. Adobe Substance 3D Stager takes the barrier of entry out of 3D design by enabling us to skip physical mockups and look at feedback faster. We’ve been able to bring digital design entirely in-house.”
            +
            +
            + +
            -

            “3D is a crucial part of how we explore the brand in a digital workflow. Adobe Substance 3D Stager takes the barrier of entry out of 3D design by enabling us to skip physical mockups and look at feedback faster. We’ve been able to bring digital design entirely in-house.”

            +

            “3D is a crucial part of how we explore the brand in a digital workflow. Adobe Substance 3D Stager takes the barrier of entry out of 3D design by enabling us to skip physical mockups and look at feedback faster. We’ve been able to bring digital design entirely in-house.”

            +

            Benny Lee

            +

            Global Manager of Experiential Design, Coca-Cola Company

            +
            +
            +
            + +
            +
            +
            “3D is a crucial part of how we explore the brand in a digital workflow. Adobe Substance 3D Stager takes the barrier of entry out of 3D design by enabling us to skip physical mockups and look at feedback faster. We’ve been able to bring digital design entirely in-house.”
            +
            +
            + +
            +
            +
            +

            “3D is a crucial part of how we explore the brand in a digital workflow. Adobe Substance 3D Stager takes the barrier of entry out of 3D design by enabling us to skip physical mockups and look at feedback faster. We’ve been able to bring digital design entirely in-house.”

            +

            Benny Lee

            +

            Global Manager of Experiential Design, Coca-Cola Company

            +
            +
            +
            + +
            +
            +
            “3D is a crucial part of how we explore the brand in a digital workflow. Adobe Substance 3D Stager takes the barrier of entry out of 3D design by enabling us to skip physical mockups and look at feedback faster. We’ve been able to bring digital design entirely in-house.”
            +
            +
            + +
            +
            +
            +

            “3D is a crucial part of how we explore the brand in a digital workflow. Adobe Substance 3D Stager takes the barrier of entry out of 3D design by enabling us to skip physical mockups and look at feedback faster. We’ve been able to bring digital design entirely in-house.”

            +
            +
            +
            + +
            +
            +
            +

            “3D is a crucial part of how we explore the brand in a digital workflow. Adobe Substance 3D Stager takes the barrier of entry out of 3D design by enabling us to skip physical mockups and look at feedback faster. We’ve been able to bring digital design entirely in-house.”

            +

            Benny Lee

            +

            Global Manager of Experiential Design, Coca-Cola Company

            +
            +
            +
            + +
            +
            +
            +
            +

            “3D is a crucial part of how we explore the brand in a digital workflow. Adobe Substance 3D Stager takes the barrier of entry out of 3D design by enabling us to skip physical mockups and look at feedback faster. We’ve been able to bring digital design entirely in-house.”

            +
            +
            +
            +
            + +
            +
            +
            +
            +

            “3D is a crucial part of how we explore the brand in a digital workflow. Adobe Substance 3D Stager takes the barrier of entry out of 3D design by enabling us to skip physical mockups and look at feedback faster. We’ve been able to bring digital design entirely in-house.”

            +

            Benny Lee

            Global Manager of Experiential Design, Coca-Cola Company

            diff --git a/test/blocks/quote/quote.test.js b/test/blocks/quote/quote.test.js index ea2e8ca370..41def79b2f 100644 --- a/test/blocks/quote/quote.test.js +++ b/test/blocks/quote/quote.test.js @@ -5,14 +5,40 @@ document.body.innerHTML = await readFile({ path: './mocks/body.html' }); const { default: init } = await import('../../../libs/blocks/quote/quote.js'); const quotes = document.querySelectorAll('.quote'); -/* eslint-disable-next-line no-restricted-syntax */ -for await (const quote of quotes) { - await init(quote); -} +describe('Quote', () => { + quotes.forEach((quote) => { + const authorType = quote.classList[1].replaceAll('-', ' '); + describe(`authored as ${authorType}`, () => { + before(() => { + init(quote); + }); -describe('Blockquote', () => { - it('Renders as a blockquote element', async () => { - const quote = quotes[0].querySelector('blockquote'); - expect(quote).to.exist; + if (authorType.includes('image')) { + it('has image', () => { + const image = quote.querySelector('img').src; + expect(image).to.exist; + }); + } + + it('has blockquote text', () => { + const blockquote = quote.querySelector('blockquote').textContent; + expect(blockquote).to.exist; + expect(blockquote).to.not.be.empty; + }); + + if (authorType.includes('caption')) { + it('has figcaption', () => { + const figcaption = quote.querySelector('.figcaption').textContent; + expect(figcaption).to.exist; + expect(figcaption).to.not.be.empty; + }); + + it('has cite', () => { + const cite = quote.querySelector('cite').textContent; + expect(cite).to.exist; + expect(cite).to.not.be.empty; + }); + } + }); }); }); diff --git a/test/blocks/read-more/read-more.test.js b/test/blocks/read-more/read-more.test.js index d3d5690318..718642b910 100644 --- a/test/blocks/read-more/read-more.test.js +++ b/test/blocks/read-more/read-more.test.js @@ -3,7 +3,7 @@ import { readFile } from '@web/test-runner-commands'; import sinon from 'sinon'; const ogDoc = document.body.innerHTML; -const { default: init } = await import('../../../libs/blocks/read-more/read-more.js') +const { default: init } = await import('../../../libs/blocks/read-more/read-more.js'); describe('init', () => { afterEach(() => { diff --git a/test/blocks/reading-time/reading-time.test.js b/test/blocks/reading-time/reading-time.test.js index 0ed119bc80..8a47d3956d 100644 --- a/test/blocks/reading-time/reading-time.test.js +++ b/test/blocks/reading-time/reading-time.test.js @@ -26,9 +26,11 @@ describe('reading-time estimate', () => { }); describe('inline variant', async () => { - document.body.innerHTML = await readFile({ path: './mocks/inline.html' }); + beforeEach(async () => { + document.body.innerHTML = await readFile({ path: './mocks/inline.html' }); + }); - it("Inline variant (with inline siblings) creates an inline-wrapper element", async () => { + it('Inline variant (with inline siblings) creates an inline-wrapper element', async () => { const section = document.querySelector('.section.inline-has-siblings'); const els = section.querySelectorAll('.reading-time.inline'); els.forEach(async (el) => { diff --git a/test/blocks/recommended-articles/mocks/body.html b/test/blocks/recommended-articles/mocks/body.html index 09775f3fa8..2ce20d2788 100644 --- a/test/blocks/recommended-articles/mocks/body.html +++ b/test/blocks/recommended-articles/mocks/body.html @@ -19,7 +19,7 @@

            Recommended Article Small

            - -
            +
            +
            +
            -
            +
            +
            diff --git a/test/blocks/section-metadata/section-meta.test.js b/test/blocks/section-metadata/section-meta.test.js index a716faf24b..a7d5096047 100644 --- a/test/blocks/section-metadata/section-meta.test.js +++ b/test/blocks/section-metadata/section-meta.test.js @@ -13,31 +13,39 @@ describe('Section Metdata', () => { expect(sec.classList.length).to.equal(0); }); - it('Handles background image', () => { + it('Handles background image', async () => { const sec = document.querySelector('.section.image'); const sm = sec.querySelector('.section-metadata'); - init(sm); + await init(sm); expect(sec.classList.contains('has-background')).to.be.true; }); - it('Handles background color', () => { + it('Handles background image focal point', async () => { + const sec = document.querySelector('.section.image'); + const sm = sec.querySelector('.section-metadata'); + await init(sm); + const image = sec.querySelector('img'); + expect(image.style.objectPosition).to.equal('center bottom'); + }); + + it('Handles background color', async () => { const sec = document.querySelector('.section.color'); const sm = sec.querySelector('.section-metadata'); - init(sm); + await init(sm); expect(sec.style.background).to.equal('rgb(239, 239, 239)'); }); - it('Handles background gradient', () => { + it('Handles background gradient', async () => { const sec = document.querySelector('.section.gradient.color'); const sm = sec.querySelector('.section-metadata'); - init(sm); + await init(sm); expect(sec.style.background).to.equal('linear-gradient(red, yellow)'); }); - it('Adds class based on layout input', () => { + it('Adds class based on layout input', async () => { const sec = document.querySelector('.section.layout'); const sm = sec.querySelector('.section-metadata'); - init(sm); + await init(sm); expect(sec.classList.contains('grid-template-columns-1-2')).to.be.true; }); @@ -46,22 +54,30 @@ describe('Section Metdata', () => { expect(metadata.background.text).to.equal('rgb(239, 239, 239)'); }); - it('gets section metadata', () => { + it('gets section metadata', async () => { const sec = document.querySelector('.section.sticky-bottom'); const sm = sec.querySelector('.section-metadata'); const main = document.querySelector('main'); - init(sm); + await init(sm); expect(main.lastElementChild).to.be.eql(sec); }); - it('add section to top', () => { + it('add section to top', async () => { const sec = document.querySelector('.section.sticky-top'); const sm = sec.querySelector('.section-metadata'); const main = document.querySelector('main'); - init(sm); + await init(sm); expect(main.firstElementChild).to.be.eql(sec); }); + it('add promobar behaviour to section', async () => { + const main = document.querySelector('main'); + const sec = document.querySelector('.section.sticky-bottom .promobar').closest('.section'); + const sm = sec.querySelector('.section-metadata'); + await init(sm); + expect(main.lastElementChild.classList.contains('hide-sticky-section')).to.be.true; + }); + it('should calculate the top position based on header height', async () => { const sec = document.querySelector('.section.sticky-top'); const header = document.createElement('header'); diff --git a/test/blocks/share/share.test.js b/test/blocks/share/share.test.js index 59beb05115..2849bbf840 100644 --- a/test/blocks/share/share.test.js +++ b/test/blocks/share/share.test.js @@ -61,7 +61,7 @@ describe('Share', () => { expect(re).to.exist; expect(tw).to.not.exist; }); - it("Inline variant (with inline siblings) creates an inline-wrapper element", async () => { + it('Inline variant (with inline siblings) creates an inline-wrapper element', async () => { const section = document.querySelector('.section.inline-has-siblings'); const shareEls = section.querySelectorAll('.share.inline'); shareEls.forEach(async (shareEl) => { @@ -75,4 +75,11 @@ describe('Share', () => { await init(shareEl); expect(shareEl.parentElement.classList.contains('inline-wrapper')).to.be.false; }); + it('Tracking attribute is added to the links in DOM', async () => { + const shareEl = document.querySelector('.share'); + const links = shareEl.querySelectorAll('a'); + links.forEach((link) => { + expect(link.hasAttribute('daa-ll')); + }); + }); }); diff --git a/test/blocks/table/mocks/body.html b/test/blocks/table/mocks/body.html new file mode 100644 index 0000000000..478e6ed9da --- /dev/null +++ b/test/blocks/table/mocks/body.html @@ -0,0 +1,947 @@ + +
            +
            +
            +
            +
            +
            Ee kj oyo kug it oi ooy o Bbibibi
            +
            e
            +
            +
            +
            +
            +
            Test1
            +
            +

            Adobe Standard2

            +

            US$19.99/mo

            +

            Free trial

            +

            Buy now

            +
            +
            +

            Adobe Standard3

            +

            US$19.99/mo

            +

            Free trial

            +

            Buy now

            +
            +
            +

            Adobe Standard4

            +

            US$19.99/mo

            +

            Free trial

            +

            Buy now

            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            E-signature core features signat core features
            +
            +
            +
            +
            +
            +
            +
            Right
            +
            +
            +
            +
            +
            +
            +
            Sign agreements
            +
            +
            +
            +
            +
            +
            +
            +

            Track and manage agreements

            +

            +
            +
            +
            +
            +
            +
            +
            +
            +

            Add a business stamp

            +

            +
            +
            +
            +
            +
            +
            +
            +
            +

            Prepare forms

            +

            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            Interact with PDFs
            +
            +
            +
            +
            +
            +
            +
            Collect signatures
            +
            +
            +
            +
            +
            +
            +
            Sign agreements
            +
            +
            +
            +
            +
            +
            +
            Track and manage agreements
            +
            +
            +
            +
            +
            +
            +
            Add a business stamp
            +
            +
            +
            +
            +
            +
            +
            Prepare forms
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            E-signature core features
            +
            +
            +
            +
            +
            +
            +
            Collect signatures
            +
            +
            +
            +
            +
            +
            +
            Sign agreements
            +
            +
            +
            +
            +
            +
            +
            +

            Track and manage agreements

            +

            +
            +
            +
            +
            +
            +
            +
            +
            Add a business stamp
            +
            +
            +
            +
            +
            +
            +
            Prepare forms
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            Interact with PDFs
            +
            +
            +
            +
            +
            +
            +
            Collect signatures
            +
            +
            +
            +
            +
            +
            +
            Sign agreements
            +
            +
            +
            +
            +
            +
            +
            Track and manage agreements
            +
            +
            +
            +
            +
            +
            +
            Add a business stamp
            +
            +
            +
            +
            +
            +
            +
            Prepare forms
            +
            +
            +
            +
            +
            +
            + +
            +
            +
            +
            +
            Test
            +
            Best Value222 ieh kuge it 98t 9t89 9t9 9t9t9t9 wr 8yr 98yer wr 8yr x
            +
            +
            +
            +
            +

            + + + + + + +

            +

            All Apps Plan1

            +

            US$54.99/mo

            +

            or get a 7-day free trial before buying.

            +

            Get 20+ desktop and mobile apps including Photoshop, Lightroom, Illustrator, InDesign, and Premiere Pro.

            +
            +
            +

            + + + + + + +

            +

            All Apps Plan2

            +

            US$54.99/mo

            +

            or get a 7-day free trial before buying.

            +

            Get 20+ desktop and mobile apps including Photoshop, Lightroom, Illustrator, InDesign, and Premiere Pro.

            +
            +
            +

            + + + + + + +

            +

            All Apps Plan3

            +

            US$54.99/mo

            +

            or get a 7-day free trial before buying.

            +

            Get 20+ desktop and mobile apps including Photoshop, Lightroom, Illustrator, InDesign, and Premiere Pro.

            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            Includes:
            +
            Includes:
            +
            Includes:
            +
            +
            +
            +

            Add a business stamp

            +

            +
            +
            Add a business stamp
            +
            +

            Track and manage agreements

            +

            +
            +
            +
            +
            +

            Add a business stamp

            +

            +
            +
            +

            Track and manage agreements

            +

            +
            +
            +

            Add a business stamp

            +

            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            Track and manage agreements:
            +
            Add a business stamp:
            +
            Add a business stamp:
            +
            +
            +
            +
            +

            Track and manage agreements

            +

            +
            +
            +

            Track and manage agreements

            +

            +
            +
            +
            +
            +
            +
            +

            Track and manage agreements

            +

            +
            +
            +
            + +
            +
            +
            +
            +
            +
            Test1
            +
            +

            Adobe Standard2

            +

            US$19.99/mo

            +

            Free trial

            +

            Buy now

            +
            +
            +

            Adobe Standard3

            +

            US$19.99/mo

            +

            Free trial

            +

            Buy now

            +
            +
            +

            Adobe Standard4

            +

            US$19.99/mo

            +

            Free trial

            +

            Buy now

            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            E-signature core features signat core features
            +
            +
            +
            +
            +
            +
            +
            Right
            +
            +
            +
            +
            +
            +
            +
            Sign agreements
            +
            +
            +
            +
            +
            +
            +
            +

            Track and manage agreements

            +

            +
            +
            +
            +
            +
            +
            +
            +
            +

            Add a business stamp

            +

            +
            +
            +
            +
            +
            +
            +
            +
            +

            Prepare forms

            +

            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            Interact with PDFs
            +
            +
            +
            +
            +
            +
            +
            Collect signatures
            +
            +
            +
            +
            +
            +
            +
            Sign agreements
            +
            +
            +
            +
            +
            +
            +
            Track and manage agreements
            +
            +
            +
            +
            +
            +
            +
            Add a business stamp
            +
            +
            +
            +
            +
            +
            +
            Prepare forms
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            E-signature core features
            +
            +
            +
            +
            +
            +
            +
            Collect signatures
            +
            +
            +
            +
            +
            +
            +
            Sign agreements
            +
            +
            +
            +
            +
            +
            +
            +

            Track and manage agreements

            +

            +
            +
            +
            +
            +
            +
            +
            +
            Add a business stamp
            +
            +
            +
            +
            +
            +
            +
            Prepare forms
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            Interact with PDFs
            +
            +
            +
            +
            +
            +
            +
            Collect signatures
            +
            +
            +
            +
            +
            +
            +
            Sign agreements
            +
            +
            +
            +
            +
            +
            +
            Track and manage agreements
            +
            +
            +
            +
            +
            +
            +
            Add a business stamp
            +
            +
            +
            +
            +
            +
            +
            Prepare forms
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            Ee kj oyo kug it oi ooy o Bbibibi
            +
            e
            +
            +
            +
            +
            +
            +

            Adobe Standard2

            +

            US$19.99/mo

            +

            Free trial

            +

            Buy now

            +
            +
            +

            Adobe Standard3

            +

            US$19.99/mo

            +

            Free trial

            +

            Buy now

            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            E-signature core features signat core features
            +
            +
            +
            +
            +
            Right
            +
            +
            +
            +
            +
            Sign agreements
            +
            +
            +
            +
            +
            +

            Track and manage agreements

            +

            +
            +
            +
            +
            +
            +
            +

            Add a business stamp

            +

            +
            +
            +
            +
            +
            +
            +

            Prepare forms

            +

            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            Interact with PDFs
            +
            +
            +
            +
            +
            Collect signatures
            +
            +
            +
            +
            Sign agreements
            +
            +
            +
            +
            +
            Track and manage agreements
            +
            +
            +
            +
            +
            Add a business stamp
            +
            +
            +
            +
            +
            Prepare forms
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            E-signature core features
            +
            +
            +
            +
            +
            Collect signatures
            +
            +
            +
            +
            +
            Sign agreements
            +
            +
            +
            +
            +
            +

            Track and manage agreements

            +

            +
            +
            +
            +
            +
            +
            Add a business stamp
            +
            +
            +
            +
            +
            Prepare forms
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            +
            Interact with PDFs
            +
            +
            +
            +
            +
            Collect signatures
            +
            +
            +
            +
            +
            Sign agreements
            +
            +
            +
            +
            +
            Track and manage agreements
            +
            +
            +
            +
            +
            Add a business stamp
            +
            +
            +
            +
            +
            Prepare forms
            +
            +
            +
            +
            + +
            +
            +
            +
            +
            Analyze — The best insights, fast. Self-serve omnichannel insights at speed using the best tool for the job.
            +
            +
            +
            User engagement trends
            +
            + +
            +
            +
            +
            Friction funnel
            +
            + +
            +
            +
            +
            Conversion-over-time funnel
            +
            + +
            +
            +
            +
            Active user growth
            +
            + +
            +
            +
            +
            Net user growth
            +
            + +
            +
            +
            +
            Feature-release impact
            +
            + +
            +
            +
            +
            First-use impact
            +
            + +
            +
            +
            +
            Time comparisons
            +
            + +
            +
            +
            +
            Segment discovery
            +
            + +
            +
            +
            +
            Anomaly detection
            +
            + +
            +
            +
            +
            + diff --git a/test/blocks/table/table.test.js b/test/blocks/table/table.test.js new file mode 100644 index 0000000000..197414593e --- /dev/null +++ b/test/blocks/table/table.test.js @@ -0,0 +1,100 @@ +import { readFile, sendMouse, sendKeys } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; +import { MILO_EVENTS } from '../../../libs/utils/utils.js'; +import { delay, waitForElement } from '../../helpers/waitfor.js'; + +document.body.innerHTML = await readFile({ path: './mocks/body.html' }); +const { default: init } = await import('../../../libs/blocks/table/table.js'); + +describe('table and tablemetadata', () => { + beforeEach(() => { + const tables = document.querySelectorAll('.table'); + tables.forEach((t) => init(t)); + window.dispatchEvent(new Event(MILO_EVENTS.DEFERRED)); + }); + + describe('standard table', () => { + const table = document.querySelector('.table'); + + it('row-heading', () => { + expect(table.querySelector('.row-heading')).to.exist; + }); + + it('expand icon event by mouse click', () => { + const expandIcon = table.querySelector('.icon.expand'); + expect(expandIcon.ariaExpanded).to.be.equal('true'); + expandIcon.parentElement.click(); + expect(expandIcon.ariaExpanded).to.be.equal('false'); + }); + + it('expand icon event by keyboard', async () => { + const expandIcon = table.querySelector('.icon.expand'); + expandIcon.parentElement.focus(); + await sendKeys({ type: 'Enter' }); + expect(expandIcon.ariaExpanded).to.be.equal('false'); + await sendKeys({ type: ' ' }); + expect(expandIcon.ariaExpanded).to.be.equal('true'); + }); + + it('hovering-test', async () => { + const headingCol = table.querySelector('.row-heading .col:not(.hidden)'); + const sectionHeads = table.querySelectorAll('.section-head'); + const lastSectionHead = sectionHeads[sectionHeads.length - 1]; + const lastExpandIcon = lastSectionHead.querySelector('.icon.expand'); + lastExpandIcon.setAttribute('aria-expanded', 'false'); + await sendMouse({ + type: 'move', + position: [10, (headingCol?.offsetTop ?? 0) + 1], + }); + expect(headingCol.classList.contains('hover')).to.be.true; + }); + + it('mobile test', async () => { + window.innerWidth = 760; + window.dispatchEvent(new Event('resize')); + const filters = await waitForElement('.filters'); + const col5 = table.querySelector('.col-5'); + expect(filters).to.be.exist; + expect(col5).to.be.null; + }); + + it('filter test: for case of both filter poiting same options', async () => { + const filters = document.querySelectorAll('.filters select'); + const options = document.querySelectorAll('.filters select option'); + options[1].selected = true; + filters[0].dispatchEvent(new Event('change', { bubbles: true })); + await delay(500); + expect(filters[0].value).to.equal('2'); + }); + + it('filter test:for case of order change', async () => { + const filters = document.querySelectorAll('.filters select'); + const options = document.querySelectorAll('.filters select option'); + options[3].selected = true; + filters[0].dispatchEvent(new Event('change', { bubbles: true })); + await delay(500); + expect(filters[0].value).to.equal('4'); + }); + + it('filter test: merch filter test', async () => { + const filters = document.querySelectorAll('.filters select'); + const options = document.querySelectorAll('.filters select option'); + options[10].selected = true; + filters[2].dispatchEvent(new Event('change', { bubbles: true })); + await delay(500); + expect(filters[2].value).to.equal('2'); + }); + + it('filter test: no filter if only 2 columns', async () => { + const tableWith2Columns = document.querySelector('.twocolumns'); + expect(tableWith2Columns.parentElement.querySelector('.filters')).to.be.null; + }); + + it('back to desktop test', async () => { + window.innerWidth = 1200; + window.dispatchEvent(new Event('resize')); + const col5 = await waitForElement('.table .col-5'); + expect(col5).to.be.exist; + }); + }); +}); diff --git a/test/blocks/tabs/mocks/body.html b/test/blocks/tabs/mocks/body.html index 33edf38ea5..344c39fb04 100644 --- a/test/blocks/tabs/mocks/body.html +++ b/test/blocks/tabs/mocks/body.html @@ -6,16 +6,16 @@

            Consonant Tabs

              -
            • Tab 1
            • -
            • Tab 2
            • -
            • Tab 3
            • +
            • Tab, 1
            • +
            • Tab, 2
            • +
            • Tab, 3
            -

            Here is Tab 1 content

            +

            Here is Tab, 1 content

            @@ -24,7 +24,7 @@

            Consonant Tabs

            @@ -34,7 +34,7 @@

            Consonant Tabs

            @@ -43,7 +43,7 @@

            Consonant Tabs

            @@ -52,7 +52,7 @@

            Consonant Tabs

            diff --git a/test/blocks/tabs/tabs.test.js b/test/blocks/tabs/tabs.test.js index 966ddfa990..26525f3a41 100644 --- a/test/blocks/tabs/tabs.test.js +++ b/test/blocks/tabs/tabs.test.js @@ -1,14 +1,6 @@ import { readFile, sendMouse, sendKeys } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; -function getMiddleOfElement(element) { - const { x, y, width, height } = element.getBoundingClientRect(); - return { - x: Math.floor(x + window.pageXOffset + width / 2), - y: Math.floor(y + window.pageYOffset + height / 2), - }; -} - document.body.innerHTML = await readFile({ path: './mocks/body.html' }); const { default: init } = await import('../../../libs/blocks/tabs/tabs.js'); @@ -26,8 +18,7 @@ describe('tabs', () => { it('clicks on a tabList button', async () => { const unSelectedBtn = allTabs[0].querySelector('div[role="tablist"] button[aria-selected="false"]'); - const { x, y } = getMiddleOfElement(unSelectedBtn); - await sendMouse({ type: 'click', position: [x, y] }); + unSelectedBtn.click(); expect(unSelectedBtn.ariaSelected).to.equal('true'); }); diff --git a/test/blocks/tag-selector/tag-selector.test.js b/test/blocks/tag-selector/tag-selector.test.js new file mode 100644 index 0000000000..e3a24cec22 --- /dev/null +++ b/test/blocks/tag-selector/tag-selector.test.js @@ -0,0 +1,76 @@ +import { expect } from '@esm-bundle/chai'; +import init from '../../../libs/blocks/tag-selector/tag-selector.js'; +import { delay, waitForElement } from '../../helpers/waitfor.js'; +import { restoreFetch, stubFetch } from '../caas-config/mockFetch.js'; +import taxonomy from './taxonomy.js'; + +describe('Tag Selector', () => { + beforeEach(async () => { + stubFetch(); + window.fetch.withArgs('./mocks/taxonomy.json').returns( + new Promise((resolve) => { + resolve({ + ok: true, + json: () => taxonomy, + }); + }), + ); + + document.body.innerHTML = '
            '; + await init(document.querySelector('.tag-selector')); + }); + + afterEach(() => { + restoreFetch(); + }); + + it('loads caas tags by default', async () => { + const firstItem = await waitForElement('.tagselect-item [data-tag=CaaS]'); + expect(firstItem).to.exist; + }); + + it('adds tags to preview box when checked', async () => { + const checkbox = await waitForElement('.tagselect-item [id^=caas]'); + checkbox.click(); + + const previewTag = await waitForElement('.tag-preview p'); + const key = checkbox.getAttribute('id'); + expect(previewTag.textContent).to.equal(key); + }); + + it('removes tags from preview box when unchecked', async () => { + const checkbox = await waitForElement('.tagselect-item [id^=caas]'); + checkbox.click(); + + const previewTag = await waitForElement('.tag-preview p'); + const key = checkbox.getAttribute('id'); + expect(previewTag.textContent).to.equal(key); + + checkbox.click(); + + await delay(50); + + expect(document.querySelector('.tag-preview p')).to.be.null; + }); + + it('shows children on arrow click', async () => { + const arrow = await waitForElement('.tagselect-picker-cols .has-children'); + + arrow.click(); + + const secondCol = await waitForElement('.tagselect-picker-cols .col:nth-child(2)'); + + expect(secondCol).to.exist; + }); + + it('shows consumer tags when clicked', async () => { + const button = await waitForElement('.tagselect-item [data-tag="Consumer Tags"]'); + + button.click(); + + const item = await waitForElement('.tagselect-picker-cols .tagselect-item'); + + expect(item).to.exist; + expect(item.dataset.key).to.equal('primaryproductname'); + }); +}); diff --git a/test/blocks/tag-selector/taxonomy.js b/test/blocks/tag-selector/taxonomy.js new file mode 100644 index 0000000000..153ec8f62a --- /dev/null +++ b/test/blocks/tag-selector/taxonomy.js @@ -0,0 +1,80 @@ +const taxonomy = { + total: 10, + offset: 0, + limit: 10, + data: [ + { + Type: 'primaryProductName', + Name: 'Experience Cloud', + 'Footer Promo Link': 'https://main--bacom--adobecom.hlx.page/footer-promo-experience', + 'Other Data': '17', + ExcludeFromMetadata: '', + }, + { + Type: 'primaryProductName', + Name: 'Creative Cloud', + 'Footer Promo Link': '', + 'Other Data': '', + ExcludeFromMetadata: '', + }, + { + Type: 'primaryProductName', + Name: 'Document Cloud', + 'Footer Promo Link': '', + 'Other Data': '54', + ExcludeFromMetadata: '', + }, + { + Type: 'tag', + Name: 'Insights & Inspiration', + 'Footer Promo Link': '', + 'Other Data': '90', + ExcludeFromMetadata: '', + }, + { + Type: 'tag', + Name: 'Insights & Inspiration | Creativity', + 'Footer Promo Link': '', + 'Other Data': '', + ExcludeFromMetadata: '', + }, + { + Type: 'tag', + Name: 'Insights & Inspiration | Creativity | 3D & AR', + 'Footer Promo Link': 'https://main--bacom--adobecom.hlx.page/footer-promo-3d-ar', + 'Other Data': '', + ExcludeFromMetadata: '', + }, + { + Type: 'tag', + Name: 'Marketing', + 'Footer Promo Link': '', + 'Other Data': '', + ExcludeFromMetadata: '', + }, + { + Type: 'tag', + Name: 'Marketing | Content Marketing', + 'Footer Promo Link': '', + 'Other Data': '', + ExcludeFromMetadata: '', + }, + { + Type: 'tag', + Name: 'Marketing | Email Marketing', + 'Footer Promo Link': '', + 'Other Data': '', + ExcludeFromMetadata: '', + }, + { + Type: 'tag', + Name: 'Marketing | Marketing Automation', + 'Footer Promo Link': '', + 'Other Data': '', + ExcludeFromMetadata: 'X', + }, + ], + ':type': 'sheet', +}; + +export default taxonomy; diff --git a/test/blocks/tags/tags.test.js b/test/blocks/tags/tags.test.js index 41ee309f15..0b880d0ddc 100644 --- a/test/blocks/tags/tags.test.js +++ b/test/blocks/tags/tags.test.js @@ -7,21 +7,23 @@ const conf = { locales }; setConfig(conf); const config = getConfig(); -const ogDoc = document.body.innerHTML; - const { default: init } = await import('../../../libs/blocks/tags/tags.js'); describe('decorateTags', () => { - config.locale.contentRoot = '/test/blocks/tags/mocks'; - - afterEach(() => { - document.body.innerHTML = ogDoc; - }); - - it('renders tags block', async () => { + before(async () => { document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + config.locale.contentRoot = '/test/blocks/tags/mocks'; + config.taxonomyRoot = undefined; const block = document.querySelector('.tags'); await init(block); + }); + + it('renders tags block', async () => { expect(document.body.querySelector('.tags')).to.exist; }); + + it('sets default taxonomy path to "topics"', async () => { + const categoryLink = document.querySelector('.tags a'); + expect(categoryLink.href.includes('/topics/')).to.be.true; + }); }); diff --git a/test/blocks/text/mocks/body.html b/test/blocks/text/mocks/body.html index 2ccc202587..656b9a1fff 100644 --- a/test/blocks/text/mocks/body.html +++ b/test/blocks/text/mocks/body.html @@ -99,3 +99,41 @@

            Text (vertical)

            + diff --git a/test/blocks/text/text.test.js b/test/blocks/text/text.test.js index 1376cb0c90..ae2a64c01a 100644 --- a/test/blocks/text/text.test.js +++ b/test/blocks/text/text.test.js @@ -31,4 +31,26 @@ describe('text block', () => { expect(body).to.exist; }); }); + + describe('Link Farm', () => { + it('is present', () => { + const element = document.querySelector('.link-farm'); + expect(element).to.exist; + }); + + it('adds h3 elements when necessary', () => { + const headingElements = document.querySelectorAll('.link-farm .foreground h3'); + expect(headingElements.length).to.equal(4); + }); + it('adds no-heading class to the h3 element', () => { + const headingElem = document.querySelector('.link-farm .foreground .no-heading'); + expect(headingElem).to.exist; + }); + it('adds h3 as the first element in the div', () => { + const divElements = document.querySelectorAll('.link-farm .foreground:nth-child(2) div'); + divElements.forEach((div) => { + expect(div.children[0].tagName).to.equal('H3'); + }); + }); + }); }); diff --git a/test/blocks/video/mocks/body.html b/test/blocks/video/mocks/body.html index ce82f9f66a..9c18dfc91f 100644 --- a/test/blocks/video/mocks/body.html +++ b/test/blocks/video/mocks/body.html @@ -16,4 +16,20 @@ https://main--blog--adobecom.hlx.page/media_17927691d22fe4e1bd058e94762a224fdc57ebb7b.mp4#autoplay1
            + + + + diff --git a/test/blocks/video/video.test.js b/test/blocks/video/video.test.js index 1387e364d5..1fe3031293 100644 --- a/test/blocks/video/video.test.js +++ b/test/blocks/video/video.test.js @@ -41,4 +41,42 @@ describe('video uploaded using franklin bot', () => { expect(block.firstElementChild.hasAttribute('loop')).to.be.false; }); + + it('decorates video with autoplay, no loop and hover play', async () => { + const block = document.querySelector('.video.no-loop.hoverplay'); + const a = block.querySelector('a'); + const { href } = a; + a.textContent = href; + block.append(a); + + init(a); + + expect(block.firstElementChild.hasAttribute('loop')).to.be.false; + expect(block.firstElementChild.hasAttribute('data-hoverplay')).to.be.true; + }); + + it('no hoverplay attribute added when with autoplay on loop', async () => { + const block = document.querySelector('.video.autoplay.playonhover'); + const a = block.querySelector('a'); + const { href } = a; + a.textContent = href; + block.append(a); + + init(a); + + expect(block.firstElementChild.hasAttribute('loop')).to.be.true; + expect(block.firstElementChild.hasAttribute('data-hoverplay')).to.be.false; + }); + + it('no hoverplay attribute added when only hoverplay is added to url', async () => { + const block = document.querySelector('.video.hoveronly'); + const a = block.querySelector('a'); + const { href } = a; + a.textContent = href; + block.append(a); + + init(a); + + expect(block.firstElementChild.hasAttribute('data-hoverplay')).to.be.false; + }); }); diff --git a/test/features/georoutingv2/georoutingv2.test.js b/test/features/georoutingv2/georoutingv2.test.js index 4cea0ada0b..0c0082657e 100644 --- a/test/features/georoutingv2/georoutingv2.test.js +++ b/test/features/georoutingv2/georoutingv2.test.js @@ -1,5 +1,6 @@ import { stub } from 'sinon'; import { expect } from '@esm-bundle/chai'; +import { setViewport } from '@web/test-runner-commands'; const { default: init, getCookie } = await import('../../../libs/features/georoutingv2/georoutingv2.js'); let { getMetadata } = await import('../../../libs/utils/utils.js'); @@ -7,7 +8,7 @@ const { createTag, loadStyle, loadBlock, setConfig } = await import('../../../li const mockConfig = { locales: { - '': { ietf: 'us' }, ch_de: {}, ch_fr: {}, ch_it: {}, mena_en: {}, de: {}, africa: {}, + '': { ietf: 'us' }, ch_de: {}, ch_fr: {}, ch_it: {}, mena_en: {}, de: {}, africa: {}, eg_ar: { dir: 'rtl' }, eg_en: { }, }, locale: { contentRoot: window.location.href, prefix: '' }, env: 'test', @@ -88,6 +89,26 @@ const mockGeoroutingJson = { languageOrder: '', geo: 'africa', }, + { + prefix: 'eg_ar', + title: 'موقع Adobe هذا لا يتطابق مع موقعك.', + text: 'بناءً على موقعك ، نعتقد أنك قد تفضل موقع مصر ، حيث ستحصل على المحتوى الجغرافي والعروض والأسعار', + button: 'مصر - اللغة العربية', + akamaiCodes: 'EG', + language: 'عربي', + languageOrder: '1', + geo: 'eg', + }, + { + prefix: 'eg_en', + title: "You're visiting Adobe.com for {{geo}}", + text: "Based on your location, we think you may prefer the Egypt website, where you'll get geoal content, offerings, and pricing", + button: 'Egypt', + akamaiCodes: 'EG', + language: 'English', + languageOrder: '2', + geo: 'eg', + }, ], }, geos: { @@ -147,6 +168,25 @@ const mockGeoroutingJson = { mena: 'the Middle East & North Africa', africa: 'Africa', }, + { + prefix: 'eg_ar', + us: 'الولايات المتحدة الأمريكية', + de: 'ألمانيا', + ch: 'سويسرا', + mena: 'الشرق الأوسط وشمال أفريقيا', + africa: 'أفريقيا', + eg: 'مصر', + }, + { + prefix: 'eg_en', + us: 'the United States', + de: 'Germany', + ch: 'Switzerland', + mena: 'the Middle East & North Africa', + africa: 'Africa', + eg: 'Egypt', + }, + ], }, }; @@ -155,15 +195,17 @@ let stubURLSearchParamsGet = stub(URLSearchParams.prototype, 'get'); const setUserCountryFromIP = (country = 'CH') => { stubURLSearchParamsGet = stubURLSearchParamsGet.withArgs('akamaiLocale').returns(country); }; -const setHideGeorouting = (setting) => { - stubURLSearchParamsGet = stubURLSearchParamsGet.withArgs('hideGeorouting').returns(setting); +const setGeorouting = (setting) => { + stubURLSearchParamsGet = stubURLSearchParamsGet.withArgs('georouting').returns(setting); }; +const ogInnerHeight = window.innerHeight; const ogFetch = window.fetch; window.fetch = stub(); function stubHeadRequestToReturnVal(prefix, val) { - window.fetch.withArgs(`${prefix}`, { method: 'HEAD' }).returns( + const path = window.location.href.replace(`${window.location.origin}`, ''); + window.fetch.withArgs(`${prefix}${path}`, { method: 'HEAD' }).returns( new Promise((resolve) => { resolve({ ok: val }); }), @@ -199,7 +241,7 @@ describe('GeoRouting', () => { before(() => { setUserCountryFromIP(); stubFetchForGeorouting(); - setHideGeorouting(); + setGeorouting(); }); after(() => { stubURLSearchParamsGet.reset(); @@ -207,7 +249,6 @@ describe('GeoRouting', () => { }); afterEach(() => { document.cookie = 'international=; expires= Thu, 01 Jan 1970 00:00:00 GMT'; - sessionStorage.removeItem('international'); closeModal(); }); @@ -241,7 +282,7 @@ describe('GeoRouting', () => { it('Does not create a modal if the user IP matches session storage.', async () => { // prepare setUserCountryFromIP('US'); - sessionStorage.setItem('international', 'us'); + document.cookie = 'international=us;path=/;'; await init(mockConfig, createTag, getMetadata, loadBlock, loadStyle); const modal = document.querySelector('.dialog-modal'); // assert @@ -292,11 +333,67 @@ describe('GeoRouting', () => { expect(italianTab.querySelectorAll('a')[1].textContent).to.be.equal(usData.button); }); + it('If aiming for US page but IP in Egypt arabic content in geo routing modal is in rtl', async () => { + // prepare + setUserCountryFromIP('EG'); + await init(mockConfig, createTag, getMetadata, loadBlock, loadStyle); + const modal = document.querySelector('.dialog-modal'); + // assert + expect(modal).to.not.be.null; + const tabs = modal.querySelectorAll('.tabpanel'); + expect(tabs.length).to.be.equal(2); + const arabicTab = tabs[0]; + const downArrow = arabicTab.querySelectorAll('img')[1]; + expect(arabicTab.querySelector('h3').getAttribute('dir')).to.be.equal('rtl'); + expect(arabicTab.querySelector('p').getAttribute('dir')).to.be.equal('rtl'); + expect(downArrow).to.not.be.null; + downArrow.click(); + expect(arabicTab.querySelector('ul').getAttribute('dir')).to.be.equal('rtl'); + // Cleanup + setUserCountryFromIP('CH'); + }); + + it('If aiming for US page but IP in Egypt english content in geo routing modal is in ltr', async () => { + // prepare + setUserCountryFromIP('EG'); + await init(mockConfig, createTag, getMetadata, loadBlock, loadStyle); + const modal = document.querySelector('.dialog-modal'); + // assert + expect(modal).to.not.be.null; + const tabs = modal.querySelectorAll('.tabpanel'); + expect(tabs.length).to.be.equal(2); + const englishTab = tabs[1]; + const downArrow = englishTab.querySelectorAll('img')[1]; + expect(englishTab.querySelector('h3').getAttribute('dir')).to.be.equal('ltr'); + expect(englishTab.querySelector('p').getAttribute('dir')).to.be.equal('ltr'); + expect(downArrow).to.not.be.null; + downArrow.click(); + expect(englishTab.querySelector('ul').getAttribute('dir')).to.be.equal('ltr'); + // Cleanup + setUserCountryFromIP('CH'); + }); + + it('If aiming for Arabic page but IP in US english content in geo routing modal is in ltr', async () => { + // prepare + mockConfig.locale.prefix = 'eg_ar'; + setUserCountryFromIP('US'); + await init(mockConfig, createTag, getMetadata, loadBlock, loadStyle); + const modal = document.querySelector('.dialog-modal'); + const wrapper = document.querySelector('.georouting-wrapper'); + // assert + expect(modal).to.not.be.null; + expect(wrapper.querySelector('h3').getAttribute('dir')).to.be.equal('ltr'); + expect(wrapper.querySelector('p').getAttribute('dir')).to.be.equal('ltr'); + // Cleanup + setUserCountryFromIP('CH'); + mockConfig.locale.prefix = ''; + }); + it('If aiming for CH page, IP in US, and session storage is DE shows DE links and CH continue', async () => { // prepare - mockConfig.locale.prefix = 'ch_fr'; + mockConfig.locale.prefix = '/ch_fr'; setUserCountryFromIP('US'); - sessionStorage.setItem('international', 'de'); + document.cookie = 'international=de;path=/;'; await init(mockConfig, createTag, getMetadata, loadBlock, loadStyle); const wrapper = document.querySelector('.georouting-wrapper'); // assert @@ -355,7 +452,7 @@ describe('GeoRouting', () => { it('If aiming for ch_de page and storage is ch_fr no modal is shown', async () => { // prepare mockConfig.locale.prefix = 'ch_de'; - sessionStorage.setItem('international', 'ch_fr'); + document.cookie = 'international=ch_fr;path=/;'; await init(mockConfig, createTag, getMetadata, loadBlock, loadStyle); const modal = document.querySelector('.dialog-modal'); // assert @@ -436,11 +533,9 @@ describe('GeoRouting', () => { await init(mockConfig, createTag, getMetadata, loadBlock, loadStyle); const modal = document.querySelector('.dialog-modal'); const cookie = getCookie('international'); - const storage = sessionStorage.getItem('international'); // assert expect(modal).to.not.be.null; expect(cookie).to.be.undefined; - expect(storage).to.be.null; const links = modal.querySelectorAll('a'); links[0].click(); const picker = document.querySelector('.locale-modal-v2 .picker'); @@ -454,32 +549,40 @@ describe('GeoRouting', () => { expect(document.querySelector('.picker')).to.be.null; }); + it('Add class .top to picker when there is no space to render below the trigger button', async () => { + await setViewport({ width: 600, height: 100 }); + await init(mockConfig, createTag, getMetadata, loadBlock, loadStyle); + const modal = document.querySelector('.dialog-modal'); + const links = modal.querySelectorAll('a'); + links[0].click(); + const picker = document.querySelector('.locale-modal-v2 .picker.top'); + expect(picker).to.not.be.null; + await setViewport({ width: 600, height: ogInnerHeight }); + }); + it('Sets international and georouting_presented cookies on link click in modal', async () => { // prepare await init(mockConfig, createTag, getMetadata, loadBlock, loadStyle); const modal = document.querySelector('.dialog-modal'); const cookie = getCookie('international'); - const storage = sessionStorage.getItem('international'); // assert expect(modal).to.not.be.null; expect(cookie).to.be.undefined; - expect(storage).to.be.null; const links = modal.querySelectorAll('a'); expect(links).to.not.be.null; expect(links[0].text).to.be.equal(mockGeoroutingJson.georouting.data.find((d) => d.prefix === 'ch_de').button); links[1].click(); expect(getCookie('international')).to.be.equal('us'); - expect(sessionStorage.getItem('international')).to.be.equal('us'); }); it('Does not open georouting modal if georouting hide is active', async () => { // prepare - setHideGeorouting('on'); + setGeorouting('off'); await init(mockConfig, createTag, getMetadata, loadBlock, loadStyle); const modal = document.querySelector('.dialog-modal'); // assert expect(modal).to.be.null; // cleanup - setHideGeorouting('off'); + setGeorouting('on'); }); }); diff --git a/test/features/google-login/google-login.test.js b/test/features/google-login/google-login.test.js new file mode 100644 index 0000000000..bcf63d7499 --- /dev/null +++ b/test/features/google-login/google-login.test.js @@ -0,0 +1,86 @@ +import sinon from 'sinon'; +import { expect } from '@esm-bundle/chai'; +import { readFile } from '@web/test-runner-commands'; +import initGoogleLogin from '../../../libs/features/google-login.js'; + +describe('Google Login', () => { + let initializeSpy; + let promptSpy; + beforeEach(async () => { + document.body.innerHTML = await readFile({ path: './mocks/google-login.html' }); + window.google = window.google || { + accounts: { + id: { + initialize: () => {}, + prompt: () => {}, + }, + }, + }; + initializeSpy = sinon.spy(window.google.accounts.id, 'initialize'); + promptSpy = sinon.spy(window.google.accounts.id, 'prompt'); + window.adobeid = { + client_id: 'milo', + scope: 'gnav', + }; + }); + + afterEach(() => { + document.body.innerHTML = ''; + initializeSpy.restore(); + promptSpy.restore(); + delete window.adobeid; + delete window.google; + }); + + it('should create a placeholder to inject DOM markup', async () => { + await initGoogleLogin(sinon.stub(), sinon.stub(), sinon.stub()); + expect(document.getElementById('feds-googleLogin')).to.exist; + }); + + it('should initialize and render the login element', async () => { + await initGoogleLogin(sinon.stub(), sinon.stub(), sinon.stub()); + expect(initializeSpy.called).to.be.true; + expect(promptSpy.called).to.be.true; + expect(initializeSpy.getCall(0).args[0].prompt_parent_id).to.equal('feds-googleLogin'); + }); + + it('should exchange tokens with adobeIMS after login', async () => { + window.adobeIMS = window.adobeIMS || { + socialHeadlessSignIn: () => {}, + isSignedInUser: () => false, + signInWithSocialProvider: () => {}, + }; + + // No account + const socialHeadlessSignInStub = sinon.stub(window.adobeIMS, 'socialHeadlessSignIn') + .returns(new Promise((resolve, reject) => { reject(); })); + const signInWithSocialProviderSpy = sinon.spy(window.adobeIMS, 'signInWithSocialProvider'); + await initGoogleLogin(sinon.stub(), sinon.stub(), sinon.stub()); + let onToken = initializeSpy.getCall(0).args[0].callback; + await onToken(sinon.stub(), sinon.stub()); + expect(signInWithSocialProviderSpy.called).to.be.true; + + // Existing account + socialHeadlessSignInStub.returns(new Promise((resolve) => { resolve(); })); + await initGoogleLogin(sinon.stub(), sinon.stub(), sinon.stub()); + onToken = initializeSpy.getCall(0).args[0].callback; + window.DISABLE_PAGE_RELOAD = true; + signInWithSocialProviderSpy.resetHistory(); + await onToken(sinon.stub(), sinon.stub()); + expect(signInWithSocialProviderSpy.called).not.to.be.true; + expect(document.getElementById('feds-googleLogin')).to.exist; + signInWithSocialProviderSpy.restore(); + socialHeadlessSignInStub.restore(); + delete window.DISABLE_PAGE_RELOAD; + }); + + it('should not initialize if IMS is not ready or user is already logged-in', async () => { + window.adobeIMS = window.adobeIMS || { isSignedInUser: () => {} }; + const loggedInStub = sinon.stub(window.adobeIMS, 'isSignedInUser').returns(() => true); + await initGoogleLogin(sinon.stub(), sinon.stub(), sinon.stub()); + expect(document.getElementById('feds-googleLogin')).not.to.exist; + await initGoogleLogin(Promise.reject, sinon.stub(), sinon.stub()); + expect(document.getElementById('feds-googleLogin')).not.to.exist; + loggedInStub.restore(); + }); +}); diff --git a/test/features/google-login/mocks/google-login.html b/test/features/google-login/mocks/google-login.html new file mode 100644 index 0000000000..ddc65a886b --- /dev/null +++ b/test/features/google-login/mocks/google-login.html @@ -0,0 +1 @@ +
            diff --git a/test/features/icons/icons.test.js b/test/features/icons/icons.test.js index a31b98a0b6..26d3655642 100644 --- a/test/features/icons/icons.test.js +++ b/test/features/icons/icons.test.js @@ -2,7 +2,7 @@ import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; import { setConfig, getConfig } from '../../../libs/utils/utils.js'; -const { default: loadIcons } = await import('../../../libs/features/icons.js'); +const { default: loadIcons } = await import('../../../libs/features/icons/icons.js'); const codeRoot = '/libs'; const conf = { codeRoot }; @@ -11,12 +11,27 @@ const config = getConfig(); document.body.innerHTML = await readFile({ path: './mocks/body.html' }); +let icons; + describe('Icon Suppprt', () => { + before(async () => { + icons = document.querySelectorAll('span.icon'); + await loadIcons(icons, config); + }); + it('Replaces span.icon', async () => { - const domIcons = document.querySelectorAll('span.icon'); - if (domIcons.length === 0) return; - await loadIcons(domIcons, config); - const selector = domIcons[0].querySelector(':scope svg'); + const selector = icons[0].querySelector(':scope svg'); expect(selector).to.exist; }); + + it('Creates default tooltip', async () => { + const tooltip = document.querySelector('.milo-tooltip.right'); + expect(tooltip).to.exist; + expect(tooltip.dataset.tooltip).to.equal('This is my tooltip text.'); + }); + + it('Creates top tooltip', async () => { + const tooltip = document.querySelector('.milo-tooltip.top'); + expect(tooltip).to.exist; + }); }); diff --git a/test/features/icons/mocks/body.html b/test/features/icons/mocks/body.html index 41e5bd3c8d..586013bf4f 100644 --- a/test/features/icons/mocks/body.html +++ b/test/features/icons/mocks/body.html @@ -8,6 +8,8 @@

            Label before

            Label after

            This is text w/ an in the middle of the paragraph.

            +

            | This is my tooltip text.

            +

            | top | This is my tooltip text.

            diff --git a/test/blocks/interlinks/interlinks.test.js b/test/features/interlinks/interlinks.test.js similarity index 55% rename from test/blocks/interlinks/interlinks.test.js rename to test/features/interlinks/interlinks.test.js index e839828d77..709ad03ea7 100644 --- a/test/blocks/interlinks/interlinks.test.js +++ b/test/features/interlinks/interlinks.test.js @@ -1,10 +1,13 @@ -import { expect } from '@esm-bundle/chai'; import { readFile } from '@web/test-runner-commands'; -import interlink from '../../../libs/features/interlinks.js'; +import { expect } from '@esm-bundle/chai'; +import { waitForElement } from '../../helpers/waitfor.js'; document.body.innerHTML = await readFile({ path: './mocks/body.html' }); let foundInterlinks = 0; +const { default: init } = await import('../../../libs/features/interlinks.js'); +const anchorUrl = 'http://oceandecade.org/'; + function interlinksCheck(item) { if (item.getAttribute('data-origin')) { expect(item.getAttribute('data-origin')).to.equal('interlink'); @@ -14,9 +17,6 @@ function interlinksCheck(item) { if (!item.getAttribute('daa-ll')) { return; } - if (item.parentElement.getAttribute('daa-lh')) { - expect(item.parentElement.getAttribute('daa-lh')).to.contain('interlinks_p_'); - } else return; foundInterlinks += 1; } @@ -24,7 +24,7 @@ describe('Interlinks', async () => { it('No valid keywords file is provided', async () => { foundInterlinks = 0; document.body.innerHTML = await readFile({ path: './mocks/maxlinks.plain.html' }); - await interlink('/test/blocks/interlinks/mocks/invalid.json'); + await init('/test/features/interlinks/mocks/invalid.json'); const atags = document.getElementsByTagName('a'); atags.forEach(interlinksCheck); expect(foundInterlinks).to.equal(0); @@ -33,7 +33,7 @@ describe('Interlinks', async () => { it('No main tag is found', async () => { foundInterlinks = 0; document.body.innerHTML = await readFile({ path: './mocks/nomain.plain.html' }); - await interlink('/test/blocks/interlinks/mocks/keywords.json'); + await init('/test/features/interlinks/mocks/keywords.json'); const atags = document.getElementsByTagName('a'); atags.forEach(interlinksCheck); expect(foundInterlinks).to.equal(0); @@ -42,7 +42,7 @@ describe('Interlinks', async () => { it('Keywords file has no records, no interlinks are made', async () => { foundInterlinks = 0; document.body.innerHTML = await readFile({ path: './mocks/interlinks.plain.html' }); - await interlink('/test/blocks/interlinks/mocks/empty.json'); + await init('/test/features/interlinks/mocks/empty.json'); const atags = document.getElementsByTagName('a'); atags.forEach(interlinksCheck); expect(foundInterlinks).to.equal(0); @@ -51,7 +51,7 @@ describe('Interlinks', async () => { it('Interlinks not made, ratio of text to links on page is too small', async () => { foundInterlinks = 0; document.body.innerHTML = await readFile({ path: './mocks/maxlinks.plain.html' }); - await interlink('/test/blocks/interlinks/mocks/keywords.json'); + await init('/test/features/interlinks/mocks/keywords.json'); const atags = document.getElementsByTagName('a'); atags.forEach(interlinksCheck); expect(foundInterlinks).to.equal(0); @@ -60,7 +60,7 @@ describe('Interlinks', async () => { it('Interlinks not made, no matches found', async () => { foundInterlinks = 0; document.body.innerHTML = await readFile({ path: './mocks/interlinks.plain.html' }); - await interlink('/test/blocks/interlinks/mocks/nomatches.json'); + await init('/test/features/interlinks/mocks/nomatches.json'); const atags = document.getElementsByTagName('a'); atags.forEach(interlinksCheck); expect(foundInterlinks).to.equal(0); @@ -69,9 +69,38 @@ describe('Interlinks', async () => { it('Interlinks are made with a valid keywords file', async () => { foundInterlinks = 0; document.body.innerHTML = await readFile({ path: './mocks/interlinks.plain.html' }); - await interlink('/test/blocks/interlinks/mocks/keywords.json'); + await init('/test/features/interlinks/mocks/keywords.json'); const atags = document.getElementsByTagName('a'); atags.forEach(interlinksCheck); expect(foundInterlinks).to.equal(3); }); }); + +describe('Interlinks check for different languages', () => { + it('works for english language', async () => { + document.body.innerHTML = await readFile({ path: './mocks/body_en.html' }); + await init('/test/features/interlinks/mocks/keywords_lang.json', 'en'); + await waitForElement('a'); + const anchorElem = document.querySelector('a'); + expect(anchorElem.getAttribute('href')).equals(anchorUrl); + expect(anchorElem.getAttribute('title')).equals('Ocean breeze'); + }); + + it('works for korean language', async () => { + document.body.innerHTML = await readFile({ path: './mocks/body_ko.html' }); + await init('/test/features/interlinks/mocks/keywords_lang.json', 'ko'); + await waitForElement('a'); + const anchorElem = document.querySelector('a'); + expect(anchorElem.getAttribute('href')).equals(anchorUrl); + expect(anchorElem.getAttribute('title')).equals('바닷바람'); + }); + + it('works for french language', async () => { + document.body.innerHTML = await readFile({ path: './mocks/body_fr.html' }); + await init('/test/features/interlinks/mocks/keywords_lang.json', 'fr'); + await waitForElement('a'); + const anchorElem = document.querySelector('a'); + expect(anchorElem.getAttribute('href')).equals(anchorUrl); + expect(anchorElem.getAttribute('title')).equals('Brise marine'); + }); +}); diff --git a/test/blocks/interlinks/mocks/body.html b/test/features/interlinks/mocks/body.html similarity index 100% rename from test/blocks/interlinks/mocks/body.html rename to test/features/interlinks/mocks/body.html diff --git a/test/features/interlinks/mocks/body_en.html b/test/features/interlinks/mocks/body_en.html new file mode 100644 index 0000000000..7e6a93feee --- /dev/null +++ b/test/features/interlinks/mocks/body_en.html @@ -0,0 +1,11 @@ +
            +
            +

            You can rotate the ring around your lens manually or choose f-numbers from a dial. Both have their advantages — take a look tosee how each one can take + your photography to the next level using a/b testing. You can rotate the ring around your lens manually or choose f-numbers from a dial. Both have their + advantages — take a look to see how each one can take your photography to the next level using a/b testing. You can rotate the ring around your lens + manually or choose f-numbers from a dial. Both have their advantages — take a look to see how each one can take your photography to the next level using + a/b testing. You can rotate the ring around your lens manually or choose f-numbers from a dial. Both have their advantages — take a look to see how + each one can take your photography to the next level using a/b testing and not even in the. Ocean breeze +

            +
            +
            diff --git a/test/features/interlinks/mocks/body_fr.html b/test/features/interlinks/mocks/body_fr.html new file mode 100644 index 0000000000..d834420b05 --- /dev/null +++ b/test/features/interlinks/mocks/body_fr.html @@ -0,0 +1,13 @@ +
            +
            +

            Vous pouvez faire pivoter manuellement l’anneau autour de votre objectif ou choisir les chiffres f à l’aide d’un cadran. Les deux ont leurs + avantages : examinez comment chacun d'eux peut donner une nouvelle dimension à vos photos à l'aide de tests a/b. Vous pouvez faire pivoter manuellement + l’anneau autour de votre objectif ou choisir les chiffres f à l’aide d’un cadran. Les deux ont leurs avantages : examinez comment chacun d'eux peut + donner une nouvelle dimension à vos photos à l'aide de tests a/b. Vous pouvez faire pivoter manuellement l’anneau autour de votre objectif ou choisir + les chiffres f à l’aide d’un cadran. Les deux ont leurs avantages : examinez comment chacun d'eux peut donner une nouvelle dimension à vos photos à + l'aide de tests a/b. Vous pouvez faire pivoter manuellement l’anneau autour de votre objectif ou choisir les chiffres f à l’aide d’un cadran. Les + deux ont leurs avantages : examinez la façon dont chacun d'eux peut donner une nouvelle dimension à vos photos à l'aide de tests a/b, et pas + même dans l'image. Brise marine +

            +
            +
            diff --git a/test/features/interlinks/mocks/body_ko.html b/test/features/interlinks/mocks/body_ko.html new file mode 100644 index 0000000000..fe7116d78c --- /dev/null +++ b/test/features/interlinks/mocks/body_ko.html @@ -0,0 +1,9 @@ +
            +
            +

            수동으로 렌즈를 중심으로 링을 회전하거나 다이얼에서 f-번호를 선택할 수 있습니다. 두 가지 모두 장점이 있습니다. a/b 테스트를 통해 각각의 사진으로 한 단계 더 끌어올릴 수 있는 방법을 살펴보세요. + 수동으로 렌즈를 중심으로 링을 회전하거나 다이얼에서 f-번호를 선택할 수 있습니다. 두 가지 모두 장점이 있습니다. a/b 테스트를 통해 각각의 사진으로 한 단계 더 끌어올릴 수 있는 방법을 살펴보세요. + 수동으로 렌즈를 중심으로 링을 회전하거나 다이얼에서 f-번호를 선택할 수 있습니다. 두 가지 모두 장점이 있습니다. a/b 테스트를 통해 각각의 사진으로 한 단계 더 끌어올릴 수 있는 방법을 살펴보세요. + 수동으로 렌즈를 중심으로 링을 회전하거나 다이얼에서 f-번호를 선택할 수 있습니다. 두 가지 모두 장점이 있습니다. 사진을 한 단계 더 끌어올릴 수 있는 방법을 살펴보세요(a/b 테스트 사용 시). 바닷바람 +

            +
            +
            diff --git a/test/blocks/interlinks/mocks/empty.json b/test/features/interlinks/mocks/empty.json similarity index 100% rename from test/blocks/interlinks/mocks/empty.json rename to test/features/interlinks/mocks/empty.json diff --git a/test/blocks/interlinks/mocks/interlinks.plain.html b/test/features/interlinks/mocks/interlinks.plain.html similarity index 100% rename from test/blocks/interlinks/mocks/interlinks.plain.html rename to test/features/interlinks/mocks/interlinks.plain.html diff --git a/test/blocks/interlinks/mocks/keywords.json b/test/features/interlinks/mocks/keywords.json similarity index 100% rename from test/blocks/interlinks/mocks/keywords.json rename to test/features/interlinks/mocks/keywords.json diff --git a/test/features/interlinks/mocks/keywords_lang.json b/test/features/interlinks/mocks/keywords_lang.json new file mode 100644 index 0000000000..511e2250fb --- /dev/null +++ b/test/features/interlinks/mocks/keywords_lang.json @@ -0,0 +1,29 @@ +{ + "total": 3, + "offset": 0, + "limit": 10, + "data": [ + { + "Cloud": "SEO", + "Keyword": "Ocean breeze", + "URL": "http://oceandecade.org/", + "Last Update": "", + "Notes (SEO Team)": "" + }, + { + "Cloud": "SEO", + "Keyword": "바닷바람", + "URL": "http://oceandecade.org/", + "Last Update": "", + "Notes (SEO Team)": "" + }, + { + "Cloud": "SEO", + "Keyword": "Brise marine", + "URL": "http://oceandecade.org/", + "Last Update": "", + "Notes (SEO Team)": "" + } + ], + ":type": "sheet" + } diff --git a/test/blocks/interlinks/mocks/maxlinks.plain.html b/test/features/interlinks/mocks/maxlinks.plain.html similarity index 100% rename from test/blocks/interlinks/mocks/maxlinks.plain.html rename to test/features/interlinks/mocks/maxlinks.plain.html diff --git a/test/blocks/interlinks/mocks/nomain.plain.html b/test/features/interlinks/mocks/nomain.plain.html similarity index 100% rename from test/blocks/interlinks/mocks/nomain.plain.html rename to test/features/interlinks/mocks/nomain.plain.html diff --git a/test/blocks/interlinks/mocks/nomatches.json b/test/features/interlinks/mocks/nomatches.json similarity index 100% rename from test/blocks/interlinks/mocks/nomatches.json rename to test/features/interlinks/mocks/nomatches.json diff --git a/test/features/japanese-word-wrap/japanese-word-wrap.test.js b/test/features/japanese-word-wrap/japanese-word-wrap.test.js index 74897f5001..d8c636a923 100644 --- a/test/features/japanese-word-wrap/japanese-word-wrap.test.js +++ b/test/features/japanese-word-wrap/japanese-word-wrap.test.js @@ -2,7 +2,12 @@ import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; import { setConfig, getConfig } from '../../../libs/utils/utils.js'; -const { controlLineBreaksJapanese } = await import('../../../libs/features/japanese-word-wrap.js'); +const { + applyJapaneseLineBreaks, + isBalancedWordWrapApplied, + isWordWrapApplied, + default: controlJapaneseLineBreaks, +} = await import('../../../libs/features/japanese-word-wrap.js'); const codeRoot = '/libs'; const conf = { codeRoot }; @@ -14,32 +19,110 @@ describe('Japanese Word Wrap', () => { document.body.innerHTML = await readFile({ path: './mocks/body.html' }); }); - it('Apply JP word wrap to all headings (h1-h6)', async () => { - await controlLineBreaksJapanese(config); - document.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((elem) => { - expect(window.getComputedStyle(elem).wordBreak).to.equal('keep-all', 'JP word wrap is not applied'); + it('Apply JP word wrap to specified headings and paragraph under div#main', async () => { + await applyJapaneseLineBreaks(config); + document.querySelectorAll('#main > :where(h1, h2, h3, h4, h5, h6, p)').forEach((elem) => { + expect(isWordWrapApplied(elem), 'JP Word Wrap should be appied').to.be.true; }); }); - it('Apply JP word wrap to all headings (h1-h6) under div#area', async () => { - const area = document.getElementById('area'); - await controlLineBreaksJapanese(config, area); - area.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((elem) => { - expect(window.getComputedStyle(elem).wordBreak).to.equal('keep-all', 'JP word wrap is not applied'); + it('Apply JP word wrap to specified headings and paragraph under div#areaa', async () => { + const scopeArea = document.getElementById('area'); + await applyJapaneseLineBreaks(config, { scopeArea }); + scopeArea.querySelectorAll('h1, h2, h3, h4, h5, h6, p').forEach((elem) => { + expect(isWordWrapApplied(elem), 'JP Word Wrap should be appied').to.be.true; }); + const elem = document.querySelector('#h1-headline'); + expect(isWordWrapApplied(elem), 'JP Word Wrap should not be appied').to.be.false; }); - it('Apply JP word wrap to H1', async () => { - await controlLineBreaksJapanese(config, document, 'h1'); - document.querySelectorAll('h1').forEach((elem) => { - expect(window.getComputedStyle(elem).wordBreak).to.equal('keep-all', 'JP word wrap is not applied'); + it('Exclude specified selector from applying JP word wrap under div#main', async () => { + await applyJapaneseLineBreaks(config, { budouxExcludeSelector: 'p' }); + document.querySelectorAll('#main > :where(h1, h2, h3, h4, h5, h6)').forEach((elem) => { + expect(isWordWrapApplied(elem), 'JP Word Wrap should be appied').to.be.true; }); + document.querySelectorAll('#main > p').forEach((elem) => { + expect(isWordWrapApplied(elem), 'JP Word Wrap should not be appied').to.be.false; + }); + }); + + it('Should not apply JP word wrap to elements under class jpwordwrap-disabled', async () => { + await applyJapaneseLineBreaks(config); + document.querySelectorAll('.jpwordwrap-disabled > :where(h1, h2, h3, h4, h5, h6)').forEach((elem) => { + expect(isWordWrapApplied(elem), 'JP Word Wrap should not be appied').to.be.false; + }); + }); + + it('Apply balanced word wrap to specified elements under div#main', async () => { + await applyJapaneseLineBreaks(config, { + bwEnabled: true, + bwDisabledSelector: 'p', + }); + document.querySelectorAll('#main > :where(h1, h2, h3, h4, h5, h6)').forEach((elem) => { + expect(isBalancedWordWrapApplied(elem), 'Balance word wrap should be applied').to.be.true; + }); + document.querySelectorAll('#main > p').forEach((elem) => { + expect(isBalancedWordWrapApplied(elem), 'Balance word wrap should not be applied').to.be.false; + }); + }); + + it('Prevent line breaks for specified patterns', async () => { + await applyJapaneseLineBreaks(config, { lineBreakNgPatterns: ['Miloは、#adobe.com', 'Franklinベースの#ウェブサイト'] }); + const elem = document.querySelector('#h1-headline'); + expect(elem.innerHTML).include('Franklinベースのウェブサイト', ' should not appear'); + expect(elem.innerHTML).include('Miloは、adobe.com', ' should not appear'); + }); + + it('Allow allow line breaks for specified patterns', async () => { + await applyJapaneseLineBreaks(config, { lineBreakOkPatterns: ['共有#機能', 'ウェブ#サイト'] }); + const elem = document.querySelector('#h1-headline'); + expect(elem.innerHTML).include('共有機能', ' should appear'); + expect(elem.innerHTML).include('ウェブサイト', ' should appear'); }); - it('Do not apply JP word wrap to paragraphs (p)', async () => { - await controlLineBreaksJapanese(config); - document.querySelectorAll('p').forEach((elem) => { - expect(window.getComputedStyle(elem).wordBreak).not.equal('keep-all', 'JP word wrap is applied'); + it('Handle invalid pattern specification gracefully', async () => { + await applyJapaneseLineBreaks(config, { + lineBreakOkPatterns: ['共有機能', '共#有機能'], + lineBreakNgPatterns: ['共有', '共有##機能', 'ウェブ#サイト共有#機能'], + }); + const elem = document.querySelector('#h1-headline'); + expect(isWordWrapApplied(elem), 'JP Word Wrap should be appied').to.be.true; + }); + + it('Respect budouxThres value in configuration', async () => { + await applyJapaneseLineBreaks(config, { budouxThres: Number.MAX_VALUE }); + const elem = document.querySelector('#h1-headline'); + expect(isWordWrapApplied(elem), 'JP Word Wrap should not be appied').to.be.false; + }); + + it('Read options from metadata correctly', async () => { + document.head.innerHTML = await readFile({ path: './mocks/head.html' }); + await controlJapaneseLineBreaks(config); + + // jpwordwrap:budoux-exclude-selector works correctly + document.querySelectorAll('#main > :where(h1, h2, h3, h4, h5, h6)').forEach((elem) => { + expect(isWordWrapApplied(elem), 'JP Word Wrap should be appied').to.be.true; + expect(isBalancedWordWrapApplied(elem), 'Balance word wrap should be applied').to.be.true; + }); + document.querySelectorAll('#main > p').forEach((elem) => { + expect(isWordWrapApplied(elem), 'JP Word Wrap should be appied').to.be.true; + expect(isBalancedWordWrapApplied(elem), 'Balance word wrap should not be applied').to.be.false; + }); + + // jpwordwrap:line-break-ok work correctly + const elem = document.querySelector('#paragraph'); + expect(elem.innerHTML).include('共有機能', ' should appear'); + expect(elem.innerHTML).include('ウェブサイト', ' should appear'); + }); + + it('Disbale JP word wrap from options correctly', async () => { + document.head.innerHTML = await readFile({ path: './mocks/head-disabled.html' }); + await controlJapaneseLineBreaks(config); + + // jpwordwrap:disabled works correctly + document.querySelectorAll('h1, h2, h3, h4, h5, h6, p').forEach((elem) => { + expect(isWordWrapApplied(elem), 'JP Word Wrap should not be appied').to.be.false; + expect(isBalancedWordWrapApplied(elem), 'Balance word wrap should not be applied').to.be.false; }); }); }); diff --git a/test/features/japanese-word-wrap/mocks/body.html b/test/features/japanese-word-wrap/mocks/body.html index c344c3e082..e0daba0dc0 100644 --- a/test/features/japanese-word-wrap/mocks/body.html +++ b/test/features/japanese-word-wrap/mocks/body.html @@ -1,18 +1,22 @@
            -
            -

            H1 Miloは、adobe.com上のFranklinベースのウェブサイトを強化するための共有機能およびサービスです。

            +
            +

            H1 Miloは、adobe.com上のFranklinベースのウェブサイトを強化するための共有機能およびサービスです。

            H2 Miloは、adobe.com上のFranklinベースのウェブサイトを強化するための共有機能およびサービスです。

            H3 Miloは、adobe.com上のFranklinベースのウェブサイトを強化するための共有機能およびサービスです。

            H4 Miloは、adobe.com上のFranklinベースのウェブサイトを強化するための共有機能およびサービスです。

            H5 Miloは、adobe.com上のFranklinベースのウェブサイトを強化するための共有機能およびサービスです。
            H6 Miloは、adobe.com上のFranklinベースのウェブサイトを強化するための共有機能およびサービスです。
            -

            P Miloは、adobe.com上のFranklinベースのウェブサイトを強化するための共有機能およびサービスです。

            +

            P Miloは、adobe.com上のFranklinベースのウェブサイトを強化するための共有機能およびサービスです。

            Area > H1 Miloは、adobe.com上のFranklinベースのウェブサイトを強化するための共有機能およびサービスです。

            Area > H2 Miloは、adobe.com上のFranklinベースのウェブサイトを強化するための共有機能およびサービスです。

            +
            +

            Area > H1 Miloは、adobe.com上のFranklinベースのウェブサイトを強化するための共有機能およびサービスです。

            +

            Area > H2 Miloは、adobe.com上のFranklinベースのウェブサイトを強化するための共有機能およびサービスです。

            +
            diff --git a/test/features/japanese-word-wrap/mocks/head-disabled.html b/test/features/japanese-word-wrap/mocks/head-disabled.html new file mode 100644 index 0000000000..a6a567c941 --- /dev/null +++ b/test/features/japanese-word-wrap/mocks/head-disabled.html @@ -0,0 +1 @@ + diff --git a/test/features/japanese-word-wrap/mocks/head.html b/test/features/japanese-word-wrap/mocks/head.html new file mode 100644 index 0000000000..f828efe97f --- /dev/null +++ b/test/features/japanese-word-wrap/mocks/head.html @@ -0,0 +1,4 @@ + + + + diff --git a/test/features/jarvis-chat/jarvis-chat.test.js b/test/features/jarvis-chat/jarvis-chat.test.js new file mode 100644 index 0000000000..822d34cb70 --- /dev/null +++ b/test/features/jarvis-chat/jarvis-chat.test.js @@ -0,0 +1,168 @@ +/* eslint-disable no-underscore-dangle */ +import sinon from 'sinon'; +import { expect } from '@esm-bundle/chai'; +import { readFile } from '@web/test-runner-commands'; +import { initJarvisChat, openChat } from '../../../libs/features/jarvis-chat.js'; +import { setConfig, getConfig } from '../../../libs/utils/utils.js'; + +const defaultConfig = { + jarvis: { + id: 'milo', + version: '1.0', + }, +}; + +describe('Jarvis Chat', () => { + let initializeSpy; + let openMessagingWindowSpy; + let isAdobeMessagingClientInitializedStub; + let getMessagingExperienceStateStub; + beforeEach(() => { + window.AdobeMessagingExperienceClient = window.AdobeMessagingExperienceClient + || { + initialize: () => {}, + openMessagingWindow: () => {}, + isAdobeMessagingClientInitialized: () => {}, + getMessagingExperienceState: () => {}, + }; + initializeSpy = sinon.spy(window.AdobeMessagingExperienceClient, 'initialize'); + openMessagingWindowSpy = sinon.spy(window.AdobeMessagingExperienceClient, 'openMessagingWindow'); + isAdobeMessagingClientInitializedStub = sinon.stub(window.AdobeMessagingExperienceClient, 'isAdobeMessagingClientInitialized').returns(true); + getMessagingExperienceStateStub = sinon.stub(window.AdobeMessagingExperienceClient, 'getMessagingExperienceState').returns({ windowState: 'hidden' }); + }); + + afterEach(() => { + initializeSpy.restore(); + openMessagingWindowSpy.restore(); + isAdobeMessagingClientInitializedStub.restore(); + getMessagingExperienceStateStub.restore(); + }); + + it('should not initialize when configuration is not available', async () => { + setConfig({}); + const config = getConfig(); + await initJarvisChat(config, sinon.stub(), sinon.stub()); + expect(initializeSpy.called).to.be.false; + }); + + it('should initialize when configuration is available', async () => { + setConfig(defaultConfig); + const config = getConfig(); + await initJarvisChat(config, sinon.stub(), sinon.stub()); + expect(initializeSpy.called).to.be.true; + }); + + it('should receive the correct configuration', async () => { + setConfig(defaultConfig); + const config = Object.assign(getConfig(), { + locale: { + ietf: 'en', + prefix: '/africa', + }, + }); + await initJarvisChat(config, sinon.stub(), sinon.stub()); + const args = initializeSpy.getCall(0).args[0]; + expect(args.appid).to.equal(config.jarvis.id); + expect(args.appver).to.equal(config.jarvis.version); + expect(args.env).to.equal(config.env.name === 'prod' ? 'prod' : 'stage'); + expect(args.accessToken).to.be.undefined; + expect(args.language).to.equal('en'); + expect(args.region).to.equal('africa'); + }); + + it('should open a chat session upon click', async () => { + document.body.innerHTML = await readFile({ path: './mocks/jarvis-chat.html' }); + setConfig(defaultConfig); + const config = getConfig(); + await initJarvisChat(config, sinon.stub(), sinon.stub()); + const args = initializeSpy.getCall(0).args[0]; + args.callbacks.initCallback({ releaseControl: { showAdobeMessaging: true } }); + openMessagingWindowSpy.resetHistory(); + document.body.querySelector('a').click(); + expect(openMessagingWindowSpy.called).to.be.true; + openMessagingWindowSpy.resetHistory(); + openChat(); + expect(openMessagingWindowSpy.called).to.be.true; + document.body.innerHTML = ''; + }); + + it('should synchronize analytics', async () => { + setConfig(defaultConfig); + const config = getConfig(); + await initJarvisChat(config, sinon.stub(), sinon.stub()); + const args = initializeSpy.getCall(0).args[0]; + + const iconRender = await readFile({ path: './mocks/sendChatIconRenderEvent.json' }); + window.digitalData = window.digitalData || {}; + window.alloy_all = window.digitalData || {}; + window._satellite = window._satellite || { track: sinon.spy() }; + window._satellite.track.resetHistory(); + args.callbacks.analyticsCallback(JSON.parse(iconRender)); + expect(window.digitalData.primaryEvent.eventInfo.eventName).to.equal('chat:init:launch:event.subtype:icon:render'); + expect(window.digitalData.chat.chatInfo.chatType).to.equal('render'); + expect(window._satellite.track.called).to.be.true; + + const iconClick = await readFile({ path: './mocks/sendChatIconClickEvent.json' }); + window._satellite.track.resetHistory(); + args.callbacks.analyticsCallback(JSON.parse(iconClick)); + expect(window.digitalData.primaryEvent.eventInfo.eventName).to.equal('chat:init:launch:event.subtype:icon:click'); + expect(window.digitalData.chat.chatInfo.chatType).to.equal('click'); + expect(window._satellite.track.called).to.be.true; + + const product = await readFile({ path: './mocks/sendProductEvent.json' }); + window._satellite.track.resetHistory(); + args.callbacks.analyticsCallback(JSON.parse(product)); + expect(window.digitalData.primaryEvent.eventInfo.eventName).to.equal('chat:product:auth-subproduct:sub:type'); + expect(window.digitalData.chat.chatInfo.primaryProduct.productName).to.equal('sub'); + expect(window._satellite.track.called).to.be.true; + + window.digitalData = { sophiaResponse: { fromPage: 'test2' } }; + const survey = await readFile({ path: './mocks/sendSurveyFeedbackEvent.json' }); + window._satellite.track.resetHistory(); + args.callbacks.analyticsCallback(JSON.parse(survey)); + expect(window.digitalData.primaryEvent.eventInfo.eventName).to.equal('chat:survey:5-star-survey:test:type:id'); + expect(window._satellite.track.called).to.be.true; + + const error = await readFile({ path: './mocks/sendChatErrorEvent.json' }); + window._satellite.track.resetHistory(); + args.callbacks.analyticsCallback(JSON.parse(error)); + expect(window.digitalData.chat.chatInfo.chatErrorCode).to.equal('x'); + expect(window.digitalData.chat.chatInfo.chatErrorType).to.equal('init'); + expect(window._satellite.track.called).to.be.true; + + window.digitalData = { sophiaResponse: { fromPage: [{ variationId: '2', campaignId: '3' }] } }; + const def = await readFile({ path: './mocks/sendPrimaryEvent.json' }); + window._satellite.track.resetHistory(); + args.callbacks.analyticsCallback(JSON.parse(def)); + expect(window.digitalData.chat.chatInfo.chatType).to.equal('default'); + expect(window.digitalData.sophiaResponse.fromPage.length).to.equal(2); + expect(window._satellite.track.called).to.be.true; + + window.digitalData = {}; + window.alloy_all = {}; + }); + + it('should initialize on demand when configured', async () => { + setConfig(defaultConfig); + const config = getConfig(); + await initJarvisChat(config, sinon.stub(), sinon.stub()); + const args = initializeSpy.getCall(0).args[0]; + // Set uninitialized state + args.callbacks.initCallback({ releaseControl: { showAdobeMessaging: false } }); + initializeSpy.resetHistory(); + + config.jarvis.onDemand = true; + document.body.innerHTML = await readFile({ path: './mocks/jarvis-chat.html' }); + await initJarvisChat(config, sinon.stub(), sinon.stub()); + expect(initializeSpy.called).to.be.false; + document.querySelector('a').click(); + await new Promise((resolve) => { + setTimeout(resolve, 1); + }); + expect(initializeSpy.called).to.be.true; + const params = initializeSpy.getCall(0).args[0]; + params.callbacks.initCallback({ releaseControl: { showAdobeMessaging: true } }); + params.callbacks.onReadyCallback(); + expect(openMessagingWindowSpy.called).to.be.true; + }); +}); diff --git a/test/features/jarvis-chat/mocks/jarvis-chat.html b/test/features/jarvis-chat/mocks/jarvis-chat.html new file mode 100644 index 0000000000..4747c1c544 --- /dev/null +++ b/test/features/jarvis-chat/mocks/jarvis-chat.html @@ -0,0 +1 @@ +open chat diff --git a/test/features/jarvis-chat/mocks/sendChatErrorEvent.json b/test/features/jarvis-chat/mocks/sendChatErrorEvent.json new file mode 100644 index 0000000000..6379615d72 --- /dev/null +++ b/test/features/jarvis-chat/mocks/sendChatErrorEvent.json @@ -0,0 +1,10 @@ +{ + "events": [ + { + "data": { + "event.error_code": "x", + "event.error_type": "init" + } + } + ] +} diff --git a/test/features/jarvis-chat/mocks/sendChatIconClickEvent.json b/test/features/jarvis-chat/mocks/sendChatIconClickEvent.json new file mode 100644 index 0000000000..dedc417824 --- /dev/null +++ b/test/features/jarvis-chat/mocks/sendChatIconClickEvent.json @@ -0,0 +1,13 @@ +{ + "events": [ + { + "data": { + "event.workflow": "init", + "event.subcategory": "launch", + "event.subtype": "event.subtype", + "content.name": "icon", + "event.type": "click" + } + } + ] +} diff --git a/test/features/jarvis-chat/mocks/sendChatIconRenderEvent.json b/test/features/jarvis-chat/mocks/sendChatIconRenderEvent.json new file mode 100644 index 0000000000..9798c23c44 --- /dev/null +++ b/test/features/jarvis-chat/mocks/sendChatIconRenderEvent.json @@ -0,0 +1,13 @@ +{ + "events": [ + { + "data": { + "event.workflow": "init", + "event.subcategory": "launch", + "event.subtype": "event.subtype", + "content.name": "icon", + "event.type": "render" + } + } + ] +} diff --git a/test/features/jarvis-chat/mocks/sendPrimaryEvent.json b/test/features/jarvis-chat/mocks/sendPrimaryEvent.json new file mode 100644 index 0000000000..9905bbe5a1 --- /dev/null +++ b/test/features/jarvis-chat/mocks/sendPrimaryEvent.json @@ -0,0 +1,15 @@ +{ + "events": [ + { + "data": { + "event.workflow": "default", + "event.subtype": "default", + "event.type": "default", + "content.name": "default", + "exp.variation_id": "1", + "exp.container_id": "x", + "exp.campaign_id": "2" + } + } + ] +} diff --git a/test/features/jarvis-chat/mocks/sendProductEvent.json b/test/features/jarvis-chat/mocks/sendProductEvent.json new file mode 100644 index 0000000000..cd94e1daf5 --- /dev/null +++ b/test/features/jarvis-chat/mocks/sendProductEvent.json @@ -0,0 +1,12 @@ +{ + "events": [ + { + "data": { + "event.workflow": "product", + "event.subtype": "sub", + "event.type": "type", + "content.name": "auth-subproduct" + } + } + ] +} diff --git a/test/features/jarvis-chat/mocks/sendSurveyFeedbackEvent.json b/test/features/jarvis-chat/mocks/sendSurveyFeedbackEvent.json new file mode 100644 index 0000000000..49120b19b3 --- /dev/null +++ b/test/features/jarvis-chat/mocks/sendSurveyFeedbackEvent.json @@ -0,0 +1,15 @@ +{ + "events": [ + { + "data": { + "event.workflow": "survey", + "event.subtype": "test", + "event.type": "type", + "content.name": "5-star-survey", + "content.id": "id", + "exp.variation_id": "variation_1", + "exp.campaign_id": "test" + } + } + ] +} diff --git a/test/features/personalization/mocks/fragmentReplaced.plain.html b/test/features/personalization/mocks/fragmentReplaced.plain.html new file mode 100644 index 0000000000..b7eaff117d --- /dev/null +++ b/test/features/personalization/mocks/fragmentReplaced.plain.html @@ -0,0 +1,3 @@ +
            +

            The fragment has been replaced

            +
            diff --git a/test/features/personalization/mocks/manifestInsertContentAfter.json b/test/features/personalization/mocks/manifestInsertContentAfter.json new file mode 100644 index 0000000000..9c5b58f53b --- /dev/null +++ b/test/features/personalization/mocks/manifestInsertContentAfter.json @@ -0,0 +1,18 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "insertContentAfter", + "selector": ".marquee", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "/fragments/insertafter", + "firefox": "", + "android": "", + "ios": "" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/manifestInsertContentBefore.json b/test/features/personalization/mocks/manifestInsertContentBefore.json new file mode 100644 index 0000000000..b55188763c --- /dev/null +++ b/test/features/personalization/mocks/manifestInsertContentBefore.json @@ -0,0 +1,18 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "insertContentBefore", + "selector": ".marquee", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "/fragments/insertbefore", + "firefox": "", + "android": "", + "ios": "" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/manifestInvalid.json b/test/features/personalization/mocks/manifestInvalid.json new file mode 100644 index 0000000000..1f80d95062 --- /dev/null +++ b/test/features/personalization/mocks/manifestInvalid.json @@ -0,0 +1,28 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "insertContentAfter", + "selector": ".marquee", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "/fragments/insertafter2", + "firefox": "", + "android": "", + "ios": "" + }, + { + "action": "insertContentAfter", + "selector": "'/.marqueee", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "/fragments/insertafter3", + "firefox": "", + "android": "", + "ios": "" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/manifestPageFilterExclude.json b/test/features/personalization/mocks/manifestPageFilterExclude.json new file mode 100644 index 0000000000..6e0fb867b1 --- /dev/null +++ b/test/features/personalization/mocks/manifestPageFilterExclude.json @@ -0,0 +1,15 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "replacePage", + "selector": "", + "page filter (optional)": "/no/match/**", + "param-newoffer=123": "", + "chrome": "/test/features/personalization/mocks/replacePage" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/manifestPageFilterInclude.json b/test/features/personalization/mocks/manifestPageFilterInclude.json new file mode 100644 index 0000000000..9c1c3ea0d1 --- /dev/null +++ b/test/features/personalization/mocks/manifestPageFilterInclude.json @@ -0,0 +1,15 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "replacePage", + "selector": "", + "page filter (optional)": "/**", + "param-newoffer=123": "", + "chrome": "/test/features/personalization/mocks/replacePage" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/manifestRemove.json b/test/features/personalization/mocks/manifestRemove.json new file mode 100644 index 0000000000..323f192b0e --- /dev/null +++ b/test/features/personalization/mocks/manifestRemove.json @@ -0,0 +1,18 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "removeContent", + "selector": ".z-pattern", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "yes", + "firefox": "", + "android": "", + "ios": "" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/manifestReplace.json b/test/features/personalization/mocks/manifestReplace.json new file mode 100644 index 0000000000..d26eab08fe --- /dev/null +++ b/test/features/personalization/mocks/manifestReplace.json @@ -0,0 +1,28 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "replaceContent", + "selector": ".how-to", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "", + "firefox": "/drafts/rwoblesk/personalization-testing/fragments/milo-replace-content-firefox-accordion", + "android": "", + "ios": "" + }, + { + "action": "replaceContent", + "selector": "#features-of-milo-experimentation-platform", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "/fragments/milo-replace-content-chrome-howto-h2", + "firefox": "", + "android": "", + "ios": "" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/manifestReplaceFragment.json b/test/features/personalization/mocks/manifestReplaceFragment.json new file mode 100644 index 0000000000..7f1cda425b --- /dev/null +++ b/test/features/personalization/mocks/manifestReplaceFragment.json @@ -0,0 +1,18 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "replaceFragment", + "selector": "/fragments/replaceme", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "/fragments/fragmentreplaced", + "firefox": "", + "android": "", + "ios": "" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/manifestReplacePage.json b/test/features/personalization/mocks/manifestReplacePage.json new file mode 100644 index 0000000000..7361cf7569 --- /dev/null +++ b/test/features/personalization/mocks/manifestReplacePage.json @@ -0,0 +1,15 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "replacePage", + "selector": "", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "/test/features/personalization/mocks/replacePage" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/manifestUpdateMetadata.json b/test/features/personalization/mocks/manifestUpdateMetadata.json new file mode 100644 index 0000000000..cce9f7c1fa --- /dev/null +++ b/test/features/personalization/mocks/manifestUpdateMetadata.json @@ -0,0 +1,36 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "updateMetadata", + "selector": "georouting", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "on" + }, + { + "action": "updateMetadata", + "selector": "mynewmetadata", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "woot" + }, + { + "action": "updateMetadata", + "selector": "og:title", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "New Title" + }, + { + "action": "updateMetadata", + "selector": "og:image", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "https://adobe.com/path/to/image.jpg" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/manifestUseBlockCode.json b/test/features/personalization/mocks/manifestUseBlockCode.json new file mode 100644 index 0000000000..b308823bae --- /dev/null +++ b/test/features/personalization/mocks/manifestUseBlockCode.json @@ -0,0 +1,18 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "useBlockCode", + "selector": "promo", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "/test/features/personalization/mocks/newpromo", + "firefox": "", + "android": "", + "ios": "" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/manifestUseBlockCode2.json b/test/features/personalization/mocks/manifestUseBlockCode2.json new file mode 100644 index 0000000000..edccc2451a --- /dev/null +++ b/test/features/personalization/mocks/manifestUseBlockCode2.json @@ -0,0 +1,18 @@ +{ + "total": 5, + "offset": 0, + "limit": 5, + "data": [ + { + "action": "useBlockCode", + "selector": "myblock", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "/test/features/personalization/mocks/myblock", + "firefox": "", + "android": "", + "ios": "" + } + ], + ":type": "sheet" +} diff --git a/test/features/personalization/mocks/metadata.html b/test/features/personalization/mocks/metadata.html new file mode 100644 index 0000000000..8d87356ed6 --- /dev/null +++ b/test/features/personalization/mocks/metadata.html @@ -0,0 +1,3 @@ + + + diff --git a/test/features/personalization/mocks/myblock/myblock.css b/test/features/personalization/mocks/myblock/myblock.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/features/personalization/mocks/myblock/myblock.js b/test/features/personalization/mocks/myblock/myblock.js new file mode 100644 index 0000000000..687dbe25c5 --- /dev/null +++ b/test/features/personalization/mocks/myblock/myblock.js @@ -0,0 +1,3 @@ +export default function init(el) { + el.innerHTML = '
            My New Block!
            '; +} diff --git a/test/features/personalization/mocks/newpromo/newpromo.css b/test/features/personalization/mocks/newpromo/newpromo.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/features/personalization/mocks/newpromo/newpromo.js b/test/features/personalization/mocks/newpromo/newpromo.js new file mode 100644 index 0000000000..55b0be5ce7 --- /dev/null +++ b/test/features/personalization/mocks/newpromo/newpromo.js @@ -0,0 +1,3 @@ +export default function init(el) { + el.innerHTML = '
            New Promo!
            '; +} diff --git a/test/features/personalization/mocks/personalization.html b/test/features/personalization/mocks/personalization.html new file mode 100644 index 0000000000..ef2bcf65a9 --- /dev/null +++ b/test/features/personalization/mocks/personalization.html @@ -0,0 +1,113 @@ + +
            +
            +
            +
            +
            +
            + + + +
            +
            +
            +
            +

            Milo Experimentation Platform

            +

            Leverage the Milo Experimentation Platform (MEP) for all your personalization needs on Milo!

            +

            Review Docs

            +
            +
            + + + +
            +
            +
            +
            +
            +
            +
            +
            +

            Features of Milo Experimentation Platform

            +

            Learn more about the features of the Milo Experimentation Platform and what it can do

            +
            +
            +
            +
            + + + +
            +
            +

            Who will win?

            +

            A/B/N Testing

            +

            Milo Experimentation Platform is integrated with Adobe Target and can help you test new experiences.

            +

            Learn more

            +
            +
            +
            +
            + + + +
            +
            +

            Speak directly to your audience

            +

            Audience Experience Targeting

            +

            Leveraging Adobe Target and Adobe Audience Manager, Milo Experimentation Platform can help serve specific experiences to specific visitors.

            +

            Learn more

            +
            +
            +
            +
            + + + +
            +
            +

            Personalize based on visitor attributes

            +

            Attribute Experience Targeting

            +

            For simple use cases, the Milo Experimentation Platform can hide/show/add content to a page based on the attributes of a visitor.

            +

            Learn more Learn more

            +
            +
            +
            +
            +
            +
            +
            +
            +

            How to leverage Milo Experimentation Platform

            +

            This will explain the basic steps on how to use the Milo Experimentation Platform.

            +

            + + + +

            +
            +
            +
            +
            +
              +
            • Create a webpage using Milo
            • +
            • Create a page manifest
            • +
            • Configure the personalization based on your requirements
            • +
            • Sit back and watch visitors enjoy your personalization!
            • +
            +
            +
            +
            +
            + +
            +
            +
            +
            Old Promo Block
            +
            +
            +
            This block does not exist
            +
            +
            +
            diff --git a/test/features/personalization/mocks/replacePage.plain.html b/test/features/personalization/mocks/replacePage.plain.html new file mode 100644 index 0000000000..a09980c128 --- /dev/null +++ b/test/features/personalization/mocks/replacePage.plain.html @@ -0,0 +1 @@ +
            This is the new page
            diff --git a/test/features/personalization/pageFilter.test.js b/test/features/personalization/pageFilter.test.js new file mode 100644 index 0000000000..941b058d19 --- /dev/null +++ b/test/features/personalization/pageFilter.test.js @@ -0,0 +1,71 @@ +import { expect } from '@esm-bundle/chai'; +import { readFile } from '@web/test-runner-commands'; +import { stub } from 'sinon'; +import { applyPers } from '../../../libs/features/personalization/personalization.js'; + +document.body.innerHTML = await readFile({ path: './mocks/personalization.html' }); + +it('pageFilter should exclude page if it is not a match', async () => { + let manifestJson = await readFile({ path: './mocks/manifestPageFilterExclude.json' }); + manifestJson = JSON.parse(manifestJson); + const replacePageHtml = await readFile({ path: './mocks/replacePage.plain.html' }); + + window.fetch = stub(); + window.fetch.onCall(0).returns( + new Promise((resolve) => { + resolve({ + ok: true, + json: () => manifestJson, + }); + }), + ); + window.fetch.onCall(1).returns( + new Promise((resolve) => { + resolve({ + ok: true, + text: () => replacePageHtml, + }); + }), + ); + + expect(document.querySelector('.marquee')).to.not.be.null; + expect(document.querySelector('.newpage')).to.be.null; + + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + + // Nothing should be changed since the pageFilter excludes this page + expect(document.querySelector('.marquee')).to.not.be.null; + expect(document.querySelector('.newpage')).to.be.null; +}); + +it('pageFilter should include page if it is a match', async () => { + let manifestJson = await readFile({ path: './mocks/manifestPageFilterInclude.json' }); + manifestJson = JSON.parse(manifestJson); + const replacePageHtml = await readFile({ path: './mocks/replacePage.plain.html' }); + + window.fetch = stub(); + window.fetch.onCall(0).returns( + new Promise((resolve) => { + resolve({ + ok: true, + json: () => manifestJson, + }); + }), + ); + window.fetch.onCall(1).returns( + new Promise((resolve) => { + resolve({ + ok: true, + text: () => replacePageHtml, + }); + }), + ); + + expect(document.querySelector('.marquee')).to.not.be.null; + expect(document.querySelector('.newpage')).to.be.null; + + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + + expect(document.querySelector('.marquee')).to.be.null; + expect(document.querySelector('.newpage')).to.not.be.null; +}); diff --git a/test/features/personalization/personalization.test.js b/test/features/personalization/personalization.test.js new file mode 100644 index 0000000000..7b91b3145e --- /dev/null +++ b/test/features/personalization/personalization.test.js @@ -0,0 +1,157 @@ +import { expect } from '@esm-bundle/chai'; +import { readFile } from '@web/test-runner-commands'; +import { stub } from 'sinon'; +import { getConfig, loadBlock } from '../../../libs/utils/utils.js'; +import initFragments from '../../../libs/blocks/fragment/fragment.js'; +import { applyPers } from '../../../libs/features/personalization/personalization.js'; + +document.head.innerHTML = await readFile({ path: './mocks/metadata.html' }); +document.body.innerHTML = await readFile({ path: './mocks/personalization.html' }); + +const setFetchResponse = (data, type = 'json') => { + window.fetch = stub().returns( + new Promise((resolve) => { + resolve({ + ok: true, + [type]: () => data, + }); + }), + ); +}; + +// Note that the manifestPath doesn't matter as we stub the fetch +describe('Functional Test', () => { + it('replaceContent should replace an element with a fragment', async () => { + let manifestJson = await readFile({ path: './mocks/manifestReplace.json' }); + manifestJson = JSON.parse(manifestJson); + setFetchResponse(manifestJson); + + expect(document.querySelector('#features-of-milo-experimentation-platform')).to.not.be.null; + expect(document.querySelector('.how-to')).to.not.be.null; + const parentEl = document.querySelector('#features-of-milo-experimentation-platform')?.parentElement; + + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + expect(document.querySelector('#features-of-milo-experimentation-platform')).to.be.null; + expect(parentEl.firstElementChild.firstElementChild.href) + .to.equal('http://localhost:2000/fragments/milo-replace-content-chrome-howto-h2'); + // .how-to should not be changed as it is targeted to firefox + expect(document.querySelector('.how-to')).to.not.be.null; + }); + + it('removeContent should remove z-pattern content from the page', async () => { + let manifestJson = await readFile({ path: './mocks/manifestRemove.json' }); + manifestJson = JSON.parse(manifestJson); + setFetchResponse(manifestJson); + + expect(document.querySelector('.z-pattern')).to.not.be.null; + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + expect(document.querySelector('.z-pattern')).to.be.null; + }); + + it('insertContentAfter should add fragment after target element', async () => { + let manifestJson = await readFile({ path: './mocks/manifestInsertContentAfter.json' }); + manifestJson = JSON.parse(manifestJson); + setFetchResponse(manifestJson); + + expect(document.querySelector('a[href="/fragments/insertafter"]')).to.be.null; + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + + const fragment = document.querySelector('a[href="/fragments/insertafter"]'); + expect(fragment).to.not.be.null; + + expect(fragment.parentElement.previousElementSibling.className).to.equal('marquee'); + }); + + it('insertContentBefore should add fragment before target element', async () => { + let manifestJson = await readFile({ path: './mocks/manifestInsertContentBefore.json' }); + manifestJson = JSON.parse(manifestJson); + setFetchResponse(manifestJson); + + expect(document.querySelector('a[href="/fragments/insertbefore"]')).to.be.null; + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + + const fragment = document.querySelector('a[href="/fragments/insertbefore"]'); + expect(fragment).to.not.be.null; + + expect(fragment.parentElement.parentElement.children[1].className).to.equal('marquee'); + }); + + it('replaceFragment should replace a fragment in the document', async () => { + let manifestJson = await readFile({ path: './mocks/manifestReplaceFragment.json' }); + manifestJson = JSON.parse(manifestJson); + setFetchResponse(manifestJson); + + expect(document.querySelector('a[href="/fragments/replaceme"]')).to.not.be.null; + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + + const fragmentResp = await readFile({ path: './mocks/fragmentReplaced.plain.html' }); + setFetchResponse(fragmentResp, 'text'); + + const replacemeFrag = document.querySelector('a[href="/fragments/replaceme"]'); + await initFragments(replacemeFrag); + + expect(document.querySelector('a[href="/fragments/replaceme"]')).to.be.null; + expect(document.querySelector('div[data-path="/fragments/fragmentreplaced"]')).to.not.be.null; + }); + + it('useBlockCode should override a current block with the custom block code provided', async () => { + let manifestJson = await readFile({ path: './mocks/manifestUseBlockCode.json' }); + manifestJson = JSON.parse(manifestJson); + setFetchResponse(manifestJson); + + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + + expect(getConfig().expBlocks).to.deep.equal({ promo: '/test/features/personalization/mocks/newpromo' }); + const promoBlock = document.querySelector('.promo'); + expect(promoBlock.textContent?.trim()).to.equal('Old Promo Block'); + await loadBlock(promoBlock); + expect(promoBlock.textContent?.trim()).to.equal('New Promo!'); + }); + + it('useBlockCode should be able to use a new type of block', async () => { + let manifestJson = await readFile({ path: './mocks/manifestUseBlockCode2.json' }); + manifestJson = JSON.parse(manifestJson); + setFetchResponse(manifestJson); + + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + + expect(getConfig().expBlocks).to.deep.equal({ myblock: '/test/features/personalization/mocks/myblock' }); + const myBlock = document.querySelector('.myblock'); + expect(myBlock.textContent?.trim()).to.equal('This block does not exist'); + await loadBlock(myBlock); + expect(myBlock.textContent?.trim()).to.equal('My New Block!'); + }); + + it('updateMetadata should be able to add and change metadata', async () => { + let manifestJson = await readFile({ path: './mocks/manifestUpdateMetadata.json' }); + manifestJson = JSON.parse(manifestJson); + setFetchResponse(manifestJson); + + const geoMetadata = document.querySelector('meta[name="georouting"]'); + expect(geoMetadata.content).to.equal('off'); + + expect(document.querySelector('meta[name="mynewmetadata"]')).to.be.null; + expect(document.querySelector('meta[property="og:title"]').content).to.equal('milo'); + expect(document.querySelector('meta[property="og:image"]')).to.be.null; + + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + + expect(geoMetadata.content).to.equal('on'); + expect(document.querySelector('meta[name="mynewmetadata"]').content).to.equal('woot'); + expect(document.querySelector('meta[property="og:title"]').content).to.equal('New Title'); + expect(document.querySelector('meta[property="og:image"]').content).to.equal('https://adobe.com/path/to/image.jpg'); + }); + + it('Invalid selector should not fail page render and rest of items', async () => { + let manifestJson = await readFile({ path: './mocks/manifestInvalid.json' }); + manifestJson = JSON.parse(manifestJson); + setFetchResponse(manifestJson); + + expect(document.querySelector('.marquee')).to.not.be.null; + expect(document.querySelector('a[href="/fragments/insertafter2"]')).to.be.null; + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + const fragment = document.querySelector('a[href="/fragments/insertafter2"]'); + expect(fragment).to.not.be.null; + expect(fragment.parentElement.previousElementSibling.className).to.equal('marquee'); + }); +}); diff --git a/test/features/personalization/replacePage.test.js b/test/features/personalization/replacePage.test.js new file mode 100644 index 0000000000..951a879189 --- /dev/null +++ b/test/features/personalization/replacePage.test.js @@ -0,0 +1,38 @@ +import { expect } from '@esm-bundle/chai'; +import { readFile } from '@web/test-runner-commands'; +import { stub } from 'sinon'; +import { applyPers } from '../../../libs/features/personalization/personalization.js'; + +document.body.innerHTML = await readFile({ path: './mocks/personalization.html' }); + +it('replacePage should replace all of the main block', async () => { + let manifestJson = await readFile({ path: './mocks/manifestReplacePage.json' }); + manifestJson = JSON.parse(manifestJson); + const replacePageHtml = await readFile({ path: './mocks/replacePage.plain.html' }); + + window.fetch = stub(); + window.fetch.onCall(0).returns( + new Promise((resolve) => { + resolve({ + ok: true, + json: () => manifestJson, + }); + }), + ); + window.fetch.onCall(1).returns( + new Promise((resolve) => { + resolve({ + ok: true, + text: () => replacePageHtml, + }); + }), + ); + + expect(document.querySelector('.marquee')).to.not.be.null; + expect(document.querySelector('.newpage')).to.be.null; + + await applyPers([{ manifestPath: '/path/to/manifest.json' }]); + + expect(document.querySelector('.marquee')).to.be.null; + expect(document.querySelector('.newpage')).to.not.be.null; +}); diff --git a/test/features/placeholders/placeholders.json b/test/features/placeholders/placeholders.json index a7f0478ee7..89c58aadfc 100644 --- a/test/features/placeholders/placeholders.json +++ b/test/features/placeholders/placeholders.json @@ -10,6 +10,10 @@ { "key": "no-results", "value": "No results found" + }, + { + "key": "phone-number", + "value": "800 12345 6789" } ], ":type": "sheet" diff --git a/test/features/placeholders/placeholders.test.js b/test/features/placeholders/placeholders.test.js index 1d7e8d6456..0359815241 100644 --- a/test/features/placeholders/placeholders.test.js +++ b/test/features/placeholders/placeholders.test.js @@ -13,12 +13,12 @@ describe('Placeholders', () => { expect(text).to.equal('recommended for you'); }); - it('Replaces text', async () => { + it('Replaces text & links', async () => { config.locale.contentRoot = '/test/features/placeholders'; - const regex = /{{(.*?)}}/g; - let text = 'Hello world {{recommended-for-you}} and {{no-results}}'; + const regex = /{{(.*?)}}|%7B%7B(.*?)%7D%7D/g; + let text = 'Hello world {{recommended-for-you}} and {{no-results}}. Call tel: %7B%7Bphone-number%7D%7D'; text = await replaceText(text, config, regex); - expect(text).to.equal('Hello world Recommended for you and No results found'); + expect(text).to.equal('Hello world Recommended for you and No results found. Call tel: 800 12345 6789'); }); it('Replaces key', async () => { diff --git a/test/features/seotech/seotech.test.js b/test/features/seotech/seotech.test.js new file mode 100644 index 0000000000..eb5845d804 --- /dev/null +++ b/test/features/seotech/seotech.test.js @@ -0,0 +1,126 @@ +import { expect } from '@esm-bundle/chai'; +import { stub } from 'sinon'; +import { waitForElement } from '../../helpers/waitfor.js'; + +import { getConfig, createTag } from '../../../libs/utils/utils.js'; +import { appendScriptTag } from '../../../libs/features/seotech/seotech.js'; + +describe('seotech', () => { + describe('appendScriptTag + seotech-structured-data', () => { + beforeEach(async () => { + window.lana = { log: (s) => console.log(`LANA NOT STUBBED! ${s}`) }; + }); + afterEach(() => { + window.fetch?.restore?.(); + window.lana?.restore?.(); + }); + + it('should not append JSON-LD', async () => { + const lanaStub = stub(window.lana, 'log'); + const getMetadata = stub().returns(null); + getMetadata.withArgs('seotech-structured-data').returns('on'); + const fetchStub = stub(window, 'fetch'); + fetchStub.returns(Promise.resolve(Response.json( + { error: 'ERROR!' }, + { status: 400 }, + ))); + await appendScriptTag( + { locationUrl: window.location.href, getMetadata, getConfig, createTag }, + ); + const expectedApiCall = 'https://14257-seotech-stage.adobeioruntime.net/api/v1/web/seotech/getStructuredData?url=http%3A%2F%2Flocalhost%3A2000%2F'; + expect(fetchStub.getCall(0).firstArg).to.equal(expectedApiCall); + expect(lanaStub.getCall(0).firstArg).to.equal('SEOTECH: Failed to fetch structured data: ERROR!'); + }); + + it('should append JSON-LD', async () => { + const locationUrl = 'http://localhost:2000/?seotech-sheet-url=http://foo'; + const lanaStub = stub(window.lana, 'log'); + const fetchStub = stub(window, 'fetch'); + const getConfigStub = stub().returns({ env: { name: 'prod' } }); + const getMetadata = stub().returns(null); + getMetadata.withArgs('seotech-structured-data').returns('on'); + const expectedObject = { + '@context': 'http://schema.org', + '@type': 'VideoObject', + name: 'fake', + }; + fetchStub.returns(Promise.resolve(Response.json( + { objects: [expectedObject] }, + { status: 200 }, + ))); + await appendScriptTag( + { locationUrl, getMetadata, getConfig: getConfigStub, createTag }, + ); + const expectedApiCall = 'https://14257-seotech.adobeioruntime.net/api/v1/web/seotech/getStructuredData?url=http%3A%2F%2Flocalhost%3A2000%2F&sheetUrl=http%3A%2F%2Ffoo'; + expect(fetchStub.getCall(0).firstArg).to.equal(expectedApiCall); + const el = await waitForElement('script[type="application/ld+json"]'); + const obj = JSON.parse(el.text); + expect(obj).to.deep.equal(expectedObject); + expect(lanaStub.called).to.be.false; + }); + }); + + describe('appendScriptTag + seotech-video-url', () => { + beforeEach(async () => { + window.lana = { log: () => console.log('LANA NOT STUBBED!') }; + }); + afterEach(() => { + window.fetch?.restore?.(); + window.lana?.restore?.(); + }); + + it('should not append JSON-LD if url is invalid', async () => { + const lanaStub = stub(window.lana, 'log'); + const getMetadata = stub().returns(null); + getMetadata.withArgs('seotech-video-url').returns('fake'); + await appendScriptTag( + { locationUrl: window.location.href, getMetadata, getConfig, createTag }, + ); + expect(lanaStub.calledOnceWith('SEOTECH: Failed to construct \'URL\': Invalid URL')).to.be.true; + }); + + it('should not append JSON-LD if url not found', async () => { + const lanaStub = stub(window.lana, 'log'); + const getMetadata = stub().returns(null); + getMetadata.withArgs('seotech-video-url').returns('http://fake'); + const fetchStub = stub(window, 'fetch'); + fetchStub.returns(Promise.resolve(Response.json( + { error: 'ERROR!' }, + { status: 400 }, + ))); + await appendScriptTag( + { locationUrl: window.location.href, getMetadata, getConfig, createTag }, + ); + expect(fetchStub.calledOnceWith( + 'https://14257-seotech-stage.adobeioruntime.net/api/v1/web/seotech/getVideoObject?url=http://fake/', + )).to.be.true; + expect(lanaStub.calledOnceWith('SEOTECH: Failed to fetch video: ERROR!')).to.be.true; + }); + + it('should append JSON-LD', async () => { + const lanaStub = stub(window.lana, 'log'); + const fetchStub = stub(window, 'fetch'); + const getMetadata = stub().returns(null); + getMetadata.withArgs('seotech-video-url').returns('http://fake'); + const expectedVideoObject = { + '@context': 'http://schema.org', + '@type': 'VideoObject', + name: 'fake', + }; + fetchStub.returns(Promise.resolve(Response.json( + { videoObject: expectedVideoObject }, + { status: 200 }, + ))); + await appendScriptTag( + { locationUrl: window.location.href, getMetadata, getConfig, createTag }, + ); + expect(fetchStub.calledOnceWith( + 'https://14257-seotech-stage.adobeioruntime.net/api/v1/web/seotech/getVideoObject?url=http://fake/', + )).to.be.true; + const el = await waitForElement('script[type="application/ld+json"]'); + const obj = JSON.parse(el.text); + expect(obj).to.deep.equal(expectedVideoObject); + expect(lanaStub.called).to.be.false; + }); + }); +}); diff --git a/test/features/title-append/mocks/head-social.html b/test/features/title-append/mocks/head-social.html new file mode 100644 index 0000000000..fada36c358 --- /dev/null +++ b/test/features/title-append/mocks/head-social.html @@ -0,0 +1,3 @@ +Document Title + + diff --git a/test/features/title-append/mocks/head.html b/test/features/title-append/mocks/head.html new file mode 100644 index 0000000000..4fec2c1aaa --- /dev/null +++ b/test/features/title-append/mocks/head.html @@ -0,0 +1 @@ +Document Title diff --git a/test/features/title-append/title-append.test.js b/test/features/title-append/title-append.test.js new file mode 100644 index 0000000000..323eb4a233 --- /dev/null +++ b/test/features/title-append/title-append.test.js @@ -0,0 +1,39 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; + +import titleAppend from '../../../libs/features/title-append/title-append.js'; + +describe('Title Append', () => { + describe('titleAppend', () => { + beforeEach(async () => { + document.head.innerHTML = await readFile({ path: './mocks/head.html' }); + }); + + it('should not append when appendage is falsey', () => { + titleAppend(''); + expect(document.title).to.equal('Document Title'); + }); + + it('should append string to doc title', () => { + titleAppend('NOODLE'); + expect(document.title).to.equal('Document Title NOODLE'); + }); + }); + describe('titleAppend with social metdata', () => { + beforeEach(async () => { + document.head.innerHTML = await readFile({ path: './mocks/head-social.html' }); + }); + + it('should append to og:title', () => { + titleAppend('NOODLE'); + const actualTitle = document.querySelector('meta[property="og:title"]').getAttribute('content'); + expect(actualTitle).to.equal('Document Title NOODLE'); + }); + + it('should append string to twitter:title', () => { + titleAppend('NOODLE'); + const actualTitle = document.querySelector('meta[name="twitter:title"]').getAttribute('content'); + expect(actualTitle).to.equal('Document Title NOODLE'); + }); + }); +}); diff --git a/test/helpers/createHTML.js b/test/helpers/createHTML.js new file mode 100644 index 0000000000..3d2d2c0eff --- /dev/null +++ b/test/helpers/createHTML.js @@ -0,0 +1,15 @@ +export default function init(obj) { + if (!obj) return ''; + const { type, props } = obj; + const { children, ...attributes } = props; + + const attributeString = Object.keys(attributes) + .map((key) => ` ${key}="${attributes[key]}"`) + .join(''); + + const childrenString = (children || []) + .map((child) => init(child)) + .join(''); + + return `<${type}${attributeString}>${childrenString}`; +} diff --git a/test/helpers/generalHelpers.js b/test/helpers/generalHelpers.js new file mode 100644 index 0000000000..23bb5b33a9 --- /dev/null +++ b/test/helpers/generalHelpers.js @@ -0,0 +1,12 @@ +import sinon from 'sinon'; + +export const mockRes = ({ payload, status = 200, ok = true } = {}) => new Promise((resolve) => { + resolve({ + status, + ok, + json: () => payload, + text: () => payload, + }); +}); + +export const mockFetch = (payload) => sinon.stub().callsFake(() => mockRes(payload)); diff --git a/test/blocks/review/components/helixReview/mockHelixData.js b/test/helpers/mockFetch.js similarity index 86% rename from test/blocks/review/components/helixReview/mockHelixData.js rename to test/helpers/mockFetch.js index 36afecfcec..9975cffdb2 100644 --- a/test/blocks/review/components/helixReview/mockHelixData.js +++ b/test/helpers/mockFetch.js @@ -2,12 +2,6 @@ import { stub } from 'sinon'; stub(window, 'fetch'); -const data = [{ - total: 100, - rating: 4, - average: 4, -}]; - function jsonOk(body) { const mockResponse = new window.Response(JSON.stringify(body), { status: 200, @@ -27,12 +21,12 @@ function jsonError(status, body) { return Promise.reject(mockResponse); } -export const stubFetch = () => { +export const stubFetch = (data) => { const resp = jsonOk({ data: JSON.stringify(data) }); window.fetch.returns(resp); }; -export const stubFetchError = () => { +export const stubFetchError = (data) => { const resp = jsonError({ status: 500, data: JSON.stringify(data), diff --git a/test/helpers/selectors.js b/test/helpers/selectors.js index f1b67637b3..dcd4a47b8f 100644 --- a/test/helpers/selectors.js +++ b/test/helpers/selectors.js @@ -1,6 +1,4 @@ /* eslint-disable no-plusplus */ -/* eslint-disable no-await-in-loop */ -/* eslint-disable no-restricted-syntax */ import { delay, waitForElement, waitForUpdate } from './waitfor.js'; const asyncSome = async (arr, predicate) => { @@ -70,11 +68,11 @@ const tagSelectorModalSelectItem = async (label, choices = []) => { selectEl.click(); const modalEl = await waitForElement('.tagselect-modal-overlay'); - const columnsEl = modalEl.querySelector('.tagselect-modal-cols'); + const columnsEl = modalEl.querySelector('.tagselect-picker-cols'); await waitForUpdate(columnsEl); const selectItem = async (choice, idx, selectCheckbox = false) => { - await waitForElement(`.tagselect-modal-cols .col:nth-child(${idx + 1})`, { + await waitForElement(`.tagselect-picker-cols .col:nth-child(${idx + 1})`, { rootEl: modalEl, options: { subtree: true, characterData: true, childList: true }, }); @@ -99,13 +97,14 @@ const tagSelectorModalSelectItem = async (label, choices = []) => { return choiceFound; }; - choices.forEach(async (choice, i) => { + for (let i = 0; i < choices.length; i += 1) { const selectCheckbox = i === choices.length - 1; const choiceFound = await selectItem(choices[i], i, selectCheckbox); if (!choiceFound) { console.warn('tagSelectorModalChoose: Unable to find label:', choices[i]); } - }); + } + modalEl.querySelector('.tagselect-modal-close').click(); await delay(50); }; diff --git a/test/helpers/waitfor.js b/test/helpers/waitfor.js index 99cfdb224d..936271fcdd 100644 --- a/test/helpers/waitfor.js +++ b/test/helpers/waitfor.js @@ -103,3 +103,28 @@ export const delay = (timeOut, cb) => new Promise((resolve) => { resolve((cb && cb()) || null); }, timeOut); }); + +/** + * Waits for predicate function to be true or times out. + * @param {function} predicate Callback that returns boolean + * @param {number} timeout Timeout in milliseconds + * @param {number} interval Interval delay in milliseconds + * @returns {Promise} + */ +export function waitFor(predicate, timeout = 1000, interval = 100) { + return new Promise((resolve, reject) => { + if (predicate()) resolve(); + + const intervalId = setInterval(() => { + if (predicate()) { + clearInterval(intervalId); + resolve(); + } + }, interval); + + setTimeout(() => { + clearInterval(intervalId); + reject(new Error('Timed out waiting for predicate to be true')); + }, timeout); + }); +} diff --git a/test/martech/analytics.test.js b/test/martech/analytics.test.js new file mode 100644 index 0000000000..088191d711 --- /dev/null +++ b/test/martech/analytics.test.js @@ -0,0 +1,21 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; + +describe('Analytics', async () => { + beforeEach(async () => { + await readFile({ path: './mocks/body.html' }); + const analytics = await import('../../libs/martech/analytics.js'); + document.body.outerHTML = await readFile({ path: './mocks/body.html' }); + document.querySelectorAll('main > div').forEach((section, idx) => analytics.decorateSectionAnalytics(section, idx)); + }); + it('should decorate with attributes', async () => { + const main = document.querySelector('main'); + expect(main?.getAttribute('daa-im')).to.equal('true'); + const section = document.querySelector('main > div'); + expect(section?.getAttribute('daa-lh')).to.equal('s1'); + const block = section.querySelector(':scope > div')?.getAttribute('daa-lh'); + expect(block).to.equal('b1|icon-block|smb|hp'); + const link = section.querySelector('#unit-test')?.getAttribute('daa-ll'); + expect(link).to.equal('Learn more-3|Do more with Adobe P'); + }); +}); diff --git a/test/martech/mocks/body.html b/test/martech/mocks/body.html new file mode 100644 index 0000000000..8b85af69a6 --- /dev/null +++ b/test/martech/mocks/body.html @@ -0,0 +1,17 @@ + +
            +
            +
            +
            +
            +

            Adobe Photoshop

            +

            Do more with Adobe Photoshop.

            +

            +

            Learn more

            +
            +
            +
            +
            +
            + + diff --git a/test/scripts/delayed.test.js b/test/scripts/delayed.test.js new file mode 100644 index 0000000000..177377f5a8 --- /dev/null +++ b/test/scripts/delayed.test.js @@ -0,0 +1,62 @@ +import sinon from 'sinon'; +import { expect } from '@esm-bundle/chai'; +import loadDelayed, { loadPrivacy, loadJarvisChat, loadGoogleLogin } from '../../libs/scripts/delayed.js'; +import { getMetadata, getConfig, setConfig, loadIms } from '../../libs/utils/utils.js'; + +describe('Delayed', () => { + const loadScript = sinon.stub().returns(() => new Promise((resolve) => { resolve(); })); + const loadStyle = sinon.stub().returns(() => new Promise((resolve) => { resolve(); })); + + it('should load privacy feature', async () => { + expect(loadPrivacy).to.exist; + setConfig({ privacyId: '7a5eb705-95ed-4cc4-a11d-0cc5760e93db' }); + document.body.innerHTML = ''; + await loadPrivacy(getConfig, loadScript); + window.adobePrivacy = { showPreferenceCenter: sinon.stub() }; + document.getElementById('privacy-link')?.click(); + expect(window.adobePrivacy.showPreferenceCenter.called).to.be.true; + }); + + it('should load jarvis feature', async () => { + setConfig({ jarvis: { id: 'test', version: '1.0' } }); + const tag = document.createElement('meta'); + tag.setAttribute('name', 'jarvis-chat'); + tag.setAttribute('content', 'on'); + document.head.appendChild(tag); + expect(loadJarvisChat).to.exist; + window.AdobeMessagingExperienceClient = { initialize: sinon.stub() }; + window.adobePrivacy = { activeCookieGroups: sinon.stub() }; + loadJarvisChat(getConfig, getMetadata, loadScript, loadStyle); + document.head.removeChild(tag); + }); + + it('should load google login feature', async () => { + const tag = document.createElement('meta'); + tag.setAttribute('name', 'google-login'); + tag.setAttribute('content', 'on'); + document.head.appendChild(tag); + expect(loadGoogleLogin).to.exist; + await loadGoogleLogin(getMetadata, loadIms, loadScript); + }); + + it('should load interlinks logic', async () => { + const clock = sinon.useFakeTimers({ toFake: ['setTimeout'] }); + document.querySelector('head')?.insertAdjacentHTML('beforeend', ''); + loadDelayed([getConfig, getMetadata, loadScript, loadStyle, loadIms]).then((module) => { + expect(module).to.exist; + expect(typeof module === 'object').to.equal(true); + }); + await clock.runAllAsync(); + clock.restore(); + }); + + it('should skip load interlinks logic when metadata is off', async () => { + const clock = sinon.useFakeTimers({ toFake: ['setTimeout'] }); + document.head.querySelector('meta[name="interlinks"]')?.remove(); + loadDelayed([getConfig, getMetadata, loadScript, loadStyle, loadIms]).then((module) => { + expect(module == null).to.equal(true); + }); + await clock.runAllAsync(); + clock.restore(); + }); +}); diff --git a/test/scripts/scripts.test.js b/test/scripts/scripts.test.js index 1958308da5..b9a9d7871b 100644 --- a/test/scripts/scripts.test.js +++ b/test/scripts/scripts.test.js @@ -1,73 +1,37 @@ import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; -import sinon from 'sinon'; import { waitForElement } from '../helpers/waitfor.js'; document.head.innerHTML = await readFile({ path: './mocks/head.html' }); document.body.innerHTML = await readFile({ path: './mocks/body.html' }); -const EXTERNAL_SCRIPTS = [ - 'https://www.adobe.com/marketingtech/main.standard.min.js', -]; - -// Prevents loading of EXTERNAL_SCRIPTS -const observer = new MutationObserver((mutations) => { - mutations.forEach(({ addedNodes }) => { - addedNodes.forEach((node) => { - if (node.nodeType === 1 && node.tagName === 'SCRIPT') { - if (EXTERNAL_SCRIPTS.includes(node.src)) { - node.setAttribute('type', 'javascript/blocked'); - } - } - }); - }); -}); - -// Starts the monitoring -observer.observe(document.head, { - childList: true, - subtree: true, -}); - -describe('Decorating', () => { +describe('Decorating', async () => { before(async () => { await import('../../libs/scripts/scripts.js'); }); - it('Decorates auto blocks', async () => { - const autoBlock = document.querySelector('a[class]'); - expect(autoBlock.className).to.equal('adobetv link-block'); + it('Decorates adobetv autoblock', async () => { + const autoBlock = await waitForElement( + 'iframe[class="adobetv"]', + { rootEl: document.body }, + ); + expect(autoBlock.className).to.equal('adobetv'); }); it('Decorates modal link', async () => { - const modalLink = document.querySelector('a[data-modal-path]'); + const modalLink = await waitForElement( + 'a[data-modal-path]', + { rootEl: document.body }, + ); expect(modalLink.dataset.modalPath).to.equal('/fragments/mock'); }); it('martech test', async () => { const el = await waitForElement( - 'script[src="https://www.adobe.com/marketingtech/main.standard.min.js"]', + 'script[src$="/libs/deps/martech.main.standard.min.js"]', { rootEl: document.head }, ); expect(el).to.exist; - expect(window.alloy_load).to.exist; - }); - - it('Loads lana.js upon calling lana.log the first time', async () => { - expect(window.lana.log).to.exist; - - sinon.spy(console, 'log'); - - await window.lana.log('test', { clientId: 'myclient', sampleRate: 0 }); - expect(window.lana.options).to.exist; - expect(console.log.args[0][0]).to.equal('LANA Msg: '); - expect(console.log.args[0][1]).to.equal('test'); - console.log.restore(); - - sinon.spy(console, 'log'); - await window.lana.log('test2', { clientId: 'myclient', sampleRate: 0 }); - expect(console.log.args[0][0]).to.equal('LANA Msg: '); - expect(console.log.args[0][1]).to.equal('test2'); - console.log.restore(); + expect(window.alloy_all).to.exist; }); }); diff --git a/test/templates/404/404.test.js b/test/templates/404/404.test.js new file mode 100644 index 0000000000..be43d2c08f --- /dev/null +++ b/test/templates/404/404.test.js @@ -0,0 +1,67 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; +import { getConfig, setConfig } from '../../../libs/utils/utils.js'; + +const config = { + codeRoot: '/libs', + contentRoot: '/test/templates/404/mocks', + locale: { + contentRoot: '/test/templates/404/mocks', + prefix: '', + ietf: 'en-US', + tk: 'hah7vzn.css', + }, +}; +setConfig(config); + +const { default: init } = await import('../../../libs/templates/404/404.js'); + +describe('Feds 404', () => { + before(async () => { + document.head.innerHTML = await readFile({ path: './mocks/head-feds.html' }); + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + await init(); + }); + + it('Appends a libs 404 fragment link', () => { + expect(document.querySelector('a').href.includes('/libs/')).to.be.true; + }); +}); + +describe('Local 404', () => { + before(async () => { + document.head.innerHTML = await readFile({ path: './mocks/head-local.html' }); + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + await init(); + }); + + it('Appends a local 404 fragment link', () => { + expect(document.querySelector('a').href.includes(config.contentRoot)).to.be.true; + }); +}); + +describe('Legacy 404', () => { + before(async () => { + document.head.innerHTML = await readFile({ path: './mocks/head-legacy.html' }); + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + await init(); + }); + + it('Adds legacy 404 from locale contentRoot', () => { + expect([...document.body.classList].includes('legacy-404')).to.be.true; + }); +}); + +describe('Legacy 404 Fallback', () => { + before(async () => { + const miloConfig = getConfig(); + miloConfig.locale.contentRoot = ''; + document.head.innerHTML = await readFile({ path: './mocks/head-legacy.html' }); + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + await init(); + }); + + it('Fallback to contentRoot legacy 404', () => { + expect([...document.body.classList].includes('legacy-404')).to.be.true; + }); +}); diff --git a/test/templates/404/mocks/404.plain.html b/test/templates/404/mocks/404.plain.html new file mode 100644 index 0000000000..40599a8190 --- /dev/null +++ b/test/templates/404/mocks/404.plain.html @@ -0,0 +1,97 @@ +
            +
            +

            + + + + + + +

            +

            These are uncharted waters.

            + +
            +
            +
            + +
            + +
            +
            +

            Experience Cloud

            + +
            +
            +
            +
            +

            Explore the possibilities.

            + +
            +
            + + + diff --git a/test/templates/404/mocks/body.html b/test/templates/404/mocks/body.html new file mode 100644 index 0000000000..4c87a74b1a --- /dev/null +++ b/test/templates/404/mocks/body.html @@ -0,0 +1,3 @@ +
            +
            +
            diff --git a/test/templates/404/mocks/head-feds.html b/test/templates/404/mocks/head-feds.html new file mode 100644 index 0000000000..a7e84206e6 --- /dev/null +++ b/test/templates/404/mocks/head-feds.html @@ -0,0 +1,3 @@ +404 + + diff --git a/test/templates/404/mocks/head-legacy.html b/test/templates/404/mocks/head-legacy.html new file mode 100644 index 0000000000..b3a159e166 --- /dev/null +++ b/test/templates/404/mocks/head-legacy.html @@ -0,0 +1,2 @@ +404 + diff --git a/test/templates/404/mocks/head-local.html b/test/templates/404/mocks/head-local.html new file mode 100644 index 0000000000..5b69e5f05a --- /dev/null +++ b/test/templates/404/mocks/head-local.html @@ -0,0 +1,3 @@ +404 + + diff --git a/test/utils/action.test.js b/test/utils/action.test.js new file mode 100644 index 0000000000..978e058ec4 --- /dev/null +++ b/test/utils/action.test.js @@ -0,0 +1,28 @@ +import sinon from 'sinon'; +import { expect } from '@esm-bundle/chai'; +import { debounce } from '../../libs/utils/action.js'; + +describe('Action', () => { + it('Debounces callback correctly', async () => { + const clock = sinon.useFakeTimers({ + toFake: ['setTimeout'], + shouldAdvanceTime: true, + }); + + const header = document.createElement('h2'); + header.setAttribute('id', 'debounce'); + const setValue = () => { + header.textContent = 'debounced!'; + }; + + debounce(setValue, 300)(); + + expect(header.textContent).to.equal(''); + clock.tick(100); + expect(header.textContent).to.equal(''); + clock.tick(300); + expect(header.textContent).to.equal('debounced!'); + header.remove(); + clock.restore(); + }); +}); diff --git a/test/utils/appendHtmlToLink.test.html b/test/utils/appendHtmlToLink.test.html new file mode 100644 index 0000000000..afaa811dcc --- /dev/null +++ b/test/utils/appendHtmlToLink.test.html @@ -0,0 +1,206 @@ + + + + + + +
            + + + + +
            + + + + + + + + + + +
            + +
            + + + diff --git a/test/utils/htmlpostfix.test.html b/test/utils/htmlpostfix.test.html deleted file mode 100644 index 946396df2e..0000000000 --- a/test/utils/htmlpostfix.test.html +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - - - - - - diff --git a/test/utils/imageLinks.test.js b/test/utils/imageLinks.test.js new file mode 100644 index 0000000000..72a9c0c1a3 --- /dev/null +++ b/test/utils/imageLinks.test.js @@ -0,0 +1,41 @@ +import { readFile } from '@web/test-runner-commands'; +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { decorateImageLinks } from '../../libs/utils/utils.js'; + +document.body.innerHTML = await readFile({ path: './mocks/image-links.html' }); + +describe('Image Link', () => { + beforeEach(() => { + sinon.spy(console, 'log'); + }); + + afterEach(() => { + console.log.restore(); + }); + + decorateImageLinks(document); + + it('Creates an image link from an alt attribute with url and pipe', () => { + const links = document.querySelectorAll('a'); + expect(links[0]).to.exist; + expect(links[0].nodeName).to.equal('A'); + }); + + it('Replaces the image in-place', () => { + const i = document.querySelector('#inline-image'); + expect(i.children[1].nodeName).to.equal('A'); + expect(i.querySelector('a picture')).to.exist; + }); + + it('Fails gracefully', () => { + const i = document.querySelector('.bad-url'); + expect(i.alt).to.equal('img/badurl#_blank | image link bad url'); + }); + + it('Has video play button', async () => { + const p = document.querySelector('.image-link-play'); + await new Promise((resolve) => { setTimeout(resolve, 500); }); + expect(p.querySelector('.modal-img-link')).to.exist; + }); +}); diff --git a/test/utils/lanaDefaultOptions.test.js b/test/utils/lanaDefaultOptions.test.js index 2a992f4f1f..324a9f5ec4 100644 --- a/test/utils/lanaDefaultOptions.test.js +++ b/test/utils/lanaDefaultOptions.test.js @@ -20,7 +20,7 @@ it('lana should load existing window.lana.options', async () => { sampleRate: 100, tags: '', implicitSampleRate: 100, - endpointStage: "https://www.stage.adobe.com/lana/ll", + endpointStage: 'https://www.stage.adobe.com/lana/ll', useProd: true, }); }); diff --git a/test/utils/loadLana.test.js b/test/utils/loadLana.test.js new file mode 100644 index 0000000000..2968244127 --- /dev/null +++ b/test/utils/loadLana.test.js @@ -0,0 +1,28 @@ +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { waitFor } from '../helpers/waitfor.js'; +import { loadLana } from '../../libs/utils/utils.js'; + +describe('Utils loadLana', () => { + it('Loads lana.js upon calling lana.log the first time', async () => { + expect(window.lana?.log).not.to.exist; + loadLana(); + expect(window.lana.log).to.exist; + + const initialLana = window.lana.log; + sinon.spy(console, 'log'); + await window.lana.log('test', { clientId: 'myclient', sampleRate: 0 }); + await waitFor(() => initialLana !== window.lana.log); + + expect(window.lana.options).to.exist; + expect(console.log.args[0][0]).to.equal('LANA Msg: '); + expect(console.log.args[0][1]).to.equal('test'); + console.log.restore(); + + sinon.spy(console, 'log'); + await window.lana.log('test2', { clientId: 'myclient', sampleRate: 0 }); + expect(console.log.args[0][0]).to.equal('LANA Msg: '); + expect(console.log.args[0][1]).to.equal('test2'); + console.log.restore(); + }); +}); diff --git a/test/utils/mocks/.milo/config.json b/test/utils/mocks/.milo/config.json new file mode 100644 index 0000000000..fb85472888 --- /dev/null +++ b/test/utils/mocks/.milo/config.json @@ -0,0 +1,40 @@ +{ + "configs": { + "total": 11, + "offset": 0, + "limit": 11, + "data": [ + { + "key": "prod.glaas.clientId", + "value": "prod-not-super-secret-client-id", + "Comments": "" + }, + { + "key": "prod.sharepoint.folder", + "value": "prod-folder", + "Comments": "" + }, + { + "key": "stage.glaas.clientId", + "value": "stage-not-super-secret-client-id", + "Comments": "" + }, + { + "key": "stage.sharepoint.siteId", + "value": "milo-stage", + "Comments": "" + }, + { + "key": "local.glaas.clientId", + "value": "local-not-super-secret-client-id", + "Comments": "" + } + ] + }, + ":version": 3, + ":names": [ + "configs", + "locales" + ], + ":type": "multi-sheet" +} diff --git a/test/utils/mocks/body.html b/test/utils/mocks/body.html index e8f87aa195..fbe8805095 100644 --- a/test/utils/mocks/body.html +++ b/test/utils/mocks/body.html @@ -1,31 +1,26 @@ -
            +
            +
            +
            +

            “Blah blah blah.”

            +

            Ron Nagy

            +

            Sr. Evangelist, Adobe@Adobe

            +
            +
            @@ -41,4 +45,3 @@

            {{nothing-to-see-here}}

            -
            diff --git a/test/utils/mocks/head-seotech-video.html b/test/utils/mocks/head-seotech-video.html new file mode 100644 index 0000000000..59ce3f3eff --- /dev/null +++ b/test/utils/mocks/head-seotech-video.html @@ -0,0 +1,3 @@ +Document Title + + diff --git a/test/utils/mocks/head-title-append.html b/test/utils/mocks/head-title-append.html new file mode 100644 index 0000000000..4aec2d3c73 --- /dev/null +++ b/test/utils/mocks/head-title-append.html @@ -0,0 +1,3 @@ + +Document Title + diff --git a/test/utils/mocks/head.html b/test/utils/mocks/head.html index 0ddf2da070..669f58a4d1 100644 --- a/test/utils/mocks/head.html +++ b/test/utils/mocks/head.html @@ -2,3 +2,4 @@ + diff --git a/test/utils/mocks/image-links.html b/test/utils/mocks/image-links.html new file mode 100644 index 0000000000..a00af67926 --- /dev/null +++ b/test/utils/mocks/image-links.html @@ -0,0 +1,12 @@ +

            + +

            alt description

            + +

            https://www.adobe.com | image link

            + +

            img/badurl#_blank | image link bad url

            + + + +

            Suffix texthttps://www.adobe.com | image linkPrefix text

            + diff --git a/test/utils/mocks/useDotHtml.html b/test/utils/mocks/useDotHtml.html new file mode 100644 index 0000000000..6cf33b89ad --- /dev/null +++ b/test/utils/mocks/useDotHtml.html @@ -0,0 +1,34 @@ +
            + +
            + +

            Milo Test

            +

            Milo Test

            +

            Milo Test

            diff --git a/test/utils/richresults.test.js b/test/utils/richresults.test.js index d1db500b6d..95a8788aa0 100644 --- a/test/utils/richresults.test.js +++ b/test/utils/richresults.test.js @@ -1,9 +1,13 @@ import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; import sinon from 'sinon'; -import { loadArea } from '../../libs/utils/utils.js'; +import { loadArea, setConfig } from '../../libs/utils/utils.js'; describe('Rich Results', () => { + beforeEach(() => { + setConfig({}); + }); + it('add the NewsArticle rich results', async () => { document.head.innerHTML = await readFile({ path: './mocks/head-rich-results.html' }); await loadArea(document); @@ -54,7 +58,7 @@ describe('Rich Results', () => { await loadArea(document); const script = document.querySelector('script[type="application/ld+json"]'); expect(script).to.be.null; - expect( console.error.calledWith('Type Unsupported is not supported') ).to.be.true; + expect(console.error.calledWith('Type Unsupported is not supported')).to.be.true; }); it('add the Site Search Box rich results', async () => { @@ -70,9 +74,9 @@ describe('Rich Results', () => { '@type': 'SearchAction', target: { '@type': 'EntryPoint', - urlTemplate: 'https://query.example.com/search?q={search_term_string}' + urlTemplate: 'https://query.example.com/search?q={search_term_string}', }, - 'query-input': 'required name=search_term_string' + 'query-input': 'required name=search_term_string', }], }; expect(actual).to.deep.equal(expected); diff --git a/test/utils/service-config.test.js b/test/utils/service-config.test.js new file mode 100644 index 0000000000..ac04a1304a --- /dev/null +++ b/test/utils/service-config.test.js @@ -0,0 +1,32 @@ +import { expect } from '@esm-bundle/chai'; +import getServiceConfig from '../../libs/utils/service-config.js'; + +const ORIGIN = 'http://localhost:2000/test/utils/mocks'; + +const config = { + codeRoot: '/libs', + locales: { '': { ietf: 'en-US', tk: 'hah7vzn.css' } }, +}; + +describe('Service Config', () => { + before(async () => { + const { setConfig } = await import('../../libs/utils/utils.js'); + setConfig(config); + window.hlx = { rum: { isSelected: false } }; + }); + + it('Should have a local value', async () => { + const { glaas } = await getServiceConfig(ORIGIN); + expect(glaas.clientId).to.equal('local-not-super-secret-client-id'); + }); + + it('Should fallbck to stage value', async () => { + const { sharepoint } = await getServiceConfig(ORIGIN); + expect(sharepoint.siteId).to.equal('milo-stage'); + }); + + it('Should fallbck to prod value', async () => { + const { sharepoint } = await getServiceConfig(ORIGIN); + expect(sharepoint.siteId).to.equal('milo-stage'); + }); +}); diff --git a/test/utils/utils.test.js b/test/utils/utils.test.js index bb234d7d4e..b3a2727c36 100644 --- a/test/utils/utils.test.js +++ b/test/utils/utils.test.js @@ -1,7 +1,8 @@ import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; import sinon from 'sinon'; -import { waitForElement } from '../helpers/waitfor.js'; +import { waitFor, waitForElement } from '../helpers/waitfor.js'; +import { mockFetch } from '../helpers/generalHelpers.js'; const utils = {}; @@ -9,30 +10,41 @@ const config = { codeRoot: '/libs', locales: { '': { ietf: 'en-US', tk: 'hah7vzn.css' } }, }; +const ogFetch = window.fetch; describe('Utils', () => { + let head; + let body; before(async () => { + head = await readFile({ path: './mocks/head.html' }); + body = await readFile({ path: './mocks/body.html' }); const module = await import('../../libs/utils/utils.js'); module.setConfig(config); Object.keys(module).forEach((func) => { utils[func] = module[func]; }); + window.hlx = { rum: { isSelected: false } }; + }); + + after(() => { + delete window.hlx; }); describe('with body', () => { - before(() => { + beforeEach(async () => { + window.fetch = mockFetch({ payload: { data: '' } }); + document.head.innerHTML = head; + document.body.innerHTML = body; + await utils.loadArea(); sinon.spy(console, 'log'); }); - after(() => { + afterEach(() => { + window.fetch = ogFetch; + // eslint-disable-next-line no-console console.log.restore(); }); - before(async () => { - document.head.innerHTML = await readFile({ path: './mocks/head.html' }); - document.body.innerHTML = await readFile({ path: './mocks/body.html' }); - }); - describe('Template', () => { it('loads a template script and style', async () => { const meta = document.createElement('meta'); @@ -62,10 +74,16 @@ describe('Utils', () => { }); it('Auto block works as expected when #_dnb is not added to url', async () => { - await waitForElement('[href="https://twitter.com/Adobe"]'); + const a = await waitForElement('[href="https://twitter.com/Adobe"]'); + utils.decorateAutoBlock(a); const autoBlockLink = document.querySelector('[href="https://twitter.com/Adobe"]'); expect(autoBlockLink.className).to.equal('twitter link-block'); }); + + it('Does not error on invalid url', () => { + const autoBlock = utils.decorateAutoBlock('http://HostName:Port/lc/system/console/configMgr'); + expect(autoBlock).to.equal(false); + }); }); describe('Fragments', () => { @@ -114,7 +132,9 @@ describe('Utils', () => { }); it('Does not setup nofollow links', async () => { - const gaLink = document.querySelector('a[href="https://analytics.google.com"]'); + window.fetch = mockFetch({ payload: { data: [] } }); + await utils.loadDeferred(document, [], { links: 'on' }); + const gaLink = document.querySelector('a[href="https://analytics.google.com/"]'); expect(gaLink.getAttribute('rel')).to.be.null; }); @@ -133,17 +153,6 @@ describe('Utils', () => { expect(gaLink).to.exist; }); - it('loadDelayed() test - expect moduled', async () => { - const mod = await utils.loadDelayed(0); - expect(mod).to.exist; - }); - - it('loadDelayed() test - expect nothing', async () => { - document.head.querySelector('meta[name="interlinks"]').remove(); - const mod = await utils.loadDelayed(0); - expect(mod).to.be.null; - }); - it('Converts UTF-8 to Base 64', () => { const b64 = utils.utf8ToB64('hello world'); expect(b64).to.equal('aGVsbG8gd29ybGQ='); @@ -156,6 +165,7 @@ describe('Utils', () => { it('Successfully dies parsing a bad config', () => { utils.parseEncodedConfig('error'); + // eslint-disable-next-line no-console expect(console.log.args[0][0].name).to.equal('InvalidCharacterError'); }); @@ -188,7 +198,7 @@ describe('Utils', () => { window.dispatchEvent(event); await waitForElement('#milo'); expect(document.getElementById('milo')).to.exist; - }) + }); it('getLocale default return', () => { expect(utils.getLocale().ietf).to.equal('en-US'); @@ -226,9 +236,11 @@ describe('Utils', () => { describe('SVGs', () => { it('Not a valid URL', () => { + document.body.innerHTML = '
            https://www.adobe.com/test
            '; const a = document.querySelector('.bad-url'); try { - const textContentUrl = new URL(a.textContent); + // eslint-disable-next-line no-new + new URL(a.textContent); } catch (err) { expect(err.message).to.equal("Failed to construct 'URL': Invalid URL"); } @@ -240,8 +252,9 @@ describe('Utils', () => { config.locales = { '': { ietf: 'en-US', tk: 'hah7vzn.css' }, africa: { ietf: 'en', tk: 'pps7abe.css' }, - il_he: { ietf: 'he', tk: 'nwq1mna.css' }, - mena_ar: { ietf: 'ar', tk: 'dis2dpj.css' }, + il_he: { ietf: 'he', tk: 'nwq1mna.css', dir: 'rtl' }, + langstore: { ietf: 'en-US', tk: 'hah7vzn.css' }, + mena_ar: { ietf: 'ar', tk: 'dis2dpj.css', dir: 'rtl' }, ua: { tk: 'aaz7dvd.css' }, }; }); @@ -257,6 +270,11 @@ describe('Utils', () => { expect(document.documentElement.getAttribute('dir')).to.equal('ltr'); }); + it('LTR Languages have dir as ltr for langstore path', () => { + setConfigWithPath('/langstore/en/solutions'); + expect(document.documentElement.getAttribute('dir')).to.equal('ltr'); + }); + it('RTL Languages have dir as rtl', () => { setConfigWithPath('/il_he/solutions'); expect(document.documentElement.getAttribute('dir')).to.equal('rtl'); @@ -264,14 +282,21 @@ describe('Utils', () => { expect(document.documentElement.getAttribute('dir')).to.equal('rtl'); }); + it('RTL Languages have dir as rtl for langstore path', () => { + setConfigWithPath('/langstore/he/solutions'); + expect(document.documentElement.getAttribute('dir')).to.equal('rtl'); + setConfigWithPath('/langstore/ar/solutions'); + expect(document.documentElement.getAttribute('dir')).to.equal('rtl'); + }); + it('Gracefully dies when locale ietf is missing and dir is not set.', () => { setConfigWithPath('/ua/solutions'); - expect(document.documentElement.getAttribute('dir')).null; + expect(document.documentElement.getAttribute('dir')).to.equal('ltr'); }); }); describe('localizeLink', () => { - before(async () => { + before(() => { config.locales = { '': { ietf: 'en-US', tk: 'hah7vzn.css' }, fi: { ietf: 'fi-FI', tk: 'aaz7dvd.css' }, @@ -280,7 +305,6 @@ describe('Utils', () => { }; config.prodDomains = ['milo.adobe.com', 'www.adobe.com']; config.pathname = '/be_fr/page'; - config.origin = 'https://main--milo--adobecom'; utils.setConfig(config); }); @@ -368,14 +392,107 @@ describe('Utils', () => { }); expect(io instanceof IntersectionObserver).to.be.true; }); + + it('should remove any blocks with the hide-block class from the DOM', async () => { + document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + const hiddenQuoteBlock = document.querySelector('.quote.hide-block'); + expect(hiddenQuoteBlock).to.exist; + const block = await utils.loadBlock(hiddenQuoteBlock); + expect(block).to.be.null; + expect(document.querySelector('.quote.hide-block')).to.be.null; + }); + }); + + describe('title-append', async () => { + beforeEach(async () => { + document.head.innerHTML = await readFile({ path: './mocks/head-title-append.html' }); + }); + it('should append to title using string from metadata', async () => { + const expected = 'Document Title NOODLE'; + await utils.loadArea(); + await waitFor(() => document.title === expected); + expect(document.title).to.equal(expected); + }); + }); + + describe('seotech', async () => { + beforeEach(async () => { + window.lana = { log: (msg) => console.error(msg) }; + document.head.innerHTML = await readFile({ path: './mocks/head-seotech-video.html' }); + }); + afterEach(() => { + window.lana.release?.(); + }); + it('should import feature when metadata is defined and error if invalid', async () => { + const expectedError = 'SEOTECH: Failed to construct \'URL\': Invalid URL'; + await utils.loadArea(); + const lanaStub = sinon.stub(window.lana, 'log'); + await waitFor(() => lanaStub.calledOnceWith(expectedError)); + expect(lanaStub.calledOnceWith(expectedError)).to.be.true; + }); + }); + + describe('scrollToHashedElement', () => { + before(() => { + const div = document.createElement('div'); + div.className = 'global-navigation'; + document.body.appendChild(div); + window.location.hash = '#not-block'; + window.scrollBy = () => {}; + }); + + it('should scroll to the hashed element', () => { + let scrollToCalled = false; + window.scrollTo = () => { + scrollToCalled = true; + }; + + utils.scrollToHashedElement('#not-block'); + expect(scrollToCalled).to.be.true; + expect(document.getElementById('not-block')).to.exist; + }); + + it('should not scroll if no hash is present', () => { + window.location.hash = ''; + let scrollToCalled = false; + window.scrollBy = () => { + scrollToCalled = true; + }; + utils.scrollToHashedElement(''); + expect(scrollToCalled).to.be.false; + }); }); - it('adds privacy trigger to cookie preferences link in footer', () => { - window.adobePrivacy = { showPreferenceCenter: sinon.spy() }; - document.body.innerHTML = ''; - utils.loadPrivacy(); - const privacyLink = document.querySelector('#privacy-link'); - privacyLink.click(); - expect(adobePrivacy.showPreferenceCenter.called).to.be.true; + describe('useDotHtml', async () => { + beforeEach(async () => { + window.lana = { log: (msg) => console.error(msg) }; + document.body.innerHTML = await readFile({ path: './mocks/useDotHtml.html' }); + }); + afterEach(() => { + window.lana.release?.(); + }); + it('should add .html to relative links when enabled', async () => { + utils.setConfig({ useDotHtml: true, htmlExclude: [/exclude\/.*/gm] }); + expect(utils.getConfig().useDotHtml).to.be.true; + await utils.decorateLinks(document.getElementById('linklist')); + expect(document.getElementById('excluded')?.getAttribute('href')) + .to.equal('/exclude/this/page'); + const htmlLinks = document.querySelectorAll('.has-html'); + htmlLinks.forEach((link) => { + expect(link.href).to.contain('.html'); + }); + }); + + it('should not add .html to relative links when disabled', async () => { + utils.setConfig({ useDotHtml: false, htmlExclude: [/exclude\/.*/gm] }); + expect(utils.getConfig().useDotHtml).to.be.false; + await utils.decorateLinks(document.getElementById('linklist')); + expect(document.getElementById('excluded')?.getAttribute('href')) + .to.equal('/exclude/this/page'); + const htmlLinks = document.querySelectorAll('.has-html'); + htmlLinks.forEach((link) => { + expect(link.href).to.not.contain('.html'); + }); + }); }); }); diff --git a/tools/caas-import/parseCaasConfig.js b/tools/caas-import/parseCaasConfig.js index 967e25bbdc..d9ac97cdf4 100644 --- a/tools/caas-import/parseCaasConfig.js +++ b/tools/caas-import/parseCaasConfig.js @@ -99,7 +99,7 @@ const parseAndQuery = (str) => { : 'OR'; return { intraTagLogic, - andTags: q.split(` ${intraTagLogic} `) + andTags: q.split(` ${intraTagLogic} `), }; }); }; diff --git a/tools/floodgate/css/floodgate.css b/tools/floodgate/css/floodgate.css index 37f2d5c6d0..801ee6775a 100644 --- a/tools/floodgate/css/floodgate.css +++ b/tools/floodgate/css/floodgate.css @@ -30,10 +30,15 @@ margin: 15% auto; padding: 20px; border: 1px solid #888; - width: 20%; + width: 25%; text-align: center; } .fg-modal-buttons button { margin: 10px; } + +.promote-publish-options { + display: none; + padding-top: 20px; +} diff --git a/tools/floodgate/index.html b/tools/floodgate/index.html index c396a93d0a..aa34f2832b 100644 --- a/tools/floodgate/index.html +++ b/tools/floodgate/index.html @@ -29,6 +29,13 @@

            + + +
            @@ -46,21 +53,25 @@

            Project Status

            Action Status Last Run + Description Copy NOT STARTED - + - Promote NOT STARTED - + - Delete NOT STARTED - + -
            @@ -75,6 +86,9 @@

            Project Status

            +
            + Promote Only? + Promote and Publish Promoted Pages?
            diff --git a/tools/floodgate/js/config.js b/tools/floodgate/js/config.js index 20d2ad1757..f2be7d7071 100644 --- a/tools/floodgate/js/config.js +++ b/tools/floodgate/js/config.js @@ -9,6 +9,16 @@ const FLOODGATE_CONFIG = '/drafts/floodgate/configs/config.json'; let decoratedConfig; +function getPromoteIgnorePaths(configJson) { + const promoteIgnorePaths = configJson.promoteignorepaths.data; + const paths = []; + promoteIgnorePaths.forEach((pathRow) => { + const path = pathRow.FilesToIgnoreFromPromote; + paths.push(path); + }); + return paths; +} + async function getConfig() { if (!decoratedConfig) { const urlInfo = getUrlInfo(); @@ -18,6 +28,7 @@ async function getConfig() { decoratedConfig = { sp: getSharepointConfig(configJson), admin: getHelixAdminConfig(), + promoteIgnorePaths: getPromoteIgnorePaths(configJson), }; } } diff --git a/tools/floodgate/js/copy.js b/tools/floodgate/js/copy.js index 0027e52d93..8a8aa40950 100644 --- a/tools/floodgate/js/copy.js +++ b/tools/floodgate/js/copy.js @@ -66,11 +66,10 @@ async function floodgateContent(project, projectDetail) { // process data in batches const copyStatuses = []; for (let i = 0; i < batchArray.length; i += 1) { - // eslint-disable-next-line no-await-in-loop copyStatuses.push(...await Promise.all( batchArray[i].map((files) => copyFilesToFloodgateTree(files[1])), )); - // eslint-disable-next-line no-await-in-loop, no-promise-executor-return + // eslint-disable-next-line no-promise-executor-return await delay(DELAY_TIME_COPY); } const endCopy = new Date(); @@ -79,11 +78,10 @@ async function floodgateContent(project, projectDetail) { const previewStatuses = []; for (let i = 0; i < copyStatuses.length; i += 1) { if (copyStatuses[i].success) { - // eslint-disable-next-line no-await-in-loop const result = await simulatePreview(handleExtension(copyStatuses[i].srcPath), 1, true); previewStatuses.push(result); } - // eslint-disable-next-line no-await-in-loop, no-promise-executor-return + // eslint-disable-next-line no-promise-executor-return await delay(); } loadingON('Completed Preview for copied files... '); diff --git a/tools/floodgate/js/floodgate.js b/tools/floodgate/js/floodgate.js index 589e18352f..33eac3bc36 100644 --- a/tools/floodgate/js/floodgate.js +++ b/tools/floodgate/js/floodgate.js @@ -1,25 +1,70 @@ import { getConfig } from './config.js'; import { loadingOFF, loadingON } from '../../loc/utils.js'; -import { enableRetry, connect as connectToSP } from '../../loc/sharepoint.js'; +import { getParams, postData } from './utils.js'; +import { enableRetry, connect as connectToSP, getAccessToken } from '../../loc/sharepoint.js'; +import updateFragments from '../../loc/fragments.js'; import { initProject, updateProjectWithDocs, purgeAndReloadProjectFile, - updateProjectStatus, } from './project.js'; import { updateProjectInfo, updateProjectDetailsUI, updateProjectStatusUI, } from './ui.js'; -import promoteFloodgatedFiles from './promote.js'; -import floodgateContent from './copy.js'; + +const IS_FLOODGATE = true; async function reloadProject() { loadingON('Purging project file cache and reloading... please wait'); await purgeAndReloadProjectFile(); } +async function floodgateContentAction(project, config) { + const params = getParams(project, config); + params.spToken = getAccessToken(); + const copyStatus = await postData(config.sp.aioCopyAction, params); + updateProjectStatusUI({ copyStatus }); +} + +async function triggerUpdateFragments() { + loadingON('Fetching and updating fragments..'); + const status = await updateFragments(initProject, IS_FLOODGATE); + loadingON(status); +} + +async function deleteFloodgateDir(project, config) { + const params = getParams(project, config); + params.spToken = getAccessToken(); + const deleteStatus = await postData(config.sp.aioDeleteAction, params); + updateProjectStatusUI({ deleteStatus }); +} + +async function promoteContentAction(project, config) { + const params = getParams(project, config); + params.spToken = getAccessToken(); + // Based on User selection on the Promote Dialog, + // passing the param if user also wants to Publish the Promoted pages. + params.doPublish = document.querySelector('input[name="promotePublishRadio"]:checked')?.value + === 'promotePublish'; + const promoteStatus = await postData(config.sp.aioPromoteAction, params); + updateProjectStatusUI({ promoteStatus }); +} + +async function fetchStatusAction(project, config) { + // fetch copy status + let params = { type: 'copy', projectExcelPath: project.excelPath, shareUrl: config.sp.shareUrl }; + const copyStatus = await postData(config.sp.aioStatusAction, params); + // fetch promote status + params = { type: 'promote', fgShareUrl: config.sp.fgShareUrl }; + const promoteStatus = await postData(config.sp.aioStatusAction, params); + // fetch delete status + params = { type: 'delete', fgShareUrl: config.sp.fgShareUrl }; + const deleteStatus = await postData(config.sp.aioStatusAction, params); + updateProjectStatusUI({ copyStatus, promoteStatus, deleteStatus }); +} + async function refreshPage(config, projectDetail, project) { // Inject Sharepoint file metadata loadingON('Updating Project with the Sharepoint Docs Data... please wait'); @@ -31,23 +76,34 @@ async function refreshPage(config, projectDetail, project) { // Read the project action status loadingON('Updating project status...'); - const status = await updateProjectStatus(project); - updateProjectStatusUI(status); + await fetchStatusAction(project, config); loadingON('UI updated..'); loadingOFF(); } -function setListeners(project, projectDetail) { +function togglePromotePublishRadioVisibility(visibility) { + const promotePublishOptions = document.getElementById('promote-publish-options'); + promotePublishOptions.style.display = visibility; + const promoteOnlyOption = document.getElementById('promoteOnlyOption'); + promoteOnlyOption.checked = true; +} + +function setListeners(project, config) { const modal = document.getElementById('fg-modal'); const handleFloodgateConfirm = ({ target }) => { modal.style.display = 'none'; - floodgateContent(project, projectDetail); + floodgateContentAction(project, config); target.removeEventListener('click', handleFloodgateConfirm); }; + const handleDeleteConfirm = ({ target }) => { + modal.style.display = 'none'; + deleteFloodgateDir(project, config); + target.removeEventListener('click', handleDeleteConfirm); + }; const handlePromoteConfirm = ({ target }) => { modal.style.display = 'none'; - promoteFloodgatedFiles(project); + promoteContentAction(project, config); target.removeEventListener('click', handlePromoteConfirm); }; document.querySelector('#reloadProject button').addEventListener('click', reloadProject); @@ -56,12 +112,22 @@ function setListeners(project, projectDetail) { modal.style.display = 'block'; document.querySelector('#fg-modal #yes-btn').addEventListener('click', handleFloodgateConfirm); }); + document.querySelector('#updateFragments button').addEventListener('click', triggerUpdateFragments); + document.querySelector('#delete button').addEventListener('click', (e) => { + modal.getElementsByTagName('p')[0].innerText = `Confirm to ${e.target.textContent}`; + modal.style.display = 'block'; + document.querySelector('#fg-modal #yes-btn').addEventListener('click', handleDeleteConfirm); + }); document.querySelector('#promoteFiles button').addEventListener('click', (e) => { modal.getElementsByTagName('p')[0].innerText = `Confirm to ${e.target.textContent}`; modal.style.display = 'block'; + togglePromotePublishRadioVisibility('block'); document.querySelector('#fg-modal #yes-btn').addEventListener('click', handlePromoteConfirm); }); - document.querySelector('#fg-modal #no-btn').addEventListener('click', () => { modal.style.display = 'none'; }); + document.querySelector('#fg-modal #no-btn').addEventListener('click', () => { + modal.style.display = 'none'; + togglePromotePublishRadioVisibility('none'); + }); document.querySelector('#loading').addEventListener('click', loadingOFF); } @@ -86,11 +152,11 @@ async function init() { updateProjectInfo(project); // Read the project excel file and parse the data - const projectDetail = await project.getDetails(); + const projectDetail = await project.detail(); loadingON('Project Details loaded...'); // Set the listeners on the floodgate action buttons - setListeners(project, projectDetail); + setListeners(project, config); loadingON('Connecting now to Sharepoint...'); const connectedToSp = await connectToSP(); @@ -100,6 +166,7 @@ async function init() { } loadingON('Connected to Sharepoint!'); await refreshPage(config, projectDetail, project); + loadingOFF(); } catch (error) { loadingON(`Error occurred when initializing the Floodgate project ${error.message}`); diff --git a/tools/floodgate/js/project.js b/tools/floodgate/js/project.js index 9e9bcbbd2e..208f6c74b9 100644 --- a/tools/floodgate/js/project.js +++ b/tools/floodgate/js/project.js @@ -8,7 +8,7 @@ import { getHelixAdminApiUrl, readProjectFile, } from '../../loc/project.js'; -import { getFilesData } from '../../loc/sharepoint.js'; +import { getSpFiles } from '../../loc/sharepoint.js'; import { getDocPathFromUrl, getFloodgateUrl } from './utils.js'; let project; @@ -22,27 +22,25 @@ const PROJECT_STATUS = { /** * Makes the sharepoint file data part of `projectDetail` per URL. */ -function injectSharepointData(projectUrls, filePaths, docPaths, spFiles, isFloodgate) { - for (let i = 0; i < spFiles.length; i += 1) { - let fileBody = {}; - let status = 404; - if (!spFiles[i].error) { - fileBody = spFiles[i]; - status = 200; - } - const filePath = docPaths[i]; - const urls = filePaths.get(filePath); - urls.forEach((key) => { - const urlObjVal = projectUrls.get(key); - if (isFloodgate) { - urlObjVal.doc.fg.sp = fileBody; - urlObjVal.doc.fg.sp.status = status; - } else { - urlObjVal.doc.sp = fileBody; - urlObjVal.doc.sp.status = status; - } +function injectSharepointData(projectUrls, filePaths, docPaths, spBatchFiles, isFloodgate) { + spBatchFiles.forEach((spFiles) => { + if (!spFiles?.responses) return; + spFiles.responses.forEach(({ id, status, body }) => { + const filePath = docPaths[id]; + const fileBody = status === 200 ? body : {}; + const urls = filePaths.get(filePath); + urls.forEach((key) => { + const urlObjVal = projectUrls.get(key); + if (isFloodgate) { + urlObjVal.doc.fg.sp = fileBody; + urlObjVal.doc.fg.sp.status = status; + } else { + urlObjVal.doc.sp = fileBody; + urlObjVal.doc.sp.status = status; + } + }); }); - } + }); } async function updateProjectWithDocs(projectDetail) { @@ -51,10 +49,10 @@ async function updateProjectWithDocs(projectDetail) { } const { filePaths } = projectDetail; const docPaths = [...filePaths.keys()]; - const spFiles = await getFilesData(docPaths); - injectSharepointData(projectDetail.urls, filePaths, docPaths, spFiles); - const fgSpFiles = await getFilesData(docPaths, true); - injectSharepointData(projectDetail.urls, filePaths, docPaths, fgSpFiles, true); + const spBatchFiles = await getSpFiles(docPaths); + injectSharepointData(projectDetail.urls, filePaths, docPaths, spBatchFiles); + const fgSpBatchFiles = await getSpFiles(docPaths, true); + injectSharepointData(projectDetail.urls, filePaths, docPaths, fgSpBatchFiles, true); } async function initProject() { @@ -91,7 +89,7 @@ async function initProject() { const hlxAdminPreviewUrl = getHelixAdminApiUrl(urlInfo, config.admin.api.preview.baseURI); return fetch(`${hlxAdminPreviewUrl}${projectPath}`, { method: 'POST' }); }, - async getDetails() { + async detail() { const projectFileJson = await readProjectFile(projectUrl); if (!projectFileJson) { return {}; diff --git a/tools/floodgate/js/promote.js b/tools/floodgate/js/promote.js index ac00af8935..60da732440 100644 --- a/tools/floodgate/js/promote.js +++ b/tools/floodgate/js/promote.js @@ -43,10 +43,8 @@ async function promoteCopy(srcPath, destinationFolder, newName) { let copySuccess = false; let copyStatusJson = {}; while (statusUrl && !copySuccess && copyStatusJson.status !== 'failed') { - // eslint-disable-next-line no-await-in-loop const status = await fetchWithRetry(statusUrl); if (status.ok) { - // eslint-disable-next-line no-await-in-loop copyStatusJson = await status.json(); copySuccess = copyStatusJson.status === 'completed'; } @@ -60,10 +58,8 @@ async function promoteCopy(srcPath, destinationFolder, newName) { async function findAllFloodgatedFiles(baseURI, options, rootFolder, fgFiles, fgFolders) { while (fgFolders.length !== 0) { const uri = `${baseURI}${fgFolders.shift()}:/children?$top=${MAX_CHILDREN}`; - // eslint-disable-next-line no-await-in-loop const res = await fetchWithRetry(uri, options); if (res.ok) { - // eslint-disable-next-line no-await-in-loop const json = await res.json(); const driveItems = json.value; driveItems?.forEach((item) => { @@ -141,11 +137,10 @@ async function promoteFloodgatedFiles(project) { // process data in batches const promoteStatuses = []; for (let i = 0; i < batchArray.length; i += 1) { - // eslint-disable-next-line no-await-in-loop promoteStatuses.push(...await Promise.all( batchArray[i].map((file) => promoteFile(file.fileDownloadUrl, file.filePath)), )); - // eslint-disable-next-line no-await-in-loop, no-promise-executor-return + // eslint-disable-next-line no-promise-executor-return await delay(DELAY_TIME_PROMOTE); } const endPromote = new Date(); @@ -154,11 +149,10 @@ async function promoteFloodgatedFiles(project) { const previewStatuses = []; for (let i = 0; i < promoteStatuses.length; i += 1) { if (promoteStatuses[i].success) { - // eslint-disable-next-line no-await-in-loop - const result = await simulatePreview(handleExtension(promoteStatuses[i].srcPath), 1, true); + const result = await simulatePreview(handleExtension(promoteStatuses[i].srcPath), 1); previewStatuses.push(result); } - // eslint-disable-next-line no-await-in-loop, no-promise-executor-return + // eslint-disable-next-line no-promise-executor-return await delay(); } loadingON('Completed Preview for promoted files... '); diff --git a/tools/floodgate/js/ui.js b/tools/floodgate/js/ui.js index 4ece9c2ea2..900eac5b27 100644 --- a/tools/floodgate/js/ui.js +++ b/tools/floodgate/js/ui.js @@ -10,7 +10,7 @@ import { getFloodgateUrl, } from './utils.js'; -const ACTION_BUTTON_IDS = ['reloadProject', 'copyFiles', 'promoteFiles']; +const ACTION_BUTTON_IDS = ['reloadProject', 'copyFiles', 'promoteFiles', 'updateFragments', 'delete']; function getSharepointStatus(doc, isFloodgate) { let sharepointStatus = 'Connect to Sharepoint'; @@ -98,10 +98,21 @@ async function updateProjectDetailsUI(projectDetail, config) { } function updateProjectStatusUI(status) { - document.querySelector('#copy-status').innerHTML = status.copy.status; - document.querySelector('#copy-status-ts').innerHTML = status.copy.lastRun; - document.querySelector('#promote-status').innerHTML = status.promote.status; - document.querySelector('#promote-status-ts').innerHTML = status.promote.lastRun; + if (status?.copyStatus?.payload?.action?.type === 'copyAction') { + document.querySelector('#copy-status').innerHTML = status.copyStatus.payload.action.status; + document.querySelector('#copy-status-msg').innerHTML = status.copyStatus.payload.action.message; + document.querySelector('#copy-status-ts').innerHTML = status.copyStatus.payload.action.startTime; + } + if (status?.promoteStatus?.payload?.action?.type === 'promoteAction') { + document.querySelector('#promote-status').innerHTML = status.promoteStatus.payload.action.status; + document.querySelector('#promote-status-msg').innerHTML = status.promoteStatus.payload.action.message; + document.querySelector('#promote-status-ts').innerHTML = status.promoteStatus.payload.action.startTime; + } + if (status?.deleteStatus?.payload?.action?.type === 'deleteAction') { + document.querySelector('#delete-status').innerHTML = status.deleteStatus.payload.action.status; + document.querySelector('#delete-status-msg').innerHTML = status.deleteStatus.payload.action.message; + document.querySelector('#delete-status-ts').innerHTML = status.deleteStatus.payload.action.startTime; + } document.querySelector('.project-status').hidden = false; } diff --git a/tools/floodgate/js/utils.js b/tools/floodgate/js/utils.js index a8f0e20d8c..0c4f789c35 100644 --- a/tools/floodgate/js/utils.js +++ b/tools/floodgate/js/utils.js @@ -46,7 +46,9 @@ export function getDocPathFromUrl(url) { path = path.slice(0, -5); return `${path}.xlsx`; } - + if (path.endsWith('.svg')) { + return path; + } if (path.endsWith('/')) { path += 'index'; } else if (path.endsWith('.html')) { @@ -60,3 +62,25 @@ export async function delay(milliseconds = 100) { // eslint-disable-next-line no-promise-executor-return await new Promise((resolve) => setTimeout(resolve, milliseconds)); } + +export async function postData(url, data) { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + return response.json(); +} + +export function getParams(project, config) { + return { + adminPageUri: window.location.href, + projectExcelPath: project.excelPath, + shareUrl: config.sp.shareUrl, + fgShareUrl: config.sp.fgShareUrl, + rootFolder: config.sp.rootFolders, + fgRootFolder: config.sp.fgRootFolder, + promoteIgnorePaths: config.promoteIgnorePaths || [], + driveId: config.sp.driveId || '', + }; +} diff --git a/tools/loc/config.js b/tools/loc/config.js index f780cc261f..76f342ba84 100644 --- a/tools/loc/config.js +++ b/tools/loc/config.js @@ -108,6 +108,7 @@ function getSharepointConfig(config) { // ${sharepointConfig.site} - MS Graph API Url with site pointers. const baseURI = `${sharepointConfig.site}${drive}/root:${sharepointConfig.rootFolders}`; const fgBaseURI = `${sharepointConfig.site}${drive}/root:${sharepointConfig.fgRootFolder}`; + const baseItemsURI = `${sharepointConfig.site}${drive}/items`; return { ...sharepointConfig, clientApp: { @@ -162,9 +163,9 @@ function getSharepointConfig(config) { }, }, excel: { + get: { baseItemsURI }, update: { - baseURI, - fgBaseURI, + baseItemsURI, method: 'POST', }, }, diff --git a/tools/loc/fragments.js b/tools/loc/fragments.js index ffec02d9f3..cfe78624ea 100644 --- a/tools/loc/fragments.js +++ b/tools/loc/fragments.js @@ -1,4 +1,3 @@ -import { init as getProjectFile } from './project.js'; import { connect as connectToSp, updateExcelTable } from './sharepoint.js'; import { loadingON } from './utils.js'; @@ -37,6 +36,10 @@ function isLocalFragment(link, baseUrlOrigin) { return link && link.startsWith(baseUrlOrigin) && link.includes(fragmentPath); } +function isReferencedAsset(link, baseUrlOrigin) { + return link && link.startsWith(baseUrlOrigin) && link.endsWith('.svg'); +} + function getOriginFromLink(link) { const url = new URL(link); return url.origin; @@ -47,7 +50,7 @@ function getSanitizedUrl(link) { return `${url.origin}${url.pathname}`; } -function getFragmentLinksFromUrlHtml(urlHtml) { +function getFragmentLinksFromUrlHtml(urlHtml, isFloodgate) { const fragments = []; const parser = new DOMParser(); const dom = parser.parseFromString(urlHtml.value, 'text/html'); @@ -57,11 +60,11 @@ function getFragmentLinksFromUrlHtml(urlHtml) { const { links } = dom; for (let i = 0; i < links.length; i += 1) { const linkHref = links[i].href; - if (isLocalFragment(linkHref, baseUrlOrigin)) { - const sanitizedUrl = getSanitizedUrl(linkHref); - if (!fragments.includes(sanitizedUrl)) { - fragments.push(sanitizedUrl); - } + const sanitizedUrl = getSanitizedUrl(linkHref); + if ((isLocalFragment(linkHref, baseUrlOrigin) + || (isFloodgate && isReferencedAsset(linkHref, baseUrlOrigin))) + && !fragments.includes(sanitizedUrl)) { + fragments.push(sanitizedUrl); } } return fragments; @@ -86,7 +89,7 @@ async function refreshProjectJson(projectFile, fragments, attempts = 0) { const maxAttempts = 2; await projectFile.purge(); const projectJson = await fetchUrl(projectFile.path, 'json'); - const urls = projectJson?.value?.translation?.data; + const urls = projectJson?.value?.urls?.data; if (!urls || !urls.map((url) => url.URL).includes(...fragments)) { if (attempts < maxAttempts) { loadingON(`Failed to reload Project JSON... Trying until max attempts ${maxAttempts}`); @@ -104,7 +107,7 @@ async function refreshProjectJson(projectFile, fragments, attempts = 0) { return isJsonUpdated; } -async function updateFragments() { +async function updateFragments(getProjectFile, isFloodgate) { let status = 'No Fragments found'; const projectFile = await getProjectFile(); const projectDetail = await projectFile.detail(); @@ -115,7 +118,7 @@ async function updateFragments() { loadingON('Finding Fragments...'); urlHtmls.forEach((urlHtml) => { if (urlHtml.status !== 'error') { - const fragmentsInUrl = getFragmentLinksFromUrlHtml(urlHtml); + const fragmentsInUrl = getFragmentLinksFromUrlHtml(urlHtml, isFloodgate); if (fragmentsInUrl.length > 0) { const filteredFragments = fragmentsInUrl .filter((fragment) => !urls.includes(fragment) && !fragments.includes(fragment)); diff --git a/tools/loc/rollout.js b/tools/loc/rollout.js index b0a007a698..bcbf08dbe5 100644 --- a/tools/loc/rollout.js +++ b/tools/loc/rollout.js @@ -172,7 +172,6 @@ function getMergedMdast(left, right) { const mergedMdast = { type: 'root', children: [] }; let leftPointer = 0; const leftArray = Array.from(left.entries()); - // eslint-disable-next-line no-restricted-syntax for (const [rightHash, rightOpInfo] of right) { if (left.has(rightHash)) { for (leftPointer; leftPointer < leftArray.length; leftPointer += 1) { diff --git a/tools/loc/sharepoint.js b/tools/loc/sharepoint.js index 598173861f..3a77228edb 100644 --- a/tools/loc/sharepoint.js +++ b/tools/loc/sharepoint.js @@ -15,6 +15,7 @@ import { getConfig as getFloodgateConfig } from '../floodgate/js/config.js'; let accessToken; const BATCH_REQUEST_LIMIT = 20; const BATCH_DELAY_TIME = 200; +const itemIdMap = {}; const getAccessToken = () => accessToken; @@ -187,11 +188,10 @@ async function getFilesData(filePaths, isFloodgate) { // process data in batches const fileJsonResp = []; for (let i = 0; i < batchArray.length; i += 1) { - // eslint-disable-next-line no-await-in-loop fileJsonResp.push(...await Promise.all( batchArray[i].map((file) => getFileData(file, isFloodgate)), )); - // eslint-disable-next-line no-await-in-loop, no-promise-executor-return + // eslint-disable-next-line no-promise-executor-return await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_TIME)); } return fileJsonResp; @@ -400,10 +400,8 @@ async function copyFile(srcPath, destinationFolder, newName, isFloodgate, isFloo let copySuccess = false; let copyStatusJson = {}; while (statusUrl && !copySuccess && copyStatusJson.status !== 'failed') { - // eslint-disable-next-line no-await-in-loop const status = await fetchWithRetry(statusUrl); if (status.ok) { - // eslint-disable-next-line no-await-in-loop copyStatusJson = await status.json(); copySuccess = copyStatusJson.status === 'completed'; } @@ -468,22 +466,31 @@ async function saveFileAndUpdateMetadata(srcPath, file, dest, customMetadata = { throw new Error(`Could not upload file ${dest}`); } -async function updateExcelTable(excelPath, tableName, values) { - const { sp } = await getConfig(); +async function executeGQL(url, opts) { + const options = await getAuthorizedRequestOption(opts); + const res = await fetchWithRetry(url, options); + if (!res.ok) { + throw new Error(`Failed to execute ${url}`); + } + return res.json(); +} - const options = getAuthorizedRequestOption({ - body: JSON.stringify({ values }), - method: sp.api.excel.update.method, - }); +async function getItemId(uri, path) { + const key = `~${uri}~${path}~`; + itemIdMap[key] = itemIdMap[key] || await executeGQL(`${uri}${path}?$select=id`); + return itemIdMap[key]?.id; +} - const res = await fetchWithRetry( - `${sp.api.excel.update.baseURI}${excelPath}:/workbook/tables/${tableName}/rows/add`, - options, - ); - if (res.ok) { - return res.json(); +async function updateExcelTable(excelPath, tableName, values) { + const { sp } = await getConfig(); + const itemId = await getItemId(sp.api.file.get.baseURI, excelPath); + if (itemId) { + return executeGQL(`${sp.api.excel.update.baseItemsURI}/${itemId}/workbook/tables/${tableName}/rows`, { + body: JSON.stringify({ values }), + method: sp.api.excel.update.method, + }); } - throw new Error(`Failed to update excel sheet ${excelPath} table ${tableName}.`); + return {}; } async function addWorksheetToExcel(excelPath, worksheetName) { diff --git a/tools/loc/ui.js b/tools/loc/ui.js index 567a5b82ea..17c2def37e 100644 --- a/tools/loc/ui.js +++ b/tools/loc/ui.js @@ -386,7 +386,7 @@ async function handleEnglishCopyProjects(langstoreEnFiles) { } statusValues.push( [projectInfo.language, projectInfo.status, projectInfo.status, projectInfo.status, - projectInfo.status, projectInfo.failureMessage, projectInfo.failedPages.join('\n')], + projectInfo.status, projectInfo.failureMessage, projectInfo.failedPages.join('\n')], ); loadingON(`Updated status for project ${projectInfo.language}...`); }); @@ -610,7 +610,11 @@ async function copyFilesToLangstoreEn() { const previewStatuses = await Promise.all( copyStatuses .filter((status) => status.success) - .map((status) => simulatePreview(stripExtension(status.dstPath))), + .map((status) => { + let { dstPath } = status; + dstPath = dstPath.endsWith('.xlsx') ? dstPath.replace(/\.xlsx$/, '.json') : stripExtension(dstPath); + return simulatePreview(dstPath); + }), ); loadingON('Completed Preview for copied files... '); const failedCopies = copyStatuses @@ -631,7 +635,7 @@ async function copyFilesToLangstoreEn() { async function triggerUpdateFragments() { loadingON('Fetching and updating fragments..'); - const status = await updateFragments(); + const status = await updateFragments(initProject); loadingON(status); } diff --git a/tools/loc/utils.js b/tools/loc/utils.js index 9242cb9b3a..20d66ce9ce 100644 --- a/tools/loc/utils.js +++ b/tools/loc/utils.js @@ -51,6 +51,7 @@ export function getDocPathFromUrl(url) { if (!path) { return undefined; } + if (path.endsWith('.json')) return path.replace(/\.json$/, '.xlsx'); if (path.endsWith('/')) { path += 'index'; } else if (path.endsWith('.html')) { diff --git a/tools/send-to-caas/bulk-publish-to-caas.js b/tools/send-to-caas/bulk-publish-to-caas.js index e2c36e9c19..e1fbe7cb65 100644 --- a/tools/send-to-caas/bulk-publish-to-caas.js +++ b/tools/send-to-caas/bulk-publish-to-caas.js @@ -1,4 +1,3 @@ -/* eslint-disable no-await-in-loop */ /* eslint-disable no-continue */ import { loadScript, loadStyle } from '../../libs/utils/utils.js'; import { getImsToken } from '../utils/utils.js'; @@ -18,10 +17,11 @@ import { import comEnterpriseToCaasTagMap from './comEnterpriseToCaasTagMap.js'; const LS_KEY = 'bulk-publish-caas'; -const FIELDS = ['host', 'repo', 'owner', 'excelFile', 'caasEnv', 'urls']; -const FIELDS_CB = ['draftOnly', 'usepreview']; +const FIELDS = ['host', 'repo', 'owner', 'excelFile', 'caasEnv', 'urls', 'contentType']; +const FIELDS_CB = ['draftOnly', 'usePreview', 'useHtml']; const DEFAULT_VALUES = { caasEnv: 'Prod', + contentType: 'caas:content-type/article', excelFile: '', host: 'business.adobe.com', owner: 'adobecom', @@ -30,7 +30,8 @@ const DEFAULT_VALUES = { }; const DEFAULT_VALUES_CB = { draftOnly: false, - usepreview: false, + usePreview: false, + useHtml: true, }; const fetchExcelJson = async (url) => { @@ -94,13 +95,20 @@ const processData = async (data, accessToken) => { let keepGoing = true; const statusModal = showAlert('', { btnText: 'Cancel', onClose: () => { keepGoing = false; } }); - const { caasEnv, draftOnly, host, owner, repo, usepreview } = getConfig(); - - const domain = usepreview + const { + caasEnv, + draftOnly, + host, + owner, + repo, + useHtml, + usePreview, + } = getConfig(); + + const domain = usePreview ? `https://main--${repo}--${owner}.hlx.page` : `https://${host}`; - // eslint-disable-next-line no-restricted-syntax for (const page of data) { if (!keepGoing) break; @@ -108,8 +116,9 @@ const processData = async (data, accessToken) => { const rawUrl = page.Path || page.path || page.url || page.URL || page.Url || page; const { pathname } = new URL(rawUrl); - const pageUrl = usepreview ? `${domain}${pathname.replace('.html', '')}` : `${domain}${pathname}`; - const prodUrl = `${host}${pathname}`; + const pathnameNoHtml = pathname.replace('.html', ''); + const pageUrl = usePreview ? `${domain}${pathnameNoHtml}` : `${domain}${pathname}`; + const prodUrl = `${host}${pathnameNoHtml}${useHtml ? '.html' : ''}`; index += 1; statusModal.setContent(`Publishing ${index} of ${data.length}:
            ${pageUrl}`); @@ -136,6 +145,11 @@ const processData = async (data, accessToken) => { caasMetadata.tags = updatedTags; } + if (!caasMetadata.tags.length) { + errorArr.push([pageUrl, 'No tags on page']); + continue; + } + const caasProps = getCaasProps(caasMetadata); const response = await postDataToCaaS({ @@ -151,6 +165,7 @@ const processData = async (data, accessToken) => { errorArr.push([pageUrl, response]); } } catch (e) { + // eslint-disable-next-line no-console console.log(`ERROR: ${e.message}`); } } @@ -181,20 +196,18 @@ const bulkPublish = async () => { const loadFromLS = () => { const ls = localStorage.getItem(LS_KEY); - if (ls) { - try { - setConfig(JSON.parse(ls)); - /* c8 ignore next */ - } catch (e) { /* do nothing */ } - } - - const config = getConfig(); - FIELDS.forEach((field) => { - document.getElementById(field).value = config[field] || DEFAULT_VALUES[field]; - }); - FIELDS_CB.forEach((field) => { - document.getElementById(field).checked = config[field] || DEFAULT_VALUES_CB[field]; - }); + if (!ls) return; + try { + setConfig(JSON.parse(ls)); + const config = getConfig(); + FIELDS.forEach((field) => { + document.getElementById(field).value = config[field] ?? DEFAULT_VALUES[field]; + }); + FIELDS_CB.forEach((field) => { + document.getElementById(field).checked = config[field] ?? DEFAULT_VALUES_CB[field]; + }); + /* c8 ignore next */ + } catch (e) { /* do nothing */ } }; const init = async () => { @@ -219,11 +232,13 @@ const init = async () => { project: '', branch: 'main', caasEnv: document.getElementById('caasEnv').value, + contentType: document.getElementById('contentType').value, repo: document.getElementById('repo').value, owner: document.getElementById('owner').value, urls: document.getElementById('urls').value, draftOnly: document.getElementById('draftOnly').checked, - usepreview: document.getElementById('usepreview').checked, + useHtml: document.getElementById('useHtml').checked, + usePreview: document.getElementById('usePreview').checked, }); bulkPublish(); }); diff --git a/tools/send-to-caas/bulkpublisher.html b/tools/send-to-caas/bulkpublisher.html index e6471fc3f1..e3085c08c8 100644 --- a/tools/send-to-caas/bulkpublisher.html +++ b/tools/send-to-caas/bulkpublisher.html @@ -7,52 +7,108 @@
            -
            -
            - CaaS Bulk Publisher -
            -
            -
            - - -
            -
            - - -
            -
            - - -
            -
            - - -
            -
            - - -
            -
            - - -
            -
            - - -
            - -
            -
            -
            -
            - +
            +
            + CaaS Bulk Publisher +
            +
            +
            + + +
            +
            + + +
            +
            + + +
            +
            + + +
            +
            + + +
            +
            + + +
            +
            + + +
            +
            + + +
            +
            + + +
            + +
            +
            +
            + +
            -
            +
            diff --git a/tools/send-to-caas/send-to-caas.css b/tools/send-to-caas/send-to-caas.css index 367e774740..5d5f369a94 100644 --- a/tools/send-to-caas/send-to-caas.css +++ b/tools/send-to-caas/send-to-caas.css @@ -68,6 +68,7 @@ .tingle-modal .tingle-modal-box__footer #caas-draft-cb { margin-left: 20px; text-align: center; + margin-right: 20px; } .tingle-modal .tingle-modal-box__footer .verify-info-footer { diff --git a/tools/send-to-caas/send-to-caas.js b/tools/send-to-caas/send-to-caas.js index f8c6fd48d6..a674da9c17 100644 --- a/tools/send-to-caas/send-to-caas.js +++ b/tools/send-to-caas/send-to-caas.js @@ -2,9 +2,7 @@ /* global tingle */ /* eslint-disable no-alert */ -import { - getImsToken, -} from '../utils/utils.js'; +import { getImsToken } from '../utils/utils.js'; import { getCardMetadata, @@ -63,6 +61,7 @@ const showConfirm = (msg, { cancelBtnType = 'default', cancelText = 'Cancel', footerContent = '', + initCode, leftButton, } = {}) => new Promise((resolve) => { let ok = false; @@ -80,10 +79,14 @@ const showConfirm = (msg, { if (footerContent) { modal.setFooterContent(footerContent); } - modal.addFooterBtn(ctaText, `tingle-btn tingle-btn--${ctaBtnType} tingle-btn--pull-right`, () => { - ok = true; - modal.close(); - }); + + if (ctaText) { + modal.addFooterBtn(ctaText, `tingle-btn tingle-btn--${ctaBtnType} tingle-btn--pull-right`, () => { + ok = true; + modal.close(); + }); + } + modal.addFooterBtn(cancelText, `tingle-btn tingle-btn--${cancelBtnType} tingle-btn--pull-right`, () => { ok = false; modal.close(); @@ -93,6 +96,9 @@ const showConfirm = (msg, { leftButton.callback?.(); }); } + + if (initCode) initCode(modal.modal); + modal.open(); }); @@ -112,6 +118,7 @@ const displayPublishingModal = () => { const verifyInfoModal = async (tags, tagErrors, showAllPropertiesAlert) => { let okToContinue = false; let draftOnly = false; + let useHtml = false; let caasEnv; const seeAllPropsBtn = { @@ -133,14 +140,42 @@ const verifyInfoModal = async (tags, tagErrors, showAllPropertiesAlert) => {
            +
            + + +
            `; const onClose = () => { - draftOnly = document.getElementById('draftcb')?.checked; caasEnv = document.getElementById('caas-env-select')?.value?.toLowerCase(); + draftOnly = document.getElementById('draftcb')?.checked; + useHtml = document.getElementById('usehtml')?.checked; + }; + + const modalInit = (modal) => { + const caasEnvSelect = modal.querySelector('#caas-env-select'); + const caasEnvVal = caasEnvSelect.value?.toLowerCase(); + const useHtmlCb = modal.querySelector('#usehtml'); + if (caasEnvVal === 'prod') { + useHtmlCb.checked = true; + } + caasEnvSelect.addEventListener('change', (e) => { + useHtmlCb.checked = e.target.value?.toLowerCase() === 'prod'; + }); }; - if (tagErrors.length) { + if (!tags.length) { + const msg = '

            No Tags found on page

            Please add at least one tag to the Card Metadata

            '; + okToContinue = await showConfirm(msg, { + cssClass: ['verify-info-modal'], + ctaText: '', + cancelBtnType: 'danger', + cancelText: 'Cancel Registration', + footerContent: footerOptions, + leftButton: seeAllPropsBtn, + onClose, + }); + } else if (tagErrors.length) { const msg = [ '
            ', '

            The following tags were not found:

            ', @@ -157,6 +192,7 @@ const verifyInfoModal = async (tags, tagErrors, showAllPropertiesAlert) => { cancelText: 'Cancel Registration', ctaBtnType: 'danger', footerContent: footerOptions, + initCode: modalInit, leftButton: seeAllPropsBtn, onClose, }); @@ -172,6 +208,7 @@ const verifyInfoModal = async (tags, tagErrors, showAllPropertiesAlert) => { cancelText: 'Cancel Registration', ctaText: 'Continue with these tags', footerContent: footerOptions, + initCode: modalInit, leftButton: seeAllPropsBtn, onClose, }); @@ -180,17 +217,30 @@ const verifyInfoModal = async (tags, tagErrors, showAllPropertiesAlert) => { caasEnv, draftOnly, okToContinue, + useHtml, }; }; +const isUseHtmlChecked = () => document.getElementById('usehtml')?.checked; + +const sortObjByPropName = (obj) => Object.keys(obj) + // eslint-disable-next-line no-return-assign, no-sequences + .sort().reduce((c, d) => (c[d] = obj[d], c), {}); + const validateProps = async (prodHost, publishingModal) => { - const { caasMetadata, errors, tags, tagErrors } = await getCardMetadata({ prodUrl: `${prodHost}${window.location.pathname}` }); + const { caasMetadata, errors, tags, tagErrors } = await getCardMetadata( + { prodUrl: `${prodHost}${window.location.pathname}` }, + ); - const showAllPropertiesAlert = () => { - showAlert(`

            All CaaS Properties

            ${JSON.stringify(caasMetadata, undefined, 4)}
            `); + const showAllPropertiesAlert = async () => { + const { caasMetadata: cMetaData } = await getCardMetadata( + { prodUrl: `${prodHost}${window.location.pathname}${isUseHtmlChecked() ? '.html' : ''}` }, + ); + const mdStr = JSON.stringify(sortObjByPropName(cMetaData), undefined, 4); + showAlert(`

            All CaaS Properties

            ${mdStr}
            `); }; - const { draftOnly, caasEnv, okToContinue } = await verifyInfoModal( + const { draftOnly, caasEnv, okToContinue, useHtml } = await verifyInfoModal( tags, tagErrors, showAllPropertiesAlert, @@ -212,9 +262,17 @@ const validateProps = async (prodHost, publishingModal) => { showAlert(msg, { error: true, onClose: setPublishingFalse }); return false; } + + let metaWithUseHtml; + if (useHtml) { + ({ caasMetadata: metaWithUseHtml } = await getCardMetadata( + { prodUrl: `${prodHost}${window.location.pathname}.html` }, + )); + } + return { caasEnv, - caasMetadata, + caasMetadata: metaWithUseHtml || caasMetadata, draftOnly, }; }; diff --git a/tools/send-to-caas/send-utils.js b/tools/send-to-caas/send-utils.js index 432c1c1c6e..aec500379b 100644 --- a/tools/send-to-caas/send-utils.js +++ b/tools/send-to-caas/send-utils.js @@ -1,4 +1,5 @@ import getUuid from '../../libs/utils/getUuid.js'; +import { getMetadata } from '../../libs/utils/utils.js'; const CAAS_TAG_URL = 'https://www.adobe.com/chimera-api/tags'; const HLX_ADMIN_STATUS = 'https://admin.hlx.page/status'; @@ -221,17 +222,11 @@ const getTag = (tagName, errors) => { const getTags = (s) => { let rawTags = []; if (s) { - rawTags = s.toLowerCase().split(/,|(\s+)|(\\n)/g).filter((t) => t && t.trim() && t !== '\n'); - } else { - rawTags = [...getConfig().doc.querySelectorAll("meta[property='article:tag']")].map( - (metaEl) => metaEl.content, - ); + rawTags = s.toLowerCase().split(/,|(\s+)|(\\n)|;/g).filter((t) => t && t.trim() && t !== '\n'); } const errors = []; - if (!rawTags.length) rawTags = ['Article']; // default if no tags found - const tagIds = rawTags.map((tag) => getTag(tag, errors)) .filter((tag) => tag !== undefined) .map((tag) => tag.tagID); @@ -416,10 +411,15 @@ const props = { return undefined; }, bookmarkicon: 0, + carddescription: 0, + cardtitle: 0, cardimage: () => getCardImageUrl(), cardimagealttext: (s) => s || getCardImageAltText(), - contentid: (_, options) => getUuid(options.prodUrl), - contenttype: (s) => s || getMetaContent('property', 'og:type') || 'Article', + contentid: (_, options) => { + const floodGateColor = getMetadata('floodgatecolor') || ''; + return getUuid(`${options.prodUrl}${floodGateColor}`); + }, + contenttype: (s) => s || getMetaContent('property', 'og:type') || getConfig().contentType, country: async (s, options) => { if (s) return s; const { country } = await getCountryAndLang(options); @@ -459,7 +459,7 @@ const props = { eventduration: 0, eventend: (s) => getDateProp(s, `Invalid Event End Date: ${s}`), eventstart: (s) => getDateProp(s, `Invalid Event Start Date: ${s}`), - floodgatecolor: (s) => s || 'default', + floodgatecolor: (s) => s || getMetadata('floodgatecolor') || 'default', lang: async (s, options) => { if (s) return s; const { lang } = await getCountryAndLang(options); @@ -497,8 +497,8 @@ const getCaasProps = (p) => { url: p.url, floodGateColor: p.floodgatecolor, universalContentIdentifier: p.uci, - title: p.title, - description: p.description, + title: p.cardtitle || p.title, + description: p.carddescription || p.description, createdDate: p.created, modifiedDate: p.modified, tags: p.tags, @@ -513,7 +513,7 @@ const getCaasProps = (p) => { language: p.lang, cardData: { style: p.style, - headline: p.title, + headline: p.cardtitle || p.title, ...(p.details && { details: p.details }), ...((p.bookmarkenabled || p.bookmarkicon || p.bookmarkaction) && { bookmark: { @@ -564,9 +564,7 @@ const getCaaSMetadata = async (pageMd, options) => { let tagErrors = []; let tags = []; // for-of required to await any async computeVal's - // eslint-disable-next-line no-restricted-syntax for (const [key, computeFn] of Object.entries(props)) { - // eslint-disable-next-line no-await-in-loop const val = computeFn ? await computeFn(pageMd[key], options) : pageMd[key]; if (val?.error) { errors.push(val.error); @@ -578,6 +576,9 @@ const getCaaSMetadata = async (pageMd, options) => { md[key] = val; } } + if (!md.contenttype && tags.length) { + md.contenttype = tags.find((tag) => tag.startsWith('caas:content-type')); + } return { caasMetadata: md, errors, tags, tagErrors }; }; diff --git a/tools/sidekick/config.json b/tools/sidekick/config.json index aeecd88371..9f07415da8 100644 --- a/tools/sidekick/config.json +++ b/tools/sidekick/config.json @@ -32,6 +32,17 @@ "excludePaths": [ "/**" ], "includePaths": [ "**/:x**" ] }, + { + "containerId": "tools", + "id": "localize-v2", + "title": "Localize project (V2)", + "environments": [ "edit" ], + "url": "https://locui--milo--adobecom.hlx.page/tools/loc", + "passReferrer": true, + "passConfig": true, + "excludePaths": [ "/**" ], + "includePaths": [ "**/:x**" ] + }, { "containerId": "tools", "id": "floodgate", @@ -46,7 +57,7 @@ "containerId": "tools", "title": "Send to CaaS", "id": "sendtocaas", - "environments": ["dev","preview", "live", "prod"], + "environments": ["dev", "preview", "live", "prod"], "event": "send-to-caas", "excludePaths": ["/tools/caas**", "/tools/ost**", "*.json"] }, @@ -65,6 +76,16 @@ "environments": ["dev", "preview", "live"], "event": "preflight" }, + { + "containerId": "tools", + "id": "offerpreview", + "title": "Offer preview", + "environments": [ "edit" ], + "isPalette": true, + "paletteRect": "top: auto; bottom: 25px; left: 75px; height: 388px; width: 360px;", + "url": "/tools/commerce", + "includePaths": [ "**.docx**" ] + }, { "containerId": "tools", "id": "locales", @@ -82,8 +103,39 @@ "id": "ost", "title": "Use offer", "environments": [ "edit", "dev", "preview" ], - "url": "/tools/ost", - "includePaths": [ "**.docx**" ] + "url": "https://milo.adobe.com/tools/ost", + "includePaths": [ "**.docx**" ], + "passConfig": true, + "passReferrer": true + }, + { + "containerId": "tools", + "title": "Tag Selector", + "id": "tag-selector", + "environments": ["edit"], + "url": "https://milo.adobe.com/tools/tag-selector", + "isPalette": true, + "paletteRect": "top: 150px; left: 7%; height: 675px; width: 85vw;" + }, + { + "containerId": "tools", + "title": "Send to CaaS", + "id": "sendtocaas", + "environments": ["dev","preview", "live", "prod"], + "event": "send-to-caas", + "excludePaths": ["/tools/caas**", "/tools/ost**", "*.json"] + }, + { + "containerId": "tools", + "id": "version-history", + "title": "Version History", + "environments": [ "edit" ], + "url": "/tools/version-history", + "isPalette": true, + "passReferrer": true, + "passConfig": true, + "paletteRect": "top: auto; bottom: 20px; left: 20px; height: 498px; width: 460px;", + "includePaths": [ "**.docx**", "**.xlsx**" ] } ] } diff --git a/tools/utils/utils.js b/tools/utils/utils.js index fd5270bad6..5bbf1ecc49 100644 --- a/tools/utils/utils.js +++ b/tools/utils/utils.js @@ -14,6 +14,4 @@ const getImsToken = async (loadScript) => { return window.adobeIMS?.getAccessToken()?.token; }; -export { - getImsToken, -}; +export { getImsToken }; diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index a593584dc8..cf2b17a797 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -1,5 +1,19 @@ import { importMapsPlugin } from '@web/dev-server-import-maps'; +import { defaultReporter, summaryReporter } from '@web/test-runner'; +function customReporter() { + return { + async reportTestFileResults({ logger, sessionsForTestFile }) { + sessionsForTestFile.forEach((session) => { + session.testResults.tests.forEach((test) => { + if (!test.passed && !test.skipped) { + logger.log(test); + } + }); + }); + }, + }; +} export default { coverageConfig: { include: [ @@ -21,4 +35,60 @@ export default { ], }, plugins: [importMapsPlugin({})], + reporters: [ + defaultReporter({ reportTestResults: true, reportTestProgress: true }), + customReporter(), + summaryReporter(), + ], + testRunnerHtml: (testFramework) => ` + + + + + + + + `, + // Comment in the files for selectively running test suites + // npm run test:file:watch allows to you to run single test file & view the result in a browser. + // files: ['**/utils.test.js'], };