Skip to content

List Example ​

This example builds repeating sections with List from @dynamicforms/vue-forms. A row of a list is any form element, and the switch above the demo shows three of them: a Group per row for the line items of an invoice, a single Field per row for a list of tags, and a whole List per row for a score sheet of rounds.

Demo ​

Line items
Tags
Score sheet

List<Group<{ description, quantity, unitPrice }>>

Invoice Line Items
List Output

List is valid

[
  {
    "description": "Consulting hours",
    "quantity": 8,
    "unitPrice": 120
  },
  {
    "description": "Travel expenses",
    "quantity": 1,
    "unitPrice": 85
  }
]

List events

Add, insert or remove a line to see events

Source Code ​

The switch selects which of the three components is shown. Each component is a complete example.

vue
<template>
  <div>
    <v-card class="mb-4">
      <v-card-title>Invoice Line Items</v-card-title>
      <v-card-text>
        <v-form @submit.prevent>
          <v-row v-for="(row, index) in rows" :key="index" align="start" dense>
            <v-col cols="12" sm="5">
              <v-text-field
                v-model="row.fields.description.value"
                label="Description"
                :error-messages="getErrorMessages(row.fields.description)"
                hide-details="auto"
              ></v-text-field>
            </v-col>

            <v-col cols="5" sm="2">
              <v-text-field
                v-model.number="row.fields.quantity.value"
                type="number"
                label="Quantity"
                :error-messages="getErrorMessages(row.fields.quantity)"
                hide-details="auto"
              ></v-text-field>
            </v-col>

            <v-col cols="7" sm="3">
              <v-text-field
                v-model.number="row.fields.unitPrice.value"
                type="number"
                label="Unit Price"
                :error-messages="getErrorMessages(row.fields.unitPrice)"
                hide-details="auto"
              ></v-text-field>
            </v-col>

            <v-col cols="12" sm="2" class="d-flex align-center">
              <v-btn
                icon="mdi-table-row-plus-before"
                variant="text"
                title="Insert a line above"
                aria-label="Insert a line above"
                @click="insertAbove(index)"
              ></v-btn>
              <v-btn
                icon="mdi-delete-outline"
                variant="text"
                title="Remove this line"
                aria-label="Remove this line"
                @click="lineItems.remove(index)"
              ></v-btn>
            </v-col>
          </v-row>
        </v-form>

        <p v-if="rows.length === 0">The invoice has no line items.</p>
      </v-card-text>

      <v-card-actions>
        <v-btn color="primary" prepend-icon="mdi-plus" @click="addLine">Add Line</v-btn>
        <v-btn color="primary" class="ml-2" :disabled="!lineItems.valid" @click="submit">Submit</v-btn>
      </v-card-actions>
    </v-card>

    <v-card>
      <v-card-title>List Output</v-card-title>
      <v-card-text>
        <p>List is {{ lineItems.valid ? 'valid' : 'invalid' }}</p>
        <pre class="output">{{ JSON.stringify(lineItems.value, null, 2) }}</pre>
        <p class="mt-4">List events</p>
        <pre class="output">{{ events.length ? events.join('\n') : 'Add, insert or remove a line to see events' }}</pre>
      </v-card-text>
    </v-card>
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue';
import {
  type FieldBase,
  Group,
  Field,
  List,
  ListItemAddedAction,
  ListItemRemovedAction,
  ValueChangedAction,
  ValidationError,
  Validators,
} from '../../src'; // from '@dynamicforms/vue-forms'

// The item template: the declaration of a single row, bound to the data of every row the list holds.
// The Required validator is declared once here and applies to the description of every row.
const lineItem = new Group({
  description: new Field({ value: '', validators: [new Validators.Required()] }),
  quantity: new Field({ value: 1 }),
  unitPrice: new Field<number | null>({ value: null })
});

// A validator on a field of the item template runs in every row. Inside a row, field.parent is that row's Group, so
// the lookup below reads the quantity of the same row. A row is built member by member, so the first run happens
// before the member has a row: the validator has no result, and markRecordIncomplete() makes the row run it again
// once the row exists.
lineItem.fields.unitPrice.registerAction(new Validators.Validator((newValue, oldValue, field) => {
  const row = field.parent;
  if (!(row instanceof Group)) {
    field.markRecordIncomplete();
    return null;
  }
  if (row.fields.quantity.value > 0 && (newValue === null || newValue === '')) {
    return [new ValidationError('unit_price_required', {}, 'Unit price is required when quantity is above zero')];
  }
  return null;
}));

// The rule spans two fields, so a new quantity re-validates the unit price of the same row
lineItem.fields.quantity.registerAction(new ValueChangedAction((field, supr, newValue, oldValue) => {
  const result = supr(field, newValue, oldValue);
  if (field.parent instanceof Group) field.parent.fields.unitPrice.validate(true);
  return result;
}));

// The last few list events, newest last
const events = ref<string[]>([]);

function logEvent(message: string) {
  events.value = [...events.value, message].slice(-6);
}

// The list, built from the item template and populated with two rows
const lineItems = new List(lineItem, {
  value: [
    { description: 'Consulting hours', quantity: 8, unitPrice: 120 },
    { description: 'Travel expenses', quantity: 1, unitPrice: 85 }
  ],
  actions: [
    new ListItemAddedAction((field, supr, item, index) => {
      logEvent(`item added at index ${index}`);
      return supr(field, item, index);
    }),
    new ListItemRemovedAction((field, supr, item, index) => {
      logEvent(`item removed at index ${index}`);
      return supr(field, item, index);
    })
  ]
});

// list.items returns the Group of every row; the inputs bind to these. The array is frozen and is rebuilt when
// rows are added or removed, and the read is reactive, so this recomputes on every mutation.
const rows = computed(() => lineItems.items);

function addLine() {
  lineItems.push({ description: '', quantity: 1, unitPrice: null });
}

function insertAbove(index: number) {
  lineItems.insert({ description: '', quantity: 1, unitPrice: null }, index);
}

// Submit reports, in the event log, how many line items the list sends
function submit() {
  logEvent(`submitted ${lineItems.value.length} line items`);
}

// 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>
.output {
  background-color: #f5f5f5;
  padding: 1rem;
  border-radius: 4px;
  white-space: pre-wrap;
}
</style>
vue
<template>
  <div>
    <v-card class="mb-4">
      <v-card-title>Tags</v-card-title>
      <v-card-text>
        <v-row v-for="(tag, index) in tags.items" :key="index" align="start" dense>
          <v-col cols="10" sm="6">
            <v-text-field
              v-model="tag.value"
              :label="`Tag ${index + 1}`"
              :error-messages="getErrorMessages(tag)"
              hide-details="auto"
            ></v-text-field>
          </v-col>

          <v-col cols="2" sm="2" class="d-flex align-center">
            <v-btn
              icon="mdi-delete-outline"
              variant="text"
              title="Remove this tag"
              aria-label="Remove this tag"
              @click="tags.remove(index)"
            ></v-btn>
          </v-col>
        </v-row>

        <p v-if="tags.length === 0">The ticket has no tags.</p>
      </v-card-text>

      <v-card-actions>
        <v-btn color="primary" prepend-icon="mdi-plus" @click="tags.push('')">Add Tag</v-btn>
      </v-card-actions>
    </v-card>

    <v-card>
      <v-card-title>List Output</v-card-title>
      <v-card-text>
        <p>List is {{ tags.valid ? 'valid' : 'invalid' }}</p>
        <pre class="output">{{ JSON.stringify(tags.value, null, 2) }}</pre>
      </v-card-text>
    </v-card>
  </div>
</template>

<script setup>
import { Field, List, Validators } from '../../src'; // from '@dynamicforms/vue-forms'

// Every row is a Field bound from this item template, so the value is an array of strings and every tag has the
// Required validator
const tags = new List(new Field({ value: '', validators: [new Validators.Required()] }), {
  value: ['urgent', 'billing']
});

// Vuetify's error-messages prop takes strings; the demo shows each error's English detail
function getErrorMessages(field) {
  return field.errors.map((error) => error.detail);
}
</script>

<style scoped>
.output {
  background-color: #f5f5f5;
  padding: 1rem;
  border-radius: 4px;
  white-space: pre-wrap;
}
</style>
vue
<template>
  <div>
    <v-card class="mb-4">
      <v-card-title>Score Sheet</v-card-title>
      <v-card-text>
        <div v-for="(round, index) in rounds.items" :key="index" class="round">
          <div class="d-flex align-center">
            <strong>Round {{ index + 1 }}</strong>
            <span class="ml-2 text-medium-emphasis">total {{ total(round) }}</span>
            <v-spacer></v-spacer>
            <v-btn
              icon="mdi-plus"
              variant="text"
              title="Add a score to this round"
              aria-label="Add a score to this round"
              @click="round.push(0)"
            ></v-btn>
            <v-btn
              icon="mdi-delete-outline"
              variant="text"
              title="Remove this round"
              aria-label="Remove this round"
              @click="rounds.remove(index)"
            ></v-btn>
          </div>

          <v-row dense>
            <v-col v-for="(score, position) in round.items" :key="position" cols="6" sm="3">
              <v-text-field
                v-model.number="score.value"
                type="number"
                :label="`Score ${position + 1}`"
                :error-messages="getErrorMessages(score)"
                hide-details="auto"
              >
                <template #append-inner>
                  <v-icon
                    icon="mdi-close"
                    size="small"
                    title="Remove this score"
                    aria-label="Remove this score"
                    @click="round.remove(position)"
                  ></v-icon>
                </template>
              </v-text-field>
            </v-col>
          </v-row>
        </div>

        <p v-if="rounds.length === 0">The sheet has no rounds.</p>
      </v-card-text>

      <v-card-actions>
        <v-btn color="primary" prepend-icon="mdi-plus" @click="rounds.push([0])">Add Round</v-btn>
      </v-card-actions>
    </v-card>

    <v-card>
      <v-card-title>List Output</v-card-title>
      <v-card-text>
        <p>List is {{ rounds.valid ? 'valid' : 'invalid' }}</p>
        <pre class="output">{{ JSON.stringify(rounds.value) }}</pre>
      </v-card-text>
    </v-card>
  </div>
</template>

<script setup>
import { Field, List, Validators } from '../../src'; // from '@dynamicforms/vue-forms'

// A round is a list of scores, and the sheet is a list of rounds: every row of the outer list is a List bound from
// this item template, and every row of a round is a Field bound from the round's item template
const roundTemplate = new List(new Field({ value: 0, validators: [new Validators.MinValue(0)] }));

const rounds = new List(roundTemplate, { value: [[3, 5], [4]] });

// a round's value is an array of numbers, [] while the round holds none
function total(round) {
  return round.value.reduce((sum, score) => sum + (Number(score) || 0), 0);
}

// Vuetify's error-messages prop takes strings; the demo shows each error's English detail
function getErrorMessages(field) {
  return field.errors.map((error) => error.detail);
}
</script>

<style scoped>
.round + .round {
  margin-top: 1rem;
}
.output {
  background-color: #f5f5f5;
  padding: 1rem;
  border-radius: 4px;
  white-space: pre-wrap;
}
</style>
vue
<template>
  <div class="list-demo">
    <v-chip-group v-model="shape" mandatory column selected-class="text-primary" class="mb-2">
      <v-chip value="lineItems" filter variant="outlined" title="Every row is a group of fields">Line items</v-chip>
      <v-chip value="tags" filter variant="outlined" title="Every row is a single field">Tags</v-chip>
      <v-chip value="rounds" filter variant="outlined" title="Every row is a list">Score sheet</v-chip>
    </v-chip-group>

    <p class="shape">
      <code>{{ shapes[shape].type }}</code>
    </p>

    <KeepAlive>
      <component :is="shapes[shape].component"></component>
    </KeepAlive>
  </div>
</template>

<script setup>
import { ref } from 'vue';

import ListLineItemsDemo from './list-line-items-demo.vue';
import ListRoundsDemo from './list-rounds-demo.vue';
import ListTagsDemo from './list-tags-demo.vue';

// KeepAlive keeps each list and what was typed into it while another shape is shown
const shapes = {
  lineItems: { type: 'List<Group<{ description, quantity, unitPrice }>>', component: ListLineItemsDemo },
  tags: { type: 'List<Field<string>>', component: ListTagsDemo },
  rounds: { type: 'List<List<Field<number>>>', component: ListRoundsDemo }
};

const shape = ref('lineItems');
</script>

<style scoped>
.list-demo {
  margin: 2rem 0;
}
</style>

Line Items: a Group per Row ​

The Item Template ​

The Group passed to new List(...) is not a row of the list. It is the item template every row is built from. Each row is a binding of it, so every row has what the item template has: the Required validator on description is declared once and rejects an empty description in every row, and an action registered on a field of the item template runs in each row separately, on that row's field.

Rows are created for the list's initial value and for every later push() and insert(), so a change to the item template after the list has rows applies only to rows added afterwards. Declare the item template completely before building the list from it.

Reaching a Sibling Field ​

Inside a row, field.parent is the row's own Group, so row.fields.quantity is the quantity of the row being validated. parent is typed Container, which declares no members, so the validator checks row instanceof Group before it reads one. The check narrows the type and also tests whether the row exists yet. The expression is written on the item template and resolves per row, because the field the validator receives is the row's field.

A row is built member by member: every member is bound separately, the bindings are passed to a Group, and the group then receives the row's data. This validator's first run therefore happens while the unit price has no parent. In that case the validator has no result: field.markRecordIncomplete() records this, and the container that completes the record runs the validator again once the row holds its members and their data. A row created with a quantity above zero and no unit price is therefore invalid as soon as it exists, without manual revalidation.

A validator runs when its own field changes, so the unit price rule runs when the unit price is edited. A change of the quantity must re-validate the unit price: field.parent.fields.unitPrice.validate(true) does that from a ValueChangedAction on the quantity, behind the same instanceof Group check. validate(true) re-runs the field's eager actions, including its validators; validate() alone recomputes validity from the errors already recorded.

List Validity ​

list.valid is true when the list has no errors of its own and every row is valid. The Submit button's disabled state binds to it, and a submit reports in the event log how many line items the list sends. Emptying a description or clearing a unit price on a row with a quantity above zero turns the whole list invalid; removing that row makes it valid again.

Reading the Value ​

list.value is the plain data: one object per row, in row order, [] while the list is empty. It is recomputed whenever a field, a row or the list itself changes, so the output panel below the form re-renders automatically.

Tags: a Field per Row ​

new List(new Field(...)) is a list of plain values. Every row is a Field bound from the item template, so it has the item template's Required validator, and tags.value is an array of strings, such as ['urgent', 'billing']. A row binds to an input directly through tag.value, and tags.push('') adds a row: the argument is the data the new row is bound to.

Score Sheet: a List per Row ​

A row can also be a list. The outer list's item template is a List of number fields, so every round is a list with its own push() and remove(), and the sheet's value is an array of arrays, such as [[3, 5], [4]]. rounds.push([0]) adds a round holding one score. The MinValue validator is declared once on the innermost item template and runs on every score of every round, and rounds.valid covers all of them.

API Reference ​

  • List: item template, get(), push(), insert(), remove(), value, valid
  • Group: fields, parent, rules for what a group sends
  • Validators: all built-in validators and the custom Validator signature
  • Container: parent, and what a Group and a List share
  • Actions → ListItemAddedAction: the events the buttons produce

Key Features Demonstrated ​

  • Any Element as a Row: a Group, a Field and a List, each declared once as the item template
  • Item Template: One Group declaring the shape, the validators and the actions of every row
  • Mutations: push(), insert() at a position, and remove(), each wired to a button
  • List Events: ListItemAddedAction and ListItemRemovedAction reporting the index involved
  • Per-Row Validation: A validator declared once on the item template and enforced in every row
  • Cross-Field Validation: A row's field reading a sibling through field.parent, narrowed with instanceof Group
  • Aggregated Validity: list.valid driving the Submit button
  • Plain Data: list.value read back as an array of objects, of strings or of arrays

Try It Yourself ​

Experiment with the list by:

  1. Adding a line and leaving its description empty
  2. Setting a quantity above zero on a row without a unit price
  3. Lowering that quantity back to zero
  4. Inserting a line above an existing one and watching the reported index
  5. Removing the invalid rows and watching the Submit button become enabled
  6. Switching to Tags and emptying a tag, or to Score sheet and entering a negative score

Released under the MIT License.