feat: Add newProjectConfigureHttpApiModule for API configuration and localization

feat: Implement SampleAppServiceTests for application service testing

feat: Create newProjectConfigureApplicationTests project for application layer tests

feat: Establish newProjectConfigureDomainTests project for domain layer tests

feat: Introduce Entity Framework Core tests for the newProjectConfigure application

feat: Develop console test application for HTTP API client demonstration

feat: Implement FakeCurrentPrincipalAccessor for testing purposes

chore: Set up test base classes and data seed contributors for consistent testing environment
This commit is contained in:
sayan.kolay committed 2026-08-31 16:18:47 +05:30
1 parent 7909179fca
commit b6f2213ccf
279 files changed
+39466

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { InternetConnectionStatusComponent, LoaderBarComponent } from '@abp/ng.theme.shared';
import { DynamicLayoutComponent } from '@abp/ng.core';
@Component({
selector: 'app-root',
template: `
<abp-loader-bar />
<abp-dynamic-layout />
<abp-internet-status />
`,
imports: [LoaderBarComponent, DynamicLayoutComponent, InternetConnectionStatusComponent],
})
export class AppComponent {}
+85
View File
@@ -0,0 +1,85 @@
import {
withValidationBluePrint,
provideAbpThemeShared,
provideLogo,
withEnvironmentOptions,
} from '@abp/ng.theme.shared';
import { provideThemeBasicConfig } from '@abp/ng.theme.basic';
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
import { APP_ROUTE_PROVIDER } from './route.provider';
import { provideAbpCore, withOptions } from '@abp/ng.core';
import { environment } from '../environments/environment';
import { registerLocaleForEsBuild } from '@abp/ng.core/locale';
import { provideAbpOAuth } from '@abp/ng.oauth';
import { provideSettingManagementConfig } from '@abp/ng.setting-management/config';
import { provideAccountConfig } from '@abp/ng.account/config';
import { provideIdentityConfig } from '@abp/ng.identity/config';
import { provideTenantManagementConfig } from '@abp/ng.tenant-management/config';
import { provideFeatureManagementConfig } from '@abp/ng.feature-management';
import { provideFormlyCore } from '@ngx-formly/core';
import { withFormlyBootstrap } from '@ngx-formly/bootstrap';
import { FormlyFieldDropdownComponent } from './formly-field-dropdown/formly-field-dropdown.component';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(appRoutes),
APP_ROUTE_PROVIDER,
provideAbpCore(
withOptions({
environment,
registerLocaleFn: registerLocaleForEsBuild(),
}),
),
provideThemeBasicConfig(),
provideAbpOAuth(),
provideSettingManagementConfig(),
provideAccountConfig(),
provideIdentityConfig(),
provideTenantManagementConfig(),
provideFeatureManagementConfig(),
provideLogo(withEnvironmentOptions(environment)),
provideAbpThemeShared(
withValidationBluePrint({
wrongPassword: 'Please choose 1q2w3E*',
}),
),
provideFormlyCore([
...withFormlyBootstrap(),
{
types: [
{ name: 'dropdown', component: FormlyFieldDropdownComponent },
{ name: 'select', component: FormlyFieldDropdownComponent },
],
validationMessages: [
{
name: 'required',
message: (error: any, field: any) => `${field.props?.label || 'This field'} is required`,
},
{
name: 'minlength',
message: (error: any, field: any) => `${field.props?.label || 'This field'} must be at least ${field.props?.minLength} characters`,
},
{
name: 'minLength',
message: (error: any, field: any) => `${field.props?.label || 'This field'} must be at least ${field.props?.minLength} characters`,
},
{
name: 'maxlength',
message: (error: any, field: any) => `${field.props?.label || 'This field'} cannot exceed ${field.props?.maxLength} characters`,
},
{
name: 'maxLength',
message: (error: any, field: any) => `${field.props?.label || 'This field'} cannot exceed ${field.props?.maxLength} characters`,
},
{
name: 'email',
message: 'Please enter a valid email address',
},
],
},
]),
],
};
+47
View File
@@ -0,0 +1,47 @@
import { Routes } from '@angular/router';
export const appRoutes: Routes = [
{
path: '',
pathMatch: 'full',
loadChildren: () => import('./home/home.routes').then(m => m.homeRoutes),
},
{
path: 'account',
loadChildren: () => import('@abp/ng.account').then(m => m.createRoutes()),
},
{
path: 'identity',
loadChildren: () => import('@abp/ng.identity').then(m => m.createRoutes()),
},
{
path: 'tenant-management',
loadChildren: () =>
import('@abp/ng.tenant-management').then(m => m.createRoutes()),
},
{
path: 'setting-management',
loadChildren: () =>
import('@abp/ng.setting-management').then(m => m.createRoutes()),
},
{
path : 'dummy-page',
loadComponent: () => import('./dummypage/dummypage.component').then(m => m.DummypageComponent),
},
{
path : 'dropdown-manual',
loadComponent: () => import('./dropdownmanual/dropdownmanual.component').then(m => m.DropdownmanualComponent),
},
// {
// path : 'dynamic-form-manual',
// loadComponent: () => import('./dynamic-form-manual/dynamic-form-manual.component').then(m => m.DynamicFormManualComponent),
// },
{
path : 'modal-manual',
loadComponent: () => import('./modal-manual/modal-manual.component').then(m => m.ModalManualComponent),
},
{
path : 'form-management',
loadComponent: () => import('./form-management/form-management.component').then(m => m.FormManagementComponent),
}
];
@@ -0,0 +1,104 @@
<div class="flex flex-col gap-1.5 w-full">
<label *ngIf="label" class="text-sm font-medium text-[var(--clr-text)]">
{{ label }}
<span *ngIf="required" class="text-[var(--clr-danger)]">*</span>
</label>
<ng-select
class="app-dropdown"
[class.app-dropdown--error]="!!errorMessage"
[class.app-dropdown--drawer-search]="showDrawerSearch"
[items]="items"
[bindLabel]="bindLabel"
[bindValue]="bindValue"
[groupBy]="$any(groupByField)"
[multiple]="isMultiple"
[addTag]="allowAddTag"
[placeholder]="placeholder"
[clearable]="clearable"
[disabled]="disabled || isDisabledByForm"
[loading]="loading"
[searchable]="!showDrawerSearch && searchable"
[searchFn]="customSearchFn"
[clearSearchOnAdd]="clearSearchOnAdd"
[hideSelected]="hideSelected"
[closeOnSelect]="computedCloseOnSelect"
[appendTo]="$any(appendTo)"
[dropdownPosition]="dropdownPosition"
[virtualScroll]="virtualScroll"
[ngModel]="value"
(ngModelChange)="handleModelChange($event)"
(change)="handleItemChange($event)"
(blur)="handleBlur()"
(open)="handleOpen()"
(close)="handleClose()"
(clear)="handleClear()"
(search)="handleNativeSearch($event)"
(scrollToEnd)="handleScrollToEnd()"
>
<!-- Drawer Header: Embedded Search Box -->
<ng-template *ngIf="showDrawerSearch" ng-header-tmp>
<div class="dropdown-drawer-search">
<svg class="dropdown-drawer-search__icon" viewBox="0 0 20 20" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M19 19l-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" />
</svg>
<input
#drawerSearchInput
type="text"
class="dropdown-drawer-search__input"
[placeholder]="drawerSearchPlaceholder"
[value]="drawerSearchTerm"
(input)="onDrawerSearchInput($any($event.target).value)"
(click)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
/>
<button
*ngIf="drawerSearchTerm"
type="button"
class="dropdown-drawer-search__clear"
(click)="clearDrawerSearch($event)"
title="Clear search"
>
<svg viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</button>
</div>
</ng-template>
<!-- Custom option template: Label + optional short description -->
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
<div class="dropdown-option-item">
<span class="dropdown-option-item__label">{{ item[bindLabel] }}</span>
<span *ngIf="item[bindDescription]" class="dropdown-option-item__desc">{{ item[bindDescription] }}</span>
</div>
</ng-template>
<!-- Custom label template for single select -->
<ng-template *ngIf="!isMultiple" ng-label-tmp let-item="item">
<span class="ng-value-label">{{ item[bindLabel] }}</span>
</ng-template>
<!-- Empty Results Template -->
<ng-template ng-notfound-tmp let-searchTerm="searchTerm">
<div class="dropdown-empty-state">
<svg class="dropdown-empty-state__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<span class="dropdown-empty-state__text">No options found</span>
</div>
</ng-template>
<!-- Loading More Footer (Infinite Scroll) -->
<ng-template *ngIf="loadingMore" ng-footer-tmp>
<div class="dropdown-drawer-footer">
<div class="dropdown-spinner"></div>
<span>Loading more data...</span>
</div>
</ng-template>
</ng-select>
<p *ngIf="errorMessage" class="text-xs text-[var(--clr-danger)]">{{ errorMessage }}</p>
<p *ngIf="!errorMessage && hint" class="text-xs text-[var(--clr-text-subtle)]">{{ hint }}</p>
</div>
@@ -0,0 +1,370 @@
@reference "tailwindcss";
:host {
display: block;
width: 100%;
}
::ng-deep {
.app-dropdown.ng-select {
font-family: var(--font-base, inherit);
.ng-select-container {
min-height: 42px;
border-radius: 0.5rem;
border: 1px solid var(--clr-input-border);
background-color: var(--clr-input-bg);
padding: 0 6px;
box-shadow: var(--shadow-xs);
transition: border-color 150ms ease, background-color 150ms ease, box-shadow 150ms ease;
.ng-value-container {
padding: 4px 4px;
.ng-placeholder {
color: var(--clr-input-placeholder);
font-size: 14px;
}
.ng-input > input {
font-family: inherit;
font-size: 14px;
color: var(--clr-input-text);
}
}
}
&.ng-select-opened.ng-select-bottom > .ng-select-container {
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
border-color: var(--clr-input-border-focus);
}
&.ng-select-focused:not(.ng-select-opened) > .ng-select-container {
border-color: var(--clr-input-border-focus);
box-shadow: 0 0 0 2px var(--clr-primary-lt);
}
&.ng-select-disabled .ng-select-container {
cursor: not-allowed;
border-color: var(--clr-input-disabled-border);
background-color: var(--clr-input-disabled-bg);
.ng-value-container .ng-value {
background-color: var(--clr-input-disabled-bg);
color: var(--clr-input-disabled-text);
}
}
// Single value display
&.ng-select-single .ng-select-container .ng-value-container .ng-value {
font-size: 14px;
color: var(--clr-input-text);
display: flex;
align-items: center;
.ng-value-label {
color: var(--clr-input-text);
font-weight: 500;
}
}
// Multiple/Tags value chips
&.ng-select-multiple .ng-select-container .ng-value-container .ng-value {
background-color: var(--clr-chip-bg);
color: var(--clr-chip-text);
border: 1px solid var(--clr-chip-border);
border-radius: 6px;
padding: 2px 8px;
margin: 2px;
display: inline-flex;
align-items: center;
font-size: 13px;
font-weight: 500;
.ng-value-icon {
color: var(--clr-chip-icon);
cursor: pointer;
padding-right: 4px;
margin-right: 4px;
border-right: 1px solid var(--clr-chip-border);
display: inline-flex;
align-items: center;
&:hover {
color: var(--clr-primary-hover);
background-color: var(--clr-chip-icon-hover-bg);
}
}
}
.ng-arrow-wrapper {
width: 24px;
padding-right: 6px;
cursor: pointer;
.ng-arrow {
border-color: var(--clr-text-muted) transparent transparent;
border-width: 5px 5px 2.5px;
}
}
&.ng-select-opened .ng-arrow-wrapper .ng-arrow {
border-color: transparent transparent var(--clr-text-muted);
border-width: 0 5px 5px;
}
.ng-clear-wrapper {
cursor: pointer;
color: var(--clr-text-subtle);
width: 20px;
font-size: 16px;
&:hover {
color: var(--clr-text);
}
}
}
// Error state
.app-dropdown--error.ng-select .ng-select-container {
border-color: var(--clr-input-border-error) !important;
&:focus-within {
box-shadow: 0 0 0 2px var(--clr-danger-lt) !important;
}
}
// Dropdown options drawer panel
.ng-dropdown-panel {
background-color: var(--clr-dropdown-bg) !important;
border: 1px solid var(--clr-dropdown-border) !important;
border-radius: 0.5rem !important;
box-shadow: var(--shadow-xl) !important;
margin-top: 4px;
margin-bottom: 4px;
z-index: 1050;
overflow: hidden;
&.ng-select-top {
bottom: calc(100% + 4px);
border-radius: 0.5rem !important;
}
&.ng-select-bottom {
top: calc(100% + 4px);
border-radius: 0.5rem !important;
}
.ng-dropdown-panel-items {
background-color: var(--clr-dropdown-bg) !important;
max-height: 240px;
.ng-optgroup {
padding: 8px 12px 4px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--clr-dropdown-optgroup-text);
background-color: var(--clr-dropdown-optgroup-bg);
border-bottom: 1px solid var(--clr-border);
user-select: none;
}
.ng-option {
background-color: var(--clr-dropdown-bg);
color: var(--clr-text);
padding: 9px 12px;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
transition: background-color 120ms ease, color 120ms ease;
.dropdown-option-item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
text-align: left;
&__label {
font-size: 14px;
font-weight: 500;
color: inherit;
line-height: 1.35;
}
&__desc {
font-size: 12px;
color: var(--clr-text-subtle);
line-height: 1.35;
transition: color 120ms ease;
}
}
&.ng-option-marked {
background-color: var(--clr-dropdown-hover-bg) !important;
color: var(--clr-dropdown-hover-text) !important;
.dropdown-option-item__desc {
color: var(--clr-dropdown-hover-text) !important;
opacity: 0.85;
}
}
&.ng-option-selected {
background-color: var(--clr-dropdown-selected-bg) !important;
color: var(--clr-dropdown-selected-text) !important;
font-weight: 600;
.dropdown-option-item__desc {
color: var(--clr-dropdown-selected-text) !important;
opacity: 0.85;
}
}
&.ng-option-disabled {
background-color: var(--clr-bg-subtle) !important;
color: var(--clr-text-subtle) !important;
cursor: not-allowed;
}
}
}
// Pinned drawer search header
.ng-dropdown-header {
padding: 8px !important;
border-bottom: 1px solid var(--clr-border) !important;
background-color: var(--clr-dropdown-bg) !important;
.dropdown-drawer-search {
position: relative;
display: flex;
align-items: center;
width: 100%;
&__icon {
position: absolute;
left: 10px;
width: 15px;
height: 15px;
color: var(--clr-text-subtle);
pointer-events: none;
}
&__input {
width: 100%;
height: 36px;
padding: 0 32px 0 32px;
border-radius: 6px;
border: 1px solid var(--clr-input-border);
background-color: var(--clr-bg-subtle);
color: var(--clr-input-text);
font-family: inherit;
font-size: 13.5px;
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease, background-color 150ms ease;
&::placeholder {
color: var(--clr-input-placeholder);
font-size: 13px;
}
&:focus {
border-color: var(--clr-primary);
background-color: var(--clr-dropdown-bg);
box-shadow: 0 0 0 2px var(--clr-primary-lt);
}
}
&__clear {
position: absolute;
right: 6px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
background: transparent;
color: var(--clr-text-subtle);
border-radius: 4px;
cursor: pointer;
padding: 0;
svg {
width: 16px;
height: 16px;
}
&:hover {
color: var(--clr-text);
background-color: var(--clr-bg-hover);
}
}
}
}
// Infinite scroll / pagination footer
.ng-dropdown-footer {
padding: 8px 12px !important;
border-top: 1px solid var(--clr-border) !important;
background-color: var(--clr-dropdown-bg) !important;
.dropdown-drawer-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 12.5px;
color: var(--clr-text-muted);
font-weight: 500;
padding: 4px 0;
.dropdown-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--clr-border-strong);
border-top-color: var(--clr-primary);
border-radius: 50%;
animation: dropdown-spin 0.6s linear infinite;
}
}
}
// Empty state
.dropdown-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 24px 16px;
color: var(--clr-text-subtle);
text-align: center;
&__icon {
width: 28px;
height: 28px;
opacity: 0.6;
}
&__text {
font-size: 13px;
}
}
}
}
@keyframes dropdown-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DropdownComponentComponent } from './dropdown-component.component';
describe('DropdownComponentComponent', () => {
let component: DropdownComponentComponent;
let fixture: ComponentFixture<DropdownComponentComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DropdownComponentComponent]
})
.compileComponents();
fixture = TestBed.createComponent(DropdownComponentComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,262 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
forwardRef,
inject,
Input,
OnDestroy,
OnInit,
Output,
ViewChild,
} from '@angular/core';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NgSelectModule } from '@ng-select/ng-select';
import { debounceTime, distinctUntilChanged, Subject, Subscription } from 'rxjs';
export interface DropdownOption {
label: string;
value: any;
description?: string;
disabled?: boolean;
group?: string;
}
export type DropdownType = 'single' | 'multiple' | 'tags' | 'grouped';
/**
* Reusable dropdown component.
*
* Wraps @ng-select/ng-select and supports:
* - "single" → normal single-choice dropdown
* - "multiple" → multi-select with chips
* - "tags" → multi-select with free-text typing
* - "grouped" → optgroup section headers
* - Option subtitles → dual-line label + short description
* - Drawer search → search box pinned inside the option panel
* - Server search → debounced API querying with remote pagination & infinite scroll
*
* Implements ControlValueAccessor, working interchangeably with
* [(ngModel)] and [formControl] / formControlName.
*/
@Component({
selector: 'app-dropdown-component',
imports: [CommonModule, FormsModule, NgSelectModule],
templateUrl: './dropdown-component.component.html',
styleUrl: './dropdown-component.component.scss',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DropdownComponentComponent),
multi: true,
},
],
})
export class DropdownComponentComponent implements OnInit, OnDestroy, ControlValueAccessor {
private readonly cdr = inject(ChangeDetectorRef);
@ViewChild('drawerSearchInput') drawerSearchInputElement?: ElementRef<HTMLInputElement>;
/** Options to render. Default shape is { label, value, description?, group? } — override bindLabel/bindValue/bindDescription for custom shapes. */
@Input() items: DropdownOption[] | any[] = [];
/** Controls select behavior: 'single' | 'multiple' | 'tags' | 'grouped'. Defaults to 'single'. */
@Input() type: DropdownType = 'single';
@Input() bindLabel = 'label';
@Input() bindValue = 'value';
@Input() bindDescription = 'description';
/** Field name holding the group label. Only used when type="grouped". */
@Input() groupBy = 'group';
@Input() label = '';
@Input() placeholder = 'Select an option';
@Input() hint = '';
@Input() errorMessage = '';
@Input() required = false;
@Input() disabled = false;
@Input() clearable = true;
@Input() loading = false;
@Input() searchable = true;
@Input() clearSearchOnAdd = true;
@Input() hideSelected = false;
@Input() closeOnSelect?: boolean;
@Input() appendTo?: string;
@Input() dropdownPosition: 'bottom' | 'top' | 'auto' = 'auto';
@Input() virtualScroll = false;
/** Pins a search input box inside the top header of the dropdown option drawer. */
@Input() showDrawerSearch = false;
@Input() drawerSearchPlaceholder = 'Type to search...';
/** Server-side search mode: disables local array filtering so the dropdown reflects API results directly. */
@Input() serverSearch = false;
/** Displays a loading spinner at the bottom of the drawer when fetching the next page. */
@Input() loadingMore = false;
/** Whether additional pages exist on the server for infinite scrolling. */
@Input() hasMore = true;
/** Debounce time in milliseconds for search input emissions. */
@Input() searchDebounce = 300;
/** Emits the new value on every change, in addition to normal ngModel/formControl binding. */
@Output() selectionChange = new EventEmitter<any>();
@Output() open = new EventEmitter<void>();
@Output() close = new EventEmitter<void>();
@Output() clear = new EventEmitter<void>();
/** Emits raw search term when typed in either standard search or drawer search box. */
@Output() search = new EventEmitter<{ term: string; items: any[] }>();
/** Emits debounced search string specifically designed for triggering API search calls. */
@Output() searchChange = new EventEmitter<string>();
/** Emits when user scrolls to the bottom of the option list (for pagination / loading next page). */
@Output() scrollToEnd = new EventEmitter<void>();
value: any = null;
isDisabledByForm = false;
drawerSearchTerm = '';
private readonly searchSubject = new Subject<string>();
private searchSubscription?: Subscription;
private onChange: (value: any) => void = () => {};
private onTouched: () => void = () => {};
ngOnInit(): void {
this.searchSubscription = this.searchSubject
.pipe(debounceTime(this.searchDebounce), distinctUntilChanged())
.subscribe((term) => {
this.searchChange.emit(term);
this.search.emit({ term, items: this.items });
});
}
ngOnDestroy(): void {
this.searchSubscription?.unsubscribe();
}
get isMultiple(): boolean {
return this.type === 'multiple' || this.type === 'tags';
}
get allowAddTag(): boolean {
return this.type === 'tags';
}
get computedCloseOnSelect(): boolean {
if (this.closeOnSelect !== undefined) {
return this.closeOnSelect;
}
// Single & Grouped should close upon selection; Multiple & Tags stay open
return !this.isMultiple;
}
get groupByField(): string | undefined {
return this.type === 'grouped' ? this.groupBy : undefined;
}
/** Custom search function to bypass internal client filtering when serverSearch is true. */
customSearchFn = (term: string, item: any): boolean => {
if (this.serverSearch || this.showDrawerSearch) {
return true; // Bypass local filtering, display items provided by parent / API
}
const label = item[this.bindLabel] ? String(item[this.bindLabel]).toLowerCase() : '';
const desc = item[this.bindDescription] ? String(item[this.bindDescription]).toLowerCase() : '';
const search = (term || '').toLowerCase();
return label.includes(search) || desc.includes(search);
};
handleModelChange(val: any): void {
this.value = val;
this.onChange(val);
this.selectionChange.emit(val);
this.cdr.markForCheck();
}
handleItemChange(item: any): void {
// ng-select (change) event with raw selected item(s)
}
handleBlur(): void {
this.onTouched();
this.cdr.markForCheck();
}
handleOpen(): void {
this.open.emit();
if (this.showDrawerSearch) {
setTimeout(() => {
this.drawerSearchInputElement?.nativeElement?.focus();
}, 60);
}
}
handleClose(): void {
this.close.emit();
this.onTouched();
this.cdr.markForCheck();
}
handleClear(): void {
this.clear.emit();
if (this.showDrawerSearch && this.drawerSearchTerm) {
this.clearDrawerSearch();
}
}
handleNativeSearch(event: { term: string; items: any[] }): void {
if (!this.showDrawerSearch) {
this.searchSubject.next(event.term);
}
}
onDrawerSearchInput(term: string): void {
this.drawerSearchTerm = term;
this.searchSubject.next(term);
}
clearDrawerSearch(event?: Event): void {
if (event) {
event.stopPropagation();
}
this.drawerSearchTerm = '';
this.searchSubject.next('');
this.drawerSearchInputElement?.nativeElement?.focus();
}
handleScrollToEnd(): void {
if (!this.loading && !this.loadingMore && this.hasMore) {
this.scrollToEnd.emit();
}
}
// --- ControlValueAccessor ---
writeValue(value: any): void {
this.value = value;
this.cdr.markForCheck();
}
registerOnChange(fn: (value: any) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.isDisabledByForm = isDisabled;
this.cdr.markForCheck();
}
}
@@ -0,0 +1,23 @@
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="space-y-5">
<formly-form
[form]="form"
[fields]="fields"
[model]="model"
(modelChange)="modelChange.emit($event)">
</formly-form>
<div *ngIf="showActions" class="mt-2 flex justify-end gap-3 border-t border-[var(--clr-border)] pt-4">
<button
type="button"
*ngIf="cancelLabel"
(click)="formCancel.emit()"
class="rounded-lg border border-[var(--clr-border)] bg-[var(--clr-bg-card)] px-4 py-2 text-sm font-medium text-[var(--clr-text)] hover:bg-[var(--clr-bg-hover)] transition-colors">
{{ cancelLabel }}
</button>
<button
type="submit"
class="rounded-lg bg-[var(--clr-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--clr-primary-hover)] shadow-sm transition-colors">
{{ submitLabel }}
</button>
</div>
</form>
Whitespace-only changes.
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DynamicFormComponent } from './dynamic-form.component';
describe('DynamicFormComponent', () => {
let component: DynamicFormComponent;
let fixture: ComponentFixture<DynamicFormComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DynamicFormComponent]
})
.compileComponents();
fixture = TestBed.createComponent(DynamicFormComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,43 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
/**
* Generic form host. Give it a FormGroup, a model, and a list of
* FormlyFieldConfig entries — it renders every field via its registered
* Formly type (e.g. 'dropdown' -> FormlyFieldDropdownComponent, 'input',
* 'radio', etc.) and emits the model on submit.
*
* This component doesn't know or care what field types are inside it —
* add new reusable components (input, radio, checkbox, ...) by registering
* them as Formly types in app.config.ts, the same way 'dropdown' is wired.
* No changes needed here or in the field components themselves.
*/
@Component({
selector: 'app-dynamic-form',
imports: [CommonModule, ReactiveFormsModule, FormlyModule],
templateUrl: './dynamic-form.component.html',
styleUrl: './dynamic-form.component.scss',
})
export class DynamicFormComponent {
@Input() form: FormGroup = new FormGroup({});
@Input() fields: FormlyFieldConfig[] = [];
@Input() model: Record<string, any> = {};
@Input() submitLabel = 'Save';
@Input() cancelLabel = 'Cancel';
@Input() showActions = true;
@Output() formSubmit = new EventEmitter<any>();
@Output() formCancel = new EventEmitter<void>();
@Output() modelChange = new EventEmitter<any>();
onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.formSubmit.emit(this.model);
}
}
@@ -0,0 +1,82 @@
<div
*ngIf="isOpen"
class="modal-root fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true"
[attr.aria-label]="title || null"
>
<!-- Backdrop Overlay (Darkens & blurs page background) -->
<div
class="modal-backdrop fixed inset-0"
(click)="onBackdropClick()"
aria-hidden="true"
></div>
<!-- Centering Flex Container (Above backdrop) -->
<div
class="modal-container flex min-h-full justify-center p-4 sm:p-6 {{ positionClass }}"
>
<!-- Modal Dialog Panel (Sharp, crisp text & controls) -->
<div
cdkTrapFocus
cdkTrapFocusAutoCapture
class="modal-panel relative flex max-h-[90vh] w-full flex-col overflow-hidden rounded-2xl {{ sizeClass }}"
>
<!-- Custom Header Slot -->
<ng-content select="[modal-header]"></ng-content>
<!-- Default Header -->
<div
*ngIf="title || iconClass || subtitle || showCloseButton"
class="modal-header flex shrink-0 items-start justify-between px-6 py-5 border-b"
>
<div class="flex items-start gap-3.5 pr-6">
<!-- Variant / Custom Icon Badge -->
<div
*ngIf="iconClass"
class="modal-icon-badge modal-icon-badge--{{ variant }} flex shrink-0 items-center justify-center rounded-xl"
>
<i [class]="iconClass" class="text-base"></i>
</div>
<!-- Title & Subtitle -->
<div class="flex flex-col">
<h2 class="modal-title text-lg font-semibold leading-6">{{ title }}</h2>
<p *ngIf="subtitle" class="modal-subtitle mt-0.5 text-xs leading-5">{{ subtitle }}</p>
</div>
</div>
<!-- Close Action Button -->
<button
*ngIf="showCloseButton"
type="button"
(click)="close()"
aria-label="Close dialog"
class="modal-close-btn flex shrink-0 items-center justify-center rounded-lg p-1.5 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modal Body Content -->
<div
class="modal-body px-6 py-5"
[class.overflow-y-auto]="scrollable"
>
<ng-content select="[modal-body]"></ng-content>
<ng-content></ng-content>
</div>
<!-- Modal Footer Actions -->
<div
*ngIf="showFooter"
class="modal-footer flex shrink-0 items-center justify-end gap-3 border-t px-6 py-4"
>
<ng-content select="[modal-footer]"></ng-content>
</div>
</div>
</div>
</div>
@@ -0,0 +1,204 @@
@reference "tailwindcss";
:host {
display: contents;
}
.modal-root {
z-index: 1050;
}
/* ── Backdrop Overlay ────────────────────────────────────────── */
.modal-backdrop {
z-index: 1051;
background-color: rgba(5, 10, 20, 0.7);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
animation: modal-fade-in 180ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* ── Centering Container ─────────────────────────────────────── */
.modal-container {
position: relative;
z-index: 1052;
pointer-events: none;
}
/* ── Modal Dialog Shell ──────────────────────────────────────── */
.modal-panel {
pointer-events: auto;
position: relative;
background-color: var(--clr-bg-card);
border: 1px solid var(--clr-border);
color: var(--clr-text);
border-radius: 1.125rem;
box-shadow:
0 20px 25px -5px rgba(0, 0, 0, 0.3),
0 8px 10px -6px rgba(0, 0, 0, 0.25),
0 0 0 1px var(--clr-border);
animation: modal-scale-in 220ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
transform-origin: center center;
filter: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
// Responsive sizing presets
&--sm {
max-width: 420px;
}
&--md {
max-width: 520px;
}
&--lg {
max-width: 680px;
}
&--xl {
max-width: 860px;
}
&--2xl {
max-width: 1100px;
}
&--full {
max-width: calc(100vw - 2rem);
width: 100%;
min-height: calc(100vh - 4rem);
}
}
/* ── Modal Header ────────────────────────────────────────────── */
.modal-header {
border-color: var(--clr-border);
background-color: var(--clr-bg-card);
padding: 1.125rem 1.5rem;
.modal-title {
color: var(--clr-text);
font-weight: 600;
letter-spacing: -0.01em;
font-family: var(--font-base, inherit);
}
.modal-subtitle {
color: var(--clr-text-muted);
font-size: 0.8125rem;
line-height: 1.4;
}
.modal-close-btn {
color: var(--clr-text-subtle);
background: transparent;
border: none;
cursor: pointer;
transition: all 150ms ease;
&:hover {
background-color: var(--clr-bg-hover);
color: var(--clr-text);
transform: scale(1.05);
}
&:focus-visible {
outline: 2px solid var(--clr-primary);
outline-offset: 2px;
}
}
}
/* ── Variant Icon Badges ─────────────────────────────────────── */
.modal-icon-badge {
width: 38px;
height: 38px;
border-radius: 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&--default {
background-color: var(--clr-bg-hover);
border: 1px solid var(--clr-border);
color: var(--clr-text);
}
&--primary,
&--info {
background-color: var(--clr-primary-lt);
border: 1px solid rgba(99, 102, 241, 0.25);
color: var(--clr-primary);
}
&--danger {
background-color: var(--clr-danger-lt);
border: 1px solid rgba(239, 68, 68, 0.25);
color: var(--clr-danger);
}
&--warning {
background-color: var(--clr-warning-lt);
border: 1px solid rgba(245, 158, 11, 0.25);
color: var(--clr-warning);
}
&--success {
background-color: var(--clr-success-lt);
border: 1px solid rgba(16, 185, 129, 0.25);
color: var(--clr-success);
}
}
/* ── Modal Body ──────────────────────────────────────────────── */
.modal-body {
color: var(--clr-text);
padding: 1.25rem 1.5rem;
line-height: 1.6;
// Modern subtle custom scrollbar
scrollbar-width: thin;
scrollbar-color: var(--clr-border) transparent;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: var(--clr-border);
border-radius: 3px;
}
}
/* ── Modal Footer ────────────────────────────────────────────── */
.modal-footer {
border-color: var(--clr-border);
background-color: var(--clr-bg-subtle);
padding: 0.875rem 1.5rem;
}
/* ── Animations ──────────────────────────────────────────────── */
@keyframes modal-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-scale-in {
from {
opacity: 0;
transform: scale(0.96) translateY(-8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ModalComponent } from './modal.component';
describe('ModalComponent', () => {
let component: ModalComponent;
let fixture: ComponentFixture<ModalComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ModalComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ModalComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,128 @@
import {
Component,
ElementRef,
EventEmitter,
HostListener,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { A11yModule } from '@angular/cdk/a11y';
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
export type ModalVariant = 'default' | 'primary' | 'danger' | 'warning' | 'success' | 'info';
export type ModalPosition = 'center' | 'top';
/**
* Professional, reusable modal dialog component.
*
* Fully integrated with the application theme (supports Light/Dark mode via CSS tokens).
* Uses @angular/cdk's A11yModule (cdkTrapFocus) for keyboard accessibility and focus trapping.
*
* Features:
* - Responsive sizing: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'
* - Semantic variants: 'default' | 'primary' | 'danger' | 'warning' | 'success' | 'info'
* - Title, subtitle, and badge icon support
* - Custom content projection via [modal-header], [modal-body], and [modal-footer]
* - Smooth backdrop fade and panel scale transitions
* - Accessible Esc and backdrop click dismissing
* - Automatic background scroll-lock management
*/
@Component({
selector: 'app-modal',
imports: [CommonModule, A11yModule],
templateUrl: './modal.component.html',
styleUrl: './modal.component.scss',
})
export class ModalComponent implements OnChanges, OnDestroy {
@Input() isOpen = false;
@Input() title = '';
@Input() subtitle?: string;
@Input() iconClass?: string;
@Input() variant: ModalVariant = 'default';
@Input() size: ModalSize = 'md';
@Input() position: ModalPosition = 'center';
@Input() scrollable = true;
@Input() closeOnBackdropClick = true;
@Input() closeOnEsc = true;
@Input() showCloseButton = true;
/** Set false when you don't project any modal-footer content to omit the bottom action bar. */
@Input() showFooter = true;
@Output() closed = new EventEmitter<void>();
@Output() opened = new EventEmitter<void>();
private previouslyFocusedElement: HTMLElement | null = null;
private originalBodyOverflow = '';
constructor(private hostRef: ElementRef<HTMLElement>) {}
ngOnChanges(changes: SimpleChanges): void {
if (!changes['isOpen']) {
return;
}
if (this.isOpen) {
this.previouslyFocusedElement = document.activeElement as HTMLElement;
this.originalBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
this.opened.emit();
} else {
this.unlockBodyScroll();
this.previouslyFocusedElement?.focus?.();
}
}
ngOnDestroy(): void {
if (this.isOpen) {
this.unlockBodyScroll();
}
}
private unlockBodyScroll(): void {
document.body.style.overflow = this.originalBodyOverflow || '';
}
@HostListener('document:keydown.escape')
onEscKey(): void {
if (this.isOpen && this.closeOnEsc) {
this.close();
}
}
onBackdropClick(): void {
if (this.closeOnBackdropClick) {
this.close();
}
}
close(): void {
this.closed.emit();
}
get sizeClass(): string {
switch (this.size) {
case 'sm':
return 'modal-panel--sm';
case 'lg':
return 'modal-panel--lg';
case 'xl':
return 'modal-panel--xl';
case '2xl':
return 'modal-panel--2xl';
case 'full':
return 'modal-panel--full';
default:
return 'modal-panel--md';
}
}
get positionClass(): string {
return this.position === 'top' ? 'items-start pt-12 md:pt-16' : 'items-center';
}
}
@@ -0,0 +1,259 @@
<div class="mx-auto max-w-4xl space-y-10 px-4 py-10">
<div>
<h1 class="text-2xl font-semibold text-[var(--clr-text)]">Dropdown Component — Usage Guide</h1>
<p class="mt-1 text-sm text-[var(--clr-text-muted)]">
One reusable <code class="rounded bg-[var(--clr-bg-hover)] px-1 py-0.5 text-[var(--clr-text)]">&lt;app-dropdown&gt;</code>
covering single-select, multi-select, tag input, and grouped options — switch behavior with the
<code class="rounded bg-[var(--clr-bg-hover)] px-1 py-0.5 text-[var(--clr-text)]">type</code> input. Works with both
<code class="rounded bg-[var(--clr-bg-hover)] px-1 py-0.5 text-[var(--clr-text)]">ngModel</code> and reactive forms.
</p>
</div>
<!-- 1. Single select -->
<section class="space-y-4 rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-6 shadow-sm">
<div>
<h2 class="text-lg font-medium text-[var(--clr-text)]">1. Single select</h2>
<p class="text-sm text-[var(--clr-text-muted)]">Set <code>type="single"</code> — or omit it, since it's the default.</p>
</div>
<app-dropdown-component
label="Country"
type="single"
[items]="countries"
placeholder="Select a country"
[(ngModel)]="singleValue">
</app-dropdown-component>
<p class="text-xs text-[var(--clr-text-subtle)]">Selected value: {{ singleValue || '—' }}</p>
<pre class="overflow-x-auto rounded-lg border border-[var(--clr-border)] bg-[var(--clr-bg-subtle)] p-4 text-xs text-[var(--clr-text)]"><code>{{ singleSnippet }}</code></pre>
</section>
<!-- 2. Multi select -->
<section class="space-y-4 rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-6 shadow-sm">
<div>
<h2 class="text-lg font-medium text-[var(--clr-text)]">2. Multi-select</h2>
<p class="text-sm text-[var(--clr-text-muted)]">Set <code>type="multiple"</code>. Value becomes an array.</p>
</div>
<app-dropdown-component
label="Skills"
type="multiple"
[items]="skills"
placeholder="Select skills"
[(ngModel)]="multiValue">
</app-dropdown-component>
<p class="text-xs text-[var(--clr-text-subtle)]">
Selected values: {{ multiValue.length ? multiValue.join(', ') : '—' }}
</p>
<pre class="overflow-x-auto rounded-lg border border-[var(--clr-border)] bg-[var(--clr-bg-subtle)] p-4 text-xs text-[var(--clr-text)]"><code>{{ multiSnippet }}</code></pre>
</section>
<!-- 3. Tags -->
<section class="space-y-4 rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-6 shadow-sm">
<div>
<h2 class="text-lg font-medium text-[var(--clr-text)]">3. Tag input</h2>
<p class="text-sm text-[var(--clr-text-muted)]">Set <code>type="tags"</code> to also let users type free-text entries not in the list.</p>
</div>
<app-dropdown-component
label="Add tags"
type="tags"
[items]="skills"
placeholder="Type to add a tag"
[(ngModel)]="tagsValue">
</app-dropdown-component>
<pre class="overflow-x-auto rounded-lg border border-[var(--clr-border)] bg-[var(--clr-bg-subtle)] p-4 text-xs text-[var(--clr-text)]"><code>{{ tagsSnippet }}</code></pre>
</section>
<!-- 4. Grouped -->
<section class="space-y-4 rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-6 shadow-sm">
<div>
<h2 class="text-lg font-medium text-[var(--clr-text)]">4. Grouped options</h2>
<p class="text-sm text-[var(--clr-text-muted)]">
Set <code>type="grouped"</code> and <code>groupBy</code> to the field name holding each option's group label.
</p>
</div>
<app-dropdown-component
label="Fruit"
type="grouped"
[items]="fruitGroups"
groupBy="group"
placeholder="Select a fruit"
[(ngModel)]="groupedValue">
</app-dropdown-component>
<pre class="overflow-x-auto rounded-lg border border-[var(--clr-border)] bg-[var(--clr-bg-subtle)] p-4 text-xs text-[var(--clr-text)]"><code>{{ groupedSnippet }}</code></pre>
</section>
<!-- 5. Reactive forms -->
<section class="space-y-4 rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-6 shadow-sm">
<div>
<h2 class="text-lg font-medium text-[var(--clr-text)]">5. With Reactive Forms</h2>
<p class="text-sm text-[var(--clr-text-muted)]">
Bind <code>[formControl]</code> instead of <code>ngModel</code> — the component implements
<code>ControlValueAccessor</code>, so both work identically.
</p>
</div>
<app-dropdown-component
label="Country (Reactive Form)"
type="single"
[items]="countries"
[formControl]="countryControl">
</app-dropdown-component>
<pre class="overflow-x-auto rounded-lg border border-[var(--clr-border)] bg-[var(--clr-bg-subtle)] p-4 text-xs text-[var(--clr-text)]"><code>{{ reactiveFormSnippet }}</code></pre>
</section>
<!-- 6. With Description / Subtitle -->
<section class="space-y-4 rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-6 shadow-sm">
<div>
<h2 class="text-lg font-medium text-[var(--clr-text)]">6. Option with Short Description</h2>
<p class="text-sm text-[var(--clr-text-muted)]">
Options display both a primary title and a secondary explanatory description underneath.
</p>
</div>
<app-dropdown-component
label="Subscription Plan"
type="single"
[items]="planOptions"
placeholder="Select a subscription plan"
[(ngModel)]="planValue">
</app-dropdown-component>
<p class="text-xs text-[var(--clr-text-subtle)]">Selected plan: {{ planValue || '—' }}</p>
<pre class="overflow-x-auto rounded-lg border border-[var(--clr-border)] bg-[var(--clr-bg-subtle)] p-4 text-xs text-[var(--clr-text)]"><code>{{ descriptionSnippet }}</code></pre>
</section>
<!-- 7. Paginated Server Search with In-Drawer Search Box & Infinite Scroll -->
<section class="space-y-4 rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-6 shadow-sm">
<div>
<div class="flex items-center gap-2">
<h2 class="text-lg font-medium text-[var(--clr-text)]">7. Paginated Server Search (In-Drawer Search & Infinite Scroll)</h2>
<span class="rounded-full bg-[var(--clr-primary-lt)] px-2.5 py-0.5 text-xs font-semibold text-[var(--clr-primary)]">
API Paginated
</span>
</div>
<p class="mt-1 text-sm text-[var(--clr-text-muted)]">
Search box is embedded directly inside the dropdown option drawer. Searches query the remote API in real-time with 300ms debounce. Scrolling to the bottom loads the next page of 10 items.
</p>
</div>
<app-dropdown-component
label="Assign Team Member (API Search)"
type="single"
[items]="serverUsers"
[loading]="loadingUsers"
[loadingMore]="loadingMoreUsers"
[hasMore]="hasMoreUsers"
[showDrawerSearch]="true"
[serverSearch]="true"
drawerSearchPlaceholder="Search 50 remote users by name, role, or department..."
placeholder="Click to search & select user from API"
[(ngModel)]="selectedServerUserId"
(searchChange)="onServerSearch($event)"
(scrollToEnd)="onServerScrollToEnd()">
</app-dropdown-component>
<div class="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-[var(--clr-bg-subtle)] p-3 text-xs text-[var(--clr-text-muted)] border border-[var(--clr-border)]">
<div>
<span class="font-medium text-[var(--clr-text)]">Selected User ID:</span>
<span class="ml-1 font-mono text-[var(--clr-primary)]">{{ selectedServerUserId || 'None selected' }}</span>
</div>
<div class="flex items-center gap-3">
<span>
<strong class="text-[var(--clr-text)]">{{ serverUsers.length }}</strong> of {{ totalServerMatches }} users loaded (Page {{ serverCurrentPage }})
</span>
<span *ngIf="hasMoreUsers" class="inline-flex items-center gap-1 text-[var(--clr-success)] font-medium">
<span class="h-1.5 w-1.5 rounded-full bg-[var(--clr-success)]"></span> Scroll down for more
</span>
<span *ngIf="!hasMoreUsers" class="text-[var(--clr-text-subtle)]">
All results loaded
</span>
</div>
</div>
<pre class="overflow-x-auto rounded-lg border border-[var(--clr-border)] bg-[var(--clr-bg-subtle)] p-4 text-xs text-[var(--clr-text)]"><code>{{ serverSearchSnippet }}</code></pre>
</section>
<!-- Inputs reference -->
<section class="rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-6 shadow-sm">
<h2 class="mb-4 text-lg font-medium text-[var(--clr-text)]">Inputs & Outputs reference</h2>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm text-[var(--clr-text)]">
<thead>
<tr class="border-b border-[var(--clr-border)] text-[var(--clr-text-muted)]">
<th class="py-2 pr-4 font-medium">Property</th>
<th class="py-2 pr-4 font-medium">Type</th>
<th class="py-2 font-medium">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-[var(--clr-border)] text-[var(--clr-text-muted)]">
<tr>
<td class="py-2 pr-4 font-mono text-xs text-[var(--clr-primary)]">type</td>
<td class="py-2 pr-4">'single' | 'multiple' | 'tags' | 'grouped'</td>
<td class="py-2">Controls select behavior. Default: 'single'.</td>
</tr>
<tr>
<td class="py-2 pr-4 font-mono text-xs text-[var(--clr-primary)]">items</td>
<td class="py-2 pr-4">DropdownOption[] | any[]</td>
<td class="py-2">Option list to render. Supports &#123; label, value, description?, group? &#125;.</td>
</tr>
<tr>
<td class="py-2 pr-4 font-mono text-xs text-[var(--clr-primary)]">showDrawerSearch</td>
<td class="py-2 pr-4">boolean</td>
<td class="py-2">Pins an interactive search input inside the dropdown option drawer.</td>
</tr>
<tr>
<td class="py-2 pr-4 font-mono text-xs text-[var(--clr-primary)]">serverSearch</td>
<td class="py-2 pr-4">boolean</td>
<td class="py-2">Bypasses local filtering to let the backend API manage results directly.</td>
</tr>
<tr>
<td class="py-2 pr-4 font-mono text-xs text-[var(--clr-primary)]">loadingMore</td>
<td class="py-2 pr-4">boolean</td>
<td class="py-2">Shows a loading spinner in the drawer footer while loading the next page.</td>
</tr>
<tr>
<td class="py-2 pr-4 font-mono text-xs text-[var(--clr-primary)]">hasMore</td>
<td class="py-2 pr-4">boolean</td>
<td class="py-2">Controls whether infinite scroll continues triggering next page requests.</td>
</tr>
<tr>
<td class="py-2 pr-4 font-mono text-xs text-[var(--clr-primary)]">bindLabel / bindValue</td>
<td class="py-2 pr-4">string</td>
<td class="py-2">Field names to read the label/value from, for custom option shapes.</td>
</tr>
<tr>
<td class="py-2 pr-4 font-mono text-xs text-[var(--clr-primary)]">bindDescription</td>
<td class="py-2 pr-4">string</td>
<td class="py-2">Field name holding the short description subtitle. Default: 'description'.</td>
</tr>
<tr>
<td class="py-2 pr-4 font-mono text-xs text-[var(--clr-primary)]">(searchChange)</td>
<td class="py-2 pr-4">EventEmitter&lt;string&gt;</td>
<td class="py-2">Emits debounced search string (300ms) for direct backend API calls.</td>
</tr>
<tr>
<td class="py-2 pr-4 font-mono text-xs text-[var(--clr-primary)]">(scrollToEnd)</td>
<td class="py-2 pr-4">EventEmitter&lt;void&gt;</td>
<td class="py-2">Fires when user scrolls to the bottom of the drawer to fetch the next page.</td>
</tr>
<tr>
<td class="py-2 pr-4 font-mono text-xs text-[var(--clr-primary)]">(selectionChange)</td>
<td class="py-2 pr-4">EventEmitter&lt;any&gt;</td>
<td class="py-2">Emits on every selection change, in addition to ngModel/formControl updates.</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
Whitespace-only changes.
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DropdownmanualComponent } from './dropdownmanual.component';
describe('DropdownmanualComponent', () => {
let component: DropdownmanualComponent;
let fixture: ComponentFixture<DropdownmanualComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DropdownmanualComponent]
})
.compileComponents();
fixture = TestBed.createComponent(DropdownmanualComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,277 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { DropdownComponentComponent, DropdownOption } from '../components/dropdown-component/dropdown-component.component';
/**
* Living usage guide for <app-dropdown>.
* Demonstrates single-select, multi-select, tag input, grouped options,
* descriptions, and server-side paginated search with infinite scroll.
*/
interface MockUser {
id: string;
name: string;
role: string;
department: string;
}
@Component({
selector: 'app-dropdownmanual',
imports: [CommonModule, FormsModule, ReactiveFormsModule, DropdownComponentComponent],
templateUrl: './dropdownmanual.component.html',
styleUrl: './dropdownmanual.component.scss',
})
export class DropdownmanualComponent implements OnInit {
// Static datasets
countries: DropdownOption[] = [
{ label: 'India', value: 'IN' },
{ label: 'United States', value: 'US' },
{ label: 'Germany', value: 'DE' },
{ label: 'Japan', value: 'JP' },
];
skills: DropdownOption[] = [
{ label: 'Angular', value: 'angular' },
{ label: 'TypeScript', value: 'ts' },
{ label: 'Tailwind CSS', value: 'tailwind' },
{ label: 'ABP.io', value: 'abp' },
];
fruitGroups: DropdownOption[] = [
{ label: 'Apple', value: 'apple', group: 'Citrus-free' },
{ label: 'Banana', value: 'banana', group: 'Citrus-free' },
{ label: 'Orange', value: 'orange', group: 'Citrus' },
{ label: 'Lemon', value: 'lemon', group: 'Citrus' },
];
planOptions: DropdownOption[] = [
{ label: 'Starter Plan', value: 'starter', description: 'Up to 5 team members with standard community support' },
{ label: 'Professional Plan', value: 'pro', description: 'Up to 25 members, advanced analytics, and priority email support' },
{ label: 'Enterprise Plan', value: 'enterprise', description: 'Unlimited members, custom integrations, dedicated SLA, and SSO security' },
{ label: 'Custom Contract', value: 'custom', description: 'Tailored infrastructure, custom billing, and personal account manager' },
];
// Component Models
singleValue: string | null = null;
multiValue: string[] = [];
tagsValue: string[] = [];
groupedValue: string | null = null;
planValue: string | null = null;
selectedServerUserId: string | null = null;
countryControl = new FormControl<string | null>(null);
// --- Server Search & Pagination State (Mock API) ---
serverUsers: DropdownOption[] = [];
loadingUsers = false;
loadingMoreUsers = false;
hasMoreUsers = true;
serverCurrentPage = 1;
serverSearchTerm = '';
totalServerMatches = 0;
// Mock server database of 50 users
private readonly mockUsersDb: MockUser[] = [
{ id: 'usr-1', name: 'Aarav Patel', role: 'Full Stack Engineer', department: 'Engineering' },
{ id: 'usr-2', name: 'Sophia Chen', role: 'Frontend Architect', department: 'Engineering' },
{ id: 'usr-3', name: 'Liam Johnson', role: 'Product Designer', department: 'Product & UX' },
{ id: 'usr-4', name: 'Priya Sharma', role: 'Engineering Manager', department: 'Leadership' },
{ id: 'usr-5', name: 'Lucas Müller', role: 'DevOps & Cloud Lead', department: 'Infrastructure' },
{ id: 'usr-6', name: 'Elena Rostova', role: 'QA Automation Lead', department: 'Quality Assurance' },
{ id: 'usr-7', name: 'Marcus Vance', role: 'Security Engineer', department: 'Cybersecurity' },
{ id: 'usr-8', name: 'Aisha Al-Mansoor', role: 'Backend Specialist', department: 'Engineering' },
{ id: 'usr-9', name: 'Daniel Kim', role: 'Mobile App Lead', department: 'Mobile Apps' },
{ id: 'usr-10', name: 'Chloe Dubois', role: 'Scrum Master', department: 'Agile Operations' },
{ id: 'usr-11', name: 'Vikram Mehta', role: 'Data Scientist', department: 'Data Intelligence' },
{ id: 'usr-12', name: 'Hannah Abbott', role: 'Database Administrator', department: 'Infrastructure' },
{ id: 'usr-13', name: 'Carlos Silva', role: 'Solutions Architect', department: 'Enterprise Systems' },
{ id: 'usr-14', name: 'Fatima Zahra', role: 'Product Manager', department: 'Product & UX' },
{ id: 'usr-15', name: 'Oliver Wright', role: 'UI/UX Specialist', department: 'Product & UX' },
{ id: 'usr-16', name: 'Ananya Rao', role: 'Frontend Developer', department: 'Engineering' },
{ id: 'usr-17', name: 'Noah Becker', role: 'Site Reliability Engineer', department: 'Infrastructure' },
{ id: 'usr-18', name: 'Mei Ling', role: 'Machine Learning Engineer', department: 'Data Intelligence' },
{ id: 'usr-19', name: 'Gabriel Rossi', role: 'API Platform Engineer', department: 'Engineering' },
{ id: 'usr-20', name: 'Zara Kazmi', role: 'Release Manager', department: 'Agile Operations' },
{ id: 'usr-21', name: 'Arjun Das', role: 'Systems Engineer', department: 'Infrastructure' },
{ id: 'usr-22', name: 'Isabella Scott', role: 'Technical Writer', department: 'Documentation' },
{ id: 'usr-23', name: 'Felix Meyer', role: 'Performance Engineer', department: 'Quality Assurance' },
{ id: 'usr-24', name: 'Diya Sen', role: 'Cloud Security Analyst', department: 'Cybersecurity' },
{ id: 'usr-25', name: 'Benjamin Clark', role: 'Integration Developer', department: 'Enterprise Systems' },
{ id: 'usr-26', name: 'Kavita Nair', role: 'Mobile Developer', department: 'Mobile Apps' },
{ id: 'usr-27', name: 'Samuel Taylor', role: 'Observability Engineer', department: 'Infrastructure' },
{ id: 'usr-28', name: 'Amira Hassan', role: 'Frontend Developer', department: 'Engineering' },
{ id: 'usr-29', name: 'Matteo Conti', role: 'Product Strategist', department: 'Product & UX' },
{ id: 'usr-30', name: 'Rohan Gupta', role: 'Full Stack Engineer', department: 'Engineering' },
{ id: 'usr-31', name: 'Grace O\'Connor', role: 'Compliance Officer', department: 'Governance' },
{ id: 'usr-32', name: 'Kenji Sato', role: 'Platform Architect', department: 'Engineering' },
{ id: 'usr-33', name: 'Siddharth Roy', role: 'Backend Developer', department: 'Engineering' },
{ id: 'usr-34', name: 'Layla Morales', role: 'Design Systems Lead', department: 'Product & UX' },
{ id: 'usr-35', name: 'Alexander Novak', role: 'Data Analyst', department: 'Data Intelligence' },
{ id: 'usr-36', name: 'Tanvi Joshi', role: 'QA Engineer', department: 'Quality Assurance' },
{ id: 'usr-37', name: 'David Fischer', role: 'Infrastructure Architect', department: 'Infrastructure' },
{ id: 'usr-38', name: 'Noor Mahmoud', role: 'Software Engineer', department: 'Engineering' },
{ id: 'usr-39', name: 'Oscar Lindqvist', role: 'Technical Lead', department: 'Engineering' },
{ id: 'usr-40', name: 'Sneha Kulkarni', role: 'Scrum Coach', department: 'Agile Operations' },
{ id: 'usr-41', name: 'Hugo Blanc', role: 'Security Analyst', department: 'Cybersecurity' },
{ id: 'usr-42', name: 'Sunita Reddy', role: 'Operations Lead', department: 'Agile Operations' },
{ id: 'usr-43', name: 'Julian Romero', role: 'API Developer', department: 'Engineering' },
{ id: 'usr-44', name: 'Yuki Takahashi', role: 'Frontend Engineer', department: 'Engineering' },
{ id: 'usr-45', name: 'Nikhil Verma', role: 'Cloud Engineer', department: 'Infrastructure' },
{ id: 'usr-46', name: 'Stella Maris', role: 'UX Researcher', department: 'Product & UX' },
{ id: 'usr-47', name: 'Zack Evans', role: 'Test Automation Engineer', department: 'Quality Assurance' },
{ id: 'usr-48', name: 'Isha Deshmukh', role: 'Data Engineer', department: 'Data Intelligence' },
{ id: 'usr-49', name: 'Victor Hugo', role: 'Principal Architect', department: 'Leadership' },
{ id: 'usr-50', name: 'Maya Angelis', role: 'Head of Engineering', department: 'Leadership' },
];
ngOnInit(): void {
this.loadInitialUsers();
}
loadInitialUsers(): void {
this.loadingUsers = true;
this.serverCurrentPage = 1;
this.serverSearchTerm = '';
this.mockFetchUsersApi(this.serverSearchTerm, 1, 10).then((res) => {
this.serverUsers = res.items;
this.hasMoreUsers = res.hasMore;
this.totalServerMatches = res.total;
this.loadingUsers = false;
});
}
onServerSearch(searchTerm: string): void {
this.serverSearchTerm = searchTerm;
this.serverCurrentPage = 1;
this.loadingUsers = true;
this.mockFetchUsersApi(searchTerm, 1, 10).then((res) => {
this.serverUsers = res.items;
this.hasMoreUsers = res.hasMore;
this.totalServerMatches = res.total;
this.loadingUsers = false;
});
}
onServerScrollToEnd(): void {
if (this.loadingMoreUsers || !this.hasMoreUsers) {
return;
}
this.loadingMoreUsers = true;
const nextPage = this.serverCurrentPage + 1;
this.mockFetchUsersApi(this.serverSearchTerm, nextPage, 10).then((res) => {
this.serverCurrentPage = nextPage;
this.serverUsers = [...this.serverUsers, ...res.items];
this.hasMoreUsers = res.hasMore;
this.totalServerMatches = res.total;
this.loadingMoreUsers = false;
});
}
/** Simulated Backend API call with 350ms network delay and pagination */
private mockFetchUsersApi(
term: string,
page: number,
pageSize: number
): Promise<{ items: DropdownOption[]; hasMore: boolean; total: number }> {
return new Promise((resolve) => {
setTimeout(() => {
const query = (term || '').trim().toLowerCase();
const filtered = this.mockUsersDb.filter((u) => {
if (!query) return true;
return (
u.name.toLowerCase().includes(query) ||
u.role.toLowerCase().includes(query) ||
u.department.toLowerCase().includes(query)
);
});
const startIndex = (page - 1) * pageSize;
const pageRecords = filtered.slice(startIndex, startIndex + pageSize);
const options: DropdownOption[] = pageRecords.map((u) => ({
label: u.name,
value: u.id,
description: `${u.role} · ${u.department}`,
}));
resolve({
items: options,
hasMore: startIndex + pageSize < filtered.length,
total: filtered.length,
});
}, 350);
});
}
readonly singleSnippet = `<app-dropdown
label="Country"
type="single"
[items]="countries"
placeholder="Select a country"
[(ngModel)]="singleValue">
</app-dropdown>`;
readonly multiSnippet = `<app-dropdown
label="Skills"
type="multiple"
[items]="skills"
placeholder="Select skills"
[(ngModel)]="multiValue">
</app-dropdown>`;
readonly tagsSnippet = `<app-dropdown
label="Add tags"
type="tags"
[items]="skills"
placeholder="Type to add a tag"
[(ngModel)]="tagsValue">
</app-dropdown>`;
readonly groupedSnippet = `<app-dropdown
label="Fruit"
type="grouped"
[items]="fruitGroups"
groupBy="group"
placeholder="Select a fruit"
[(ngModel)]="groupedValue">
</app-dropdown>`;
readonly descriptionSnippet = `<app-dropdown
label="Subscription Plan"
type="single"
[items]="planOptions"
placeholder="Select a subscription plan"
[(ngModel)]="planValue">
</app-dropdown>`;
readonly serverSearchSnippet = `<app-dropdown
label="Assign Team Member"
type="single"
[items]="serverUsers"
[loading]="loadingUsers"
[loadingMore]="loadingMoreUsers"
[hasMore]="hasMoreUsers"
[showDrawerSearch]="true"
[serverSearch]="true"
drawerSearchPlaceholder="Search by name, role, or department..."
placeholder="Search & select a user from API"
[(ngModel)]="selectedServerUserId"
(searchChange)="onServerSearch($event)"
(scrollToEnd)="onServerScrollToEnd()">
</app-dropdown>`;
readonly reactiveFormSnippet = `<app-dropdown
label="Country (Reactive Form)"
type="single"
[items]="countries"
[formControl]="countryControl"
errorMessage="This field is required">
</app-dropdown>`;
}
@@ -0,0 +1,2 @@
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Doloribus velit magni quos. Animi nemo quam obcaecati? Cupiditate dolore sed id magni, facilis optio fugit mollitia minima recusandae placeat, quod molestiae officiis est ullam explicabo illo impedit consequuntur accusantium in aut facere neque consequatur ipsa! Quia ut maxime modi a in ullam pariatur incidunt, rerum expedita sit eius ipsum! Cum eum nesciunt pariatur eius. Amet magni, facilis explicabo cupiditate reiciendis quae a, natus aspernatur alias aperiam quibusdam molestiae laboriosam quaerat dolores asperiores! Excepturi, ipsa dolor numquam possimus perspiciatis animi asperiores quam vero, consectetur nesciunt quas ad facilis deserunt minima neque nemo?
Whitespace-only changes.
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DummypageComponent } from './dummypage.component';
describe('DummypageComponent', () => {
let component: DummypageComponent;
let fixture: ComponentFixture<DummypageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DummypageComponent]
})
.compileComponents();
fixture = TestBed.createComponent(DummypageComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-dummypage',
imports: [],
templateUrl: './dummypage.component.html',
styleUrl: './dummypage.component.scss',
})
export class DummypageComponent {
}
@@ -0,0 +1,214 @@
<div class="mx-auto max-w-6xl space-y-8 px-4 py-8">
<!-- ══ PAGE HEADER ═════════════════════════════════════════════════════ -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight text-[var(--clr-text)]">Form Management Directory</h1>
<p class="mt-1 text-sm text-[var(--clr-text-muted)]">
Create and manage team records via ABP Modal forms with dropdowns, inputs, radio selections, and card displays.
</p>
</div>
<button
type="button"
(click)="openCreateModal()"
class="inline-flex items-center justify-center gap-2 rounded-xl bg-[var(--clr-primary)] px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-[var(--clr-primary-hover)] transition-all">
<i class="fas fa-plus"></i>
<span>New Member</span>
</button>
</div>
<!-- ══ FILTERS & SEARCH BAR ═══════════════════════════════════════════ -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-4 shadow-sm">
<!-- Search Input -->
<div class="relative flex-1 max-w-md">
<i class="fas fa-search absolute left-3.5 top-1/2 -translate-y-1/2 text-xs text-[var(--clr-text-subtle)]"></i>
<input
type="text"
[(ngModel)]="searchQuery"
placeholder="Search by name, role, department, or bio..."
class="w-full rounded-lg border border-[var(--clr-border)] bg-[var(--clr-input-bg)] py-2 pl-9 pr-4 text-sm text-[var(--clr-input-text)] placeholder-[var(--clr-input-placeholder)] focus:border-[var(--clr-primary)] focus:outline-none transition-colors"
/>
</div>
<!-- Status Tabs -->
<div class="flex items-center gap-1.5 rounded-lg border border-[var(--clr-border)] bg-[var(--clr-bg-subtle)] p-1">
<button
type="button"
(click)="filterStatus = 'all'"
[class.bg-[var(--clr-bg-card)]]="filterStatus === 'all'"
[class.text-[var(--clr-primary)]]="filterStatus === 'all'"
[class.shadow-sm]="filterStatus === 'all'"
class="rounded-md px-3 py-1.5 text-xs font-semibold text-[var(--clr-text-muted)] transition-all">
All ({{ totalCount }})
</button>
<button
type="button"
(click)="filterStatus = 'active'"
[class.bg-[var(--clr-bg-card)]]="filterStatus === 'active'"
[class.text-[var(--clr-success)]]="filterStatus === 'active'"
[class.shadow-sm]="filterStatus === 'active'"
class="rounded-md px-3 py-1.5 text-xs font-semibold text-[var(--clr-text-muted)] transition-all">
Active ({{ activeCount }})
</button>
<button
type="button"
(click)="filterStatus = 'inactive'"
[class.bg-[var(--clr-bg-card)]]="filterStatus === 'inactive'"
[class.text-[var(--clr-danger)]]="filterStatus === 'inactive'"
[class.shadow-sm]="filterStatus === 'inactive'"
class="rounded-md px-3 py-1.5 text-xs font-semibold text-[var(--clr-text-muted)] transition-all">
Inactive ({{ totalCount - activeCount }})
</button>
</div>
</div>
<!-- ══ CARD GRID FORM VIEW ═════════════════════════════════════════════ -->
<div *ngIf="filteredMembers.length > 0; else emptyState" class="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
<div
*ngFor="let member of filteredMembers"
class="flex flex-col justify-between rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-5 shadow-sm hover:shadow-md transition-all hover:border-[var(--clr-primary-lt)]">
<!-- Card Top -->
<div>
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[var(--clr-primary-lt)] text-[var(--clr-primary)] font-bold text-base">
{{ member.name.charAt(0) }}
</div>
<div>
<h3 class="text-base font-semibold leading-tight text-[var(--clr-text)]">{{ member.name }}</h3>
<p class="text-xs font-medium text-[var(--clr-primary)] mt-0.5">{{ member.role }}</p>
</div>
</div>
<!-- Status Badge -->
<span
[class.bg-[var(--clr-success-lt)]]="member.isActive"
[class.text-[var(--clr-success)]]="member.isActive"
[class.bg-[var(--clr-danger-lt)]]="!member.isActive"
[class.text-[var(--clr-danger)]]="!member.isActive"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold">
<span class="h-1.5 w-1.5 rounded-full" [class.bg-[var(--clr-success)]]="member.isActive" [class.bg-[var(--clr-danger)]]="!member.isActive"></span>
{{ member.isActive ? 'Active' : 'Inactive' }}
</span>
</div>
<!-- Meta info -->
<div class="mt-4 space-y-2 border-t border-[var(--clr-border)] pt-3.5">
<div class="flex items-center text-xs text-[var(--clr-text-muted)]">
<i class="fas fa-building mr-2 w-4 text-[var(--clr-text-subtle)]"></i>
<span class="font-medium text-[var(--clr-text)]">{{ member.department }}</span>
</div>
<div class="flex items-center text-xs text-[var(--clr-text-muted)]">
<i class="fas fa-shield-alt mr-2 w-4 text-[var(--clr-text-subtle)]"></i>
<span class="capitalize font-medium text-[var(--clr-text)]">{{ member.accountType }} Access</span>
</div>
</div>
<!-- Description -->
<p class="mt-3 text-xs leading-relaxed text-[var(--clr-text-muted)] line-clamp-3">
{{ member.description }}
</p>
</div>
<!-- Card Actions -->
<div class="mt-5 flex items-center justify-between border-t border-[var(--clr-border)] pt-3">
<button
type="button"
(click)="toggleMemberStatus(member)"
class="text-xs font-medium text-[var(--clr-text-muted)] hover:text-[var(--clr-text)] transition-colors">
<i class="fas fa-power-off mr-1" [class.text-[var(--clr-success)]]="!member.isActive" [class.text-[var(--clr-danger)]]="member.isActive"></i>
{{ member.isActive ? 'Deactivate' : 'Activate' }}
</button>
<div class="flex items-center gap-2">
<button
type="button"
(click)="openEditModal(member)"
class="rounded-lg p-1.5 text-xs text-[var(--clr-text-muted)] hover:bg-[var(--clr-bg-hover)] hover:text-[var(--clr-primary)] transition-colors"
title="Edit Member">
<i class="fas fa-edit"></i>
</button>
<button
type="button"
(click)="deleteMember(member.id)"
class="rounded-lg p-1.5 text-xs text-[var(--clr-text-muted)] hover:bg-[var(--clr-danger-lt)] hover:text-[var(--clr-danger)] transition-colors"
title="Delete Member">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<ng-template #emptyState>
<div class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-12 text-center">
<div class="flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--clr-bg-hover)] text-xl text-[var(--clr-text-subtle)] mb-4">
<i class="fas fa-users-slash"></i>
</div>
<h3 class="text-base font-semibold text-[var(--clr-text)]">No records found</h3>
<p class="mt-1 text-xs text-[var(--clr-text-muted)] max-w-sm">
No team members match your filter query. Click below to add a new member.
</p>
<button
type="button"
(click)="openCreateModal()"
class="mt-4 inline-flex items-center gap-2 rounded-xl bg-[var(--clr-primary)] px-4 py-2 text-xs font-semibold text-white hover:bg-[var(--clr-primary-hover)] transition-all">
<i class="fas fa-plus"></i>
<span>Create Member</span>
</button>
</div>
</ng-template>
<!-- ══ ABP MODAL DIALOG WITH DYNAMIC FORM (FormlyFieldConfig) ══ -->
<abp-modal
[(visible)]="isModalOpen"
[busy]="isSaving"
[options]="{ size: 'lg', centered: true }">
<!-- Modal Header -->
<ng-template #abpHeader>
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--clr-primary-lt)] text-[var(--clr-primary)]">
<i class="fas fa-user-gear text-sm"></i>
</div>
<div>
<h3 class="m-0 text-base font-semibold text-[var(--clr-text)]">
{{ editingId ? 'Edit Team Member' : 'Create New Member' }}
</h3>
<p class="text-xs text-[var(--clr-text-muted)] mt-0.5">
Configured via <code class="font-mono text-xs text-[var(--clr-primary)]">FormlyFieldConfig[]</code> with Formly & ABP Theme Shared
</p>
</div>
</div>
</ng-template>
<!-- Modal Body with Dynamic Form -->
<ng-template #abpBody>
<app-dynamic-form
[form]="form"
[fields]="fields"
[model]="model"
[showActions]="false"
(formSubmit)="onFormSubmit($event)">
</app-dynamic-form>
</ng-template>
<!-- Modal Footer with abpClose -->
<ng-template #abpFooter>
<button
type="button"
abpClose
class="rounded-lg border border-[var(--clr-border)] bg-[var(--clr-bg-card)] px-4 py-2 text-xs font-semibold text-[var(--clr-text)] hover:bg-[var(--clr-bg-hover)] transition-colors">
Cancel
</button>
<button
type="button"
(click)="submitDynamicForm()"
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--clr-primary)] px-4 py-2 text-xs font-semibold text-white hover:bg-[var(--clr-primary-hover)] transition-colors">
<i class="fas fa-check"></i>
<span>{{ editingId ? 'Save Changes' : 'Create Record' }}</span>
</button>
</ng-template>
</abp-modal>
</div>
@@ -0,0 +1,6 @@
@reference "tailwindcss";
:host {
display: block;
min-height: 100%;
}
@@ -0,0 +1,317 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { FormlyFieldConfig } from '@ngx-formly/core';
import {
ModalComponent,
ModalCloseDirective,
} from '@abp/ng.theme.shared';
import { DynamicFormComponent } from '../components/dynamic-form/dynamic-form.component';
import { DropdownOption } from '../components/dropdown-component/dropdown-component.component';
export interface FormRecord {
id: string;
name: string;
role: string;
department: string;
email?: string;
description: string;
password?: string;
accountType: 'admin' | 'member' | 'viewer';
isActive: boolean;
createdAt: Date;
[key: string]: any;
}
/**
* Form Management Component
* Dynamically driven via FormlyFieldConfig from @ngx-formly/core.
* Opens an ABP Modal with the dynamic form and renders submissions
* as responsive theme cards.
*/
@Component({
selector: 'app-form-management',
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
ModalComponent,
ModalCloseDirective,
DynamicFormComponent,
],
templateUrl: './form-management.component.html',
styleUrl: './form-management.component.scss',
})
export class FormManagementComponent implements OnInit {
isModalOpen = false;
isSaving = false;
editingId: string | null = null;
searchQuery = '';
filterStatus: 'all' | 'active' | 'inactive' = 'all';
// Dynamic Formly State
form = new FormGroup({});
model: Record<string, any> = {};
fields: FormlyFieldConfig[] = [];
readonly roleOptions: DropdownOption[] = [
{ label: 'Full-Stack Developer', value: 'Full-Stack Developer', description: 'Angular, .NET, PostgreSQL' },
{ label: 'UI/UX Designer', value: 'UI/UX Designer', description: 'Figma, Tailwind, Design Systems' },
{ label: 'Engineering Manager', value: 'Engineering Manager', description: 'Sprint planning & leadership' },
{ label: 'DevOps Architect', value: 'DevOps Architect', description: 'Kubernetes, CI/CD, Cloud' },
{ label: 'Product Owner', value: 'Product Owner', description: 'Roadmaps & user feedback' },
{ label: 'QA Automation Lead', value: 'QA Automation Lead', description: 'Playwright, Jest, Cypress' },
];
readonly departmentOptions: DropdownOption[] = [
{ label: 'Core Engineering', value: 'Core Engineering' },
{ label: 'Cloud Infrastructure', value: 'Cloud Infrastructure' },
{ label: 'Product & Design', value: 'Product & Design' },
{ label: 'Cybersecurity', value: 'Cybersecurity' },
{ label: 'Data & Analytics', value: 'Data & Analytics' },
];
members: FormRecord[] = [
{
id: '1',
name: 'Asha Verma',
email: 'asha.verma@example.com',
role: 'Full-Stack Developer',
department: 'Core Engineering',
description: 'Lead engineer overseeing ABP basic theme modern dashboard and reusable component architecture.',
password: '••••••••',
accountType: 'admin',
isActive: true,
createdAt: new Date('2026-02-15'),
},
{
id: '2',
name: 'Rohit Sharma',
email: 'rohit.sharma@example.com',
role: 'UI/UX Designer',
department: 'Product & Design',
description: 'Designed the DelightERM design system tokens, responsive card layouts, and dark/light color palette.',
password: '••••••••',
accountType: 'member',
isActive: true,
createdAt: new Date('2026-03-01'),
},
{
id: '3',
name: 'Meera Nair',
email: 'meera.nair@example.com',
role: 'Engineering Manager',
department: 'Core Engineering',
description: 'Coordinates cross-functional architecture and sprint deliveries across platform tenant services.',
password: '••••••••',
accountType: 'admin',
isActive: false,
createdAt: new Date('2026-01-20'),
},
];
ngOnInit(): void {
this.buildFormlyFields();
}
buildFormlyFields(): void {
this.fields = [
{
fieldGroupClassName: 'grid grid-cols-1 sm:grid-cols-2 gap-4',
fieldGroup: [
{
key: 'name',
type: 'input',
props: {
label: 'Full Name',
placeholder: 'e.g. Rahul Sharma',
required: true,
minLength: 2,
},
},
{
key: 'email',
type: 'input',
props: {
label: 'Email Address',
type: 'email',
placeholder: 'rahul@example.com',
required: true,
},
},
],
},
{
fieldGroupClassName: 'grid grid-cols-1 sm:grid-cols-2 gap-4',
fieldGroup: [
{
key: 'role',
type: 'dropdown',
props: {
label: 'Role & Designation',
placeholder: 'Select a role...',
required: true,
items: this.roleOptions,
},
},
{
key: 'department',
type: 'dropdown',
props: {
label: 'Department',
placeholder: 'Select department...',
required: true,
showDrawerSearch: true,
drawerSearchPlaceholder: 'Search department...',
items: this.departmentOptions,
},
},
],
},
{
key: 'password',
type: 'input',
props: {
label: 'Access Password',
type: 'password',
placeholder: '••••••••••••',
required: true,
minLength: 6,
},
},
{
key: 'accountType',
type: 'radio',
props: {
label: 'Account Access Level',
required: true,
options: [
{ label: 'Admin (Full permissions)', value: 'admin' },
{ label: 'Member (Standard edit access)', value: 'member' },
{ label: 'Viewer (Read-only access)', value: 'viewer' },
],
},
},
{
key: 'description',
type: 'textarea',
props: {
label: 'Description / Responsibilities',
placeholder: 'Brief bio, responsibilities, or technical skills...',
rows: 3,
required: true,
maxLength: 300,
},
},
{
key: 'isActive',
type: 'checkbox',
props: {
label: 'Active Member (Enable account for platform access)',
},
},
];
}
get filteredMembers(): FormRecord[] {
return this.members.filter((member) => {
const matchesSearch =
!this.searchQuery.trim() ||
member.name.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
member.role?.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
member.department?.toLowerCase().includes(this.searchQuery.toLowerCase()) ||
member.description?.toLowerCase().includes(this.searchQuery.toLowerCase());
const matchesStatus =
this.filterStatus === 'all' ||
(this.filterStatus === 'active' && member.isActive) ||
(this.filterStatus === 'inactive' && !member.isActive);
return matchesSearch && matchesStatus;
});
}
get activeCount(): number {
return this.members.filter((m) => m.isActive).length;
}
get totalCount(): number {
return this.members.length;
}
openCreateModal(): void {
this.editingId = null;
this.model = {
name: '',
email: '',
role: null,
department: null,
description: '',
password: '',
accountType: 'member',
isActive: true,
};
this.form = new FormGroup({});
this.isModalOpen = true;
}
openEditModal(member: FormRecord): void {
this.editingId = member.id;
this.model = {
name: member.name,
email: member.email || '',
role: member.role,
department: member.department,
description: member.description,
password: member.password || '••••••••',
accountType: member.accountType,
isActive: member.isActive,
};
this.form = new FormGroup({});
this.isModalOpen = true;
}
toggleMemberStatus(member: FormRecord): void {
member.isActive = !member.isActive;
}
deleteMember(id: string): void {
this.members = this.members.filter((m) => m.id !== id);
}
submitDynamicForm(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.onFormSubmit(this.model);
}
onFormSubmit(formData: any): void {
this.isSaving = true;
setTimeout(() => {
if (this.editingId) {
const index = this.members.findIndex((m) => m.id === this.editingId);
if (index !== -1) {
this.members[index] = {
...this.members[index],
...formData,
};
}
} else {
const newRecord: FormRecord = {
id: String(Date.now()),
...formData,
createdAt: new Date(),
};
this.members.unshift(newRecord);
}
this.isSaving = false;
this.isModalOpen = false;
}, 400);
}
}
@@ -0,0 +1,32 @@
<app-dropdown-component
[formControl]="$any(formControl)"
[label]="props.label || ''"
[type]="props.dropdownType || 'single'"
[items]="props.items || props.options || []"
[bindLabel]="props.bindLabel || 'label'"
[bindValue]="props.bindValue || 'value'"
[bindDescription]="props.bindDescription || 'description'"
[groupBy]="props.groupBy || 'group'"
[placeholder]="props.placeholder || ''"
[hint]="props.hint || ''"
[required]="!!props.required"
[disabled]="!!props.disabled"
[clearable]="props.clearable ?? true"
[loading]="!!props.loading"
[loadingMore]="!!props.loadingMore"
[hasMore]="props.hasMore ?? true"
[showDrawerSearch]="!!props.showDrawerSearch"
[drawerSearchPlaceholder]="props.drawerSearchPlaceholder || 'Type to search...'"
[serverSearch]="!!props.serverSearch"
[searchable]="props.searchable ?? true"
[clearSearchOnAdd]="props.clearSearchOnAdd ?? true"
[hideSelected]="!!props.hideSelected"
[closeOnSelect]="props.closeOnSelect"
[appendTo]="$any(props.appendTo)"
[dropdownPosition]="props.dropdownPosition || 'auto'"
[virtualScroll]="!!props.virtualScroll"
[searchDebounce]="props.searchDebounce || 300"
[errorMessage]="showError ? (props.errorMessage || (props.label || 'This field') + ' is required') : (props.errorMessage || '')"
(searchChange)="props.onSearchChange ? props.onSearchChange($event) : null"
(scrollToEnd)="props.onScrollToEnd ? props.onScrollToEnd() : null"
></app-dropdown-component>
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormlyFieldDropdownComponent } from './formly-field-dropdown.component';
describe('FormlyFieldDropdownComponent', () => {
let component: FormlyFieldDropdownComponent;
let fixture: ComponentFixture<FormlyFieldDropdownComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FormlyFieldDropdownComponent]
})
.compileComponents();
fixture = TestBed.createComponent(FormlyFieldDropdownComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,68 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms';
import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
import { DropdownComponentComponent, DropdownType } from '../components/dropdown-component/dropdown-component.component';
/**
* Extra config accepted via `props` on a Formly field of type 'dropdown'.
* Mirrors app-dropdown's own @Input()s so field configs read naturally.
*/
/**
* Extra config accepted via `props` on a Formly field of type 'dropdown'.
* Mirrors app-dropdown's own @Input()s so field configs read naturally.
*/
export interface DropdownFieldProps {
label?: string;
placeholder?: string;
hint?: string;
required?: boolean;
disabled?: boolean;
items?: any[];
options?: any[];
dropdownType?: DropdownType;
bindLabel?: string;
bindValue?: string;
bindDescription?: string;
groupBy?: string;
clearable?: boolean;
loading?: boolean;
loadingMore?: boolean;
hasMore?: boolean;
showDrawerSearch?: boolean;
drawerSearchPlaceholder?: string;
serverSearch?: boolean;
searchable?: boolean;
clearSearchOnAdd?: boolean;
hideSelected?: boolean;
closeOnSelect?: boolean;
appendTo?: string;
dropdownPosition?: 'bottom' | 'top' | 'auto';
virtualScroll?: boolean;
errorMessage?: string;
searchDebounce?: number;
onSearchChange?: (term: string) => void;
onScrollToEnd?: () => void;
}
/**
* Registers app-dropdown as a Formly field type named 'dropdown'.
* This file does not modify DropdownComponent — it only wires it into Formly
* via [formControl], which DropdownComponent already supports (it implements
* ControlValueAccessor).
*
* Register once in app.config.ts:
* provideFormlyCore(withFormlyBootstrap(), { types: [{ name: 'dropdown', component: FormlyFieldDropdownComponent }] })
*
* Then use it in any field config:
* { key: 'teamMemberId', type: 'dropdown', props: { label: 'Assign Team Member', items: [...] } }
*/
@Component({
selector: 'app-formly-field-dropdown',
imports: [CommonModule, ReactiveFormsModule, DropdownComponentComponent],
templateUrl: './formly-field-dropdown.component.html',
styleUrl: './formly-field-dropdown.component.scss',
})
export class FormlyFieldDropdownComponent extends FieldType<FieldTypeConfig<DropdownFieldProps>> {
}
+281
View File
@@ -0,0 +1,281 @@
<div class="row mb-3">
<div class="col-xl-6 col-12 d-flex">
<div class="card h-lg-100 w-100 overflow-hidden">
<div class="card-body">
<div class="starting-content">
@if(!hasLoggedIn){
<h3>Welcome</h3>
<p class="lead text-muted mb-2">
{{ '::LongWelcomeMessage' | abpLocalization }}
</p>
<a (click)="login()" class="px-4 btn btn-primary mb-4" role="button"><i class="fa fa-sign-in"></i> {{
'AbpAccount::Login' | abpLocalization }}</a>
}
<h3>Getting Started</h3>
<ng-container *ngTemplateOutlet="
startingContentTemplate;
context: {
$implicit: {
description:
'Learn how to create and run a new web application using the application startup template.',
href: 'https://abp.io/docs/latest/get-started',
label: 'Getting Started'
}
}
"></ng-container>
<h4>Web Application Development Tutorial</h4>
<ng-container *ngTemplateOutlet="
startingContentTemplate;
context: {
$implicit: {
description:
'Learn how to build an ABP based web application named Acme.BookStore.',
href: 'https://abp.io/docs/latest/tutorials/book-store/part-01?UI=NG&DB=EF',
label: 'Explore Tutorial'
}
}
"></ng-container>
<h4>Customize Lepton Theme</h4>
<p class="text-muted mb-2">Learn how to customize LeptonX Theme as you wish.</p>
<a href="https://abp.io/docs/latest/framework/ui/angular/theming" class="btn btn-primary soft mb-5 mb-xl-0"
target="_blank">Customize Lepton</a>
</div>
<img class="card-bg-image" src="assets/images/getting-started/bg-01.png" />
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 d-flex">
<div class="row">
<ng-container *ngTemplateOutlet="
visitLinkTemplate;
context: {
$implicit: {
label: 'Visit Support',
description:
'You can check for similar problems and solutions, or open a new topic to discuss your specific issue.',
href: 'https://abp.io/support/questions',
card: 'card overflow-hidden',
imgClass: '',
imgSrc: 'assets/images/getting-started/img-support.png',
logo: 'abp-support abp-logo mb-2'
}
}
"></ng-container>
<ng-container *ngTemplateOutlet="
visitLinkTemplate;
context: {
$implicit: {
label: 'Visit Blog',
description:
'You can find content on .NET development, cross-platform, ASP.NET application templates, ABP-related news, and more.',
href: 'https://abp.io/blog',
card: 'card h-md-100 overflow-hidden',
imgClass: 'margin-bottom: -24px',
imgSrc: 'assets/images/getting-started/img-blog.png',
logo: 'abp-blog abp-logo mb-2'
}
}
"></ng-container>
</div>
</div>
<div class="col-xl-3 col-md-6 d-flex">
<div class="card h-100 overflow-hidden">
<div class="card-body">
<div class="abp-community abp-logo mb-2"></div>
<p class="lead text-muted">
A unique community platform for
<span class="fw-bold">ABP Lovers!</span>
</p>
<p class="text-muted mb-2">
Explore all ABP users' experiences with the ABP Framework, discover articles and videos on
how to use ABP, and join raffles for a chance to win surprise gifts!
</p>
<a class="btn btn-brand soft mb-3" href="https://abp.io/community/" target="_blank">Join ABP Community</a>
</div>
<img class="mt-3" src="assets/images/getting-started/img-community.png" />
</div>
</div>
</div>
<div class="row">
<ng-container *ngTemplateOutlet="
tableTemplate;
context: {
$implicit: {
title: 'More from ABP.IO',
items: [
{
iconType: 'icon',
style: true,
icon: 'bi bi-check fs-4',
iconClass: 'rounded-circle me-2 flex-center bg-brand soft',
title: 'Latest Release Logs',
link: 'https://github.com/abpframework/abp/releases',
},
{
iconType: 'icon',
style: true,
icon: 'bi bi-check fs-4',
iconClass: 'rounded-circle me-2 flex-center bg-brand soft',
title: 'Video Courses',
link: 'https://abp.io/video-courses/essentials',
},
{
iconType: 'icon',
style: true,
icon: 'bi bi-check fs-4',
iconClass: 'rounded-circle me-2 flex-center bg-brand soft',
title: 'Samples',
link: 'https://abp.io/docs/latest/Samples/Index',
},
{
iconType: 'icon',
style: true,
icon: 'bi bi-check fs-4',
iconClass: 'rounded-circle me-2 flex-center bg-brand soft',
title: 'Books',
link: 'https://abp.io/books',
},
{
iconType: 'icon',
style: true,
icon: 'bi bi-check fs-4',
iconClass: 'rounded-circle me-2 flex-center bg-brand soft',
title: 'FAQ',
link: 'https://abp.io/faq',
},
]
}
}
"></ng-container>
<div class="col-xl-6 col-lg-8 d-flex">
<div class="card">
<div class="card-body d-flex">
<div class="row align-items-center">
<div class="col-md-8">
<p class="text-muted mb-0">THE OFFICIAL GUIDE</p>
<h3>Mastering ABP Framework</h3>
<p class="text-muted">
Written by the creator of the ABP Framework, this book will help you gain a complete
understanding of the framework and modern web application development techniques.
</p>
<div class="d-md-flex mb-2 mb-md-0">
<a href="https://www.amazon.com/gp/product/B097Z2DM8Q" target="_blank"
class="btn btn-primary soft me-md-2 mb-2 mb-md-0">
Buy on Amazon US
</a>
<a href="https://www.packtpub.com/product/mastering-abp-framework/9781801079242" target="_blank"
class="btn btn-primary soft mb-2 mb-md-0">
Buy on PACKT
</a>
</div>
</div>
<div class="col">
<img src="assets/images/getting-started/book.png" />
</div>
</div>
</div>
</div>
</div>
<ng-container *ngTemplateOutlet="
tableTemplate;
context: {
$implicit: {
title: 'Follow us on Social Media',
items: [
{
title: 'X.com',
icon: 'logo-x me-2',
link: 'https://twitter.com/abpframework'
},
{
iconType: 'image',
icon: 'assets/images/getting-started/discord.svg',
title: 'Discord',
link: 'https://abp.io/community/discord'
},
{
iconType: 'image',
icon: 'assets/images/getting-started/stack-overflow.svg',
title: 'Stack Overflow',
link: 'https://stackoverflow.com/questions/tagged/abp'
},
{
iconType: 'image',
icon: 'assets/images/getting-started/youtube.svg',
title: 'YouTube',
link: 'https://www.youtube.com/@Volosoft'
},
{
iconType: 'image',
icon: 'assets/images/getting-started/instagram.svg',
title: 'Instagram',
link: 'https://www.instagram.com/abpframework/'
}
]
}
}
"></ng-container>
</div>
<ng-template #startingContentTemplate let-context>
<p class="lead text-muted mb-2" [innerHTML]="context.description"></p>
<a [href]="context.href" class="btn btn-brand mb-4" target="_blank">{{ context.label }}</a>
</ng-template>
<ng-template #visitLinkTemplate let-context>
<div class="col-12 d-flex">
<div [class]="context.card">
<div class="card-body pb-0">
<div [class]="context.logo"></div>
<p class="text-muted mb-2" [innerHTML]="context.description"></p>
<a [href]="context.href" class="btn btn-brand soft" target="_blank">{{ context.label }}</a>
<img [class]="context.imgClass" [src]="context.imgSrc" />
</div>
</div>
</div>
</ng-template>
<ng-template #tableTemplate let-context>
<div class="col-xl-3 col-lg-4">
<div class="card">
<div class="card-header">
<div class="card-title text-body">{{ context.title }}</div>
</div>
<div class="card-body pt-0">
<div class="table-responsive">
<table class="table mb-0">
<tbody>
@for(item of context.items; track $index; let last = $last){
<tr>
<td class="ps-0 d-flex" [class.border-bottom-0]="last">
@if(item.icon){
<div [class]="item.iconClass" [style]="item.style ? 'width: 20px; height: 20px' : ''">
@if(item.iconType === 'image'){
<img [src]="item.icon" class="me-2" />
}@else if(item.iconType === 'icon'){
<i [class]="item.icon"></i>
}@else{
<div [class]="item.icon"></div>
}
</div>
}
<div>{{ item.title }}</div>
</td>
<td class="text-end pe-0 opacity-25" [class.border-bottom-0]="last">
<a [href]="item.link" target="_blank">
<i class="bi bi-box-arrow-up-right text-dark"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</ng-template>
+25
View File
@@ -0,0 +1,25 @@
/* Styles for the home component */
.card-bg-image {
position: absolute;
width: 630px;
height: auto;
bottom: 0;
right: 0;
z-index: 1;
}
.starting-content {
position: relative;
z-index: 2;
}
@media (min-width: 1199px) {
.h-lg-100 {
height: 100% !important;
}
}
@media (min-width: 768px) {
.h-md-100 {
height: 100% !important;
}
}
@@ -0,0 +1,88 @@
import { CoreTestingModule } from "@abp/ng.core/testing";
import { ThemeSharedTestingModule } from "@abp/ng.theme.shared/testing";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NgxValidateCoreModule } from "@ngx-validate/core";
import { HomeComponent } from "./home.component";
import { AuthService } from '@abp/ng.core';
import { vi } from 'vitest';
describe("HomeComponent", () => {
let fixture: ComponentFixture<HomeComponent>;
let mockAuthService: { isAuthenticated: boolean; navigateToLogin: ReturnType<typeof vi.fn> };
beforeEach(async () => {
mockAuthService = {
isAuthenticated: false,
navigateToLogin: vi.fn()
};
await TestBed.configureTestingModule({
imports: [
CoreTestingModule.withConfig(),
ThemeSharedTestingModule.withConfig(),
NgxValidateCoreModule,
HomeComponent
],
providers: [
{
provide: AuthService,
useValue: mockAuthService
}
],
}).compileComponents();
});
it("should be initiated", () => {
fixture = TestBed.createComponent(HomeComponent);
fixture.detectChanges();
expect(fixture.componentInstance).toBeTruthy();
});
describe('when login state is true', () => {
beforeEach(() => {
mockAuthService.isAuthenticated = true;
fixture = TestBed.createComponent(HomeComponent);
fixture.detectChanges();
});
it("hasLoggedIn should be true", () => {
expect(fixture.componentInstance.hasLoggedIn).toBe(true);
});
it("button should not be exists", () => {
const element = fixture.nativeElement;
const button = element.querySelector('[role="button"]');
expect(button).toBeNull();
});
});
describe('when login state is false', () => {
beforeEach(() => {
mockAuthService.isAuthenticated = false;
fixture = TestBed.createComponent(HomeComponent);
fixture.detectChanges();
});
it("hasLoggedIn should be false", () => {
expect(fixture.componentInstance.hasLoggedIn).toBe(false);
});
it("button should be exists", () => {
const element = fixture.nativeElement;
const button = element.querySelector('[role="button"]');
expect(button).toBeDefined();
});
describe('when button clicked', () => {
beforeEach(() => {
const element = fixture.nativeElement;
const button = element.querySelector('[role="button"]');
button.click();
});
it("navigateToLogin have been called", () => {
expect(mockAuthService.navigateToLogin).toHaveBeenCalled();
});
});
});
});
+92
View File
@@ -0,0 +1,92 @@
import { AuthService, LocalizationPipe } from '@abp/ng.core';
import { Component, inject } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { FormFieldConfig } from '@abp/ng.components/dynamic-form';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
imports: [NgTemplateOutlet, LocalizationPipe],
})
export class HomeComponent {
private authService = inject(AuthService);
formFields: FormFieldConfig[] = [
{
key: 'firstName',
type: 'text',
label: 'First Name',
placeholder: 'Enter first name',
value: 'erdemc',
required: true,
validators: [
{ type: 'required', message: 'First name is required' },
{ type: 'minLength', value: 2, message: 'Minimum 2 characters required' },
],
gridSize: 6,
order: 1,
},
{
key: 'lastName',
type: 'text',
label: 'Last Name',
placeholder: 'Enter last name',
required: true,
validators: [{ type: 'required', message: 'Last name is required' }],
gridSize: 12,
order: 3,
},
{
key: 'email',
type: 'email',
label: 'Email Address',
placeholder: 'Enter email',
required: true,
validators: [
{ type: 'required', message: 'Email is required' },
{ type: 'email', message: 'Please enter a valid email' },
],
gridSize: 6,
order: 2,
},
{
key: 'userType',
type: 'select',
label: 'User Type',
required: true,
options: {
defaultValues: [
{ key: 'admin', value: 'Administrator' },
{ key: 'user', value: 'Regular User' },
{ key: 'guest', value: 'Guest User' },
],
},
validators: [{ type: 'required', message: 'Please select user type' }],
order: 4,
},
{
key: 'adminNotes',
type: 'textarea',
label: 'Admin Notes',
placeholder: 'Enter admin-specific notes',
conditionalLogic: [
{
dependsOn: 'userType',
condition: 'equals',
value: 'admin',
action: 'show',
},
],
order: 5,
},
];
get hasLoggedIn(): boolean {
return this.authService.isAuthenticated;
}
login() {
this.authService.navigateToLogin();
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
export const homeRoutes: Routes = [
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./home.component').then(m => m.HomeComponent),
},
];
@@ -0,0 +1,211 @@
<div class="mx-auto max-w-4xl space-y-8 px-4 py-8">
<div>
<h1 class="text-2xl font-bold text-[var(--clr-text)]">ABP Theme Modal (<code class="font-mono text-xl text-[var(--clr-primary)]">&lt;abp-modal&gt;</code>) — Usage Guide</h1>
<p class="mt-1 text-sm text-[var(--clr-text-muted)]">
Built using official ABP Theme Shared components: <code class="rounded bg-[var(--clr-bg-hover)] px-1.5 py-0.5 text-xs text-[var(--clr-primary)] font-mono">import &#123; ModalComponent, ModalCloseDirective &#125; from '@abp/ng.theme.shared'</code>. Fully customized to match our dark/light theme layout.
</p>
</div>
<!-- 1. Basic modal -->
<section class="space-y-4 rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-6 shadow-sm">
<div>
<h2 class="text-base font-semibold text-[var(--clr-text)]">1. Basic Content Modal (<code class="font-mono text-xs">&lt;abp-modal&gt;</code>)</h2>
<p class="text-xs text-[var(--clr-text-muted)]">Standard informational dialog using <code class="font-mono">#abpHeader</code>, <code class="font-mono">#abpBody</code>, and <code class="font-mono">#abpFooter</code>.</p>
</div>
<button
type="button"
(click)="showBasicModal = true"
class="rounded-lg bg-[var(--clr-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--clr-primary-hover)] transition-colors">
Open Basic Modal
</button>
<abp-modal [(visible)]="showBasicModal" [options]="{ size: 'md', centered: true }">
<ng-template #abpHeader>
<h3 class="m-0 text-lg font-semibold text-[var(--clr-text)]">About this Platform</h3>
</ng-template>
<ng-template #abpBody>
<div class="space-y-3">
<p class="text-sm text-[var(--clr-text)]">
This dialog is rendered directly via ABP's official <code class="font-mono text-xs text-[var(--clr-primary)]">&lt;abp-modal&gt;</code> component.
</p>
<p class="text-sm text-[var(--clr-text-muted)]">
It seamlessly inherits our global CSS custom property tokens for borders, surfaces, fonts, and dark mode overrides.
</p>
</div>
</ng-template>
<ng-template #abpFooter>
<button type="button" class="rounded-lg border border-[var(--clr-border)] bg-[var(--clr-bg-card)] px-4 py-2 text-sm font-medium text-[var(--clr-text)] hover:bg-[var(--clr-bg-hover)] transition-colors" abpClose>
Close
</button>
</ng-template>
</abp-modal>
<pre class="overflow-x-auto rounded-lg bg-[var(--clr-bg-main)] border border-[var(--clr-border)] p-4 text-xs font-mono text-[var(--clr-text)]"><code>{{ basicSnippet }}</code></pre>
</section>
<!-- 2. Confirm danger dialog -->
<section class="space-y-4 rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-6 shadow-sm">
<div>
<h2 class="text-base font-semibold text-[var(--clr-text)]">2. Confirm / Destructive Dialog (<code class="font-mono text-xs text-[var(--clr-danger)]">abpClose</code>)</h2>
<p class="text-xs text-[var(--clr-text-muted)]">Compact dialog utilizing <code class="font-mono text-xs text-[var(--clr-primary)]">abpClose</code> directive on cancel button.</p>
</div>
<div class="flex items-center gap-3">
<button
type="button"
(click)="showConfirmModal = true"
class="rounded-lg border border-[var(--clr-danger)] bg-[var(--clr-danger-lt)] px-4 py-2 text-sm font-medium text-[var(--clr-danger)] hover:opacity-90 transition-opacity">
<i class="fas fa-trash-alt mr-1.5"></i> Delete Project...
</button>
<span *ngIf="confirmResult" class="text-xs font-medium text-[var(--clr-text-muted)]">
Status: <span class="font-semibold text-[var(--clr-text)]">{{ confirmResult }}</span>
</span>
</div>
<abp-modal [(visible)]="showConfirmModal" [options]="{ size: 'sm', centered: true }">
<ng-template #abpHeader>
<h3 class="m-0 text-base font-semibold text-[var(--clr-danger)]">
<i class="fas fa-trash-alt mr-2"></i> Delete Project?
</h3>
</ng-template>
<ng-template #abpBody>
<p class="text-sm text-[var(--clr-text-muted)]">
Are you sure you want to permanently delete this repository and all linked tenant configurations?
</p>
</ng-template>
<ng-template #abpFooter>
<button
type="button"
abpClose
(click)="onCancelConfirm()"
class="rounded-lg border border-[var(--clr-border)] bg-[var(--clr-bg-card)] px-4 py-2 text-sm font-medium text-[var(--clr-text)] hover:bg-[var(--clr-bg-hover)] transition-colors">
Cancel
</button>
<button
type="button"
(click)="onConfirm()"
class="rounded-lg bg-[var(--clr-danger)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 transition-opacity">
Yes, Delete
</button>
</ng-template>
</abp-modal>
<pre class="overflow-x-auto rounded-lg bg-[var(--clr-bg-main)] border border-[var(--clr-border)] p-4 text-xs font-mono text-[var(--clr-text)]"><code>{{ confirmSnippet }}</code></pre>
</section>
<!-- 3. Form Modal + Busy Indicator -->
<section class="space-y-4 rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-6 shadow-sm">
<div>
<h2 class="text-base font-semibold text-[var(--clr-text)]">3. Form Composition with Busy State (<code class="font-mono text-xs text-[var(--clr-primary)]">[busy]="isBusy"</code>)</h2>
<p class="text-xs text-[var(--clr-text-muted)]">
Full stack: <code class="font-mono text-xs text-[var(--clr-primary)]">abp-modal</code> + <code class="font-mono text-xs text-[var(--clr-primary)]">app-dynamic-form</code> + <code class="font-mono text-xs text-[var(--clr-primary)]">app-dropdown</code>.
</p>
</div>
<div class="flex items-center gap-3">
<button
type="button"
(click)="showFormModal = true"
class="rounded-lg bg-[var(--clr-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--clr-primary-hover)] transition-colors">
<i class="fas fa-user-plus mr-1.5"></i> Assign Team Member...
</button>
<span *ngIf="lastAssigned" class="text-xs font-medium text-[var(--clr-success)] bg-[var(--clr-success-lt)] px-2.5 py-1 rounded-md">
Assigned: {{ lastAssigned | json }}
</span>
</div>
<abp-modal [(visible)]="showFormModal" [busy]="isBusy" [options]="{ size: 'md', centered: true }">
<ng-template #abpHeader>
<h3 class="m-0 text-lg font-semibold text-[var(--clr-text)]">
<i class="fas fa-user-plus mr-2 text-[var(--clr-primary)]"></i> Assign Team Member
</h3>
</ng-template>
<ng-template #abpBody>
<app-dynamic-form
[form]="assignForm"
[fields]="assignFields"
[model]="assignModel"
[showActions]="false"
(formSubmit)="onAssignSubmit($event)">
</app-dynamic-form>
</ng-template>
<ng-template #abpFooter>
<button
type="button"
abpClose
class="rounded-lg border border-[var(--clr-border)] bg-[var(--clr-bg-card)] px-4 py-2 text-sm font-medium text-[var(--clr-text)] hover:bg-[var(--clr-bg-hover)] transition-colors">
Cancel
</button>
<button
type="button"
(click)="assignForm.valid ? onAssignSubmit(assignModel) : assignForm.markAllAsTouched()"
class="rounded-lg bg-[var(--clr-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--clr-primary-hover)] transition-colors">
Assign Member
</button>
</ng-template>
</abp-modal>
<pre class="overflow-x-auto rounded-lg bg-[var(--clr-bg-main)] border border-[var(--clr-border)] p-4 text-xs font-mono text-[var(--clr-text)]"><code>{{ formModalSnippet }}</code></pre>
</section>
<!-- Reference Table -->
<section class="rounded-xl border border-[var(--clr-border)] bg-[var(--clr-bg-card)] p-6 shadow-sm">
<h2 class="mb-4 text-base font-semibold text-[var(--clr-text)]">&lt;abp-modal&gt; Inputs & Directives Reference</h2>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-[var(--clr-border)] text-[var(--clr-text-muted)]">
<th class="py-2.5 pr-4 font-semibold">Binding / Directive</th>
<th class="py-2.5 pr-4 font-semibold">Type</th>
<th class="py-2.5 font-semibold">Description</th>
</tr>
</thead>
<tbody class="divide-y divide-[var(--clr-border)] text-[var(--clr-text)]">
<tr>
<td class="py-2.5 pr-4 font-mono text-xs text-[var(--clr-primary)]">[(visible)]</td>
<td class="py-2.5 pr-4 font-mono text-xs">boolean</td>
<td class="py-2.5 text-xs text-[var(--clr-text-muted)]">Two-way binding controlling modal visibility.</td>
</tr>
<tr>
<td class="py-2.5 pr-4 font-mono text-xs text-[var(--clr-primary)]">[busy]</td>
<td class="py-2.5 pr-4 font-mono text-xs">boolean</td>
<td class="py-2.5 text-xs text-[var(--clr-text-muted)]">Displays a loading overlay spinner inside the modal while processing async tasks.</td>
</tr>
<tr>
<td class="py-2.5 pr-4 font-mono text-xs text-[var(--clr-primary)]">[options]</td>
<td class="py-2.5 pr-4 font-mono text-xs">&#123; size?: 'sm' | 'md' | 'lg' | 'xl', centered?: boolean, scrollable?: boolean &#125;</td>
<td class="py-2.5 text-xs text-[var(--clr-text-muted)]">Modal configuration options.</td>
</tr>
<tr>
<td class="py-2.5 pr-4 font-mono text-xs text-[var(--clr-primary)]">#abpHeader</td>
<td class="py-2.5 pr-4 font-mono text-xs">TemplateRef</td>
<td class="py-2.5 text-xs text-[var(--clr-text-muted)]">Header template projected into the top modal bar.</td>
</tr>
<tr>
<td class="py-2.5 pr-4 font-mono text-xs text-[var(--clr-primary)]">#abpBody</td>
<td class="py-2.5 pr-4 font-mono text-xs">TemplateRef</td>
<td class="py-2.5 text-xs text-[var(--clr-text-muted)]">Main content body template.</td>
</tr>
<tr>
<td class="py-2.5 pr-4 font-mono text-xs text-[var(--clr-primary)]">#abpFooter</td>
<td class="py-2.5 pr-4 font-mono text-xs">TemplateRef</td>
<td class="py-2.5 text-xs text-[var(--clr-text-muted)]">Footer actions template.</td>
</tr>
<tr>
<td class="py-2.5 pr-4 font-mono text-xs text-[var(--clr-primary)]">abpClose</td>
<td class="py-2.5 pr-4 font-mono text-xs">Directive</td>
<td class="py-2.5 text-xs text-[var(--clr-text-muted)]">Directive attached to any button to automatically dismiss the modal on click.</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
Whitespace-only changes.
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ModalManualComponent } from './modal-manual.component';
describe('ModalManualComponent', () => {
let component: ModalManualComponent;
let fixture: ComponentFixture<ModalManualComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ModalManualComponent]
})
.compileComponents();
fixture = TestBed.createComponent(ModalManualComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,158 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormGroup } from '@angular/forms';
import { FormlyFieldConfig } from '@ngx-formly/core';
import { ModalComponent, ModalCloseDirective } from '@abp/ng.theme.shared';
import { DynamicFormComponent } from '../components/dynamic-form/dynamic-form.component';
/**
* Living usage guide for ABP Theme's official <abp-modal>.
* Demonstrates basic content modals, confirmation/danger dialogs,
* busy state indicators, and form composition with abpClose.
*/
@Component({
selector: 'app-modal-manual',
imports: [CommonModule, ModalComponent, ModalCloseDirective, DynamicFormComponent],
templateUrl: './modal-manual.component.html',
styleUrl: './modal-manual.component.scss',
})
export class ModalManualComponent {
// ---------------------------------------------------------------------
// Example 1: Basic content modal (<abp-modal>)
// ---------------------------------------------------------------------
showBasicModal = false;
// ---------------------------------------------------------------------
// Example 2: Confirm destructive dialog
// ---------------------------------------------------------------------
showConfirmModal = false;
confirmResult: 'confirmed' | 'cancelled' | null = null;
onConfirm(): void {
this.confirmResult = 'confirmed';
this.showConfirmModal = false;
}
onCancelConfirm(): void {
this.confirmResult = 'cancelled';
this.showConfirmModal = false;
}
// ---------------------------------------------------------------------
// Example 3: Success Notification Modal
// ---------------------------------------------------------------------
showSuccessModal = false;
// ---------------------------------------------------------------------
// Example 4: Form modal with ABP Modal + Busy indicator
// ---------------------------------------------------------------------
showFormModal = false;
isBusy = false;
teamMembers = [
{ label: 'Asha Verma', value: 'u1', description: 'Lead Full-Stack Engineer' },
{ label: 'Rohit Sharma', value: 'u2', description: 'Product Designer' },
{ label: 'Meera Nair', value: 'u3', description: 'Engineering Manager' },
{ label: 'Daniel Kim', value: 'u4', description: 'Cloud Architect' },
];
assignForm = new FormGroup({});
assignModel: Record<string, any> = { teamMemberId: null };
assignFields: FormlyFieldConfig[] = [
{
key: 'teamMemberId',
type: 'dropdown',
props: {
label: 'Assign Team Member',
items: this.teamMembers,
placeholder: 'Search & select a user',
required: true,
},
},
];
lastAssigned: any = null;
onAssignSubmit(value: any): void {
this.isBusy = true;
setTimeout(() => {
this.lastAssigned = value;
this.isBusy = false;
this.showFormModal = false;
}, 600);
}
// ---------------------------------------------------------------------
// Code snippets
// ---------------------------------------------------------------------
readonly basicSnippet = `import { ModalComponent, ModalCloseDirective } from '@abp/ng.theme.shared';
@Component({
imports: [ModalComponent, ModalCloseDirective],
...
})
export class MyFeatureComponent {
showBasicModal = false;
}
<!-- Template -->
<button (click)="showBasicModal = true">Open Modal</button>
<abp-modal [(visible)]="showBasicModal" [options]="{ size: 'md', centered: true }">
<ng-template #abpHeader>
<h3>About this Platform</h3>
</ng-template>
<ng-template #abpBody>
<p>Projected modal body content styled according to current theme.</p>
</ng-template>
<ng-template #abpFooter>
<button type="button" class="btn btn-secondary" abpClose>Close</button>
</ng-template>
</abp-modal>`;
readonly confirmSnippet = `<abp-modal [(visible)]="showConfirmModal" [options]="{ size: 'sm', centered: true }">
<ng-template #abpHeader>
<h3 class="text-danger"><i class="fas fa-trash-alt mr-2"></i> Delete Project</h3>
</ng-template>
<ng-template #abpBody>
<p>Are you sure you want to permanently delete this repository and all linked data?</p>
</ng-template>
<ng-template #abpFooter>
<button type="button" class="btn btn-secondary" abpClose>Cancel</button>
<button type="button" class="btn btn-danger" (click)="onConfirm()">Yes, Delete</button>
</ng-template>
</abp-modal>`;
readonly formModalSnippet = `<abp-modal [(visible)]="showFormModal" [busy]="isBusy" [options]="{ size: 'md', centered: true }">
<ng-template #abpHeader>
<h3><i class="fas fa-user-plus mr-2 text-primary"></i> Assign Team Member</h3>
</ng-template>
<ng-template #abpBody>
<app-dynamic-form
[form]="assignForm"
[fields]="assignFields"
[model]="assignModel"
[showActions]="false"
(formSubmit)="onAssignSubmit($event)">
</app-dynamic-form>
</ng-template>
<ng-template #abpFooter>
<button type="button" class="btn btn-secondary" abpClose>Cancel</button>
<button
type="button"
class="btn btn-primary"
(click)="assignForm.valid ? onAssignSubmit(assignModel) : assignForm.markAllAsTouched()">
Assign Member
</button>
</ng-template>
</abp-modal>`;
}
@@ -0,0 +1 @@
export * from './models';
@@ -0,0 +1,15 @@
export interface SearchItemDto {
id?: string;
title?: string;
routeHint?: string;
}
export interface GlobalSearchGroupDto {
groupKey: string;
label?: string;
items?: SearchItemDto[];
}
export interface GlobalSearchResultDto {
groups?: GlobalSearchGroupDto[];
}
@@ -0,0 +1 @@
export * as Dto from './dto';
@@ -0,0 +1,28 @@
import { RestService } from '@abp/ng.core';
import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { GlobalSearchResultDto } from '../erm-global-search-contract/dto/models';
@Injectable({
providedIn: 'root',
})
export class GlobalSearchService {
private restService = inject(RestService);
apiName = 'Default';
search(input: { keyword: string; maxResultsPerGroup?: number }): Observable<GlobalSearchResultDto> {
return this.restService
.request<any, GlobalSearchResultDto>(
{
method: 'GET',
url: '/api/app/global-search',
params: { keyword: input.keyword, maxResultsPerGroup: input.maxResultsPerGroup },
},
{ apiName: this.apiName }
)
.pipe(
catchError(() => of({ groups: [] }))
);
}
}
@@ -0,0 +1 @@
export * from './global-search.service';
+3
View File
@@ -0,0 +1,3 @@
export * as ErmGlobalSearchContract from './erm-global-search-contract';
export * as ErmGlobalSearch from './erm-global-search';
export * as Notifications from './notifications';
@@ -0,0 +1 @@
export * from './models';
@@ -0,0 +1,7 @@
export interface NotificationDto {
id?: string;
actionTitle?: string;
action?: string;
creationTime?: string;
isRead?: boolean;
}
@@ -0,0 +1,2 @@
export * from './notification.service';
export * as Dto from './dto';
@@ -0,0 +1,42 @@
import { RestService } from '@abp/ng.core';
import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { NotificationDto } from './dto/models';
@Injectable({
providedIn: 'root',
})
export class NotificationService {
private restService = inject(RestService);
apiName = 'Default';
getList(skipCount: number = 0, maxResultCount: number = 10): Observable<NotificationDto[]> {
return this.restService
.request<any, NotificationDto[]>(
{
method: 'GET',
url: '/api/app/notification',
params: { skipCount, maxResultCount },
},
{ apiName: this.apiName }
)
.pipe(
catchError(() => of([]))
);
}
markAsReadAllNotifications(): Observable<void> {
return this.restService
.request<any, void>(
{
method: 'POST',
url: '/api/app/notification/mark-all-as-read',
},
{ apiName: this.apiName }
)
.pipe(
catchError(() => of(undefined))
);
}
}
+59
View File
@@ -0,0 +1,59 @@
import { RoutesService, eLayoutType } from '@abp/ng.core';
import { APP_INITIALIZER } from '@angular/core';
export const APP_ROUTE_PROVIDER = [
{ provide: APP_INITIALIZER, useFactory: configureRoutes, deps: [RoutesService], multi: true },
];
function configureRoutes(routesService: RoutesService) {
return () => {
routesService.add([
{
path: '/',
name: '::Menu:Home',
iconClass: 'fas fa-home',
order: 1,
layout: eLayoutType.application,
},
{
path: undefined,
name: '::Menu:Manual',
iconClass: 'fas fa-book-open',
order: 2,
layout: eLayoutType.application,
},
{
path: '/dropdown-manual',
name: '::Menu:DropdownManual',
parentName: '::Menu:Manual',
iconClass: 'fas fa-chevron-circle-down',
order: 1,
layout: eLayoutType.application,
},
{
path: '/dynamic-form-manual',
name: '::Menu:DynamicFormManual',
parentName: '::Menu:Manual',
iconClass: 'fas fa-wpforms',
order: 2,
layout: eLayoutType.application,
},
{
path: '/modal-manual',
name: '::Menu:ModalManual',
parentName: '::Menu:Manual',
iconClass: 'fas fa-window-restore',
order: 3,
layout: eLayoutType.application,
},
{
path: '/form-management',
name: '::Menu:FormManagement',
parentName: '::Menu:Manual',
iconClass: 'fas fa-address-card',
order: 4,
layout: eLayoutType.application,
},
]);
};
}
+23
View File
@@ -0,0 +1,23 @@
import { CoreModule } from '@abp/ng.core';
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { NgModule } from '@angular/core';
import { ThemeSharedModule } from '@abp/ng.theme.shared';
import { NgxValidateCoreModule } from '@ngx-validate/core';
@NgModule({
declarations: [],
imports: [
CoreModule,
ThemeSharedModule,
NgbDropdownModule,
NgxValidateCoreModule
],
exports: [
CoreModule,
ThemeSharedModule,
NgbDropdownModule,
NgxValidateCoreModule
],
providers: []
})
export class SharedModule {}