Skip to content
This repository has been archived by the owner on Jul 19, 2023. It is now read-only.

feat: Save Generator Form State for a Session #185

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]";
export const DEFAULT_MAX_AGE = 3600;

export const VALID_CLIENT_IDENTIFIER_DOCUMENT = JSON.stringify({
"@context": ["https://www.w3.org/ns/solid/oidc-context.jsonld"],
Expand Down
71 changes: 68 additions & 3 deletions e2e/generatorForm.playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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");

Expand All @@ -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");
Expand Down Expand Up @@ -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);
});

Expand All @@ -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);
});

Expand Down Expand Up @@ -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./
);
});
});
6 changes: 5 additions & 1 deletion src/components/VerboseHelperText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ export default function VerboseHelperText({
state: VerboseFieldState | undefined;
}) {
return state ? (
<FormHelperText className={`Mui-${state.statusValue}`} sx={statusColors}>
<FormHelperText
className={`Mui-${state.statusValue}`}
data-testid="VerboseHelperText"
sx={statusColors}
>
{state.statusDescription}
</FormHelperText>
) : (
Expand Down
13 changes: 5 additions & 8 deletions src/components/VerboseTextFieldArray.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -40,9 +40,8 @@ export type VerboseFieldArrayRenderProps = TextFieldProps & {
componentFieldName?: string;
description: string | string[];
values: string[];
state?: VerboseFieldState | undefined;
state?: FormFieldState<VerboseFieldState | undefined>;
necessity?: Necessity;
childStates?: (VerboseFieldState | undefined)[];
allowEmpty?: boolean;
pushItem(obj: unknown): void;
removeItem(index: number): void;
Expand All @@ -64,7 +63,6 @@ export default function VerboseTextFieldArray(
values,
state = undefined,
necessity = undefined,
childStates = undefined,
allowEmpty = false,

pushItem,
Expand Down Expand Up @@ -96,7 +94,7 @@ export default function VerboseTextFieldArray(
</Grid>

<Grid item>
<VerboseHelperText state={state} />
<VerboseHelperText state={state?.state} />
</Grid>

{/* Field name label */}
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -184,7 +182,6 @@ export default function VerboseTextFieldArray(
VerboseTextFieldArray.defaultProps = {
state: undefined,
necessity: undefined,
childStates: undefined,
componentFieldName: undefined,
addRowLabel: "Add row",
allowEmpty: false,
Expand Down
97 changes: 97 additions & 0 deletions src/components/useExtendedFormik.ts
Original file line number Diff line number Diff line change
@@ -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<FormParameters extends FormikValues>({
initialValues,
initialStates,
}: {
initialValues: FormParameters;
initialStates: FormFieldStates<FormParameters, VerboseFieldState | undefined>;
}) {
const states = useFieldStates<FormParameters, VerboseFieldState | undefined>(
initialStates
);

const formik = useFormik<FormParameters>({
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 };
}
38 changes: 26 additions & 12 deletions src/lib/formValidationTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,19 @@

import { useState } from "react";

export declare type FormFieldState<State> = {
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<Values>` type.
*/
export declare type FormFieldStates<Values, State> = {
[K in keyof Values]: { state?: State; childStates?: State[] };
[K in keyof Values]: FormFieldState<State>;
};

/**
Expand All @@ -41,20 +47,22 @@ export declare type FormFieldHelperTexts<Values> = 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<IFormParameters, V>(
initialValues: FormFieldStates<IFormParameters, V>
): [
FormFieldStates<IFormParameters, V>,
React.Dispatch<React.SetStateAction<FormFieldStates<IFormParameters, V>>>,
(fieldName: keyof IFormParameters, value: V) => void,
(fieldName: keyof IFormParameters, value: V, index: number) => void,
(fieldName: keyof IFormParameters, value: V[]) => void
] {
): {
all: FormFieldStates<IFormParameters, V>;
setAll: React.Dispatch<
React.SetStateAction<FormFieldStates<IFormParameters, V>>
>;
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) => {
Expand Down Expand Up @@ -89,7 +97,13 @@ export function useFieldStates<IFormParameters, V>(
});
};

return [states, setStates, setFieldState, setChildState, setChildStates];
return {
all: states,
setAll: setStates,
setFor: setFieldState,
setChild: setChildState,
setChildren: setChildStates,
};
}

export type FieldStatus =
Expand Down
3 changes: 3 additions & 0 deletions src/lib/generatorFormParameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, keyof FormParameters> = {
clientId: "clientId",
Expand Down
Loading