feat: implement dynamic form and popup components

- Added DynamicField interface to define form field structure.
- Created DynamicFormConfig interface for form configuration.
- Developed DynamicFormComponent to handle dynamic form rendering and validation.
- Implemented DynamicPopupComponent for displaying forms in a modal dialog.
- Added HTML and SCSS for dynamic form and popup styling.
- Integrated Material Design components for form inputs and buttons.
- Implemented form submission logic with API integration.
- Added tests for DynamicForm and DynamicPopup components.
- Updated global styles for Material components in themed popups.
- Included Material Icons in index.html for better UI representation.
This commit is contained in:
2026-02-15 03:56:17 +05:30
parent 9e34de73ee
commit 55b436c7d2
43 changed files with 2043 additions and 186 deletions
@@ -0,0 +1,145 @@
import { Component, EventEmitter, inject, Input, Output, OnInit, ElementRef, OnDestroy } from '@angular/core';
import { DynamicFormComponent } from '../dynamic-form/dynamic-form';
import { CommonModule } from '@angular/common';
import { DynamicFormConfig } from '../dynamic-form/dynamic-form-config';
import { FormGroup } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { environment } from '../../../environments/environment';
@Component({
selector: "app-dynamic-popup",
templateUrl: "./dynamic-popup.html",
styleUrls: ['./dynamic-popup.scss'],
standalone: true,
imports: [DynamicFormComponent, CommonModule]
})
export class DynamicPopupComponent implements OnInit, OnDestroy {
@Input() config!: DynamicFormConfig;
@Input() data: Record<string, unknown> = {};
form!: FormGroup;
@Output() saved = new EventEmitter<unknown>();
readonly http = inject(HttpClient);
private dialogRef = inject(MatDialogRef, { optional: true }) as unknown as MatDialogRef<DynamicPopupComponent> | null;
private injectedDialogData = inject(MAT_DIALOG_DATA, { optional: true }) as { config?: DynamicFormConfig; data?: Record<string, unknown> } | undefined;
private el = inject(ElementRef);
private focusableElements: HTMLElement[] = [];
private keydownHandler?: (e: KeyboardEvent) => void;
titleId = `popup-title-${Math.random().toString(36).slice(2,9)}`;
ngOnInit(): void {
if (this.injectedDialogData) {
if (this.injectedDialogData.config) {
this.config = this.injectedDialogData.config;
}
if (this.injectedDialogData.data) {
this.data = this.injectedDialogData.data;
}
}
// setup focus trap
this.keydownHandler = (e: KeyboardEvent) => this.onKeyDown(e);
this.el.nativeElement.addEventListener('keydown', this.keydownHandler as EventListener);
}
onFormBuilt(f: FormGroup) {
this.form = f;
setTimeout(() => {
// collect focusable elements inside this component
const nodes = this.el.nativeElement.querySelectorAll('button, a, input, textarea, select, [tabindex]:not([tabindex="-1"])') as NodeListOf<HTMLElement>;
this.focusableElements = Array.from(nodes).filter(n => !n.hasAttribute('disabled'));
if (this.focusableElements.length) {
this.focusableElements[0].focus();
}
}, 0);
}
submit() {
let payload: unknown;
if (this.containsFile()) {
payload = new FormData();
Object.keys(this.form.value).forEach(k => {
(payload as FormData).append(k, this.form.value[k]);
});
} else {
payload = this.form.value;
}
// If no API configured, just close with form value
if (!this.config.api?.save) {
this.saved.emit(payload);
if (this.dialogRef) {
this.dialogRef.close(payload);
} else {
this.close();
}
return;
}
const url = this.resolveApiUrl(this.config.api.save as string);
let apiBody = payload;
if (this.config.api.bodyKey && payload && typeof payload === 'object' && !(payload instanceof FormData)) {
apiBody = (payload as Record<string, unknown>)[this.config.api.bodyKey];
}
this.http.request(
this.config.api.method,
url,
{ body: apiBody }
).subscribe(res => {
this.saved.emit(res);
if(this.dialogRef){
this.dialogRef.close(res);
} else {
this.close();
}
});
}
private resolveApiUrl(path: string) {
if (!path) return path;
if (/^https?:\/\//i.test(path)) return path;
const base = (environment.apiUrl || '').replace(/\/$/, '');
return `${base}/${path.replace(/^\//, '')}`;
}
containsFile() {
return this.config.fields.some(f => f.type === "file");
}
close() {
this.saved.emit(null);
if(this.dialogRef){
this.dialogRef.close(null);
}
}
onKeyDown(e: KeyboardEvent){
if (e.key !== 'Tab') return;
if (!this.focusableElements.length) return;
const first = this.focusableElements[0];
const last = this.focusableElements[this.focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
ngOnDestroy(): void {
if (this.keydownHandler) {
this.el.nativeElement.removeEventListener('keydown', this.keydownHandler as EventListener);
}
}
}