Skip to content

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 ​

Sign-up
Action State

Form is invalid and unchanged; Submit is not executable

Fill both fields, then submit

What the demo shows ​

  • An actions bar. actions is a Group of two actions inside the form. An action sends nothing, so form.value is { name, email }, and the template renders the bar with one v-for over its fields, from each action's label, icon, executable and busy.
  • executable. A button is disabled while its action is not executable. SubmitAction makes Submit executable while the form is valid and not pending, 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 keydown handlers call form.confirm() and form.reject(). Each finds the action whose SubmitAction or RejectAction targets the form and executes it if it is executable; confirm() returns undefined where it executes nothing.
  • Outcomes. A submit resolves with a SubmitResult (sent and received), a SubmitFailedException where the handler failed, or a SubmitRefusedException where 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.com address it writes a ValidationError with the origin 'server' to the email field and throws, so the submit ends with a SubmitFailedException and 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 writes touched = true when it loses focus.
  • Rebinding. Otherwise the handler returns the record as stored (name trimmed, email lower-cased), and SubmitAction rebinds the form to it: the inputs show the stored record and isChanged is false. Cancel rebinds the form to its originalValue.
  • busy and the lock. SubmitAction makes the form 'readonly' while the submit runs, so the inputs, bound to effectiveEnabled, accept no input for that time; the form is busy meanwhile.

Source Code ​

vue
<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 ​

Try It Yourself ​

  1. Leave the name empty: Submit is disabled, and Enter executes nothing
  2. Fill both fields and submit: busy is true for the duration of the run, and the log shows the SubmitResult
  3. Submit an @example.com address: the email field shows the server's error and the log shows the SubmitFailedException
  4. Edit a field, then press Escape: Cancel puts the form back to its baseline
  5. Empty the name and call submit.execute() directly: the log shows the SubmitRefusedException

Released under the MIT License.