From 9ae76f14c214d6512d1519bcf9b12413781e544a Mon Sep 17 00:00:00 2001 From: Vito Galatro Date: Tue, 10 Oct 2023 08:58:17 -0400 Subject: [PATCH] THEME-738: Identity | Account Management (#1739) * THEMES-738: copied in v1 account management code, began converting to v2. * THEMES-738: saving work, need to convert social sign on first. * THEMES-738: converted most of the account management block. * THEMES-738: updated components to use v2 social sign on block. * THEMES-768: updated styling for account management to better match mock ups. * THEMES-738: finished correcting styling for account management page. * THEMES-738: updated tests to use react testing library. * THEMES-738: fixed tests for editable form input. * THEMES-738: tiny edits to testing files so jest --changed-since can see those tests for the coverage report. * THEMES-738: added testing ignores for Identity SDK functionality. * THEMES-738: updated styling for paragraphs in the edit blocks. Also updated styles for the commerce theme. * Apply suggestions from code review --------- Co-authored-by: nschubach --- blocks/identity-block/_index.scss | 25 +++ .../components/editable-form-input/index.jsx | 101 ++++++++++ .../index.story-ignore.jsx | 32 ++++ .../editable-form-input/index.test.jsx | 123 ++++++++++++ .../components/identity/index.test.jsx | 1 - .../components/social-sign-on/index.test.jsx | 1 - .../_children/EmailEditableFieldContainer.jsx | 65 +++++++ .../PasswordEditableFieldContainer.jsx | 170 ++++++++++++++++ .../PasswordEditableFieldContainer.test.jsx | 99 ++++++++++ .../SocialEditableFieldContainer.jsx | 30 +++ .../SocialEditableFieldContainer.test.jsx | 34 ++++ .../_children/SocialEditableSection.jsx | 72 +++++++ .../_children/SocialEditableSection.test.jsx | 62 ++++++ .../features/account-management/default.jsx | 164 ++++++++++++++++ .../account-management/default.test.jsx | 126 ++++++++++++ blocks/identity-block/themes/commerce.json | 181 ++++++++++++++++++ blocks/identity-block/themes/news.json | 106 ++++++++++ 17 files changed, 1390 insertions(+), 2 deletions(-) create mode 100644 blocks/identity-block/components/editable-form-input/index.jsx create mode 100644 blocks/identity-block/components/editable-form-input/index.story-ignore.jsx create mode 100644 blocks/identity-block/components/editable-form-input/index.test.jsx create mode 100644 blocks/identity-block/features/account-management/_children/EmailEditableFieldContainer.jsx create mode 100644 blocks/identity-block/features/account-management/_children/PasswordEditableFieldContainer.jsx create mode 100644 blocks/identity-block/features/account-management/_children/PasswordEditableFieldContainer.test.jsx create mode 100644 blocks/identity-block/features/account-management/_children/SocialEditableFieldContainer.jsx create mode 100644 blocks/identity-block/features/account-management/_children/SocialEditableFieldContainer.test.jsx create mode 100644 blocks/identity-block/features/account-management/_children/SocialEditableSection.jsx create mode 100644 blocks/identity-block/features/account-management/_children/SocialEditableSection.test.jsx create mode 100644 blocks/identity-block/features/account-management/default.jsx create mode 100644 blocks/identity-block/features/account-management/default.test.jsx diff --git a/blocks/identity-block/_index.scss b/blocks/identity-block/_index.scss index 36e8fbe7f0..d8bcd60fab 100644 --- a/blocks/identity-block/_index.scss +++ b/blocks/identity-block/_index.scss @@ -1,5 +1,30 @@ @use "@wpmedia/arc-themes-components/scss"; +.b-account-management { + &__edit { + @include scss.block-components("account-management-edit"); + @include scss.block-properties("account-management-edit"); + } + + &__edit-label { + @include scss.block-components("account-management-edit-label"); + @include scss.block-properties("account-management-edit-label"); + } + + &__section { + @include scss.block-components("account-management-section"); + @include scss.block-properties("account-management-section"); + } + + &__social-edit { + @include scss.block-components("account-management-social-edit"); + @include scss.block-properties("account-management-social-edit"); + } + + @include scss.block-components("account-management"); + @include scss.block-properties("account-management"); +} + .b-forgot-password { @include scss.block-components("forgot-password"); @include scss.block-properties("forgot-password"); diff --git a/blocks/identity-block/components/editable-form-input/index.jsx b/blocks/identity-block/components/editable-form-input/index.jsx new file mode 100644 index 0000000000..3a68ee8e45 --- /dev/null +++ b/blocks/identity-block/components/editable-form-input/index.jsx @@ -0,0 +1,101 @@ +import React, { useState, useRef } from "react"; +import { Button, Paragraph } from "@wpmedia/arc-themes-components"; + +// handles submit and display of form +// will toggle back to not editable upon successful submit +export function ConditionalFormContainer({ showForm, children, onSubmit, setIsEditable }) { + const formRef = useRef(); + + // handleSubmit from headline submit form + const handleSubmit = (event) => { + event.preventDefault(); + + const valid = formRef.current.checkValidity(); + if (valid) { + const namedFields = Array.from(formRef.current.elements) + .filter((element) => element?.name && typeof element?.name !== "undefined") + .reduce( + (accumulator, element) => ({ + ...accumulator, + [element.name]: element.value, + }), + {} + ); + onSubmit(namedFields).then(() => setIsEditable(false)); + } + }; + + return showForm ? ( +
+ {children} +
+ ) : ( + <>{children} + ); +} + +function EditableFieldPresentational({ + blockClassName, + cancelEdit, + cancelText, + children, + editText, + formErrorText, + initialValue, + label, + onSubmit, + saveText, +}) { + const [isEditable, setIsEditable] = useState(!!formErrorText); + return ( +
+ +
+ {isEditable ? ( + <> + {children} + {formErrorText ? ( +
+ {formErrorText} +
+ ) : null} +
+ + +
+ + ) : ( + <> +
+ {label} + +
+ {initialValue} + + )} +
+
+
+ ); +} + +export default EditableFieldPresentational; diff --git a/blocks/identity-block/components/editable-form-input/index.story-ignore.jsx b/blocks/identity-block/components/editable-form-input/index.story-ignore.jsx new file mode 100644 index 0000000000..12a8a9531e --- /dev/null +++ b/blocks/identity-block/components/editable-form-input/index.story-ignore.jsx @@ -0,0 +1,32 @@ +import React from "react"; + +import { FormInputField, FIELD_TYPES } from "@wpmedia/shared-styles"; +import EditableFormInput from "."; + +export default { + title: "Blocks/Identity/Components/EditableFormInput", + parameters: { + chromatic: { viewports: [320, 1200] }, + }, +}; + +export const emailField = () => ( + + + +); diff --git a/blocks/identity-block/components/editable-form-input/index.test.jsx b/blocks/identity-block/components/editable-form-input/index.test.jsx new file mode 100644 index 0000000000..88b7b29e8a --- /dev/null +++ b/blocks/identity-block/components/editable-form-input/index.test.jsx @@ -0,0 +1,123 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import EditableFormInputField, { ConditionalFormContainer } from "."; + +describe("Editable form input field", () => { + it("conditional form renders a form when show form elected", () => { + render(); + expect(screen.getByTestId("conditional-form")).not.toBeNull(); + }); + + it("conditional form does not render a form when show form not elected", () => { + render(); + expect(screen.queryByTestId("conditional-form")).toBeNull(); + }); + + it("editable form field shows initial value, label, and edit button when not editable and hides children", () => { + render( + {}} + > +

Test child

+
+ ); + expect(screen.getByText("initial value")).not.toBeNull(); + expect(screen.getByText("edit text")).not.toBeNull(); + expect(screen.getByText("label")).not.toBeNull(); + }); + + it("shows error text if passed in with formErrorText prop", () => { + render( + {}} + formErrorText="Error Text" + > +

Test child

+
+ ); + + expect(screen.getByText("Error Text")).not.toBeNull(); + }); + + it("editable form field hides edit button when editable and shows children", async () => { + render( + {}} + > +

Test child

+
+ ); + + fireEvent.click(screen.getByRole("button")); + expect(screen.queryByText("initial value")).toBeNull(); + expect(screen.queryByText("edit text")).toBeNull(); + }); + + it("does not submit if the input is invalid", () => { + const callback = jest.fn(() => Promise.resolve()); + + render( + {}} showForm> + + + ); + + fireEvent.submit(screen.getByTestId("conditional-form")); + + expect(callback).not.toHaveBeenCalled(); + }); + + it("does submit if the input is valid", () => { + const callback = jest.fn(() => Promise.resolve()); + + render( + {}}> + + + ); + + fireEvent.submit(screen.getByTestId("conditional-form")); + + expect(callback).toHaveBeenCalledWith({ + inputField: "valid@email.com", + }); + }); + + it("calls passed in cancelEdit function when using cancel button", () => { + const callback = jest.fn(); + + render( + + + + ); + + fireEvent.click(screen.getByText("cancel change")); + + expect(callback).toHaveBeenCalled(); + }); + + it("calls passed in cancelEdit function when using cancel button", () => { + render( + + + + ); + + expect(screen.getByText("Error")).not.toBeNull(); + }); +}); diff --git a/blocks/identity-block/components/identity/index.test.jsx b/blocks/identity-block/components/identity/index.test.jsx index 089f00ded8..97bec3d3c1 100644 --- a/blocks/identity-block/components/identity/index.test.jsx +++ b/blocks/identity-block/components/identity/index.test.jsx @@ -78,7 +78,6 @@ describe("Identity useIdentity Hook", () => { }, ], }; - const Test = () => { const { getSignedInIdentity } = useIdentity(); const getCurrent = getSignedInIdentity(testUser); diff --git a/blocks/identity-block/components/social-sign-on/index.test.jsx b/blocks/identity-block/components/social-sign-on/index.test.jsx index 0aaf066308..ce8084883c 100644 --- a/blocks/identity-block/components/social-sign-on/index.test.jsx +++ b/blocks/identity-block/components/social-sign-on/index.test.jsx @@ -20,7 +20,6 @@ describe("Identity Social Login Component", () => { initializeFacebook: () => {}, }, })); - render( diff --git a/blocks/identity-block/features/account-management/_children/EmailEditableFieldContainer.jsx b/blocks/identity-block/features/account-management/_children/EmailEditableFieldContainer.jsx new file mode 100644 index 0000000000..7b9d806276 --- /dev/null +++ b/blocks/identity-block/features/account-management/_children/EmailEditableFieldContainer.jsx @@ -0,0 +1,65 @@ +import React, { useState } from "react"; +import { useFusionContext } from "fusion:context"; +import getProperties from "fusion:properties"; +import getTranslatedPhrases from "fusion:intl"; +import { Input } from "@wpmedia/arc-themes-components"; +import useIdentity from "../../../components/identity"; +import EditableFieldPresentational from "../../../components/editable-form-input"; + +function EmailEditableFieldContainer({ blockClassName, email, setEmail }) { + const [formErrorText, setFormErrorText] = useState(null); + + const { arcSite } = useFusionContext(); + const { locale } = getProperties(arcSite); + const phrases = getTranslatedPhrases(locale); + const { Identity } = useIdentity(); + + const formEmailLabel = phrases.t("identity-block.email"); + const emailRequirements = phrases.t("identity-block.email-requirements"); + const editText = phrases.t("identity-block.edit"); + const saveText = phrases.t("identity-block.save"); + const cancelText = phrases.t("identity-block.cancel"); + const emailError = phrases.t("identity-block.update-email-error"); + + // istanbul ignore next + const handleEmailUpdate = ({ email: newEmail }) => + Identity.updateUserProfile({ email: newEmail }) + .then((profileObject) => { + setEmail(profileObject.email); + }) + .catch(() => { + setFormErrorText(emailError); + throw new Error(); + }); + + const handleCancelEdit = () => { + setFormErrorText(null); + }; + + return ( + + + + ); +} + +export default EmailEditableFieldContainer; diff --git a/blocks/identity-block/features/account-management/_children/PasswordEditableFieldContainer.jsx b/blocks/identity-block/features/account-management/_children/PasswordEditableFieldContainer.jsx new file mode 100644 index 0000000000..9b7c3e3f41 --- /dev/null +++ b/blocks/identity-block/features/account-management/_children/PasswordEditableFieldContainer.jsx @@ -0,0 +1,170 @@ +import React, { useEffect, useState } from "react"; +import { useFusionContext } from "fusion:context"; +import getProperties from "fusion:properties"; +import getTranslatedPhrases from "fusion:intl"; +import { Input } from "@wpmedia/arc-themes-components"; +import useIdentity from "../../../components/identity"; +import EditableFieldPresentational from "../../../components/editable-form-input"; +import FormPasswordConfirm from "../../../components/form-password-confirm"; +import passwordValidationMessage from "../../../utils/password-validation-message"; +import validatePasswordPattern from "../../../utils/validate-password-pattern"; + +function PasswordEditableFieldContainer({ blockClassName, email, hasPassword, setHasPassword }) { + const [error, setError] = useState(false); + const [passwordRequirements, setPasswordRequirements] = useState({}); + const [configStatus, setConfigStatus] = useState("loading"); + + const { arcSite } = useFusionContext(); + const { locale } = getProperties(arcSite); + const phrases = getTranslatedPhrases(locale); + + const { Identity } = useIdentity(); + + useEffect(() => { + const getConfigInfo = () => + Identity.getConfig() + .then((response) => { + setPasswordRequirements(response); + setConfigStatus("success"); + }) + .catch(() => setConfigStatus("error")); + + if (Identity) { + getConfigInfo(); + } + }, [setPasswordRequirements, Identity]); + + const { + pwLowercase = 0, + pwMinLength = 0, + pwPwNumbers = 0, + pwSpecialCharacters = 0, + pwUppercase = 0, + } = passwordRequirements; + + const passwordErrorMessage = passwordValidationMessage({ + defaultMessage: phrases.t("identity-block.password-requirements"), + options: { + lowercase: { + value: pwLowercase, + message: phrases.t("identity-block.password-requirements-lowercase", { + requirementCount: pwLowercase, + }), + }, + minLength: { + value: pwMinLength, + message: phrases.t("identity-block.password-requirements-characters", { + requirementCount: pwMinLength, + }), + }, + uppercase: { + value: pwUppercase, + message: phrases.t("identity-block.password-requirements-uppercase", { + requirementCount: pwUppercase, + }), + }, + numbers: { + value: pwPwNumbers, + message: phrases.t("identity-block.password-requirements-numbers", { + requirementCount: pwPwNumbers, + }), + }, + specialCharacters: { + value: pwSpecialCharacters, + message: phrases.t("identity-block.password-requirements-uppercase", { + requirementCount: pwUppercase, + }), + }, + }, + }); + + // istanbul ignore next + const handlePasswordUpdate = ({ "current-password": oldPassword, password: newPassword }) => { + if (hasPassword) { + return Identity.updatePassword(oldPassword, newPassword) + .then(() => { + setError(false); + }) + .catch(() => { + setError(phrases.t("identity-block.update-password-error")); + throw new Error(); + }); + } + + return Identity.signUp({ userName: email, credentials: newPassword }, { email }) + .then(() => { + setHasPassword(true); + setError(false); + }) + .catch(() => setError(phrases.t("identity-block.sign-up-form-error"))); + }; + + const handleCancelEdit = () => { + setError(false); + }; + + const passwordValue = hasPassword + ? phrases.t("identity-block.password-placeholder") + : phrases.t("identity-block.add-password"); + + return ( + + {hasPassword ? ( + <> + + + + ) : ( + + )} + + ); +} + +export default PasswordEditableFieldContainer; diff --git a/blocks/identity-block/features/account-management/_children/PasswordEditableFieldContainer.test.jsx b/blocks/identity-block/features/account-management/_children/PasswordEditableFieldContainer.test.jsx new file mode 100644 index 0000000000..4ea446943d --- /dev/null +++ b/blocks/identity-block/features/account-management/_children/PasswordEditableFieldContainer.test.jsx @@ -0,0 +1,99 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { act } from "react-dom/test-utils"; +import useIdentity from "../../../components/identity"; +import PasswordEditableFieldContainer from "./PasswordEditableFieldContainer"; + +jest.mock("fusion:properties", () => + jest.fn(() => ({ + locale: "en", + })) +); + +jest.mock("../../../components/identity"); + +jest.mock("fusion:intl", () => ({ + __esModule: true, + default: jest.fn((locale) => ({ + t: jest.fn((phrase) => require("../../../intl.json")[phrase][locale]), + })), +})); + +const responseData = { + pwLowercase: "1", + pwMinLength: "1", + pwPwNumbers: "1", + pwSpecialCharacters: "1", + pwUppercase: "1", +}; + +const getConfigMock = jest.fn(() => Promise.resolve(responseData)); + +describe("PasswordEditableFieldContainer", () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("should render component with Add Password label", async () => { + useIdentity.mockImplementation(() => ({ + Identity: { + getConfig: getConfigMock, + }, + })); + + await act(async () => { + await render(); + }); + await getConfigMock; + expect(screen.getByText("Add Password")).not.toBeNull(); + }); + + it("should render component with password placeholder label", async () => { + useIdentity.mockImplementation(() => ({ + Identity: { + getConfig: getConfigMock, + }, + })); + + await act(async () => { + await render(); + }); + await getConfigMock; + expect(screen.getByText("**********")).not.toBeNull(); + }); + + it("renders current password field and password confirm fields when hasPassword and editing", async () => { + useIdentity.mockImplementation(() => ({ + Identity: { + getConfig: getConfigMock, + }, + })); + + await act(async () => { + await render(); + }); + await getConfigMock; + expect(screen.getByText("**********")).not.toBeNull(); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByLabelText("Current Password")).not.toBeNull(); + expect(screen.getByLabelText("New Password")).not.toBeNull(); + expect(screen.getByLabelText("Confirm password")).not.toBeNull(); + }); + + it("renders only password confirm fields when NOT hasPassword and editing", async () => { + useIdentity.mockImplementation(() => ({ + Identity: { + getConfig: getConfigMock, + }, + })); + + await act(async () => { + await render(); + }); + await getConfigMock; + expect(screen.getByText("Add Password")).not.toBeNull(); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByLabelText("New Password")).not.toBeNull(); + expect(screen.getByLabelText("Confirm password")).not.toBeNull(); + }); +}); diff --git a/blocks/identity-block/features/account-management/_children/SocialEditableFieldContainer.jsx b/blocks/identity-block/features/account-management/_children/SocialEditableFieldContainer.jsx new file mode 100644 index 0000000000..e65a3e7e11 --- /dev/null +++ b/blocks/identity-block/features/account-management/_children/SocialEditableFieldContainer.jsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Button } from "@wpmedia/arc-themes-components"; + +function SocialEditableFieldContainer({ + text, + onDisconnectFunction, + showDisconnectButton, + disconnectText, +}) { + return ( +
+ + {text} + {showDisconnectButton ? " " : ""} + + {showDisconnectButton ? ( + + ) : null} +
+ ); +} + +export default SocialEditableFieldContainer; diff --git a/blocks/identity-block/features/account-management/_children/SocialEditableFieldContainer.test.jsx b/blocks/identity-block/features/account-management/_children/SocialEditableFieldContainer.test.jsx new file mode 100644 index 0000000000..04b5e4e652 --- /dev/null +++ b/blocks/identity-block/features/account-management/_children/SocialEditableFieldContainer.test.jsx @@ -0,0 +1,34 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import SocialEditableFieldContainer from "./SocialEditableFieldContainer"; + +describe("SocialEditableFieldContainer", () => { + it("should render with disconnect option when connected", () => { + render( + {}} + showDisconnectButton + disconnectText="disconnect test" + /> + ); + expect(screen.getByText("disconnect test")).not.toBeNull(); + + // added space is for formatting with the disconenct button + expect(screen.getByText(/Connected user/i)).not.toBeNull(); + }); + it("should render without disconnect when disconnected", () => { + render( + {}} + showDisconnectButton={false} + disconnectText="disconnect test" + /> + ); + + expect(screen.queryByRole("button")).toBeNull(); + // no added space is for formatting without the disconenct button + expect(screen.getByText("Disconnected user")).not.toBeNull(); + }); +}); diff --git a/blocks/identity-block/features/account-management/_children/SocialEditableSection.jsx b/blocks/identity-block/features/account-management/_children/SocialEditableSection.jsx new file mode 100644 index 0000000000..c2d39b1b13 --- /dev/null +++ b/blocks/identity-block/features/account-management/_children/SocialEditableSection.jsx @@ -0,0 +1,72 @@ +import React from "react"; +import { useFusionContext } from "fusion:context"; +import getProperties from "fusion:properties"; +import getTranslatedPhrases from "fusion:intl"; +import useSocialSignIn from "../../../components/social-sign-on/utils/useSocialSignIn"; +import FacebookSignIn from "../../../components/social-sign-on/_children/FacebookSignIn"; +import GoogleSignIn from "../../../components/social-sign-on/_children/GoogleSignIn"; +import SocialEditableFieldContainer from "./SocialEditableFieldContainer"; + +function SocialEditableSection({ + blockClassName, + hasFacebook, + hasGoogle, + hasPasswordAccount, + unlinkFacebook, + unlinkGoogle, +}) { + // get current because social sign in has reload and need to re-render page anyway + const currentUrl = window.location.href; + + const { facebookAppId, googleClientId } = useSocialSignIn(currentUrl); + + const { arcSite } = useFusionContext(); + const { locale } = getProperties(arcSite); + const phrases = getTranslatedPhrases(locale); + + const socialText = phrases.t("identity-block.connect-account"); + const disconnectText = phrases.t("identity-block.disconnect-account"); + const facebookConnectText = phrases.t("identity-block.connect-platform", { + platform: "Facebook", + }); + const googleConnectText = phrases.t("identity-block.connect-platform", { + platform: "Google", + }); + + return ( + <> + {googleClientId ? ( +
+
+ +
+
+ +
+
+ ) : null} + {facebookAppId ? ( +
+
+ +
+
+ +
+
+ ) : null} + + ); +} + +export default SocialEditableSection; diff --git a/blocks/identity-block/features/account-management/_children/SocialEditableSection.test.jsx b/blocks/identity-block/features/account-management/_children/SocialEditableSection.test.jsx new file mode 100644 index 0000000000..477f36e204 --- /dev/null +++ b/blocks/identity-block/features/account-management/_children/SocialEditableSection.test.jsx @@ -0,0 +1,62 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import SocialEditableSection from "./SocialEditableSection"; +import useSocialSignIn from "../../../components/social-sign-on/utils/useSocialSignIn"; + +jest.mock("../../../components/social-sign-on/utils/useSocialSignIn"); + +jest.mock("fusion:properties", () => + jest.fn(() => ({ + locale: "en", + })) +); + +jest.mock("fusion:intl", () => ({ + __esModule: true, + default: jest.fn((locale) => ({ + t: jest.fn((phrase) => require("../../../intl.json")[phrase][locale]), + })), +})); + +describe("SocialEditableSection", () => { + it("should render facebook and google with google and facebook app id", () => { + useSocialSignIn.mockImplementation(() => ({ + facebookAppId: "123", + googleClientId: "456", + })); + + render(); + expect(screen.getAllByText("Connect %{platform}")).toHaveLength(2); + }); + it("should not render facebook and google without id", () => { + useSocialSignIn.mockImplementation(() => ({ + facebookAppId: "", + googleClientId: "", + })); + + render(); + expect(screen.queryAllByText("Connect %{platform}")).toHaveLength(0); + }); + it("should render facebook only with google no id", () => { + useSocialSignIn.mockImplementation(() => ({ + facebookAppId: "123", + googleClientId: "", + })); + + render(); + expect(screen.getAllByText("Connect %{platform}")).toHaveLength(1); + }); + + it("should show text based on hasGoogle and hasFacebook props", () => { + useSocialSignIn.mockImplementation(() => ({ + isFacebookInitialized: true, + isGoogleInitialized: true, + facebookAppId: "123", + googleClientId: "456", + })); + + render(); + expect(screen.getAllByText(/Connected/i)).toHaveLength(2); + }); +}); diff --git a/blocks/identity-block/features/account-management/default.jsx b/blocks/identity-block/features/account-management/default.jsx new file mode 100644 index 0000000000..14e71fa1ae --- /dev/null +++ b/blocks/identity-block/features/account-management/default.jsx @@ -0,0 +1,164 @@ +import React, { useEffect, useState } from "react"; +import PropTypes from "@arc-fusion/prop-types"; +import { useFusionContext } from "fusion:context"; +import getProperties from "fusion:properties"; +import getTranslatedPhrases from "fusion:intl"; +import { Heading } from "@wpmedia/arc-themes-components"; +import useIdentity from "../../components/identity"; +import { GoogleSignInProvider } from "../../components/social-sign-on/utils/googleContext"; +import EmailEditableFieldContainer from "./_children/EmailEditableFieldContainer"; +import PasswordEditableFieldContainer from "./_children/PasswordEditableFieldContainer"; +import SocialEditableSection from "./_children/SocialEditableSection"; + +const BLOCK_CLASS_NAME = "b-account-management"; + +export function AccountManagementPresentational({ header, children }) { + return ( +
+ {header} + {children} +
+ ); +} + +function AccountManagement({ customFields }) { + const [loggedIn, setLoggedIn] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [email, setEmail] = useState(""); + const [hasPassword, setHasPassword] = useState(); + const [hasGoogle, setHasGoogle] = useState(false); + const [hasFacebook, setHasFacebook] = useState(false); + + const { redirectURL, showEmail, showPassword, showSocialProfile } = customFields; + + // get properties from context for using translations in intl.json + // See document for more info https://arcpublishing.atlassian.net/wiki/spaces/TI/pages/2538275032/Lokalise+and+Theme+Blocks + const { arcSite, isAdmin } = useFusionContext(); + const { locale } = getProperties(arcSite); + const phrases = getTranslatedPhrases(locale); + + const { isInitialized, Identity } = useIdentity(); + + // istanbul ignore next + useEffect(() => { + const checkLoggedInStatus = () => + Identity.isLoggedIn().then((isLoggedIn) => { + if (!isLoggedIn) { + window.location = redirectURL; + return; + } + setLoggedIn(true); + }); + if (Identity && !isAdmin) { + checkLoggedInStatus(); + } + }, [Identity, isAdmin, redirectURL]); + + // istanbul ignore next + useEffect(() => { + const getProfile = () => + Identity.getUserProfile().then((profileObject) => { + const { email: loggedInEmail, identities } = profileObject; + + if (loggedInEmail) { + setEmail(loggedInEmail); + } + + const passwordProfile = identities.filter( + ({ type }) => type === "Password" || type === "Identity" + ); + + setHasPassword(passwordProfile?.length > 0); + setHasGoogle(identities.filter(({ type }) => type === "Google").length > 0); + setHasFacebook(identities.filter(({ type }) => type === "Facebook").length > 0); + + setIsLoading(false); + }); + + if (!isAdmin && loggedIn) { + getProfile(); + } + }, [loggedIn, setEmail, Identity, isAdmin]); + + // cause re-render to re-check if identity has social identity + // istanbul ignore next + const unlinkFacebook = () => + Identity.unlinkSocialIdentity("facebook").then(() => setHasFacebook(false)); + // istanbul ignore next + const unlinkGoogle = () => + Identity.unlinkSocialIdentity("google").then(() => setHasGoogle(false)); + + if (!isInitialized || (isLoading && !isAdmin)) { + return null; + } + + const header = phrases.t("identity-block.account-information"); + const socialProfileHeader = phrases.t("identity-block.connected-accounts"); + + // if logged in, return account info + return ( +
+ + {showEmail && ( + + )} + {showPassword && ( + + )} + + {showSocialProfile ? ( + + + + + + ) : null} +
+ ); +} + +AccountManagement.label = "Identity Account Management – Arc Block"; + +AccountManagement.icon = "monitor-user"; + +AccountManagement.propTypes = { + customFields: PropTypes.shape({ + redirectURL: PropTypes.string.tag({ + name: "Redirect URL", + defaultValue: "/account/login/", + }), + showEmail: PropTypes.bool.tag({ + // this is to to show or hide the editable input thing and non-editable text + name: "Enable Email Address Editing", + defaultValue: false, + }), + showPassword: PropTypes.bool.tag({ + // this is to to show or hide the editable input thing and non-editable text + name: "Enable Password Editing", + defaultValue: false, + }), + showSocialProfile: PropTypes.bool.tag({ + // this is to to show or hide the editable social inputs non-editable text + name: "Enable Social Profile Editing", + defaultValue: false, + }), + }), +}; + +export default AccountManagement; diff --git a/blocks/identity-block/features/account-management/default.test.jsx b/blocks/identity-block/features/account-management/default.test.jsx new file mode 100644 index 0000000000..13b357dabe --- /dev/null +++ b/blocks/identity-block/features/account-management/default.test.jsx @@ -0,0 +1,126 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom/extend-expect"; +import { act } from "react-dom/test-utils"; +import useIdentity from "../../components/identity"; +import AccountManagement, { AccountManagementPresentational } from "./default"; + +jest.mock("fusion:properties", () => + jest.fn(() => ({ + locale: "en", + })) +); + +jest.mock("../../components/identity"); + +jest.mock("./_children/PasswordEditableFieldContainer", () => () => { + const MockName = "password-editable-field-container-mock"; + return ( + + Password + + ); +}); + +jest.mock("fusion:intl", () => ({ + __esModule: true, + default: jest.fn((locale) => ({ + t: jest.fn((phrase) => require("../../intl.json")[phrase][locale]), + })), +})); + +const userProfileMock = jest.fn(() => + Promise.resolve({ email: "test@domain.com", identities: [] }) +); + +describe("Account management", () => { + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("renders header text", () => { + render(); + expect(screen.getByText("header")).not.toBeNull(); + }); + + it("should render account management if logged in and initialized", async () => { + useIdentity.mockImplementation(() => ({ + isInitialized: true, + isLoggedIn: () => true, + Identity: { + isLoggedIn: jest.fn(async () => true), + getConfig: jest.fn(async () => ({})), + getUserProfile: userProfileMock, + }, + })); + + await act(async () => { + await render(); + }); + await userProfileMock; + + expect(screen.getByText("Account Information")).not.toBeNull(); + }); + + it("should not render if not logged in and not initialized", async () => { + useIdentity.mockImplementation(() => ({ + isInitialized: false, + isLoggedIn: () => false, + Identity: { + isLoggedIn: jest.fn(async () => false), + getConfig: jest.fn(async () => ({})), + }, + })); + let container; + await act(async () => { + ({ container } = await render()); + }); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows email input editable field if showing email", async () => { + useIdentity.mockImplementation(() => ({ + isInitialized: true, + isLoggedIn: () => true, + Identity: { + isLoggedIn: jest.fn(async () => true), + getConfig: jest.fn(async () => ({})), + getUserProfile: userProfileMock, + }, + })); + + await act(async () => { + await render(); + }); + await userProfileMock; + expect(screen.getByText("Email address")).not.toBeNull(); + }); + + it("hides email input editable field if showing email", () => { + render(); + expect(screen.queryByText("Email address")).toBeNull(); + }); + + it("shows password input editable field if showing password", async () => { + useIdentity.mockImplementation(() => ({ + isInitialized: true, + isLoggedIn: () => true, + Identity: { + isLoggedIn: jest.fn(async () => true), + getConfig: jest.fn(async () => ({})), + getUserProfile: userProfileMock, + }, + })); + + await act(async () => { + await render(); + }); + await userProfileMock; + expect(screen.getByText("Password")).not.toBeNull(); + }); + + it("hides password input editable field if showing password", () => { + render(); + expect(screen.queryByText("Password")).toBeNull(); + }); +}); diff --git a/blocks/identity-block/themes/commerce.json b/blocks/identity-block/themes/commerce.json index 8b97446b8e..da9b31e984 100644 --- a/blocks/identity-block/themes/commerce.json +++ b/blocks/identity-block/themes/commerce.json @@ -1,4 +1,160 @@ { + "account-management": { + "styles": { + "default": { + "font-family": "var(--font-family-primary)", + "margin-block-start": "var(--global-spacing-8)", + "margin-block-end": "var(--global-spacing-2)", + "margin-inline-start": "var(--global-spacing-7)", + "margin-inline-end": "var(--global-spacing-7)", + "components": { + "button": { + "font-size": "var(--global-font-size-4)" + }, + "heading": { + "font-size": "var(--global-font-size-7)", + "margin-block-end": "var(--global-spacing-2)", + "margin-bottom": "var(--global-spacing-2)" + }, + "input": { + "margin-block-end": "var(--global-spacing-5)" + }, + "input-error-tip": { + "color": "var(--status-color-danger)" + }, + "input-input": { + "inline-size": "100%", + "width": "100%", + "padding-block-end": "var(--global-spacing-2)", + "padding-block-start": "var(--global-spacing-2)", + "padding-inline-end": "var(--global-spacing-2)", + "padding-inline-start": "var(--global-spacing-2)", + "padding-left": "var(--global-spacing-2)" + }, + "paragraph": { + "font-family": "var(--font-family-primary)", + "margin-block-end": "var(--global-spacing-5)" + } + } + } + } + }, + "account-management-edit": { + "styles": { + "default": { + "border-color": "var(--border-color)", + "border-radius": "var(--border-radius)", + "border-style": "var(--global-border-style-1)", + "border-width": "var(--global-border-width-1)", + "margin-block-end": "var(--global-spacing-2)", + "padding-block-start": "var(--global-spacing-4)", + "padding-block-end": "var(--global-spacing-4)", + "padding-inline-start": "var(--global-spacing-4)", + "padding-inline-end": "var(--global-spacing-4)", + "components": { + "button-default": { + "color": "var(--color-primary)", + "text-decoration": "underline" + }, + "button-default-hover": { + "color": "var(--color-primary-hover)" + }, + "paragraph": { + "margin-block-end": "0" + } + } + } + } + }, + "account-management-edit-label": { + "styles": { + "default": { + "align-items": "center", + "display": "flex", + "justify-content": "space-between", + "components": { + "paragraph": { + "font-weight": "var(--global-font-weight-7)" + } + } + } + } + }, + "account-management-section": { + "styles": { + "default": { + "margin-block-end": "var(--global-spacing-7)" + } + } + }, + "account-management-social-edit": { + "styles": { + "default": { + "align-items": "center", + "border-color": "var(--border-color)", + "border-radius": "var(--border-radius)", + "border-style": "var(--global-border-style-1)", + "border-width": "var(--global-border-width-1)", + "display": "flex", + "justify-content": "space-between", + "margin-block-end": "var(--global-spacing-2)", + "padding-block-start": "var(--global-spacing-4)", + "padding-block-end": "var(--global-spacing-4)", + "padding-inline-start": "var(--global-spacing-4)", + "padding-inline-end": "var(--global-spacing-4)" + } + } + }, + "forgot-password": { + "styles": { + "default": { + "font-family": "var(--font-family-primary)", + "components": { + "button": { + "font-size": "var(--global-font-size-4)" + }, + "heading": { + "border-block-end-color": "var(--border-color)", + "border-block-end-style": "var(--global-border-style-1)", + "border-block-end-width": "var(--global-border-width-1)", + "font-size": "var(--global-font-size-9)", + "margin-block-end": "var(--global-spacing-4)", + "margin-bottom": "var(--global-spacing-4)", + "padding-block-end": "var(--global-spacing-2)", + "text-align": "center" + }, + "input": { + "margin-block-end": "var(--global-spacing-5)" + }, + "input-error-tip": { + "color": "var(--status-color-danger)" + }, + "input-input": { + "inline-size": "100%", + "width": "100%", + "padding-block-end": "var(--global-spacing-2)", + "padding-block-start": "var(--global-spacing-2)", + "padding-inline-end": "var(--global-spacing-2)", + "padding-inline-start": "var(--global-spacing-2)", + "padding-left": "var(--global-spacing-2)" + }, + "paragraph": { + "font-family": "var(--font-family-primary)", + "margin-block-end": "var(--global-spacing-4)", + "text-align": "center" + } + } + }, + "desktop": { + "components": { + "heading": { + "font-size": "var(--global-font-size-12)", + "padding-block-end": "var(--global-spacing-4)" + } + } + } + } + }, "header-account-action": { "styles": { "default": { @@ -258,5 +414,30 @@ }, "desktop": {} } + }, + "social-sign-on": { + "styles": { + "default": { + "inline-size": "300px", + "margin-inline": "auto", + "components": { + "paragraph": { + "color": "var(--status-color-danger)", + "font-family": "var(--font-family-primary)" + } + } + }, + "desktop": {} + } + }, + "social-sign-on-button-container": { + "styles": { + "default": { + "display": "flex", + "flex-direction": "column", + "gap": "var(--global-spacing-4)" + }, + "desktop": {} + } } } diff --git a/blocks/identity-block/themes/news.json b/blocks/identity-block/themes/news.json index c1b0e18404..da9b31e984 100644 --- a/blocks/identity-block/themes/news.json +++ b/blocks/identity-block/themes/news.json @@ -1,4 +1,110 @@ { + "account-management": { + "styles": { + "default": { + "font-family": "var(--font-family-primary)", + "margin-block-start": "var(--global-spacing-8)", + "margin-block-end": "var(--global-spacing-2)", + "margin-inline-start": "var(--global-spacing-7)", + "margin-inline-end": "var(--global-spacing-7)", + "components": { + "button": { + "font-size": "var(--global-font-size-4)" + }, + "heading": { + "font-size": "var(--global-font-size-7)", + "margin-block-end": "var(--global-spacing-2)", + "margin-bottom": "var(--global-spacing-2)" + }, + "input": { + "margin-block-end": "var(--global-spacing-5)" + }, + "input-error-tip": { + "color": "var(--status-color-danger)" + }, + "input-input": { + "inline-size": "100%", + "width": "100%", + "padding-block-end": "var(--global-spacing-2)", + "padding-block-start": "var(--global-spacing-2)", + "padding-inline-end": "var(--global-spacing-2)", + "padding-inline-start": "var(--global-spacing-2)", + "padding-left": "var(--global-spacing-2)" + }, + "paragraph": { + "font-family": "var(--font-family-primary)", + "margin-block-end": "var(--global-spacing-5)" + } + } + } + } + }, + "account-management-edit": { + "styles": { + "default": { + "border-color": "var(--border-color)", + "border-radius": "var(--border-radius)", + "border-style": "var(--global-border-style-1)", + "border-width": "var(--global-border-width-1)", + "margin-block-end": "var(--global-spacing-2)", + "padding-block-start": "var(--global-spacing-4)", + "padding-block-end": "var(--global-spacing-4)", + "padding-inline-start": "var(--global-spacing-4)", + "padding-inline-end": "var(--global-spacing-4)", + "components": { + "button-default": { + "color": "var(--color-primary)", + "text-decoration": "underline" + }, + "button-default-hover": { + "color": "var(--color-primary-hover)" + }, + "paragraph": { + "margin-block-end": "0" + } + } + } + } + }, + "account-management-edit-label": { + "styles": { + "default": { + "align-items": "center", + "display": "flex", + "justify-content": "space-between", + "components": { + "paragraph": { + "font-weight": "var(--global-font-weight-7)" + } + } + } + } + }, + "account-management-section": { + "styles": { + "default": { + "margin-block-end": "var(--global-spacing-7)" + } + } + }, + "account-management-social-edit": { + "styles": { + "default": { + "align-items": "center", + "border-color": "var(--border-color)", + "border-radius": "var(--border-radius)", + "border-style": "var(--global-border-style-1)", + "border-width": "var(--global-border-width-1)", + "display": "flex", + "justify-content": "space-between", + "margin-block-end": "var(--global-spacing-2)", + "padding-block-start": "var(--global-spacing-4)", + "padding-block-end": "var(--global-spacing-4)", + "padding-inline-start": "var(--global-spacing-4)", + "padding-inline-end": "var(--global-spacing-4)" + } + } + }, "forgot-password": { "styles": { "default": {