diff --git a/npm/ng-packs/apps/dev-app/src/app/abp-form-field-demo/abp-form-field-demo.component.html b/npm/ng-packs/apps/dev-app/src/app/abp-form-field-demo/abp-form-field-demo.component.html new file mode 100644 index 00000000000..6ed16cdd39f --- /dev/null +++ b/npm/ng-packs/apps/dev-app/src/app/abp-form-field-demo/abp-form-field-demo.component.html @@ -0,0 +1,30 @@ + + + Abp Form Field Demo + + + + + Name + + + + + Email + + + + + Password + + + + + Age + + + + Submit + + + diff --git a/npm/ng-packs/apps/dev-app/src/app/abp-form-field-demo/abp-form-field-demo.component.ts b/npm/ng-packs/apps/dev-app/src/app/abp-form-field-demo/abp-form-field-demo.component.ts new file mode 100644 index 00000000000..c0e1f430edd --- /dev/null +++ b/npm/ng-packs/apps/dev-app/src/app/abp-form-field-demo/abp-form-field-demo.component.ts @@ -0,0 +1,42 @@ +import { Component, inject } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; +import { AbpFormFieldComponent, AbpFormFieldLabelComponent } from '@abp/ng.components/abp-form-field'; +import { CardComponent, CardBodyComponent, CardHeaderComponent } from '@abp/ng.theme.shared'; +import { CommonModule } from '@angular/common'; +import { AbpInputComponent } from '@abp/ng.components/abp-input'; + +@Component({ + selector: 'app-abp-form-field-demo', + templateUrl: './abp-form-field-demo.component.html', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + AbpFormFieldComponent, + CardComponent, + CardBodyComponent, + CardHeaderComponent, + AbpInputComponent, + AbpFormFieldLabelComponent, + ], +}) +export class AbpFormFieldDemoComponent { + private fb = inject(FormBuilder); + + form = this.fb.group({ + name: ['', [Validators.required, Validators.minLength(3)]], + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(6)]], + age: [null, [Validators.required, Validators.min(18)]], + description: ['', [Validators.maxLength(200)]], + agree: [false, [Validators.requiredTrue]], + }); + + submit() { + if (this.form.valid) { + console.log(this.form.value); + } else { + console.log('Form is invalid'); + } + } +} diff --git a/npm/ng-packs/apps/dev-app/src/app/app.routes.ts b/npm/ng-packs/apps/dev-app/src/app/app.routes.ts index 47462b7f8fa..a56bf459fe1 100644 --- a/npm/ng-packs/apps/dev-app/src/app/app.routes.ts +++ b/npm/ng-packs/apps/dev-app/src/app/app.routes.ts @@ -6,6 +6,10 @@ export const appRoutes: Routes = [ pathMatch: 'full', loadComponent: () => import('./home/home.component').then(m => m.HomeComponent), }, + { + path: 'form-field-demo', + loadComponent: () => import('./abp-form-field-demo/abp-form-field-demo.component').then(m => m.AbpFormFieldDemoComponent), + }, { path: 'account', loadChildren: () => import('@abp/ng.account').then(m => m.createRoutes()), diff --git a/npm/ng-packs/apps/dev-app/src/app/route.provider.ts b/npm/ng-packs/apps/dev-app/src/app/route.provider.ts index f60e7ca3309..9b11cbc4105 100644 --- a/npm/ng-packs/apps/dev-app/src/app/route.provider.ts +++ b/npm/ng-packs/apps/dev-app/src/app/route.provider.ts @@ -16,6 +16,13 @@ function configureRoutes() { iconClass: 'fas fa-home', order: 1, layout: eLayoutType.application, + }, + { + path: '/form-field-demo', + name: 'Form Field Demo', + iconClass: 'fas fa-file-alt', + order: 2, + layout: eLayoutType.application, } ]); } diff --git a/npm/ng-packs/packages/components/abp-form-field/ng-package.json b/npm/ng-packs/packages/components/abp-form-field/ng-package.json new file mode 100644 index 00000000000..e09fb3fd037 --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-error.component.spec.ts b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-error.component.spec.ts new file mode 100644 index 00000000000..3266f94408b --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-error.component.spec.ts @@ -0,0 +1,26 @@ +import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; +import { AbpFormFieldErrorComponent } from './abp-form-field-error.component'; + +describe('AbpFormFieldErrorComponent', () => { + let spectator: SpectatorHost; + const createHost = createHostFactory({ + component: AbpFormFieldErrorComponent, + }); + + it('should create', () => { + spectator = createHost('Test error'); + expect(spectator.component).toBeTruthy(); + }); + + it('should render content', () => { + spectator = createHost('Test error'); + expect(spectator.element).toHaveText('Test error'); + }); + + it('should have correct CSS classes', () => { + spectator = createHost('Test error'); + const div = spectator.query('div'); + expect(div).toHaveClass('invalid-feedback'); + expect(div).toHaveClass('d-block'); + }); +}); diff --git a/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-error.component.ts b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-error.component.ts new file mode 100644 index 00000000000..15dfb226962 --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-error.component.ts @@ -0,0 +1,13 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'abp-form-field-error', + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) + +export class AbpFormFieldErrorComponent { + +} diff --git a/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-hint.component.spec.ts b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-hint.component.spec.ts new file mode 100644 index 00000000000..4be7890ff23 --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-hint.component.spec.ts @@ -0,0 +1,26 @@ +import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; +import { AbpFormFieldHintComponent } from './abp-form-field-hint.component'; + +describe('AbpFormFieldHintComponent', () => { + let spectator: SpectatorHost; + const createHost = createHostFactory({ + component: AbpFormFieldHintComponent, + }); + + it('should create', () => { + spectator = createHost('Test hint'); + expect(spectator.component).toBeTruthy(); + }); + + it('should render content', () => { + spectator = createHost('Test hint'); + expect(spectator.element).toHaveText('Test hint'); + }); + + it('should have correct CSS classes', () => { + spectator = createHost('Test hint'); + const small = spectator.query('small'); + expect(small).toHaveClass('form-text'); + expect(small).toHaveClass('text-muted'); + }); +}); diff --git a/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-hint.component.ts b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-hint.component.ts new file mode 100644 index 00000000000..222ca6163cd --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-hint.component.ts @@ -0,0 +1,11 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; + +@Component({ + selector: 'abp-form-field-hint', + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AbpFormFieldHintComponent { +} diff --git a/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-label.component.spec.ts b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-label.component.spec.ts new file mode 100644 index 00000000000..368fd473443 --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-label.component.spec.ts @@ -0,0 +1,31 @@ +import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; +import { AbpFormFieldLabelComponent } from './abp-form-field-label.component'; + +describe('AbpFormFieldLabelComponent', () => { + let spectator: SpectatorHost; + const createHost = createHostFactory({ + component: AbpFormFieldLabelComponent, + }); + + it('should create', () => { + spectator = createHost('Test Label'); + expect(spectator.component).toBeTruthy(); + }); + + it('should render content', () => { + spectator = createHost('Test Label'); + expect(spectator.element).toHaveText('Test Label'); + }); + + it('should have for input property', () => { + spectator = createHost( + 'Test Label' + ); + expect(spectator.component.for()).toBe('test-input'); + }); + + it('should have empty for by default', () => { + spectator = createHost('Test Label'); + expect(spectator.component.for()).toBe(''); + }); +}); diff --git a/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-label.component.ts b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-label.component.ts new file mode 100644 index 00000000000..634263cdc95 --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field-label.component.ts @@ -0,0 +1,11 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +@Component({ + selector: 'abp-form-field-label', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush +}) + +export class AbpFormFieldLabelComponent { + for= input(''); +} diff --git a/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field.component.html b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field.component.html new file mode 100644 index 00000000000..da5f06ab6d6 --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field.component.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field.component.scss b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field.component.scss new file mode 100644 index 00000000000..f2471cb8f98 --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field.component.scss @@ -0,0 +1,39 @@ +:host { + display: block; +} + +.form-group { + margin-bottom: 0.75rem; +} + +.form-label { + display: inline-block; + margin-bottom: 0.5rem; + font-weight: 500; +} + +.form-text { + display: block; + margin-top: 0.25rem; + font-size: 0.875rem; +} + +.invalid-feedback { + display: block; + width: 100%; + margin-top: 0.25rem; + font-size: 0.875rem; + color: var(--bs-danger, #dc3545); +} + +// Disabled state +:host-context([disabled]) { + opacity: 0.6; + pointer-events: none; +} + +// Required field indicator +.form-label.required::after { + content: ' *'; + color: var(--bs-danger, #dc3545); +} diff --git a/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field.component.spec.ts b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field.component.spec.ts new file mode 100644 index 00000000000..4ee163976d1 --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field.component.spec.ts @@ -0,0 +1,140 @@ +import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; +import { AbpFormFieldComponent } from './abp-form-field.component'; +import { AbpFormFieldLabelComponent } from './abp-form-field-label.component'; +import { AbpFormFieldHintComponent } from './abp-form-field-hint.component'; +import { AbpFormFieldErrorComponent } from './abp-form-field-error.component'; + +describe('AbpFormFieldComponent', () => { + let spectator: SpectatorHost; + const createHost = createHostFactory({ + component: AbpFormFieldComponent, + declarations: [ + AbpFormFieldLabelComponent, + AbpFormFieldHintComponent, + AbpFormFieldErrorComponent, + ], + }); + + describe('Basic rendering', () => { + it('should create', () => { + spectator = createHost(` + + Test Label + + `); + expect(spectator.component).toBeTruthy(); + }); + + it('should render with default container class', () => { + spectator = createHost(` + + Test Label + + `); + expect(spectator.hostElement).toHaveClass('mb-3'); + expect(spectator.hostElement).toHaveClass('d-block'); + }); + + it('should apply custom container class', () => { + spectator = createHost(` + + Test Label + + `); + expect(spectator.hostElement).toHaveClass('custom-class'); + }); + }); + + describe('Label integration', () => { + it('should render label component', () => { + spectator = createHost(` + + Test Label + + `); + const label = spectator.query('label'); + expect(label).toBeTruthy(); + expect(label).toHaveText('Test Label'); + }); + + it('should bind label for attribute', () => { + spectator = createHost(` + + Test Label + + `); + const label = spectator.query('label'); + expect(label).toHaveAttribute('for', 'test-input'); + }); + }); + + describe('Content projection', () => { + it('should project abp-input', () => { + spectator = createHost(` + + Test Label + + + `); + const input = spectator.query('input'); + expect(input).toBeTruthy(); + }); + + it('should project hint component', () => { + spectator = createHost(` + + Test Label + Test hint + + `); + const hint = spectator.query('abp-form-field-hint'); + expect(hint).toBeTruthy(); + }); + + it('should project error component', () => { + spectator = createHost(` + + Test Label + Test error + + `); + const error = spectator.query('abp-form-field-error'); + expect(error).toBeTruthy(); + }); + }); + + describe('Host binding', () => { + it('should have correct host classes', () => { + spectator = createHost(` + + Test Label + + `); + expect(spectator.hostElement).toHaveClass('d-block'); + expect(spectator.hostElement).toHaveClass('mb-3'); + }); + + it('should combine default and custom classes', () => { + spectator = createHost(` + + Test Label + + `); + expect(spectator.hostElement).toHaveClass('d-block'); + expect(spectator.hostElement).toHaveClass('mb-3'); + expect(spectator.hostElement).toHaveClass('mt-4'); + }); + }); + + describe('Export as', () => { + it('should be accessible via exportAs', () => { + spectator = createHost(` + + Test Label + + `); + const formField = spectator.queryHost('abp-form-field'); + expect(formField).toBeTruthy(); + }); + }); +}); diff --git a/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field.component.ts b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field.component.ts new file mode 100644 index 00000000000..71cdb488682 --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/abp-form-field.component.ts @@ -0,0 +1,34 @@ +import { + Component, + ChangeDetectionStrategy, + input, + HostBinding, + InjectionToken, + QueryList, + ContentChild, + contentChild, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AbpFormFieldLabelComponent } from './abp-form-field-label.component'; + +export const ABP_FORM_FIELD = new InjectionToken('AbpFormFieldComponent'); + +@Component({ + selector: 'abp-form-field', + templateUrl: './abp-form-field.component.html', + styleUrls: ['./abp-form-field.component.scss'], + imports: [CommonModule], + exportAs: 'abpFormField', + providers: [{ provide: ABP_FORM_FIELD, useExisting: AbpFormFieldComponent }], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AbpFormFieldComponent { + + containerClass = input('mb-3'); + labelComponent = contentChild(AbpFormFieldLabelComponent); + + @HostBinding('class') + get hostClasses(): string { + return `d-block mb-3 ${this.containerClass()}`; + } +} diff --git a/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/index.ts b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/index.ts new file mode 100644 index 00000000000..529106c889e --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/src/lib/abp-form-field/index.ts @@ -0,0 +1,3 @@ +export * from './abp-form-field.component'; +export * from './abp-form-field-hint.component'; +export * from './abp-form-field-label.component'; diff --git a/npm/ng-packs/packages/components/abp-form-field/src/lib/index.ts b/npm/ng-packs/packages/components/abp-form-field/src/lib/index.ts new file mode 100644 index 00000000000..e65c3233d9b --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/src/lib/index.ts @@ -0,0 +1 @@ +export * from './abp-form-field'; diff --git a/npm/ng-packs/packages/components/abp-form-field/src/public-api.ts b/npm/ng-packs/packages/components/abp-form-field/src/public-api.ts new file mode 100644 index 00000000000..f41a696fd20 --- /dev/null +++ b/npm/ng-packs/packages/components/abp-form-field/src/public-api.ts @@ -0,0 +1 @@ +export * from './lib'; diff --git a/npm/ng-packs/packages/components/abp-input/ng-package.json b/npm/ng-packs/packages/components/abp-input/ng-package.json new file mode 100644 index 00000000000..e09fb3fd037 --- /dev/null +++ b/npm/ng-packs/packages/components/abp-input/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/npm/ng-packs/packages/components/abp-input/src/abp-input.component.html b/npm/ng-packs/packages/components/abp-input/src/abp-input.component.html new file mode 100644 index 00000000000..e31818a2ff5 --- /dev/null +++ b/npm/ng-packs/packages/components/abp-input/src/abp-input.component.html @@ -0,0 +1,35 @@ +@if(abpFormField) { + + + +} @else { + + @if (label()) { + {{ label() | abpLocalization }} + } + + @if (hint()) { + {{ hint() | abpLocalization }} + } + @if (errors.length > 0) { + + @for (error of errors; track error) { + {{ error }} + } + + } + + +} diff --git a/npm/ng-packs/packages/components/abp-input/src/abp-input.component.ts b/npm/ng-packs/packages/components/abp-input/src/abp-input.component.ts new file mode 100644 index 00000000000..3a3dbe13328 --- /dev/null +++ b/npm/ng-packs/packages/components/abp-input/src/abp-input.component.ts @@ -0,0 +1,109 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DestroyRef, + forwardRef, + inject, + OnInit, + input, + Injector +} from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + FormControl, + FormControlName, + FormGroup, + FormGroupDirective, + NG_VALUE_ACCESSOR, + NgControl, + ReactiveFormsModule, +} from '@angular/forms'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { LocalizationPipe } from '@abp/ng.core'; +import { ABP_FORM_FIELD } from '@abp/ng.components/abp-form-field'; + +const ABP_INPUT_CONTROL_VALUE_ACCESSOR = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AbpInputComponent), + multi: true, +}; + +@Component({ + selector: 'abp-input', + templateUrl: './abp-input.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule, LocalizationPipe], + exportAs: 'abpInput', + host: { + class: 'abp-input', + }, + providers: [ABP_INPUT_CONTROL_VALUE_ACCESSOR], +}) +export class AbpInputComponent implements OnInit, ControlValueAccessor { + label = input(); + type = input<'text' | 'number' | 'password'>('text'); + id = input(''); + placeholder = input(''); + hint = input(''); + control: FormControl; + readonly formBuilder = inject(FormBuilder); + readonly changeDetectorRef = inject(ChangeDetectorRef); + readonly destroyRef = inject(DestroyRef); + readonly injector = inject(Injector); + readonly abpFormField = inject(ABP_FORM_FIELD, { optional: true }); + abpInputFormGroup: FormGroup; + + ngOnInit() { + + const ngControl = this.injector.get(NgControl, null); + if (ngControl) { + this.control = this.injector.get(FormGroupDirective).getControl(ngControl as FormControlName); + } + + this.abpInputFormGroup = this.formBuilder.group({ + value: [''], + }); + + this.value.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => { + this.onChange(val); + }); + } + + writeValue(value: any): void { + this.value.setValue(value); + this.changeDetectorRef.markForCheck(); + } + + registerOnChange(fn: any): void { + this.onChange = fn; + } + + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.value.disable(); + } else { + this.value.enable(); + } + } + + get errors(): string[] { + if (this.control && this.control.errors) { + return [] + } + return [] + } + + get value(): AbstractControl { + return this.abpInputFormGroup.get('value'); + } + + private onChange: (value: any) => void = () => {}; + private onTouched: () => void = () => {}; +} diff --git a/npm/ng-packs/packages/components/abp-input/src/public-api.ts b/npm/ng-packs/packages/components/abp-input/src/public-api.ts new file mode 100644 index 00000000000..319fe5737bf --- /dev/null +++ b/npm/ng-packs/packages/components/abp-input/src/public-api.ts @@ -0,0 +1 @@ +export * from './abp-input.component'; diff --git a/npm/ng-packs/tsconfig.base.json b/npm/ng-packs/tsconfig.base.json index 496d83ca676..0e91a27b3eb 100644 --- a/npm/ng-packs/tsconfig.base.json +++ b/npm/ng-packs/tsconfig.base.json @@ -20,6 +20,8 @@ "@abp/ng.account.core/proxy": ["packages/account-core/proxy/src/public-api.ts"], "@abp/ng.account/config": ["packages/account/config/src/public-api.ts"], "@abp/ng.components": ["packages/components/src/public-api.ts"], + "@abp/ng.components/abp-form-field": ["packages/components/abp-form-field/src/public-api.ts"], + "@abp/ng.components/abp-input": ["packages/components/abp-input/src/public-api.ts"], "@abp/ng.components/chart.js": ["packages/components/chart.js/src/public-api.ts"], "@abp/ng.components/extensible": ["packages/components/extensible/src/public-api.ts"], "@abp/ng.components/lookup": ["packages/components/lookup/src/public-api.ts"],