@@ -105,7 +103,7 @@
color="primary"
size="medium"
pi-disabled
- [isDisabled]="disabled"
+ [isDisabled]="disabled()"
>
}
@@ -137,7 +135,7 @@
[description]="option?.description"
[icon]="option?.icon"
[isSelected]="option?.id === selectedOption?.id"
- [isDisabled]="disabled"
+ [isDisabled]="disabled()"
[size]="selectOptionSize"
>
@@ -164,7 +162,7 @@
[description]="option?.description"
[icon]="option?.icon"
[isSelected]="option?.id === selectedOption?.id"
- [isDisabled]="disabled"
+ [isDisabled]="disabled()"
[flexDirection]="flexDirection()"
[size]="selectOptionSize"
>
diff --git a/libs/ui/src/lib/ui/select/select-period/select-period.component.ts b/libs/ui/src/lib/ui/select/select-period/select-period.component.ts
index 2a8f4cd..a068429 100644
--- a/libs/ui/src/lib/ui/select/select-period/select-period.component.ts
+++ b/libs/ui/src/lib/ui/select/select-period/select-period.component.ts
@@ -6,14 +6,10 @@ import {
ElementRef,
HostBinding,
HostListener,
- Injector,
Renderer2,
- input,
+ effect,
} from '@angular/core';
-import { toObservable } from '@angular/core/rxjs-interop';
-import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { TranslateService, TranslatePipe } from '@ngx-translate/core';
-import { merge } from 'rxjs';
import { PiBaseSelectComponent } from '../base-select.component';
import { PiSelectOptionComponent } from '../select-option/select-option.component';
import _ from 'lodash';
@@ -27,15 +23,7 @@ import { PiDisabledDirective as PiDisabledDirective_1 } from '../../text/directi
templateUrl: './select-period.component.html',
styleUrls: ['./select-period.component.scss', '../styles/base-select.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: PiSelectPeriodComponent,
- multi: true,
- },
- ],
imports: [
- ReactiveFormsModule,
PiIconComponent,
NgTemplateOutlet,
PiDisabledDirective,
@@ -71,7 +59,7 @@ export class PiSelectPeriodComponent
*/
@HostListener('click')
onClick() {
- if (!this.isMenuVisible() && !this.disabled) {
+ if (!this.isMenuVisible() && !this.disabled()) {
this.setIsVisible();
} else {
this.setIsHidden();
@@ -94,35 +82,32 @@ export class PiSelectPeriodComponent
* Component's constructor. Initializes the component with required services.
* @param translateService - Service used for translating option names.
* @param el - Element reference of the component.
- * @param injector - Injector to get instances of dependencies.
* @param _renderer - Renderer to modify native elements.
*/
constructor(
private readonly translateService: TranslateService,
protected override el: ElementRef,
- protected override injector: Injector,
protected override readonly _renderer: Renderer2,
private _cdr: ChangeDetectorRef,
) {
- super(el, injector, _renderer);
+ super(el, _renderer);
+
+ effect(() => {
+ this.childrenOptionsComponents();
+ this.viewChildOptionsComponents();
+ const selectedOption = this.selectedOption;
+ queueMicrotask(() => {
+ this.updateChildrenOptionsSelectedState(selectedOption);
+ });
+ });
}
/**
- * Getter for the value of the control.
- * @returns The value of the control as a string.
- */
- get controlValue(): any {
- return this.control.value;
- }
-
- /**
- * Getter for the selected option based on the control value.
+ * Getter for the selected option based on the current value.
* @returns The selected option.
*/
get selectedOption(): any {
- return this.options().find((option) =>
- _.isEqual(this.controlValue, option),
- );
+ return this.options().find((option) => _.isEqual(this.value(), option));
}
/**
@@ -134,48 +119,18 @@ export class PiSelectPeriodComponent
return this.translateService.instant(this.selectedOption.name);
}
- /**
- * Angular lifecycle hook that is called after Angular has fully initialized
- * a component's content. This method sets up reactive subscriptions to listen
- * for changes in either content children or view children.
- *
- * The method uses the `merge` RxJS operator to combine streams of changes
- * from both `childrenOptionsComponents` (content children) and
- * `viewChildOptionsComponents` (view children). This ensures that whenever
- * any changes occur in either of the child lists, the visual state of the
- * select options is updated appropriately.
- *
- * Using `Promise.resolve().then(...)`, the method ensures that the
- * `updateChildrenOptionsSelectedState` method is executed in the next
- * microtask, after any ongoing change detection cycles are completed.
- * This approach ensures consistent and correct behavior in all scenarios.
- *
- * Lastly, it proactively calls `updateChildrenOptionsSelectedState` to
- * set the initial state of the child options based on the current value of
- * `selectedOption`.
- */
ngAfterContentInit(): void {
- this.control.setValue(this.selectedOption);
- merge(
- toObservable(this.childrenOptionsComponents, { injector: this.injector }),
- toObservable(this.viewChildOptionsComponents, {
- injector: this.injector,
- }),
- this.control.valueChanges,
- ).subscribe(() => {
- Promise.resolve().then(() => {
- this.updateChildrenOptionsSelectedState(this.selectedOption);
- });
- });
+ this.updateChildrenOptionsSelectedState(this.selectedOption);
}
/**
* Handles the logic for selecting an option.
- * Sets the control value to the value of the selected option and updates child states.
+ * Sets the value to the selected option and updates child states.
* @param option - The option to be selected.
*/
onSelectOption(option: any) {
- this.control.setValue(option);
+ this.value.set(option);
+ this.touch.emit();
this.updateChildrenOptionsSelectedState(option);
}
@@ -208,7 +163,7 @@ export class PiSelectPeriodComponent
// Update the visual state of each child option.
childrenOptionsComponent?.forEach((childOption, index) => {
const isSelected = selectedOption
- ? selectedOption[this.keyIdProperty()] === childOption['id']
+ ? selectedOption[this.keyIdProperty()] === childOption.id()
: false;
// If the current child option matches the selected option, highlight it.
if (isSelected) {
diff --git a/libs/ui/src/lib/ui/select/select/select.component.html b/libs/ui/src/lib/ui/select/select/select.component.html
index 3cd1690..cf82dde 100644
--- a/libs/ui/src/lib/ui/select/select/select.component.html
+++ b/libs/ui/src/lib/ui/select/select/select.component.html
@@ -1,5 +1,3 @@
-
-
@if (type() === 'dropdown') {
@if (pickedOptionType() === 'button') {
@@ -27,7 +25,7 @@
size="x-large"
appearance="fill"
[isStroked]="true"
- [animation]="disabled ? undefined : 'clickable'"
+ [animation]="disabled() ? undefined : 'clickable'"
>
@@ -105,7 +103,7 @@
color="primary"
size="medium"
pi-disabled
- [isDisabled]="disabled"
+ [isDisabled]="disabled()"
>
}
@@ -136,7 +134,7 @@
[description]="option?.description"
[icon]="option?.icon"
[isSelected]="option?.id === selectedOption?.id"
- [isDisabled]="disabled"
+ [isDisabled]="disabled()"
[flexDirection]="flexDirection()"
[size]="selectOptionSize"
>
diff --git a/libs/ui/src/lib/ui/select/select/select.component.ts b/libs/ui/src/lib/ui/select/select/select.component.ts
index 5a378b9..878e2c3 100644
--- a/libs/ui/src/lib/ui/select/select/select.component.ts
+++ b/libs/ui/src/lib/ui/select/select/select.component.ts
@@ -6,16 +6,12 @@ import {
ElementRef,
HostBinding,
HostListener,
- Injector,
- QueryList,
Renderer2,
+ effect,
forwardRef,
input,
} from '@angular/core';
-import { toObservable } from '@angular/core/rxjs-interop';
-import { NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { TranslateService, TranslatePipe } from '@ngx-translate/core';
-import { merge } from 'rxjs';
import { PiBaseSelectComponent } from '../base-select.component';
import { PiSelectOptionComponent } from '../select-option/select-option.component';
import _ from 'lodash';
@@ -29,15 +25,7 @@ import { PiDisabledDirective as PiDisabledDirective_1 } from '../../text/directi
templateUrl: './select.component.html',
styleUrls: ['../styles/base-select.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: PiSelectComponent,
- multi: true,
- },
- ],
imports: [
- ReactiveFormsModule,
PiIconComponent,
NgTemplateOutlet,
PiDisabledDirective,
@@ -77,7 +65,7 @@ export class PiSelectComponent
*/
@HostListener('click')
onClick() {
- if (!this.isMenuVisible() && !this.disabled) {
+ if (!this.isMenuVisible() && !this.disabled()) {
this.setIsVisible();
} else {
this.setIsHidden();
@@ -102,35 +90,37 @@ export class PiSelectComponent
* Component's constructor. Initializes the component with required services.
* @param translateService - Service used for translating option names.
* @param el - Element reference of the component.
- * @param injector - Injector to get instances of dependencies.
* @param _renderer - Renderer to modify native elements.
*/
constructor(
private readonly translateService: TranslateService,
protected override el: ElementRef,
- protected override injector: Injector,
protected override readonly _renderer: Renderer2,
private _cdr: ChangeDetectorRef,
) {
- super(el, injector, _renderer);
+ super(el, _renderer);
+
+ /**
+ * Sustituye al `merge(...).subscribe(...)` sobre `control.valueChanges` +
+ * content/view children: al ser todo señales, un solo `effect` reacciona
+ * a los mismos cambios (valor, hijos proyectados o de plantilla).
+ */
+ effect(() => {
+ this.childrenOptionsComponents();
+ this.viewChildOptionsComponents();
+ const selectedOption = this.selectedOption;
+ queueMicrotask(() => {
+ this.updateChildrenOptionsSelectedState(selectedOption);
+ });
+ });
}
/**
- * Getter for the value of the control.
- * @returns The value of the control as a string.
- */
- get controlValue(): any {
- return this.control.value;
- }
-
- /**
- * Getter for the selected option based on the control value.
+ * Getter for the selected option based on the current value.
* @returns The selected option.
*/
get selectedOption(): any {
- return this.options().find((option) =>
- _.isEqual(this.controlValue, option),
- );
+ return this.options().find((option) => _.isEqual(this.value(), option));
}
/**
@@ -142,48 +132,18 @@ export class PiSelectComponent
return this.translateService.instant(this.selectedOption.name);
}
- /**
- * Angular lifecycle hook that is called after Angular has fully initialized
- * a component's content. This method sets up reactive subscriptions to listen
- * for changes in either content children or view children.
- *
- * The method uses the `merge` RxJS operator to combine streams of changes
- * from both `childrenOptionsComponents` (content children) and
- * `viewChildOptionsComponents` (view children). This ensures that whenever
- * any changes occur in either of the child lists, the visual state of the
- * select options is updated appropriately.
- *
- * Using `Promise.resolve().then(...)`, the method ensures that the
- * `updateChildrenOptionsSelectedState` method is executed in the next
- * microtask, after any ongoing change detection cycles are completed.
- * This approach ensures consistent and correct behavior in all scenarios.
- *
- * Lastly, it proactively calls `updateChildrenOptionsSelectedState` to
- * set the initial state of the child options based on the current value of
- * `selectedOption`.
- */
ngAfterContentInit(): void {
- this.control.setValue(this.selectedOption);
- merge(
- toObservable(this.childrenOptionsComponents, { injector: this.injector }),
- toObservable(this.viewChildOptionsComponents, {
- injector: this.injector,
- }),
- this.control.valueChanges,
- ).subscribe(() => {
- Promise.resolve().then(() => {
- this.updateChildrenOptionsSelectedState(this.selectedOption);
- });
- });
+ this.updateChildrenOptionsSelectedState(this.selectedOption);
}
/**
* Handles the logic for selecting an option.
- * Sets the control value to the value of the selected option and updates child states.
+ * Sets the value to the selected option and updates child states.
* @param option - The option to be selected.
*/
onSelectOption(option: any) {
- this.control.setValue(option);
+ this.value.set(option);
+ this.touch.emit();
this.updateChildrenOptionsSelectedState(option);
}
@@ -216,7 +176,7 @@ export class PiSelectComponent
// Update the visual state of each child option.
childrenOptionsComponent?.forEach((childOption, index) => {
const isSelected = selectedOption
- ? selectedOption[this.keyIdProperty()] === childOption['id']
+ ? selectedOption[this.keyIdProperty()] === childOption.id()
: false;
// If the current child option matches the selected option, highlight it.
if (isSelected) {
diff --git a/libs/ui/src/lib/ui/stepper/index.ts b/libs/ui/src/lib/ui/stepper/index.ts
new file mode 100644
index 0000000..e7df546
--- /dev/null
+++ b/libs/ui/src/lib/ui/stepper/index.ts
@@ -0,0 +1 @@
+export * from './stepper/stepper.component';
diff --git a/libs/ui/src/lib/ui/stepper/stepper/stepper.component.html b/libs/ui/src/lib/ui/stepper/stepper/stepper.component.html
new file mode 100644
index 0000000..3630452
--- /dev/null
+++ b/libs/ui/src/lib/ui/stepper/stepper/stepper.component.html
@@ -0,0 +1,37 @@
+
+ @for (step of steps(); track step.label; let i = $index) {
+
+
+
+ @if (isCompleted(i) && showCompletedIcon()) {
+
+ } @else {
+ {{ i + 1 }}
+ }
+
+
+ {{ step.label | translate }}
+ @if (step.description) {
+ {{ step.description | translate }}
+ }
+
+
+
+
+ @if (i < lastIndex()) {
+
+ }
+
+ }
+
diff --git a/libs/ui/src/lib/ui/stepper/stepper/stepper.component.scss b/libs/ui/src/lib/ui/stepper/stepper/stepper.component.scss
new file mode 100644
index 0000000..ba67e29
--- /dev/null
+++ b/libs/ui/src/lib/ui/stepper/stepper/stepper.component.scss
@@ -0,0 +1,124 @@
+:host {
+ display: block;
+}
+
+.pi-stepper {
+ &__steps {
+ display: flex;
+ gap: var(--pi-spacing-x-small);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ &__step {
+ display: flex;
+ align-items: center;
+ gap: var(--pi-spacing-x-small);
+ flex: 1;
+ min-width: 0;
+ }
+
+ &__trigger {
+ display: flex;
+ align-items: center;
+ gap: var(--pi-spacing-xx-small);
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--pi-color-grey);
+ font-family: inherit;
+ text-align: left;
+ cursor: pointer;
+
+ &:disabled {
+ cursor: default;
+ }
+ }
+
+ // Círculo con el número o el check.
+ &__marker {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: var(--pi-spacing-x-large);
+ height: var(--pi-spacing-x-large);
+ border: 2px solid var(--pi-color-grey-200);
+ border-radius: 50%;
+ background-color: var(--pi-color-white);
+ font-size: var(--pi-font-size-x-small);
+ font-weight: 600;
+ transition: var(--pi-theme-button-transition);
+ }
+
+ &__text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ font-size: var(--pi-font-size-small);
+
+ small {
+ color: var(--pi-color-grey);
+ font-size: var(--pi-font-size-xx-small);
+ }
+ }
+
+ // Línea de unión: ocupa el hueco sobrante entre pasos.
+ &__connector {
+ flex: 1;
+ min-width: var(--pi-spacing-medium);
+ height: 2px;
+ background-color: var(--pi-color-grey-200);
+ }
+
+ &__step.is-selected {
+ .pi-stepper__marker {
+ border-color: var(--pi-color-primary);
+ color: var(--pi-color-primary);
+ }
+ .pi-stepper__trigger {
+ color: var(--pi-color-black);
+ font-weight: 600;
+ }
+ }
+
+ &__step.is-completed {
+ .pi-stepper__marker {
+ border-color: var(--pi-color-primary);
+ background-color: var(--pi-color-primary);
+ color: var(--pi-color-white);
+ }
+ .pi-stepper__connector {
+ background-color: var(--pi-color-primary);
+ }
+ }
+
+ &__step.is-disabled {
+ .pi-stepper__marker,
+ .pi-stepper__trigger {
+ opacity: 0.5;
+ }
+ }
+}
+
+// Vertical: los pasos se apilan y el conector pasa a ser una línea vertical
+// alineada con el centro del círculo.
+:host(.pi-stepper__direction--vertical) {
+ .pi-stepper__steps {
+ flex-direction: column;
+ gap: 0;
+ }
+ .pi-stepper__step {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--pi-spacing-xxx-small);
+ }
+ .pi-stepper__connector {
+ width: 2px;
+ height: var(--pi-spacing-large);
+ min-width: unset;
+ flex: none;
+ margin-left: calc(var(--pi-spacing-x-large) / 2 - 1px);
+ }
+}
diff --git a/libs/ui/src/lib/ui/stepper/stepper/stepper.component.ts b/libs/ui/src/lib/ui/stepper/stepper/stepper.component.ts
new file mode 100644
index 0000000..c17cc9f
--- /dev/null
+++ b/libs/ui/src/lib/ui/stepper/stepper/stepper.component.ts
@@ -0,0 +1,94 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ HostBinding,
+ computed,
+ input,
+ model,
+ output,
+} from '@angular/core';
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { ComponentFamilyClass } from '../../../styles';
+import { PiIconComponent } from '../../icon/icon/icon.component';
+
+/** Paso de un `pi-stepper`. */
+export interface PiStep {
+ label: string;
+ /** Texto secundario bajo la etiqueta. */
+ description?: string;
+ /** Bloquea el salto directo a este paso. */
+ disabled?: boolean;
+}
+
+/**
+ * Indicador de progreso por pasos.
+ *
+ * `WrapperTypeClass.Steps` ya declaraba la familia (`pi-wrapper__steps`) pero
+ * no existía ni componente ni CSS. Este componente aporta ambos.
+ *
+ * Solo pinta la cabecera de pasos: el contenido de cada uno lo controla el
+ * consumidor a partir de `selectedIndex`, para no imponer una estructura.
+ */
+@Component({
+ selector: 'pi-stepper',
+ templateUrl: './stepper.component.html',
+ styleUrls: ['./stepper.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [PiIconComponent, TranslatePipe],
+})
+export class PiStepperComponent {
+ @HostBinding('class') get classes(): string {
+ return [
+ ComponentFamilyClass.List,
+ 'pi-stepper',
+ `pi-stepper__direction--${this.direction()}`,
+ ].join(' ');
+ }
+
+ readonly steps = input.required
();
+
+ readonly selectedIndex = model(0);
+
+ readonly direction = input<'horizontal' | 'vertical'>('horizontal');
+
+ /** Si es `false`, solo se avanza con `next()`/`previous()`. */
+ readonly isClickable = input(true);
+
+ /** Marca los pasos ya superados con un check en vez del número. */
+ readonly showCompletedIcon = input(true);
+
+ readonly stepChange = output();
+
+ protected readonly lastIndex = computed(() => this.steps().length - 1);
+
+ protected isCompleted(index: number): boolean {
+ return index < this.selectedIndex();
+ }
+
+ protected isSelected(index: number): boolean {
+ return index === this.selectedIndex();
+ }
+
+ protected select(index: number): void {
+ if (!this.isClickable()) return;
+ if (this.steps()[index]?.disabled) return;
+ if (index === this.selectedIndex()) return;
+ this.selectedIndex.set(index);
+ this.stepChange.emit(index);
+ }
+
+ next(): void {
+ const next = Math.min(this.selectedIndex() + 1, this.lastIndex());
+ if (next === this.selectedIndex()) return;
+ this.selectedIndex.set(next);
+ this.stepChange.emit(next);
+ }
+
+ previous(): void {
+ const previous = Math.max(this.selectedIndex() - 1, 0);
+ if (previous === this.selectedIndex()) return;
+ this.selectedIndex.set(previous);
+ this.stepChange.emit(previous);
+ }
+}
diff --git a/libs/ui/src/lib/ui/wrapper/form/form.wrapper.html b/libs/ui/src/lib/ui/wrapper/form/form.wrapper.html
index f0fed23..2cbdf1d 100644
--- a/libs/ui/src/lib/ui/wrapper/form/form.wrapper.html
+++ b/libs/ui/src/lib/ui/wrapper/form/form.wrapper.html
@@ -1,6 +1,6 @@
@if (title()) {
{{ title() }}
}
-
diff --git a/libs/ui/src/lib/ui/wrapper/form/form.wrapper.ts b/libs/ui/src/lib/ui/wrapper/form/form.wrapper.ts
index 7f8859e..b15f036 100644
--- a/libs/ui/src/lib/ui/wrapper/form/form.wrapper.ts
+++ b/libs/ui/src/lib/ui/wrapper/form/form.wrapper.ts
@@ -27,7 +27,15 @@ export class PiFormWrapper extends PiBaseWrapper {
].join(' ');
}
- readonly formGroup = input.required();
+ /**
+ * Opcional: solo hace falta si el contenido proyectado usa Reactive Forms
+ * clásico (`[formControlName]`), que exige un `ControlContainer`. Con Signal
+ * Forms (`[formField]`) no se necesita ningún `FormGroup`.
+ */
+ readonly formGroup = input();
+
+ /** Evita pasar `undefined` a `[formGroup]` cuando no se proporciona. */
+ protected readonly emptyFormGroup = new FormGroup({});
readonly title = input();
readonly subtitle = input();
diff --git a/libs/ui/src/lib/util/validator/utils.ts b/libs/ui/src/lib/util/validator/utils.ts
index 51b4493..a2d898e 100644
--- a/libs/ui/src/lib/util/validator/utils.ts
+++ b/libs/ui/src/lib/util/validator/utils.ts
@@ -1,14 +1,34 @@
-import {
- AbstractControl,
- FormGroup,
- ValidationErrors,
- ValidatorFn,
-} from '@angular/forms';
+/**
+ * Validadores de dominio para Signal Forms.
+ *
+ * Antes eran `ValidatorFn`/`AbstractControl` de Reactive Forms. Ahora son
+ * *helpers de esquema*, del mismo estilo que los `required()` / `email()` /
+ * `min()` que trae `@angular/forms/signals`: se invocan dentro del callback de
+ * `form()` sobre el `path` del campo.
+ *
+ * ```ts
+ * const f = form(model, (m) => {
+ * validateNif(m.nif);
+ * validateNotRepeatedChar(m.name, { message: 'Nombre incorrecto' });
+ * });
+ * ```
+ *
+ * Se han eliminado los que solo reimplementaban un built-in: para obligatorio,
+ * longitudes y mínimos/máximos usa `required()`, `minLength()`, `maxLength()`,
+ * `min()` y `max()` de `@angular/forms/signals`.
+ *
+ * Los mensajes por defecto siguen siendo claves de i18n para que el consumidor
+ * las resuelva con su propio `TranslateService`.
+ */
+import { validate, validateTree } from '@angular/forms/signals';
+import type { SchemaPath, ValidationError } from '@angular/forms/signals';
+
import { environment } from '../environments';
import {
+ cognitoPhonePattern,
dniPattern,
emailPattern,
- urlPattern,
+ monthYearPattern,
niePattern,
nifPattern,
onlyNumbersDecimalsPattern,
@@ -16,309 +36,229 @@ import {
onlyPositiveNumberPattern,
passwordPattern,
phonePattern,
- positiveNumbersWithDecimals,
positiveNumbersIncludingZero,
+ positiveNumbersWithDecimals,
repeatedPattern,
- cognitoPhonePattern,
- monthYearPattern,
+ urlPattern,
} from './definitions';
-export function validateRequired(
- control: AbstractControl,
-): ValidationErrors | null {
- const value: string = control.value as string;
- if (
- value === null ||
- value === undefined ||
- value === '' ||
- value.length === 0
- ) {
- return { message: ['common.validator.requiredField'] };
- }
- return null;
+/** Opciones comunes: permiten sustituir el mensaje por defecto. */
+export interface PiValidatorConfig {
+ message?: string;
}
-export function validateRequiredIfDirty(
- control: AbstractControl,
-): ValidationErrors | null {
- const value: string = control.value as string;
- if (
- value === null ||
- value === undefined ||
- value === '' ||
- value.length === 0 ||
- value === 'undefined'
- ) {
- if (control.dirty) {
- return { message: ['common.validator.requiredField'] };
- }
- return null;
- }
- return null;
-}
+type StringPath = SchemaPath;
-export function validateNotZero(
- control: AbstractControl,
-): ValidationErrors | null {
- const value: number = control.value as number;
- if (value === 0) {
- return { message: ['common.validator.requiredField'] };
- }
- return null;
-}
-
-export function validateRequiredWithoutMessage(
- control: AbstractControl,
-): ValidationErrors | null {
- const value: boolean = control.value as boolean;
- if (!value) {
- return { message: [''] };
- }
- return null;
-}
-
-export function validatePassword(
- control: AbstractControl,
-): ValidationErrors | null {
- const password: string = control.value as string;
- if (password && !passwordPattern.test(password)) {
- return { message: ['common.validator.minimumPasswordCharacters'] };
- }
- return null;
-}
-
-export function validateEmail(
- control: AbstractControl,
-): ValidationErrors | null {
- const email: string = control.value as string;
- if (email && !emailPattern.test(email)) {
- return { message: ['common.validator.enterValidEmail'] };
- }
- return null;
-}
-
-export function validateUrl(control: AbstractControl): ValidationErrors | null {
- const url: string = control.value as string;
- if (url && !urlPattern.test(url)) {
- return { message: ['common.validator.enterValidUrl'] };
- }
- return null;
-}
-
-export function validatePhone(
- control: AbstractControl,
-): ValidationErrors | null {
- const phone: string = control.value as string;
- if (phone && !phonePattern.test(phone)) {
- return { message: ['common.validator.enterValidPhoneNumber'] };
- }
- return null;
-}
-
-export function validateCognitoPhone(
- control: AbstractControl,
-): ValidationErrors | null {
- const phone: string = control.value as string;
- if (phone && !cognitoPhonePattern.test(phone)) {
- return { message: ['common.validator.enterValidPhoneNumber'] };
- }
- return null;
-}
-
-export function validateMaxLength(maxLength: number): ValidatorFn {
- return (control: AbstractControl): ValidationErrors | null => {
- const maxChars: string = control.value as string;
- if (maxChars && maxChars.length > maxLength) {
+/**
+ * Construye un validador de patrón reutilizable.
+ *
+ * Los campos vacíos se consideran válidos: la obligatoriedad se expresa aparte
+ * con `required()`, igual que hacían estos validadores al devolver `null`
+ * cuando no había valor.
+ */
+function patternValidator(
+ pattern: RegExp,
+ kind: string,
+ defaultMessage: string,
+ shouldMatch = true,
+) {
+ return (path: StringPath, config?: PiValidatorConfig): void => {
+ validate(path, ({ value }) => {
+ const raw = value();
+ if (raw === null || raw === undefined || raw === '') return null;
+ const matches = pattern.test(String(raw));
+ if (matches === shouldMatch) return null;
return {
- message: [
- 'common.validator.noMoreThanCharacters.1',
- `${maxLength}`,
- 'common.validator.noMoreThanCharacters.2',
- ],
- };
- }
- return null;
+ kind,
+ message: config?.message ?? defaultMessage,
+ } satisfies ValidationError.WithoutFieldTree;
+ });
};
}
-export function validateMinLength(minLength: number): ValidatorFn {
- return (control: AbstractControl): ValidationErrors | null => {
- const minChars: string = control.value as string;
- if (minChars && minChars.length <= minLength) {
- return {
- message: [
- 'common.validator.noLessThanCharacters.1',
- `${minLength}`,
- 'common.validator.noLessThanCharacters.2',
- ],
- };
- }
- return null;
- };
-}
+/* --- Identidad -------------------------------------------------------- */
-export function validateMaximumNumber(maximum: number): ValidatorFn {
- return (control: AbstractControl): ValidationErrors | null => {
- const num: number = control.value as number;
- if (maximum && num > Number(maximum)) {
- return {
- message: ['common.validator.noMoreThanMaxNumber.1', `${maximum}`],
- };
- }
- return null;
- };
-}
-
-export function validateMinimumNumber(minimum: number): ValidatorFn {
- return (control: AbstractControl): ValidationErrors | null => {
- const num: number = control.value as number;
- if (minimum && num < Number(minimum)) {
- return { message: ['common.validator.noLessThanMinNumbers', minimum] };
- }
- return null;
- };
-}
-
-export function validatePositiveNumber(
- control: AbstractControl,
-): ValidationErrors | null {
- const num: string = control.value as string;
- if (num && !onlyPositiveNumberPattern.test(num)) {
- return { message: ['common.validator.onlyPositiveNumbers'] };
- }
- return null;
-}
-
-export function validateNif(control: AbstractControl): ValidationErrors | null {
- const nif: string = control.value as string;
- if (nif && !nifPattern.test(nif)) {
- return { message: ['common.validator.letterAndNumberNIF'] };
- }
- return null;
-}
+export const validateNif = patternValidator(
+ nifPattern,
+ 'nif',
+ 'common.validator.letterAndNumberNIF',
+);
+/** Acepta NIF, DNI o NIE. */
export function validateNifInvestor(
- control: AbstractControl,
-): ValidationErrors | null {
- const nif: string = control.value as string;
- if (
- nif &&
- !nifPattern.test(nif) &&
- !dniPattern.test(nif) &&
- !niePattern.test(nif)
- ) {
- return { message: ['common.validator.incorrectNIF'] };
- }
- return null;
+ path: StringPath,
+ config?: PiValidatorConfig,
+): void {
+ validate(path, ({ value }) => {
+ const raw = value();
+ if (raw === null || raw === undefined || raw === '') return null;
+ const doc = String(raw);
+ const ok =
+ nifPattern.test(doc) || dniPattern.test(doc) || niePattern.test(doc);
+ return ok
+ ? null
+ : {
+ kind: 'nif',
+ message: config?.message ?? 'common.validator.incorrectNIF',
+ };
+ });
}
+/** En producción rechaza los NIF de prueba. */
export function validateNifProd(
- control: AbstractControl,
-): ValidationErrors | null {
- const nif: string = control.value as string;
- if (environment.production) {
- if (nif && (nif === 'A00000000' || nif === 'B00000000')) {
- return { message: ['common.validator.invalidNIF'] };
+ path: StringPath,
+ config?: PiValidatorConfig,
+): void {
+ validate(path, ({ value }) => {
+ if (!environment.production) return null;
+ const doc = value();
+ if (doc === 'A00000000' || doc === 'B00000000') {
+ return {
+ kind: 'nif',
+ message: config?.message ?? 'common.validator.invalidNIF',
+ };
}
- }
- return null;
+ return null;
+ });
}
-export function validateOnlyNumbers(
- control: AbstractControl,
-): ValidationErrors | null {
- const num: string = control.value as string;
- if (num && !onlyNumbersPattern.test(num)) {
- return { message: ['common.validator.onlyNumbers'] };
- }
- return null;
+/* --- Contacto --------------------------------------------------------- */
+
+export const validateEmail = patternValidator(
+ emailPattern,
+ 'email',
+ 'common.validator.enterValidEmail',
+);
+
+export const validateUrl = patternValidator(
+ urlPattern,
+ 'url',
+ 'common.validator.enterValidUrl',
+);
+
+export const validatePhone = patternValidator(
+ phonePattern,
+ 'phone',
+ 'common.validator.enterValidPhoneNumber',
+);
+
+export const validateCognitoPhone = patternValidator(
+ cognitoPhonePattern,
+ 'phone',
+ 'common.validator.enterValidPhoneNumber',
+);
+
+/* --- Texto ------------------------------------------------------------ */
+
+export const validatePassword = patternValidator(
+ passwordPattern,
+ 'password',
+ 'common.validator.minimumPasswordCharacters',
+);
+
+/** Rechaza 4 o más caracteres iguales seguidos. */
+export const validateNotRepeatedChar = patternValidator(
+ repeatedPattern,
+ 'repeatedChar',
+ 'common.validator.incorrectName',
+);
+
+export const validateMonthYear = patternValidator(
+ monthYearPattern,
+ 'monthYear',
+ 'common.validator.monthYear',
+);
+
+/* --- Números ---------------------------------------------------------- */
+
+export const validateOnlyNumbers = patternValidator(
+ onlyNumbersPattern,
+ 'onlyNumbers',
+ 'common.validator.onlyNumbers',
+);
+
+/** El inverso: rechaza los valores que son solo dígitos. */
+export const validateNotOnlyNumbers = patternValidator(
+ onlyNumbersPattern,
+ 'notOnlyNumbers',
+ 'common.validator.notOnlyNumbers',
+ false,
+);
+
+export const validateOnlyNumbersWithDecimals = patternValidator(
+ onlyNumbersDecimalsPattern,
+ 'numbersWithDecimals',
+ 'common.validator.numbersWithDecimals',
+);
+
+export const validatePositiveNumber = patternValidator(
+ onlyPositiveNumberPattern,
+ 'positiveNumber',
+ 'common.validator.onlyPositiveNumbers',
+);
+
+export const validatePositiveNumbersWithDecimals = patternValidator(
+ positiveNumbersWithDecimals,
+ 'numbersWithDecimals',
+ 'common.validator.numbersWithDecimals',
+);
+
+export const validatePositiveNumbersIncludingZero = patternValidator(
+ positiveNumbersIncludingZero,
+ 'positiveNumbersIncludingZero',
+ 'common.validator.positiveNumbersIncludingZero',
+);
+
+/** Rechaza el 0; para el resto de mínimos usa el `min()` de Signal Forms. */
+export function validateNotZero(
+ path: SchemaPath,
+ config?: PiValidatorConfig,
+): void {
+ validate(path, ({ value }) =>
+ value() === 0
+ ? {
+ kind: 'notZero',
+ message: config?.message ?? 'common.validator.requiredField',
+ }
+ : null,
+ );
}
-export function validateOnlyNumbersWithDecimals(
- control: AbstractControl,
-): ValidationErrors | null {
- const num: string = control.value as string;
- if (num && !onlyNumbersDecimalsPattern.test(num)) {
- return { message: ['common.validator.numbersWithDecimals'] };
- }
- return null;
-}
-
-export function validatePositiveNumbersWithDecimals(
- control: AbstractControl,
-): ValidationErrors | null {
- const num: string = control.value as string;
- if (num && !positiveNumbersWithDecimals.test(num)) {
- return { message: ['common.validator.numbersWithDecimals'] };
- }
- return null;
-}
-
-export function validatePositiveNumbersIncludingZero(
- control: AbstractControl,
-): ValidationErrors | null {
- const num: string = control.value as string;
- if (num && !positiveNumbersIncludingZero.test(num)) {
- return { message: ['common.validator.positiveNumbersIncludingZero'] };
- }
- return null;
-}
-
-export function validateNotOnlyNumbers(
- control: AbstractControl,
-): ValidationErrors | null {
- const num: string = control.value as string;
- if (num && onlyNumbersPattern.test(num)) {
- return { message: ['common.validator.notOnlyNumbers'] };
- }
- return null;
-}
-
-export function notRepeatedChar(
- control: AbstractControl,
-): ValidationErrors | null {
- const num: string = control.value as string;
- if (num && !repeatedPattern.test(num)) {
- return { message: ['common.validator.incorrectName'] };
- }
- return null;
-}
+/* --- Compuestos ------------------------------------------------------- */
+/** Al menos una opción marcada en un mapa `{ [opcion]: boolean }`. */
export function validateMultipleChoiceRequired(
- control: AbstractControl,
-): ValidationErrors | null {
- const choices: { [key: string]: boolean } = control.value as {
- [key: string]: boolean;
- };
- let selected: boolean = false;
-
- selected = !!choices
- ? Object.entries(choices)?.some(([key, value]) => value)
- : false;
-
- if (choices && !selected) {
- return { message: ['common.validator.atLeastOneOption'] };
- }
- return null;
+ path: SchemaPath>,
+ config?: PiValidatorConfig,
+): void {
+ validate(path, ({ value }) => {
+ const choices = value();
+ const selected = choices
+ ? Object.values(choices).some((checked) => checked)
+ : false;
+ return selected
+ ? null
+ : {
+ kind: 'multipleChoiceRequired',
+ message: config?.message ?? 'common.validator.atLeastOneOption',
+ };
+ });
}
-export function rangeValidator(
- control: AbstractControl,
-): ValidationErrors | null {
- const from = Number(control.get('from')?.value);
- const to = Number(control.get('to')?.value);
- if (!from || !to) return null;
- return from <= to
- ? null
- : { message: ['common.validator.minNotSuperiorToMax'] };
-}
-
-export function validateMonthYear(
- control: AbstractControl,
-): ValidationErrors | null {
- const num: string = control.value as string;
- if (num && !monthYearPattern.test(num)) {
- return { message: ['common.validator.monthYear'] };
- }
- return null;
+/**
+ * `from` no puede superar a `to`. Usa `validateTree` porque el error depende de
+ * dos subcampos a la vez y se publica en el nodo del rango.
+ */
+export function validateRange(
+ path: SchemaPath<{ from: number; to: number }>,
+ config?: PiValidatorConfig,
+): void {
+ validateTree(path, ({ value }) => {
+ const { from, to } = value();
+ if (!from || !to || Number(from) <= Number(to)) return null;
+ return {
+ kind: 'range',
+ message: config?.message ?? 'common.validator.minNotSuperiorToMax',
+ };
+ });
}