This example demonstrates a complete constraints registry using the constraints plugin and real business rules.
constraints/
├── index.ts # Shared types and injection key
├── ConstraintsMemberList.vue # Parent list component
└── ConstraintsMemberItem.vue # Individual member component
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');
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>
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>
The constraints plugin supports multiple types:
Errors are updated and displayed as soon as a constraint is violated. Async constraints are supported.
The registry tracks its overall validity and displays errors and rules in the UI.
This pattern is ideal for:
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>
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>
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,
});
The plugin extends the desk with constraints methods:
getConstraintErrors(): Get all constraint errorsgetConstraintErrorsById(id): Get errors for a specific memberclearConstraintErrors(): Clear all errorshasConstraintErrors: Boolean flag indicating if errors existconstraintErrorCount: Number of current constraint errors (computed)Members are validated in real-time when data changes:
watchData detects the change and calls desk.update()The registry tracks its overall validity:
const isListValid = computed(() => constraintErrors.value.length === 0);
autoCheckIn: true)watchData: true) detects value changesThis pattern is ideal for: