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

@@ -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,
},
},
}));