refactor(forms): add onError callback to validateHttp for HTTP errors

Adds onError callback inside validateHttp validator in signal forms

PR-Close: #63949
(cherry picked from commit a5678f6f2b)
This commit is contained in:
csorrentino 2025-10-23 23:14:44 +02:00 committed by Kristiyan Kostadinov
parent 04dd75ba94
commit bd780366e8
6 changed files with 91 additions and 29 deletions

View file

@ -61,8 +61,9 @@ export type AsyncValidationResult<E extends ValidationError = ValidationError> =
// @public
export interface AsyncValidatorOptions<TValue, TParams, TResult, TPathKind extends PathKind = PathKind.Root> {
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
readonly factory: (params: Signal<TParams | undefined>) => ResourceRef<TResult | undefined>;
readonly onError: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
readonly onSuccess: MapToErrorsFn<TValue, TResult, TPathKind>;
readonly params: (ctx: FieldContext<TValue, TPathKind>) => TParams;
}
@ -233,7 +234,8 @@ export function hidden<TValue, TPathKind extends PathKind = PathKind.Root>(path:
// @public
export interface HttpValidatorOptions<TValue, TResult, TPathKind extends PathKind = PathKind.Root> {
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
readonly onError: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
readonly onSuccess: MapToErrorsFn<TValue, TResult, TPathKind>;
readonly options?: HttpResourceOptions<TResult, unknown>;
readonly request: ((ctx: FieldContext<TValue, TPathKind>) => string | undefined) | ((ctx: FieldContext<TValue, TPathKind>) => HttpResourceRequest | undefined);
}

View file

@ -71,7 +71,11 @@ export interface AsyncValidatorOptions<
* @returns A reference to the constructed resource.
*/
readonly factory: (params: Signal<TParams | undefined>) => ResourceRef<TResult | undefined>;
/**
* A function to handle errors thrown by httpResource (HTTP errors, network errors, etc.).
* Receives the error and the field context, returns a list of validation errors.
*/
readonly onError: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
/**
* A function that takes the resource result, and the current field context and maps it to a list
* of validation errors.
@ -83,7 +87,7 @@ export interface AsyncValidatorOptions<
* A targeted error will show up as an error on its target field rather than the field being validated.
* If a field is not given, the error is assumed to apply to the field being validated.
*/
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
readonly onSuccess: MapToErrorsFn<TValue, TResult, TPathKind>;
}
/**
@ -120,8 +124,13 @@ export interface HttpValidatorOptions<TValue, TResult, TPathKind extends PathKin
* A targeted error will show up as an error on its target field rather than the field being validated.
* If a field is not given, the error is assumed to apply to the field being validated.
*/
readonly errors: MapToErrorsFn<TValue, TResult, TPathKind>;
readonly onSuccess: MapToErrorsFn<TValue, TResult, TPathKind>;
/**
* A function to handle errors thrown by httpResource (HTTP errors, network errors, etc.).
* Receives the error and the field context, returns a list of validation errors.
*/
readonly onError: (error: unknown, ctx: FieldContext<TValue, TPathKind>) => TreeValidationResult;
/**
* The options to use when creating the httpResource.
*/
@ -163,6 +172,7 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
pathNode.logic.addAsyncErrorRule((ctx) => {
const res = ctx.state.metadata(RESOURCE)!;
let errors;
switch (res.status()) {
case 'idle':
return undefined;
@ -174,11 +184,11 @@ export function validateAsync<TValue, TParams, TResult, TPathKind extends PathKi
if (!res.hasValue()) {
return undefined;
}
const errors = opts.errors(res.value()!, ctx as FieldContext<TValue, TPathKind>);
errors = opts.onSuccess(res.value()!, ctx as FieldContext<TValue, TPathKind>);
return addDefaultField(errors, ctx.field);
case 'error':
// TODO: Design error handling for async validation. For now, just throw the error.
throw res.error();
errors = opts.onError(res.error(), ctx as FieldContext<TValue, TPathKind>);
return addDefaultField(errors, ctx.field);
}
});
}
@ -203,6 +213,7 @@ export function validateHttp<TValue, TResult = unknown, TPathKind extends PathKi
validateAsync(path, {
params: opts.request,
factory: (request: Signal<any>) => httpResource(request, opts.options),
errors: opts.errors,
onSuccess: opts.onSuccess,
onError: opts.onError,
});
}

View file

@ -87,9 +87,10 @@ export function validateStandardSchema<TSchema, TValue extends IgnoreUnknownProp
loader: async ({params}) => (await params)?.issues ?? [],
});
},
errors: (issues, {fieldOf}) => {
onSuccess: (issues, {fieldOf}) => {
return issues.map((issue) => standardIssueToFormTreeError(fieldOf(path), issue));
},
onError: () => {},
});
}

View file

@ -161,7 +161,7 @@ describe('resources', () => {
return params as Cat[];
},
}),
errors: (cats, {fieldOf}) => {
onSuccess: (cats, {fieldOf}) => {
return cats.map((cat, index) =>
customError({
kind: 'meows_too_much',
@ -170,6 +170,7 @@ describe('resources', () => {
}),
);
},
onError: () => null,
});
};
@ -196,13 +197,14 @@ describe('resources', () => {
return params as Cat[];
},
}),
errors: (cats, {fieldOf}) => {
onSuccess: (cats, {fieldOf}) => {
return customError({
kind: 'meows_too_much',
name: cats[0].name,
field: fieldOf(p)[0],
});
},
onError: () => null,
});
};
@ -222,8 +224,9 @@ describe('resources', () => {
(p) => {
validateHttp(p, {
request: ({value}) => `/api/check?username=${value()}`,
errors: (available: boolean) =>
onSuccess: (available: boolean) =>
available ? undefined : customError({kind: 'username-taken'}),
onError: () => null,
});
},
{injector},
@ -266,8 +269,9 @@ describe('resources', () => {
required(address.street);
validateHttp(address, {
request: ({value}) => ({url: '/checkaddress', params: {...value()}}),
errors: (message: string, {fieldOf}) =>
onSuccess: (message: string, {fieldOf}) =>
customError({message, field: fieldOf(address.street)}),
onError: () => null,
});
});
const addressForm = form(addressModel, addressSchema, {injector});
@ -286,4 +290,36 @@ describe('resources', () => {
customError({message: 'Invalid!', field: addressForm.street}),
]);
});
it('should call onError handler when http validation fails', async () => {
const addressModel = signal<Address>({street: '123 Main St', city: '', zip: ''});
const addressSchema = schema<Address>((address) => {
required(address.street);
validateHttp(address, {
request: ({value}) => ({url: '/checkaddress', params: {...value()}}),
onSuccess: () => undefined,
onError: () => [
customError({kind: 'address-api-failed', message: 'API is down', field: addressForm}),
],
});
});
const addressForm = form(addressModel, addressSchema, {injector});
TestBed.tick();
const req = backend.expectOne('/checkaddress?street=123%20Main%20St&city=&zip=');
req.flush(null, {status: 500, statusText: 'Server Error'});
await appRef.whenStable();
expect(addressForm().pending()).toBe(false);
expect(addressForm().invalid()).toBe(true);
expect(addressForm().errors()).toEqual([
customError({
kind: 'address-api-failed',
message: 'API is down',
field: addressForm,
}),
]);
});
});

View file

@ -49,7 +49,8 @@ describe('submit', () => {
params,
loader: () => resolvers.promise,
}),
errors: () => {},
onSuccess: () => {},
onError: () => {},
});
},
{injector: TestBed.inject(Injector)},

View file

@ -188,7 +188,8 @@ describe('validation status', () => {
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs) => errs,
onSuccess: (results) => results,
onError: () => null,
});
},
{injector},
@ -233,11 +234,12 @@ describe('validation status', () => {
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs, {fieldOf}) =>
errs.map((e) => ({
onSuccess: (results, {fieldOf}) =>
results.map((e) => ({
...e,
field: fieldOf(p.child),
})),
onError: () => null,
});
},
{injector},
@ -282,11 +284,12 @@ describe('validation status', () => {
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs, {fieldOf}) =>
errs.map((e) => ({
onSuccess: (results, {fieldOf}) =>
results.map((e) => ({
...e,
field: fieldOf(p.child),
})),
onError: () => null,
});
},
{injector},
@ -334,11 +337,12 @@ describe('validation status', () => {
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs, {fieldOf}) =>
errs.map((e) => ({
onSuccess: (results, {fieldOf}) =>
results.map((e) => ({
...e,
field: fieldOf(p.child),
})),
onError: () => null,
});
},
{injector},
@ -383,7 +387,8 @@ describe('validation status', () => {
setTimeout(() => r(validateValueForChild(params, undefined))),
),
})),
errors: (errs) => errs,
onSuccess: (results) => results,
onError: () => null,
});
},
{injector},
@ -443,7 +448,8 @@ describe('validation status', () => {
params,
loader: () => new Promise<ValidationError[]>((r) => setTimeout(() => r([]))),
})),
errors: (errs) => errs,
onSuccess: (results) => results,
onError: () => null,
});
},
{injector},
@ -470,7 +476,8 @@ describe('validation status', () => {
params,
loader: () => promise,
})),
errors: (errs) => errs,
onSuccess: (results) => results,
onError: () => null,
});
validateAsync(p, {
params: () => [],
@ -479,7 +486,8 @@ describe('validation status', () => {
params,
loader: () => promise2,
})),
errors: (errs) => errs,
onSuccess: (results) => results,
onError: () => null,
});
},
{injector},
@ -514,7 +522,8 @@ describe('validation status', () => {
params,
loader: () => invalidPromise,
})),
errors: (errs) => errs,
onSuccess: (results) => results,
onError: () => null,
});
validateAsync(p, {
params: () => [],
@ -523,7 +532,8 @@ describe('validation status', () => {
params,
loader: () => validPromise,
})),
errors: (errs) => errs,
onSuccess: (results) => results,
onError: () => null,
});
validateAsync(p, {
params: () => [],
@ -532,7 +542,8 @@ describe('validation status', () => {
params,
loader: () => pendingPromise,
})),
errors: (errs) => errs,
onSuccess: (results) => results,
onError: () => null,
});
},
{injector},