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

+16
View File
@@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+50
View File
@@ -0,0 +1,50 @@
{
"root": true,
"ignorePatterns": [
"projects/**/*"
],
"overrides": [
{
"files": [
"*.ts"
],
"parserOptions": {
"project": [
"tsconfig.json"
],
"createDefaultProgram": true
},
"extends": [
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
]
}
},
{
"files": [
"*.html"
],
"extends": [
"plugin:@angular-eslint/template/recommended"
],
"rules": {}
}
]
}
+46
View File
@@ -0,0 +1,46 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# dependencies
/node_modules
# profiling files
chrome-profiler-events*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.angular/cache
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db
+1
View File
@@ -0,0 +1 @@
{ "plugins": { "@tailwindcss/postcss": {} } }
+5
View File
@@ -0,0 +1,5 @@
{
"singleQuote": true,
"printWidth": 100,
"arrowParens": "avoid"
}
+27
View File
@@ -0,0 +1,27 @@
# newProjectConfigure
This is a startup project based on the ABP framework. For more information, visit <a href="https://abp.io/" target="_blank">abp.io</a>
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
## Running unit tests
Run `ng test` to execute the unit tests via [Vitest](https://vitest.dev).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
+183
View File
@@ -0,0 +1,183 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"cli": {
"analytics": false,
"schematicCollections": ["@angular-eslint/schematics"]
},
"version": 1,
"newProjectRoot": "projects",
"projects": {
"newProjectConfigure": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/newProjectConfigure",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["src/polyfills.ts"],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"allowedCommonJsDependencies": ["chart.js", "js-sha256"],
"assets": ["src/favicon.ico", "src/assets"],
"styles": [
{
"input": "node_modules/bootstrap/dist/css/bootstrap.rtl.min.css",
"inject": false,
"bundleName": "bootstrap-rtl.min"
},
{
"input": "node_modules/bootstrap/dist/css/bootstrap.min.css",
"inject": true,
"bundleName": "bootstrap-ltr.min"
},
{
"input": "node_modules/@fortawesome/fontawesome-free/css/all.min.css",
"inject": true,
"bundleName": "fontawesome-all.min"
},
{
"input": "node_modules/@fortawesome/fontawesome-free/css/v4-shims.min.css",
"inject": true,
"bundleName": "fontawesome-v4-shims.min"
},
{
"input": "node_modules/@swimlane/ngx-datatable/index.css",
"inject": true,
"bundleName": "ngx-datatable-index"
},
{
"input": "node_modules/@swimlane/ngx-datatable/assets/icons.css",
"inject": true,
"bundleName": "ngx-datatable-icons"
},
{
"input": "node_modules/@swimlane/ngx-datatable/themes/material.css",
"inject": true,
"bundleName": "ngx-datatable-material"
},
{
"input": "node_modules/@ng-select/ng-select/themes/default.theme.css",
"inject": true,
"bundleName": "ng-select-default"
},
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "2.5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "25kb",
"maximumError": "35kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "newProjectConfigure:build:production"
},
"development": {
"buildTarget": "newProjectConfigure:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n",
"options": {
"buildTarget": "newProjectConfigure:build"
}
},
"test": {
"builder": "@angular/build:unit-test"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
}
}
},
"theme-basic": {
"projectType": "library",
"root": "projects/theme-basic10_1_0",
"sourceRoot": "projects/theme-basic10_1_0/src",
"prefix": "abp",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:ng-packagr",
"options": {
"tsConfig": "projects/theme-basic10_1_0/tsconfig.lib.json",
"project": "projects/theme-basic10_1_0/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "projects/theme-basic10_1_0/tsconfig.lib.prod.json"
}
}
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}
+13472
View File
File diff suppressed because it is too large. Load diff
+65
View File
@@ -0,0 +1,65 @@
{
"name": "newProjectConfigure",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve --open",
"build": "ng build",
"build:prod": "ng build --configuration production",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"lint": "ng lint"
},
"private": true,
"dependencies": {
"@abp/ng.account": "~10.6.0",
"@abp/ng.components": "~10.6.0",
"@abp/ng.core": "~10.6.0",
"@abp/ng.identity": "~10.6.0-rc.3",
"@abp/ng.oauth": "~10.6.0",
"@abp/ng.setting-management": "~10.6.0",
"@abp/ng.tenant-management": "~10.6.0",
"@abp/ng.theme.basic": "~10.6.0",
"@abp/ng.theme.shared": "~10.6.0",
"@angular/animations": "~22.0.1",
"@angular/aria": "~22.0.0",
"@angular/cdk": "~22.0.0",
"@angular/common": "~22.0.1",
"@angular/compiler": "~22.0.1",
"@angular/core": "~22.0.1",
"@angular/forms": "~22.0.1",
"@angular/localize": "~22.0.1",
"@angular/platform-browser": "~22.0.1",
"@angular/platform-browser-dynamic": "~22.0.1",
"@angular/router": "~22.0.1",
"@microsoft/signalr": "^10.0.11",
"@ng-select/ng-select": "^24.0.2",
"@ngx-formly/bootstrap": "^7.1.0",
"@ngx-formly/core": "^7.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.0.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@abp/ng.schematics": "~10.6.0",
"@angular-eslint/builder": "~21.2.0",
"@angular-eslint/eslint-plugin": "~21.2.0",
"@angular-eslint/eslint-plugin-template": "~21.2.0",
"@angular-eslint/schematics": "~21.2.0",
"@angular-eslint/template-parser": "~21.2.0",
"@angular/build": "~22.0.1",
"@angular/cli": "~22.0.1",
"@angular/compiler-cli": "~22.0.1",
"@angular/language-service": "~22.0.1",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "~20.11.0",
"@typescript-eslint/eslint-plugin": "7.16.0",
"@typescript-eslint/parser": "7.16.0",
"eslint": "^8.0.0",
"jsdom": "^27.1.0",
"postcss": "^8.5.26",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.3",
"vitest": "^4.0.0"
}
}
@@ -0,0 +1,33 @@
{
"extends": ["../../.eslintrc.json"],
"ignorePatterns": ["!**/*", "**/vitest.config.*.timestamp*"],
"overrides": [
{
"files": ["*.ts"],
"extends": ["plugin:@nx/angular", "plugin:@angular-eslint/template/process-inline-templates"],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "abp",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "abp",
"style": "kebab-case"
}
]
}
},
{
"files": ["*.html"],
"extends": ["plugin:@nx/angular-template"],
"rules": {}
}
]
}
@@ -0,0 +1,130 @@
## ℹ️ Description
ABP Framework is a complete open-source infrastructure to create modern web applications by following the best practices and conventions of software development. This package is a part of the [ABP Framework](https://abp.io) and contains client-side files.
For more information, check out the below links:
🔗Official Website: https://abp.io
🔗Commercial Website: https://commercial.abp.io
🔗Commercial Demo: https://commercial.abp.io/demo
🔗GitHub Repository: https://github.com/abpframework/abp
🔗Official Theme: https://www.LeptonTheme.com
🔗Documentation: https://docs.abp.io
🔗Community: https://community.abp.io
🔗Blog: https://blog.abp.io
🔗Books: https://abp.io/books
🔗Twitter: https://twitter.com/abpframework
🔗Discord: https://community.abp.io/discord
🔗Stackoverflow: https://stackoverflow.com/questions/tagged/abp
🔗YouTube: https://www.youtube.com/@Volosoft
## 🤔 Why ABP Platform?
Why should you use the ABP.IO Platform instead of creating a new solution from scratch?
You can find the answer here 👉🏻 [Why ABP Platform?](https://docs.abp.io/en/commercial/latest/why-abp-io-platform)
## 🚀 Key Features of the ABP Framework
🟡 Modularity
🟡 Multi-Tenancy
🟡 Bootstrap Tag Helpers
🟡 Dynamic Forms
🟡 Authentication
🟡 Authorization
🟡 Distributed Event Bus
🟡 BLOB Storing
🟡 Text Templating
🟡 Tooling: ABP CLI
🟡 Cross-Cutting Concerns
🟡 Bundling & Minification
🟡 Virtual File System
🟡 Theming
🟡 Background Jobs
🟡 DDD Infrastructure
🟡 Auto REST APIs
🟡 Dynamic Client Proxies
🟡 Multiple Database Providers
🟡 Data filtering
🟡 Test Infrastructure
🟡 Audit Logging
🟡 Object to Object Mapping
🟡 Email & SMS Abstractions
🟡 Localization
🟡 Setting Management
🟡 Extension Methods
🟡 Aspect Oriented Programming
🟡 Dependency Injection
## 🧐 How It Works?
The following page explains how you use the ABP.IO Platform as a .NET developer 👉 [How it works?](https://commercial.abp.io/how-it-works)
### 📘 Supported Database Providers
🔵 Entity Framework Core
🔵 MongoDB
🔵 Dapper
### 🎴 Supported UI Frameworks
🔵 Angular
🔵 Razor Pages
🔵 Blazor Web Assembly
🔵 Blazor Server
🔵 MAUI with Blazor Hybrid
## 📫 Bug & Support
Support for open-source ABP Framework client-side packages is available at [GitHub Issues](https://github.com/abpframework/abp/issues), and the commercial support is available at [support.abp.io](https://support.abp.io).
@@ -0,0 +1,27 @@
/* eslint-disable */
/**
* @deprecated use vitest instead of jest
* @see https://vitest.dev/guide/migration.html#jest
*/
export default {
displayName: 'theme-basic',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: {},
coverageDirectory: '../../coverage/packages/theme-basic',
transform: {
'^.+.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
@@ -0,0 +1,8 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/packages/theme-basic",
"lib": {
"entryFile": "src/public-api.ts"
},
"allowedNonPeerDependencies": ["@abp/ng.theme.shared", "@abp/ng.account.core"]
}
@@ -0,0 +1,31 @@
{
"name": "@abp/ng.theme.basic",
"version": "10.1.0",
"homepage": "https://abp.io",
"repository": {
"type": "git",
"url": "https://github.com/abpframework/abp.git"
},
"dependencies": {
"@abp/ng.account.core": "~10.1.0",
"@abp/ng.theme.shared": "~10.1.0",
"tslib": "^2.0.0"
},
"publishConfig": {
"access": "public"
},
"license": "LGPL-3.0",
"keywords": [
"aspnetcore",
"boilerplate",
"framework",
"web",
"best-practices",
"angular",
"maui",
"blazor",
"mvc",
"csharp",
"webapp"
]
}
@@ -0,0 +1,38 @@
{
"name": "theme-basic",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "packages/theme-basic/src",
"prefix": "abp",
"tags": [],
"implicitDependencies": ["core", "theme-shared", "account-core"],
"targets": {
"build": {
"executor": "@nx/angular:package",
"outputs": ["{workspaceRoot}/dist/packages/theme-basic"],
"options": {
"project": "packages/theme-basic/ng-package.json"
},
"configurations": {
"production": {
"tsConfig": "packages/theme-basic/tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "packages/theme-basic/tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"]
},
"test": {
"executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../coverage/packages/theme-basic"
}
}
}
}
@@ -0,0 +1,63 @@
<nav
class="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm flex-column flex-md-row mb-4"
id="main-navbar"
style="min-height: 4rem"
>
<div class="container">
<abp-logo *abpReplaceableTemplate="{ componentKey: service.logoComponentKey }"></abp-logo>
<button
class="navbar-toggler"
type="button"
[attr.aria-expanded]="!service.isCollapsed"
(click)="service.isCollapsed = !service.isCollapsed"
>
<span class="navbar-toggler-icon"></span>
</button>
<div
class="navbar-collapse"
[class.overflow-hidden]="service.smallScreen"
id="main-navbar-collapse"
>
<ng-container *ngTemplateOutlet="!service.smallScreen ? navigations : null"></ng-container>
@if (service.smallScreen) {
<div
[@collapseWithMargin]="service.isCollapsed ? 'collapsed' : 'expanded'"
>
<ng-container *ngTemplateOutlet="navigations"></ng-container>
</div>
}
<ng-template #navigations>
<abp-routes
*abpReplaceableTemplate="{
componentKey: service.routesComponentKey,
inputs: {
smallScreen: { value: service.smallScreen }
}
}"
class="mx-auto"
[smallScreen]="service.smallScreen"
></abp-routes>
<abp-nav-items
*abpReplaceableTemplate="{
componentKey: service.navItemsComponentKey
}"
></abp-nav-items>
</ng-template>
</div>
</div>
</nav>
<!-- [@slideFromBottom]="outlet.isActivated && outlet.activatedRoute?.routeConfig?.path" TODO: throws ExpressionChangedAfterItHasBeenCheck when animation is active. It should be fixed -->
<div class="container">
<abp-page-alert-container></abp-page-alert-container>
<abp-auth-wrapper
*abpReplaceableTemplate="{
componentKey: authWrapperKey
}"
>
<router-outlet #outlet="outlet"></router-outlet>
</abp-auth-wrapper>
</div>
@@ -0,0 +1,40 @@
import { AfterViewInit, Component, inject } from '@angular/core';
import { eLayoutType, ReplaceableTemplateDirective, SubscriptionService } from '@abp/ng.core';
import { LayoutService } from '../../services/layout.service';
import { NgTemplateOutlet } from '@angular/common';
import { LogoComponent } from '../logo/logo.component';
import { RoutesComponent } from '../routes/routes.component';
import { NavItemsComponent } from '../nav-items/nav-items.component';
import { AuthWrapperComponent } from './auth-wrapper/auth-wrapper.component';
import { PageAlertContainerComponent } from '../page-alert-container/page-alert-container.component';
import { RouterOutlet } from '@angular/router';
import { collapseWithMargin } from '@abp/ng.theme.shared';
@Component({
selector: 'abp-layout-account',
templateUrl: './account-layout.component.html',
animations: [collapseWithMargin],
providers: [LayoutService, SubscriptionService],
imports: [
NgTemplateOutlet,
LogoComponent,
RoutesComponent,
NavItemsComponent,
AuthWrapperComponent,
PageAlertContainerComponent,
ReplaceableTemplateDirective,
RouterOutlet,
],
})
export class AccountLayoutComponent implements AfterViewInit {
service = inject(LayoutService);
// required for dynamic component
static type = eLayoutType.account;
authWrapperKey = 'Account.AuthWrapperComponent';
ngAfterViewInit() {
this.service.subscribeWindowSize();
}
}
@@ -0,0 +1,24 @@
<div class="row">
<div class="mx-auto col col-md-5">
@if ((service.isMultiTenancyEnabled$ | async) && service.isTenantBoxVisible) {
<abp-tenant-box
*abpReplaceableTemplate="{ componentKey: service.tenantBoxKey }"
></abp-tenant-box>
}
<div class="abp-account-container">
@if (service.enableLocalLogin$ | async) {
<div class="card mt-3 shadow-sm rounded">
<div class="card-body p-5">
<ng-content></ng-content>
</div>
</div>
} @else {
<div class="alert alert-warning">
<strong>{{ 'AbpAccount::InvalidLoginRequest' | abpLocalization }}</strong>
{{ 'AbpAccount::ThereAreNoLoginSchemesConfiguredForThisClient' | abpLocalization }}
</div>
}
</div>
</div>
</div>
@@ -0,0 +1,15 @@
import { AuthWrapperService } from '@abp/ng.account.core';
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { LocalizationPipe, ReplaceableTemplateDirective } from '@abp/ng.core';
import { TenantBoxComponent } from '../tenant-box/tenant-box.component';
@Component({
selector: 'abp-auth-wrapper',
templateUrl: './auth-wrapper.component.html',
providers: [AuthWrapperService],
imports: [AsyncPipe, TenantBoxComponent, ReplaceableTemplateDirective, LocalizationPipe],
})
export class AuthWrapperComponent {
service = inject(AuthWrapperService);
}
@@ -0,0 +1,65 @@
@if ((service.currentTenant$ | async) || { name: '' }; as currentTenant) {
<div class="card shadow-sm rounded mb-3">
<div class="card-body px-5">
<div class="row">
<div class="col">
<span style="font-size: 0.8em" class="text-uppercase text-muted">{{
'AbpUiMultiTenancy::Tenant' | abpLocalization
}}</span
><br />
<h6 class="m-0 d-inline-block">
<i>{{ currentTenant.name || ('AbpUiMultiTenancy::NotSelected' | abpLocalization) }}</i>
</h6>
</div>
<div class="col-auto">
<a
id="AbpTenantSwitchLink"
href="javascript:void(0);"
class="btn btn-sm mt-3 btn-outline-primary"
(click)="service.onSwitch()"
>{{ 'AbpUiMultiTenancy::Switch' | abpLocalization }}</a
>
</div>
</div>
</div>
</div>
<abp-modal [(visible)]="service.isModalVisible" [busy]="service.modalBusy">
<ng-template #abpHeader>
<h5>Switch Tenant</h5>
</ng-template>
<ng-template #abpBody>
<form (ngSubmit)="service.save()">
<div class="mt-2">
<div class="mb-3 form-group">
<label for="name" class="form-label">{{
'AbpUiMultiTenancy::Name' | abpLocalization
}}</label>
<input
[(ngModel)]="service.name"
type="text"
id="name"
name="tenant"
class="form-control"
autofocus
/>
</div>
<p>{{ 'AbpUiMultiTenancy::SwitchTenantHint' | abpLocalization }}</p>
</div>
</form>
</ng-template>
<ng-template #abpFooter>
<button abpClose type="button" class="btn btn-outline-primary">
{{ 'AbpTenantManagement::Cancel' | abpLocalization }}
</button>
<abp-button
type="abp-button"
iconClass="fa fa-check"
(click)="service.save()"
[disabled]="currentTenant?.name === service.name"
>
<span>{{ 'AbpTenantManagement::Save' | abpLocalization }}</span>
</abp-button>
</ng-template>
</abp-modal>
}
@@ -0,0 +1,23 @@
import { TenantBoxService } from '@abp/ng.account.core';
import { Component, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { LocalizationPipe } from '@abp/ng.core';
import { ButtonComponent, ModalCloseDirective, ModalComponent } from '@abp/ng.theme.shared';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'abp-tenant-box',
templateUrl: './tenant-box.component.html',
providers: [TenantBoxService],
imports: [
AsyncPipe,
FormsModule,
ModalComponent,
LocalizationPipe,
ButtonComponent,
ModalCloseDirective,
],
})
export class TenantBoxComponent {
service = inject(TenantBoxService);
}
@@ -0,0 +1,693 @@
<div
class="app-shell"
[class.sidebar-collapsed]="sidebarCollapsed"
[class.mobile-open]="mobileOpen"
>
<!-- ══ MOBILE OVERLAY ══════════════════════════════════════════════ -->
<div class="mobile-overlay" *ngIf="mobileOpen" (click)="mobileOpen = false"></div>
<!-- ══ SIDEBAR ═════════════════════════════════════════════════════════ -->
<!-- ══ SIDEBAR ═════════════════════════════════════════════════════════ -->
<aside class="sidebar" [class.collapsed]="sidebarCollapsed">
<!-- ── HEAD: single block for both states ── -->
<div class="sidebar__head" [class.sidebar__head--collapsed]="sidebarCollapsed">
<!-- Logo (only when expanded) -->
<div class="sidebar__logo" *ngIf="!sidebarCollapsed" (click)="navigateToDashboard()">
<abp-logo *abpReplaceableTemplate="{ componentKey: service.logoComponentKey }"></abp-logo>
</div>
<!-- Toggle button — chevron left / right -->
<button
class="sidebar__toggle"
[title]="sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'"
(click)="sidebarCollapsed = !sidebarCollapsed; mobileOpen = false"
>
<!-- ← when expanded -->
<svg
*ngIf="!sidebarCollapsed"
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 18l-6-6 6-6" />
</svg>
<!-- → when collapsed -->
<svg
*ngIf="sidebarCollapsed"
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</div>
<!-- ── NAV ── -->
<nav class="sidebar__nav">
<!-- EXPANDED -->
<ng-container *ngIf="!sidebarCollapsed">
<ng-container *ngFor="let route of visibleRoutes$ | async; trackBy: trackByFn">
<ng-container *abpPermission="route.requiredPolicy || ''">
<a
*ngIf="route.isLeaf"
class="nav-item"
[routerLink]="[route.path]"
[class.nav-item--active]="isRouteActive(route.path)"
[title]="route.name | abpLocalization"
>
<span class="nav-item__indicator"></span>
<span class="nav-item__icon" [innerHTML]="getRouteIcon(route)"></span>
<span class="nav-item__label">{{ route.name | abpLocalization }}</span>
</a>
<div *ngIf="!route.isLeaf && route.children?.length" class="nav-group">
<button
class="nav-item nav-item--group"
(click)="toggleGroup(route.name)"
[class.nav-item--expanded]="isGroupExpanded(route)"
[class.nav-item--active]="isGroupRouteActive(route)"
>
<span class="nav-item__indicator"></span>
<span class="nav-item__icon" [innerHTML]="getRouteIcon(route)"></span>
<span class="nav-item__label">{{ route.name | abpLocalization }}</span>
<svg
class="nav-item__chevron"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<div class="nav-group__children" *ngIf="isGroupExpanded(route)">
<ng-container *ngFor="let child of route.children">
<a
*abpPermission="child.requiredPolicy || ''"
class="nav-child"
[routerLink]="[child.path]"
[class.nav-child--active]="isRouteActive(child.path)"
>
{{ child.name | abpLocalization }}
</a>
</ng-container>
</div>
</div>
</ng-container>
</ng-container>
<div class="nav-section" *ngIf="showPlatformMenu">
<button
class="nav-section__toggle"
(click)="platformGroupExpanded = !platformGroupExpanded"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"
/>
</svg>
<span>Platform</span>
<svg
class="nav-section__chevron"
[class.rotated]="platformGroupExpanded"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<div class="nav-section__items" *ngIf="platformGroupExpanded">
<a
class="nav-child"
routerLink="/platform/tenants"
routerLinkActive="nav-child--active"
[routerLinkActiveOptions]="{ exact: false }"
>Tenants</a
>
<a
class="nav-child"
routerLink="/platform/audit"
routerLinkActive="nav-child--active"
[routerLinkActiveOptions]="{ exact: false }"
>Audit Log</a
>
<a
class="nav-child"
routerLink="/platform/security"
routerLinkActive="nav-child--active"
[routerLinkActiveOptions]="{ exact: false }"
>Security</a
>
<a
class="nav-child"
routerLink="/platform/integrations"
routerLinkActive="nav-child--active"
[routerLinkActiveOptions]="{ exact: false }"
>Integrations</a
>
</div>
</div>
</ng-container>
<!-- COLLAPSED -->
<ng-container *ngIf="sidebarCollapsed">
<ng-container *ngFor="let route of visibleRoutes$ | async; trackBy: trackByFn">
<ng-container *abpPermission="route.requiredPolicy || ''">
<a
*ngIf="route.isLeaf"
class="nav-icon-item"
[routerLink]="[route.path]"
[class.nav-icon-item--active]="isRouteActive(route.path)"
[title]="route.name | abpLocalization"
>
<span class="nav-icon-item__indicator"></span>
<span [innerHTML]="getRouteIcon(route)"></span>
</a>
<a
*ngIf="!route.isLeaf && route.children?.length"
class="nav-icon-item"
[routerLink]="[route.children[0]?.path]"
[class.nav-icon-item--active]="isRouteActive(route.path) || isGroupRouteActive(route)"
[title]="route.name | abpLocalization"
>
<span class="nav-icon-item__indicator"></span>
<span [innerHTML]="getRouteIcon(route)"></span>
</a>
</ng-container>
</ng-container>
<a
*ngIf="showPlatformMenu"
class="nav-icon-item"
routerLink="/platform/tenants"
routerLinkActive="nav-icon-item--active"
[routerLinkActiveOptions]="{ exact: false }"
title="Platform"
>
<span class="nav-icon-item__indicator"></span>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21"
/>
</svg>
</a>
</ng-container>
</nav>
<!-- ── FOOTER ── -->
<!-- <div class="sidebar__footer">
<a *ngIf="!sidebarCollapsed" href="#" class="nav-item nav-item--help" title="Help & Support">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
<span class="nav-item__label">Help &amp; Support</span>
</a>
<a *ngIf="sidebarCollapsed" href="#" class="nav-icon-item" title="Help & Support">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
</a>
</div> -->
</aside>
<!-- ══ MAIN AREA ════════════════════════════════════════════════════ -->
<div class="main-area">
<!-- ── TOP NAV ── -->
<header class="topnav">
<!-- Mobile hamburger -->
<button class="icon-btn topnav__hamburger" (click)="mobileOpen = true" title="Open menu">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<!-- Search trigger -->
<button *abpPermission="'DelightErm.NavbarGroupPermission.Navbar.GlobalSearch'" class="topnav__search" (click)="showSearchDialog = true">
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<span>Search anything...</span>
<kbd>/</kbd>
</button>
<!-- Right side: ABP nav items + notification + user -->
<div class="topnav__right">
<!-- ABP Nav Items (language switcher etc.) -->
<!-- <abp-nav-items
*abpReplaceableTemplate="{ componentKey: service.navItemsComponentKey }">
</abp-nav-items> -->
<!-- Create button -->
<button *abpPermission="'DelightErm.NavbarGroupPermission.Navbar.Create'" class="btn-create" (click)="onCreateClick()">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M12 4v16m8-8H4" />
</svg>
Create
</button>
<!-- Theme Toggle (Light / Dark) -->
<button
type="button"
class="icon-btn topnav__icon-btn theme-toggle-btn"
(click)="toggleTheme()"
[title]="isDarkMode ? 'Switch to Light mode' : 'Switch to Dark mode'"
[attr.aria-label]="isDarkMode ? 'Switch to Light mode' : 'Switch to Dark mode'"
>
<!-- Sun icon (shown in dark mode to switch to light) -->
<svg
*ngIf="isDarkMode"
class="theme-icon theme-icon--sun"
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
</svg>
<!-- Moon icon (shown in light mode to switch to dark) -->
<svg
*ngIf="!isDarkMode"
class="theme-icon theme-icon--moon"
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
</button>
<!-- Notification bell -->
<div class="notif-wrap" #notificationRef>
<button
class="icon-btn topnav__icon-btn"
(click)="toggleNotifications()"
title="Notifications"
>
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
/>
</svg>
<span class="notif-dot" *ngIf="unreadCount > 0 && !isLoading && !isLoadingMore"></span>
<span class="notif-icon-spinner" *ngIf="isLoading || isLoadingMore"></span>
</button>
<!-- Notification dropdown -->
<div class="notif-dropdown" *ngIf="showNotifications">
<div class="notif-dropdown__head">
<div class="notif-dropdown__title-wrap">
<span class="notif-dropdown__title">Notifications</span>
<span class="notif-count-badge" *ngIf="unreadCount > 0">{{ unreadCount }} new</span>
</div>
<button class="link-btn" *ngIf="unreadCount > 0" (click)="markAllAsRead()">
Mark all as read
</button>
</div>
<div class="notif-dropdown__list" (scroll)="onDropdownScroll($event)">
<!-- Initial Loading Spinner -->
<div class="notif-spinner" *ngIf="isLoading && notifications.length === 0">
<svg class="animate-spin" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
</div>
<!-- Empty State -->
<div class="notif-empty" *ngIf="!isLoading && notifications.length === 0">
<div class="notif-empty__icon-wrap">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
</div>
<span class="notif-empty__title">All caught up!</span>
<span class="notif-empty__subtitle">You have no new notifications.</span>
</div>
<!-- Notifications list -->
<ng-container *ngIf="notifications.length > 0">
<div
class="notif-item"
*ngFor="let n of notifications"
(click)="onNotificationClick(n)"
[class.notif-item--unread]="n.unread"
>
<div class="notif-item__left">
<div class="notif-item__icon" [attr.data-type]="n.type">
<svg
*ngIf="n.type === 'risk'"
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
<svg
*ngIf="n.type === 'task'"
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg
*ngIf="n.type === 'incident'"
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
<svg
*ngIf="n.type === 'indicator'"
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path
d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941"
/>
</svg>
</div>
</div>
<div class="notif-item__body">
<div class="notif-item__title">{{ n.title }}</div>
<div class="notif-item__msg">{{ n.message }}</div>
<div class="notif-item__meta">
<span class="notif-item__time">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 4px; display: inline-block; vertical-align: middle;">
<path d="M12 6v6l4 2M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ n.timestamp }}
</span>
</div>
</div>
<div class="notif-item__right" *ngIf="n.unread">
<span class="notif-item__unread-indicator"></span>
</div>
</div>
</ng-container>
<!-- Loading More Spinner at Bottom -->
<div class="notif-spinner" *ngIf="isLoadingMore">
<svg class="animate-spin" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
</div>
</div>
</div>
</div>
<!-- User menu -->
<app-user-menu></app-user-menu>
</div>
</header>
<!-- PAGE ALERTS (ABP) -->
<abp-page-alert-container></abp-page-alert-container>
<!-- CONTENT -->
<main class="content">
<router-outlet></router-outlet>
</main>
</div>
</div>
<!-- ══ SEARCH DIALOG ════════════════════════════════════════════════ -->
<ng-container *abpPermission="'DelightErm.NavbarGroupPermission.Navbar.GlobalSearch'">
<div class="dialog-overlay" *ngIf="showSearchDialog" (click)="closeSearchOnBackdrop($event)">
<div class="search-dialog" (click)="$event.stopPropagation()">
<div class="search-dialog__head">
<h3>Search</h3>
<p>Search across risks, incidents, and tasks</p>
</div>
<div class="search-dialog__input-wrap">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
#searchInputRef
type="text"
placeholder="Search risks, incidents, tasks..."
[(ngModel)]="searchQuery"
(ngModelChange)="onSearchChange($event)"
/>
<button *ngIf="searchQuery" (click)="searchQuery = ''; onSearchChange('')">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="search-dialog__results">
<ng-container *ngIf="searchQuery && searchResults?.groups">
<div class="search-section" *ngFor="let group of searchResults.groups">
<div
class="search-section__label search-section__label--clickable"
[attr.data-group-key]="group.groupKey"
(click)="toggleSearchGroup(group.groupKey)"
title="Click to expand/collapse {{ group.label || group.groupKey }}"
>
<div class="search-section__head-left">
<svg
*ngIf="group.groupKey === 'Risk'"
width="14"
height="14"
style="color: #f97316"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<svg
*ngIf="group.groupKey === 'Incident'"
width="14"
height="14"
style="color: #ef4444"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<svg
*ngIf="group.groupKey === 'Task'"
width="14"
height="14"
style="color: #3b82f6"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<svg
*ngIf="group.groupKey !== 'Risk' && group.groupKey !== 'Incident' && group.groupKey !== 'Task'"
width="14"
height="14"
style="color: #64748b"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<span class="search-section__title">{{ group.label || group.groupKey }}</span>
<span class="search-section__count">{{ group.items?.length || 0 }}</span>
</div>
<svg
class="search-section__chevron"
[class.search-section__chevron--collapsed]="isSearchGroupCollapsed(group.groupKey)"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</div>
<div class="search-group-items" *ngIf="!isSearchGroupCollapsed(group.groupKey)">
<div
class="search-result"
[attr.data-group]="group.groupKey"
*ngFor="let item of group.items"
(click)="onSearchResultClick(item)"
>
<div class="search-result__info">
<div class="search-result__name">{{ item.title }}</div>
<div class="search-result__sub">
<span class="search-result__parent-tag" [attr.data-type]="group.groupKey">{{ group.groupKey }}</span>
<span class="search-result__id">ID: {{ item.id }}</span>
</div>
</div>
<span class="search-result__badge" [attr.data-type]="group.groupKey">{{ group.groupKey }}</span>
</div>
</div>
</div>
<!-- Empty Results -->
<div
class="search-empty"
*ngIf="!hasSearchResults()"
>
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<p>No results for "{{ searchQuery }}"</p>
</div>
</ng-container>
<div class="search-empty" *ngIf="!searchQuery">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<p>Start typing to search</p>
</div>
</div>
</div>
</div>
</ng-container>
<!-- Create overlay component -->
<app-create-overlay></app-create-overlay>
@@ -0,0 +1,639 @@
import { ABP, ConfigStateService, eLayoutType, EnvironmentService, LocalizationPipe, PermissionDirective, ReplaceableTemplateDirective, RoutesService, SubscriptionService, TreeNode } from '@abp/ng.core';
import { collapseWithMargin, slideFromBottom } from '@abp/ng.theme.shared';
import { AsyncPipe, CommonModule } from '@angular/common';
import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, HostListener, inject, NgZone, OnDestroy, OnInit, TrackByFunction, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { OAuthService } from 'angular-oauth2-oidc';
import { Observable, Subject, Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { GlobalSearchService } from '../../../../../../src/app/proxy/erm-global-search/global-search.service';
import { GlobalSearchResultDto, SearchItemDto } from '../../../../../../src/app/proxy/erm-global-search-contract/dto/models';
import { environment } from '../../../../../../src/environments/environment';
import { NotificationDto } from '../../../../../../src/app/proxy/notifications/dto/models';
import { NotificationService } from '../../../../../../src/app/proxy/notifications/notification.service';
import { LayoutService } from '../../services/layout.service';
import { ThemeService } from '../../services/theme.service';
import { LogoComponent } from '../logo/logo.component';
import { PageAlertContainerComponent } from '../page-alert-container/page-alert-container.component';
import { CreateOverlayComponent } from "./create-overlay.component";
import { CreateOverlayService } from './create-overlay.service';
import { UserMenuComponent } from "./user-menu.component";
import { PermissionService } from '@abp/ng.core';
/* ── Types (mirrors existing interfaces) ─────────────────────────── */
export interface AppNotification {
id: string;
type: 'risk' | 'task' | 'incident' | 'indicator' | string;
title: string;
message: string;
timestamp: string;
unread: boolean;
link: string;
}
export interface SearchResults {
risks: { id: string; name: string; category: string; residualRisk: number }[];
incidents: { id: string; title: string; priority?: string }[];
tasks: { id: string; name: string; status: string; dueDate: string }[];
}
/* ── Route icon map ──────────────────────────────────────────────── */
const ROUTE_ICONS: Record<string, string> = {
// ABP route names → SVG strings (stroke icons, 18×18)
Dashboard: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"/></svg>`,
Incidents: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>`,
Risks: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"/></svg>`,
Assessments: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>`,
Tasks: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M10.125 2.25h-4.5c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125v-9M10.125 2.25h.375a9 9 0 019 9v.375M10.125 2.25A3.375 3.375 0 0113.5 5.625v1.5c0 .621.504 1.125 1.125 1.125h1.5a3.375 3.375 0 013.375 3.375M9 15l2.25 2.25L15 12"/></svg>`,
Indicators:`<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18L9 11.25l4.306 4.307a11.95 11.95 0 015.814-5.519l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941"/></svg>`,
Reporting: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"/></svg>`,
Admin: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>`,
DEFAULT: `<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>`,
};
@Component({
selector: 'abp-layout-application',
templateUrl: './application-layout.component.html',
styleUrl: './application-layout.component.scss',
animations: [slideFromBottom, collapseWithMargin],
providers: [LayoutService, SubscriptionService,PermissionDirective],
imports: [
CommonModule,
AsyncPipe,
FormsModule,
RouterLink,
RouterLinkActive,
RouterOutlet,
LocalizationPipe,
PermissionDirective,
ReplaceableTemplateDirective,
LogoComponent,
PageAlertContainerComponent,
UserMenuComponent,
CreateOverlayComponent,
],
})
export class ApplicationLayoutComponent implements OnInit, AfterViewInit, OnDestroy {
static type = eLayoutType.application;
readonly service = inject(LayoutService);
readonly themeService = inject(ThemeService);
private readonly router = inject(Router);
private readonly config = inject(ConfigStateService);
private readonly routesService = inject(RoutesService);
private readonly createOverlay = inject(CreateOverlayService);
private readonly environment = inject(EnvironmentService);
private readonly notificationSvc = inject(NotificationService);
private readonly ngZone = inject(NgZone);
private readonly oauthService = inject(OAuthService);
private readonly cdr = inject(ChangeDetectorRef);
private readonly globalSearchService = inject(GlobalSearchService);
private readonly permissionService = inject(PermissionService);
private isDestroyed = false;
private readonly SIDEBAR_KEY = 'delight_sidebar_collapsed';
get isDarkMode(): boolean {
return this.themeService.isDarkMode;
}
toggleTheme(): void {
this.themeService.toggleTheme();
}
/* ── Sidebar ──────────────────────────────────────────────────── */
private _sidebarCollapsed = false;
isMobile = false;
mobileOpen = false;
platformGroupExpanded = false;
expandedGroups: Record<string, boolean> = {};
constructor() {
const stored = localStorage.getItem(this.SIDEBAR_KEY);
this.routesService.remove(['AbpUiNavigation::Menu:Administration']);
if (stored !== null) this._sidebarCollapsed = stored === 'true';
}
set sidebarCollapsed(v: boolean) {
this._sidebarCollapsed = v;
localStorage.setItem(this.SIDEBAR_KEY, String(v));
}
get sidebarCollapsed(): boolean { return this._sidebarCollapsed; }
/* ── ABP Routes observable ────────────────────────────────────── */
visibleRoutes$: Observable<TreeNode<ABP.Route>[]> = this.routesService.visible$;
trackByFn: TrackByFunction<TreeNode<ABP.Route>> = (_, item) => item.name;
getRouteIcon(route: TreeNode<ABP.Route>): string {
if (route.iconClass) {
return `<i class="${route.iconClass}" aria-hidden="true"></i>`;
}
const routeName = String(route.name ?? '');
const normalizedName = routeName.startsWith('::Menu:')
? routeName.replace('::Menu:', '')
: routeName;
return ROUTE_ICONS[normalizedName] ?? ROUTE_ICONS['DEFAULT'];
}
toggleGroup(name: string): void {
if (this.expandedGroups[name] === undefined) {
this.expandedGroups[name] = false;
} else {
this.expandedGroups[name] = !this.expandedGroups[name];
}
}
isGroupExpanded(route: any): boolean {
if (this.expandedGroups[route.name] !== undefined) {
return this.expandedGroups[route.name];
}
return this.isGroupRouteActive(route);
}
/* ── Route helpers ────────────────────────────────────────────── */
getDashboardRoute(): string {
const user = this.config.getOne('currentUser') as any;
const roles: string[] = user?.roles ?? [];
if (roles.includes('Super Admin')) return '/platform-dashboard';
if (roles.includes('Tenant Admin')) return '/tenant-dashboard';
if (roles.includes('Department')) return '/department-dashboard';
return '/';
}
navigateToDashboard(): void {
this.router.navigateByUrl(this.getDashboardRoute());
}
get isAdminRoute(): boolean {
const url = this.router.url;
return url.startsWith('/admin') || url.startsWith('/config') || url.startsWith('/configuration');
}
isRouteActive(routePath?: string): boolean {
const url = this.router.url;
const cleanUrl = url.split('?')[0].replace(/^\/|\/$/g, '');
const cleanRoutePath = (routePath || '').replace(/^\/|\/$/g, '');
if (cleanUrl === cleanRoutePath) {
return true;
}
if (cleanRoutePath === 'risk-register') {
return (
cleanUrl.startsWith('risk-details') ||
cleanUrl.startsWith('new-risk-details')
);
}
if (cleanRoutePath === 'risk-assessment') {
return (
cleanUrl === 'risk-assessment' ||
cleanUrl.startsWith('risk-assessment/') ||
cleanUrl.startsWith('risk-assessment-details')
);
}
if (cleanRoutePath === 'incident-management') {
return cleanUrl.startsWith('incident-details');
}
if (cleanRoutePath === 'task-management') {
return cleanUrl.startsWith('tasks/');
}
if (cleanRoutePath === 'admin') {
return (
cleanUrl === 'admin' ||
cleanUrl.startsWith('admin/') ||
cleanUrl.startsWith('user-roles') ||
cleanUrl.startsWith('approval-groups') ||
cleanUrl.startsWith('user-profile-details') ||
cleanUrl.startsWith('departments') ||
cleanUrl.startsWith('location') ||
cleanUrl.startsWith('email-template') ||
cleanUrl.startsWith('assets-types') ||
cleanUrl.startsWith('incident-priority') ||
cleanUrl.startsWith('config/') ||
cleanUrl.startsWith('risk-matrix') ||
cleanUrl.startsWith('threshold')
);
}
if (cleanRoutePath && cleanUrl.startsWith(cleanRoutePath + '/')) {
return true;
}
return false;
}
isGroupRouteActive(route: any): boolean {
if (!route.children) return false;
return route.children.some((child: any) => this.isRouteActive(child.path));
}
get showPlatformMenu(): boolean {
const user = this.config.getOne('currentUser') as any;
const roles: string[] = user?.roles ?? [];
return roles.includes('Super Admin');
}
/* ── Mobile ───────────────────────────────────────────────────── */
@HostListener('window:resize')
onResize(): void { this.checkMobile(); }
private checkMobile(): void {
this.isMobile = window.innerWidth < 1024;
if (!this.isMobile) this.mobileOpen = false;
}
/* ── Keyboard shortcuts ───────────────────────────────────────── */
@HostListener('document:keydown', ['$event'])
onKeydown(event: KeyboardEvent): void {
const target = event.target as HTMLElement;
const isInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
if (event.key === '/' && !isInput && !this.showSearchDialog) {
if (this.permissionService.getGrantedPolicy('DelightErm.NavbarGroupPermission.Navbar.GlobalSearch')) {
event.preventDefault();
this.showSearchDialog = true;
}
}
if (event.key === 'Escape') {
this.showSearchDialog = false;
this.showNotifications = false;
}
}
/* ── Notifications ────────────────────────────────────────────── */
@ViewChild('notificationRef') notificationRef!: ElementRef<HTMLDivElement>;
showNotifications = false;
private hubConnection!: HubConnection;
notifications: AppNotification[] = [];
isLoading = false;
isLoadingMore = false;
hasMore = true;
get unreadCount(): number { return this.notifications.filter(n => n.unread).length; }
toggleNotifications(): void { this.showNotifications = !this.showNotifications; }
loadNotifications(isInitial: boolean = false): void {
if (isInitial) {
this.isLoading = true;
this.notifications = [];
this.hasMore = true;
} else {
if (this.isLoading || this.isLoadingMore || !this.hasMore) return;
this.isLoadingMore = true;
}
const skipCount = isInitial ? 0 : this.notifications.length;
this.notificationSvc.getList(skipCount, 10).subscribe({
next: (dtos: NotificationDto[]) => {
const mapped = (dtos || []).map(d => this.mapNotification(d));
if (isInitial) {
this.notifications = mapped;
this.isLoading = false;
} else {
this.notifications = [...this.notifications, ...mapped];
this.isLoadingMore = false;
}
if ((dtos || []).length < 10) {
this.hasMore = false;
this.cdr.markForCheck();
}
},
error: (err) => {
console.error('Error loading notifications:', err);
this.isLoading = false;
this.isLoadingMore = false;
this.cdr.markForCheck();
}
});
}
private mapNotification(dto: NotificationDto): AppNotification {
const type = this.getNotificationType(dto.actionTitle || dto.action || '');
return {
id: dto.id || '',
type,
title: dto.actionTitle || 'Notification',
message: dto.action || '',
timestamp: (dto as any).creationTime ? this.formatTimeAgo((dto as any).creationTime) : 'Recent',
unread: !dto.isRead,
link: this.getNotificationLink(type)
};
}
private getNotificationType(text: string): string {
const lower = text.toLowerCase();
if (lower.includes('risk')) return 'risk';
if (lower.includes('task')) return 'task';
if (lower.includes('incident')) return 'incident';
if (lower.includes('indicator') || lower.includes('kri')) return 'indicator';
return 'task'; // fallback type
}
private getNotificationLink(type: string): string {
if (type === 'risk') return '/risks';
if (type === 'task') return '/tasks';
if (type === 'incident') return '/incidents';
return '/';
}
private formatTimeAgo(dateInput: any): string {
if (!dateInput) return 'Recent';
try {
const date = new Date(dateInput);
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (seconds < 60) return 'Just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days === 1) return 'Yesterday';
return `${days} days ago`;
} catch {
return 'Recent';
}
}
initSignalR(): void {
const tenantId = (this.config.getOne('currentTenant')?.id || '00000000-0000-0000-0000-000000000000').toLowerCase();
const rawUrl = environment.apis?.default?.url || '';
const apiBaseUrl = rawUrl.replace('{0}.', '');
const hubUrl = `${apiBaseUrl.replace(/\/$/, '')}/erm-notification-hub/${tenantId}`;
console.log('🚀 initSignalR called');
console.log('🔑 TenantId:', tenantId);
console.log('🌐 Hub URL:', hubUrl);
console.log('🎫 Token:', this.oauthService.getAccessToken() ? 'Present ✅' : 'Missing ❌');
this.hubConnection = new HubConnectionBuilder()
.withUrl(hubUrl, {
accessTokenFactory: () => {
const token = this.oauthService.getAccessToken() || '';
console.log('🎫 accessTokenFactory called, token length:', token.length);
return token;
}
})
.withAutomaticReconnect()
.build();
console.log('🔧 HubConnection built, registering handlers...');
this.hubConnection.on('NotificationEvent', (data: any) => {
console.log('🔔 ===== NotificationEvent FIRED =====');
console.log('🔔 Raw data:', data);
console.log('🔔 JSON:', JSON.stringify(data, null, 2));
if (data) {
console.log('✅ Data exists, entering ngZone.run...');
this.ngZone.run(() => {
console.log('✅ Inside ngZone.run');
const mapped = this.mapNotification(data);
console.log('✅ Mapped notification:', mapped);
this.notifications = [mapped, ...this.notifications];
console.log('✅ notifications length now:', this.notifications.length);
this.cdr.markForCheck();
console.log('✅ markForCheck called');
});
} else {
console.warn('⚠️ data is null or undefined');
}
});
console.log('🔧 NotificationEvent handler registered');
this.hubConnection.onreconnecting((error) => {
console.warn('🔄 SignalR reconnecting:', error);
});
this.hubConnection.onreconnected((connectionId) => {
console.log('🔄 SignalR reconnected. ID:', connectionId);
});
this.hubConnection.onclose((error) => {
console.error('❌ SignalR connection closed:', error);
if (!this.isDestroyed) {
console.log('🔄 Attempting restart...');
this.startHubConnection();
}
});
console.log('🔧 All handlers registered, starting connection...');
this.startHubConnection();
}
private startHubConnection(): void {
if (this.isDestroyed) return;
console.log('🚀 startHubConnection called, current state:', this.hubConnection.state);
this.hubConnection.start()
.then(() => {
console.log('✅ SignalR connected successfully');
console.log('✅ Connection ID:', this.hubConnection.connectionId);
console.log('✅ Connection state:', this.hubConnection.state);
})
.catch(err => {
console.error('❌ SignalR connection failed:', err);
console.error('❌ Error details:', JSON.stringify(err));
if (!this.isDestroyed) {
console.log('🔄 Retrying in 5 seconds...');
setTimeout(() => this.startHubConnection(), 5000);
}
});
}
onDropdownScroll(event: Event): void {
const element = event.target as HTMLElement;
if (element.scrollHeight - element.scrollTop <= element.clientHeight + 15) {
this.loadNotifications(false);
}
}
markAllAsRead(): void {
this.notificationSvc.markAsReadAllNotifications().subscribe({
next: () => {
this.notifications = this.notifications.map(n => ({ ...n, unread: false }));
this.cdr.markForCheck();
},
error: (err) => {
console.error('Error marking notifications as read:', err);
this.notifications = this.notifications.map(n => ({ ...n, unread: false }));
}
});
}
onNotificationClick(n: AppNotification): void {
n.unread = false;
this.showNotifications = false;
}
@HostListener('document:mousedown', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (this.showNotifications && this.notificationRef?.nativeElement &&
!this.notificationRef.nativeElement.contains(event.target as Node)) {
this.showNotifications = false;
}
}
/* ── Search ───────────────────────────────────────────────────── */
private _showSearchDialog = false;
get showSearchDialog(): boolean { return this._showSearchDialog; }
set showSearchDialog(val: boolean) {
this._showSearchDialog = val;
if (val) {
setTimeout(() => {
this.searchInputRef?.nativeElement?.focus();
});
}
}
searchQuery = '';
searchResults: GlobalSearchResultDto | null = null;
private readonly searchSubject = new Subject<string>();
private searchSub?: Subscription;
@ViewChild('searchInputRef') searchInputRef!: ElementRef<HTMLInputElement>;
onSearchChange(q: string): void {
this.searchSubject.next(q);
}
onSearchResultClick(item: SearchItemDto): void {
let path = item.routeHint || '';
if (!path) return;
if (!path.startsWith('/')) {
path = '/' + path;
}
if (item.id && !path.includes(item.id)) {
const qIndex = path.indexOf('?');
if (qIndex !== -1) {
const basePath = path.substring(0, qIndex);
const queryStr = path.substring(qIndex);
const cleanBase = basePath.endsWith('/') ? basePath : basePath + '/';
path = cleanBase + item.id + queryStr;
} else {
if (!path.endsWith('/')) {
path += '/';
}
path += item.id;
}
}
// Split into base route and query parameters
let urlPath = path;
let queryParams: Record<string, string> = {};
const firstQ = path.indexOf('?');
if (firstQ !== -1) {
urlPath = path.substring(0, firstQ);
const rawQuery = path.substring(firstQ + 1).replace(/\?/g, '&');
const pairs = rawQuery.split('&');
pairs.forEach(p => {
const [k, v] = p.split('=');
if (k && k.trim()) {
queryParams[k.trim()] = decodeURIComponent(v || '');
}
});
}
this.ngZone.run(() => {
if (Object.keys(queryParams).length > 0) {
this.router.navigate([urlPath], {
queryParams,
queryParamsHandling: 'merge',
});
} else {
this.router.navigateByUrl(urlPath);
}
});
this.showSearchDialog = false;
this.searchQuery = '';
this.searchResults = null;
}
closeSearchOnBackdrop(event: MouseEvent): void {
if ((event.target as HTMLElement) === event.currentTarget) {
this.showSearchDialog = false;
this.searchQuery = '';
this.searchResults = null;
}
}
collapsedSearchGroups: Record<string, boolean> = {};
toggleSearchGroup(groupKey: string): void {
if (!groupKey) return;
this.collapsedSearchGroups[groupKey] = !this.collapsedSearchGroups[groupKey];
this.cdr.markForCheck();
}
isSearchGroupCollapsed(groupKey: string): boolean {
if (!groupKey) return false;
return !!this.collapsedSearchGroups[groupKey];
}
hasSearchResults(): boolean {
if (!this.searchResults || !this.searchResults.groups) return false;
return this.searchResults.groups.some(g => g.items && g.items.length > 0);
}
/* ── Create ───────────────────────────────────────────────────── */
onCreateClick(): void { this.createOverlay.open(); }
/* ── Lifecycle ────────────────────────────────────────────────── */
ngOnInit(): void {
this.checkMobile();
this.loadNotifications(true);
this.initSignalR();
this.searchSub = this.searchSubject
.pipe(
debounceTime(300),
distinctUntilChanged()
)
.subscribe(q => {
const keyword = (q || '').trim();
if (!keyword) {
this.searchResults = null;
this.cdr.markForCheck();
return;
}
this.globalSearchService.search({ keyword, maxResultsPerGroup: 5 }).subscribe({
next: (res: GlobalSearchResultDto) => {
this.searchResults = res;
this.cdr.markForCheck();
},
error: (err) => {
console.error('Global search error:', err);
this.searchResults = null;
this.cdr.markForCheck();
}
});
});
}
ngAfterViewInit(): void { this.service.subscribeWindowSize(); }
ngOnDestroy(): void {
this.isDestroyed = true;
if (this.searchSub) {
this.searchSub.unsubscribe();
}
if (this.hubConnection) {
this.hubConnection.stop()
.then(() => console.log('SignalR connection stopped.'))
.catch(err => console.error('Error stopping SignalR connection:', err));
}
}
}
@@ -0,0 +1,235 @@
<!-- ══════════════════════════════════════════════════════════════
CREATE OVERLAY SELECTOR MODAL
Uses inline styles for guaranteed responsiveness (no Tailwind dependency).
══════════════════════════════════════════════════════════════ -->
<ng-container *ngIf="isOpen">
<!-- Backdrop -->
<div
style="
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(4px);
"
(click)="onBackdropClick($event)"
>
<!-- Modal Container -->
<div
style="
position: relative;
width: calc(100% - 32px);
max-width: 860px;
background: #ffffff;
border-radius: 24px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.18), 0 0 0 1px rgba(226,232,240,0.6);
margin: 0 auto;
"
(click)="$event.stopPropagation()"
>
<!-- Header Area -->
<div style="
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 28px;
flex-shrink: 0;
border-bottom: 1px solid #f1f5f9;
">
<div>
<h2 style="margin: 0; font-size: clamp(16px, 2.5vw, 20px); font-weight: 700; color: #1e293b; line-height: 1.3;">
What would you like to create?
</h2>
<p style="margin: 4px 0 0; font-size: 13px; color: #94a3b8;">
Select an entity type to get started
</p>
</div>
<button
type="button"
(click)="close()"
style="
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: transparent;
cursor: pointer;
color: #94a3b8;
flex-shrink: 0;
outline: none;
transition: background 0.15s, color 0.15s;
"
onmouseenter="this.style.background='#f1f5f9'; this.style.color='#475569';"
onmouseleave="this.style.background='transparent'; this.style.color='#94a3b8';"
title="Close"
>
<svg width="18" height="18" 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>
<!-- Content wrapper -->
<div style="padding: 24px 28px;">
<!-- Options Grid — auto-reflowing: 3 cols → 2 cols → 1 col -->
<div style="
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
">
<!-- Incident -->
<button
type="button"
(click)="navigateToCreate('Incident')"
style="
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 24px 16px;
border-radius: 16px;
border: 1.5px solid #f1f5f9;
background: #ffffff;
cursor: pointer;
outline: none;
transition: border-color 0.2s, background 0.2s, transform 0.2s, box-shadow 0.2s;
width: 100%;
"
onmouseenter="this.style.transform='translateY(-3px)'; this.style.borderColor='#fecdd3'; this.style.background='#fff1f2'; this.style.boxShadow='0 10px 24px -4px rgba(244,63,94,0.08)';"
onmouseleave="this.style.transform='none'; this.style.borderColor='#f1f5f9'; this.style.background='#ffffff'; this.style.boxShadow='none';"
>
<!-- Red Icon -->
<div style="
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 14px;
margin-bottom: 14px;
background: #fff1f2;
color: #f43f5e;
flex-shrink: 0;
">
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<span style="display: block; font-size: 15px; font-weight: 700; color: #1e293b; margin-bottom: 6px;">Incident</span>
<span style="display: block; font-size: 12px; color: #64748b; line-height: 1.5;">Report a new incident or event for investigation</span>
</button>
<!-- Risk -->
<button
type="button"
(click)="navigateToCreate('Risk')"
style="
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 24px 16px;
border-radius: 16px;
border: 1.5px solid #f1f5f9;
background: #ffffff;
cursor: pointer;
outline: none;
transition: border-color 0.2s, background 0.2s, transform 0.2s, box-shadow 0.2s;
width: 100%;
"
onmouseenter="this.style.transform='translateY(-3px)'; this.style.borderColor='#bfdbfe'; this.style.background='#eff6ff'; this.style.boxShadow='0 10px 24px -4px rgba(59,130,246,0.08)';"
onmouseleave="this.style.transform='none'; this.style.borderColor='#f1f5f9'; this.style.background='#ffffff'; this.style.boxShadow='none';"
>
<!-- Blue Icon -->
<div style="
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 14px;
margin-bottom: 14px;
background: #eff6ff;
color: #3b82f6;
flex-shrink: 0;
">
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
</div>
<span style="display: block; font-size: 15px; font-weight: 700; color: #1e293b; margin-bottom: 6px;">Risk</span>
<span style="display: block; font-size: 12px; color: #64748b; line-height: 1.5;">Register a new risk through the governance lifecycle</span>
</button>
<!-- Task -->
<button
type="button"
(click)="navigateToCreate('Task')"
style="
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 24px 16px;
border-radius: 16px;
border: 1.5px solid #f1f5f9;
background: #ffffff;
cursor: pointer;
outline: none;
transition: border-color 0.2s, background 0.2s, transform 0.2s, box-shadow 0.2s;
width: 100%;
"
onmouseenter="this.style.transform='translateY(-3px)'; this.style.borderColor='#a7f3d0'; this.style.background='#ecfdf5'; this.style.boxShadow='0 10px 24px -4px rgba(16,185,129,0.08)';"
onmouseleave="this.style.transform='none'; this.style.borderColor='#f1f5f9'; this.style.background='#ffffff'; this.style.boxShadow='none';"
>
<!-- Green Icon -->
<div style="
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 14px;
margin-bottom: 14px;
background: #ecfdf5;
color: #10b981;
flex-shrink: 0;
">
<svg width="22" height="22" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span style="display: block; font-size: 15px; font-weight: 700; color: #1e293b; margin-bottom: 6px;">Task</span>
<span style="display: block; font-size: 12px; color: #64748b; line-height: 1.5;">Create a new task linked to a risk or incident</span>
</button>
</div>
<!-- Footer Notice -->
<div style="text-align: center; padding-top: 4px;">
<span style="font-size: 12px; color: #94a3b8; font-weight: 500;">
Assessments are embedded in the risk governance lifecycle.
</span>
</div>
</div>
</div>
</div>
</ng-container>
@@ -0,0 +1,341 @@
import {
Component,
OnInit,
OnDestroy,
HostListener,
inject,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { CreateOverlayService, CreateEntityType } from './create-overlay.service';
// ── Form interfaces ───────────────────────────────────────────────────────────
interface IncidentForm {
title: string;
category: string;
priority: string;
status: string;
dateOccurred: string;
description: string;
impact: string;
urgency: string;
financialLoss: number | null;
affectedSystems: string;
immediateActions: string;
}
interface TaskForm {
name: string;
linkedEntityType: string;
linkedEntityId: string;
owner: string;
dueDate: string;
priority: string;
description: string;
}
interface AssessmentForm {
name: string;
type: string;
dueDate: string;
assignedTo: string;
priority: string;
scope: string;
}
interface RiskForm {
name: string;
category: string;
primaryOwner: string;
description: string;
likelihood: string;
impact: string;
}
// ── Risk step names (matches React RISK_STEPS array) ─────────────────────────
const RISK_STEPS = [
'Risk Identification',
'Root Cause & Consequence',
'Inherent Risk Assessment',
'Risk Analysis & Voting',
'Control Identification',
'Residual Risk Calculation',
'Target Risk Definition',
'Mitigation Planning',
'KRI Linking',
'Reassessment',
'Monitoring Phase',
'Risk Closure',
];
@Component({
selector: 'app-create-overlay',
templateUrl: './create-overlay.component.html',
standalone: true,
imports: [CommonModule, FormsModule],
})
export class CreateOverlayComponent implements OnInit, OnDestroy {
private readonly overlayService = inject(CreateOverlayService);
private readonly router = inject(Router);
// ── Expose constants to template ─────────────────────────────────
readonly RISK_STEPS = RISK_STEPS;
readonly RISK_TOTAL_STEPS = RISK_STEPS.length;
// ── Overlay state (reads from service signals) ────────────────────
get isOpen(): boolean { return this.overlayService.isOpen(); }
get preselected(): CreateEntityType { return this.overlayService.preselected(); }
// ── Navigation state ──────────────────────────────────────────────
/** step 1 = type selector, step 2+ = form */
step = 1;
/** Which entity type is selected */
selectedType: CreateEntityType = null;
/** Sub-step within multi-step forms (Incident: 1-3, Risk: 1-12) */
formStep = 1;
/** Submission spinner */
isSubmitting = false;
// ── Form models ───────────────────────────────────────────────────
incidentForm: IncidentForm = this.freshIncident();
taskForm: TaskForm = this.freshTask();
assessmentForm: AssessmentForm = this.freshAssessment();
riskForm: RiskForm = this.freshRisk();
// ── Lifecycle ─────────────────────────────────────────────────────────────
ngOnInit(): void {
// When the service opens with a pre-selected type, skip step 1
// We use a poll/effect pattern since Angular signals don't auto-subscribe
// in class fields — wire this manually if you use signal-based change detection
}
ngOnDestroy(): void {
// Always restore scroll on destroy to avoid a stuck lock
this.unlockBodyScroll();
}
private lockBodyScroll(): void {
// Lock every possible scroll host in this app's layout
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
const content = document.querySelector<HTMLElement>('.content');
const mainArea = document.querySelector<HTMLElement>('.main-area');
const appShell = document.querySelector<HTMLElement>('.app-shell');
if (content) { content.dataset['prevOverflow'] = content.style.overflow; content.style.overflow = 'hidden'; }
if (mainArea) { mainArea.dataset['prevOverflow'] = mainArea.style.overflow; mainArea.style.overflow = 'hidden'; }
if (appShell) { appShell.dataset['prevOverflow'] = appShell.style.overflow; appShell.style.overflow = 'hidden'; }
}
private unlockBodyScroll(): void {
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
const content = document.querySelector<HTMLElement>('.content');
const mainArea = document.querySelector<HTMLElement>('.main-area');
const appShell = document.querySelector<HTMLElement>('.app-shell');
if (content) content.style.overflow = content.dataset['prevOverflow'] ?? '';
if (mainArea) mainArea.style.overflow = mainArea.dataset['prevOverflow'] ?? '';
if (appShell) appShell.style.overflow = appShell.dataset['prevOverflow'] ?? '';
}
// ── Watch service state ───────────────────────────────────────────
/**
* Called by the parent layout whenever the overlay opens.
* The parent template renders <app-create-overlay> and the service
* signals drive isOpen automatically.
* If a preselectedType is set, jump straight to step 2.
*/
private _wasOpen = false;
ngDoCheck(): void {
const open = this.isOpen;
// Lock / unlock body scroll whenever open state toggles
if (open && !this._wasOpen) {
this.lockBodyScroll();
} else if (!open && this._wasOpen) {
this.unlockBodyScroll();
}
this._wasOpen = open;
if (open && this.preselected && this.step === 1) {
this.selectedType = this.preselected;
this.step = 2;
this.formStep = 1;
}
}
// ── Type selection ────────────────────────────────────────────────
selectType(type: CreateEntityType): void {
this.selectedType = type;
}
navigateToCreate(type: 'Risk' | 'Incident' | 'Task'): void {
this.close();
if (type === 'Risk') {
this.router.navigate(['/risk-register'], { queryParams: { create: 'true' } });
} else if (type === 'Incident') {
this.router.navigate(['/incident-management'], { queryParams: { create: 'true' } });
} else if (type === 'Task') {
this.router.navigate(['/task-management'], { queryParams: { create: 'true' } });
}
}
proceedFromTypeSelection(): void {
if (!this.selectedType) return;
this.step = 2;
this.formStep = 1;
}
// ── Navigation ────────────────────────────────────────────────────
goBack(): void {
if (this.formStep > 1) {
this.formStep--;
} else {
// Back to type selector
this.step = 1;
this.selectedType = null;
this.formStep = 1;
}
}
// Fix: use this.formStep
// private get formStep(): number { return this._formStep; }
// private set formStep(v: number) { this._formStep = v; }
_formStep = 1;
nextStep(): void {
if (!this.canProceed()) return;
this._formStep++;
}
prevStep(): void {
if (this._formStep > 1) this._formStep--;
}
isLastFormStep(): boolean {
if (this.selectedType === 'Risk') return this._formStep === this.RISK_TOTAL_STEPS;
if (this.selectedType === 'Incident') return this._formStep === 3;
// Task and Assessment are single-step
return this.selectedType === 'Task' || this.selectedType === 'Assessment';
}
// ── Titles ────────────────────────────────────────────────────────
getTitle(): string {
if (this.step === 1) return 'What would you like to create?';
if (this.selectedType === 'Task') return 'Create Task';
if (this.selectedType === 'Incident') return 'Create Incident';
if (this.selectedType === 'Assessment') return 'Create Assessment';
if (this.selectedType === 'Risk')
return `Create Risk — ${RISK_STEPS[this._formStep - 1] || 'Lifecycle'}`;
return '';
}
getSubtitle(): string {
if (this.step === 1) return 'Select an entity type to get started';
if (this.selectedType === 'Task') return 'Create a new task linked to an entity';
if (this.selectedType === 'Assessment') return 'Run a structured risk or compliance assessment';
return '';
}
getRiskStepName(): string {
return RISK_STEPS[this._formStep - 1] ?? '';
}
// ── Validation ────────────────────────────────────────────────────
canProceed(): boolean {
if (this.selectedType === 'Incident') {
if (this._formStep === 1) return !!this.incidentForm.title && !!this.incidentForm.category && !!this.incidentForm.priority;
if (this._formStep === 2) return !!this.incidentForm.impact;
return true;
}
if (this.selectedType === 'Task') {
return !!this.taskForm.name && !!this.taskForm.owner && !!this.taskForm.dueDate;
}
if (this.selectedType === 'Assessment') {
return !!this.assessmentForm.name && !!this.assessmentForm.type;
}
if (this.selectedType === 'Risk') {
if (this._formStep === 1) return !!this.riskForm.name && !!this.riskForm.category;
return true; // placeholder steps pass through
}
return false;
}
// ── Submit ────────────────────────────────────────────────────────
handleSubmit(): void {
if (!this.canProceed() || this.isSubmitting) return;
this.isSubmitting = true;
// Simulate async save — replace with your real API service call
setTimeout(() => {
this.isSubmitting = false;
console.log('Submitted:', this.selectedType, this.getFormData());
this.close();
// Optionally navigate: this.router.navigateByUrl('/risks');
}, 1200);
}
private getFormData(): unknown {
switch (this.selectedType) {
case 'Incident': return this.incidentForm;
case 'Task': return this.taskForm;
case 'Assessment': return this.assessmentForm;
case 'Risk': return this.riskForm;
default: return {};
}
}
// ── Close / reset ─────────────────────────────────────────────────
close(): void {
this.overlayService.close();
// Reset after animation frame so closing feels instant
setTimeout(() => {
this.step = 1;
this.selectedType = null;
this._formStep = 1;
this.isSubmitting = false;
this.incidentForm = this.freshIncident();
this.taskForm = this.freshTask();
this.assessmentForm = this.freshAssessment();
this.riskForm = this.freshRisk();
}, 200);
}
onBackdropClick(event: MouseEvent): void {
// Only close if the backdrop itself was clicked (not the panel)
if ((event.target as HTMLElement) === event.currentTarget) {
this.close();
}
}
/** Escape key closes overlay */
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.isOpen) this.close();
}
// ── Fresh form factories ──────────────────────────────────────────
private freshIncident(): IncidentForm {
return { title:'', category:'', priority:'', status:'Open', dateOccurred:'',
description:'', impact:'', urgency:'', financialLoss:null,
affectedSystems:'', immediateActions:'' };
}
private freshTask(): TaskForm {
return { name:'', linkedEntityType:'', linkedEntityId:'',
owner:'', dueDate:'', priority:'', description:'' };
}
private freshAssessment(): AssessmentForm {
return { name:'', type:'', dueDate:'', assignedTo:'', priority:'', scope:'' };
}
private freshRisk(): RiskForm {
return { name:'', category:'', primaryOwner:'', description:'',
likelihood:'', impact:'' };
}
}
@@ -0,0 +1,62 @@
import { Injectable, signal, computed } from '@angular/core';
export type CreateEntityType = 'Risk' | 'Incident' | 'Task' | 'Assessment' | null;
export interface LinkedEntity {
type: 'Risk' | 'Incident' | 'Task';
id: string;
name: string;
}
export interface CreateOverlayState {
isOpen: boolean;
preselectedType: CreateEntityType;
linkedEntity?: LinkedEntity;
}
/**
* Angular equivalent of React's CreateOverlayContext.
* Inject this wherever you need to open the Create overlay.
*
* Usage:
* constructor(private createOverlay: CreateOverlayService) {}
* this.createOverlay.open({ type: 'Risk' });
* this.createOverlay.open(); // shows type selector
*/
@Injectable({ providedIn: 'root' })
export class CreateOverlayService {
private _state = signal<CreateOverlayState>({
isOpen: false,
preselectedType: null,
});
/** Read-only state signal */
readonly state = this._state.asReadonly();
/** Convenience computed signals */
readonly isOpen = computed(() => this._state().isOpen);
readonly preselected = computed(() => this._state().preselectedType);
readonly linkedEntity = computed(() => this._state().linkedEntity);
/**
* Open the overlay. Optionally pre-select a type and/or linked entity.
* Matches React: openCreate({ type?, linkedEntity? })
*/
open(opts?: { type?: CreateEntityType; linkedEntity?: LinkedEntity }): void {
this._state.set({
isOpen: true,
preselectedType: opts?.type ?? null,
linkedEntity: opts?.linkedEntity,
});
}
/** Close and reset — matches React closeCreate() */
close(): void {
this._state.set({
isOpen: false,
preselectedType: null,
linkedEntity: undefined,
});
}
}
@@ -0,0 +1,85 @@
<!-- User Menu Wrapper -->
<div class="relative" #menuRef>
<!-- ── Avatar Button ── -->
<button type="button" (click)="toggleMenu()"
class="flex items-center gap-2.5 px-2.5 py-1.5 rounded-xl transition-all duration-150 border-none bg-transparent cursor-pointer hover:bg-[var(--clr-bg-hover)]">
<!-- Avatar circle with initials -->
<div class="w-8 h-8 rounded-xl flex items-center justify-center text-white text-[12px] font-semibold flex-shrink-0"
style="background: linear-gradient(135deg, #4F46E5, #4338CA); box-shadow: 0 1px 3px rgba(79,70,229,0.3);">
{{ initials }}
</div>
<!-- Name + role (hidden on small screens, visible on sm+) -->
<div class="text-left hidden sm:block">
<div class="text-[13px] font-medium text-[var(--clr-text)]">{{ displayName }}</div>
<div class="text-[11px] text-[var(--clr-text-subtle)]">{{ displayRole }}</div>
</div>
<!-- Chevron -->
<svg class="w-3.5 h-3.5 hidden sm:block transition-transform duration-150 text-[var(--clr-text-subtle)]"
[style.transform]="isOpen ? 'rotate(180deg)' : 'rotate(0deg)'" fill="none" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- ── Dropdown ── -->
<div *ngIf="isOpen" class="absolute right-0 mt-2 w-64 bg-[var(--clr-bg-card)] rounded-2xl py-2 z-50 border border-[var(--clr-border)]"
style="box-shadow: var(--shadow-xl);">
<!-- User info header -->
<div class="px-4 py-3 border-b border-[var(--clr-border)]">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center text-white font-semibold flex-shrink-0"
style="background: linear-gradient(135deg, #4F46E5, #4338CA); box-shadow: 0 1px 3px rgba(79,70,229,0.3);">
{{ initials }}
</div>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[13px] truncate text-[var(--clr-text)]">{{ displayName }}</div>
<div class="text-[12px] truncate text-[var(--clr-text-subtle)]">{{ displayEmail }}</div>
</div>
</div>
<!-- Role badge -->
<div class="mt-2" *ngIf="displayRole">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-[var(--clr-primary-lt)] text-[var(--clr-primary)] border border-[var(--clr-border-focus)]">{{ displayRole }}</span>
</div>
</div>
<!-- Menu items -->
<div class="py-1 px-1">
<button type="button" (click)="navigate('/account-details')"
class="w-full flex items-center gap-3 px-3 py-2 text-[13px] rounded-xl transition-all duration-150 border-none bg-transparent cursor-pointer text-left text-[var(--clr-text-muted)] hover:bg-[var(--clr-bg-hover)] hover:text-[var(--clr-text)]">
<!-- User icon -->
<svg class="w-4 h-4 flex-shrink-0 text-[var(--clr-text-subtle)]" fill="none" viewBox="0 0 24 24" stroke="currentColor"
stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
<span>My Account</span>
</button>
</div>
<!-- Logout -->
<div class="px-1 pt-1 mt-1 border-t border-[var(--clr-border)]">
<button *ngIf="displayName !== 'Guest User'" type="button" (click)="handleLogout()"
class="w-full flex items-center gap-3 px-3 py-2 text-[13px] rounded-xl transition-all duration-150 border-none bg-transparent cursor-pointer text-left text-red-500 hover:bg-red-500/10">
<svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
<span>Log Out</span>
</button>
<button *ngIf="displayName === 'Guest User'" type="button" (click)="navigate('/login')"
class="w-full flex items-center gap-3 px-3 py-2 text-[13px] rounded-xl transition-all duration-150 border-none bg-transparent cursor-pointer text-left text-[var(--clr-primary)] hover:bg-[var(--clr-primary-lt)]">
<svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m-6 0l3-3m0 0l-3-3m3 3H3" />
</svg>
<span>Log In</span>
</button>
</div>
</div>
</div>
@@ -0,0 +1,115 @@
import {
Component,
OnInit,
OnDestroy,
HostListener,
ElementRef,
ViewChild,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { ConfigStateService, AuthService } from '@abp/ng.core';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-user-menu',
templateUrl: './user-menu.component.html',
standalone: true,
imports: [CommonModule],
})
export class UserMenuComponent implements OnInit, OnDestroy {
private readonly router = inject(Router);
private readonly config = inject(ConfigStateService);
private readonly auth = inject(AuthService);
private readonly destroy$ = new Subject<void>();
@ViewChild('menuRef') menuRef!: ElementRef<HTMLDivElement>;
isOpen = false;
// ── Derived display values ────────────────────────────────────────
displayName = 'Guest User';
displayEmail = 'guest@company.com';
displayRole = 'Department';
initials = 'GU';
ngOnInit(): void {
// this.loadUser();
this.config.getAll$()
.pipe(takeUntil(this.destroy$))
.subscribe(() => this.loadUser());
}
private loadUser(): void {
const currentUser = this.config.getOne('currentUser') as any;
// Reset to guest defaults when logged out
if (!currentUser?.isAuthenticated) {
this.displayName = 'Guest User';
this.displayEmail = '';
this.displayRole = '';
this.initials = 'GU';
return;
}
const name: string = currentUser.name
|| currentUser.userName
|| currentUser.email
|| 'Guest User';
this.displayName = name;
this.displayEmail = currentUser.email ?? '';
const roles: string[] = currentUser.roles ?? [];
this.displayRole = roles[0] ?? 'User';
this.initials = this.getInitials(name);
}
private getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
// ── Toggle ────────────────────────────────────────────────────────
toggleMenu(): void {
this.isOpen = !this.isOpen;
}
// ── Close on outside click — mirrors React useEffect ─────────────
@HostListener('document:mousedown', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (
this.isOpen &&
this.menuRef?.nativeElement &&
!this.menuRef.nativeElement.contains(event.target as Node)
) {
this.isOpen = false;
}
}
// ── Navigation ────────────────────────────────────────────────────
navigate(path: string): void {
this.router.navigateByUrl(path);
this.isOpen = false;
}
// ── Logout — mirrors React handleLogout ──────────────────────────
handleLogout(): void {
this.isOpen = false;
this.auth.logout().subscribe({
next: () => { /* ABP handles redirect via post_logout_redirect_uri */ },
error: () => this.router.navigateByUrl('/'),
});
}
ngOnDestroy(): void {
// HostListener cleanup is automatic
this.destroy$.next();
this.destroy$.complete();
}
}
@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { eLayoutType } from '@abp/ng.core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'abp-layout-empty',
template: ` <router-outlet></router-outlet> `,
imports: [RouterOutlet],
})
export class EmptyLayoutComponent {
static type = eLayoutType.empty;
}
@@ -0,0 +1,12 @@
export * from './account-layout/account-layout.component';
export * from './account-layout/auth-wrapper/auth-wrapper.component';
export * from './account-layout/tenant-box/tenant-box.component';
export * from './application-layout/application-layout.component';
export * from './empty-layout/empty-layout.component';
export * from './logo/logo.component';
export * from './nav-items/current-user.component';
export * from './nav-items/languages.component';
export * from './nav-items/nav-items.component';
export * from './page-alert-container/page-alert-container.component';
export * from './routes/routes.component';
export * from './validation-error/validation-error.component';
@@ -0,0 +1,148 @@
import { EnvironmentService } from '@abp/ng.core';
import { RouterLink } from '@angular/router';
import { Component, inject } from '@angular/core';
import { LOGO_APP_NAME_TOKEN, LOGO_URL_TOKEN } from '@abp/ng.theme.shared';
import { CommonModule } from '@angular/common';
@Component({
selector: 'abp-logo',
template: `
<a class="logo-link" routerLink="/">
<!-- ── If a logoUrl is configured in environment.ts, use it ── -->
<ng-container *ngIf="logoUrl; else defaultLogo">
<img [src]="logoUrl" [alt]="appName" class="logo-img" />
</ng-container>
<!-- ── Default: branded wordmark ── -->
<ng-template #defaultLogo>
<div class="logo-wordmark">
<!-- Icon mark -->
<div class="logo-icon">
<svg
width="28"
height="28"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<!-- Shield base -->
<path
d="M16 2L4 7.5V16.5C4 22.85 9.33 28.74 16 30C22.67 28.74 28 22.85 28 16.5V7.5L16 2Z"
fill="url(#shield-grad)"
/>
<!-- Inner check / chart line — ERM motif -->
<polyline
points="9,16 13,20 23,11"
stroke="white"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<defs>
<linearGradient
id="shield-grad"
x1="4"
y1="2"
x2="28"
y2="30"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stop-color="var(--clr-primary)" />
<stop offset="100%" stop-color="var(--clr-primary-dk)" />
</linearGradient>
</defs>
</svg>
</div>
<!-- Text -->
<div class="logo-text">
<span class="logo-text__brand">Delight</span><span class="logo-text__suffix">ERM</span>
</div>
</div>
</ng-template>
</a>
`,
styles: [
`
:host {
display: block;
}
/* ── Link reset ───────────────────────────────────────── */
.logo-link {
display: flex;
align-items: center;
text-decoration: none;
outline: none;
user-select: none;
}
/* ── External image logo ──────────────────────────────── */
.logo-img {
height: 32px;
width: auto;
display: block;
}
/* ── Wordmark layout ──────────────────────────────────── */
.logo-wordmark {
display: flex;
align-items: center;
gap: 9px;
}
/* ── Icon mark ────────────────────────────────────────── */
.logo-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
filter: drop-shadow(0 2px 4px rgba(37, 99, 235, 0.25));
svg {
display: block;
}
}
/* ── Text ─────────────────────────────────────────────── */
.logo-text {
display: flex;
align-items: baseline;
gap: 1px;
line-height: 1;
}
.logo-text__brand {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.3px;
color: var(--clr-text);
font-family: var(--font-base);
}
.logo-text__suffix {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.3px;
color: var(--clr-primary);
font-family: var(--font-base);
}
`,
],
standalone: true,
imports: [RouterLink, CommonModule],
})
export class LogoComponent {
private readonly environment = inject(EnvironmentService);
private readonly providedLogoUrl = inject(LOGO_URL_TOKEN, { optional: true });
private readonly providedAppName = inject(LOGO_APP_NAME_TOKEN, { optional: true });
get logoUrl(): string {
return this.providedLogoUrl ?? this.environment.getEnvironment().application?.logoUrl ?? '';
}
get appName(): string {
return this.providedAppName ?? this.environment.getEnvironment().application?.name ?? 'DelightERM';
}
}
@@ -0,0 +1,62 @@
@if ((currentUser$ | async)?.isAuthenticated) {
<div
ngbDropdown
class="dropdown"
#currentUserDropdown="ngbDropdown"
display="static"
>
<a
ngbDropdownToggle
class="nav-link"
href="javascript:void(0)"
role="button"
id="dropdownMenuLink"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
@if ((selectedTenant$ | async)?.name; as tenantName) {
<small
><i>{{ tenantName }}</i
>\</small
>
}
<strong>{{ (currentUser$ | async)?.userName }}</strong>
</a>
<div
class="dropdown-menu dropdown-menu-end border-0 shadow-sm"
aria-labelledby="dropdownMenuLink"
[class.d-block]="smallScreen && currentUserDropdown.isOpen()"
>
@for (item of userMenu.items$ | async; track $index) {
<ng-container *abpVisible="!item.visible || item.visible(item)">
<li class="nav-item d-flex align-items-center" *abpPermission="item.requiredPolicy || ''">
@if (item.component) {
<ng-container
[ngComponentOutlet]="item.component"
[ngComponentOutletInjector]="item | toInjector"
></ng-container>
} @else {
@if (item.html) {
<div [innerHTML]="item.html" (click)="item.action ? item.action() : null"></div>
} @else {
@if (item.textTemplate) {
<a (click)="item.action ? item.action() : null" class="dropdown-item pointer">
@if (item.textTemplate.icon) {
<i class="me-1 {{ item.textTemplate.icon }}"></i>
}
{{ item.textTemplate.text | abpLocalization }}</a
>
}
}
}
</li>
</ng-container>
}
</div>
</div>
} @else {
<a role="button" class="nav-link pointer" (click)="navigateToLogin()">
{{ 'AbpAccount::Login' | abpLocalization }}
</a>
}
@@ -0,0 +1,53 @@
import {
AuthService,
ConfigStateService,
CurrentUserDto,
LocalizationPipe,
NAVIGATE_TO_MANAGE_PROFILE,
PermissionDirective,
SessionStateService,
ToInjectorPipe,
} from '@abp/ng.core';
import { AbpVisibleDirective, UserMenu, UserMenuService } from '@abp/ng.theme.shared';
import { Component, TrackByFunction, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { NgComponentOutlet, AsyncPipe, DOCUMENT } from '@angular/common';
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
@Component({
selector: 'abp-current-user',
templateUrl: './current-user.component.html',
imports: [
NgComponentOutlet,
AsyncPipe,
NgbDropdownModule,
AbpVisibleDirective,
PermissionDirective,
ToInjectorPipe,
LocalizationPipe,
],
})
export class CurrentUserComponent {
readonly navigateToManageProfile = inject(NAVIGATE_TO_MANAGE_PROFILE);
readonly userMenu = inject(UserMenuService);
private authService = inject(AuthService);
private configState = inject(ConfigStateService);
private sessionState = inject(SessionStateService);
private document = inject(DOCUMENT);
currentUser$: Observable<CurrentUserDto> = this.configState.getOne$('currentUser');
selectedTenant$ = this.sessionState.getTenant$();
trackByFn: TrackByFunction<UserMenu> = (_, element) => element.id;
get smallScreen(): boolean {
return (this.document.defaultView?.innerWidth ?? 0) < 992;
}
navigateToLogin() {
this.authService.navigateToLogin();
}
logout() {
this.authService.logout().subscribe();
}
}
@@ -0,0 +1,79 @@
import { ConfigStateService, LanguageInfo, SessionStateService } from '@abp/ng.core';
import { Component, inject } from '@angular/core';
import { DOCUMENT, AsyncPipe } from '@angular/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
@Component({
selector: 'abp-languages',
template: `
@if (((dropdownLanguages$ | async)?.length || 0) > 0) {
<div class="dropdown" ngbDropdown #languageDropdown="ngbDropdown" display="static">
<a
ngbDropdownToggle
class="nav-link"
href="javascript:void(0)"
role="button"
id="dropdownMenuLink"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
{{ defaultLanguage$ | async }}
</a>
<div
class="dropdown-menu dropdown-menu-end border-0 shadow-sm"
aria-labelledby="dropdownMenuLink"
[class.d-block]="smallScreen && languageDropdown.isOpen()"
>
@for (lang of dropdownLanguages$ | async; track $index) {
<a
href="javascript:void(0)"
class="dropdown-item"
(click)="onChangeLang(lang.cultureName || '')"
>{{ lang?.displayName }}</a
>
}
</div>
</div>
}
`,
imports: [AsyncPipe, NgbDropdownModule],
})
export class LanguagesComponent {
private sessionState = inject(SessionStateService);
private configState = inject(ConfigStateService);
document = inject(DOCUMENT);
get smallScreen(): boolean {
return (this.document.defaultView?.innerWidth ?? 0) < 992;
}
languages$: Observable<LanguageInfo[]> = this.configState.getDeep$('localization.languages');
get defaultLanguage$(): Observable<string> {
return this.languages$.pipe(
map(
languages =>
languages?.find(lang => lang.cultureName === this.selectedLangCulture)?.displayName || '',
),
);
}
get dropdownLanguages$(): Observable<LanguageInfo[]> {
return this.languages$.pipe(
map(
languages => languages?.filter(lang => lang.cultureName !== this.selectedLangCulture) || [],
),
);
}
get selectedLangCulture(): string {
return this.sessionState.getLanguage();
}
onChangeLang(cultureName: string) {
this.sessionState.setLanguage(cultureName);
}
}
@@ -0,0 +1,18 @@
<ul class="navbar-nav">
@if (isBrowser) {
@for (item of navItems.items$ | async; track $index) {
<ng-container *abpVisible="!item.visible || item.visible(item)">
<li class="nav-item d-flex align-items-center" *abpPermission="item.requiredPolicy || ''">
@if (item.component) {
<ng-container
[ngComponentOutlet]="item.component"
[ngComponentOutletInjector]="item | toInjector"
></ng-container>
} @else {
<div [innerHTML]="item.html" (click)="item.action ? item.action() : null"></div>
}
</li>
</ng-container>
}
}
</ul>
@@ -0,0 +1,17 @@
import { AbpVisibleDirective, NavItem, NavItemsService } from '@abp/ng.theme.shared';
import { Component, TrackByFunction, inject, PLATFORM_ID } from '@angular/core';
import { NgComponentOutlet, AsyncPipe, isPlatformBrowser } from '@angular/common';
import { PermissionDirective, ToInjectorPipe } from '@abp/ng.core';
@Component({
selector: 'abp-nav-items',
templateUrl: 'nav-items.component.html',
imports: [NgComponentOutlet, AsyncPipe, AbpVisibleDirective, PermissionDirective, ToInjectorPipe],
})
export class NavItemsComponent {
readonly navItems = inject(NavItemsService);
private platformId = inject(PLATFORM_ID);
readonly isBrowser = isPlatformBrowser(this.platformId);
trackByFn: TrackByFunction<NavItem> = (_, element) => element.id;
}
@@ -0,0 +1,26 @@
@for (alert of service.alerts$ | async; track i; let i = $index) {
<div
class="alert alert-{{ alert.type }} fade show"
[ngClass]="{ 'alert-dismissible fade show': alert.dismissible }"
role="alert"
>
@if (alert.title) {
<h4
class="alert-heading"
[innerHTML]="alert.title | abpSafeHtml | abpLocalization: alert.titleLocalizationParams"
></h4>
}
<span
[innerHTML]="alert.message | abpSafeHtml | abpLocalization: alert.messageLocalizationParams"
></span>
@if (alert.dismissible) {
<button
type="button"
class="btn-close"
data-dismiss="alert"
aria-label="Close"
(click)="service.remove(i)"
></button>
}
</div>
}
@@ -0,0 +1,14 @@
import { Component, ViewEncapsulation, inject } from '@angular/core';
import { PageAlertService } from '@abp/ng.theme.shared';
import { NgClass, AsyncPipe } from '@angular/common';
import { LocalizationPipe, SafeHtmlPipe } from '@abp/ng.core';
@Component({
selector: 'abp-page-alert-container',
templateUrl: './page-alert-container.component.html',
encapsulation: ViewEncapsulation.None,
imports: [NgClass, AsyncPipe, LocalizationPipe, SafeHtmlPipe],
})
export class PageAlertContainerComponent {
service = inject(PageAlertService);
}
@@ -0,0 +1,109 @@
<ul class="navbar-nav">
@for (route of routesService.visible$ | async; track $index) {
<ng-container
[ngTemplateOutlet]="isDropdown(route) ? dropdownLink : defaultLink"
[ngTemplateOutletContext]="{ $implicit: route }"
/>
}
<ng-template #defaultLink let-route>
<li class="nav-item" *abpPermission="route.requiredPolicy">
<a class="nav-link" [routerLink]="[route.path]">
@if (route.iconClass) {
<i [ngClass]="route.iconClass"></i>
}
{{ route.name | abpAsyncLocalization | async }}
</a>
</li>
</ng-template>
<ng-template #dropdownLink let-route>
@if (route.children?.length) {
<li
class="nav-item dropdown"
display="static"
*abpPermission="route.requiredPolicy"
(click)="
rootDropdownExpand[route.name]
? (rootDropdownExpand[route.name] = false)
: (rootDropdownExpand[route.name] = true)
"
>
<a
class="nav-link dropdown-toggle"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
href="javascript:void(0)"
>
@if (route.iconClass) {
<i [ngClass]="route.iconClass"></i>
}
{{ route.name | abpAsyncLocalization | async }}
</a>
<div
#routeContainer
class="dropdown-menu border-0 shadow-sm"
(click)="$event.preventDefault(); $event.stopPropagation()"
[class.d-block]="smallScreen && rootDropdownExpand[route.name]"
>
<ng-container *ngTemplateOutlet="forTemplate; context: { $implicit: route }" />
</div>
</li>
}
</ng-template>
<ng-template #forTemplate let-route>
@for (child of route.children; track $index) {
<ng-template
[ngTemplateOutlet]="child.children?.length ? dropdownChild : defaultChild"
[ngTemplateOutletContext]="{ $implicit: child }"
/>
}
</ng-template>
<ng-template #defaultChild let-child>
@if (child.path) {
<div class="dropdown-submenu" *abpPermission="child.requiredPolicy">
<a class="dropdown-item" [routerLink]="[child.path]" (click)="closeDropdown()">
@if (child.iconClass) {
<i [ngClass]="child.iconClass"></i>
}
{{ child.name | abpLocalization }}</a
>
</div>
}
</ng-template>
<ng-template #dropdownChild let-child>
<div
class="dropdown-submenu"
ngbDropdown
#dropdownSubmenu="ngbDropdown"
placement="right-top"
[autoClose]="true"
*abpPermission="child.requiredPolicy"
>
<div ngbDropdownToggle [class.dropdown-toggle]="false">
<a
abpEllipsis="210px"
[abpEllipsisEnabled]="!smallScreen"
role="button"
class="btn d-block text-start dropdown-toggle"
>
@if (child.iconClass) {
<i [ngClass]="child.iconClass"></i>
}
{{ child.name | abpLocalization }}
</a>
</div>
<div
#childrenContainer
class="dropdown-menu dropdown-menu-start border-0 shadow-sm"
[class.d-block]="smallScreen && dropdownSubmenu.isOpen()"
>
<ng-container *ngTemplateOutlet="forTemplate; context: { $implicit: child }" />
</div>
</div>
</ng-template>
</ul>
@@ -0,0 +1,61 @@
import {
ABP,
AsyncLocalizationPipe,
LocalizationPipe,
PermissionDirective,
RoutesService,
TreeNode,
} from '@abp/ng.core';
import {
Component,
ElementRef,
inject,
Input,
QueryList,
Renderer2,
TrackByFunction,
ViewChildren,
} from '@angular/core';
import { NgTemplateOutlet, NgClass, AsyncPipe } from '@angular/common';
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap';
import { RouterLink } from '@angular/router';
import { EllipsisDirective } from '@abp/ng.theme.shared';
@Component({
selector: 'abp-routes',
templateUrl: 'routes.component.html',
imports: [
NgTemplateOutlet,
NgClass,
AsyncPipe,
RouterLink,
NgbDropdownModule,
AsyncLocalizationPipe,
PermissionDirective,
EllipsisDirective,
LocalizationPipe,
],
})
export class RoutesComponent {
public readonly routesService = inject(RoutesService);
protected renderer = inject(Renderer2);
@Input() smallScreen?: boolean;
@ViewChildren('childrenContainer') childrenContainers!: QueryList<ElementRef<HTMLDivElement>>;
rootDropdownExpand = {} as { [key: string]: boolean };
trackByFn: TrackByFunction<TreeNode<ABP.Route>> = (_, item) => item.name;
isDropdown(node: TreeNode<ABP.Route>) {
return !node?.isLeaf || this.routesService.hasChildren(node.name);
}
closeDropdown() {
this.childrenContainers.forEach(({ nativeElement }) => {
this.renderer.addClass(nativeElement, 'd-none');
setTimeout(() => this.renderer.removeClass(nativeElement, 'd-none'), 0);
});
}
}
@@ -0,0 +1,38 @@
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
import { Validation, ValidationErrorComponent as ErrorComponent } from '@ngx-validate/core';
import { LocalizationPipe } from '@abp/ng.core';
@Component({
selector: 'abp-validation-error',
template: `
@for (error of abpErrors; track $index) {
<div class="invalid-feedback">
{{ error.message | abpLocalization: error.interpoliteParams }}
</div>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [LocalizationPipe],
})
export class ValidationErrorComponent extends ErrorComponent {
get abpErrors(): (Validation.Error & { interpoliteParams?: string[] })[] {
if (!this.errors || !this.errors.length) return [];
return this.errors.map(error => {
if (!error.message) return error;
const index = error.message.indexOf('[');
if (index > -1) {
return {
...error,
message: error.message.slice(0, index),
interpoliteParams: error.message.slice(index + 1, error.message.length - 1).split(','),
};
}
return error;
});
}
}
@@ -0,0 +1,182 @@
export default `
.content-header-title {
font-size: 24px;
}
.entry-row {
margin-bottom: 15px;
}
#main-navbar-tools a.dropdown-toggle {
text-decoration: none;
color: #fff;
}
.navbar .dropdown-submenu {
position: relative;
}
.navbar .dropdown-menu {
margin: 0;
padding: 0;
}
.navbar .dropdown-menu a {
font-size: .9em;
padding: 10px 15px;
display: block;
min-width: 210px;
text-align: left;
border-radius: 0.25rem;
min-height: 44px;
}
[dir=rtl] .navbar .dropdown-menu a {
text-align: right!important;
}
.navbar .dropdown-submenu a::after {
transform: rotate(-90deg);
position: absolute;
right: 16px;
top: 18px;
}
[dir=rtl] .navbar .dropdown-submenu a::after {
transform: rotate(90deg);
left: 16px;
right: auto;
top: 20px;
}
.navbar .dropdown-submenu .dropdown-menu {
top: 0;
left: 100%;
}
/* work around for rtl. Track https://github.com/ng-bootstrap/ng-bootstrap/issues/4100 issue */
[dir=rtl] .navbar .dropdown-submenu .dropdown-menu {
top: 0;
right: 100%;
}
.card-header .btn {
padding: 2px 6px;
}
.card-header h5 {
margin: 0;
}
.container > .card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important;
}
@media screen and (min-width: 992px) {
.navbar .dropdown:hover > .dropdown-menu {
display: block;
}
.navbar .dropdown-submenu:hover > .dropdown-menu {
display: block;
}
}
.input-validation-error {
border-color: #dc3545;
}
.field-validation-error {
font-size: 0.8em;
}
.ui-table .ui-table-tbody > tr.empty-row > div.empty-row-content {
border: 1px solid #c8c8c8;
}
.abp-loading {
background: rgba(0, 0, 0, 0.05);
}
.modal-backdrop {
background-color: rgba(0, 0, 0, 0.6);
}
.confirmation .confirmation-backdrop {
background: rgba(0, 0, 0, 0.7) !important;
}
.confirmation .confirmation-dialog {
border: none;
border-radius: 10px;
background-color: #fff;
box-shadow: 0 0 10px -5px rgba(0, 0, 0, 0.5);
}
.confirmation .confirmation-dialog .icon-container .icon {
stroke: #fff;
color: #fff;
}
.confirmation .confirmation-dialog .icon-container.info .icon {
stroke: #2f96b4;
color: #2f96b4;
}
.confirmation .confirmation-dialog .icon-container.success .icon {
stroke: #51a351;
color: #51a351;
}
.confirmation .confirmation-dialog .icon-container.warning .icon {
stroke: #f89406;
color: #f89406;
}
.confirmation .confirmation-dialog .icon-container.error .icon {
stroke: #bd362f;
color: #bd362f;
}
.confirmation .confirmation-dialog .content .title {
color: #222;
}
.confirmation .confirmation-dialog .content .message {
color: #777;
}
.confirmation .confirmation-dialog .footer {
background: transparent;
}
.confirmation .confirmation-dialog .footer .confirmation-button {
background-color: #eee;
color: #777;
}
.confirmation .confirmation-dialog .footer .confirmation-button:hover, .confirmation .confirmation-dialog .footer .confirmation-button:focus, .confirmation .confirmation-dialog .footer .confirmation-button:active {
background-color: #bbb;
}
.confirmation .confirmation-dialog .footer .confirmation-button--confirm {
background-color: #2f96b4;
color: #fff;
}
.confirmation .confirmation-dialog .footer .confirmation-button--confirm:hover {
background-color: #2e819b;
}
.ui-table .pagination-wrapper {
background-color: #f4f4f4;
border: 1px solid #c8c8c8;
}
.bordered .datatable-body-row {
border-top: 1px solid #eee;
margin-top: -1px;
}
.breadcrumb {
background-color: transparent;
padding: 0.27rem;
}
.abp-md-form {
max-width: 540px;
}
abp-chat-icon,
abp-back-to-impersonator-nav-item {
margin-inline: 0.4rem;
}
abp-chat-icon {
.fa-comments:before {
color: var(--bs-navbar-color);
}
}
abp-back-to-impersonator-nav-item {
.fa-undo {
color: var(--bs-navbar-color);
}
}
.ngx-datatable.material:has(.datatable-body-row) .datatable-footer {
border-top: none;
}
.ngx-datatable.material:not(:has(.datatable-body-row)) .datatable-footer {
border-top: 1px solid #dee2e6;
}
`;
@@ -0,0 +1,10 @@
export const enum eThemeBasicComponents {
ApplicationLayout = 'Theme.ApplicationLayoutComponent',
AccountLayout = 'Theme.AccountLayoutComponent',
EmptyLayout = 'Theme.EmptyLayoutComponent',
Logo = 'Theme.LogoComponent',
Routes = 'Theme.RoutesComponent',
NavItems = 'Theme.NavItemsComponent',
CurrentUser = 'Theme.CurrentUserComponent',
Languages = 'Theme.LanguagesComponent',
}
@@ -0,0 +1,2 @@
export * from './components';
export * from './user-menu-items';
@@ -0,0 +1,4 @@
export const enum eUserMenuItems {
MyAccount = 'UserMenu.MyAccount',
Logout = 'UserMenu.Logout',
}
@@ -0,0 +1 @@
export * from './lazy-style.handler';
@@ -0,0 +1,104 @@
import { LazyLoadService, LOADING_STRATEGY } from '@abp/ng.core';
import { DocumentDirHandlerService, LocaleDirection } from '@abp/ng.theme.shared';
import { Injectable, Injector, inject } from '@angular/core';
import { LAZY_STYLES } from '../tokens/lazy-styles.token';
import { DOCUMENT } from '@angular/common';
export const BOOTSTRAP = 'bootstrap-{{dir}}.min.css';
@Injectable()
export class LazyStyleHandler {
private document = inject(DOCUMENT);
private lazyLoad!: LazyLoadService;
private styles!: string[];
private _dir: LocaleDirection = 'ltr';
readonly loaded = new Map<string, HTMLLinkElement>();
set dir(dir: LocaleDirection) {
if (dir === this._dir) return;
this.switchCSS(dir);
this._dir = dir;
}
get dir(): LocaleDirection {
return this._dir;
}
constructor() {
const injector = inject(Injector);
this.setStyles(injector);
this.setLazyLoad(injector);
this.listenToDirectionChanges(injector);
}
private getHrefFromLink(link: HTMLLinkElement | null | undefined): string {
if (!link) return '';
const a = this.document.createElement('a');
a.href = link.href;
return a.pathname.replace(/^\//, '');
}
private getLoadedBootstrap(): LoadedStyle {
const href = createLazyStyleHref(BOOTSTRAP, this.dir);
const selector = `[href*="${href.replace(/\.css$/, '')}"]`;
const link = this.document.querySelector<HTMLLinkElement>(selector);
return { href, link };
}
private listenToDirectionChanges(injector: Injector) {
const docDirHandler = injector.get(DocumentDirHandlerService);
// will always listen, no need to unsubscribe
docDirHandler.dir$.subscribe(dir => {
this.dir = dir;
});
}
private setLazyLoad(injector: Injector) {
this.lazyLoad = injector.get(LazyLoadService);
const { href, link } = this.getLoadedBootstrap();
if (link) {
this.lazyLoad.loaded.set(href, link);
}
}
private setStyles(injector: Injector) {
this.styles = injector.get(LAZY_STYLES, [BOOTSTRAP]);
}
private switchCSS(dir: LocaleDirection) {
this.styles.forEach(style => {
const oldHref = createLazyStyleHref(style, this.dir);
const newHref = createLazyStyleHref(style, dir);
const link = this.loaded.get(newHref);
const href = this.getHrefFromLink(link) || newHref;
const strategy = LOADING_STRATEGY.PrependAnonymousStyleToHead(href);
this.lazyLoad.load(strategy).subscribe(() => {
const oldLink = this.lazyLoad.loaded.get(oldHref) as HTMLLinkElement;
this.loaded.delete(newHref);
this.loaded.set(oldHref, oldLink);
const newLink = this.lazyLoad.loaded.get(href) as HTMLLinkElement;
this.lazyLoad.loaded.delete(href);
this.lazyLoad.loaded.set(newHref, newLink);
this.lazyLoad.remove(oldHref);
});
});
}
}
export function createLazyStyleHref(style: string, dir: string): string {
return style.replace(/{{\s*dir\s*}}/g, dir);
}
export function initLazyStyleHandler() {
return () => new LazyStyleHandler();
}
interface LoadedStyle {
href: string;
link: HTMLLinkElement | null;
}
@@ -0,0 +1 @@
export * from './layout';
@@ -0,0 +1,13 @@
import { TemplateRef } from '@angular/core';
export namespace Layout {
export interface State {
navigationElements: NavigationElement[];
}
export interface NavigationElement {
name: string;
element: TemplateRef<any>;
order?: number;
}
}
@@ -0,0 +1,4 @@
export * from './nav-item.provider';
export * from './styles.provider';
export * from './user-menu.provider';
export * from './theme-basic-config.provider';
@@ -0,0 +1,27 @@
import { NavItemsService } from '@abp/ng.theme.shared';
import { inject, provideAppInitializer } from '@angular/core';
import { CurrentUserComponent } from '../components/nav-items/current-user.component';
import { LanguagesComponent } from '../components/nav-items/languages.component';
import { eThemeBasicComponents } from '../enums/components';
export const BASIC_THEME_NAV_ITEM_PROVIDERS = [
provideAppInitializer(() => {
configureNavItems();
}),
];
export function configureNavItems() {
const navItems = inject(NavItemsService);
navItems.addItems([
{
id: eThemeBasicComponents.Languages,
order: 100,
component: LanguagesComponent,
},
{
id: eThemeBasicComponents.CurrentUser,
order: 100,
component: CurrentUserComponent,
},
]);
}
@@ -0,0 +1,43 @@
import { CONTENT_STRATEGY, DomInsertionService, ReplaceableComponentsService } from '@abp/ng.core';
import { inject, provideAppInitializer } from '@angular/core';
import { AccountLayoutComponent } from '../components/account-layout/account-layout.component';
import { ApplicationLayoutComponent } from '../components/application-layout/application-layout.component';
import { EmptyLayoutComponent } from '../components/empty-layout/empty-layout.component';
import styles from '../constants/styles';
import { eThemeBasicComponents } from '../enums/components';
export const BASIC_THEME_STYLES_PROVIDERS = [
provideAppInitializer(() => {
const initializerFn = configureStyles(
inject(DomInsertionService),
inject(ReplaceableComponentsService),
);
return initializerFn();
}),
];
export function configureStyles(
domInsertion: DomInsertionService,
replaceableComponents: ReplaceableComponentsService,
) {
return () => {
domInsertion.insertContent(CONTENT_STRATEGY.AppendStyleToHead(styles));
initLayouts(replaceableComponents);
};
}
function initLayouts(replaceableComponents: ReplaceableComponentsService) {
replaceableComponents.add({
key: eThemeBasicComponents.ApplicationLayout,
component: ApplicationLayoutComponent,
});
replaceableComponents.add({
key: eThemeBasicComponents.AccountLayout,
component: AccountLayoutComponent,
});
replaceableComponents.add({
key: eThemeBasicComponents.EmptyLayout,
component: EmptyLayoutComponent,
});
}
@@ -0,0 +1,36 @@
import { makeEnvironmentProviders, inject, provideAppInitializer } from '@angular/core';
import {
VALIDATION_ERROR_TEMPLATE,
VALIDATION_TARGET_SELECTOR,
VALIDATION_INVALID_CLASSES,
} from '@ngx-validate/core';
import { LazyStyleHandler } from '../handlers';
import { BASIC_THEME_NAV_ITEM_PROVIDERS } from './nav-item.provider';
import { BASIC_THEME_STYLES_PROVIDERS } from './styles.provider';
import { BASIC_THEME_USER_MENU_PROVIDERS } from './user-menu.provider';
import { ValidationErrorComponent } from '../components';
export function provideThemeBasicConfig() {
return makeEnvironmentProviders([
BASIC_THEME_NAV_ITEM_PROVIDERS,
BASIC_THEME_USER_MENU_PROVIDERS,
BASIC_THEME_STYLES_PROVIDERS,
{
provide: VALIDATION_ERROR_TEMPLATE,
useValue: ValidationErrorComponent,
},
{
provide: VALIDATION_TARGET_SELECTOR,
useValue: '.form-group',
},
{
provide: VALIDATION_INVALID_CLASSES,
useValue: 'is-invalid',
},
LazyStyleHandler,
provideAppInitializer(() => {
inject(LazyStyleHandler);
}),
]);
}
@@ -0,0 +1,42 @@
import { AuthService, NAVIGATE_TO_MANAGE_PROFILE } from '@abp/ng.core';
import { UserMenuService } from '@abp/ng.theme.shared';
import { Injector, inject, provideAppInitializer } from '@angular/core';
import { eUserMenuItems } from '../enums/user-menu-items';
export const BASIC_THEME_USER_MENU_PROVIDERS = [
provideAppInitializer(() => {
const initializerFn = configureUserMenu(inject(Injector));
return initializerFn();
}),
];
export function configureUserMenu(injector: Injector) {
const userMenu = injector.get(UserMenuService);
const authService = injector.get(AuthService);
const navigateToManageProfile = injector.get(NAVIGATE_TO_MANAGE_PROFILE);
return () => {
userMenu.addItems([
{
id: eUserMenuItems.MyAccount,
order: 100,
textTemplate: {
text: 'AbpAccount::MyAccount',
icon: 'fa fa-cog',
},
action: () => navigateToManageProfile(),
},
{
id: eUserMenuItems.Logout,
order: 101,
textTemplate: {
text: 'AbpUi::Logout',
icon: 'fa fa-power-off',
},
action: () => {
authService.logout().subscribe();
},
},
]);
};
}
@@ -0,0 +1,2 @@
export * from './layout.service';
export * from './theme.service';
@@ -0,0 +1,54 @@
import {RouterEvents, SubscriptionService} from '@abp/ng.core';
import { ChangeDetectorRef, Injectable, inject } from '@angular/core';
import { fromEvent } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { eThemeBasicComponents } from '../enums';
import { DOCUMENT } from '@angular/common';
@Injectable()
export class LayoutService {
private subscription = inject(SubscriptionService);
private cdRef = inject(ChangeDetectorRef);
document = inject(DOCUMENT);
isCollapsed = true;
smallScreen!: boolean; // do not set true or false
logoComponentKey = eThemeBasicComponents.Logo;
routesComponentKey = eThemeBasicComponents.Routes;
navItemsComponentKey = eThemeBasicComponents.NavItems;
constructor() {
const subscription = this.subscription;
const routerEvents = inject(RouterEvents);
subscription.addOne(routerEvents.getNavigationEvents("End"),() => {
this.isCollapsed = true;
})
}
private checkWindowWidth() {
if (!this.document.defaultView) return;
const isSmallScreen = this.document.defaultView.innerWidth < 992;
if (isSmallScreen && this.smallScreen === false) {
this.isCollapsed = false;
setTimeout(() => {
this.isCollapsed = true;
}, 100);
}
this.smallScreen = isSmallScreen;
this.cdRef.detectChanges();
}
subscribeWindowSize() {
this.checkWindowWidth();
if (this.document.defaultView) {
const resize$ = fromEvent(this.document.defaultView, 'resize').pipe(debounceTime(150));
this.subscription.addOne(resize$, () => this.checkWindowWidth());
}
}
}
@@ -0,0 +1,91 @@
import { DOCUMENT } from '@angular/common';
import { Injectable, inject } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
export type AppTheme = 'light' | 'dark';
@Injectable({
providedIn: 'root',
})
export class ThemeService {
private readonly document = inject(DOCUMENT);
private readonly STORAGE_KEY = 'delight_theme';
private readonly themeSubject = new BehaviorSubject<AppTheme>(this.getInitialTheme());
readonly theme$: Observable<AppTheme> = this.themeSubject.asObservable();
constructor() {
this.applyTheme(this.currentTheme);
}
get currentTheme(): AppTheme {
return this.themeSubject.value;
}
get isDarkMode(): boolean {
return this.currentTheme === 'dark';
}
toggleTheme(): void {
const nextTheme: AppTheme = this.isDarkMode ? 'light' : 'dark';
this.setTheme(nextTheme);
}
setTheme(theme: AppTheme): void {
this.themeSubject.next(theme);
this.saveTheme(theme);
this.applyTheme(theme);
}
private getInitialTheme(): AppTheme {
try {
if (typeof window !== 'undefined' && window.localStorage) {
const saved = localStorage.getItem(this.STORAGE_KEY) as AppTheme | null;
if (saved === 'light' || saved === 'dark') {
return saved;
}
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
}
} catch {
// Fallback in case of restricted environment
}
return 'light';
}
private saveTheme(theme: AppTheme): void {
try {
if (typeof window !== 'undefined' && window.localStorage) {
localStorage.setItem(this.STORAGE_KEY, theme);
}
} catch {
// Ignore storage errors
}
}
private applyTheme(theme: AppTheme): void {
if (!this.document || !this.document.documentElement) {
return;
}
const root = this.document.documentElement;
const body = this.document.body;
if (theme === 'dark') {
root.classList.add('dark');
root.setAttribute('data-theme', 'dark');
if (body) {
body.classList.add('dark');
body.setAttribute('data-theme', 'dark');
}
} else {
root.classList.remove('dark');
root.setAttribute('data-theme', 'light');
if (body) {
body.classList.remove('dark');
body.setAttribute('data-theme', 'light');
}
}
}
}
@@ -0,0 +1,90 @@
import { DOCUMENT } from '@angular/common';
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest';
import { of, Subject } from 'rxjs';
import { vi } from 'vitest';
import { LazyLoadService, LoadingStrategy, LocalizationService } from '@abp/ng.core';
import { DocumentDirHandlerService } from '@abp/ng.theme.shared';
import { BOOTSTRAP, createLazyStyleHref, LazyStyleHandler } from '../handlers';
import { LAZY_STYLES } from '../tokens/lazy-styles.token';
import { setupComponentResources } from './utils';
describe('LazyStyleHandler', () => {
let spectator: SpectatorService<LazyStyleHandler>;
let handler: LazyStyleHandler;
let lazyLoad: any;
beforeAll(async () => {
await setupComponentResources(
'../components/breadcrumb',
import.meta.url
);
});
const dir$ = new Subject<'ltr' | 'rtl'>();
const createService = createServiceFactory({
service: LazyStyleHandler,
providers: [
{
provide: DOCUMENT,
useValue: document,
},
{
provide: LAZY_STYLES,
useValue: [BOOTSTRAP],
},
{
provide: LazyLoadService,
useValue: {
loaded: new Map(),
load: vi.fn(() => of(null)),
remove: vi.fn(),
},
},
{
provide: DocumentDirHandlerService,
useValue: {
dir$,
},
},
{
provide: LocalizationService,
useValue: {
currentLang: 'en',
currentLang$: of({ payload: 'en' }),
},
},
],
});
beforeEach(() => {
spectator = createService();
handler = spectator.service;
lazyLoad = spectator.inject(LazyLoadService);
});
describe('#dir', () => {
it('should initially be "ltr"', () => {
expect(handler.dir).toBe('ltr');
});
it('should set bootstrap to rtl', () => {
const oldHref = createLazyStyleHref(BOOTSTRAP, 'ltr');
const newHref = createLazyStyleHref(BOOTSTRAP, 'rtl');
lazyLoad.loaded.set(newHref, null);
const loadSpy = vi.spyOn(lazyLoad, 'load');
const removeSpy = vi.spyOn(lazyLoad, 'remove');
handler.dir = 'rtl';
expect(loadSpy).toHaveBeenCalledTimes(1);
const [strategy] = loadSpy.mock.calls[0];
expect((strategy as LoadingStrategy).path).toBe(newHref);
expect(removeSpy).toHaveBeenCalledWith(oldHref);
});
});
});
@@ -0,0 +1 @@
export * from './setup-component-resources';
@@ -0,0 +1,54 @@
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Sets up component resource resolution for Angular component tests.
* This is needed when components have external templates or stylesheets.
*
* @param componentDirPath - The path to the component directory relative to the test file.
* For example: '../components/loader-bar' or './components/my-component'
* @param testFileUrl - The import.meta.url from the test file. Defaults to the caller's location.
*
* @example
* ```typescript
*
* import { setupComponentResources } from './utils';
*
* beforeAll(() => setupComponentResources('../components/loader-bar', import.meta.url));
* ```
*/
export async function setupComponentResources(
componentDirPath: string,
testFileUrl: string = import.meta.url,
): Promise<void> {
try {
if (typeof process !== 'undefined' && process.versions?.node) {
const { ɵresolveComponentResources: resolveComponentResources } = await import('@angular/core');
// Get the test file directory path
const testFileDir = dirname(fileURLToPath(testFileUrl));
const componentDir = resolve(testFileDir, componentDirPath);
await resolveComponentResources((url: string) => {
// For SCSS/SASS files, return empty CSS since jsdom can't parse SCSS
if (url.endsWith('.scss') || url.endsWith('.sass')) {
return Promise.resolve('');
}
// For other files (HTML, CSS, etc.), read the actual content
try {
// Resolve relative paths like './component.scss' or 'component.scss'
const normalizedUrl = url.replace(/^\.\//, '');
const filePath = resolve(componentDir, normalizedUrl);
return Promise.resolve(readFileSync(filePath, 'utf-8'));
} catch (error) {
// If file not found, return empty string
return Promise.resolve('');
}
});
}
} catch (error) {
console.warn('Failed to set up component resource resolver:', error);
}
}
@@ -0,0 +1,54 @@
import { ModuleWithProviders, NgModule } from '@angular/core';
import {
AccountLayoutComponent,
AuthWrapperComponent,
TenantBoxComponent,
ApplicationLayoutComponent,
EmptyLayoutComponent,
LogoComponent,
CurrentUserComponent,
LanguagesComponent,
NavItemsComponent,
PageAlertContainerComponent,
RoutesComponent,
ValidationErrorComponent,
} from './components';
import { provideThemeBasicConfig } from './providers';
export const LAYOUTS = [ApplicationLayoutComponent, AccountLayoutComponent, EmptyLayoutComponent];
export const THEME_BASIC_COMPONENTS = [
...LAYOUTS,
ValidationErrorComponent,
LogoComponent,
NavItemsComponent,
RoutesComponent,
CurrentUserComponent,
LanguagesComponent,
PageAlertContainerComponent,
AuthWrapperComponent,
TenantBoxComponent,
];
@NgModule({
declarations: [],
exports: [...THEME_BASIC_COMPONENTS],
imports: [...THEME_BASIC_COMPONENTS],
})
export class BaseThemeBasicModule {}
@NgModule({
exports: [BaseThemeBasicModule],
imports: [BaseThemeBasicModule],
})
export class ThemeBasicModule {
/**
* @deprecated forRoot method is deprecated, use `provideThemeBasicConfig` *function* for config settings.
*/
static forRoot(): ModuleWithProviders<ThemeBasicModule> {
return {
ngModule: ThemeBasicModule,
providers: [provideThemeBasicConfig()],
};
}
}
@@ -0,0 +1 @@
export * from './lazy-styles.token';
@@ -0,0 +1,3 @@
import { InjectionToken } from '@angular/core';
export const LAZY_STYLES = new InjectionToken<string[]>('LAZY_STYLES');
@@ -0,0 +1,12 @@
/*
* Public API Surface of theme-basic
*/
export * from './lib/components';
export * from './lib/enums';
export * from './lib/handlers';
export * from './lib/models';
export * from './lib/providers';
export * from './lib/services';
export * from './lib/theme-basic.module';
export * from './lib/tokens';
@@ -0,0 +1,20 @@
import '@angular/compiler';
import 'zone.js';
import 'zone.js/testing';
import { getTestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
// Initialize Angular testing environment
getTestBed().initTestEnvironment(BrowserTestingModule, platformBrowserTesting());
// Mock window.location for test environment
Object.defineProperty(window, 'location', {
value: {
href: 'http://localhost:4200',
origin: 'http://localhost:4200',
pathname: '/',
search: '',
hash: '',
},
writable: true,
});
@@ -0,0 +1,6 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json",
"lib": {
"entryFile": "src/public-api.ts"
}
}
@@ -0,0 +1,40 @@
import {
BaseThemeBasicModule,
BASIC_THEME_NAV_ITEM_PROVIDERS,
BASIC_THEME_STYLES_PROVIDERS,
ValidationErrorComponent,
} from '@abp/ng.theme.basic';
import { ModuleWithProviders, NgModule } from '@angular/core';
import {
VALIDATION_ERROR_TEMPLATE,
VALIDATION_INVALID_CLASSES,
VALIDATION_TARGET_SELECTOR,
} from '@ngx-validate/core';
@NgModule({
exports: [BaseThemeBasicModule],
imports: [BaseThemeBasicModule],
})
export class ThemeBasicTestingModule {
static withConfig(): ModuleWithProviders<ThemeBasicTestingModule> {
return {
ngModule: ThemeBasicTestingModule,
providers: [
BASIC_THEME_NAV_ITEM_PROVIDERS,
BASIC_THEME_STYLES_PROVIDERS,
{
provide: VALIDATION_ERROR_TEMPLATE,
useValue: ValidationErrorComponent,
},
{
provide: VALIDATION_TARGET_SELECTOR,
useValue: '.form-group',
},
{
provide: VALIDATION_INVALID_CLASSES,
useValue: 'is-invalid',
},
],
};
}
}
@@ -0,0 +1 @@
export * from './lib/theme-basic-testing.module';
@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"target": "es2020"
}
}
@@ -0,0 +1,32 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"target": "ES2022",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": [],
"lib": ["dom", "es2020"],
"useDefineForClassFields": false
},
"exclude": [
"src/test-setup.ts",
"**/*.spec.ts",
"jest.config.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/test-setup.ts"
],
"include": ["**/*.ts"]
}
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false,
"target": "ES2022",
"useDefineForClassFields": false
},
"angularCompilerOptions": {
"compilationMode": "partial"
}
}
@@ -0,0 +1,23 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}
@@ -0,0 +1,22 @@
import { defineConfig } from 'vitest/config';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/packages/theme-basic',
plugins: [nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
test: {
name: 'theme-basic',
watch: false,
globals: true,
environment: 'jsdom',
setupFiles: ['src/test-setup.ts'],
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/packages/theme-basic',
provider: 'v8' as const,
},
},
}));
+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);
}
}
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff. Show more