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:
@@ -0,0 +1,10 @@
|
||||
export interface DynamicField {
|
||||
name: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
type: 'text' | 'textarea' | 'number' | 'date' | 'select' | 'file' | 'array' | 'hidden' | 'year';
|
||||
required?: boolean;
|
||||
options?: { label: string; value: unknown }[];
|
||||
itemConfig?: DynamicField[];
|
||||
yearRange?: { start?: number; end?: number; allowPresent?: boolean; valueType?: 'string' | 'number' };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DynamicField } from "./dynamic-field";
|
||||
|
||||
export interface DynamicFormConfig {
|
||||
title: string;
|
||||
submitLabel: string;
|
||||
api?: {
|
||||
save: string; // POST or PUT endpoint
|
||||
method: 'POST' | 'PUT';
|
||||
bodyKey?: string; // extract this key from form value before sending (e.g. 'academics' sends the array directly)
|
||||
};
|
||||
fields: DynamicField[];
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<form [formGroup]="form">
|
||||
|
||||
@for (f of config.fields; track f) {
|
||||
|
||||
<ng-container *ngIf="f.type !== 'array' && f.type !== 'hidden'">
|
||||
@if (f.type === 'text') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
<input matInput [id]="f.name" [formControlName]="f.name" [placeholder]="f.placeholder || ''" />
|
||||
<mat-hint *ngIf="f.placeholder">{{ f.placeholder }}</mat-hint>
|
||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
||||
This field is required.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (f.type === 'textarea') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
<textarea matInput [id]="f.name" cdkTextareaAutosize cdkAutosizeMinRows="3" cdkAutosizeMaxRows="6" [formControlName]="f.name" [placeholder]="f.placeholder || ''"></textarea>
|
||||
<mat-hint *ngIf="f.placeholder">{{ f.placeholder }}</mat-hint>
|
||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
||||
This field is required.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (f.type === 'number') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
<input matInput [id]="f.name" type="number" [formControlName]="f.name" />
|
||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
||||
Please enter a valid number.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (f.type === 'select') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
<mat-select [id]="f.name" [formControlName]="f.name">
|
||||
@for (opt of f.options; track opt) {
|
||||
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
||||
Please select a value.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (f.type === 'year') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
<mat-select [id]="f.name" [formControlName]="f.name" [compareWith]="compareYearValues">
|
||||
@if (f.yearRange?.allowPresent) {
|
||||
<mat-option value="Present">Present</mat-option>
|
||||
}
|
||||
@for (yr of getYearOptions(f); track yr) {
|
||||
<mat-option [value]="yr">{{ yr }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
||||
Please select a year.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (f.type === 'date') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ f.label }}</mat-label>
|
||||
<input matInput [matDatepicker]="picker" [id]="f.name" [formControlName]="f.name" [placeholder]="f.placeholder || ''" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #picker></mat-datepicker>
|
||||
<mat-error *ngIf="form.get(f.name)?.invalid && (form.get(f.name)?.touched || form.get(f.name)?.dirty)">
|
||||
Please select a date.
|
||||
</mat-error>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
@if (f.type === 'file') {
|
||||
<div>
|
||||
<label [for]="f.name">{{ f.label }}</label>
|
||||
<input [id]="f.name" type="file" (change)="onFileChange($event, f.name)" />
|
||||
</div>
|
||||
}
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="f.type === 'array'">
|
||||
<div>
|
||||
<h3>{{ f.label }}</h3>
|
||||
<div [formArrayName]="f.name">
|
||||
@for (item of getArrayControls(f.name); track item; let i = $index) {
|
||||
<div [formGroupName]="i" class="array-item">
|
||||
<div class="array-item-fields">
|
||||
@for (sub of f.itemConfig; track sub) {
|
||||
@if (sub.type !== 'hidden') {
|
||||
@if (sub.type === 'date') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ sub.label }}</mat-label>
|
||||
<input matInput [matDatepicker]="subPicker" [id]="sub.name + '_' + i" [formControlName]="sub.name" [placeholder]="sub.placeholder || ''" />
|
||||
<mat-datepicker-toggle matIconSuffix [for]="subPicker"></mat-datepicker-toggle>
|
||||
<mat-datepicker #subPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
} @else if (sub.type === 'year') {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ sub.label }}</mat-label>
|
||||
<mat-select [id]="sub.name + '_' + i" [formControlName]="sub.name" [compareWith]="compareYearValues">
|
||||
@if (sub.yearRange?.allowPresent) {
|
||||
<mat-option value="Present">Present</mat-option>
|
||||
}
|
||||
@for (yr of getYearOptions(sub); track yr) {
|
||||
<mat-option [value]="yr">{{ yr }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
} @else {
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ sub.label }}</mat-label>
|
||||
<input matInput [id]="sub.name + '_' + i" [formControlName]="sub.name" [placeholder]="sub.placeholder || ''" />
|
||||
</mat-form-field>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="array-item-actions">
|
||||
<button type="button" class="remove-link" (click)="removeArrayItem(f, i)">
|
||||
<i class="fa-solid fa-trash-can"></i> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="add-btn" (click)="addArrayItem(f)">
|
||||
<i class="fa-solid fa-plus"></i> Add Item
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
</form>
|
||||
@@ -0,0 +1,154 @@
|
||||
/* Dynamic form spacing and material tweaks to match popup theme */
|
||||
.full-width { width: 100%; display: block; }
|
||||
|
||||
mat-form-field.full-width { margin-bottom: 12px; }
|
||||
|
||||
/* Reduce label size slightly to fit popup */
|
||||
mat-form-field .mat-form-field-label { font-size: 0.95rem; color: var(--light-gray-70); }
|
||||
|
||||
/* Make input text contrast better */
|
||||
.mat-input-element,
|
||||
input.mat-mdc-input-element,
|
||||
textarea.mat-mdc-input-element,
|
||||
.mat-mdc-input-element {
|
||||
color: var(--white-1) !important;
|
||||
}
|
||||
|
||||
/* Smaller helper/hint text */
|
||||
.mat-hint { color: var(--light-gray-70); font-size: 0.85rem; }
|
||||
|
||||
/* Error styling consistent with theme */
|
||||
mat-error { color: #ff8a80; font-size: 0.9rem; }
|
||||
|
||||
/* Outline border always visible, highlight on focus */
|
||||
::ng-deep .mdc-notched-outline__leading,
|
||||
::ng-deep .mdc-notched-outline__notch,
|
||||
::ng-deep .mdc-notched-outline__trailing {
|
||||
border-color: var(--light-gray-70) !important;
|
||||
}
|
||||
|
||||
::ng-deep .mat-mdc-form-field.mat-focused .mdc-notched-outline__leading,
|
||||
::ng-deep .mat-mdc-form-field.mat-focused .mdc-notched-outline__notch,
|
||||
::ng-deep .mat-mdc-form-field.mat-focused .mdc-notched-outline__trailing {
|
||||
border-color: var(--orange-yellow-crayola) !important;
|
||||
}
|
||||
|
||||
/* Make array item fields inline on larger screens */
|
||||
.array-item-fields { display: flex; gap: 12px; align-items: baseline; flex-wrap: wrap; }
|
||||
.array-item-fields mat-form-field { flex: 1 1 auto; margin-bottom: 0; min-width: 120px; }
|
||||
|
||||
/* Stack array item fields vertically on small screens */
|
||||
@media (max-width: 580px) {
|
||||
.array-item-fields {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.array-item-fields mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide subscript wrapper inside array items so fields align */
|
||||
.array-item ::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Remove link at bottom-right of each array card */
|
||||
.array-item-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.remove-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: hsl(0deg 75% 60%);
|
||||
background: rgba(255, 70, 70, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Force material label/input colors to match dark popup theme (covers MDC + legacy classes) */
|
||||
.themed-popup {
|
||||
::ng-deep .mat-form-field-label,
|
||||
::ng-deep .mat-mdc-floating-label,
|
||||
::ng-deep .mat-form-field .mat-form-field-label {
|
||||
color: var(--light-gray-70) !important;
|
||||
}
|
||||
|
||||
::ng-deep .mat-input-element,
|
||||
::ng-deep input.mat-input-element,
|
||||
::ng-deep .mat-mdc-text-field-input,
|
||||
::ng-deep textarea.mat-input-element {
|
||||
color: var(--white-1) !important;
|
||||
}
|
||||
|
||||
::ng-deep .mat-select-value-text,
|
||||
::ng-deep .mat-mdc-select-value-text {
|
||||
color: var(--white-1) !important;
|
||||
}
|
||||
|
||||
::ng-deep input::placeholder,
|
||||
::ng-deep textarea::placeholder {
|
||||
color: var(--light-gray-70) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
.form-array {
|
||||
margin-bottom: 20px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.array-item {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sub-field {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--orange-yellow-crayola);
|
||||
border: 1px dashed rgba(227, 179, 65, 0.3);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(227, 179, 65, 0.1);
|
||||
border-color: rgba(227, 179, 65, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
margin-top: 8px;
|
||||
background: #ff4747;
|
||||
color: #fff;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DynamicForm } from './dynamic-form';
|
||||
|
||||
describe('DynamicForm', () => {
|
||||
let component: DynamicForm;
|
||||
let fixture: ComponentFixture<DynamicForm>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DynamicForm]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DynamicForm);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { ReactiveFormsModule, FormGroup, FormControl, Validators, FormArray, AbstractControl } from '@angular/forms';
|
||||
import { DynamicFormConfig } from './dynamic-form-config';
|
||||
import { DynamicField } from './dynamic-field';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { provideNativeDateAdapter } from '@angular/material/core';
|
||||
|
||||
@Component({
|
||||
selector: "app-dynamic-form",
|
||||
templateUrl: "./dynamic-form.html",
|
||||
standalone: true,
|
||||
styleUrls: ['./dynamic-form.scss'],
|
||||
providers: [provideNativeDateAdapter()],
|
||||
imports: [ReactiveFormsModule, CommonModule, MatFormFieldModule, MatInputModule, MatSelectModule, MatButtonModule, MatIconModule, MatDatepickerModule]
|
||||
})
|
||||
export class DynamicFormComponent implements OnInit {
|
||||
@Input() config!: DynamicFormConfig;
|
||||
@Input() initialData: Record<string, unknown> = {};
|
||||
|
||||
@Output() formBuilt = new EventEmitter<FormGroup>();
|
||||
|
||||
form!: FormGroup;
|
||||
|
||||
ngOnInit() {
|
||||
const group: Record<string, AbstractControl> = {};
|
||||
|
||||
this.config.fields.forEach(f => {
|
||||
if (f.type === "array") {
|
||||
group[f.name] = new FormArray(
|
||||
this.initialData[f.name] && Array.isArray(this.initialData[f.name])
|
||||
? (this.initialData[f.name] as unknown[]).map((item: unknown) =>
|
||||
this.buildArrayItem(f.itemConfig!, item)
|
||||
)
|
||||
: [this.buildArrayItem(f.itemConfig!, {})]
|
||||
);
|
||||
} else {
|
||||
group[f.name] = new FormControl(
|
||||
this.initialData[f.name] || "",
|
||||
f.required ? Validators.required : null
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.form = new FormGroup(group);
|
||||
this.formBuilt.emit(this.form);
|
||||
}
|
||||
|
||||
buildArrayItem(config: DynamicField[], item: unknown) {
|
||||
const group: Record<string, FormControl> = {};
|
||||
|
||||
config.forEach(f => {
|
||||
group[f.name] = new FormControl(
|
||||
(item as Record<string, unknown>)[f.name] || "",
|
||||
f.required ? Validators.required : null
|
||||
);
|
||||
});
|
||||
|
||||
return new FormGroup(group);
|
||||
}
|
||||
|
||||
addArrayItem(field: DynamicField) {
|
||||
const array = this.form.get(field.name) as FormArray;
|
||||
array.push(this.buildArrayItem(field.itemConfig!, {}));
|
||||
}
|
||||
|
||||
removeArrayItem(field: DynamicField, index: number) {
|
||||
const array = this.form.get(field.name) as FormArray;
|
||||
array.removeAt(index);
|
||||
}
|
||||
|
||||
getArrayControls(name: string): AbstractControl[] {
|
||||
return (this.form.get(name) as FormArray).controls;
|
||||
}
|
||||
|
||||
onFileChange(e: Event, field: string) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (target?.files?.length) {
|
||||
const file = target.files[0];
|
||||
this.form.patchValue({ [field]: file });
|
||||
}
|
||||
}
|
||||
|
||||
private yearOptionsCache = new Map<string, (number | string)[]>();
|
||||
|
||||
getYearOptions(field: DynamicField): (number | string)[] {
|
||||
const asString = field.yearRange?.valueType === 'string';
|
||||
const key = `${field.name}_${field.yearRange?.start}_${field.yearRange?.end}_${asString}`;
|
||||
if (this.yearOptionsCache.has(key)) return this.yearOptionsCache.get(key)!;
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const start = field.yearRange?.start ?? (currentYear - 50);
|
||||
const end = field.yearRange?.end ?? (currentYear + 5);
|
||||
const years: (number | string)[] = [];
|
||||
for (let y = end; y >= start; y--) {
|
||||
years.push(asString ? y.toString() : y);
|
||||
}
|
||||
this.yearOptionsCache.set(key, years);
|
||||
return years;
|
||||
}
|
||||
|
||||
compareYearValues = (a: unknown, b: unknown): boolean => {
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
return a.toString() === b.toString();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<div class="popup-box themed-popup" role="dialog" [attr.aria-modal]="true" [attr.aria-labelledby]="titleId">
|
||||
<div class="popup-header">
|
||||
<h2 class="popup-title" [id]="titleId">{{ config.title }}</h2>
|
||||
<button class="close-btn" (click)="close()" aria-label="Close">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="popup-content">
|
||||
<app-dynamic-form class="popup-form" [config]="config" [initialData]="data" (formBuilt)="onFormBuilt($event)">
|
||||
</app-dynamic-form>
|
||||
</div>
|
||||
|
||||
<div class="popup-actions">
|
||||
<button class="submit-btn primary" (click)="submit()">{{ config.submitLabel }}</button>
|
||||
<button class="submit-btn" (click)="close()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,245 @@
|
||||
.themed-popup{
|
||||
background: linear-gradient(180deg, hsl(240, 2%, 13%) 0%, hsl(0, 0%, 7%) 100%);
|
||||
padding: 24px 28px;
|
||||
border-radius: 14px;
|
||||
min-width: 420px;
|
||||
width: min(92vw, 820px);
|
||||
max-width: 820px;
|
||||
color: var(--white-2);
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.10);
|
||||
font-family: var(--ff-poppins);
|
||||
animation: popupFade 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
max-height: 78vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Scrollbar styles moved to .popup-content */
|
||||
|
||||
/* Responsive min-width adjustments */
|
||||
@media (max-width: 1200px) {
|
||||
.themed-popup {
|
||||
/* slightly smaller min width on medium screens */
|
||||
min-width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.themed-popup {
|
||||
/* allow the popup to shrink more on small/tablet screens */
|
||||
min-width: unset;
|
||||
width: 80vw;
|
||||
max-width: 80vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.themed-popup {
|
||||
/* full-bleed popup on phones */
|
||||
min-width: 0;
|
||||
width: 80vw;
|
||||
max-width: 80vw;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-title{
|
||||
margin: 0;
|
||||
padding-bottom: 14px;
|
||||
color: var(--white-1);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
background: linear-gradient(90deg, var(--orange-yellow-crayola), var(--vegas-gold));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.10);
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--light-gray-70);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 150ms ease, background 150ms ease;
|
||||
|
||||
mat-icon, i {
|
||||
font-size: 18px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--white-1);
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.popup-form label{
|
||||
display:block;
|
||||
margin-bottom:6px;
|
||||
color: var(--light-gray-70);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.popup-form input,
|
||||
.popup-form textarea,
|
||||
.popup-form select{
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
color: var(--white-1);
|
||||
min-height: 42px;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.popup-form textarea{ min-height: 92px; resize: vertical; }
|
||||
|
||||
.popup-form input:focus,
|
||||
.popup-form textarea:focus,
|
||||
.popup-form select:focus{
|
||||
border-color: var(--orange-yellow-crayola);
|
||||
box-shadow: 0 6px 18px rgba(36,26,18,0.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.popup-content{
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 16px 6px 8px 0;
|
||||
min-height: 0; /* allow flex child to shrink below content size */
|
||||
}
|
||||
|
||||
.popup-actions{
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding-top: 14px;
|
||||
padding-bottom: 4px;
|
||||
border-top: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.submit-btn{
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--white-2);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.submit-btn.primary{
|
||||
background: linear-gradient(90deg, var(--orange-yellow-crayola), var(--vegas-gold));
|
||||
color: var(--smoky-black);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 8px 24px rgba(45,30,10,0.22);
|
||||
}
|
||||
|
||||
.submit-btn:hover{
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-1px);
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
|
||||
.submit-btn:active{
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.array-item{ margin-bottom:8px; padding:8px; border-radius:8px; background: rgba(255,255,255,0.02); }
|
||||
|
||||
/* Themed scrollbar for popup content */
|
||||
.popup-content::-webkit-scrollbar { width: 10px; }
|
||||
.popup-content::-webkit-scrollbar-track { background: transparent; }
|
||||
.popup-content::-webkit-scrollbar-thumb { background: linear-gradient(180deg, var(--orange-yellow-crayola), var(--vegas-gold)); border-radius: 8px; }
|
||||
.popup-content { scrollbar-width: thin; scrollbar-color: var(--orange-yellow-crayola) transparent; }
|
||||
|
||||
/* textarea scrollbar */
|
||||
.popup-form textarea::-webkit-scrollbar { width: 10px; }
|
||||
.popup-form textarea::-webkit-scrollbar-track { background: rgba(255,255,255,0.02); border-radius:6px; }
|
||||
.popup-form textarea::-webkit-scrollbar-thumb { background: linear-gradient(180deg, var(--orange-yellow-crayola), var(--vegas-gold)); border-radius:6px; }
|
||||
|
||||
/* Material-specific color fixes inside the themed popup */
|
||||
.themed-popup {
|
||||
/* Floating labels */
|
||||
.mat-form-field-label,
|
||||
.mat-mdc-floating-label {
|
||||
color: var(--light-gray-70) !important;
|
||||
}
|
||||
|
||||
/* Input / textarea text */
|
||||
.mat-input-element,
|
||||
textarea.mat-input-element,
|
||||
.mat-mdc-text-field-input {
|
||||
color: var(--white-1) !important;
|
||||
}
|
||||
|
||||
/* Select value text */
|
||||
.mat-select-value-text,
|
||||
.mat-mdc-select-value-text {
|
||||
color: var(--white-1) !important;
|
||||
}
|
||||
|
||||
/* Placeholder visibility */
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--light-gray-70) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Outline/border color adjustments for outlined fields */
|
||||
.mat-form-field-appearance-outline .mat-form-field-outline,
|
||||
.mat-mdc-notched-outline {
|
||||
stroke: rgba(255,255,255,0.06) !important;
|
||||
border-color: rgba(255,255,255,0.06) !important;
|
||||
}
|
||||
|
||||
/* Focused state accent */
|
||||
.mat-form-field.mat-focused .mat-form-field-outline,
|
||||
.mat-mdc-text-field.mat-mdc-focused .mat-mdc-notched-outline {
|
||||
stroke: var(--orange-yellow-crayola) !important;
|
||||
border-color: var(--orange-yellow-crayola) !important;
|
||||
box-shadow: 0 6px 18px rgba(36,26,18,0.12) !important;
|
||||
}
|
||||
|
||||
/* Make mat-hint lighter */
|
||||
.mat-hint {
|
||||
color: var(--light-gray-70) !important;
|
||||
}
|
||||
|
||||
/* Scrollbar for material textarea/input */
|
||||
textarea.mat-input-element::-webkit-scrollbar { width: 10px; }
|
||||
textarea.mat-input-element::-webkit-scrollbar-track { background: rgba(255,255,255,0.02); border-radius:6px; }
|
||||
textarea.mat-input-element::-webkit-scrollbar-thumb { background: linear-gradient(180deg, var(--orange-yellow-crayola), var(--vegas-gold)); border-radius:6px; }
|
||||
}
|
||||
|
||||
@keyframes popupFade{ from{opacity:0; transform:translateY(-8px);} to{opacity:1; transform:none;} }
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DynamicPopupComponent } from './dynamic-popup';
|
||||
|
||||
describe('DynamicPopupComponent', () => {
|
||||
let component: DynamicPopupComponent;
|
||||
let fixture: ComponentFixture<DynamicPopupComponent >;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DynamicPopupComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DynamicPopupComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user