diff --git a/CHANGELOG.md b/CHANGELOG.md index ce869efbd..91ab49348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +## [0.36.0] - 2023-10-30 + +### Added + +- Introduced the capability to utilize custom components in the Email-Password based recipes' signup form fields by exposing inputComponent types. +- Implemented the functionality to assign default values to the form fields in the Email-Password based recipes. +- Enhanced the onChange function to operate independently without requiring an id field. + +Following is an example of how to use above features. + +```tsx +EmailPassword.init({ + signInAndUpFeature: { + signUpForm: { + formFields: [ + { + id: "select-dropdown", + label: "Select Option", + getDefaultValue: () => "option 2", + inputComponent: ({ value, name, onChange }) => ( + + ) + } + ] + } + } +}); +... +``` + ## [0.35.6] - 2023-10-16 ### Test changes diff --git a/examples/for-tests/src/App.js b/examples/for-tests/src/App.js index f65dac002..505beeb61 100644 --- a/examples/for-tests/src/App.js +++ b/examples/for-tests/src/App.js @@ -168,6 +168,159 @@ const formFields = [ }, ]; +const formFieldsWithDefault = [ + { + id: "country", + label: "Your Country", + placeholder: "Where do you live?", + optional: true, + getDefaultValue: () => "India", + }, + { + id: "select-dropdown", + label: "Select Option", + getDefaultValue: () => "option 2", + inputComponent: ({ value, name, onChange }) => ( + + ), + optional: true, + }, + { + id: "terms", + label: "", + optional: false, + getDefaultValue: () => "true", + inputComponent: ({ name, onChange, value }) => ( +
= ComponentClass
| ((props: P) => JSX.Element);
export declare type FeatureBaseConfig = {
diff --git a/lib/ts/recipe/emailpassword/components/library/formBase.tsx b/lib/ts/recipe/emailpassword/components/library/formBase.tsx
index 1dabbd92a..f9d5e201b 100644
--- a/lib/ts/recipe/emailpassword/components/library/formBase.tsx
+++ b/lib/ts/recipe/emailpassword/components/library/formBase.tsx
@@ -25,7 +25,7 @@ import STGeneralError from "supertokens-web-js/utils/error";
import { MANDATORY_FORM_FIELDS_ID_ARRAY } from "../../constants";
import type { APIFormField } from "../../../../types";
-import type { FormBaseProps } from "../../types";
+import type { FormBaseProps, FormFieldThemeProps } from "../../types";
import type { FormEvent } from "react";
import { Button, FormRow, Input, InputError, Label } from ".";
@@ -37,6 +37,89 @@ type FieldState = {
value: string;
};
+const fetchDefaultValue = (field: FormFieldThemeProps): string => {
+ if (field.getDefaultValue !== undefined) {
+ const defaultValue = field.getDefaultValue();
+ if (typeof defaultValue !== "string") {
+ throw new Error(`getDefaultValue for ${field.id} must return a string`);
+ } else {
+ return defaultValue;
+ }
+ }
+ return "";
+};
+
+function InputComponentWrapper(props: {
+ field: FormFieldThemeProps;
+ type: string;
+ fstate: FieldState;
+ onInputFocus: (field: APIFormField) => void;
+ onInputBlur: (field: APIFormField) => void;
+ onInputChange: (field: APIFormField) => void;
+}) {
+ const { field, type, fstate, onInputFocus, onInputBlur, onInputChange } = props;
+
+ const useCallbackOnInputFocus = useCallback<(value: string) => void>(
+ (value) => {
+ onInputFocus({
+ id: field.id,
+ value,
+ });
+ },
+ [onInputFocus, field]
+ );
+
+ const useCallbackOnInputBlur = useCallback<(value: string) => void>(
+ (value) => {
+ onInputBlur({
+ id: field.id,
+ value,
+ });
+ },
+ [onInputBlur, field]
+ );
+
+ const useCallbackOnInputChange = useCallback(
+ (value) => {
+ onInputChange({
+ id: field.id,
+ value,
+ });
+ },
+ [onInputChange, field]
+ );
+
+ return field.inputComponent !== undefined ? (
+ = ComponentClass | ((props: P) => JSX.Element);
diff --git a/test/end-to-end/signin.test.js b/test/end-to-end/signin.test.js
index 86a204645..e9c4721f1 100644
--- a/test/end-to-end/signin.test.js
+++ b/test/end-to-end/signin.test.js
@@ -669,6 +669,94 @@ describe("SuperTokens SignIn", function () {
});
});
});
+
+ describe("SignIn default field tests", function () {
+ it("Should contain email and password fields prefilled", async function () {
+ await page.evaluate(() => window.localStorage.setItem("SHOW_SIGNIN_DEFAULT_FIELDS", "YES"));
+
+ await page.reload({
+ waitUntil: "domcontentloaded",
+ });
+
+ const expectedDefaultValues = {
+ email: "abc@xyz.com",
+ password: "fakepassword123",
+ };
+
+ const emailInput = await getInputField(page, "email");
+ const defaultEmail = await emailInput.evaluate((f) => f.value);
+ assert.strictEqual(defaultEmail, expectedDefaultValues["email"]);
+
+ const passwordInput = await getInputField(page, "password");
+ const defaultPassword = await passwordInput.evaluate((f) => f.value);
+ assert.strictEqual(defaultPassword, expectedDefaultValues["password"]);
+ });
+
+ it("Should have default values in the signin request payload", async function () {
+ await page.evaluate(() => window.localStorage.setItem("SHOW_SIGNIN_DEFAULT_FIELDS", "YES"));
+
+ await page.reload({
+ waitUntil: "domcontentloaded",
+ });
+
+ const expectedDefautlValues = [
+ { id: "email", value: "abc@xyz.com" },
+ { id: "password", value: "fakepassword123" },
+ ];
+
+ let assertionError = null;
+ let interceptionPassed = false;
+
+ const requestHandler = async (request) => {
+ if (request.url().includes(SIGN_IN_API) && request.method() === "POST") {
+ try {
+ const postData = JSON.parse(request.postData());
+ expectedDefautlValues.forEach(({ id, value }) => {
+ let findFormData = postData.formFields.find((inputData) => inputData.id === id);
+ if (findFormData) {
+ assert.strictEqual(findFormData["value"], value, `Mismatch in payload for key: ${id}`);
+ } else {
+ throw new Error("Field not found in req.data");
+ }
+ });
+ interceptionPassed = true;
+ return request.respond({
+ status: 200,
+ headers: {
+ "access-control-allow-origin": TEST_CLIENT_BASE_URL,
+ "access-control-allow-credentials": "true",
+ },
+ body: JSON.stringify({
+ status: "OK",
+ }),
+ });
+ } catch (error) {
+ assertionError = error; // Store the error
+ }
+ }
+ return request.continue();
+ };
+
+ await page.setRequestInterception(true);
+ page.on("request", requestHandler);
+
+ try {
+ // Perform the button click and wait for all network activity to finish
+ await Promise.all([page.waitForNetworkIdle(), submitForm(page)]);
+ } finally {
+ page.off("request", requestHandler);
+ await page.setRequestInterception(false);
+ }
+
+ if (assertionError) {
+ throw assertionError;
+ }
+
+ if (!interceptionPassed) {
+ throw new Error("test failed");
+ }
+ });
+ });
});
describe("SuperTokens SignIn => Server Error", function () {
diff --git a/test/end-to-end/signup.test.js b/test/end-to-end/signup.test.js
index 857bfbc42..829ad2c4f 100644
--- a/test/end-to-end/signup.test.js
+++ b/test/end-to-end/signup.test.js
@@ -39,6 +39,8 @@ import {
getGeneralError,
waitForSTElement,
backendBeforeEach,
+ setSelectDropdownValue,
+ getInputField,
} from "../helpers";
import {
@@ -342,6 +344,314 @@ describe("SuperTokens SignUp", function () {
assert.deepStrictEqual(emailError, "This email already exists. Please sign in instead.");
});
});
+
+ describe("Signup custom fields test", function () {
+ beforeEach(async function () {
+ // set cookie and reload which loads the form with custom field
+ await page.evaluate(() => window.localStorage.setItem("SHOW_CUSTOM_FIELDS", "YES"));
+
+ await page.reload({
+ waitUntil: "domcontentloaded",
+ });
+ await toggleSignInSignUp(page);
+ });
+
+ it("Check if the custom fields are loaded", async function () {
+ let text = await getAuthPageHeaderText(page);
+ assert.deepStrictEqual(text, "Sign Up");
+
+ // check if select dropdown is loaded
+ const selectDropdownExists = await waitForSTElement(page, "select");
+ assert.ok(selectDropdownExists, "Select dropdown exists");
+
+ // check if checbox is loaded
+ const checkboxExists = await waitForSTElement(page, 'input[type="checkbox"]');
+ assert.ok(checkboxExists, "Checkbox exists");
+ });
+
+ it("Should show error messages, based on optional flag", async function () {
+ await submitForm(page);
+ let formFieldErrors = await getFieldErrors(page);
+
+ // 2 regular form field errors +
+ // 1 required custom field => terms checkbox
+ assert.deepStrictEqual(formFieldErrors, [
+ "Field is not optional",
+ "Field is not optional",
+ "Field is not optional",
+ ]);
+
+ // supply values for regular-required fields only
+ await setInputValues(page, [
+ { name: "email", value: "jack.doe@supertokens.io" },
+ { name: "password", value: "Str0ngP@ssw0rd" },
+ ]);
+
+ await submitForm(page);
+ formFieldErrors = await getFieldErrors(page);
+ assert.deepStrictEqual(formFieldErrors, ["Field is not optional"]);
+
+ // check terms and condition checkbox
+ let termsCheckbox = await waitForSTElement(page, '[name="terms"]');
+ await page.evaluate((e) => e.click(), termsCheckbox);
+
+ //un-checking the required checkbox should throw custom error message
+ await page.evaluate((e) => e.click(), termsCheckbox);
+
+ await submitForm(page);
+ formFieldErrors = await getFieldErrors(page);
+ assert.deepStrictEqual(formFieldErrors, ["Please check Terms and conditions"]);
+ });
+
+ it("Check if custom values are part of the signup payload", async function () {
+ const customFields = {
+ terms: "true",
+ "select-dropdown": "option 3",
+ };
+ let assertionError = null;
+ let interceptionPassed = false;
+
+ const requestHandler = async (request) => {
+ if (request.url().includes(SIGN_UP_API) && request.method() === "POST") {
+ try {
+ const postData = JSON.parse(request.postData());
+ Object.keys(customFields).forEach((key) => {
+ let findFormData = postData.formFields.find((inputData) => inputData.id === key);
+ if (findFormData) {
+ assert.strictEqual(
+ findFormData["value"],
+ customFields[key],
+ `Mismatch in payload for key: ${key}`
+ );
+ } else {
+ throw new Error("Field not found in req.data");
+ }
+ });
+ interceptionPassed = true;
+ return request.respond({
+ status: 200,
+ headers: {
+ "access-control-allow-origin": TEST_CLIENT_BASE_URL,
+ "access-control-allow-credentials": "true",
+ },
+ body: JSON.stringify({
+ status: "OK",
+ }),
+ });
+ } catch (error) {
+ assertionError = error; // Store the error
+ }
+ }
+ return request.continue();
+ };
+
+ await page.setRequestInterception(true);
+ page.on("request", requestHandler);
+
+ try {
+ // Fill and submit the form with custom fields
+ await setInputValues(page, [
+ { name: "email", value: "john.doe@supertokens.io" },
+ { name: "password", value: "Str0ngP@assw0rd" },
+ ]);
+
+ await setSelectDropdownValue(page, "select", customFields["select-dropdown"]);
+
+ // Check terms and condition checkbox
+ let termsCheckbox = await waitForSTElement(page, '[name="terms"]');
+ await page.evaluate((e) => e.click(), termsCheckbox);
+
+ // Perform the button click and wait for all network activity to finish
+ await Promise.all([page.waitForNetworkIdle(), submitForm(page)]);
+ } finally {
+ page.off("request", requestHandler);
+ await page.setRequestInterception(false);
+ }
+
+ if (assertionError) {
+ throw assertionError;
+ }
+
+ if (!interceptionPassed) {
+ throw new Error("test failed");
+ }
+ });
+ });
+
+ // Default values test
+ describe("Signup default value for fields test", function () {
+ beforeEach(async function () {
+ // set cookie and reload which loads the form fields with default values
+ await page.evaluate(() => window.localStorage.setItem("SHOW_CUSTOM_FIELDS_WITH_DEFAULT_VALUES", "YES"));
+
+ await page.reload({
+ waitUntil: "domcontentloaded",
+ });
+ await toggleSignInSignUp(page);
+ });
+
+ it("Check if default values are set already", async function () {
+ const fieldsWithDefault = {
+ country: "India",
+ "select-dropdown": "option 2",
+ terms: true,
+ };
+
+ // regular input field default value
+ const countryInput = await getInputField(page, "country");
+ const defaultCountry = await countryInput.evaluate((f) => f.value);
+ assert.strictEqual(defaultCountry, fieldsWithDefault["country"]);
+
+ // custom dropdown default value is also getting set correctly
+ const selectDropdown = await waitForSTElement(page, "select");
+ const defaultOption = await selectDropdown.evaluate((f) => f.value);
+ assert.strictEqual(defaultOption, fieldsWithDefault["select-dropdown"]);
+
+ // custom dropdown default value is also getting set correctly
+ const termsCheckbox = await waitForSTElement(page, '[name="terms"]');
+ // checkbox is checked
+ const defaultChecked = await termsCheckbox.evaluate((f) => f.checked);
+ assert.strictEqual(defaultChecked, fieldsWithDefault["terms"]);
+ // also the value = string
+ const defaultValue = await termsCheckbox.evaluate((f) => f.value);
+ assert.strictEqual(defaultValue, fieldsWithDefault["terms"].toString());
+ });
+
+ it("Check if changing the field value actually overwrites the default value", async function () {
+ const updatedFields = {
+ country: "USA",
+ "select-dropdown": "option 3",
+ };
+
+ await setInputValues(page, [{ name: "country", value: updatedFields["country"] }]);
+ await setSelectDropdownValue(page, "select", updatedFields["select-dropdown"]);
+
+ // input field default value
+ const countryInput = await getInputField(page, "country");
+ const updatedCountry = await countryInput.evaluate((f) => f.value);
+ assert.strictEqual(updatedCountry, updatedFields["country"]);
+
+ // dropdown default value is also getting set correctly
+ const selectDropdown = await waitForSTElement(page, "select");
+ const updatedOption = await selectDropdown.evaluate((f) => f.value);
+ assert.strictEqual(updatedOption, updatedFields["select-dropdown"]);
+ });
+
+ it("Check if default values are getting sent in signup-payload", async function () {
+ // directly submit the form and test the payload
+ const expectedDefautlValues = [
+ { id: "email", value: "test@one.com" },
+ { id: "password", value: "fakepassword123" },
+ { id: "terms", value: "true" },
+ { id: "select-dropdown", value: "option 2" },
+ { id: "country", value: "India" },
+ ];
+
+ let assertionError = null;
+ let interceptionPassed = false;
+
+ const requestHandler = async (request) => {
+ if (request.url().includes(SIGN_UP_API) && request.method() === "POST") {
+ try {
+ const postData = JSON.parse(request.postData());
+ expectedDefautlValues.forEach(({ id, value }) => {
+ let findFormData = postData.formFields.find((inputData) => inputData.id === id);
+ if (findFormData) {
+ assert.strictEqual(findFormData["value"], value, `Mismatch in payload for key: ${id}`);
+ } else {
+ throw new Error("Field not found in req.data");
+ }
+ });
+ interceptionPassed = true;
+ return request.respond({
+ status: 200,
+ headers: {
+ "access-control-allow-origin": TEST_CLIENT_BASE_URL,
+ "access-control-allow-credentials": "true",
+ },
+ body: JSON.stringify({
+ status: "OK",
+ }),
+ });
+ } catch (error) {
+ assertionError = error; // Store the error
+ }
+ }
+ return request.continue();
+ };
+
+ await page.setRequestInterception(true);
+ page.on("request", requestHandler);
+
+ try {
+ // Perform the button click and wait for all network activity to finish
+ await Promise.all([page.waitForNetworkIdle(), submitForm(page)]);
+ } finally {
+ page.off("request", requestHandler);
+ await page.setRequestInterception(false);
+ }
+
+ if (assertionError) {
+ throw assertionError;
+ }
+
+ if (!interceptionPassed) {
+ throw new Error("test failed");
+ }
+ });
+ });
+
+ describe("Incorrect field config test", function () {
+ beforeEach(async function () {
+ // set cookie and reload which loads the form fields with default values
+ await page.evaluate(() => window.localStorage.setItem("SHOW_INCORRECT_FIELDS", "YES"));
+
+ await page.reload({
+ waitUntil: "domcontentloaded",
+ });
+ });
+
+ it("Check if incorrect getDefaultValue throws error", async function () {
+ let pageErrorMessage = "";
+ page.on("pageerror", (err) => {
+ pageErrorMessage = err.message;
+ });
+
+ await page.reload({
+ waitUntil: "domcontentloaded",
+ });
+ await toggleSignInSignUp(page);
+
+ const expectedErrorMessage = "getDefaultValue for country must return a string";
+ assert(
+ pageErrorMessage.includes(expectedErrorMessage),
+ `Expected "${expectedErrorMessage}" to be included in page-error`
+ );
+ });
+
+ it("Check if non-string params to onChange throws error", async function () {
+ await page.evaluate(() => window.localStorage.setItem("INCORRECT_ONCHANGE", "YES"));
+ await page.reload({
+ waitUntil: "domcontentloaded",
+ });
+ await toggleSignInSignUp(page);
+
+ let pageErrorMessage = "";
+ page.on("pageerror", (err) => {
+ pageErrorMessage = err.message;
+ });
+
+ // check terms and condition checkbox since it emits non-string value => boolean
+ let termsCheckbox = await waitForSTElement(page, '[name="terms"]');
+ await page.evaluate((e) => e.click(), termsCheckbox);
+
+ const expectedErrorMessage = "terms value must be a string";
+ assert(
+ pageErrorMessage.includes(expectedErrorMessage),
+ `Expected "${expectedErrorMessage}" to be included in page-error`
+ );
+ });
+ });
});
describe("SuperTokens SignUp => Server Error", function () {
diff --git a/test/helpers.js b/test/helpers.js
index bc7138534..ebfbbc067 100644
--- a/test/helpers.js
+++ b/test/helpers.js
@@ -417,6 +417,20 @@ export async function setInputValues(page, fields) {
return await new Promise((r) => setTimeout(r, 300));
}
+export async function setSelectDropdownValue(page, selector, optionValue) {
+ const shadowRootHandle = await getShadowRootHandle(page);
+ return await page.evaluate(
+ (root, selector, optionValue) => {
+ const select = root.querySelector(selector);
+ select.value = optionValue;
+ select.dispatchEvent(new Event("change", { bubbles: true }));
+ },
+ shadowRootHandle,
+ selector,
+ optionValue
+ );
+}
+
export async function clearBrowserCookiesWithoutAffectingConsole(page, console) {
let toReturn = [...console];
const client = await page.target().createCDPSession();
@@ -1006,3 +1020,8 @@ export async function backendBeforeEach() {
}).catch(console.error);
}
}
+
+export async function getShadowRootHandle(page) {
+ const hostElement = await page.$(ST_ROOT_SELECTOR);
+ return await page.evaluateHandle((el) => el.shadowRoot, hostElement);
+}
diff --git a/test/with-typescript/src/App.tsx b/test/with-typescript/src/App.tsx
index 65b0a3a5d..1f8a4b3fa 100644
--- a/test/with-typescript/src/App.tsx
+++ b/test/with-typescript/src/App.tsx
@@ -373,6 +373,71 @@ function getEmailPasswordConfigs() {
placeholder: "Where do you live?",
optional: true,
},
+ {
+ id: "terms",
+ label: "",
+ optional: false,
+ getDefaultValue: () => "true",
+ inputComponent: (inputProps) => (
+