Skip to content

Commit

Permalink
Added support for resetting forms (#2691)
Browse files Browse the repository at this point in the history
Action forms also have a default 'Discard changes' button now
  • Loading branch information
dpwatrous committed Mar 23, 2023
1 parent 778aca0 commit 1154a4e
Show file tree
Hide file tree
Showing 12 changed files with 262 additions and 82 deletions.
11 changes: 10 additions & 1 deletion packages/common/src/ui-common/action/__tests__/action.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ describe("Action tests", () => {
const action = new HelloAction({
subject: "planet",
});

// Test that we can call waitFor() functions even while execute/initialize
// aren't running, and they return immediately
await action.waitForInitialization();
await action.waitForExecution();

action.initialize();

expect(action.isInitialized).toBe(false);
Expand Down Expand Up @@ -37,7 +43,10 @@ describe("Action tests", () => {

// Can change form values and execute again
action.form.updateValue("subject", "universe");
await action.execute();
action.execute();
// Calling waitForExecution() should be the same as awaiting
// the execute() return value
await action.waitForExecution();
expect(action.message).toEqual("Hello universe from Contoso");

// Validation should have only happened once per execution
Expand Down
129 changes: 91 additions & 38 deletions packages/common/src/ui-common/action/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ export interface Action<V extends FormValues = FormValues> {
onValidateSync?(formValues: V): ValidationStatus;
onValidateAsync?(formValues: V): Promise<ValidationStatus>;
onExecute(formValues: V): Promise<void>;

/**
* Returns a promise that resolves when action execution
* has finished. If there is no execution currently in-progress,
* the promise will resolve immediately.
*/
waitForExecution(): Promise<void>;

/**
* Returns a promise that resolves when action initialization
* has finished. If there is no initialization currently in-progress,
* the promise will resolve immediately.
*/
waitForInitialization(): Promise<void>;
}

export type ActionExecutionResult = {
Expand All @@ -25,6 +39,11 @@ export abstract class AbstractAction<V extends FormValues>
protected _form?: Form<V>;

private _isInitialized: boolean = false;

private _isExecuting = false;
private _executionDeferred = new Deferred();

private _isInitializing = false;
private _initializationDeferred = new Deferred();

/**
Expand All @@ -46,27 +65,44 @@ export abstract class AbstractAction<V extends FormValues>
}

async initialize(): Promise<void> {
const initialValues = await this.onInitialize();
this._form = this.buildForm(initialValues);
this.form.onValidateSync = (values) => {
return this._validateSync(values);
};
this.form.onValidateAsync = (values) => {
return this._validateAsync(values);
};
try {
this._isInitializing = true;

const initialValues = await this.onInitialize();

this._isInitialized = true;
this._initializationDeferred.resolve();
this._form = this.buildForm(initialValues);
this.form.onValidateSync = (values) => {
return this._validateSync(values);
};
this.form.onValidateAsync = (values) => {
return this._validateAsync(values);
};

this._isInitialized = true;
this._initializationDeferred.resolve();
} catch (e) {
this._initializationDeferred.reject(e);
} finally {
this._isInitializing = false;
}
}

waitForInitialization(): Promise<void> {
if (this._initializationDeferred.done) {
if (!this._isInitializing || this._initializationDeferred.done) {
return Promise.resolve();
} else {
return this._initializationDeferred.promise;
}
}

waitForExecution(): Promise<void> {
if (!this._isExecuting || this._executionDeferred.done) {
return Promise.resolve();
} else {
return this._executionDeferred.promise;
}
}

private _validateSync(values: V): ValidationStatus {
if (this.onValidateSync) {
return this.onValidateSync(values);
Expand All @@ -82,40 +118,57 @@ export abstract class AbstractAction<V extends FormValues>
}

async execute(): Promise<ActionExecutionResult> {
// Store a reference to the current form values at the time validation
// was run in case they change
const formValues = this.form.values;

// Finalize will force the validation to complete, and not be
// pre-empted by a subsequent call to validate()
const snapshot = await this.form.validate({ force: true });

const validationStatus = snapshot.overallStatus;
if (!validationStatus) {
// This would indicate a bug
throw new Error(
"Form validation failed: validation status is null or undefined"
);
}

const executionResult: ActionExecutionResult = {
success: false,
formValidationStatus: validationStatus,
// Default to error status in case an exception is thrown
formValidationStatus: new ValidationStatus(
"error",
"Failed to execute action"
),
};

if (validationStatus.level === "error") {
// Validation failed - early out
return executionResult;
}

try {
await this.onExecute(formValues);
executionResult.success = true;
this._isExecuting = true;

// Store a reference to the current form values at the time validation
// was run in case they change
const formValues = this.form.values;

// Finalize will force the validation to complete, and not be
// pre-empted by a subsequent call to validate()
const snapshot = await this.form.validate({ force: true });

const validationStatus = snapshot.overallStatus;
if (!validationStatus) {
// This would indicate a bug
throw new Error(
"Form validation failed: validation status is null or undefined"
);
}

executionResult.formValidationStatus = validationStatus;
if (validationStatus.level === "error") {
// Validation failed - early out
return executionResult;
}

try {
await this.onExecute(formValues);
executionResult.success = true;
} catch (e) {
executionResult.error = e;
getLogger().warn("Action failed to execute:", e);
}

this._executionDeferred.resolve();
} catch (e) {
executionResult.error = e;
getLogger().warn("Action failed to execute:", e);
this._executionDeferred.reject(e);
} finally {
// Create a new deferred execution object since execution
// can happen again and again
this._executionDeferred = new Deferred();
this._isExecuting = false;
}

return executionResult;
}

Expand Down
46 changes: 46 additions & 0 deletions packages/common/src/ui-common/form/__tests__/form.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,52 @@ describe("Form tests", () => {
expect(form.values.numberPlusOne).toBe(3);
expect(form.values.numberPlusTwo).toBe(4);
});

test("Reset to initial values", async () => {
type AnimalFormValues = {
family?: string;
genus?: string;
species?: string;
};

const form = createForm<AnimalFormValues>({
values: {
family: "Canidae",
genus: "Canis",
species: "Canis familiaris",
},
onValidateSync: (values) => {
if (values.family !== "Canidae") {
return new ValidationStatus("error", "Dogs only!");
}
return new ValidationStatus("ok");
},
});

await form.validate();
expect(form.validationStatus?.level).toEqual("ok");

// Change values and make the form invalid
form.setValues({
family: "Felidae",
genus: "Felis",
species: "Felis catus",
});
await form.validate();
expect(form.validationStatus?.level).toEqual("error");
expect(form.validationStatus?.message).toEqual("Dogs only!");

// Resetting the form restores the original values, and makes the
// form valid again
form.reset();
expect(form.values).toStrictEqual({
family: "Canidae",
genus: "Canis",
species: "Canis familiaris",
});
await form.validate();
expect(form.validationStatus?.level).toEqual("ok");
});
});

type NationalParkFormValues = {
Expand Down
5 changes: 5 additions & 0 deletions packages/common/src/ui-common/form/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ export interface Form<V extends FormValues> {
*/
updateValue<K extends ParameterName<V>>(name: K, value: V[K]): void;

/**
* Reset the form to the initial values it was constructed with
*/
reset(): void;

onValidateSync?: (values: V) => ValidationStatus;

onValidateAsync?: (values: V) => Promise<ValidationStatus>;
Expand Down
13 changes: 13 additions & 0 deletions packages/common/src/ui-common/form/internal/form-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const asyncValidationDelay = 300;
* Internal form implementation
*/
export class FormImpl<V extends FormValues> implements Form<V> {
// A copy of the form's initial values used for resetting the form
private _initialValuesCopy?: V;

title?: string;
description?: string;

Expand Down Expand Up @@ -96,6 +99,10 @@ export class FormImpl<V extends FormValues> implements Form<V> {

constructor(init: FormInit<V>) {
this._values = init.values;

// Clone a copy of the form's values so we can reset it
this._initialValuesCopy = cloneDeep(this.values);

this.title = init.title;
this.description = init.description;

Expand Down Expand Up @@ -173,6 +180,12 @@ export class FormImpl<V extends FormValues> implements Form<V> {
return entry;
}

reset(): void {
if (this._initialValuesCopy) {
this.setValues(this._initialValuesCopy);
}
}

evaluate(): boolean {
const propsChanged = this._updateDynamicProperties(this.values);
if (propsChanged) {
Expand Down
4 changes: 4 additions & 0 deletions packages/common/src/ui-common/form/subform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ export class SubForm<
this.form.updateValue(name, value);
}

reset(): void {
this.form.reset();
}

async validate(opts?: ValidationOpts): Promise<ValidationSnapshot<S>> {
return this.form.validate(opts);
}
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/ui-common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ export { uniqueElementId } from "./dom";
export { autoFormat } from "./format";
export { createForm } from "./form";
export { copyToClipboard } from "./clipboard";
export { translate } from "./localization";
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export class StandardLocalizer implements Localizer {
return "Resource Group";
case "accountName":
return "Account Name";
case "form.buttons.apply":
return "Apply";
case "form.buttons.discardChanges":
return "Discard changes";
}

throw new Error("Unable to translate string " + message);
Expand Down
13 changes: 5 additions & 8 deletions packages/react/src/ui-react/account/create-account-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,16 @@ export type CreateAccountFormValues = {
};

export class CreateAccountAction extends AbstractAction<CreateAccountFormValues> {
private _defaultValues: CreateAccountFormValues = {};
private _initialValues: CreateAccountFormValues = {};

async onInitialize(): Promise<CreateAccountFormValues> {
// TODO: Default some of these values. We'll probably want to make
// this a CreateOrUpdate action and support loading an existing
// account too.
return this._defaultValues;
return this._initialValues;
}

constructor(defaultValues: CreateAccountFormValues) {
constructor(initialValues: CreateAccountFormValues) {
super();
if (defaultValues) {
this._defaultValues = defaultValues;
if (initialValues) {
this._initialValues = initialValues;
}
}

Expand Down
Loading

0 comments on commit 1154a4e

Please sign in to comment.