Examples

Business Rules

Enforce business rules on a registry of members using the createConstraintsPlugin from @vue-airport/plugins-validation.

This example demonstrates a complete constraints registry using the constraints plugin and real business rules.

0 members

Errors

  • No errors

Constraint Rules

  • Name must be unique
  • Maximum 5 members allowed
  • Name must be at least 3 letters (A-Z)
  • ID must be between 1 and 99999
  • Name "Admin" and "Root" are forbidden
  • Only one guest allowed
  • Async: Name "forbiddenasync" is not allowed
  • Cannot remove the last admin

Project Structure

constraints/
├── index.ts                 # Shared types and injection key
├── ConstraintsMemberList.vue # Parent list component
└── ConstraintsMemberItem.vue # Individual member component

Shared Types (index.ts)

import type { InjectionKey, Ref } from 'vue';
import type { DeskWithContext } from '#vue-airport';
import type {
  ConstraintsPluginComputed,
  ConstraintsPluginMethods,
} from '@vue-airport/plugins-validation';

export { default as ConstraintsMemberList } from './ConstraintsMemberList.vue';
export { default as ConstraintsMemberItem } from './ConstraintsMemberItem.vue';

export interface MemberData {
  id: string | number;
  name: string;
  avatar?: string;
  role: 'admin' | 'user' | 'guest';
}

export interface MemberListContext {
  members: Ref<MemberData[]>;
  roleClasses: Record<MemberData['role'], string>;
}

export type DeskWithConstraints = DeskWithContext<MemberData, MemberListContext> &
  ConstraintsPluginMethods &
  ConstraintsPluginComputed<MemberData>;

export const DESK_CONSTRAINTS_KEY: InjectionKey<Ref<MemberData> & MemberListContext> =
  Symbol('constraintsDesk');

Parent Component (ConstraintsMemberList.vue)

Implements a registry with multiple constraints and real-time error feedback:

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useCheckIn } from 'vue-airport';
import {
  createConstraintsPlugin,
  ConstraintType,
  type Constraint,
} from '@vue-airport/plugins-validation';
import {
  ConstraintsMemberItem,
  DESK_CONSTRAINTS_KEY,
  type MemberData,
  type MemberListContext,
} from '.';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';

const constraints: Constraint<MemberData>[] = [
  { type: ConstraintType.Unique, key: 'name', message: 'Name must be unique' },
  { type: ConstraintType.MaxCount, count: 5, message: 'Maximum 5 members allowed' },
  {
    type: ConstraintType.Pattern,
    key: 'name',
    regex: /^[A-Za-z]{3,}$/,
    message: 'Name must be at least 3 letters (A-Z)',
  },
  {
    type: ConstraintType.Range,
    key: 'id',
    min: 1,
    max: 99999,
    message: 'ID must be between 1 and 99999',
  },
  {
    type: ConstraintType.Forbidden,
    key: 'name',
    values: ['Admin', 'Root'],
    message: 'Name "Admin" and "Root" are forbidden',
  },
  {
    type: ConstraintType.Custom,
    fn: (member, members) => {
      if (member.role === 'guest' && members.filter((m) => m.role === 'guest').length >= 1) {
        return 'Only one guest allowed';
      }
      return null;
    },
  },
  {
    type: ConstraintType.Custom,
    fn: async (member) => {
      await new Promise((r) => setTimeout(r, 100));
      if (member.name.toLowerCase() === 'forbiddenasync') {
        return 'Name "forbiddenasync" is not allowed (async check)';
      }
      return null;
    },
  },
  {
    type: ConstraintType.BeforeCheckOut,
    rule: (member, members) => {
      if (member.role === 'admin') {
        const adminCount = members.filter((m) => m.role === 'admin').length;
        if (adminCount <= 1) {
          return 'Cannot remove the last admin.';
        }
      }
      return null;
    },
    message: 'Cannot remove the last admin.',
  },
];

const newName = ref('');
const newRole = ref<MemberData['role']>('user');

const addMember = async (name: string, role: MemberData['role']) => {
  (desk as any).clearConstraintErrors();
  const id = Math.floor(((Date.now() % 100000) + Math.random() * 100000) % 100000) + 1;
  const gender = Math.random() < 0.5 ? 'men' : 'women';
  const avatar = `https://randomuser.me/api/portraits/${gender}/${Math.floor(Math.random() * 99)}.jpg`;
  const member: MemberData = {
    id,
    name: name.trim(),
    role,
    avatar,
  };
  const isValid = await desk.checkIn(id, member);
  if (isValid) {
    (desk as any).members.value.push(member);
  }
  newName.value = '';
  newRole.value = 'user';
};

const { createDesk } = useCheckIn<MemberData, MemberListContext>();
const { desk } = createDesk(DESK_CONSTRAINTS_KEY, {
  plugins: [createConstraintsPlugin<MemberData>(constraints)],
  devTools: true,
  debug: false,
  context: {
    members: ref([]),
    roleClasses: {
      admin: 'bg-yellow-800/10 border border-yellow-800 text-yellow-800',
      user: 'bg-blue-800/10 border border-blue-800 text-blue-800',
      guest: 'bg-green-800/10 border border-green-800 text-green-800',
    },
  },
  onCheckOut(id, desk) {
    const ctx = desk.getContext<MemberListContext>();
    if (ctx && ctx.members) {
      ctx.members.value = ctx.members.value.filter((m) => m.id !== id);
    }
  },
});

const items = computed(() => {
  return (desk as any).members.value || [];
});

const errors = computed(() =>
  (desk as any)
    .getConstraintErrors()
    .map((e: any) => e.errors)
    .flat()
);

onMounted(async () => {
  await addMember('Alice', 'admin');
  await addMember('Bob', 'user');
  await addMember('Charlie', 'guest');
});
</script>

<template>
  <div>
    <div class="flex gap-3 mb-6 flex-wrap justify-between items-center">
      <div class="flex gap-2 items-center">
        <Input
          v-model="newName"
          placeholder="Name"
          class="input input-bordered"
          @keyup.enter="() => addMember(newName, newRole)"
        />
        <Select v-model="newRole">
          <SelectTrigger class="input input-bordered">
            <SelectValue placeholder="Choose a role" />
          </SelectTrigger>
          <SelectContent>
            <SelectGroup>
              <SelectLabel>Role</SelectLabel>
              <SelectItem value="user">User</SelectItem>
              <SelectItem value="admin">Admin</SelectItem>
              <SelectItem value="guest">Guest</SelectItem>
            </SelectGroup>
          </SelectContent>
        </Select>
        <Button :disabled="!newName" @click="() => addMember(newName, newRole)">
          <span class="mr-2">+</span>Add Member
        </Button>
      </div>
      <Badge variant="outline" class="border-primary bg-primary/20 text-primary px-3 py-1">
        {{ items.length }} members
      </Badge>
    </div>

    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
      <!-- Members list -->
      <div class="p-4 bg-card border border-muted rounded-md flex-1">
        <ul class="list-none p-0 m-0 flex flex-col gap-2 max-h-[400px] overflow-y-auto">
          <li
            v-for="item in items"
            :key="item.id"
            :data-slot="`constraints-list-item-${item.id}`"
            class="flex items-center justify-between p-3 border border-muted rounded-md cursor-pointer transition-all duration-200 hover:bg-accent dark:hover:bg-accent-dark"
          >
            <ConstraintsMemberItem :id="item.id" />
          </li>
        </ul>
      </div>
      <!-- Errors and rules -->
      <div class="flex flex-col gap-4">
        <div class="p-4 bg-card border border-muted rounded-md flex-1">
          <h3 class="m-0 mb-2 text-base font-semibold">Errors</h3>
          <ul class="text-sm list-none text-destructive">
            <li v-if="!errors.length" class="text-muted">No errors</li>
            <li v-for="(err, idx) in errors" :key="idx">{{ err }}</li>
          </ul>
        </div>
        <div class="p-4 bg-card border border-muted rounded-md">
          <h3 class="m-0 mb-4 text-base font-semibold">Constraint Rules</h3>
          <ul class="text-sm list-disc pl-4">
            <li>Name must be unique</li>
            <li>Maximum 5 members allowed</li>
            <li>Name must be at least 3 letters (A-Z)</li>
            <li>ID must be between 1 and 99999</li>
            <li>Name "Admin" and "Root" are forbidden</li>
            <li>Only one guest allowed</li>
            <li>Async: Name "forbiddenasync" is not allowed</li>
            <li>Cannot remove the last admin</li>
          </ul>
        </div>
      </div>
    </div>
  </div>
</template>

Child Component (ConstraintsMemberItem.vue)

Displays member info, avatar, role badge, and remove button:

<script setup lang="ts">
import { useCheckIn } from 'vue-airport';
import type { MemberData, MemberListContext } from '.';
import { DESK_CONSTRAINTS_KEY } from '.';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';

const props = defineProps<{ id: string | number }>();

const { checkIn } = useCheckIn<MemberData, MemberListContext>();
const { desk } = checkIn(DESK_CONSTRAINTS_KEY, {
  id: props.id,
  autoCheckIn: false,
  watchData: false,
  data: (desk) => {
    const item = desk.get(props.id);
    return item?.data as any;
  },
});

const item = computed(() => desk!.get(props.id)?.data);
const roleColor = computed(
  () => desk!.roleClasses?.[item.value?.role || 'user'] || 'bg-gray-200 text-gray-800'
);

const remove = async () => {
  const result = await desk!.checkOut(props.id);
  console.log('Result of checkOut:', result);
};
</script>

<template>
  <div class="w-full flex items-center gap-4">
    <Avatar class="w-12 h-12">
      <AvatarImage v-if="item?.avatar" :src="item.avatar" alt="Avatar" />
      <AvatarFallback v-else>{{ item?.name?.[0] ?? '?' }}</AvatarFallback>
    </Avatar>
    <div class="flex-1 flex flex-col gap-1 justify-center">
      <Label class="font-bold text-base">{{ item?.name }}</Label>
      <span class="text-xs text-gray-600 dark:text-gray-400 mt-1">ID: {{ item?.id }}</span>
    </div>
    <div class="flex flex-col items-end gap-2 h-full justify-center">
      <Badge :class="cn(roleColor)">{{ item?.role }}</Badge>
      <Button
        size="icon"
        aria-label="Remove item"
        class="bg-transparent hover:bg-transparent border-0 text-destructive/80 hover:text-destructive"
        @click.stop="remove"
      >
        <UIcon name="i-heroicons-trash" class="w-4 h-4" />
      </Button>
    </div>
  </div>
</template>

Key Concepts

Constraints Plugin

The constraints plugin supports multiple types:

  • Unique, MaxCount, Pattern, Range, Forbidden, Custom (sync/async), BeforeCheckOut

Real-time Error Feedback

Errors are updated and displayed as soon as a constraint is violated. Async constraints are supported.

Registry Validation State

The registry tracks its overall validity and displays errors and rules in the UI.

How It Works

  1. Constraints plugin is created with business rules
  2. Desk is created with the plugin and context
  3. Members are added with avatar, name, role
  4. Constraints are checked on add/remove and update
  5. Errors are displayed in real-time
  6. Cannot remove last admin (special rule)

Usage

This pattern is ideal for:

  • Member registries with business rules
  • User management lists
  • Role enforcement
  • Any registry requiring cross-item constraints

Parent Component (ConstraintsMemberList.vue)

Creates a desk with constraints plugin:

<script setup lang="ts">
import { useCheckIn, type DeskWithContext } from '#vue-airport';
import { createConstraintsPlugin, type ConstraintError } from '@vue-airport/plugins-validation';
import { type Member, CONSTRAINTS_DESK_KEY, type ConstraintsContext, ConstraintsMemberItem } from '.';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';

/**
 * Constraints Example - Constraints Plugin
 *
 * Demonstrates:
 * - Uniqueness constraint
 * - Maximum count constraint
 * - Custom business rule (max 2 admins)
 * - Real-time error feedback
 */

// Create custom constraints plugin
const constraintsPlugin = createConstraintsPlugin<Member>({
  rules: [
    {
      name: 'uniqueName',
      validate: (members) => {
        const names = members.map(m => m.name);
        const hasDuplicates = names.length !== new Set(names).size;
        return hasDuplicates ? 'Name must be unique' : true;
      },
    },
    {
      name: 'maxCount',
      validate: (members) => {
        return members.length > 5 ? 'Maximum 5 members allowed' : true;
      },
    },
    {
      name: 'maxAdmins',
      validate: (members) => {
        const adminCount = members.filter(m => m.role === 'admin').length;
        return adminCount > 2 ? 'Maximum 2 admins allowed' : true;
      },
    },
  ],
  maxErrors: 100,
});

const members = ref<Member[]>([
  { id: '1', name: 'Alice', role: 'admin' },
  { id: '2', name: 'Bob', role: 'user' },
]);

type DeskWithConstraints = DeskWithContext<Member, ConstraintsContext> & {
  getConstraintErrors?: () => ConstraintError[];
  clearConstraintErrors?: () => void;
};

const { createDesk } = useCheckIn<Member, ConstraintsContext>();
const { desk } = createDesk(CONSTRAINTS_DESK_KEY, {
  devTools: true,
  debug: false,
  plugins: [constraintsPlugin],
  context: {
    members,
    errorById: (id: string) => {
      const allErrors = (desk as any)?.getConstraintErrors?.() || [];
      return allErrors.find((e: ConstraintError) => e.id === id);
    },
  },
});

const constrainedDesk = desk as DeskWithConstraints;

const constraintErrors = computed(() => {
  return constrainedDesk.getConstraintErrors?.() || [];
});

const isListValid = computed(() => {
  return constraintErrors.value.length === 0;
});

const addMember = () => {
  const newId = String(Date.now());
  members.value.push({ id: newId, name: '', role: 'user' });
};

const removeMember = (id: string) => {
  members.value = members.value.filter(m => m.id !== id);
};

const clearErrors = () => {
  constrainedDesk.clearConstraintErrors?.();
};
</script>

<template>
  <div>
    <div class="flex flex-col gap-4">
      <ConstraintsMemberItem
        v-for="member in members"
        :id="member.id"
        :key="member.id"
        @remove="removeMember"
      />
      <Button @click="addMember" :disabled="members.length >= 5">
        Add Member
      </Button>
      <Button variant="outline" @click="clearErrors">
        Clear Errors
      </Button>
    </div>
    <div class="flex items-center gap-4 p-4 bg-card border border-border rounded-md mt-4">
      <Badge
        :variant="isListValid ? 'default' : 'destructive'"
        :class="{ 'px-3 py-1 text-sm': true, 'bg-green-500': isListValid }"
      >
        {{ isListValid ? '✓ Valid list' : '✗ Invalid list' }}
      </Badge>
      <span class="text-sm text-muted"> {{ constraintErrors.length }} error(s) </span>
    </div>
    <div v-if="constraintErrors.length" class="mt-2 text-red-500">
      <ul>
        <li v-for="err in constraintErrors" :key="err.message">{{ err.message }}</li>
      </ul>
    </div>
  </div>
</template>

Child Component (ConstraintsMemberItem.vue)

Individual member item with error feedback:

<script setup lang="ts">
import { useCheckIn } from '#vue-airport/composables/useCheckIn';
import { type Member, CONSTRAINTS_DESK_KEY, type ConstraintsContext } from '.';
import { Input } from '@/components/ui/input';

const props = defineProps<{
  id: string;
}>();
const emit = defineEmits(['remove']);

const { checkIn } = useCheckIn<Member, ConstraintsContext>();
const { desk } = checkIn(CONSTRAINTS_DESK_KEY, {
  id: props.id,
  autoCheckIn: true,
  watchData: true,
  data: (desk) => {
    const member = desk.members?.value?.find((m) => m.id === props.id);
    return {
      id: props.id,
      name: member?.name || '',
      role: member?.role || 'user',
    };
  },
});

const memberData = computed(() => {
  return desk?.members?.value.find((m) => m.id === props.id);
});

const errorMessage = computed<string | undefined>(() => {
  return desk?.errorById(props.id)?.message;
});

const updateMember = (field: 'name' | 'role', value: string) => {
  if (memberData.value) {
    memberData.value[field] = value;
  }
};
</script>

<template>
  <div class="flex items-center gap-2">
    <Input
      :model-value="memberData?.name"
      placeholder="Name"
      @update:model-value="val => updateMember('name', val)"
    />
    <select v-model="memberData.role">
      <option value="user">User</option>
      <option value="admin">Admin</option>
    </select>
    <Button variant="outline" @click="() => emit('remove', props.id)">
      Remove
    </Button>
    <span v-if="errorMessage" class="text-red-500 text-sm">
      {{ errorMessage }}
    </span>
  </div>
</template>

Key Concepts

Constraints Plugin

The constraints plugin provides registry-level business rules:

const constraintsPlugin = createConstraintsPlugin<Member>({
  rules: [
    {
      name: 'uniqueName',
      validate: (members) => {
        const names = members.map(m => m.name);
        return names.length !== new Set(names).size ? 'Name must be unique' : true;
      },
    },
    // ...other rules
  ],
  maxErrors: 100,
});

Constraints Methods

The plugin extends the desk with constraints methods:

  • getConstraintErrors(): Get all constraint errors
  • getConstraintErrorsById(id): Get errors for a specific member
  • clearConstraintErrors(): Clear all errors
  • hasConstraintErrors: Boolean flag indicating if errors exist
  • constraintErrorCount: Number of current constraint errors (computed)

Real-time Error Feedback

Members are validated in real-time when data changes:

  1. User edits a member
  2. Member's local value updates
  3. watchData detects the change and calls desk.update()
  4. Constraints plugin validates the registry
  5. Errors are updated or cleared in real-time
  6. UI reflects current constraint state immediately

Registry Validation State

The registry tracks its overall validity:

const isListValid = computed(() => constraintErrors.value.length === 0);

How It Works

  1. Constraints plugin is created with business rules
  2. Desk is created with the constraints plugin in the parent component
  3. Members check in automatically when mounted (autoCheckIn: true)
  4. Initial validation runs via hooks
  5. Local value tracking in each member for independent state management
  6. Data watching (watchData: true) detects value changes
  7. Updates trigger validation on every change
  8. Errors are updated in real-time and displayed per member
  9. Registry actions (add/remove) update the state and trigger validation

Usage

This pattern is ideal for:

  • Member registries with business rules
  • User management lists
  • Admin/user role enforcement
  • Any registry requiring cross-item constraints
  • Complex lists with custom business logic