Forms with @craft-ng
@craft-ng provides a complete form management system based on Angular Signal-Forms, enabling the creation of reactive, type-safe, and composable forms with @craft-ng primitives.
Overview
The main benefits of @craft-ng form system are built on three pillars:
- Form Insertions - Modular composition to tackle logic complexity
- Type-safe errors - Synchronous and asynchronous validation (soon) with type-safe exceptions (inferred from validators and submit handler)
- Parallel Forms - Support for multiple forms in the same state with automatic scoping
WARNING
The form system is currently in early development and is not yet stable. The API may change without deprecation, and some features are not fully implemented yet (e.g. asynchronous validation). I'm encountering many limitations with signal-based forms, and I'm considering creating my own directives to achieve a more appropriate and complete developer experience. This is also the reason why there is no JSDoc for these utilities.
Form Insertions
Form insertions enable modular composition of functionality:
insertForm
The primary insertion that creates an Angular Signal-Form from a primitive.
import { state } from '@craft-ng/core';
import {
insertForm,
insertFormAttributes,
insertNoopTypingAnchor,
insertSelectFormTree,
cRequired,
cEmail,
} from '@craft-ng/core';
const userFormState = state(
{ name: '', email: '' },
insertForm(
insertSelectFormTree(
'name',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired()],
})),
),
insertSelectFormTree(
'email',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
})),
),
),
);
const form = userFormState.form();
const nameField = form.selectName();
const emailField = form.selectEmail();Note: It only works with the
stateprimitive from now.
insertNoopTypingAnchoris a special insertion that does not add any logic but allows to anchor the typing of the form field. It is required for the form system to infer the correct types of fields and exceptions. (TS limitations...)
insertFormAttributes
Adds attributes and validators to a form field.
const formState = state(
{ email: '' },
insertForm(
insertSelectFormTree(
'email',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
disable: () => isLoading(),
hidden: () => !showField(),
})),
),
),
);
// Access email field and its exceptions
const form = formState.form();
const emailField = form.selectEmail();
const errors = emailField()().exceptions.list; // fully typed list of exceptions
const emailError = emailField()().exceptions.byValidator['cEmail'];insertFormSubmit
Connects form submission to a mutation.
const updateUserMutation = mutation({
method: (data: ValidatedFormValue<UserForm>) => data,
loader: async ({ params }) => {
const response = await fetch('/api/users', {
method: 'PATCH',
body: JSON.stringify(params),
});
return response.json();
},
});
const userFormState = state(
{ name: '', email: '' },
insertForm(
insertSelectFormTree(
'name',
insertNoopTypingAnchor,
insertFormAttributes(() => ({ validators: [cRequired()] })),
),
insertSelectFormTree(
'email',
insertNoopTypingAnchor,
insertFormAttributes(() => ({ validators: [cRequired(), cEmail()] })),
),
insertFormSubmit(updateUserMutation, {
success: () => {
console.log('Form submitted successfully');
return undefined;
},
}),
),
);
// Submit the form
userFormState.form().submit(); // Automatically triggers the mutationinsertSelectFormTree
Selects and composes nested sub-forms.
interface ProductForm {
name: string;
variants: Array<{
color: string;
stock: number;
}>;
}
const productFormState = state(
{ name: '', variants: [] } as ProductForm,
insertForm(
insertSelectFormTree(
'variant',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cMin({ min: 0 })],
})),
),
),
);
// Access sub-forms
const form = productFormState.form();
const variant0 = form.selectVariant(0);
const allVariants = form.items();Validators
@craft-ng provides a complete set of validators with structured exception handling:
Built-in Validators
cRequired
Checks that a value is present (not empty).
insertFormAttributes(() => ({
validators: [cRequired()],
}));
// With condition
insertFormAttributes(() => ({
validators: [cRequired({ when: () => fieldIsRequired() })],
}));cEmail
Checks that a string is a valid email.
insertFormAttributes(() => ({
validators: [cEmail()],
}));cMin / cMax
Checks that a numeric value is within a range.
insertFormAttributes(() => ({
validators: [cMin({ min: 18 }), cMax({ max: 100 })],
}));
// Dynamic values
insertFormAttributes(() => ({
validators: [cMin({ min: () => minimumValue() })],
}));cMinLength / cMaxLength
Checks the length of a string or collection.
insertFormAttributes(() => ({
validators: [cMinLength({ minLength: 8 }), cMaxLength({ maxLength: 500 })],
}));cPattern
Checks that a string matches a regex pattern.
insertFormAttributes(() => ({
validators: [cPattern({ pattern: /^\d{10}$/ })],
}));Custom Validators
cValidate
Creates a custom synchronous validator.
insertFormAttributes(() => ({
validators: [
cValidate({
name: 'passwordStrength',
validWhen: () => {
const pwd = password();
return pwd.length >= 8 && /[A-Z]/.test(pwd);
},
exception: () =>
craftException(
{ code: 'weak-password' },
{
message:
'Password must contain 8 characters and an uppercase letter',
},
),
}),
],
}));cAsyncValidate
Creates an asynchronous validator based on a resource (query or mutation).
It is not working yet, we are still working on it. The API is not final and may change.const checkEmailQuery = query({
params: () => ({ email: emailInput() }),
loader: async ({ params }) => {
const response = await fetch(`/api/check-email?email=${params.email}`);
return response.json();
},
});
insertFormAttributes(() => ({
validators: [
cAsyncValidate(checkEmailQuery, {
name: 'emailAvailability',
exceptionsOnSuccess: ({ validateAsyncCraftResource }) => {
if (!validateAsyncCraftResource.value()?.available) {
return craftException({ code: 'email-taken' }, undefined);
}
return undefined;
},
}),
],
}));Exception Handling
@craft-ng forms use a structured exception system:
const form = userFormState.form();
// All exceptions
const allErrors = form.exceptions().list;
// Exception by validator
const requiredError = form().selectEmail()().exceptions()?.byValidator[
'cRequired'
];
const emailError = form().selectEmail()().exceptions()?.byValidator.cEmail;
// Each exception has a code and payload
if (emailError) {
console.log(emailError.code); // 'email'
console.log(emailError.payload); // undefined
}
if (form.exceptions()?.byValidator?.cMin) {
const minError = form.exceptions()?.byValidator?.['cMin'];
console.log(minError.code); // 'min'
console.log(minError.payload); // 18 (the minimum value)
}Complete Examples
Creation Form with Validation
interface User {
name: string;
email: string;
age: number;
}
const createUserMutation = mutation({
method: (data: ValidatedFormValue<User>) => data,
loader: async ({ params }) => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(params),
});
return response.json();
},
});
const userFormState = state(
{ name: '', email: '', age: 0 } satisfies User,
insertForm(
insertSelectFormTree(
'name',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired()],
})),
),
insertSelectFormTree(
'email',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
})),
),
insertSelectFormTree(
'age',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cMin({ min: 18 })],
})),
),
insertFormSubmit(createUserMutation),
),
);Complex Nested Form
interface Address {
street: string;
city: string;
zipCode: string;
}
interface User {
name: string;
email: string;
addresses: Address[];
}
const userFormState = state(
{
name: '',
email: '',
addresses: [],
} satisfies User,
insertForm(
insertSelectFormTree(
'name',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired()],
})),
),
insertSelectFormTree(
'email',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cEmail()],
})),
),
insertSelectFormTree(
'addresses',
insertNoopTypingAnchor,
insertSelectFormTree(
'street',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired()],
})),
),
insertSelectFormTree(
'city',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired()],
})),
),
insertSelectFormTree(
'zipCode',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cPattern({ pattern: /^\d{5}$/ })],
})),
),
),
insertSelectFormTree(
'address',
insertNoopTypingAnchor,
insertFormAttributes(() => ({
validators: [cRequired(), cMinLength({ minLength: 5 })],
})),
),
),
);