Action Example
This example shows a form with an actions bar: a Submit and a Cancel Action in a Group inside the form. Submit runs a SubmitAction, Cancel a RejectAction, and Enter and Escape reach them through the form's confirm() and reject().
Demo
Form is invalid and unchanged; Submit is not executable
Fill both fields, then submit
What the demo shows
- An actions bar.
actionsis aGroupof two actions inside the form. An action sends nothing, soform.valueis{ name, email }, and the template renders the bar with onev-forover its fields, from each action'slabel,icon,executableandbusy. executable. A button is disabled while its action is not executable.SubmitActionmakes Submit executable while the form is valid and notpending, so Submit is disabled while a field is invalid and while the submit runs. Cancel is executable whatever the form's validity.- Enter and Escape. The form's
keydownhandlers callform.confirm()andform.reject(). Each finds the action whoseSubmitActionorRejectActiontargets the form and executes it if it is executable;confirm()returnsundefinedwhere it executes nothing. - Outcomes. A submit resolves with a
SubmitResult(sentandreceived), aSubmitFailedExceptionwhere the handler failed, or aSubmitRefusedExceptionwhere the submit was refused before the handler ran. The log shows each. A refusal does not occur through the buttons or Enter, which run only an executable action; "Call submit.execute() directly" bypasses that check: on an invalid form it is refused with the reason'invalid', and a second call during a run with the reason'running'. - A server error on a field. The handler waits 1.2 seconds, as a request would. For an
@example.comaddress it writes aValidationErrorwith the origin'server'to the email field and throws, so the submit ends with aSubmitFailedExceptionand the field shows the error. The default rule of that origin (ErrorPolicy) removes the error when the user leaves the email field after changing the address: the field writestouched = truewhen it loses focus. - Rebinding. Otherwise the handler returns the record as stored (name trimmed, email lower-cased), and
SubmitActionrebinds the form to it: the inputs show the stored record andisChangedisfalse. Cancel rebinds the form to itsoriginalValue. busyand the lock.SubmitActionmakes the form'readonly'while the submit runs, so the inputs, bound toeffectiveEnabled, accept no input for that time; the form isbusymeanwhile.
Source Code
<template>
<div class="action-demo">
<v-card class="mb-4">
<v-card-title>Sign-up</v-card-title>
<v-card-text>
<!-- Enter and Escape find the form's submit and cancel actions through confirm() and reject() -->
<v-form @submit.prevent @keydown.enter.prevent="confirm" @keydown.esc="reject">
<v-text-field
v-model="form.fields.name.value"
label="Name"
:error-messages="getErrorMessages(form.fields.name)"
:disabled="!form.fields.name.effectiveEnabled"
hide-details="auto"
@update:focused="(focused) => focused || (form.fields.name.touched = true)"
></v-text-field>
<v-text-field
v-model="form.fields.email.value"
label="Email"
:error-messages="getErrorMessages(form.fields.email)"
:disabled="!form.fields.email.effectiveEnabled"
hint="An @example.com address fails on the server"
persistent-hint
hide-details="auto"
class="mt-4"
@update:focused="(focused) => focused || (form.fields.email.touched = true)"
></v-text-field>
</v-form>
</v-card-text>
<!-- the actions bar: every action of the group, rendered from its label, icon and executable -->
<v-card-actions>
<v-btn
v-for="(action, name) in form.fields.actions.fields"
:key="name"
:prepend-icon="action.icon"
:disabled="!action.executable"
:loading="action.busy"
@click="run(action)"
>
{{ action.label }}
</v-btn>
</v-card-actions>
</v-card>
<v-card>
<v-card-title>Action State</v-card-title>
<v-card-text>
<p>
Form is {{ form.valid ? 'valid' : 'invalid' }} and {{ form.isChanged ? 'changed' : 'unchanged' }};
Submit is {{ submit.executable ? 'executable' : 'not executable' }}{{ submit.busy ? ' and busy' : '' }}
</p>
<v-btn variant="text" size="small" class="my-2" @click="run(submit)">
Call submit.execute() directly
</v-btn>
<pre class="output">{{ log.length ? log.join('\n') : 'Fill both fields, then submit' }}</pre>
</v-card-text>
</v-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import {
Action,
type CommandException,
type FieldBase,
Field,
Group,
RejectAction,
SubmitAction,
SubmitFailedException,
SubmitRefusedException,
type SubmitResult,
ValidationError,
Validators,
} from '../../src'; // from '@dynamicforms/vue-forms'
interface Registration {
name: string;
email: string;
}
const log = ref<string[]>([]);
function report(message: string) {
log.value = [...log.value, message].slice(-6);
}
// The submit handler stands in for a server: it stores the record and returns it as stored, or rejects the address
// and reports the error for the email field
async function register(value: Registration): Promise<Registration> {
await new Promise((resolve) => setTimeout(resolve, 1200));
if (value.email.endsWith('@example.com')) {
const email = form.fields.email;
email.errors = [
...email.errors,
new ValidationError('email_rejected', {}, 'example.com addresses are not accepted', 'server'),
];
throw new Error('the server rejected the address');
}
return { name: value.name.trim(), email: value.email.toLowerCase() };
}
// The target of both actions is the form: an action's parent is the actions bar, whose parent is the form
const target = (action: FieldBase) => action.parent?.parent;
const form = new Group({
name: new Field({ value: '', validators: [new Validators.Required()] }),
email: new Field({
value: '',
validators: [
new Validators.Pattern(/^[^@\s]+@[^@\s]+\.[^@\s]+$/, {
code: 'invalid_email',
detail: 'Please enter a valid email address',
}),
],
}),
actions: new Group({
submit: new Action({
value: { label: 'Submit', icon: 'mdi-content-save', defaultConfirm: true },
actions: [new SubmitAction(target, register)],
}),
cancel: new Action({
value: { label: 'Cancel', icon: 'mdi-undo', defaultReject: true },
actions: [new RejectAction(target)],
}),
}),
});
const submit = form.fields.actions.fields.submit;
// The text of every outcome a run of either action resolves with
function describe(result: SubmitResult<Registration> | CommandException | null | undefined): string {
if (result === undefined) return 'nothing executed: no executable action';
if (result instanceof SubmitRefusedException) return `SubmitRefusedException, reason '${result.reason}'`;
if (result instanceof SubmitFailedException) {
return `SubmitFailedException: ${result.cause instanceof Error ? result.cause.message : String(result.cause)}`;
}
if (result === null || !('received' in result)) return 'Cancel: the form is back at its baseline';
return `SubmitResult: sent ${JSON.stringify(result.sent)}, received ${JSON.stringify(result.received)}`;
}
// A run rejects only where something other than the submit fails, such as a target callback that returns nothing
async function settle(running: Promise<any> | undefined) {
try {
report(describe(await running));
} catch (error) {
report(`execute() rejected: ${error instanceof Error ? error.message : String(error)}`);
}
}
const run = (action: Action) => settle(action.execute());
const confirm = () => settle(form.confirm());
const reject = () => settle(form.reject());
// Vuetify's error-messages prop takes strings; the demo shows each error's English detail
function getErrorMessages(field: FieldBase) {
return field.errors.map((error) => error.detail);
}
</script>
<style scoped>
.action-demo {
margin: 2rem 0;
}
.output {
background-color: #f5f5f5;
padding: 1rem;
border-radius: 4px;
white-space: pre-wrap;
}
</style>What Action carries for the interface
@dynamicforms/vue-forms describes data and behaviour; the few members that concern the user interface are listed in Rationale. Action is one of them. It is the element a form's commands (submit, cancel, delete) are attached to, and its value is an ActionValue: { label?, icon?, defaultConfirm?, defaultReject? }. An action sends nothing to its container's value.
What an action does is form behaviour, and the library implements it: execute() runs the ExecuteAction chain, busy counts the runs that have not settled, executable combines access, visibility, busy and each handler's canExecute(), SubmitAction waits for validation, refuses an invalid form and rebinds the form to the result, and RejectAction puts the form back to its baseline. confirm() and reject() find these actions by their targets. busy is form state: whether it renders as a spinner, a disabled button or nothing is up to the application.
The { label, icon } shape is the part a renderer reads, and a UI library extends it. Action<T extends ActionValue> takes a wider value type, so a subclass adds accessors that read this.value.X and keeps everything the base class does: the ExecuteAction chain, busy, access, visibility, the conditional actions, the transaction semantics. label and icon are members Action declares, and both are stored in its value, so a subclass that reads either in its own shape narrows the getter and declares the setter beside it, delegating to the base. A getter declared alone leaves the property without a setter, and the documented write throws a TypeError. The rules are in Widening the value in a subclass. @dynamicforms/vuetify-inputs does this: its Action widens the value with render options and per-breakpoint variants, and adds renderAs, showLabel, showIcon, confirmation defaults and passthrough attributes. Its df-actions page shows how an action declared with this library renders there; its responsive render options are the per-breakpoint part of the widened value.
Declaring the action
new Action({ value: { label, icon } }) declares an action. label and icon are accessors over the value, so writing either is an ordinary value change: ValueChangedAction fires, isChanged reflects it, and a disabled action takes the write like any other field. submit.label = 'Saving…' therefore re-renders every template that reads it. defaultConfirm and defaultReject mark the action confirm() or reject() executes where no SubmitAction or RejectAction targets the container.
The action is a Field, so it has access and visibility like any other element, and a toolbar renders visibility and executable without knowing what the action does.
Executing it
execute(params?) runs the ExecuteAction chain and returns the chain's return value as a promise. The chain is entered synchronously, so a handler has already run when the call returns; the promise settles with the handler's result, awaiting it where the handler returned a promise. execute() does not check executable.
busy is true from the call until the run settles, whether it resolves or rejects. Overlapping runs are counted, so it stays true until the last of them settles.
The outcomes of a submit are resolved values: SubmitFailedException and SubmitRefusedException extend CommandException, which extends AbortEventHandlingException, and execute() resolves with an AbortEventHandlingException a handler throws. Any other exception a handler throws rejects the promise.
WARNING
Await the promise or attach a .catch(), as settle() in the demo does; a call that does neither leaves a rejection unhandled, which under Node's default settings ends the process. A template handler such as @click="save.execute()" passes the rejection to app.config.errorHandler: Vue attaches its own catch to the promise an event handler returns.
API Reference
- Action:
label,icon,execute(),busy,executable,SubmitAction,RejectAction - Container →
confirm()andreject() - Actions →
ExecuteAction: the chainexecute()runs - Validators →
origin: the origin of an error written by the application - Cookbook → Showing errors the server returned
Try It Yourself
- Leave the name empty: Submit is disabled, and Enter executes nothing
- Fill both fields and submit:
busyistruefor the duration of the run, and the log shows theSubmitResult - Submit an
@example.comaddress: the email field shows the server's error and the log shows theSubmitFailedException - Edit a field, then press Escape: Cancel puts the form back to its baseline
- Empty the name and call
submit.execute()directly: the log shows theSubmitRefusedException
