diff --git a/e2e/constants.ts b/e2e/constants.ts index ad2e966..59ad973 100644 --- a/e2e/constants.ts +++ b/e2e/constants.ts @@ -27,6 +27,7 @@ export const DEFAULT_CLIENT_LOGO_URI = "https://app.example/logo"; export const DEFAULT_CLIENT_TOS_URI = "https://app.example/tos"; export const DEFAULT_CLIENT_POLICY_URI = "https://app.example/policy"; export const DEFAULT_CLIENT_EMAIL = "maintainers@app.example"; +export const DEFAULT_MAX_AGE = 3600; export const VALID_CLIENT_IDENTIFIER_DOCUMENT = JSON.stringify({ "@context": ["https://www.w3.org/ns/solid/oidc-context.jsonld"], diff --git a/e2e/generatorForm.playwright.ts b/e2e/generatorForm.playwright.ts index 4a378b1..00f2804 100644 --- a/e2e/generatorForm.playwright.ts +++ b/e2e/generatorForm.playwright.ts @@ -34,6 +34,7 @@ import { DEFAULT_CLIENT_POLICY_URI, DEFAULT_CLIENT_TOS_URI, DEFAULT_CLIENT_EMAIL, + DEFAULT_MAX_AGE, } from "./constants"; async function openAccordion(page: Page, selector: string) { @@ -79,6 +80,21 @@ async function fillUserFacingFieldsWithDefaults(page: Page) { await page.locator('input[name="contacts.0"]').fill(DEFAULT_CLIENT_EMAIL); } +async function expectUserFacingFieldsDefaults(page: Page) { + await expect(page.locator('input[name="logoUri"]')).toHaveValue( + DEFAULT_CLIENT_LOGO_URI + ); + await expect(page.locator('input[name="policyUri"]')).toHaveValue( + DEFAULT_CLIENT_POLICY_URI + ); + await expect(page.locator('input[name="tosUri"]')).toHaveValue( + DEFAULT_CLIENT_TOS_URI + ); + await expect(page.locator('input[name="contacts.0"]')).toHaveValue( + DEFAULT_CLIENT_EMAIL + ); +} + async function fillTechnicalFields(page: Page) { await openAccordion(page, ".AdvancedFieldsAccordion"); @@ -90,12 +106,22 @@ async function fillTechnicalFields(page: Page) { // Set default max age. await page.locator('input[name="defaultMaxAge"]').click(); - await page.locator('input[name="defaultMaxAge"]').fill("3600"); + await page + .locator('input[name="defaultMaxAge"]') + .fill(DEFAULT_MAX_AGE.toString()); // Request a time of authentication claim. await page.locator("text=Request a time of authentication claim").click(); } +async function expectTechnicalFieldsDefaults(page: Page) { + await expect(page.locator("input[name='applicationType']")).toHaveValue( + "native" + ); + await expect(page.locator('input[name="defaultMaxAge"]')).toHaveValue("3600"); + await expect(page.locator("input[name='requireAuthTime']")).toBeChecked(); +} + test.describe("Generator page functionality", () => { it("has title Client Identifier Helper", async ({ page }) => { await page.goto("/generator"); @@ -168,7 +194,7 @@ test.describe("Generator page functionality", () => { const clientIdentifierDocument = await clickAndGenerateDocument(page); expect(clientIdentifierDocument.application_type).toBe("native"); - expect(clientIdentifierDocument.default_max_age).toBe(3600); + expect(clientIdentifierDocument.default_max_age).toBe(DEFAULT_MAX_AGE); expect(clientIdentifierDocument.require_auth_time).toBe(true); }); @@ -190,7 +216,7 @@ test.describe("Generator page functionality", () => { expect(clientIdentifierDocument?.contacts[0]).toBe(DEFAULT_CLIENT_EMAIL); expect(clientIdentifierDocument.application_type).toBe("native"); - expect(clientIdentifierDocument.default_max_age).toBe(3600); + expect(clientIdentifierDocument.default_max_age).toBe(DEFAULT_MAX_AGE); expect(clientIdentifierDocument.require_auth_time).toBe(true); }); @@ -228,4 +254,43 @@ test.describe("Generator page functionality", () => { await expect(page.locator("[name=generatedJson]")).not.toBeVisible(); }); + + it("Remembers states and values", async ({ page }) => { + await page.goto("/generator"); + await fillEssentialFieldsWithDefaults(page); + await fillUserFacingFieldsWithDefaults(page); + await fillTechnicalFields(page); + + // Cause field error state for empty clientId. + await page.locator("[name=clientId]").fill(""); + + // Switch page and go back. + await page.locator(".openValidatorPage").click(); + await page.locator(".openGeneratorPage").click(); + + // Expect values to have remained the same. + await expect(page.locator("[name=clientId]")).toHaveValue(""); + await expect(page.locator("[name=clientName]")).toHaveValue( + DEFAULT_CLIENT_NAME + ); + await expect(page.locator("[name=clientUri]")).toHaveValue( + DEFAULT_CLIENT_HOMEPAGE + ); + await expect(page.locator(`[name="redirectUris.0"]`)).toHaveValue( + `${DEFAULT_CLIENT_REDIRECT_URI}1` + ); + await expectUserFacingFieldsDefaults(page); + await expectTechnicalFieldsDefaults(page); + + // Expect clientId error state to have remained. + expect( + await page.locator(`[data-testid=VerboseHelperText].Mui-error`).count() + ).toBe(1); + const errorDescriptions = page.locator( + `[data-testid=VerboseHelperText].Mui-error` + ); + await expect(errorDescriptions).toHaveText( + /The given URI field is not present./ + ); + }); }); diff --git a/src/components/VerboseHelperText.tsx b/src/components/VerboseHelperText.tsx index c4c4c8b..834cc07 100644 --- a/src/components/VerboseHelperText.tsx +++ b/src/components/VerboseHelperText.tsx @@ -29,7 +29,11 @@ export default function VerboseHelperText({ state: VerboseFieldState | undefined; }) { return state ? ( - + {state.statusDescription} ) : ( diff --git a/src/components/VerboseTextFieldArray.tsx b/src/components/VerboseTextFieldArray.tsx index 02da74b..20a45d3 100644 --- a/src/components/VerboseTextFieldArray.tsx +++ b/src/components/VerboseTextFieldArray.tsx @@ -27,7 +27,7 @@ import { TextFieldProps, Typography, } from "@mui/material"; -import { VerboseFieldState } from "../lib/formValidationTypes"; +import { FormFieldState, VerboseFieldState } from "../lib/formValidationTypes"; import FieldNameLabel from "./FieldNameLabel"; import NecessityLabel, { Necessity } from "./NecessityLabel"; import VerboseHelperText from "./VerboseHelperText"; @@ -40,9 +40,8 @@ export type VerboseFieldArrayRenderProps = TextFieldProps & { componentFieldName?: string; description: string | string[]; values: string[]; - state?: VerboseFieldState | undefined; + state?: FormFieldState; necessity?: Necessity; - childStates?: (VerboseFieldState | undefined)[]; allowEmpty?: boolean; pushItem(obj: unknown): void; removeItem(index: number): void; @@ -64,7 +63,6 @@ export default function VerboseTextFieldArray( values, state = undefined, necessity = undefined, - childStates = undefined, allowEmpty = false, pushItem, @@ -96,7 +94,7 @@ export default function VerboseTextFieldArray( - + {/* Field name label */} @@ -138,8 +136,8 @@ export default function VerboseTextFieldArray( name={`${componentName}.${index}`} label={rowLabel} state={ - Array.isArray(childStates) - ? childStates[index] + Array.isArray(state?.childStates) + ? state?.childStates[index] : undefined } value={value} @@ -184,7 +182,6 @@ export default function VerboseTextFieldArray( VerboseTextFieldArray.defaultProps = { state: undefined, necessity: undefined, - childStates: undefined, componentFieldName: undefined, addRowLabel: "Add row", allowEmpty: false, diff --git a/src/components/useExtendedFormik.ts b/src/components/useExtendedFormik.ts new file mode 100644 index 0000000..c50e0ff --- /dev/null +++ b/src/components/useExtendedFormik.ts @@ -0,0 +1,97 @@ +// +// Copyright 2022 Inrupt Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the +// Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import { FormikValues, useFormik } from "formik"; +import { + FormFieldStates, + useFieldStates, + VerboseFieldState, +} from "../lib/formValidationTypes"; + +/** + * Extends the {@link useFormik} function by adding state property supporting + * verbose field state management. + * + * @param initialValues The initial values for the form fields. + * @param initialStates The initial states of the form fields. + * + * @returns formik object + */ +export default function useExtendedFormik({ + initialValues, + initialStates, +}: { + initialValues: FormParameters; + initialStates: FormFieldStates; +}) { + const states = useFieldStates( + initialStates + ); + + const formik = useFormik({ + initialValues, + onSubmit: () => {}, + validateOnBlur: false, + validateOnChange: false, + }); + + const modifiedFormik: Omit< + typeof formik, + "errors" | "setErrors" | "initialErrors" | "setFieldErrors" + > & { + states: typeof states; + } = { ...formik, states }; + + // Helper to add an array field's child value. + const addChild = async (fieldName: keyof FormParameters, value: unknown) => { + await formik.setValues({ + ...formik.values, + [fieldName]: [...formik.values[fieldName], value], + }); + states.setChildren(fieldName, [ + ...(states.all[fieldName].childStates || []), + undefined, + ]); + }; + + // Helper to remove an array field's child value. + const removeChild = async ( + fieldName: keyof FormParameters, + index: number + ) => { + const values = formik.values[fieldName]; + if (!Array.isArray(values)) { + throw new Error( + "Cannot remove child form value from an object that's not an array." + ); + } + await formik.setValues({ + ...formik.values, + [fieldName]: values.filter((_: unknown, i: number) => i !== index), + }); + states.setChildren( + fieldName, + states.all[fieldName].childStates?.filter((_, i) => i !== index) || [] + ); + }; + + return { ...modifiedFormik, addChild, removeChild }; +} diff --git a/src/lib/formValidationTypes.ts b/src/lib/formValidationTypes.ts index 57988a3..9c68b3a 100644 --- a/src/lib/formValidationTypes.ts +++ b/src/lib/formValidationTypes.ts @@ -21,13 +21,19 @@ import { useState } from "react"; +export declare type FormFieldState = { + state?: State; + childStates?: State[]; +}; + /** - * Provides a type to store states of type State of a form - * with field values of type `Value`. + * Provides a type to store states of type `State` of a form + * with the value keys of type `Value`. Additionally, an array + * `childStates` is provided for nested components. * Inspired by `FormikErrors` type. */ export declare type FormFieldStates = { - [K in keyof Values]: { state?: State; childStates?: State[] }; + [K in keyof Values]: FormFieldState; }; /** @@ -41,20 +47,22 @@ export declare type FormFieldHelperTexts = FormFieldStates< >; /** - * Helper function to manage field values of verbose forms. + * Helper function to manage validation state of forms. * * @param initialValues * @returns [states, setStates, setFieldState, setArrayFieldState] */ export function useFieldStates( initialValues: FormFieldStates -): [ - FormFieldStates, - React.Dispatch>>, - (fieldName: keyof IFormParameters, value: V) => void, - (fieldName: keyof IFormParameters, value: V, index: number) => void, - (fieldName: keyof IFormParameters, value: V[]) => void -] { +): { + all: FormFieldStates; + setAll: React.Dispatch< + React.SetStateAction> + >; + setFor: (fieldName: keyof IFormParameters, value: V) => void; + setChild: (fieldName: keyof IFormParameters, value: V, index: number) => void; + setChildren: (fieldName: keyof IFormParameters, value: V[]) => void; +} { const [states, setStates] = useState(initialValues); const setFieldState = (fieldName: keyof IFormParameters, value: V) => { @@ -89,7 +97,13 @@ export function useFieldStates( }); }; - return [states, setStates, setFieldState, setChildState, setChildStates]; + return { + all: states, + setAll: setStates, + setFor: setFieldState, + setChild: setChildState, + setChildren: setChildStates, + }; } export type FieldStatus = diff --git a/src/lib/generatorFormParameters.ts b/src/lib/generatorFormParameters.ts index 404e896..5e51291 100644 --- a/src/lib/generatorFormParameters.ts +++ b/src/lib/generatorFormParameters.ts @@ -36,6 +36,8 @@ export type FormParameters = { defaultMaxAge?: number; }; +// TODO: make this dependent on initial values which ought to be moved here. + const emptyFormState: FormFieldStates< FormParameters, VerboseFieldState | undefined @@ -65,6 +67,7 @@ export function getEmptyFormState() { * @param key The FormParameters key as string. * @returns FormParameters key */ +// TODO: does this warn if parameters are added? export function getFormParametersKey(key: string): keyof FormParameters { const parameterMap: Record = { clientId: "clientId", diff --git a/src/pages/generate.tsx b/src/pages/generate.tsx index b7255bf..c259f92 100644 --- a/src/pages/generate.tsx +++ b/src/pages/generate.tsx @@ -36,41 +36,29 @@ import { TextField, Typography, } from "@mui/material"; -import type { FieldArrayRenderProps, FormikProps } from "formik"; -import { FieldArray, Form, Formik } from "formik"; +import { Form, Formik } from "formik"; import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import FieldNameLabel from "../components/FieldNameLabel"; +import useExtendedFormik from "../components/useExtendedFormik"; import VerboseSlider from "../components/VerboseSlider"; import VerboseTextField from "../components/VerboseTextField"; import VerboseTextFieldArray from "../components/VerboseTextFieldArray"; +import { FieldStatus, VerboseFieldState } from "../lib/formValidationTypes"; import generateClientIdDocument from "../lib/generateDocument/generateDocument"; import { FormParameters, getEmptyFormState, getFormParametersKey, } from "../lib/generatorFormParameters"; -import { - FieldStatus, - useFieldStates, - VerboseFieldState, -} from "../lib/formValidationTypes"; import { statusToNumber } from "../lib/helperFunctions"; import { validateField } from "../lib/validateLocalDocument"; import { localRules } from "../lib/validationRules"; +/** Wrapped by `ClientIdentifierGenerator`, */ export default function ClientIdentifierGenerator() { const [documentJson, setDocumentJson] = useState(""); const [hasFormError, setHasFormError] = useState(""); - const [ - formFieldStates, - setFormFieldStates, - setFormFieldState, - setFormArrayFieldState, - setFormArrayFieldStates, - ] = useFieldStates( - getEmptyFormState() - ); const initialFormValues: FormParameters = { clientId: "", @@ -87,10 +75,42 @@ export default function ClientIdentifierGenerator() { defaultMaxAge: undefined, }; + /** Helper to load form state and values from storage or defaults. */ + const getSessionStorageStateOrDefault: () => { + initialValues: FormParameters; + initialStates: ReturnType; + } = () => { + // Load state from session store, if present, else use empty values. + const formStateString = sessionStorage.getItem("formState") || ""; + try { + const formState = JSON.parse(formStateString); + // Only restore form data younger than an hour. + if (new Date(formState.timestamp) > new Date(Date.now() - 3600)) { + return { + initialValues: formState.values, + initialStates: formState.states, + }; + } + } catch (error) { + // We just return the default values. + } + return { + initialValues: initialFormValues, + initialStates: getEmptyFormState(), + }; + }; + + /** Create a modified formik instance supporting verbose field states. */ + const formik = useExtendedFormik({ + ...getSessionStorageStateOrDefault(), + }); + const onReset = () => { + sessionStorage.removeItem("formValues"); + sessionStorage.removeItem("formValidationState"); setDocumentJson(""); setHasFormError(""); - setFormFieldStates(getEmptyFormState()); + formik.states.setAll(getEmptyFormState()); }; const navigate = useNavigate(); @@ -139,9 +159,9 @@ export default function ClientIdentifierGenerator() { const setFieldState = arrayFieldIndex === undefined ? (state?: VerboseFieldState) => - setFormFieldState(targetFieldKey, state) + formik.states.setFor(targetFieldKey, state) : (state?: VerboseFieldState) => - setFormArrayFieldState(targetFieldKey, state, arrayFieldIndex); + formik.states.setChild(targetFieldKey, state, arrayFieldIndex); // Omit showing success messages on fields. if (mostSevereStatus !== "success") { @@ -161,14 +181,16 @@ export default function ClientIdentifierGenerator() { }; const formHasErrors = () => { - const hasErrors = Object.entries(formFieldStates).some(([, fieldState]) => { - if (Array.isArray(fieldState.childStates)) { - return fieldState.childStates.some( - (state) => state?.statusValue === "error" - ); + const hasErrors = Object.entries(formik.states.all).some( + ([, fieldState]) => { + if (Array.isArray(fieldState.childStates)) { + return fieldState.childStates.some( + (state) => state?.statusValue === "error" + ); + } + return fieldState?.state?.statusValue === "error"; } - return fieldState?.state?.statusValue === "error"; - }); + ); return hasErrors; }; @@ -176,10 +198,9 @@ export default function ClientIdentifierGenerator() { * Handles field validation triggers */ const handleFieldBlur = async ( - form: FormikProps, blurEvent: React.FocusEvent ) => { - form.handleBlur(blurEvent); + formik.handleBlur(blurEvent); const fieldName = blurEvent.target.name; @@ -188,28 +209,42 @@ export default function ClientIdentifierGenerator() { } // If the value has not initially been set, do not validate. - if (!form.getFieldMeta(fieldName).touched && !blurEvent.target.value) { + if (!formik.getFieldMeta(fieldName).touched && !blurEvent.target.value) { return; } - await validateFormField(fieldName, form.values); + await validateFormField(fieldName, formik.values); if (!formHasErrors()) { setHasFormError(""); } + + // Save state of the form. + try { + sessionStorage.setItem( + "formState", + JSON.stringify({ + values: formik.values, + states: formik.states.all, + timestamp: new Date(), + }) + ); + } catch (exception) { + // Failing to save form state is no drama.. + } }; /** Set field validation status for each field. * @returns true, if form has errors. */ - const validateAll = async (form: FormikProps) => { + const validateAll = async () => { const formValues = { - ...form.values, - contacts: form.values.contacts.filter((contact) => contact !== ""), + ...formik.values, + contacts: formik.values.contacts.filter((contact) => contact !== ""), }; const fieldStatuses = ( await Promise.all( - Object.keys(formFieldStates).map(async (key) => { + Object.keys(formik.states.all).map(async (key) => { const formKey = getFormParametersKey(key); const value = formValues[formKey]; @@ -228,14 +263,14 @@ export default function ClientIdentifierGenerator() { // set all fields to touched Object.keys(formValues).forEach((field) => - form.setFieldTouched(field, true) + formik.setFieldTouched(field, true) ); return fieldStatuses.some((s) => s === "error"); }; - const onSubmit = async (form: FormikProps) => { - const hasErrors = await validateAll(form); + const onSubmit = async () => { + const hasErrors = await validateAll(); if (hasErrors) { setHasFormError("There are errors in the form."); @@ -244,8 +279,8 @@ export default function ClientIdentifierGenerator() { setHasFormError(""); const clientIdDocument = generateClientIdDocument({ - ...form.values, - contacts: form.values.contacts.filter((contact) => contact !== ""), + ...formik.values, + contacts: formik.values.contacts.filter((contact) => contact !== ""), compact: false, }); setDocumentJson(clientIdDocument); @@ -302,452 +337,413 @@ export default function ClientIdentifierGenerator() { {}} - initialValues={initialFormValues} - validateOnChange={false} - validateOnBlur={false} + validateOnChange={formik.validateOnChange} + validateOnBlur={formik.validateOnBlur} > - {(form) => ( -
- - - + + handleFieldBlur(form, e)} - inputProps={{ inputMode: "url" }} - fullWidth - size="small" - /> - - - + + + handleFieldBlur(form, e)} - fullWidth - size="small" - /> - - - + + + handleFieldBlur(form, e)} - fullWidth - size="small" - /> - + state={formik.states.all.clientUri.state} + necessity="recommended" + value={formik.values.clientUri} + onChange={formik.handleChange} + onBlur={handleFieldBlur} + fullWidth + size="small" + /> + - - - {(props: FieldArrayRenderProps) => ( - { - props.remove(index); - if (!formFieldStates.redirectUris.childStates) { - return; - } - setFormArrayFieldStates( - "redirectUris", - formFieldStates.redirectUris.childStates.filter( - (_val, i) => index !== i - ) - ); - }} - onChange={form.handleChange} - onBlur={(e) => handleFieldBlur(form, e)} - inputProps={{ inputMode: "url" }} - fullWidth - size="small" - necessity="required" - /> - )} - - + + + formik.addChild("redirectUris", value) + } + removeItem={(index: number) => + formik.removeChild("redirectUris", index) + } + values={formik.values.redirectUris} + onChange={formik.handleChange} + onBlur={handleFieldBlur} + inputProps={{ inputMode: "url" }} + fullWidth + size="small" + necessity="required" + /> + - - - Refresh Tokens - + + + Refresh Tokens + + - - - Field names: - - - - grant_types = ["refresh_token"] - - - - scope = "offline_access" - - + + Field names: + + + + grant_types = ["refresh_token"] + + + + scope = "offline_access" + - - + value={formik.values.useRefreshTokens} + onChange={formik.handleChange} + /> - - + + + - - - - - Additional client information - - - - - + + + + Additional client information + - - - - - + + + + + + + handleFieldBlur(form, e)} - inputProps={{ inputMode: "url" }} - fullWidth - size="small" - /> - - - + + + handleFieldBlur(form, e)} - inputProps={{ inputMode: "url" }} - fullWidth - size="small" - /> - - - + + + handleFieldBlur(form, e)} - inputProps={{ inputMode: "url" }} - fullWidth - size="small" - /> - - - - {(props: FieldArrayRenderProps) => ( - { - props.remove(index); - if ( - formFieldStates.contacts.childStates - ) { - setFormArrayFieldStates( - "contacts", - formFieldStates.contacts.childStates.filter( - (_val, i) => index !== i - ) - ); - } - }} - onChange={form.handleChange} - onBlur={(e) => handleFieldBlur(form, e)} - inputProps={{ inputMode: "email" }} - fullWidth - size="small" - allowEmpty - /> - )} - - + state={formik.states.all.tosUri.state} + value={formik.values.tosUri} + onChange={formik.handleChange} + onBlur={handleFieldBlur} + inputProps={{ inputMode: "url" }} + fullWidth + size="small" + /> - - - + + formik.addChild("contacts", value) + } + removeItem={(index: number) => + formik.removeChild("contacts", index) + } + onChange={formik.handleChange} + onBlur={handleFieldBlur} + inputProps={{ inputMode: "email" }} + fullWidth + size="small" + allowEmpty + /> + + + + + + - - - - - Advanced OIDC options - - - - - + + + + Advanced OIDC options + - - - - - - - - - - Application Type - - - - - Usually, you will develop a Web Application. - With a Client Identifier Document for a - native application, your redirect urls will - go to local host or use a non-http protocol. - - - + + + + + + + + + + - - handleFieldBlur(form, e)} - inputProps={{ - inputMode: "numeric", - pattern: "[0-9]*", + + + Application Type + + + + + Usually, you will develop a Web Application. + With a Client Identifier Document for a native + application, your redirect urls will go to + local host or use a non-http protocol. + + + + + + + + + - + /> - - - + + + - - - - - - {hasFormError} - - - - + + + + + + + {hasFormError} + + + - - )} + +