diff --git a/.github/actions/changelog-check/action.yml b/.github/actions/changelog-check/action.yml index ffdc5a2139..d7a6804a9d 100644 --- a/.github/actions/changelog-check/action.yml +++ b/.github/actions/changelog-check/action.yml @@ -1,9 +1,6 @@ name: 'Changelog Check' description: 'Check if changelog is updated for the changed packages' inputs: - github_token: - description: 'GitHub token' - required: true pr_number: description: 'Pull request number' required: true @@ -16,39 +13,26 @@ runs: with: fetch-depth: 0 # Fetch full history to access all commits - - name: Get Base Branch SHA - id: get_base_sha + - name: Determine Base SHA and Merge Base + id: determine_base run: | - if [ -n "${{ inputs.pr_number }}" ]; then - BASE_SHA=$(curl -s -H "Authorization: token ${{ inputs.github_token }}" \ - "https://api.github.com/repos/${{ github.repository }}/pulls/${{ inputs.pr_number }}" | \ - jq -r '.base.sha') + if [ -n "${{ github.event.pull_request.base.sha }}" ]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + MERGE_BASE=$(git merge-base $BASE_SHA ${{ github.sha }}) + echo "BASE_SHA=$BASE_SHA" >> $GITHUB_ENV + echo "MERGE_BASE=$MERGE_BASE" >> $GITHUB_ENV else - echo "Not running in a PR context. Skipping changelog check." - echo "SKIP_CHANGELOG_CHECK=true" >> $GITHUB_ENV - fi - - if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" == "null" ]; then - echo "Unable to fetch base SHA or no base SHA available. Skipping changelog check." + echo "Not running in a PR context or unable to determine base SHA. Skipping changelog check." echo "SKIP_CHANGELOG_CHECK=true" >> $GITHUB_ENV - else - echo "BASE_SHA=${BASE_SHA}" >> $GITHUB_ENV fi shell: bash - - name: Find merge base - id: find_merge_base + - name: Check if 'skip changelog' label is present + id: check_labels run: | - if [ -n "${{ env.BASE_SHA }}" ] && [ "${{ env.BASE_SHA }}" != "null" ]; then - MERGE_BASE=$(git merge-base ${{ env.BASE_SHA }} ${{ github.sha }}) - if [ -z "$MERGE_BASE" ]; then - echo "Unable to find merge base. Skipping changelog check." - echo "SKIP_CHANGELOG_CHECK=true" >> $GITHUB_ENV - else - echo "MERGE_BASE=${MERGE_BASE}" >> $GITHUB_ENV - fi - else - echo "Unable to make the merge base calculation due to missing or null BASE_SHA. Skipping changelog check." + SKIP_CHANGELOG_LABEL="${{ contains(github.event.pull_request.labels.*.name, 'skip changelog') }}" + if [ "$SKIP_CHANGELOG_LABEL" = "true" ]; then + echo "Skip changelog label is present. Skipping changelog check." echo "SKIP_CHANGELOG_CHECK=true" >> $GITHUB_ENV fi shell: bash @@ -58,20 +42,7 @@ runs: echo "Base SHA: ${{ env.BASE_SHA }}" echo "Current SHA: ${{ github.sha }}" echo "Merge Base: ${{ env.MERGE_BASE }}" - shell: bash - - - name: Fetch PR labels and check for skip changelog - id: fetch_and_check_labels - run: | - PR_LABELS=$(curl -s -H "Authorization: token ${{ inputs.github_token }}" \ - "https://api.github.com/repos/${{ github.repository }}/issues/${{ inputs.pr_number }}/labels" | \ - jq -r '.[].name | @sh' | tr '\n' ' ') - echo "PR_LABELS=${PR_LABELS}" >> $GITHUB_ENV - - if echo "${PR_LABELS}" | grep -q "'skip changelog'"; then - echo "Skip changelog label is present. Skipping changelog check." - echo "SKIP_CHANGELOG_CHECK=true" >> $GITHUB_ENV - fi + echo "SKIP_CHANGELOG_CHECK: ${{ env.SKIP_CHANGELOG_CHECK }}" shell: bash - name: Check if changelog is updated @@ -87,7 +58,7 @@ runs: PUBLIC_PACKAGES=("commerce-sdk-react" "pwa-kit-create-app" "pwa-kit-dev" "pwa-kit-react-sdk" "pwa-kit-runtime" "template-retail-react-app") for PACKAGE in "${PUBLIC_PACKAGES[@]}"; do - if echo "$CHANGED_FILES" | grep -i "^packages/$PACKAGE/"; then + if echo "$CHANGED_FILES" | grep -iq "^packages/$PACKAGE/"; then if ! echo "$CHANGED_FILES" | grep -iq "^packages/$PACKAGE/CHANGELOG.md"; then echo "CHANGELOG.md was not updated for package $PACKAGE. Please update the CHANGELOG.md or add 'skip changelog' label to the PR." exit 1 diff --git a/.github/actions/create_mrt_target/action.yml b/.github/actions/create_mrt_target/action.yml new file mode 100644 index 0000000000..f93ef67d20 --- /dev/null +++ b/.github/actions/create_mrt_target/action.yml @@ -0,0 +1,108 @@ +name: create_mrt_target +description: Create MRT Environment +inputs: + project_id: + description: "MRT Project ID" + target_id: + description: "MRT Target ID" + proxy_configs: + description: "Proxy Configs" + mobify_api_key: + description: "Mobify user API key" + +runs: + using: composite + steps: + - name: Initialize + id: initialize + shell: bash + run: | + set -e + echo "TARGET_API_BASE_URL=https://cloud.mobify.com/api/projects/${{ inputs.project_id }}/target" >> $GITHUB_ENV + + - name: Get target + id: get_target + shell: bash + run: |- + set -e + response=$(curl --location --silent --show-error --write-out "HTTPSTATUS:%{http_code}" "$TARGET_API_BASE_URL/${{ inputs.target_id }}" \ + --header "Authorization: Bearer ${{ inputs.mobify_api_key }}") + + http_status=$(echo $response | tr -d '\n' | sed -e 's/.*HTTPSTATUS://') + echo "status=$http_status" >> $GITHUB_OUTPUT + + if [ "$http_status" -eq 404 ]; then + echo "MRT environment not found, it will be created in the next step." + elif [ "$http_status" -eq 200 ]; then + echo "MRT environment already exists." + else + echo "Error: Unexpected HTTP status: $http_status" + exit 1 + fi + + - name: Create target + id: create_target + if: ${{ steps.get_target.outputs.status == '404' }} + shell: bash + run: |- + set -e + proxy_config_json=$(echo ${{ inputs.proxy_configs }} | jq -r .) + response=$(curl --location --silent --show-error --write-out "HTTPSTATUS:%{http_code}" "$TARGET_API_BASE_URL/" \ + --header "Authorization: Bearer ${{ inputs.mobify_api_key }}" \ + --header "Content-Type: application/json" \ + --data "$(jq -n \ + --arg name "${{ inputs.target_id }}" \ + --arg slug "${{ inputs.target_id }}" \ + --argjson ssr_proxy_configs "$proxy_config_json" \ + '{name: $name, slug: $slug, ssr_proxy_configs: $ssr_proxy_configs}')") + + http_status=$(echo "$response" | sed -n 's/.*HTTPSTATUS://p') + response_body=$(echo "$response" | sed -e 's/HTTPSTATUS:.*//g') + + echo "status=$http_status" >> $GITHUB_OUTPUT + + if [ "$http_status" -ne 201 ]; then + echo "Request failed with status code $http_status" + echo "Response Body: $response_body" + exit 1 + fi + + - name: Wait for target to be active + id: wait_for_target + if: ${{ steps.create_target.outputs.status == '201' }} + shell: bash + run: |- + set -e + max_attempts=30 + sleep_duration=30 + attempts=0 + + while [ $attempts -lt $max_attempts ]; do + response=$(curl --location --silent --show-error --write-out "HTTPSTATUS:%{http_code}" "$TARGET_API_BASE_URL/${{ inputs.target_id }}" \ + --header "Authorization: Bearer ${{ inputs.mobify_api_key }}") + + http_status=$(echo $response | tr -d '\n' | sed -e 's/.*HTTPSTATUS://') + response_body=$(echo $response | sed -e 's/HTTPSTATUS\:.*//g') + + if [ "$http_status" -ne 200 ]; then + echo "Request failed with status code $http_status" + exit 1 + fi + + current_state=$(echo $response_body | jq -r '.state') + + if [ "$current_state" == "ACTIVE" ]; then + echo "Target is now ACTIVE." + exit 0 + elif [ "$current_state" != "CREATE_IN_PROGRESS" ]; then + echo "Unexpected target state: $current_state." + exit 1 + fi + + attempts=$((attempts + 1)) + echo "Waiting for target to be ACTIVE. Attempt $attempts/$max_attempts." + sleep $sleep_duration + done + + echo "Target did not become active within the expected time." + exit 1 diff --git a/.github/actions/deploy_app/action.yml b/.github/actions/deploy_app/action.yml new file mode 100644 index 0000000000..d8b7df5298 --- /dev/null +++ b/.github/actions/deploy_app/action.yml @@ -0,0 +1,50 @@ +name: deploy_app +description: Deploy application to MRT +inputs: + project_id: + description: MRT Project ID + target_id: + description: MRT Target ID + project_dir: + description: Project Directory + mobify_user: + description: "Mobify user email" + mobify_api_key: + description: "Mobify user API key" +runs: + using: composite + steps: + - name: Create MRT credentials file + id: create_mrt_credentials + uses: "./.github/actions/create_mrt" + with: + mobify_user: ${{ inputs.mobify_user }} + mobify_api_key: ${{ inputs.mobify_api_key }} + + - name: Read application config + id: read_config + shell: bash + run: | + # Read proxy configs from the default config file using Node.js + config=$(node -e "console.log(JSON.stringify(require('${{ inputs.project_dir }}/config/default.js')))") + # Extract proxyConfigs as a JSON string + echo "proxy_configs=$(echo "$config" | jq -c '.ssrParameters.proxyConfigs' | jq @json)" >> $GITHUB_OUTPUT + + + - name: Create MRT target + id: create_mrt_target + uses: "./.github/actions/create_mrt_target" + with: + project_id: ${{ inputs.project_id }} + target_id: ${{ inputs.target_id }} + proxy_configs: ${{ steps.read_config.outputs.proxy_configs }} + mobify_api_key: ${{ inputs.mobify_api_key }} + + + - name: Push bundle to MRT + id: push_bundle + uses: "./.github/actions/push_to_mrt" + with: + CWD: ${{ inputs.project_dir }} + TARGET: ${{ inputs.target_id }} + FLAGS: --wait diff --git a/.github/actions/e2e_generate_app/action.yml b/.github/actions/e2e_generate_app/action.yml index f17bba0fe0..9884519f32 100644 --- a/.github/actions/e2e_generate_app/action.yml +++ b/.github/actions/e2e_generate_app/action.yml @@ -8,5 +8,5 @@ runs: using: composite steps: - name: Generate new project based on project-key - run: node e2e/scripts/generate-project.js ${{ inputs.PROJECT_KEY }} + run: node e2e/scripts/generate-project.js --project-key ${{ inputs.PROJECT_KEY }} shell: bash diff --git a/.github/actions/generate_app/action.yml b/.github/actions/generate_app/action.yml new file mode 100644 index 0000000000..3387150365 --- /dev/null +++ b/.github/actions/generate_app/action.yml @@ -0,0 +1,118 @@ +name: generate_app +description: Generate Application +inputs: + use_extensibility: + description: Use Extensibility? + project_id: + description: Project ID + instance_url: + description: Instance Url + org_id: + description: Org Id + short_code: + description: Short Code + client_id: + description: Client Id + site_id: + description: Site Id + is_private_client: + description: Is Private Client? + setup_hybrid: + description: Setup Phased Headless rollout? + project_dir: + description: Project Directory + +runs: + using: composite + steps: + - name: Parse input values + id: parse_input + shell: bash + run: | + use_extensibility_input="${{ inputs.use_extensibility }}" + if [ "use_extensibility_input" = "true" ]; then + use_extensibility_value=2 + else + use_extensibility_value=1 + fi + echo "USE_EXTENSIBILITY_VALUE=$use_extensibility_value" >> $GITHUB_ENV + + is_private_client_input="${{ inputs.is_private_client }}" + if [ "$is_private_client_input" = "true" ]; then + is_private_client_value=1 + else + is_private_client_value=2 + fi + echo "IS_PRIVATE_CLIENT_VALUE=$is_private_client_value" >> $GITHUB_ENV + + setup_hybrid_input="${{ inputs.setup_hybrid }}" + if [ "$setup_hybrid_input" = "true" ]; then + setup_hybrid_value=2 + else + setup_hybrid_value=1 + fi + echo "SETUP_HYBRID_VALUE=$setup_hybrid_value" >> $GITHUB_ENV + + - name: Build project generator inputs + id: build_generator_inputs + shell: bash + run: | + echo '{ + "projectDir":"${{ inputs.project_dir }}", + "responses": [ + { + "expectedPrompt": "Choose a project preset to get started:", + "response": "1\n" + }, + { + "expectedPrompt": "Do you wish to use template extensibility?", + "response": "${{ env.USE_EXTENSIBILITY_VALUE }}\n" + }, + { + "expectedPrompt": "What is the name of your Project?", + "response": "${{ inputs.project_id }}\n" + }, + { + "expectedPrompt": "What is the URL for your Commerce Cloud instance?", + "response": "${{ inputs.instance_url }}\n" + }, + { + "expectedPrompt": "What is your SLAS Client ID?", + "response": "${{ inputs.client_id }}\n" + }, + { + "expectedPrompt": "Is your SLAS client private?", + "response":"${{ env.IS_PRIVATE_CLIENT_VALUE }}\n" + }, + { + "expectedPrompt": "What is your Site ID in Business Manager?", + "response": "${{ inputs.site_id }}\n" + }, + { + "expectedPrompt": "What is your Commerce API organization ID in Business Manager?", + "response": "${{ inputs.org_id }}\n" + }, + { + "expectedPrompt": "What is your Commerce API short code in Business Manager?", + "response": "${{ inputs.short_code }}\n" + }, + { + "expectedPrompt": "Do you wish to set up a phased headless rollout?", + "response": "${{ env.SETUP_HYBRID_VALUE }}\n" + } + ] }' > generator-responses.json + + - name: Generate project + id: generate_project + run: | + cat generator-responses.json + node e2e/scripts/generate-project.js --project-config "$(jq -c . generator-responses.json)" + shell: bash + + - name: Build generated project + id: build_generated_project + working-directory: ../generated-projects/${{ inputs.project_dir }} + run: |- + npm ci + npm run build + shell: bash diff --git a/.github/workflows/setup_pwa_manual.yml b/.github/workflows/setup_pwa_manual.yml new file mode 100644 index 0000000000..596444de21 --- /dev/null +++ b/.github/workflows/setup_pwa_manual.yml @@ -0,0 +1,94 @@ +name: SalesforceCommerceCloud/pwa-kit/setup_pwa_manual +on: + workflow_dispatch: + inputs: + use_extensibility: + type: boolean + description: Use Extensibility? + default: true + project_id: + type: string + description: Project ID/Name + default: "scaffold-pwa" + instance_url: + type: string + description: Instance Url + org_id: + type: string + description: Org Id + short_code: + type: string + description: Short Code + client_id: + type: string + description: Client Id + site_id: + type: string + description: Site Id + mrt_target_id: + type: string + description: MRT Target ID/Name + is_private_client: + type: boolean + description: Is Private Client? + setup_hybrid: + type: boolean + description: Setup Phased Headless rollout? + default: false + +jobs: + setup-pwa-kit: + runs-on: ubuntu-latest + steps: + - name: Initialize + id: initialize + run: | + set -e + echo "PROJECT_DIR=my-retail-react-app" >> $GITHUB_ENV + + - name: Checkout + id: checkout + uses: actions/checkout@v3 + + - name: Setup node + id: setup_node + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: "npm" + + - name: Setup PWA dependencies + id: setup_pwa_dependencies + run: |- + set -e + sudo apt-get update -yq && sudo apt-get install --no-install-recommends python2 python3-pip time -yq + sudo pip install -U pip setuptools + sudo pip install awscli==1.18.85 datadog==0.40.1 + node ./scripts/gtime.js monorepo_install npm ci + npm ci + shell: bash + + - name: Generate app + id: generate_app + uses: "./.github/actions/generate_app" + with: + use_extensibility: ${{ github.event.inputs.use_extensibility }} + project_id: ${{ github.event.inputs.project_id }} + instance_url: ${{ github.event.inputs.instance_url }} + org_id: ${{ github.event.inputs.org_id }} + short_code: ${{ github.event.inputs.short_code }} + client_id: ${{ github.event.inputs.client_id }} + site_id: ${{ github.event.inputs.site_id }} + is_private_client: ${{ github.event.inputs.is_private_client }} + setup_hybrid: ${{ github.event.inputs.setup_hybrid }} + project_dir: ${{ env.PROJECT_DIR }} + + - name: Deploy app + id: deploy_app + uses: "./.github/actions/deploy_app" + with: + project_id: ${{ github.event.inputs.project_id }} + target_id: ${{ github.event.inputs.mrt_target_id }} + project_dir: "../generated-projects/${{ env.PROJECT_DIR }}" + mobify_user: ${{ secrets.MOBIFY_CLIENT_USER }} + mobify_api_key: ${{ secrets.MOBIFY_CLIENT_API_KEY }} diff --git a/.github/workflows/setup_template_retail_react_app_manual.yml b/.github/workflows/setup_template_retail_react_app_manual.yml new file mode 100644 index 0000000000..6f9e47531f --- /dev/null +++ b/.github/workflows/setup_template_retail_react_app_manual.yml @@ -0,0 +1,50 @@ +name: SalesforceCommerceCloud/pwa-kit/setup_template_retail_react_app +on: + workflow_dispatch: + +jobs: + setup-template-retail-react-app: + runs-on: ubuntu-latest + steps: + - name: Initialize + id: initialize + shell: bash + run: | + set -e + echo "PROJECT_ID=scaffold-pwa" >> $GITHUB_ENV + branch_name="${GITHUB_REF##*/}" + # Trim using cut as MRT target name is limited to 19 chars + mrt_target_id=$(echo "$branch_name" | cut -c 1-19) + echo "MRT_TARGET_ID=$mrt_target_id" >> $GITHUB_ENV + + - name: Checkout + id: checkout + uses: actions/checkout@v3 + + - name: Setup node + id: setup_node + uses: actions/setup-node@v4 + with: + node-version: 18 + cache: "npm" + + - name: Setup PWA dependencies + id: setup_pwa_dependencies + run: |- + set -e + sudo apt-get update -yq && sudo apt-get install --no-install-recommends python2 python3-pip time -yq + sudo pip install -U pip setuptools + sudo pip install awscli==1.18.85 datadog==0.40.1 + node ./scripts/gtime.js monorepo_install npm ci + npm ci + shell: bash + + - name: Deploy app + id: deploy_app + uses: "./.github/actions/deploy_app" + with: + project_id: ${{ env.PROJECT_ID }} + target_id: ${{ env.MRT_TARGET_ID }} + project_dir: "./packages/template-retail-react-app" + mobify_user: ${{ secrets.MOBIFY_CLIENT_USER }} + mobify_api_key: ${{ secrets.MOBIFY_CLIENT_API_KEY }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0e777dd6f5..7e226ea9f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,7 +37,6 @@ jobs: - name: Changelog Check uses: ./.github/actions/changelog-check with: - github_token: ${{ secrets.GITHUB_TOKEN }} pr_number: ${{ github.event.pull_request.number }} pwa-kit: diff --git a/e2e/scripts/generate-project.js b/e2e/scripts/generate-project.js index cb17133b6d..ea33e29d95 100644 --- a/e2e/scripts/generate-project.js +++ b/e2e/scripts/generate-project.js @@ -6,52 +6,81 @@ */ const { runGeneratorWithResponses } = require("./execute-shell-commands.js"); const config = require("../config.js"); -const { program, Argument } = require("commander"); +const { program } = require("commander"); const { mkdirIfNotExists } = require("./utils.js"); const main = async (opts) => { - const { args } = opts; - const [project] = args; - if (opts.args.length !== 1) { + const { projectKey, projectConfig } = opts; + + if (!projectKey && !projectConfig) { + console.error("You must provide either or ."); console.log(program.helpInformation()); process.exit(1); } try { + let cliResponses = []; + let projectDir = projectKey; + let preset; + if (projectKey) { + cliResponses = config.CLI_RESPONSES[projectKey]; + preset = config.PRESET[projectKey]; + } else { + projectDir = projectConfig["projectDir"]; + let cliResponsesJsonArr = projectConfig["responses"]; + cliResponsesJsonArr.forEach((item) => { + cliResponses.push({ + expectedPrompt: new RegExp(item.expectedPrompt, "i"), + response: item.response, + }); + }); + } + // Explicitly create outputDir because generator runs into permissions issue when generating no-ext projects. await mkdirIfNotExists(config.GENERATED_PROJECTS_DIR); - const outputDir = `${config.GENERATED_PROJECTS_DIR}/${project}`; - // TODO: Update script to setup local verdaccio npm repo to allow running 'npx @salesforce/pwa-kit-create-app' to generate apps + const outputDir = `${config.GENERATED_PROJECTS_DIR}/${projectDir}`; let generateAppCommand = `${config.GENERATOR_CMD} ${outputDir}`; - const preset = config.PRESET[project]; - + // TODO: Update script to setup local verdaccio npm repo to allow running 'npx @salesforce/pwa-kit-create-app' to generate apps if (preset) { - generateAppCommand = `${config.GENERATOR_CMD} ${outputDir} --preset ${preset}` + generateAppCommand = `${config.GENERATOR_CMD} ${outputDir} --preset ${preset}`; } - - const stdout = await runGeneratorWithResponses( - generateAppCommand, - config.CLI_RESPONSES[project] - ); - return stdout; + return await runGeneratorWithResponses(generateAppCommand, cliResponses); } catch (err) { // Generator failed to create project - console.log("Generator failed to create project", err); + console.error("Generator failed to create project", err); process.exit(1); } }; -program.description( - `Generate a retail-react-app project using the key ` -); - -program.addArgument( - new Argument("", "project key").choices([ - "retail-app-demo", - "retail-app-private-client", - ]) -); +// Define the program with description and arguments +program + .description( + "Generate a retail-react-app project using the key or the JSON " + ) + .option("--project-key ", "Project key", (value) => { + const validKeys = [ + "retail-app-demo", + "retail-app-private-client", + ]; + if (!validKeys.includes(value)) { + throw new Error("Invalid project key."); + } + return value; + }) + .option( + "--project-config ", + "Project config as JSON string", + (value) => { + try { + return JSON.parse(value); + } catch (e) { + throw new Error("Invalid JSON string."); + } + } + ) + .action((options) => { + // Call the main function with parsed options + main(options); + }); program.parse(process.argv); - -main(program); diff --git a/e2e/scripts/pageHelpers.js b/e2e/scripts/pageHelpers.js new file mode 100644 index 0000000000..b67b4a6e6c --- /dev/null +++ b/e2e/scripts/pageHelpers.js @@ -0,0 +1,367 @@ +const { expect } = require("@playwright/test"); +const config = require("../config"); +const { getCreditCardExpiry } = require("../scripts/utils.js") + +/** + * Navigates to the `Cotton Turtleneck Sweater` PDP (Product Detail Page) on mobile + * with the black variant selected + * + * @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright + */ +export const navigateToPDPMobile = async ({page}) => { + // Home page + await page.goto(config.RETAIL_APP_HOME); + + await page.getByLabel("Menu", { exact: true }).click(); + + // SSR nav loads top level categories as direct links so we wait till all sub-categories load in the accordion + const categoryAccordion = page.locator( + "#category-nav .chakra-accordion__button svg+:text('Womens')" + ); + await categoryAccordion.waitFor(); + + await page.getByRole("button", { name: "Womens" }).click(); + + const clothingNav = page.getByRole("button", { name: "Clothing" }); + + await clothingNav.waitFor(); + + await clothingNav.click(); + + const topsLink = page.getByLabel('Womens').getByRole("link", { name: "Tops" }); + await topsLink.click(); + // Wait for the nav menu to close first + await topsLink.waitFor({state: 'hidden'}) + + await expect(page.getByRole("heading", { name: "Tops" })).toBeVisible(); + + // PLP + const productTile = page.getByRole("link", { + name: /Cotton Turtleneck Sweater/i, + }); + await productTile.scrollIntoViewIfNeeded() + // selecting swatch + const productTileImg = productTile.locator("img"); + await productTileImg.waitFor({state: 'visible'}) + const initialSrc = await productTileImg.getAttribute("src"); + await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible(); + + await productTile.getByLabel(/Black/, { exact: true }).click(); + // Make sure the image src has changed + await expect(async () => { + const newSrc = await productTileImg.getAttribute("src") + expect(newSrc).not.toBe(initialSrc) + }).toPass() + await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible(); + await productTile.click(); +} + +/** + * Navigates to the `Cotton Turtleneck Sweater` PDP (Product Detail Page) on Desktop + * with the black variant selected. + * + * @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright + */ +export const navigateToPDPDesktop = async ({page}) => { + await page.goto(config.RETAIL_APP_HOME); + + await page.getByRole("link", { name: "Womens" }).hover(); + const topsNav = await page.getByRole("link", { name: "Tops", exact: true }); + await expect(topsNav).toBeVisible(); + + await topsNav.click(); + + // PLP + const productTile = page.getByRole("link", { + name: /Cotton Turtleneck Sweater/i, + }); + // selecting swatch + const productTileImg = productTile.locator("img"); + await productTileImg.waitFor({state: 'visible'}) + const initialSrc = await productTileImg.getAttribute("src"); + await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible(); + + await productTile.getByLabel(/Black/, { exact: true }).hover(); + // Make sure the image src has changed + await expect(async () => { + const newSrc = await productTileImg.getAttribute("src") + expect(newSrc).not.toBe(initialSrc) + }).toPass() + await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible(); + await productTile.click(); +} + +/** + * Adds the `Cotton Turtleneck Sweater` product to the cart with the variant: + * Color: Black + * Size: L + * + * @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright + * @param {Boolean} options.isMobile - Flag to indicate if device type is mobile or not, defaulted to false + */ +export const addProductToCart = async ({page, isMobile = false}) => { + // Navigate to Cotton Turtleneck Sweater with Black color variant selected + if(isMobile) { + await navigateToPDPMobile({page}) + } else { + await navigateToPDPDesktop({page}) + } + + // PDP + await expect( + page.getByRole("heading", { name: /Cotton Turtleneck Sweater/i }) + ).toBeVisible(); + await page.getByRole("radio", { name: "L", exact: true }).click(); + + await page.locator("button[data-testid='quantity-increment']").click(); + + // Selected Size and Color texts are broken into multiple elements on the page. + // So we need to look at the page URL to verify selected variants + const updatedPageURL = await page.url(); + const params = updatedPageURL.split("?")[1]; + expect(params).toMatch(/size=9LG/i); + expect(params).toMatch(/color=JJ169XX/i); + await page.getByRole("button", { name: /Add to Cart/i }).click(); + + const addedToCartModal = page.getByText(/2 items added to cart/i); + + await addedToCartModal.waitFor(); + + await page.getByLabel("Close").click(); +} + +/** + * Registers a shopper with provided user credentials + * + * @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright + * @param {Object} options.userCredentials - Object containing user credentials with the following properties: + * - firstName + * - lastName + * - email + * - password + * @param {Boolean} options.isMobile - flag to indicate if device type is mobile or not, defaulted to false + */ +export const registerShopper = async ({page, userCredentials, isMobile = false}) => { + // Create Account and Sign In + await page.goto(config.RETAIL_APP_HOME + "/registration"); + + await page.waitForLoadState(); + + const registrationFormHeading = page.getByText(/Let's get started!/i); + await registrationFormHeading.waitFor(); + + await page + .locator("input#firstName") + .fill(userCredentials.firstName); + await page + .locator("input#lastName") + .fill(userCredentials.lastName); + await page.locator("input#email").fill(userCredentials.email); + await page + .locator("input#password") + .fill(userCredentials.password); + + await page.getByRole("button", { name: /Create Account/i }).click(); + + await page.waitForLoadState(); + + await expect( + page.getByRole("heading", { name: /Account Details/i }) + ).toBeVisible(); + + if(!isMobile) { + await expect( + page.getByRole("heading", { name: /My Account/i }) + ).toBeVisible(); + } + + await expect(page.getByText(/Email/i)).toBeVisible(); + await expect(page.getByText(userCredentials.email)).toBeVisible(); +} + +/** + * Validates that the `Cotton Turtleneck Sweater` product appears in the Order History page + * + * @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright + */ +export const validateOrderHistory = async ({page}) => { + await page.goto(config.RETAIL_APP_HOME + "/account/orders"); + await expect( + page.getByRole("heading", { name: /Order History/i }) + ).toBeVisible(); + + await page.getByRole('link', { name: 'View details' }).click(); + + await expect( + page.getByRole("heading", { name: /Order Details/i }) + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: /Cotton Turtleneck Sweater/i }) + ).toBeVisible(); + await expect(page.getByText(/Color: Black/i)).toBeVisible(); + await expect(page.getByText(/Size: L/i)).toBeVisible(); +} + +/** + * Validates that the `Cotton Turtleneck Sweater` product appears in the Wishlist page + * + * @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright + */ +export const validateWishlist = async ({page}) => { + await page.goto(config.RETAIL_APP_HOME + "/account/wishlist"); + + await expect( + page.getByRole("heading", { name: /Wishlist/i }) + ).toBeVisible(); + + await expect( + page.getByRole("heading", { name: /Cotton Turtleneck Sweater/i }) + ).toBeVisible(); + await expect(page.getByText(/Color: Black/i)).toBeVisible() + await expect(page.getByText(/Size: L/i)).toBeVisible() +} + +/** + * Attempts to log in a shopper with provided user credentials. + * + * @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright + * @param {Object} options.userCredentials - Object containing user credentials with the following properties: + * - firstName + * - lastName + * - email + * - password + * + * @return {Boolean} - denotes whether or not login was successful + */ +export const loginShopper = async ({page, userCredentials}) => { + try { + await page.goto(config.RETAIL_APP_HOME + "/login"); + await page.locator("input#email").fill(userCredentials.email); + await page + .locator("input#password") + .fill(userCredentials.password); + await page.getByRole("button", { name: /Sign In/i }).click(); + + await page.waitForLoadState(); + + // redirected to Account Details page after logging in + await expect( + page.getByRole("heading", { name: /Account Details/i }) + ).toBeVisible({ timeout: 2000 }); + return true; + } catch { + return false; + } +} + +/** + * Search for products by query string that takes you to the PLP + * + * @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright + * @param {String} options.query - Product name other product related descriptors to search for + * @param {Object} options.isMobile - Flag to indicate if device type is mobile or not, defaulted to false + */ +export const searchProduct = async ({page, query, isMobile = false}) => { + await page.goto(config.RETAIL_APP_HOME); + + // For accessibility reasons, we have two search bars + // one for desktop and one for mobile depending on your device type + const searchInputs = page.locator('input[aria-label="Search for products..."]'); + + let searchInput = isMobile ? searchInputs.nth(1) : searchInputs.nth(0); + await searchInput.fill(query); + await searchInput.press('Enter'); + + await page.waitForLoadState(); +} + +/** + * Checkout products that are in the cart + * + * @param {Object} options.page - Object that represents a tab/window in the browser provided by playwright + * @param {Object} options.userCredentials - Object containing user credentials with the following properties: + * - firstName + * - lastName + * - email + * - password + */ +export const checkoutProduct = async ({ page, userCredentials }) => { + await page.getByRole("link", { name: "Proceed to Checkout" }).click(); + + await expect( + page.getByRole("heading", { name: /Contact Info/i }) + ).toBeVisible(); + + await page.locator("input#email").fill("test@gmail.com"); + + await page.getByRole("button", { name: /Checkout as guest/i }).click(); + + // Confirm the email input toggles to show edit button on clicking "Checkout as guest" + const step0Card = page.locator("div[data-testid='sf-toggle-card-step-0']"); + + await expect(step0Card.getByRole("button", { name: /Edit/i })).toBeVisible(); + + await expect( + page.getByRole("heading", { name: /Shipping Address/i }) + ).toBeVisible(); + + await page.locator("input#firstName").fill(userCredentials.firstName); + await page.locator("input#lastName").fill(userCredentials.lastName); + await page.locator("input#phone").fill(userCredentials.phone); + await page + .locator("input#address1") + .fill(userCredentials.address.street); + await page.locator("input#city").fill(userCredentials.address.city); + await page + .locator("select#stateCode") + .selectOption(userCredentials.address.state); + await page + .locator("input#postalCode") + .fill(userCredentials.address.zipcode); + + await page + .getByRole("button", { name: /Continue to Shipping Method/i }) + .click(); + + // Confirm the shipping details form toggles to show edit button on clicking "Checkout as guest" + const step1Card = page.locator("div[data-testid='sf-toggle-card-step-1']"); + + await expect(step1Card.getByRole("button", { name: /Edit/i })).toBeVisible(); + + await expect( + page.getByRole("heading", { name: /Shipping & Gift Options/i }) + ).toBeVisible(); + + try { + // sometimes the shipping & gifts section gets skipped + // so there is no 'Continue to payment' button available + const continueToPayment = page.getByRole("button", { + name: /Continue to Payment/i + }); + await expect(continueToPayment).toBeVisible({ timeout: 2000 }); + await continueToPayment.click(); + } catch { + + } + + await expect(page.getByRole("heading", { name: /Payment/i })).toBeVisible(); + const creditCardExpiry = getCreditCardExpiry(); + + await page.locator("input#number").fill("4111111111111111"); + await page.locator("input#holder").fill("John Doe"); + await page.locator("input#expiry").fill(creditCardExpiry); + await page.locator("input#securityCode").fill("213"); + + await page.getByRole("button", { name: /Review Order/i }).click(); + + page + .getByRole("button", { name: /Place Order/i }) + .first() + .click(); + + // order confirmation + const orderConfirmationHeading = page.getByRole("heading", { + name: /Thank you for your order!/i, + }); + await orderConfirmationHeading.waitFor(); +} diff --git a/e2e/tests/desktop/guest-shopper.spec.js b/e2e/tests/desktop/guest-shopper.spec.js index 41b76d5a44..29dcf7c325 100644 --- a/e2e/tests/desktop/guest-shopper.spec.js +++ b/e2e/tests/desktop/guest-shopper.spec.js @@ -6,153 +6,125 @@ */ const { test, expect } = require("@playwright/test"); -const config = require("../../config"); const { generateUserCredentials, - getCreditCardExpiry, } = require("../../scripts/utils.js"); +const { addProductToCart, searchProduct, checkoutProduct } = require("../../scripts/pageHelpers.js") const GUEST_USER_CREDENTIALS = generateUserCredentials(); +/** + * Test that guest shoppers can add a product to cart and go through the entire checkout process, + * validating that shopper is able to get to the order summary section + */ test("Guest shopper can checkout items as guest", async ({ page }) => { - // home page - await page.goto(config.RETAIL_APP_HOME); - - await page.getByRole("link", { name: "Womens" }).hover(); - const topsNav = await page.getByRole("link", { name: "Tops", exact: true }); - await expect(topsNav).toBeVisible(); - - await topsNav.click(); - // PLP - const productTile = page.getByRole("link", { - name: /Cotton Turtleneck Sweater/i, - }); - // selecting swatch - const productTileImg = productTile.locator("img"); - await productTileImg.waitFor({state: 'visible'}) - const initialSrc = await productTileImg.getAttribute("src"); - await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible(); - - await productTile.getByLabel(/Black/, { exact: true }).hover(); - // Make sure the image src has changed - await expect(async () => { - const newSrc = await productTileImg.getAttribute("src") - expect(newSrc).not.toBe(initialSrc) - }).toPass() - await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible(); - await productTile.click(); - - // PDP + await addProductToCart({page}) + + // cart + await page.getByLabel(/My cart/i).click(); + await expect( - page.getByRole("heading", { name: /Cotton Turtleneck Sweater/i }) + page.getByRole("link", { name: /Cotton Turtleneck Sweater/i }) ).toBeVisible(); - await page.getByRole("radio", { name: "L", exact: true }).click(); - - await page.locator("button[data-testid='quantity-increment']").click(); - // // Selected Size and Color texts are broken into multiple elements on the page. - // // So we need to look at the page URL to verify selected variants - const updatedPageURL = await page.url(); - const params = updatedPageURL.split("?")[1]; - expect(params).toMatch(/size=9LG/i); - expect(params).toMatch(/color=JJ169XX/i); - await page.getByRole("button", { name: /Add to Cart/i }).click(); + await checkoutProduct({page, userCredentials: GUEST_USER_CREDENTIALS }); - const addedToCartModal = page.getByText(/2 items added to cart/i); + await expect( + page.getByRole("heading", { name: /Order Summary/i }) + ).toBeVisible(); + await expect(page.getByText(/2 Items/i)).toBeVisible(); + await expect( + page.getByRole("link", { name: /Cotton Turtleneck Sweater/i }) + ).toBeVisible(); +}); - await addedToCartModal.waitFor(); +/** + * Test that guest shoppers can use the product edit modal on cart page + */ +test("Guest shopper can edit product item in cart", async ({ page }) => { + await addProductToCart({page}); - await page.getByLabel("Close").click(); // cart await page.getByLabel(/My cart/i).click(); + await page.waitForLoadState(); await expect( page.getByRole("link", { name: /Cotton Turtleneck Sweater/i }) ).toBeVisible(); - await page.getByRole("link", { name: "Proceed to Checkout" }).click(); + await expect(page.getByText(/Color: Black/i)).toBeVisible(); + await expect(page.getByText(/Size: L/i)).toBeVisible(); - // checkout - await expect( - page.getByRole("heading", { name: /Contact Info/i }) - ).toBeVisible(); + // open product edit modal + const editBtn = page.getByRole("button", { name: /Edit/i }); + await editBtn.waitFor(); + + expect(editBtn).toBeAttached(); - await page.locator("input#email").fill("test@gmail.com"); + await editBtn.click(); + await page.waitForLoadState(); - await page.getByRole("button", { name: /Checkout as guest/i }).click(); + // Product edit modal should be open + await expect(page.getByTestId('product-view')).toBeVisible(); + + await page.getByRole("radio", { name: "S", exact: true }).click(); + await page.getByRole("radio", { name: "Meadow Violet", exact: true }).click(); + await page.getByRole("button", { name: /Update/i }).click(); - // Confirm the email input toggles to show edit button on clicking "Checkout as guest" - const step0Card = page.locator("div[data-testid='sf-toggle-card-step-0']"); + await page.waitForLoadState(); + await expect(page.getByText(/Color: Meadow Violet/i)).toBeVisible(); + await expect(page.getByText(/Size: S/i)).toBeVisible(); +}); - await expect(step0Card.getByRole("button", { name: /Edit/i })).toBeVisible(); +/** + * Test that guest shoppers can add product bundle to cart and successfully checkout + */ +test("Guest shopper can checkout product bundle", async ({ page }) => { + await searchProduct({page, query: 'bundle'}); - await expect( - page.getByRole("heading", { name: /Shipping Address/i }) - ).toBeVisible(); + await page.getByRole("link", { + name: /Turquoise Jewelry Bundle/i, + }).click(); - await page.locator("input#firstName").fill(GUEST_USER_CREDENTIALS.firstName); - await page.locator("input#lastName").fill(GUEST_USER_CREDENTIALS.lastName); - await page.locator("input#phone").fill(GUEST_USER_CREDENTIALS.phone); - await page - .locator("input#address1") - .fill(GUEST_USER_CREDENTIALS.address.street); - await page.locator("input#city").fill(GUEST_USER_CREDENTIALS.address.city); - await page - .locator("select#stateCode") - .selectOption(GUEST_USER_CREDENTIALS.address.state); - await page - .locator("input#postalCode") - .fill(GUEST_USER_CREDENTIALS.address.zipcode); - - await page - .getByRole("button", { name: /Continue to Shipping Method/i }) - .click(); - - // Confirm the shipping details form toggles to show edit button on clicking "Checkout as guest" - const step1Card = page.locator("div[data-testid='sf-toggle-card-step-1']"); - - await expect(step1Card.getByRole("button", { name: /Edit/i })).toBeVisible(); + await page.waitForLoadState(); await expect( - page.getByRole("heading", { name: /Shipping & Gift Options/i }) + page.getByRole("heading", { name: /Turquoise Jewelry Bundle/i }) ).toBeVisible(); - await page.waitForTimeout(2000); - const continueToPayment = page.getByRole("button", { - name: /Continue to Payment/i, - }); + await page.getByRole("button", { name: /Add Bundle to Cart/i }).click(); - if (continueToPayment.isEnabled()) { - await continueToPayment.click(); - } - - await expect(page.getByRole("heading", { name: /Payment/i })).toBeVisible(); + const addedToCartModal = page.getByText(/1 item added to cart/i); + await addedToCartModal.waitFor(); + await page.getByLabel("Close").click(); - const creditCardExpiry = getCreditCardExpiry(); + await page.getByLabel(/My cart/i).click(); + await page.waitForLoadState(); - await page.locator("input#number").fill("4111111111111111"); - await page.locator("input#holder").fill("John Doe"); - await page.locator("input#expiry").fill(creditCardExpiry); - await page.locator("input#securityCode").fill("213"); + await expect( + page.getByRole("heading", { name: /Turquoise Jewelry Bundle/i }) + ).toBeVisible(); - await page.getByRole("button", { name: /Review Order/i }).click(); + // bundle child selections with all color gold + await expect(page.getByText(/Turquoise and Gold Bracelet/i)).toBeVisible(); + await expect(page.getByText(/Turquoise and Gold Necklace/i)).toBeVisible(); + await expect(page.getByText(/Turquoise and Gold Hoop Earring/i)).toBeVisible(); - page - .getByRole("button", { name: /Place Order/i }) - .first() - .click(); + const qtyText = page.locator('text="Qty: 1"'); + const colorGoldText = page.locator('text="Color: Gold"'); + await expect(colorGoldText).toHaveCount(3); + await expect(qtyText).toHaveCount(3); - // order confirmation - const orderConfirmationHeading = page.getByRole("heading", { - name: /Thank you for your order!/i, - }); - await orderConfirmationHeading.waitFor(); + await checkoutProduct({page, userCredentials: GUEST_USER_CREDENTIALS }); await expect( page.getByRole("heading", { name: /Order Summary/i }) ).toBeVisible(); - await expect(page.getByText(/2 Items/i)).toBeVisible(); + await expect(page.getByText(/1 Item/i)).toBeVisible(); await expect( - page.getByRole("link", { name: /Cotton Turtleneck Sweater/i }) + page.getByRole("link", { name: /Turquoise Jewelry Bundle/i }) ).toBeVisible(); + await expect(page.getByText(/Turquoise and Gold Bracelet/i)).toBeVisible(); + await expect(page.getByText(/Turquoise and Gold Necklace/i)).toBeVisible(); + await expect(page.getByText(/Turquoise and Gold Hoop Earring/i)).toBeVisible(); }); diff --git a/e2e/tests/desktop/registered-shopper.spec.js b/e2e/tests/desktop/registered-shopper.spec.js index 1fea7028c6..c1c34afa17 100644 --- a/e2e/tests/desktop/registered-shopper.spec.js +++ b/e2e/tests/desktop/registered-shopper.spec.js @@ -7,6 +7,14 @@ const { test, expect } = require("@playwright/test"); const config = require("../../config"); +const { + addProductToCart, + registerShopper, + validateOrderHistory, + validateWishlist, + loginShopper, + navigateToPDPDesktop, +} = require("../../scripts/pageHelpers"); const { generateUserCredentials, getCreditCardExpiry, @@ -14,86 +22,18 @@ const { const REGISTERED_USER_CREDENTIALS = generateUserCredentials(); +/** + * Test that registered shoppers can add a product to cart and go through the entire checkout process, + * validating that shopper is able to get to the order summary section, + * and that order shows up in order history + */ test("Registered shopper can checkout items", async ({ page }) => { - // Create Account and Sign In - await page.goto(config.RETAIL_APP_HOME + "/registration"); - - const registrationFormHeading = page.getByText(/Let's get started!/i); - await registrationFormHeading.waitFor(); - - await page - .locator("input#firstName") - .fill(REGISTERED_USER_CREDENTIALS.firstName); - await page - .locator("input#lastName") - .fill(REGISTERED_USER_CREDENTIALS.lastName); - await page.locator("input#email").fill(REGISTERED_USER_CREDENTIALS.email); - await page - .locator("input#password") - .fill(REGISTERED_USER_CREDENTIALS.password); - - await page.getByRole("button", { name: /Create Account/i }).click(); - - await expect( - page.getByRole("heading", { name: /Account Details/i }) - ).toBeVisible(); - - await expect( - page.getByRole("heading", { name: /My Account/i }) - ).toBeVisible(); - - await expect(page.getByText(/Email/i)).toBeVisible(); - await expect(page.getByText(REGISTERED_USER_CREDENTIALS.email)).toBeVisible(); + // register and login user + await registerShopper({page, userCredentials: REGISTERED_USER_CREDENTIALS}); // Shop for items as registered user - await page.goto(config.RETAIL_APP_HOME); - - await page.getByRole("link", { name: "Womens" }).hover(); - const topsNav = await page.getByRole("link", { name: "Tops", exact: true }); - await expect(topsNav).toBeVisible(); - - await topsNav.click(); + await addProductToCart({page}); - // PLP - const productTile = page.getByRole("link", { - name: /Cotton Turtleneck Sweater/i, - }); - // selecting swatch - const productTileImg = productTile.locator("img"); - await productTileImg.waitFor({state: 'visible'}) - const initialSrc = await productTileImg.getAttribute("src"); - await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible(); - - await productTile.getByLabel(/Black/, { exact: true }).hover(); - // Make sure the image src has changed - await expect(async () => { - const newSrc = await productTileImg.getAttribute("src") - expect(newSrc).not.toBe(initialSrc) - }).toPass() - await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible(); - await productTile.click(); - - // PDP - await expect( - page.getByRole("heading", { name: /Cotton Turtleneck Sweater/i }) - ).toBeVisible(); - await page.getByRole("radio", { name: "L", exact: true }).click(); - - await page.locator("button[data-testid='quantity-increment']").click(); - - // // Selected Size and Color texts are broken into multiple elements on the page. - // // So we need to look at the page URL to verify selected variants - const updatedPageURL = await page.url(); - const params = updatedPageURL.split("?")[1]; - expect(params).toMatch(/size=9LG/i); - expect(params).toMatch(/color=JJ169XX/i); - await page.getByRole("button", { name: /Add to Cart/i }).click(); - - const addedToCartModal = page.getByText(/2 items added to cart/i); - - await addedToCartModal.waitFor(); - - await page.getByLabel("Close").click(); // cart await page.getByLabel(/My cart/i).click(); @@ -146,7 +86,7 @@ test("Registered shopper can checkout items", async ({ page }) => { await expect( page.getByRole("heading", { name: /Shipping & Gift Options/i }) ).toBeVisible(); - await page.waitForTimeout(2000); + await page.waitForLoadState(); const continueToPayment = page.getByRole("button", { name: /Continue to Payment/i, @@ -193,4 +133,35 @@ test("Registered shopper can checkout items", async ({ page }) => { await expect( page.getByRole("link", { name: /Cotton Turtleneck Sweater/i }) ).toBeVisible(); + + // order history + await validateOrderHistory({page}); +}); + +/** + * Test that registered shoppers can navigate to PDP and add a product to wishlist + */ +test("Registered shopper can add item to wishlist", async ({ page }) => { + const isLoggedIn = await loginShopper({ + page, + userCredentials: REGISTERED_USER_CREDENTIALS + }) + + if(!isLoggedIn) { + await registerShopper({page, userCredentials: REGISTERED_USER_CREDENTIALS}) + } + + // Navigate to PDP + await navigateToPDPDesktop({page}); + + // add product to wishlist + await expect( + page.getByRole("heading", { name: /Cotton Turtleneck Sweater/i }) + ).toBeVisible(); + + await page.getByRole("radio", { name: "L", exact: true }).click(); + await page.getByRole("button", { name: /Add to Wishlist/i }).click() + + // wishlist + await validateWishlist({page}) }); diff --git a/e2e/tests/mobile/guest-shopper.spec.js b/e2e/tests/mobile/guest-shopper.spec.js index 6d45f51eaa..38dbc849ed 100644 --- a/e2e/tests/mobile/guest-shopper.spec.js +++ b/e2e/tests/mobile/guest-shopper.spec.js @@ -7,6 +7,7 @@ const { test, expect } = require("@playwright/test"); const config = require("../../config"); +const { addProductToCart, searchProduct, checkoutProduct } = require("../../scripts/pageHelpers"); const { generateUserCredentials, getCreditCardExpiry, @@ -14,75 +15,12 @@ const { const GUEST_USER_CREDENTIALS = generateUserCredentials(); +/** + * Test that guest shoppers can add a product to cart and go through the entire checkout process, + * validating that shopper is able to get to the order summary section + */ test("Guest shopper can checkout items as guest", async ({ page }) => { - // Home page - await page.goto(config.RETAIL_APP_HOME); - - await page.getByLabel("Menu", { exact: true }).click(); - - // SSR nav loads top level categories as direct links so we wait till all sub-categories load in the accordion - const categoryAccordion = page.locator( - "#category-nav .chakra-accordion__button svg+:text('Womens')" - ); - await categoryAccordion.waitFor(); - - await page.getByRole("button", { name: "Womens" }).click(); - - const clothingNav = page.getByRole("button", { name: "Clothing" }); - - await clothingNav.waitFor(); - - await clothingNav.click(); - - const topsLink = page.getByLabel('Womens').getByRole("link", { name: "Tops" }) - await topsLink.click(); - // Wait for the nav menu to close first - await topsLink.waitFor({state: 'hidden'}) - - await expect(page.getByRole("heading", { name: "Tops" })).toBeVisible(); - - // PLP - const productTile = page.getByRole("link", { - name: /Cotton Turtleneck Sweater/i, - }); - await productTile.scrollIntoViewIfNeeded() - // selecting swatch - const productTileImg = productTile.locator("img"); - await productTileImg.waitFor({state: 'visible'}) - const initialSrc = await productTileImg.getAttribute("src"); - await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible(); - - await productTile.getByLabel(/Black/, { exact: true }).click(); - // Make sure the image src has changed - await expect(async () => { - const newSrc = await productTileImg.getAttribute("src") - expect(newSrc).not.toBe(initialSrc) - }).toPass() - await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible(); - await productTile.click(); - - // PDP - await expect( - page.getByRole("heading", { name: /Cotton Turtleneck Sweater/i }) - ).toBeVisible(); - await page.getByRole("radio", { name: "L", exact: true }).click(); - - await page.locator("button[data-testid='quantity-increment']").click(); - - // Selected Size and Color texts are broken into multiple elements on the page. - // So we need to look at the page URL to verify selected variants - const updatedPageURL = await page.url(); - const params = updatedPageURL.split("?")[1]; - expect(params).toMatch(/size=9LG/i); - expect(params).toMatch(/color=JJ169XX/i); - - await page.getByRole("button", { name: /Add to Cart/i }).click(); - - const addedToCartModal = page.getByText(/2 items added to cart/i); - - await addedToCartModal.waitFor(); - - await page.getByLabel("Close").click(); + await addProductToCart({page, isMobile: true}) // Cart await page.getByLabel(/My cart/i).click(); @@ -137,7 +75,7 @@ test("Guest shopper can checkout items as guest", async ({ page }) => { await expect( page.getByRole("heading", { name: /Shipping & Gift Options/i }) ).toBeVisible(); - await page.waitForTimeout(2000); + await page.waitForLoadState(); const continueToPayment = page.getByRole("button", { name: /Continue to Payment/i, @@ -181,3 +119,84 @@ test("Guest shopper can checkout items as guest", async ({ page }) => { page.getByRole("link", { name: /Cotton Turtleneck Sweater/i }) ).toBeVisible(); }); + +/** + * Test that guest shoppers can use the product edit modal on cart page + */ +test("Guest shopper can edit product item in cart", async ({ page }) => { + await addProductToCart({page, isMobile: true}); + + // Cart + await page.getByLabel(/My cart/i).click(); + + await expect( + page.getByRole("link", { name: /Cotton Turtleneck Sweater/i }) + ).toBeVisible(); + + await expect(page.getByText(/Color: Black/i)).toBeVisible() + await expect(page.getByText(/Size: L/i)).toBeVisible() + + await page.getByRole("button", { name: "Edit" }).click(); + await expect(page.getByTestId('product-view')).toBeVisible() + + // update variant in product edit modal + await page.getByRole("radio", { name: "S", exact: true }).click(); + await page.getByRole("radio", { name: "Meadow Violet", exact: true }).click(); + await page.getByRole("button", { name: /Update/i }).click() + + await expect(page.getByText(/Color: Meadow Violet/i)).toBeVisible() + await expect(page.getByText(/Size: S/i)).toBeVisible() +}); + +/** + * Test that guest shoppers can add product bundle to cart and successfully checkout + */ +test("Guest shopper can checkout product bundle", async ({ page }) => { + await searchProduct({page, query: 'bundle', isMobile: true}); + + await page.getByRole("link", { + name: /Turquoise Jewelry Bundle/i, + }).click(); + + await page.waitForLoadState(); + + await expect( + page.getByRole("heading", { name: /Turquoise Jewelry Bundle/i }) + ).toBeVisible(); + + await page.getByRole("button", { name: /Add Bundle to Cart/i }).click(); + + const addedToCartModal = page.getByText(/1 item added to cart/i); + await addedToCartModal.waitFor(); + await page.getByLabel("Close").click(); + + await page.getByLabel(/My cart/i).click(); + await page.waitForLoadState(); + + await expect( + page.getByRole("heading", { name: /Turquoise Jewelry Bundle/i }) + ).toBeVisible(); + + // bundle child selections with all color gold + await expect(page.getByText(/Turquoise and Gold Bracelet/i)).toBeVisible(); + await expect(page.getByText(/Turquoise and Gold Necklace/i)).toBeVisible(); + await expect(page.getByText(/Turquoise and Gold Hoop Earring/i)).toBeVisible(); + + const qtyText = page.locator('text="Qty: 1"'); + const colorGoldText = page.locator('text="Color: Gold"'); + await expect(colorGoldText).toHaveCount(3); + await expect(qtyText).toHaveCount(3); + + await checkoutProduct({page, userCredentials: GUEST_USER_CREDENTIALS }); + + await expect( + page.getByRole("heading", { name: /Order Summary/i }) + ).toBeVisible(); + await expect(page.getByText(/1 Item/i)).toBeVisible(); + await expect( + page.getByRole("link", { name: /Turquoise Jewelry Bundle/i }) + ).toBeVisible(); + await expect(page.getByText(/Turquoise and Gold Bracelet/i)).toBeVisible(); + await expect(page.getByText(/Turquoise and Gold Necklace/i)).toBeVisible(); + await expect(page.getByText(/Turquoise and Gold Hoop Earring/i)).toBeVisible(); +}); diff --git a/e2e/tests/mobile/registered-shopper.spec.js b/e2e/tests/mobile/registered-shopper.spec.js index 919aba09de..e751a85ea7 100644 --- a/e2e/tests/mobile/registered-shopper.spec.js +++ b/e2e/tests/mobile/registered-shopper.spec.js @@ -7,6 +7,14 @@ const { test, expect } = require("@playwright/test"); const config = require("../../config"); +const { + registerShopper, + addProductToCart, + validateOrderHistory, + validateWishlist, + loginShopper, + navigateToPDPMobile +} = require("../../scripts/pageHelpers"); const { generateUserCredentials, getCreditCardExpiry, @@ -14,100 +22,21 @@ const { const REGISTERED_USER_CREDENTIALS = generateUserCredentials(); +/** + * Test that registered shoppers can add a product to cart and go through the entire checkout process, + * validating that shopper is able to get to the order summary section, + * and that order shows up in order history + */ test("Registered shopper can checkout items", async ({ page }) => { // Create Account and Sign In - await page.goto(config.RETAIL_APP_HOME + "/registration"); - - const registrationFormHeading = page.getByText(/Let's get started!/i); - await registrationFormHeading.waitFor(); - - await page - .locator("input#firstName") - .fill(REGISTERED_USER_CREDENTIALS.firstName); - await page - .locator("input#lastName") - .fill(REGISTERED_USER_CREDENTIALS.lastName); - await page.locator("input#email").fill(REGISTERED_USER_CREDENTIALS.email); - await page - .locator("input#password") - .fill(REGISTERED_USER_CREDENTIALS.password); - - await page.getByRole("button", { name: /Create Account/i }).click(); - - await expect( - page.getByRole("heading", { name: /Account Details/i }) - ).toBeVisible(); - - await expect(page.getByText(/Email/i)).toBeVisible(); - await expect(page.getByText(REGISTERED_USER_CREDENTIALS.email)).toBeVisible(); + await registerShopper({ + page, + userCredentials: REGISTERED_USER_CREDENTIALS, + isMobile: true + }) // Shop for items as registered user - await page.goto(config.RETAIL_APP_HOME); - - await page.getByLabel("Menu", { exact: true }).click(); - - // SSR nav loads top level categories as direct links so we wait till all sub-categories load in the accordion - const categoryAccordion = page.locator( - "#category-nav .chakra-accordion__button svg+:text('Womens')" - ); - await categoryAccordion.waitFor(); - - await page.getByRole("button", { name: "Womens" }).click(); - - const clothingNav = page.getByRole("button", { name: "Clothing" }); - - await clothingNav.waitFor(); - - await clothingNav.click(); - - const topsLink = page.getByLabel('Womens').getByRole("link", { name: "Tops" }) - await topsLink.click(); - // Wait for the nav menu to close first - await topsLink.waitFor({state: 'hidden'}) - - await expect(page.getByRole("heading", { name: "Tops" })).toBeVisible(); - // PLP - const productTile = page.getByRole("link", { - name: /Cotton Turtleneck Sweater/i, - }); - await productTile.scrollIntoViewIfNeeded() - // selecting swatch - const productTileImg = productTile.locator("img"); - await productTileImg.waitFor({state: 'visible'}) - const initialSrc = await productTileImg.getAttribute("src"); - await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible(); - - await productTile.getByLabel(/Black/, { exact: true }).click(); - // Make sure the image src has changed - await expect(async () => { - const newSrc = await productTileImg.getAttribute("src") - expect(newSrc).not.toBe(initialSrc) - }).toPass() - await expect(productTile.getByText(/From \$39\.99/i)).toBeVisible(); - await productTile.click(); - - // PDP - await expect( - page.getByRole("heading", { name: /Cotton Turtleneck Sweater/i }) - ).toBeVisible(); - await page.getByRole("radio", { name: "L", exact: true }).click(); - - await page.locator("button[data-testid='quantity-increment']").click(); - - // Selected Size and Color texts are broken into multiple elements on the page. - // So we need to look at the page URL to verify selected variants - const updatedPageURL = await page.url(); - const params = updatedPageURL.split("?")[1]; - expect(params).toMatch(/size=9LG/i); - expect(params).toMatch(/color=JJ169XX/i); - - await page.getByRole("button", { name: /Add to Cart/i }).click(); - - const addedToCartModal = page.getByText(/2 items added to cart/i); - - await addedToCartModal.waitFor(); - - await page.getByLabel("Close").click(); + await addProductToCart({page, isMobile: true}) // cart await page.getByLabel(/My cart/i).click(); @@ -162,7 +91,7 @@ test("Registered shopper can checkout items", async ({ page }) => { page.getByRole("heading", { name: /Shipping & Gift Options/i }) ).toBeVisible(); - await page.waitForTimeout(2000); + await page.waitForLoadState(); const continueToPayment = page.getByRole("button", { name: /Continue to Payment/i, }); @@ -207,4 +136,38 @@ test("Registered shopper can checkout items", async ({ page }) => { await expect( page.getByRole("link", { name: /Cotton Turtleneck Sweater/i }) ).toBeVisible(); + + // order history + await validateOrderHistory({page}); +}); + +/** + * Test that registered shoppers can navigate to PDP and add a product to wishlist + */ +test("Registered shopper can add item to wishlist", async ({ page }) => { + const isLoggedIn = await loginShopper({ + page, + userCredentials: REGISTERED_USER_CREDENTIALS + }) + + if(!isLoggedIn) { + await registerShopper({ + page, + userCredentials: REGISTERED_USER_CREDENTIALS, + isMobile: true + }) + } + + // PDP + await navigateToPDPMobile({page}); + + // add product to wishlist + await expect( + page.getByRole("heading", { name: /Cotton Turtleneck Sweater/i }) + ).toBeVisible(); + await page.getByRole("radio", { name: "L", exact: true }).click(); + await page.getByRole("button", { name: /Add to Wishlist/i }).click() + + // wishlist + await validateWishlist({page}) }); diff --git a/packages/commerce-sdk-react/CHANGELOG.md b/packages/commerce-sdk-react/CHANGELOG.md index 194e19c8af..8db48be6d3 100644 --- a/packages/commerce-sdk-react/CHANGELOG.md +++ b/packages/commerce-sdk-react/CHANGELOG.md @@ -1,17 +1,44 @@ -## v3.1.1-preview.3 (Dec 13, 2024) ## v4.0.0-extensibility-preview.3 (Dec 13, 2024) ## v3.1.1-preview.3 (Dec 13, 2024) +## v3.1.1-preview.3 (Dec 13, 2024) ## v3.1.1-preview.2 (Dec 09, 2024) ## v3.1.1-preview.1 (Dec 09, 2024) ## v4.0.0-extensibility-preview.2 (Dec 09, 2024) ## v3.1.1-preview.1 (Dec 09, 2024) ## v3.1.1-preview.0 (Dec 02, 2024) +## v3.2.0-dev (Oct 29, 2024) +- Allow cookies for ShopperLogin API [#2190](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2190 +- Fix refresh token TTL warning from firing when override is not provided [#2114](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2114) + +- Update CacheUpdateMatrix for mergeBasket mutation [#2138](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2092) +- Clear auth state if session has been invalidated by a password change [#2092](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2092) + +## v3.1.0 (Oct 28, 2024) + +- [Server Affinity] Attach dwsid to SCAPI request headers [#2090](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2090) +- Add the `authorizeCustomer` and `getPasswordResetToken` to the `ShopperLoginMutations` [#2056](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2056) +- Add useDNT hook to commerce-sdk-react and put DNT in auth [#2067](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2067/files) +- Add Trusted Agent on Behalf of (TAOB) support for SLAS APIs [#2077](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2077) +- Add optional `refreshTokenRegisteredCookieTTL` and `refreshTokenGuestCookieTTL` to Commerce API config [#2077](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2077) +- Improve refresh token error logging [#2028](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2028) +- Remove ocapi session-bridging on phased launches [#2011](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2011) +- Add `defaultDnt` to support setting the dnt flag for SLAS. Upgrade `commerce-sdk-isomorphic` to v3.1.1 [#1979](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1979) +- Update logout helper to work for guest users [#1997](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1997) +- Update `useCustomMutation` hook to accept request body as a parameter to the mutate function [#2030](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2030) +- Simplify `useCustomMutation` hook implementation [#2034](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2034) +- Documentation for `useCustomMutation` hook along with new dynamic `body` param option [#2042](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2042) + +## v3.0.1 (Sep 04, 2024) + +- Fixed an issue where the `expires` attribute in cookies, ensuring it uses seconds instead of days. [#1994](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1994) ## v3.0.0 (Aug 07, 2024) + - Add `meta.displayName` to queries. It can be used to identify queries in performance metrics or logs. [#1895](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1895) - Upgrade to commerce-sdk-isomorphic v3.0.0 [#1914](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1914) ### :warning: Planned API Changes :warning: + #### Shopper Context Starting July 31st 2024, all endpoints in the Shopper context API will require the `siteId` parameter for new customers. This field is marked as optional for backward compatibility and will be changed to mandatory tentatively by January 2025. You can read more about the planned change [here](https://developer.salesforce.com/docs/commerce/commerce-api/references/shopper-context?meta=Summary) in the notes section. @@ -20,21 +47,25 @@ Starting July 31st 2024, all endpoints in the Shopper context API will require t SLAS will soon require new tenants to pass `channel_id` as an argument for retrieving guest access tokens. You can read more about the planned change [here](https://developer.salesforce.com/docs/commerce/commerce-api/guide/slas.html#guest-tokens). -Please be aware that existing tenants are on a temporary allow list and will see no immediate disruption to service. We do ask that all users seek to adhere to the `channel_id` requirement before the end of August to enhance your security posture before the holiday peak season. +Please be aware that existing tenants are on a temporary allow list and will see no immediate disruption to service. We do ask that all users seek to adhere to the `channel_id` requirement before the end of August to enhance your security posture before the holiday peak season. In practice, we recommend: + - For customers using the SLAS helpers with a private client, it is recommended to upgrade to `v3.0.0` of the `commerce-sdk-react`. ## v2.0.2 (Jul 12, 2024) + - Updated StorefrontPreview component to make siteId available [#1874](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1874) ## v2.0.1 (Jul 08, 2024) + - Fix private slas proxy config for commerce api in provider [#1883](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1883) - Fix `useCustomQuery` error handling [#1883](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1883) - Fix `updateCustomer` squashing existing data [#1883](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1883) - Fix `transferBasket` updating the wrong customer basket [#1887](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1887) ## v2.0.0 (Jun 25, 2024) + - Add `useCustomQuery` and `useCustomMutation` for SCAPI custom endpoint support [#1793](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1793) - Add Shopper Stores hooks [#1788](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1788) - Add a helper method to add an item to either new or existing basket [#1677](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1677) @@ -42,10 +73,12 @@ In practice, we recommend: - Upgrade to commerce-sdk-isomorphic v2.1.0 [#1852](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1852) ## v1.4.2 (Apr 17, 2024) + - Update SLAS private proxy path [#1752](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1752) ## v1.4.1 (Apr 16, 2024) -- Add missing params keys `allVariationProperties` and `perPricebook` for Shopper Search [#1750](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1750) + +- Add missing params keys `allVariationProperties` and `perPricebook` for Shopper Search [#1750](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1750) ## v1.4.0 (Apr 15, 2024) diff --git a/packages/commerce-sdk-react/README.md b/packages/commerce-sdk-react/README.md index 2990536fdd..fa9a104eaf 100644 --- a/packages/commerce-sdk-react/README.md +++ b/packages/commerce-sdk-react/README.md @@ -321,7 +321,135 @@ const Example = ({basketId}) => { } ``` -You could also import the mutation options as a constant like: +##### `useCustomMutation` + +The `useCustomMutation` hook facilitates communication with the SCAPI custom endpoint. It has a different signature than the other declared mutation hooks. + +###### Parameters + +- `options` (Object): Configuration for the API request. + - `method` (String): The HTTP method to use (e.g., 'POST', 'GET'). + - `customApiPathParameters` (Object): Contains parameters to define the API path. + - `endpointPath` (String): Specific endpoint path to target in the API. + - `apiName` (String): The name of the API. + +- `clientConfig` (Object): Configuration settings for the client. + - `parameters` (Object): Essential parameters required by the Salesforce Commerce Cloud API. + - `clientId` (String): Your client ID. + - `siteId` (String): Your site ID. + - `organizationId` (String): Your organization ID. + - `shortCode` (String): Short code for your organization. + - `proxy` (String): Proxy address for API calls. + +- `rawResponse` (Boolean): Determines whether to receive the raw response from the API or a parsed version. + +###### `mutate` Method + +The `mutation.mutate(args)` function is used to execute the mutation. It accepts an argument `args`, which is an object that may contain the following properties: + +- `headers` (Object): Optional headers to send with the request. +- `parameters` (Object): Optional query parameters to append to the API URL. +- `body` (Object): Optional the payload for POST, PUT, PATCH methods. + +##### Usage + +Below is a sample usage of the `useCustomMutation` hook within a React component. + + + +```jsx +const clientConfig = { + parameters: { + clientId: 'CLIENT_ID', + siteId: 'SITE_ID', + organizationId: 'ORG_ID', + shortCode: 'SHORT_CODE' + }, + proxy: 'http://localhost:8888/mobify/proxy/api' +}; + +const mutation = useCustomMutation({ + options: { + method: 'POST', + customApiPathParameters: { + endpointPath: 'test-hello-world', + apiName: 'hello-world' + } + }, + clientConfig, + rawResponse: false +}); + +// In your React component + +``` + +It is a common scenario that a mutate function might pass a value along to a request that is dynamic and therefore can't be available when the hook is declared (contrary to example in [Mutation Hooks](#mutation-hooks) above, which would work for a button that only adds one product to a basket, but doesn't handle a changeable input for adding a different product). + +Sending a custom body param is supported, the example below combines this strategy with the use of a `useCustomMutation()` hook, making it possible to dynamically declare a body when calling a custom API endpoint. + +```jsx +import {useCustomMutation} from '@salesforce/commerce-sdk-react' +const clientConfig = { + parameters: { + clientId: 'CLIENT_ID', + siteId: 'SITE_ID', + organizationId: 'ORG_ID', + shortCode: 'SHORT_CODE' + }, + proxy: 'http://localhost:8888/mobify/proxy/api' +}; + +const mutation = useCustomMutation({ + options: { + method: 'POST', + customApiPathParameters: { + endpointPath: 'path/to/resource', + apiName: 'hello-world' + } + }, + clientConfig, + rawResponse: false +}); + +// use it in a react component +const ExampleDynamicMutation = () => { + const [colors, setColors] = useState(['blue', 'green', 'white']) + const [selectedColor, setSelectedColor] = useState(colors[0]) + + return ( + <> + + )} @@ -54,6 +68,7 @@ const ActionCard = ({children, onEdit, onRemove, editBtnRef, ...props}) => { colorScheme="red" onClick={handleRemove} color="red.600" + aria-label={removeBtnLabel} > { + renderWithProviders() + expect(screen.getByLabelText(/from current price £100\.00/i)).toHaveAttribute( + 'aria-live', + 'polite' + ) + }) }) describe('ListPrice', function () { diff --git a/packages/template-retail-react-app/app/components/drawer-menu/drawer-menu.jsx b/packages/template-retail-react-app/app/components/drawer-menu/drawer-menu.jsx index 4cf7274ad7..3892cf3bdc 100644 --- a/packages/template-retail-react-app/app/components/drawer-menu/drawer-menu.jsx +++ b/packages/template-retail-react-app/app/components/drawer-menu/drawer-menu.jsx @@ -122,7 +122,12 @@ const DrawerMenu = ({ {/* Header Content */} - + } variant="unstyled" diff --git a/packages/template-retail-react-app/app/components/footer/index.jsx b/packages/template-retail-react-app/app/components/footer/index.jsx index eb26596da0..b7044a563a 100644 --- a/packages/template-retail-react-app/app/components/footer/index.jsx +++ b/packages/template-retail-react-app/app/components/footer/index.jsx @@ -71,7 +71,7 @@ const Footer = ({...otherProps}) => { return ( - + @@ -201,7 +201,7 @@ const Subscribe = ({...otherProps}) => { const intl = useIntl() return ( - + {intl.formatMessage({ id: 'footer.subscribe.heading.first_to_know', defaultMessage: 'Be the first to know' diff --git a/packages/template-retail-react-app/app/components/forms/address-fields.jsx b/packages/template-retail-react-app/app/components/forms/address-fields.jsx index 1953bc812d..6d65ad1c25 100644 --- a/packages/template-retail-react-app/app/components/forms/address-fields.jsx +++ b/packages/template-retail-react-app/app/components/forms/address-fields.jsx @@ -18,11 +18,15 @@ const defaultFormTitleAriaLabel = defineMessage({ id: 'use_address_fields.label.address_form' }) -const AddressFields = ({form, prefix = '', formTitleAriaLabel = defaultFormTitleAriaLabel}) => { +const AddressFields = ({ + form, + prefix = '', + formTitleAriaLabel = defaultFormTitleAriaLabel, + isBillingAddress = false +}) => { const {data: customer} = useCurrentCustomer() const fields = useAddressFields({form, prefix}) const intl = useIntl() - const addressFormRef = useRef() useEffect(() => { // Focus on the form when the component mounts for accessibility @@ -52,7 +56,7 @@ const AddressFields = ({form, prefix = '', formTitleAriaLabel = defaultFormTitle - {customer.isRegistered && } + {customer.isRegistered && !isBillingAddress && } ) } @@ -65,7 +69,10 @@ AddressFields.propTypes = { prefix: PropTypes.string, /** Optional aria label to use for the address form */ - formTitleAriaLabel: MESSAGE_PROPTYPE + formTitleAriaLabel: MESSAGE_PROPTYPE, + + /** Optional flag to indication if an address is a billing address */ + isBillingAddress: PropTypes.bool } export default AddressFields diff --git a/packages/template-retail-react-app/app/components/header/index.jsx b/packages/template-retail-react-app/app/components/header/index.jsx index 565ae3f0bf..8f9ad05d58 100644 --- a/packages/template-retail-react-app/app/components/header/index.jsx +++ b/packages/template-retail-react-app/app/components/header/index.jsx @@ -193,7 +193,7 @@ const Header = ({ icon={} aria-label={intl.formatMessage({ id: 'header.button.assistive_msg.my_account', - defaultMessage: 'My account' + defaultMessage: 'My Account' })} variant="unstyled" {...styles.icons} @@ -283,7 +283,11 @@ const Header = ({ - ))} - - + + {navLinks.map((link) => ( + + + + ))} + + + @@ -182,7 +195,7 @@ const Account = () => { {showLoading && } - + { ) : ( <> - + - + { @@ -276,14 +276,16 @@ const AccountOrderDetail = () => { - + - + - {CardIcon && } + {CardIcon && ( + - + - + {shippingAddress.firstName} {shippingAddress.lastName} @@ -318,12 +320,12 @@ const AccountOrderDetail = () => { - + - + {order.billingAddress.firstName}{' '} diff --git a/packages/template-retail-react-app/app/pages/account/profile.jsx b/packages/template-retail-react-app/app/pages/account/profile.jsx index 3cd93ef890..48441e603a 100644 --- a/packages/template-retail-react-app/app/pages/account/profile.jsx +++ b/packages/template-retail-react-app/app/pages/account/profile.jsx @@ -232,11 +232,11 @@ const PasswordCard = () => { const {formatMessage} = useIntl() const headingRef = useRef(null) const {data: customer} = useCurrentCustomer() - const {isRegistered, customerId, email} = customer - - const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) + const {isRegistered} = customer - const updateCustomerPassword = useShopperCustomersMutation('updateCustomerPassword') + // Here we use AuthHelpers.UpdateCustomerPassword rather than invoking the updateCustomerPassword mutation directly + // because the AuthHelper will re-authenticate the user's current session after the password change. + const updateCustomerPassword = useAuthHelper(AuthHelpers.UpdateCustomerPassword) const toast = useToast() const [isEditing, setIsEditing] = useState(false) @@ -245,40 +245,26 @@ const PasswordCard = () => { const submit = async (values) => { try { form.clearErrors() - updateCustomerPassword.mutate( - { - parameters: {customerId}, - body: { - password: values.password, - currentPassword: values.currentPassword - } - }, - { - onSuccess: () => { - setIsEditing(false) - toast({ - title: formatMessage({ - defaultMessage: 'Password updated', - id: 'password_card.info.password_updated' - }), - status: 'success', - isClosable: true - }) - login.mutate({ - username: email, - password: values.password - }) - headingRef?.current?.focus() - form.reset() - }, - onError: async (err) => { - const resObj = await err.response.json() - form.setError('root.global', {type: 'manual', message: resObj.detail}) - } - } - ) + await updateCustomerPassword.mutateAsync({ + customer, + password: values.password, + currentPassword: values.currentPassword, + shouldReloginCurrentSession: true + }) + setIsEditing(false) + toast({ + title: formatMessage({ + defaultMessage: 'Password updated', + id: 'password_card.info.password_updated' + }), + status: 'success', + isClosable: true + }) + headingRef?.current?.focus() + form.reset() } catch (error) { - form.setError('root.global', {type: 'manual', message: error.message}) + const resObj = await error.response.json() + form.setError('root.global', {type: 'manual', message: resObj.detail}) } } diff --git a/packages/template-retail-react-app/app/pages/account/wishlist/index.jsx b/packages/template-retail-react-app/app/pages/account/wishlist/index.jsx index cf7a9a1256..9e3a60666a 100644 --- a/packages/template-retail-react-app/app/pages/account/wishlist/index.jsx +++ b/packages/template-retail-react-app/app/pages/account/wishlist/index.jsx @@ -6,7 +6,7 @@ */ import React, {useState, useEffect, useRef} from 'react' import {FormattedMessage, useIntl} from 'react-intl' -import {Box, Stack, Heading, Flex, Skeleton} from '@chakra-ui/react' +import {Box, Flex, Skeleton, Stack, Heading} from '@chakra-ui/react' import {useProducts, useShopperCustomersMutation} from '@salesforce/commerce-sdk-react' import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation' @@ -215,6 +215,7 @@ const AccountWishlist = () => { secondaryActions={ { renderWithProviders() await waitFor(() => { expect(screen.getByTestId('account-wishlist-page')).toBeInTheDocument() - expect(screen.getByRole('link', {name: /fall look/i})).toBeInTheDocument() + expect(screen.getByTestId('sf-cart-item-P0150M')).toBeInTheDocument() }) }) diff --git a/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-primary-action.jsx b/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-primary-action.jsx index d17b6b00e6..4066eced56 100644 --- a/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-primary-action.jsx +++ b/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-primary-action.jsx @@ -107,6 +107,13 @@ const WishlistPrimaryAction = () => { onClick={() => handleAddToCart(variant, variant.quantity)} w={'full'} isLoading={isLoading} + aria-label={formatMessage( + { + id: 'wishlist_primary_action.button.addSetToCart.label', + defaultMessage: 'Add {productName} set to cart' + }, + {productName: variant.name} + )} > {buttonText.addSetToCart} @@ -119,6 +126,13 @@ const WishlistPrimaryAction = () => { w={'full'} variant={'solid'} _hover={{textDecoration: 'none'}} + aria-label={formatMessage( + { + id: 'wishlist_primary_action.button.viewFullDetails.label', + defaultMessage: 'View full details for {productName}' + }, + {productName: variant.name} + )} > {buttonText.viewFullDetails} @@ -132,6 +146,13 @@ const WishlistPrimaryAction = () => { w={'full'} variant={'solid'} _hover={{textDecoration: 'none'}} + aria-label={formatMessage( + { + id: 'wishlist_primary_action.button.viewFullDetails.label', + defaultMessage: 'View full details for {productName}' + }, + {productName: variant.name} + )} > {buttonText.viewFullDetails} @@ -140,7 +161,18 @@ const WishlistPrimaryAction = () => { if (isMasterProduct) { return ( <> - {isOpen && ( @@ -161,6 +193,13 @@ const WishlistPrimaryAction = () => { onClick={() => handleAddToCart(variant, variant.quantity)} w={'full'} isLoading={isLoading} + aria-label={formatMessage( + { + id: 'wishlist_primary_action.button.addToCart.label', + defaultMessage: 'Add {productName} to cart' + }, + {productName: variant.name} + )} > {buttonText.addToCart} diff --git a/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-primary-action.test.js b/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-primary-action.test.js index 622f796532..40719d19ac 100644 --- a/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-primary-action.test.js +++ b/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-primary-action.test.js @@ -53,7 +53,7 @@ test('the Add To Cart button', async () => { const {user} = renderWithProviders() const addToCartButton = await screen.findByRole('button', { - name: /add to cart/i + name: new RegExp(`Add ${variant.name} to cart`, 'i') }) await user.click(addToCartButton) @@ -65,8 +65,9 @@ test('the Add To Cart button', async () => { test('the Add Set To Cart button', async () => { const productSetWithoutVariants = mockWishListDetails.data[1] const {user} = renderWithProviders() - - const button = await screen.findByRole('button', {name: /add set to cart/i}) + const button = await screen.findByRole('button', { + name: new RegExp(`Add ${productSetWithoutVariants.name} set to cart`, 'i') + }) await user.click(button) await waitFor(() => { diff --git a/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-secondary-button-group.jsx b/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-secondary-button-group.jsx index f3078f0cdd..eedec16330 100644 --- a/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-secondary-button-group.jsx +++ b/packages/template-retail-react-app/app/pages/account/wishlist/partials/wishlist-secondary-button-group.jsx @@ -53,6 +53,7 @@ export const REMOVE_WISHLIST_ITEM_CONFIRMATION_DIALOG_CONFIG = { */ const WishlistSecondaryButtonGroup = ({ productListItemId, + productName = '', focusElementOnRemove, onClick = noop }) => { @@ -108,6 +109,13 @@ const WishlistSecondaryButtonGroup = ({ size="sm" onClick={showRemoveItemConfirmation} data-testid={`sf-wishlist-remove-${productListItemId}`} + aria-label={formatMessage( + { + defaultMessage: 'Remove {productName}', + id: 'wishlist_secondary_button_group.info.item.remove.label' + }, + {productName} + )} > { } return res(ctx.json(updatedBasket)) }), - rest.patch('*/baskets/:basketId/items/:itemId', (req, res, ctx) => {}) + rest.patch('*/baskets/:basketId/items/:itemId', () => {}) ) }) diff --git a/packages/template-retail-react-app/app/pages/checkout/confirmation.mock.js b/packages/template-retail-react-app/app/pages/checkout/confirmation.mock.js index 5927795d40..fd3b45c9d9 100644 --- a/packages/template-retail-react-app/app/pages/checkout/confirmation.mock.js +++ b/packages/template-retail-react-app/app/pages/checkout/confirmation.mock.js @@ -45,8 +45,8 @@ export const mockOrder = { paymentCard: { cardType: 'Visa', creditCardExpired: false, - expirationMonth: 12, - expirationYear: 2023, + expirationMonth: 1, + expirationYear: 2040, holder: 'test', maskedNumber: '************1111', numberLastDigits: '1111' diff --git a/packages/template-retail-react-app/app/pages/checkout/index.test.js b/packages/template-retail-react-app/app/pages/checkout/index.test.js index 82a377d6b0..482ec0f239 100644 --- a/packages/template-retail-react-app/app/pages/checkout/index.test.js +++ b/packages/template-retail-react-app/app/pages/checkout/index.test.js @@ -130,10 +130,10 @@ beforeEach(() => { address1: '123 Main St', city: 'Tampa', countryCode: 'US', - firstName: 'Test', - fullName: 'Test McTester', + firstName: 'John', + fullName: 'John Smith', id: '047b18d4aaaf4138f693a4b931', - lastName: 'McTester', + lastName: 'Smith', phone: '(727) 555-1234', postalCode: '33712', stateCode: 'FL', @@ -159,7 +159,7 @@ beforeEach(() => { cardType: 'Master Card', creditCardExpired: false, expirationMonth: 1, - expirationYear: 2030, + expirationYear: 2040, holder: 'Test McTester', maskedNumber: '************5454', numberLastDigits: '5454', @@ -275,8 +275,8 @@ test('Can proceed through checkout steps as guest', async () => { paymentCard: { cardType: 'Visa', creditCardExpired: false, - expirationMonth: 12, - expirationYear: 2024, + expirationMonth: 1, + expirationYear: 2040, holder: 'Testy McTester', maskedNumber: '************1111', numberLastDigits: '1111', @@ -394,7 +394,7 @@ test('Can proceed through checkout steps as guest', async () => { // Fill out credit card payment form await user.type(screen.getByLabelText(/card number/i), '4111111111111111') await user.type(screen.getByLabelText(/name on card/i), 'Testy McTester') - await user.type(screen.getByLabelText(/expiration date/i), '1224') + await user.type(screen.getByLabelText(/expiration date/i), '0140') await user.type(screen.getByLabelText(/^security code$/i /* not "security code info" */), '123') // Same as shipping checkbox selected by default @@ -417,7 +417,7 @@ test('Can proceed through checkout steps as guest', async () => { // Verify applied payment and billing address expect(step3Content.getByText('Visa')).toBeInTheDocument() expect(step3Content.getByText('•••• 1111')).toBeInTheDocument() - expect(step3Content.getByText('12/2024')).toBeInTheDocument() + expect(step3Content.getByText('1/2040')).toBeInTheDocument() expect(step3Content.getByText('Tester McTesting')).toBeInTheDocument() expect(step3Content.getByText('123 Main St')).toBeInTheDocument() @@ -485,7 +485,7 @@ test('Can proceed through checkout as registered customer', async () => { // (we no longer have saved payment methods) await user.type(screen.getByLabelText(/card number/i), '4111111111111111') await user.type(screen.getByLabelText(/name on card/i), 'Testy McTester') - await user.type(screen.getByLabelText(/expiration date/i), '1224') + await user.type(screen.getByLabelText(/expiration date/i), '0140') await user.type(screen.getByLabelText(/^security code$/i /* not "security code info" */), '123') // Same as shipping checkbox selected by default @@ -495,6 +495,19 @@ test('Can proceed through checkout as registered customer', async () => { const step3Content = within(screen.getByTestId('sf-toggle-card-step-3-content')) expect(step3Content.getByText('123 Main St')).toBeInTheDocument() + // Edit billing address + const sameAsShippingBtn = screen.getByText(/same as shipping address/i) + await user.click(sameAsShippingBtn) + + const firstNameInput = screen.getByLabelText(/first name/i) + const lastNameInput = screen.getByLabelText(/last name/i) + expect(step3Content.queryByText(/Set as default/)).not.toBeInTheDocument() + + await user.clear(firstNameInput) + await user.clear(lastNameInput) + await user.type(firstNameInput, 'John') + await user.type(lastNameInput, 'Smith') + // Move to final review step await user.click(screen.getByText(/review order/i)) @@ -505,8 +518,9 @@ test('Can proceed through checkout as registered customer', async () => { // Verify applied payment and billing address expect(step3Content.getByText('Master Card')).toBeInTheDocument() expect(step3Content.getByText('•••• 5454')).toBeInTheDocument() - expect(step3Content.getByText('1/2030')).toBeInTheDocument() + expect(step3Content.getByText('1/2040')).toBeInTheDocument() + expect(step3Content.getByText('John Smith')).toBeInTheDocument() expect(step3Content.getByText('123 Main St')).toBeInTheDocument() // Place the order diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx index fd38ab55a7..7fd715735a 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/contact-info.jsx @@ -46,7 +46,7 @@ const ContactInfo = () => { const login = useAuthHelper(AuthHelpers.LoginRegisteredUserB2C) const logout = useAuthHelper(AuthHelpers.Logout) const updateCustomerForBasket = useShopperBasketsMutation('updateCustomerForBasket') - const transferBasket = useShopperBasketsMutation('transferBasket') + const mergeBasket = useShopperBasketsMutation('mergeBasket') const {step, STEPS, goToStep, goToNextStep} = useCheckout() @@ -72,11 +72,14 @@ const ContactInfo = () => { } else { await login.mutateAsync({username: data.email, password: data.password}) - // Because we lazy load the basket there is no guarantee that a basket exists for the newly registered - // user, for this reason we must transfer the ownership of the previous basket to the logged in user. - await transferBasket.mutateAsync({ - parameters: {overrideExisting: true} - }) + const hasBasketItem = basket.productItems?.length > 0 + if (hasBasketItem) { + mergeBasket.mutate({ + parameters: { + createDestinationBasket: true + } + }) + } } goToNextStep() } catch (error) { diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/payment.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/payment.jsx index b1d84dec2d..83e85f5dea 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/payment.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/payment.jsx @@ -222,6 +222,7 @@ const Payment = () => { selectedAddress={selectedBillingAddress} formTitleAriaLabel={billingAddressAriaLabel} hideSubmitButton + isBillingAddress /> )} diff --git a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address-selection.jsx b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address-selection.jsx index 9ad2f3793f..a872883888 100644 --- a/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address-selection.jsx +++ b/packages/template-retail-react-app/app/pages/checkout/partials/shipping-address-selection.jsx @@ -32,31 +32,37 @@ const ShippingAddressEditForm = ({ hideSubmitButton, form, submitButtonLabel, - formTitleAriaLabel + formTitleAriaLabel, + isBillingAddress = false }) => { const {formatMessage} = useIntl() return ( - {hasSavedAddresses && ( + {hasSavedAddresses && !isBillingAddress && ( {title} )} - + {hasSavedAddresses && !hideSubmitButton ? ( null + onSubmit = async () => null, + isBillingAddress = false }) => { const {formatMessage} = useIntl() const {data: customer, isLoading, isFetching} = useCurrentCustomer() @@ -142,10 +150,13 @@ const ShippingAddressSelection = ({ const {id, _type, ...selectedAddr} = selectedAddress return shallowEquals(address, selectedAddr) }) - const removeCustomerAddress = useShopperCustomersMutation('removeCustomerAddress') useEffect(() => { + if (isBillingAddress) { + form.reset({...selectedAddress}) + return + } // Automatically select the customer's default/preferred shipping address if (customer.addresses) { const address = customer.addresses.find((addr) => addr.preferred === true) @@ -257,11 +268,10 @@ const ShippingAddressSelection = ({ // Don't render anything yet, to make sure values like hasSavedAddresses are correct return null } - return (
- {hasSavedAddresses && ( + {hasSavedAddresses && !isBillingAddress && ( - {customer.addresses?.map((address, index) => ( - - - - removeSavedAddress(address.addressId) - } - onEdit={() => toggleAddressEdit(address)} - editBtnRef={editBtnRefs[address.addressId]} - data-testid={`sf-checkout-shipping-address-${index}`} - > - - - {/*Arrow up icon pointing to the address that is being edited*/} + {customer.addresses?.map((address, index) => { + const editLabel = formatMessage( + { + defaultMessage: 'Edit {address}', + id: 'shipping_address.label.edit_button' + }, + {address: address.address1} + ) + + const removeLabel = formatMessage( + { + defaultMessage: 'Remove {address}', + id: 'shipping_address.label.remove_button' + }, + {address: address.address1} + ) + return ( + + + + removeSavedAddress(address.addressId) + } + onEdit={() => toggleAddressEdit(address)} + editBtnRef={editBtnRefs[address.addressId]} + data-testid={`sf-checkout-shipping-address-${index}`} + editBtnLabel={editLabel} + removeBtnLabel={removeLabel} + > + + + {/*Arrow up icon pointing to the address that is being edited*/} + {isEditingAddress && + address.addressId === selectedAddressId && ( + + )} + {isEditingAddress && address.addressId === selectedAddressId && ( - )} - - {isEditingAddress && - address.addressId === selectedAddressId && ( - - )} - - ))} + + ) + })}